From 2dae5691137b629b532cb5e2eec866040e5309e0 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 20 Feb 2026 15:58:33 +0000 Subject: [PATCH 01/28] Added a host of widgets, a default root object, default styling methods --- Python_Engine/Python/pyproject.toml | 1 + .../src/python_toolkit/plot/cmap_sample.py | 61 + .../python_toolkit/tkinter-ui/DefaultRoot.py | 425 ++++++ .../tkinter-ui/DirectoryFileSelector.py | 108 ++ .../python_toolkit/tkinter-ui/LandingPage.py | 132 ++ .../tkinter-ui/ProcessingWindow.py | 123 ++ .../src/python_toolkit/tkinter-ui/style.tcl | 1289 +++++++++++++++++ .../tkinter-ui/widgets/Calender.py | 101 ++ .../tkinter-ui/widgets/CmapSelector.py | 176 +++ .../tkinter-ui/widgets/FigureContainer.py | 108 ++ .../tkinter-ui/widgets/ListBox.py | 116 ++ .../tkinter-ui/widgets/PathSelector.py | 79 + .../tkinter-ui/widgets/RadioSelection.py | 107 ++ .../tkinter-ui/widgets/ValidatedEntryBox.py | 268 ++++ 14 files changed, 3094 insertions(+) create mode 100644 Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/DefaultRoot.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/DirectoryFileSelector.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/LandingPage.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/ProcessingWindow.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/style.tcl create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/Calender.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/FigureContainer.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ListBox.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/PathSelector.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/RadioSelection.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ValidatedEntryBox.py diff --git a/Python_Engine/Python/pyproject.toml b/Python_Engine/Python/pyproject.toml index 6a0be4e..c18c03a 100644 --- a/Python_Engine/Python/pyproject.toml +++ b/Python_Engine/Python/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pytest-cov>=6.0.0", "pytest-order", "virtualenv", + "darkdetect" ] [urls] diff --git a/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py new file mode 100644 index 0000000..768d2fa --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py @@ -0,0 +1,61 @@ +from matplotlib import pyplot as plt +from matplotlib import cm +from matplotlib.colors import Colormap, Normalize +import numpy as np +from typing import Union, Optional + +def cmap_sample_plot( + cmap: Union[str, Colormap], + bounds: Optional[tuple] = None, + figsize: tuple = (9, 1) +) -> plt.Figure: + + """ + Generate a sample plot for a given colormap. + + Args: + cmap: Either a colormap string (e.g., 'viridis') or a custom Colormap object + bounds: Optional tuple of (vmin, vmax) for normalization. If None, uses (0, 1) + figsize: Figure size as (width, height) + + Returns: + Matplotlib Figure object + """ + + # Set bounds + if bounds is None: + bounds = (0, 1) + + vmin, vmax = bounds + + # Create a gradient image with appropriate range + gradient = np.linspace(vmin, vmax, 256).reshape(1, -1) + + # Create the figure and axis + fig, ax = plt.subplots(figsize=figsize) + + # Create normalization if custom colormap or custom bounds + norm = Normalize(vmin=vmin, vmax=vmax) + + # Display the gradient with the specified colormap + im = ax.imshow(gradient, aspect='auto', cmap=cmap, norm=norm) + + # Remove axes for a cleaner look + ax.set_axis_off() + + return fig + +if __name__ == "__main__": + # Example 1: Using a preset colormap string + fig1 = cmap_sample_plot('viridis') + + # Example 2: Using a custom colormap with bounds + fig2 = cmap_sample_plot('plasma', bounds=(0, 100)) + + # Example 3: Creating and using a custom colormap + from matplotlib.colors import ListedColormap + custom_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728'] + custom_cmap = ListedColormap(custom_colors) + fig3 = cmap_sample_plot(custom_cmap, bounds=(0, 3)) + + plt.show() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/DefaultRoot.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/DefaultRoot.py new file mode 100644 index 0000000..8b12905 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/DefaultRoot.py @@ -0,0 +1,425 @@ +import tkinter as tk +from tkinter import ttk +from pathlib import Path +from typing import Optional, Callable, Literal + +try: + import darkdetect +except ImportError: + darkdetect = None +import ctypes + + +class DefaultRoot: + """ + A reusable default root window template for tkinter applications. + Includes a branded banner, content area, and optional action buttons. + """ + + def __init__( + self, + title: str = "Application", + logo_path: Optional[Path] = None, + min_width: int = 500, + min_height: int = 400, + width: Optional[int] = None, + height: Optional[int] = None, + resizable: bool = True, + center_on_screen: bool = True, + show_submit: bool = True, + submit_text: str = "Submit", + submit_command: Optional[Callable] = None, + show_close: bool = True, + close_text: str = "Close", + close_command: Optional[Callable] = None, + on_close_window: Optional[Callable] = None, + theme_path: Optional[Path] = None, + theme_mode: Literal["light", "dark", "auto"] = "auto", + ): + """ + Initialize the default root window. + + Args: + title (str): Window and banner title text. + logo_path (Path, optional): Path to logo image file. + min_width (int): Minimum window width. + min_height (int): Minimum window height. + width (int, optional): Fixed width (overrides dynamic sizing). + height (int, optional): Fixed height (overrides dynamic sizing). + resizable (bool): Whether window can be resized. + center_on_screen (bool): Center window on screen. + show_submit (bool): Show submit button. + submit_text (str): Text for submit button. + submit_command (callable, optional): Command for submit button. + show_close (bool): Show close button. + close_text (str): Text for close button. + close_command (callable, optional): Command for close button. + 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"). + """ + self.root = tk.Tk() + self.root.title(title) + self.root.minsize(min_width, min_height) + self.root.resizable(resizable, resizable) + + # Hide window during setup to prevent flash + self.root.withdraw() + + # Determine theme based on mode and system preference + theme_name = self._determine_theme_name(theme_mode) + self.theme_name = theme_name + + # Load custom dark theme + self._load_theme(theme_path, theme_name) + + self.min_width = min_width + self.min_height = min_height + self.fixed_width = width + self.fixed_height = height + self.center_on_screen = center_on_screen + self.submit_command = submit_command + self.close_command = close_command + self.result = None + + # Handle window close (X button) + self.root.protocol("WM_DELETE_WINDOW", lambda: self._on_close_window(on_close_window)) + + # Main container + main_container = ttk.Frame(self.root) + main_container.pack(fill=tk.BOTH, expand=True) + + # Banner section + self._build_banner(main_container, title, logo_path) + + # Content area (public access for adding widgets) + self.content_frame = ttk.Frame(main_container, padding=20) + self.content_frame.pack(fill=tk.BOTH, expand=True) + + # Bottom button frame (if needed) + if show_submit or show_close: + self._build_buttons(main_container, show_submit, submit_text, show_close, close_text) + + # Apply sizing + self._apply_sizing() + + def _determine_theme_name(self, theme_mode: str) -> str: + """ + Determine the theme name based on the specified mode and system preference. + + Args: + theme_mode (str): "light", "dark", or "auto" + + Returns: + str: Theme name ("bhom_light" or "bhom_dark") + """ + if theme_mode == "auto": + # Try to detect system theme + if darkdetect is not None: + try: + is_dark = darkdetect.is_dark() + return "bhom_dark" if is_dark else "bhom_light" + except Exception: + # Fall back to dark if detection fails + return "bhom_dark" + else: + # Default to dark if darkdetect not available + return "bhom_dark" + elif theme_mode == "light": + return "bhom_light" + else: # "dark" + return "bhom_dark" + + def _set_titlebar_theme(self, theme_name: str) -> None: + """ + Apply titlebar theme using Windows API. + + Args: + theme_name (str): Theme name ("bhom_light" or "bhom_dark") + """ + try: + import platform + if platform.system() == "Windows" and ctypes is not None and self.root.winfo_exists(): + hwnd = self.root.winfo_id() + hwnd = ctypes.windll.user32.GetParent(self.root.winfo_id()) + if hwnd: + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + use_dark = 1 if theme_name == "bhom_dark" else 0 + print(f"Applying titlebar theme: {theme_name} (dark={use_dark})") + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(ctypes.c_int(use_dark)), + ctypes.sizeof(ctypes.c_int) + ) + except Exception: + pass + + def _apply_titlebar_theme(self, theme_name: str) -> None: + """ + Apply Windows titlebar theme using DWM API (deferred version). + + Args: + theme_name (str): Theme name ("bhom_light" or "bhom_dark") + """ + self._set_titlebar_theme(theme_name) + + def _load_theme(self, custom_theme_path: Optional[Path] = None, theme_name: str = "bhom_dark") -> None: + """ + Load a custom theme from a TCL file. + + Args: + custom_theme_path (Path, optional): Path to custom TCL theme file. + If None, uses default style.tcl in same directory. + theme_name (str): Name of the theme to apply from the TCL file. + """ + try: + # Determine which theme file to use + if custom_theme_path and custom_theme_path.exists(): + theme_path = custom_theme_path + else: + # Use default theme in same directory as this file + current_dir = Path(__file__).parent + theme_path = current_dir / "style.tcl" + + if theme_path.exists(): + # Load the TCL theme file + self.root.tk.call('source', str(theme_path)) + + # Apply the specified theme + style = ttk.Style() + style.theme_use(theme_name) + else: + print(f"Warning: Theme file not found at {theme_path}") + except Exception as e: + print(f"Warning: Could not load custom theme: {e}") + + def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path]) -> None: + """Build the branded banner section.""" + banner = ttk.Frame(parent, relief=tk.RIDGE, borderwidth=1) + banner.pack(fill=tk.X, padx=0, pady=0) + + banner_content = ttk.Frame(banner, padding=10) + banner_content.pack(fill=tk.X) + + # Logo (if provided) + if logo_path and logo_path.exists(): + try: + from PIL import Image, ImageTk + img = Image.open(logo_path) + img.thumbnail((40, 40), Image.Resampling.LANCZOS) + self.logo_image = ImageTk.PhotoImage(img) + logo_label = ttk.Label(banner_content, image=self.logo_image) + logo_label.pack(side=tk.LEFT, padx=(0, 10)) + except ImportError: + pass # PIL not available, skip logo + + # Text container + text_container = ttk.Frame(banner_content) + text_container.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Title + title_label = ttk.Label( + text_container, + text=title, + style="Title.TLabel" + ) + title_label.pack(anchor="w") + + # Subtitle + subtitle_label = ttk.Label( + text_container, + text="powered by BHoM", + style="Caption.TLabel" + ) + subtitle_label.pack(anchor="w") + + def _build_buttons( + self, + parent: ttk.Frame, + show_submit: bool, + submit_text: str, + show_close: bool, + close_text: str, + ) -> None: + """Build the bottom button bar.""" + button_bar = ttk.Frame(parent, padding=(20, 10)) + button_bar.pack(side=tk.BOTTOM, fill=tk.X) + + button_container = ttk.Frame(button_bar) + button_container.pack(anchor=tk.E) + + if show_close: + self.close_button = ttk.Button( + button_container, text=close_text, command=self._on_close + ) + self.close_button.pack(side=tk.LEFT, padx=5) + + if show_submit: + self.submit_button = ttk.Button( + button_container, text=submit_text, command=self._on_submit + ) + self.submit_button.pack(side=tk.LEFT, padx=5) + + def _apply_sizing(self) -> None: + """Apply window sizing and positioning.""" + self.root.update_idletasks() + + # Determine final dimensions + if self.fixed_width and self.fixed_height: + final_width = self.fixed_width + final_height = self.fixed_height + elif self.fixed_width: + final_width = self.fixed_width + final_height = max(self.min_height, self.root.winfo_reqheight()) + elif self.fixed_height: + final_width = max(self.min_width, self.root.winfo_reqwidth()) + final_height = self.fixed_height + else: + # Dynamic sizing + final_width = max(self.min_width, self.root.winfo_reqwidth()) + final_height = max(self.min_height, self.root.winfo_reqheight()) + + # Position + if self.center_on_screen: + screen_width = self.root.winfo_screenwidth() + screen_height = self.root.winfo_screenheight() + x = (screen_width - final_width) // 2 + y = (screen_height - final_height) // 2 + self.root.geometry(f"{final_width}x{final_height}+{x}+{y}") + else: + self.root.geometry(f"{final_width}x{final_height}") + + # Defer window display until after styling is applied + self.root.after(0, self._show_window_with_styling) + + def _show_window_with_styling(self) -> None: + """Apply titlebar styling and show the window.""" + # Apply titlebar theme + self._apply_titlebar_theme(self.theme_name) + + # Show window after styling + self.root.deiconify() + + def refresh_sizing(self) -> None: + """Recalculate and apply window sizing (useful after adding widgets).""" + self._apply_sizing() + + def _on_submit(self) -> None: + """Handle submit button click.""" + self.result = "submit" + if self.submit_command: + self.submit_command() + self.root.destroy() + + def _on_close(self) -> None: + """Handle close button click.""" + self.result = "close" + if self.close_command: + self.close_command() + self.root.destroy() + + def _on_close_window(self, callback: Optional[Callable]) -> None: + """Handle window X button click.""" + self.result = "window_closed" + if callback: + callback() + self.root.destroy() + + def run(self) -> Optional[str]: + """Show the window and return the result.""" + self.root.mainloop() + return self.result + + +if __name__ == "__main__": + from widgets.PathSelector import PathSelector + from widgets.RadioSelection import RadioSelection + from widgets.ValidatedEntryBox import ValidatedEntryBox + from widgets.ListBox import ScrollableListBox + + # Store form state + form_data = {} + + def on_submit(): + # Collect form data from widgets + form_data["name"] = name_entry.get() + form_data["age"] = age_entry.get_value() + form_data["file_path"] = file_selector.get() + form_data["priority"] = priority_radio.get() + form_data["selected_items"] = listbox.get_selection() + print("\nForm submitted with data:") + for key, value in form_data.items(): + print(f" {key}: {value}") + + def on_close(): + print("Window closed without submitting") + + window = DefaultRoot( + title="Example Form Application", + min_width=600, + min_height=500, + submit_command=on_submit, + close_command=on_close, + ) + + # Add form widgets to the content area + ttk.Label(window.content_frame, text="Name:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) + name_entry = ValidatedEntryBox( + window.content_frame, + value_type=str, + min_length=2, + max_length=50, + required=True, + ) + name_entry.pack(fill=tk.X, pady=(0, 15)) + + ttk.Label(window.content_frame, text="Age:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) + age_entry = ValidatedEntryBox( + window.content_frame, + value_type=int, + min_value=1, + max_value=120, + required=True, + ) + age_entry.pack(fill=tk.X, pady=(0, 15)) + age_entry.set(25) + + ttk.Label(window.content_frame, text="Select a file:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) + file_selector = PathSelector( + window.content_frame, + button_text="Browse...", + filetypes=[("All Files", "*.*")], + mode="file", + ) + file_selector.pack(fill=tk.X, pady=(0, 15)) + + ttk.Label(window.content_frame, text="Priority:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) + priority_radio = RadioSelection( + window.content_frame, + fields=["Low", "Medium", "High", "Critical"], + default="Medium", + orient="horizontal", + max_per_line=4, + ) + priority_radio.pack(anchor="w", pady=(0, 15)) + + ttk.Label(window.content_frame, text="Select items:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) + items = [f"Item {i}" for i in range(1, 11)] + listbox = ScrollableListBox( + window.content_frame, + items=items, + selectmode=tk.MULTIPLE, + height=6, + show_selection_controls=True, + ) + listbox.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + listbox.set_selections(["Item 2", "Item 5"]) + + # Refresh window sizing after adding all widgets + window.refresh_sizing() + + # Run the window + result = window.run() + print(f"\nWindow result: {result}") + if result == "submit": + print("Final form data:", form_data) diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/DirectoryFileSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/DirectoryFileSelector.py new file mode 100644 index 0000000..4b8b97a --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/DirectoryFileSelector.py @@ -0,0 +1,108 @@ +import tkinter as tk +from tkinter import ttk +from pathlib import Path +from typing import Iterable, List + +from widgets.ListBox import ScrollableListBox +from DefaultRoot import DefaultRoot + +class DirectoryFileSelector: + def __init__(self, directory: Path, file_types: Iterable[str], selection_label: str = "file(s)") -> None: + self.directory = Path(directory) + self.file_types = self._normalise_file_types(file_types) + self.selection_label = selection_label + self._cancelled = False + self._selected_files = [] + + # Create DefaultRoot window + self.window = DefaultRoot( + title=f"Select {selection_label}", + min_width=600, + min_height=400, + show_submit=True, + submit_text="OK", + submit_command=self._on_submit, + show_close=True, + close_text="Cancel", + close_command=self._on_cancel, + on_close_window=self._on_cancel, + ) + + self.files = self._discover_files() + self.display_items = [self._display_value(file) for file in self.files] + self.file_lookup = dict(zip(self.display_items, self.files)) + + # Add content to the window's content frame + ttk.Label( + self.window.content_frame, + text=f"Select the {self.selection_label} to analyse.", + justify=tk.LEFT, + ).pack(anchor="w", pady=(0, 10)) + + self.listbox = ScrollableListBox( + self.window.content_frame, + items=self.display_items, + selectmode=tk.MULTIPLE, + height=12, + show_selection_controls=True, + ) + self.listbox.pack(fill=tk.BOTH, expand=True) + self.listbox.select_all() + + # Refresh sizing after adding widgets + self.window.refresh_sizing() + + def _normalise_file_types(self, file_types: Iterable[str]) -> List[str]: + normalised = [] + for file_type in file_types: + suffix = str(file_type).strip() + if not suffix: + continue + if not suffix.startswith("."): + suffix = f".{suffix}" + normalised.append(suffix.lower()) + return normalised + + def _discover_files(self) -> List[Path]: + if not self.directory.exists(): + return [] + + matches = [] + for path in self.directory.rglob("*"): + if path.is_file() and path.suffix.lower() in self.file_types: + matches.append(path) + return sorted(matches) + + def _display_value(self, path: Path) -> str: + try: + return str(path.relative_to(self.directory)) + except ValueError: + return str(path) + + def run(self) -> List[Path]: + result = self.window.run() + if self._cancelled or result != "submit": + return [] + return self._selected_files + + def _on_submit(self): + """Handle OK button - capture selection before window closes.""" + selected = self.listbox.get_selection() + self._selected_files = [self.file_lookup[item] for item in selected if item in self.file_lookup] + + def _on_cancel(self): + """Handle Cancel button or window close.""" + self._cancelled = True + + +if __name__ == "__main__": + # Example usage + selector = DirectoryFileSelector( + directory=Path.cwd(), + file_types=[".py", ".txt"], + selection_label="scripts and text files", + ) + selected_files = selector.run() + print("Selected files:") + for file in selected_files: + print(file) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/LandingPage.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/LandingPage.py new file mode 100644 index 0000000..21fd20c --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/LandingPage.py @@ -0,0 +1,132 @@ +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable +from DefaultRoot import DefaultRoot + + +class LandingPage: + """ + A reusable landing page GUI with configurable title, message, and buttons. + Uses DefaultRoot as the base template. + """ + + def __init__( + self, + title: str = "Landing Page", + message: Optional[str] = None, + min_width: int = 400, + min_height: int = 200, + show_continue: bool = True, + continue_text: str = "Continue", + continue_command: Optional[Callable] = None, + show_close: bool = True, + close_text: str = "Close", + close_command: Optional[Callable] = None, + ): + """ + Initializes the landing page GUI. + + Args: + title (str): Window and header title text. + message (str, optional): Commentary/message text to display. + min_width (int): Minimum window width in pixels. + min_height (int): Minimum window height in pixels. + show_continue (bool): Whether to show the continue button. + continue_text (str): Text for the continue button. + continue_command (callable, optional): Command to run on continue. + show_close (bool): Whether to show the close button. + close_text (str): Text for the close button. + close_command (callable, optional): Command to run on close. + """ + # Store callbacks + self.continue_command = continue_command + self.close_command = close_command + + # Initialize DefaultRoot with continue mapped to submit + self.window = DefaultRoot( + title=title, + min_width=min_width, + min_height=min_height, + show_submit=show_continue, + submit_text=continue_text, + submit_command=self._on_continue, + show_close=show_close, + close_text=close_text, + close_command=self._on_close, + ) + + # Optional message/commentary + if message: + message_label = ttk.Label( + self.window.content_frame, + text=message, + justify=tk.LEFT, + wraplength=min_width - 80, + ) + message_label.pack(anchor="w", pady=(0, 20)) + + # Custom buttons container + self.custom_buttons_frame = ttk.Frame(self.window.content_frame) + self.custom_buttons_frame.pack(fill=tk.X, pady=(0, 20)) + + def add_custom_button(self, text: str, command: Callable, **kwargs) -> ttk.Button: + """ + Add a custom button to the landing page. + + Args: + text (str): Button text. + command (callable): Function to call when button is clicked. + **kwargs: Additional ttk.Button options. + + Returns: + ttk.Button: The created button widget. + """ + button = ttk.Button(self.custom_buttons_frame, text=text, command=command, **kwargs) + button.pack(pady=5, fill=tk.X) + # Recalculate window size after adding button + self.window.refresh_sizing() + return button + + def _on_continue(self): + """Handle continue button click.""" + if self.continue_command: + self.continue_command() + + def _on_close(self): + """Handle close button click.""" + if self.close_command: + self.close_command() + + def run(self) -> Optional[str]: + """Show the landing page and return the result.""" + result = self.window.run() + # Map DefaultRoot results to LandingPage convention + if result == "submit": + return "continue" + return result + + +if __name__ == "__main__": + # Basic example + def on_continue(): + print("Continue clicked!") + + def on_close(): + print("Close clicked!") + + landing = LandingPage( + title="Example Application", + message="Welcome to the landing page example.\n\nThis demonstrates a configurable landing page with custom buttons.\n\nPlease select an option below to proceed.", + continue_text="Proceed", + continue_command=on_continue, + close_command=on_close, + ) + + # Add custom buttons + landing.add_custom_button("Option A", lambda: print("Option A selected")) + landing.add_custom_button( + "Option B", lambda: print("Option B selected") + ) + + result = landing.run() + print(f"Result: {result}") diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/ProcessingWindow.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/ProcessingWindow.py new file mode 100644 index 0000000..cb1f71e --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/ProcessingWindow.py @@ -0,0 +1,123 @@ +import tkinter as tk +from tkinter import ttk +import os +import time + +class ProcessingWindow: + """A simple processing window with animated indicator.""" + + def __init__(self, title="Processing", message="Processing..."): + """ + Args: + title (str): Window title. + message (str): Message to display. + """ + self.root = tk.Tk() + + self.root.title(title) + self.root.attributes("-topmost", True) + self.root.resizable(False, False) + + # Container + container = ttk.Frame(self.root, padding=20) + container.pack(fill="both", expand=True) + + # Constant title label + self.title_label = ttk.Label( + container, + text=title, + style="Title.TLabel", + justify="center", + wraplength=400 + ) + self.title_label.pack(pady=(0, 8)) + + # Updatable message label + self.message_label = ttk.Label( + container, + text=message, + justify="center", + wraplength=400 + ) + self.message_label.pack(pady=(0, 20)) + + # Animation frame + animation_frame = ttk.Frame(container) + animation_frame.pack(expand=True) + + self.animation_label = ttk.Label( + animation_frame, + text="●", + style="Title.TLabel", + foreground="#0078d4" + ) + self.animation_label.pack() + + # Animation state + self.animation_frames = ["●", "●", "●"] + self.current_frame = 0 + self.is_running = False + + # Update to calculate the required size + self.root.update_idletasks() + + # Get the required width and height + required_width = self.root.winfo_reqwidth() + required_height = self.root.winfo_reqheight() + + # Set minimum size + min_width = 300 + min_height = 150 + window_width = max(required_width, min_width) + window_height = max(required_height, min_height) + + # Center on screen + screen_width = self.root.winfo_screenwidth() + screen_height = self.root.winfo_screenheight() + x = (screen_width - window_width) // 2 + y = (screen_height - window_height) // 2 + self.root.geometry(f"{window_width}x{window_height}+{x}+{y}") + + def start(self): + """Start the processing window and animation.""" + self.is_running = True + self._animate() + + def keep_alive(self): + """Call this repeatedly to process animation updates. Returns False when done.""" + if self.is_running and self.root.winfo_exists(): + self.root.update() + return True + return False + + def stop(self): + """Stop the animation and close the window.""" + self.is_running = False + self.root.destroy() + + def _animate(self): + """Update animation frames.""" + if self.is_running: + # Create rotating dot animation + dots = ["◐", "◓", "◑", "◒"] + self.animation_label.config(text=dots[self.current_frame % len(dots)]) + self.current_frame += 1 + self.root.after(200, self._animate) + + def update_message(self, message: str): + """Update the message text.""" + self.message_label.config(text=message) + self.root.update() + + +if __name__ == "__main__": + # Test the processing window + + processing = ProcessingWindow(title="Test Processing", message="Running Test Calculation...") + processing.start() + + # Simulate some work + for i in range(50): + time.sleep(0.1) + processing.update_message(f"Running Test Calculation... {i+1}/50") + processing.stop() diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/style.tcl b/Python_Engine/Python/src/python_toolkit/tkinter-ui/style.tcl new file mode 100644 index 0000000..9160f00 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/style.tcl @@ -0,0 +1,1289 @@ +# Dark and Light Mode Theme for Python Toolkit +# Professional themes inspired by modern IDEs +# Font: Segoe UI, Roboto, Helvetica, Arial, sans-serif + +# ============================================================================ +# DARK MODE THEME +# ============================================================================ +namespace eval ttk::theme::bhom_dark { + variable colors + array set colors { + -bg "#1e1e1e" + -fg "#ffffff" + -dark "#2d2d2d" + -darker "#252526" + -selectbg "#1b6ec2" + -selectfg "#ffffff" + -primary "#1b6ec2" + -primary-hover "#1861ac" + -primary-light "#258cfb" + -secondary "#ff4081" + -secondary-hover "#ff1f69" + -tertiary "#c4d600" + -info "#006bb7" + -success "#26b050" + -warning "#eb671c" + -error "#e50000" + -border "#3d3d3d" + -border-light "#555555" + -disabled-bg "#2d2d2d" + -disabled-fg "#666666" + -inputbg "#2d2d2d" + -inputfg "#ffffff" + -hover-bg "#2a2d2e" + -active-bg "#383838" + -text-secondary "#999999" + } + + ttk::style theme create bhom_dark -parent clam -settings { + # General font settings - Segoe UI for Windows, fallback to Roboto/Helvetica + ttk::style configure . \ + -font {{Segoe UI} 10} \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border) \ + -darkcolor $colors(-border) \ + -lightcolor $colors(-border) \ + -troughcolor $colors(-darker) \ + -focuscolor $colors(-primary) \ + -selectbackground $colors(-selectbg) \ + -selectforeground $colors(-selectfg) \ + -selectborderwidth 0 \ + -insertwidth 1 \ + -insertcolor $colors(-primary) \ + -relief flat + + # Frame + ttk::style configure TFrame \ + -background $colors(-bg) \ + -borderwidth 0 \ + -relief flat + + ttk::style configure Card.TFrame \ + -background $colors(-dark) \ + -borderwidth 2 \ + -relief groove \ + -bordercolor $colors(-border-light) + + # Label - Extended dynamic typography system + ttk::style configure TLabel \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {8 6} + + ttk::style configure Display.TLabel \ + -font {{Segoe UI} 28 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure LargeTitle.TLabel \ + -font {{Segoe UI} 24 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Title.TLabel \ + -font {{Segoe UI} 20 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Headline.TLabel \ + -font {{Segoe UI} 16 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Subtitle.TLabel \ + -font {{Segoe UI} 14 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Heading.TLabel \ + -font {{Segoe UI} 12 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Body.TLabel \ + -font {{Segoe UI} 10} \ + -foreground $colors(-fg) \ + -padding {6 4} + + ttk::style configure Caption.TLabel \ + -font {{Segoe UI} 9} \ + -foreground $colors(-text-secondary) \ + -padding {6 4} + + ttk::style configure Small.TLabel \ + -font {{Segoe UI} 8} \ + -foreground $colors(-text-secondary) \ + -padding {4 2} + + ttk::style configure Success.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-success) + + ttk::style configure Warning.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-warning) + + ttk::style configure Error.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-error) + + ttk::style configure Info.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-info) + + # Button - soft rounded design + ttk::style configure TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-active-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -focuscolor "" \ + -padding {16 8} \ + -relief raised + + # Large Button variant + ttk::style configure Large.TButton \ + -font {{Segoe UI} 13 bold} \ + -padding {20 12} \ + -borderwidth 2 + + # Small Button variant + ttk::style configure Small.TButton \ + -font {{Segoe UI} 9 bold} \ + -padding {12 6} \ + -borderwidth 2 + + ttk::style map TButton \ + -background [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + active $colors(-fg) \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + active $colors(-primary-hover) \ + disabled $colors(-border)] \ + -lightcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg)] \ + -darkcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg)] \ + -relief [list \ + pressed sunken] + + # Primary Button - accent color with soft rounded edges + ttk::style configure Primary.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-primary) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-primary-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Primary.TButton \ + -background [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-bg)] \ + -lightcolor [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -darkcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover)] \ + -relief [list \ + pressed sunken] + + # Secondary Button - soft rounded edges + ttk::style configure Secondary.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-secondary) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-secondary) \ + -lightcolor $colors(-secondary) \ + -darkcolor $colors(-secondary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Secondary.TButton \ + -background [list \ + active $colors(-secondary-hover) \ + pressed $colors(-secondary-hover) \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Accent Button - lime green from app.css + ttk::style configure Accent.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-tertiary) \ + -foreground "#000000" \ + -bordercolor $colors(-tertiary) \ + -lightcolor $colors(-tertiary) \ + -darkcolor "#9fad00" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Accent.TButton \ + -background [list \ + active "#9fad00" \ + pressed "#9fad00" \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + + # Success Button - green from app.css + ttk::style configure Success.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-success) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-success) \ + -lightcolor $colors(-success) \ + -darkcolor "#1e9038" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Success.TButton \ + -background [list \ + active "#1e9038" \ + pressed "#1e9038" \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Link Button - blue link from app.css + ttk::style configure Link.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-bg) \ + -foreground $colors(-info) \ + -borderwidth 0 \ + -padding {14 8} \ + -relief flat + + ttk::style map Link.TButton \ + -foreground [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-fg)] + + # Outline Button - soft rounded with bold font + ttk::style configure Outline.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -bordercolor $colors(-primary) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Outline.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -bordercolor [list \ + active $colors(-primary-light) \ + disabled $colors(-border)] \ + -relief [list \ + pressed sunken] + + # Text Button - bold font with padding + ttk::style configure Text.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -borderwidth 0 \ + -padding {14 8} + + ttk::style map Text.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] + + # Entry - soft rounded design with subtle depth + ttk::style configure TEntry \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border) \ + -darkcolor $colors(-hover-bg) \ + -insertcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TEntry \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary) \ + invalid $colors(-error)] + + # Combobox - soft rounded design + ttk::style configure TCombobox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TCombobox \ + -fieldbackground [list \ + readonly $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] \ + -arrowcolor [list \ + disabled $colors(-disabled-fg)] + + # Checkbutton - bold font + ttk::style configure TCheckbutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TCheckbutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Radiobutton - sleek hover effect with bold font + ttk::style configure TRadiobutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TRadiobutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + selected $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Scrollbar - minimal sleek design without arrows + ttk::style configure TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -relief flat \ + -width 10 + + ttk::style map TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Vertical.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Vertical.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Horizontal.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Horizontal.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + # Scale - minimal sleek design + ttk::style configure TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-bg) \ + -bordercolor $colors(-bg) \ + -slidercolor $colors(-primary) \ + -borderwidth 0 \ + -sliderrelief flat + + ttk::style map TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] + + # Progressbar - soft rounded design + ttk::style configure TProgressbar \ + -background $colors(-primary) \ + -troughcolor $colors(-darker) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -thickness 24 \ + -relief raised + + # Notebook - soft rounded tabs + ttk::style configure TNotebook \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -tabmargins {2 5 2 0} \ + -borderwidth 2 + + ttk::style configure TNotebook.Tab \ + -background $colors(-dark) \ + -foreground $colors(-text-secondary) \ + -bordercolor $colors(-border-light) \ + -font {{Segoe UI} 11 bold} \ + -padding {18 10} \ + -borderwidth 2 + + ttk::style map TNotebook.Tab \ + -background [list \ + selected $colors(-bg) \ + active $colors(-hover-bg)] \ + -foreground [list \ + selected $colors(-primary) \ + active $colors(-fg)] \ + -expand [list \ + selected {2 2 2 0}] + + # Treeview - soft design with bold headings + ttk::style configure Treeview \ + -background $colors(-inputbg) \ + -foreground $colors(-fg) \ + -fieldbackground $colors(-inputbg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border-light) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -rowheight 32 \ + -padding {6 4} + + ttk::style map Treeview \ + -background [list selected $colors(-primary)] \ + -foreground [list selected $colors(-selectfg)] + + ttk::style configure Treeview.Heading \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -relief raised \ + -padding {10 8} \ + -font {{Segoe UI} 11 bold} + + ttk::style map Treeview.Heading \ + -background [list active $colors(-hover-bg)] \ + -relief [list pressed sunken] + + # Separator + ttk::style configure TSeparator \ + -background $colors(-border) + + ttk::style configure Horizontal.TSeparator \ + -background $colors(-border) + + ttk::style configure Vertical.TSeparator \ + -background $colors(-border) + + # Labelframe - soft rounded design with depth + ttk::style configure TLabelframe \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -relief groove \ + -padding {16 12} + + ttk::style configure TLabelframe.Label \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 12 bold} \ + -padding {10 -8} + + # Panedwindow + ttk::style configure TPanedwindow \ + -background $colors(-bg) + + ttk::style configure Sash \ + -sashthickness 8 \ + -gripcount 0 \ + -background $colors(-border) + + # Sizegrip + ttk::style configure TSizegrip \ + -background $colors(-bg) + + # Spinbox + # Spinbox - soft rounded design\n ttk::style configure TSpinbox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TSpinbox \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] + + # Menubutton - soft rounded design + ttk::style configure TMenubutton \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {14 8} \ + -borderwidth 2 \ + -relief raised + + ttk::style map TMenubutton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + } +} + +# ============================================================================ +# LIGHT MODE THEME +# ============================================================================ +namespace eval ttk::theme::bhom_light { + variable colors + array set colors { + -bg "#ffffff" + -fg "#1a1a1a" + -dark "#f3f3f3" + -darker "#ececec" + -selectbg "#1b6ec2" + -selectfg "#ffffff" + -primary "#1b6ec2" + -primary-hover "#1861ac" + -primary-light "#258cfb" + -secondary "#ff4081" + -secondary-hover "#ff1f69" + -tertiary "#a4a900" + -info "#006bb7" + -success "#26b050" + -warning "#eb671c" + -error "#e50000" + -border "#d0d0d0" + -border-light "#e5e5e5" + -disabled-bg "#f3f3f3" + -disabled-fg "#999999" + -inputbg "#ffffff" + -inputfg "#1a1a1a" + -hover-bg "#f5f5f5" + -active-bg "#ececec" + -text-secondary "#666666" + } + + ttk::style theme create bhom_light -parent clam -settings { + # General font settings - Segoe UI for Windows, fallback to Roboto/Helvetica + ttk::style configure . \ + -font {{Segoe UI} 10} \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border) \ + -darkcolor $colors(-border) \ + -lightcolor $colors(-border) \ + -troughcolor $colors(-darker) \ + -focuscolor $colors(-primary) \ + -selectbackground $colors(-selectbg) \ + -selectforeground $colors(-selectfg) \ + -selectborderwidth 0 \ + -insertwidth 1 \ + -insertcolor $colors(-primary) \ + -relief flat + + # Frame + ttk::style configure TFrame \ + -background $colors(-bg) \ + -borderwidth 0 \ + -relief flat + + ttk::style configure Card.TFrame \ + -background $colors(-dark) \ + -borderwidth 2 \ + -relief groove \ + -bordercolor $colors(-border-light) + + # Label - Extended dynamic typography system + ttk::style configure TLabel \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {8 6} + + ttk::style configure Display.TLabel \ + -font {{Segoe UI} 28 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure LargeTitle.TLabel \ + -font {{Segoe UI} 24 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Title.TLabel \ + -font {{Segoe UI} 20 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Headline.TLabel \ + -font {{Segoe UI} 16 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Subtitle.TLabel \ + -font {{Segoe UI} 14 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Heading.TLabel \ + -font {{Segoe UI} 12 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Body.TLabel \ + -font {{Segoe UI} 10} \ + -foreground $colors(-fg) \ + -padding {6 4} + + ttk::style configure Caption.TLabel \ + -font {{Segoe UI} 9} \ + -foreground $colors(-text-secondary) \ + -padding {6 4} + + ttk::style configure Small.TLabel \ + -font {{Segoe UI} 8} \ + -foreground $colors(-text-secondary) \ + -padding {4 2} + + ttk::style configure Success.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-success) + + ttk::style configure Warning.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-warning) + + ttk::style configure Error.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-error) + + ttk::style configure Info.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-info) + + # Button - soft rounded design + ttk::style configure TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-active-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -focuscolor "" \ + -padding {16 8} \ + -relief raised + + # Large Button variant + ttk::style configure Large.TButton \ + -font {{Segoe UI} 13 bold} \ + -padding {20 12} \ + -borderwidth 2 + + # Small Button variant + ttk::style configure Small.TButton \ + -font {{Segoe UI} 9 bold} \ + -padding {12 6} \ + -borderwidth 2 + + ttk::style map TButton \ + -background [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + active $colors(-fg) \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + active $colors(-primary-hover) \ + disabled $colors(-border)] \ + -lightcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg)] \ + -darkcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg)] \ + -relief [list \ + pressed sunken] + + # Primary Button - accent color with soft rounded edges + ttk::style configure Primary.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-primary) \ + -foreground "#ffffff" \ + -bordercolor $colors(-primary-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Primary.TButton \ + -background [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-bg)] \ + -lightcolor [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -darkcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover)] \ + -relief [list \ + pressed sunken] + + # Secondary Button - soft rounded edges + ttk::style configure Secondary.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-secondary) \ + -foreground "#ffffff" \ + -bordercolor $colors(-secondary) \ + -lightcolor $colors(-secondary) \ + -darkcolor $colors(-secondary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Secondary.TButton \ + -background [list \ + active $colors(-secondary-hover) \ + pressed $colors(-secondary-hover) \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Accent Button - muted yellow for light mode + ttk::style configure Accent.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-tertiary) \ + -foreground "#ffffff" \ + -bordercolor $colors(-tertiary) \ + -lightcolor $colors(-tertiary) \ + -darkcolor "#8a8a00" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Accent.TButton \ + -background [list \ + active "#8a8a00" \ + pressed "#8a8a00" \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + + # Success Button - green from app.css + ttk::style configure Success.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-success) \ + -foreground "#ffffff" \ + -bordercolor $colors(-success) \ + -lightcolor $colors(-success) \ + -darkcolor "#1e9038" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Success.TButton \ + -background [list \ + active "#1e9038" \ + pressed "#1e9038" \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Link Button - blue link from app.css + ttk::style configure Link.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-bg) \ + -foreground $colors(-info) \ + -borderwidth 0 \ + -padding {14 8} \ + -relief flat + + ttk::style map Link.TButton \ + -foreground [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-fg)] + + # Outline Button - soft rounded with bold font + ttk::style configure Outline.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -bordercolor $colors(-primary) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Outline.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -bordercolor [list \ + active $colors(-primary-light) \ + disabled $colors(-border)] \ + -relief [list \ + pressed sunken] + + # Text Button - bold font with padding + ttk::style configure Text.TButton \ + -font {{Segoe UI} 11 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -borderwidth 0 \ + -padding {14 8} + + ttk::style map Text.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] + + # Entry - soft rounded design with subtle depth + ttk::style configure TEntry \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border) \ + -darkcolor $colors(-hover-bg) \ + -insertcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TEntry \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary) \ + invalid $colors(-error)] + + # Combobox - soft rounded design + ttk::style configure TCombobox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TCombobox \ + -fieldbackground [list \ + readonly $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] \ + -arrowcolor [list \ + disabled $colors(-disabled-fg)] + + # Checkbutton - bold font + ttk::style configure TCheckbutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TCheckbutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Radiobutton - sleek hover effect with bold font + ttk::style configure TRadiobutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TRadiobutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + selected $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Scrollbar - minimal sleek design without arrows + ttk::style configure TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -relief flat \ + -width 10 + + ttk::style map TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Vertical.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Vertical.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Horizontal.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Horizontal.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + # Scale - minimal sleek design + ttk::style configure TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-bg) \ + -bordercolor $colors(-bg) \ + -slidercolor $colors(-primary) \ + -borderwidth 0 \ + -sliderrelief flat + + ttk::style map TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] + + # Progressbar - soft rounded design + ttk::style configure TProgressbar \ + -background $colors(-primary) \ + -troughcolor $colors(-darker) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -thickness 24 \ + -relief raised + + # Notebook - soft rounded tabs + ttk::style configure TNotebook \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -tabmargins {2 5 2 0} \ + -borderwidth 2 + + ttk::style configure TNotebook.Tab \ + -background $colors(-dark) \ + -foreground $colors(-text-secondary) \ + -bordercolor $colors(-border-light) \ + -font {{Segoe UI} 11 bold} \ + -padding {18 10} \ + -borderwidth 2 + + ttk::style map TNotebook.Tab \ + -background [list \ + selected $colors(-bg) \ + active $colors(-hover-bg)] \ + -foreground [list \ + selected $colors(-primary) \ + active $colors(-fg)] \ + -expand [list \ + selected {2 2 2 0}] + + # Treeview - soft design with bold headings + ttk::style configure Treeview \ + -background $colors(-inputbg) \ + -foreground $colors(-fg) \ + -fieldbackground $colors(-inputbg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border-light) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -rowheight 32 \ + -padding {6 4} + + ttk::style map Treeview \ + -background [list selected $colors(-primary)] \ + -foreground [list selected $colors(-selectfg)] + + ttk::style configure Treeview.Heading \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -relief raised \ + -padding {10 8} \ + -font {{Segoe UI} 11 bold} + + ttk::style map Treeview.Heading \ + -background [list active $colors(-hover-bg)] \ + -relief [list pressed sunken] + + # Separator + ttk::style configure TSeparator \ + -background $colors(-border) + + ttk::style configure Horizontal.TSeparator \ + -background $colors(-border) + + ttk::style configure Vertical.TSeparator \ + -background $colors(-border) + + # Labelframe - soft rounded design with depth + ttk::style configure TLabelframe \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -relief groove \ + -padding {16 12} + + ttk::style configure TLabelframe.Label \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 12 bold} \ + -padding {10 -8} + + # Panedwindow + ttk::style configure TPanedwindow \ + -background $colors(-bg) + + ttk::style configure Sash \ + -sashthickness 8 \ + -gripcount 0 \ + -background $colors(-border) + + # Sizegrip + ttk::style configure TSizegrip \ + -background $colors(-bg) + + # Spinbox - soft rounded design + ttk::style configure TSpinbox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TSpinbox \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] + + # Menubutton - soft rounded design + ttk::style configure TMenubutton \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {14 8} \ + -borderwidth 2 \ + -relief raised + + ttk::style map TMenubutton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + } +} + +# Set default options for tk widgets (non-ttk) +option add *Background "#1e1e1e" +option add *Foreground "#ffffff" +option add *Font {{Segoe UI} 10 bold} +option add *selectBackground "#1b6ec2" +option add *selectForeground "#ffffff" +option add *activeBackground "#2a2d2e" +option add *activeForeground "#ffffff" +option add *highlightColor "#1b6ec2" +option add *highlightBackground "#1e1e1e" +option add *disabledForeground "#666666" +option add *insertBackground "#ffffff" +option add *troughColor "#2d2d2d" +option add *borderWidth 1 +option add *relief flat + +# Listbox specific - matches design theme +option add *Listbox.background "#2d2d2d" +option add *Listbox.foreground "#ffffff" +option add *Listbox.selectBackground "#1b6ec2" +option add *Listbox.selectForeground "#ffffff" +option add *Listbox.font {{Segoe UI} 10} +option add *Listbox.borderWidth 1 +option add *Listbox.relief flat +option add *Listbox.highlightThickness 1 +option add *Listbox.highlightColor "#3d3d3d" +option add *Listbox.highlightBackground "#3d3d3d" + +# Text widget specific +option add *Text.background "#2d2d2d" +option add *Text.foreground "#ffffff" +option add *Text.insertBackground "#ffffff" +option add *Text.selectBackground "#1b6ec2" +option add *Text.selectForeground "#ffffff" +option add *Text.font {{Segoe UI} 10} +option add *Text.borderWidth 1 +option add *Text.relief flat +option add *Text.highlightThickness 1 +option add *Text.highlightColor "#1b6ec2" + +# Canvas specific +option add *Canvas.background "#1e1e1e" +option add *Canvas.highlightThickness 0 + +# Menu specific +option add *Menu.background "#2d2d2d" +option add *Menu.foreground "#ffffff" +option add *Menu.activeBackground "#1b6ec2" +option add *Menu.activeForeground "#ffffff" +option add *Menu.activeBorderWidth 0 +option add *Menu.borderWidth 1 +option add *Menu.relief flat +option add *Menu.font {{Segoe UI} 10} + +# Toplevel/window specific +option add *Toplevel.background "#1e1e1e" + +# Message widget +option add *Message.background "#1e1e1e" +option add *Message.foreground "#ffffff" +option add *Message.font {{Segoe UI} 10} diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/Calender.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/Calender.py new file mode 100644 index 0000000..4f9f418 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/Calender.py @@ -0,0 +1,101 @@ +import tkinter as tk +import calendar +import datetime + +class CalendarWidget(tk.Frame): + def __init__(self, parent, def_year: int, def_month: int, def_day: int, show_year_selector: bool = False, year_min: int = 1900, year_max: int = 2100, **kwargs): + super().__init__(parent, **kwargs) + self.year = def_year + self.month = def_month + self.show_year_selector = show_year_selector + self.year_min = year_min + self.year_max = year_max + + self.cal_frame = tk.Frame(self) + self.cal_frame.pack(side="top", fill="x") + + self.month_frame = tk.Frame(self) + self.month_frame.pack(side="top", fill="x") + + self.date_frame = tk.Frame(self) + self.date_frame.pack(side="top", fill="x") + + if self.show_year_selector: + self.year_selector() + self.month_selector() + self.set_day(def_day) + self.redraw() + + def year_selector(self): + year_var = tk.IntVar() + year_var.set(self.year) + + years = list(range(self.year_min, self.year_max + 1)) + drop = tk.OptionMenu(self.month_frame, year_var, *years) + year_var.trace_add("write", lambda *args: self.set_year(year_var)) + drop.pack(side="left", padx=4, pady=4) + + def month_selector(self): + self.months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] + + clicked = tk.StringVar() + clicked.set(self.months[self.month-1]) + + drop = tk.OptionMenu(self.month_frame, clicked, *self.months) + clicked.trace_add("write", lambda *args: self.set_month(clicked)) + drop.pack(side="left", padx=4, pady=4) + + def set_year(self, var): + year = var.get() + self.year = year + self.redraw() + + def set_month(self, var): + month = var.get() + self.month = self.months.index(month) + 1 + self.redraw() + + def redraw(self): + for child in self.cal_frame.winfo_children(): + child.destroy() + + for col, day in enumerate(("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su")): + label = tk.Label(self.cal_frame, text=day) + label.grid(row=0, column=col, sticky="nsew") + + cal = calendar.monthcalendar(self.year, self.month) + + for row, week in enumerate(cal): + for col, day in enumerate(week): + text = "" if day == 0 else day + state = "normal" if day > 0 else "disabled" + cell = tk.Button(self.cal_frame, text=text, state=state, command=lambda day=day: self.set_day(day)) + cell.grid(row=row+1, column=col, sticky="nsew") + + def set_day(self, num): + self.day = num + + for child in self.date_frame.winfo_children(): + child.destroy() + + date = self.months[self.month-1] + " " + str(self.day) + label = tk.Label(self.date_frame, text=f"Selected Date: {date}") + label.pack(padx=4, pady=4) + + def get_date(self): + return datetime.date(self.year, self.month, self.day) + +if __name__ == "__main__": + root = tk.Tk() + root.title("Calendar Widget Test") + + # Example without year selector + cal_widget1 = CalendarWidget(root, def_year=2024, def_month=6, def_day=15) + cal_widget1.pack(padx=20, pady=20) + + # Example with year selector + tk.Label(root, text="With Year Selector:").pack(pady=(20, 0)) + cal_widget2 = CalendarWidget(root, def_year=2026, def_month=2, def_day=20, show_year_selector=True, year_min=2000, year_max=2030) + cal_widget2.pack(padx=20, pady=20) + + root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py new file mode 100644 index 0000000..2b23c63 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py @@ -0,0 +1,176 @@ +from typing import Dict, List, Optional +from matplotlib import cm +from tkinter import ttk +import tkinter as tk + +from python_toolkit.plot.cmap_sample import cmap_sample_plot +from FigureContainer import FigureContainer + +class CmapSelector(ttk.Frame): + """ + A widget for selecting and previewing a matplotlib colormap. + """ + + CATEGORICAL_CMAPS = [ + "Accent", + "Dark2", + "Paired", + "Pastel1", + "Pastel2", + "Set1", + "Set2", + "Set3", + "tab10", + "tab20", + "tab20b", + "tab20c", + ] + + CONTINUOUS_CMAPS = [ + "viridis", + "plasma", + "inferno", + "magma", + "cividis", + "turbo", + "Blues", + "Greens", + "Greys", + "Oranges", + "Purples", + "Reds", + "YlGn", + "YlGnBu", + "YlOrBr", + "YlOrRd", + "coolwarm", + "seismic", + "Spectral", + "RdYlBu", + "RdYlGn", + "twilight", + "twilight_shifted", + "hsv", + ] + + def __init__( + self, + parent: tk.Widget, + colormaps: Optional[List[str]] = None, + cmap_set: str = "all", + **kwargs + ) -> None: + """ + 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". + **kwargs: Additional Frame options + """ + super().__init__(parent, **kwargs) + + self.colormap_var = tk.StringVar(value="viridis") + self._all_colormaps = self._get_all_colormaps() + self._preset_map: Dict[str, List[str]] = { + "all": self._all_colormaps, + "continuous": self._filter_available(self.CONTINUOUS_CMAPS), + "categorical": self._filter_available(self.CATEGORICAL_CMAPS), + } + self._uses_explicit_colormaps = colormaps is not None + + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + content = ttk.Frame(self, width=440, height=130) + content.grid(row=0, column=0, padx=4, pady=4) + content.grid_propagate(False) + + header = ttk.Frame(content) + header.pack(fill=tk.X, padx=8, pady=(8, 4)) + + self.cmap_set_var = tk.StringVar(value=cmap_set.lower()) + + ttk.Label(header, text="Select cmap").pack(side=tk.LEFT, padx=(0, 8)) + self.cmap_combobox = ttk.Combobox( + header, + textvariable=self.colormap_var, + state="readonly", + ) + self.cmap_combobox.pack(side=tk.LEFT, fill=tk.X, expand=True) + self.cmap_combobox.bind("<>", self._on_cmap_selected) + + self.figure_widget = FigureContainer(content, width=420, height=90) + self.figure_widget.pack(anchor="w", padx=8, pady=(0, 8)) + self.figure_widget.pack_propagate(False) + + if self._uses_explicit_colormaps: + current_colormaps = sorted(set(colormaps or [])) + else: + current_colormaps = self._preset_colormaps(self.cmap_set_var.get()) + + self._populate_cmap_list(current_colormaps) + self._select_default_cmap(current_colormaps) + + def _get_all_colormaps(self) -> List[str]: + """Return all registered colormap names, including reversed variants.""" + return sorted(cm.datad.keys()) + + def _filter_available(self, names: List[str]) -> List[str]: + """Filter a candidate list to available names and include reversed variants when present.""" + available = set(self._all_colormaps) + selected: List[str] = [] + for name in names: + if name in available: + selected.append(name) + reversed_name = f"{name}_r" + if reversed_name in available: + selected.append(reversed_name) + return selected + + def _preset_colormaps(self, cmap_set: str) -> List[str]: + """Resolve a preset colormap set name to a colormap list.""" + key = (cmap_set or "all").lower() + return self._preset_map.get(key, self._preset_map["all"]) + + def _populate_cmap_list(self, colormaps: List[str]) -> None: + """Replace the combobox options with the provided colormap names.""" + self.cmap_combobox["values"] = tuple(colormaps) + + def _select_default_cmap(self, colormaps: List[str]) -> None: + """Select an initial colormap and render its preview.""" + if not colormaps: + self.figure_widget.clear() + self.colormap_var.set("") + return + + default_cmap = "viridis" if "viridis" in colormaps else colormaps[0] + self.colormap_var.set(default_cmap) + self._update_cmap_sample() + + def _on_cmap_selected(self, event=None) -> None: + """Handle combobox selection changes.""" + self._update_cmap_sample() + + def _update_cmap_sample(self, *args) -> None: + """Update the colormap sample plot.""" + cmap_name = self.colormap_var.get() + if not cmap_name: + self.figure_widget.clear() + return + + fig = cmap_sample_plot(cmap_name, figsize=(4, 1)) + self.figure_widget.embed_figure(fig) + +if __name__ == "__main__": + root = tk.Tk() + root.title("Colormap Selector Test") + root.geometry("800x600") + + cmap_selector = CmapSelector(root, cmap_set="categorical") + cmap_selector.pack(fill=tk.BOTH, expand=True) + + root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/FigureContainer.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/FigureContainer.py new file mode 100644 index 0000000..3abeb5c --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/FigureContainer.py @@ -0,0 +1,108 @@ +import tkinter as tk +from tkinter import ttk +from typing import Optional +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import Figure + + +class FigureContainer(ttk.Frame): + """ + A reusable widget for embedding matplotlib figures and images. + """ + + def __init__( + self, + parent: tk.Widget, + **kwargs + ) -> None: + """ + Initialize the FigureContainer widget. + + Args: + parent: Parent widget + **kwargs: Additional Frame options + """ + super().__init__(parent, **kwargs) + + self.figure: Optional[Figure] = None + self.figure_canvas: Optional[FigureCanvasTkAgg] = None + self.image: Optional[tk.PhotoImage] = None + self.image_label: Optional[ttk.Label] = None + + def _clear_children(self) -> None: + """Destroy any child widgets hosted by this frame.""" + for widget in self.winfo_children(): + widget.destroy() + + def embed_figure(self, figure: Figure) -> None: + """ + Embed a matplotlib figure in the figure container. + + Args: + figure: Matplotlib Figure object to embed + """ + self._clear_children() + + self.figure = figure + self.figure_canvas = FigureCanvasTkAgg(figure, master=self) + self.figure_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + self.figure_canvas.draw() + self.image = None + self.image_label = None + + def embed_image(self, image: tk.PhotoImage) -> None: + """ + Embed a Tk image in the figure container. + + Args: + image: Tk PhotoImage object to embed + """ + self._clear_children() + + self.image = image + self.image_label = ttk.Label(self, image=image) + self.image_label.pack(fill=tk.BOTH, expand=True) + + self.figure = None + self.figure_canvas = None + + def embed_image_file(self, file_path: str) -> None: + """ + Load and embed an image file supported by Tk PhotoImage. + + Args: + file_path: Path to image file + """ + image = tk.PhotoImage(file=file_path) + self.embed_image(image) + + def clear(self) -> None: + """Clear the figure container.""" + self._clear_children() + self.figure = None + self.figure_canvas = None + self.image = None + self.image_label = None + + +if __name__ == "__main__": + import matplotlib.pyplot as plt + + root = tk.Tk() + root.title("Figure Container Test") + root.geometry("500x400") + + # Create figure container + figure_container = FigureContainer(root) + figure_container.pack(padx=10, pady=10, fill=tk.BOTH, expand=True) + + # Create and embed a matplotlib figure + fig, ax = plt.subplots(figsize=(5, 4), dpi=80) + ax.plot([1, 2, 3, 4], [1, 4, 2, 3], marker='o') + ax.set_title("Sample Plot") + ax.set_xlabel("X") + ax.set_ylabel("Y") + + figure_container.embed_figure(fig) + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ListBox.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ListBox.py new file mode 100644 index 0000000..6550fbd --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ListBox.py @@ -0,0 +1,116 @@ +import tkinter as tk +from tkinter import filedialog, ttk + +class ScrollableListBox(ttk.Frame): + """A reusable listbox widget with auto-hiding scrollbar.""" + + def __init__(self, parent, items=None, selectmode=tk.MULTIPLE, height=None, show_selection_controls=False, **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + 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. + show_selection_controls (bool): Show Select All and Deselect All buttons. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.items = items or [] + if height is None: + height = len(self.items) if self.items else 5 + + self.content_frame = ttk.Frame(self) + self.content_frame.pack(fill=tk.BOTH, expand=True) + + # Create scrollbar + self.scrollbar = ttk.Scrollbar(self.content_frame) + self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Create listbox + self.listbox = tk.Listbox( + self.content_frame, + selectmode=selectmode, + height=height, + yscrollcommand=self.scrollbar.set, + exportselection=False, + ) + self.listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self.scrollbar.config(command=self.listbox.yview) + + # Populate with items + for item in self.items: + self.listbox.insert(tk.END, item) + + # Auto-hide scrollbar when not needed + self.listbox.bind("", self._on_configure) + self._on_configure() + + if show_selection_controls: + controls = ttk.Frame(self) + controls.pack(fill=tk.X, pady=(8, 0)) + + self.select_all_button = ttk.Button(controls, text="Select All", command=self.select_all) + self.select_all_button.pack(side=tk.LEFT) + + self.deselect_all_button = ttk.Button(controls, text="Deselect All", command=self.deselect_all) + self.deselect_all_button.pack(side=tk.LEFT, padx=(8, 0)) + + 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.pack_forget() + else: + self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + def set_selections(self, items): + """Set the selection to the specified items.""" + self.listbox.selection_clear(0, tk.END) + for index, item in enumerate(self.items): + if item in items: + self.listbox.selection_set(index) + + def get_selection(self): + """Return a list of selected items.""" + selected_indices = self.listbox.curselection() + return [self.listbox.get(i) for i in selected_indices] + + def get_selection_indices(self): + """Return tuple of selected indices.""" + return self.listbox.curselection() + + def select_all(self): + """Select all items in the listbox.""" + self.listbox.selection_set(0, tk.END) + + def deselect_all(self): + """Clear all selected items in the listbox.""" + self.listbox.selection_clear(0, tk.END) + + def insert(self, index, item): + """Insert an item at the specified index.""" + self.listbox.insert(index, item) + self._on_configure() + + def delete(self, index): + """Delete an item at the specified index.""" + self.listbox.delete(index) + self._on_configure() + + def clear(self): + """Clear all items from the listbox.""" + self.listbox.delete(0, tk.END) + self._on_configure() + + +if __name__ == "__main__": + root = tk.Tk() + root.title("Scrollable ListBox Example") + + items = [f"Item {i}" for i in range(1, 21)] + listbox = ScrollableListBox(root, items=items, height=10, show_selection_controls=True) + listbox.pack(padx=20, pady=20) + + print("Selected items:", listbox.get_selection()) + + root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/PathSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/PathSelector.py new file mode 100644 index 0000000..be64c77 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/PathSelector.py @@ -0,0 +1,79 @@ +import tkinter as tk +from tkinter import filedialog, ttk +from pathlib import Path + +class PathSelector(ttk.Frame): + """A reusable path/file selector widget with a button and a readonly entry.""" + + def __init__(self, parent, button_text="Browse...", filetypes=None, command=None, initialdir=None, mode="file", **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + button_text (str): The text to display on the button. + filetypes (list, optional): List of filetypes for the dialog. + command (callable, optional): Called with the file path after selection. + initialdir (str, optional): Initial directory for the file dialog. + mode (str): Either "file" or "directory" to select files or directories. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + self.path_var = tk.StringVar() + self.command = command + self.filetypes = filetypes + self.initialdir = initialdir + self.mode = mode + + self.display_name = tk.StringVar() + self.entry = ttk.Entry(self, textvariable=self.display_name, width=40) + self.entry.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True) + + self.button = ttk.Button(self, text=button_text, command=self._on_click) + self.button.pack(side=tk.LEFT) + + def _on_click(self): + if self.mode == "directory": + path = filedialog.askdirectory( + initialdir=self.initialdir + ) + else: + path = filedialog.askopenfilename( + filetypes=self.filetypes, + initialdir=self.initialdir + ) + 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)) + + def get(self): + """Return the currently selected file path.""" + return self.path_var.get() + + def set(self, path): + """Set the file path in the entry.""" + 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 __name__ == "__main__": + # Test the file selector widget + root = tk.Tk() + root.title("File Selector Test") + + def on_file_selected(path): + print(f"Selected: {path}") + + path_selector = PathSelector(root, button_text="Select File", filetypes=[("All Files", "*.*")], command=on_file_selected) + path_selector.pack(padx=20, pady=20) + + root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/RadioSelection.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/RadioSelection.py new file mode 100644 index 0000000..eae9927 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/RadioSelection.py @@ -0,0 +1,107 @@ +import tkinter as tk +from tkinter import ttk + +class RadioSelection(ttk.Frame): + """A reusable radio selection widget built from a list of fields.""" + + def __init__(self, parent, fields=None, command=None, default=None, orient="vertical", max_per_line=None, **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + fields (list, optional): List of selectable fields. + command (callable, optional): Called with selected value when changed. + default (str, optional): Default selected value. + orient (str): Either "vertical" or "horizontal". + max_per_line (int, optional): Maximum items per row/column before wrapping. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.fields = [str(field) for field in (fields or [])] + self.command = command + self.orient = orient.lower() + self.max_per_line = max_per_line + self.value_var = tk.StringVar() + self._buttons = [] + + self._build_buttons() + + if default is not None: + self.set(default) + elif self.fields: + self.value_var.set(self.fields[0]) + + def _build_buttons(self): + """Create radio buttons from current fields.""" + for button in self._buttons: + button.destroy() + self._buttons.clear() + + for index, field in enumerate(self.fields): + button = ttk.Radiobutton( + self, + text=field, + value=field, + variable=self.value_var, + command=self._on_select, + ) + if self.max_per_line and self.max_per_line > 0: + if self.orient == "horizontal": + row = index // self.max_per_line + column = index % self.max_per_line + else: + row = index % self.max_per_line + column = index // self.max_per_line + button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky="w") + elif self.orient == "horizontal": + button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky="w") + else: + button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky="w") + self._buttons.append(button) + + def _on_select(self): + """Handle option selection.""" + if self.command: + self.command(self.get()) + + def get(self): + """Return the currently selected value.""" + return self.value_var.get() + + def set(self, value): + """Set the selected value if it exists in fields.""" + value = str(value) + if value in self.fields: + self.value_var.set(value) + + def set_fields(self, fields, default=None): + """Replace the available fields and rebuild the widget.""" + self.fields = [str(field) for field in (fields or [])] + self._build_buttons() + + if default is not None: + self.set(default) + elif self.fields: + self.value_var.set(self.fields[0]) + else: + self.value_var.set("") + + +if __name__ == "__main__": + root = tk.Tk() + root.title("Radio Selection Test") + + def on_selection(value): + print(f"Selected: {value}") + + widget = RadioSelection( + root, + fields=["Option A", "Option B", "Option C", "Option D", "Option E", "Option F", "Option G"], + command=on_selection, + default="Option B", + orient="vertical", + max_per_line=6, + ) + widget.pack(padx=20, pady=20) + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ValidatedEntryBox.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ValidatedEntryBox.py new file mode 100644 index 0000000..7e16b39 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ValidatedEntryBox.py @@ -0,0 +1,268 @@ +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable, Any, Union + +class ValidatedEntryBox: + """ + A reusable entry box component with built-in validation for different data types. + + Supports validation for: + - String (required/optional, min/max length) + - Integer (min/max value) + - Float (min/max value) + - Custom validation via callback + """ + + def __init__( + self, + parent: tk.Widget, + variable: Optional[tk.StringVar] = None, + width: int = 15, + value_type: type = str, + min_value: Optional[Union[int, float]] = None, + max_value: Optional[Union[int, float]] = None, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + required: bool = True, + custom_validator: Optional[Callable[[Any], tuple[bool, str]]] = None, + on_validate: Optional[Callable[[bool], None]] = None, + ) -> None: + """ + Initialize the ValidatedEntryBox. + + Args: + parent: Parent widget + variable: StringVar to bind to the entry (creates one if not provided) + width: Width of the entry widget + value_type: Type to validate against (str, int, float) + min_value: Minimum value for numeric types + max_value: Maximum value for numeric types + min_length: Minimum length for string type + max_length: Maximum length for string type + required: Whether the field is required + custom_validator: Custom validation function that returns (is_valid, error_message) + on_validate: Callback function called after validation with validation result + """ + self.parent = parent + self.value_type = value_type + self.min_value = min_value + self.max_value = max_value + self.min_length = min_length + self.max_length = max_length + self.required = required + self.custom_validator = custom_validator + self.on_validate = on_validate + + # Create frame to hold entry and error label + self.frame = ttk.Frame(parent) + + # Create or use provided StringVar + self.variable = variable if variable is not None else tk.StringVar(value="") + + # Create entry widget + self.entry = ttk.Entry(self.frame, textvariable=self.variable, width=width) + self.entry.pack(side="left", fill="x", expand=True) + + # Bind validation events + self.entry.bind("", lambda _: self.validate()) + self.entry.bind("", lambda _: self.validate()) + + # Create error label + self.error_label = ttk.Label(self.frame, text="", style="Error.TLabel") + self.error_label.pack(side="left", padx=(10, 0)) + + def pack(self, **kwargs) -> None: + """Pack the entry box frame.""" + self.frame.pack(**kwargs) + + def grid(self, **kwargs) -> None: + """Grid the entry box frame.""" + self.frame.grid(**kwargs) + + def place(self, **kwargs) -> None: + """Place the entry box frame.""" + self.frame.place(**kwargs) + + def get(self) -> str: + """Get the current value as a string.""" + return self.variable.get().strip() + + def get_value(self) -> Optional[Union[str, int, float]]: + """Get the current value converted to the specified type.""" + value_str = self.get() + if not value_str: + return None + + try: + if self.value_type == int: + return int(value_str) + elif self.value_type == float: + return float(value_str) + else: + return value_str + except (ValueError, TypeError): + return None + + def set(self, value: Union[str, int, float]) -> None: + """Set the entry value.""" + self.variable.set(str(value)) + + def validate(self) -> bool: + """ + Validate the current entry value. + + Returns: + bool: True if valid, False otherwise + """ + value_str = self.get() + + # Check if required + if self.required and not value_str: + self._show_error("Required") + self._call_validate_callback(False) + return False + + # If not required and empty, it's valid + if not self.required and not value_str: + self._show_success() + self._call_validate_callback(True) + return True + + # Type-specific validation + if self.value_type == str: + return self._validate_string(value_str) + elif self.value_type == int: + return self._validate_int(value_str) + elif self.value_type == float: + return self._validate_float(value_str) + else: + self._show_error(f"Unsupported type: {self.value_type}") + self._call_validate_callback(False) + return False + + def _validate_string(self, value: str) -> bool: + """Validate string value.""" + # 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 + if self.custom_validator: + 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: + """Validate integer value.""" + try: + 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 + if self.custom_validator: + 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: + """Validate float value.""" + try: + value = float(value_str) + except ValueError: + self._show_error("Must be a valid number") + self._call_validate_callback(False) + return False + + # Check range constraints + if self.min_value is not None and value < self.min_value: + if self.max_value is not None: + 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: + if self.min_value is not None: + 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 + if self.custom_validator: + 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 _show_error(self, message: str) -> None: + """Display error message.""" + self.error_label.config(text=message, foreground="#ff4444") + + def _show_success(self) -> None: + """Display success indicator.""" + self.error_label.config(text="✓", foreground="#4bb543") + + def clear_error(self) -> None: + """Clear the error message.""" + self.error_label.config(text="") + + def _call_validate_callback(self, is_valid: bool) -> None: + """Call the validation callback if provided.""" + if self.on_validate: + self.on_validate(is_valid) + + + +if __name__ == "__main__": + # Test the ValidatedEntryBox + root = tk.Tk() + root.title("Validated Entry Box Test") + + def on_validate(is_valid): + print(f"Validation result: {is_valid}") + + entry_box = ValidatedEntryBox(root, value_type=int, min_value=0, max_value=100, on_validate=on_validate) + entry_box.pack(padx=20, pady=20) + + root.mainloop() \ No newline at end of file From 98aa1b9008f57d3f076674c357ac1534251f38f3 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 20 Feb 2026 16:04:50 +0000 Subject: [PATCH 02/28] renamed filing --- .../src/python_toolkit/plot/cmap_sample.py | 5 +- .../tkinter-ui/widgets/CmapSelector.py | 22 ++- .../{tkinter-ui => tkinter}/DefaultRoot.py | 48 +++-- .../DirectoryFileSelector.py | 0 .../{tkinter-ui => tkinter}/LandingPage.py | 0 .../ProcessingWindow.py | 0 .../{tkinter-ui => tkinter}/style.tcl | 0 .../widgets/Calender.py | 0 .../tkinter/widgets/CmapSelector.py | 182 ++++++++++++++++++ .../widgets/FigureContainer.py | 0 .../widgets/ListBox.py | 0 .../widgets/PathSelector.py | 0 .../widgets/RadioSelection.py | 0 .../widgets/ValidatedEntryBox.py | 0 14 files changed, 235 insertions(+), 22 deletions(-) rename Python_Engine/Python/src/python_toolkit/{tkinter-ui => tkinter}/DefaultRoot.py (92%) rename Python_Engine/Python/src/python_toolkit/{tkinter-ui => tkinter}/DirectoryFileSelector.py (100%) rename Python_Engine/Python/src/python_toolkit/{tkinter-ui => tkinter}/LandingPage.py (100%) rename Python_Engine/Python/src/python_toolkit/{tkinter-ui => tkinter}/ProcessingWindow.py (100%) rename Python_Engine/Python/src/python_toolkit/{tkinter-ui => tkinter}/style.tcl (100%) rename Python_Engine/Python/src/python_toolkit/{tkinter-ui => tkinter}/widgets/Calender.py (100%) create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py rename Python_Engine/Python/src/python_toolkit/{tkinter-ui => tkinter}/widgets/FigureContainer.py (100%) rename Python_Engine/Python/src/python_toolkit/{tkinter-ui => tkinter}/widgets/ListBox.py (100%) rename Python_Engine/Python/src/python_toolkit/{tkinter-ui => tkinter}/widgets/PathSelector.py (100%) rename Python_Engine/Python/src/python_toolkit/{tkinter-ui => tkinter}/widgets/RadioSelection.py (100%) rename Python_Engine/Python/src/python_toolkit/{tkinter-ui => tkinter}/widgets/ValidatedEntryBox.py (100%) diff --git a/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py index 768d2fa..5272221 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py +++ b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py @@ -1,6 +1,7 @@ from matplotlib import pyplot as plt from matplotlib import cm from matplotlib.colors import Colormap, Normalize +from matplotlib.figure import Figure import numpy as np from typing import Union, Optional @@ -8,7 +9,7 @@ def cmap_sample_plot( cmap: Union[str, Colormap], bounds: Optional[tuple] = None, figsize: tuple = (9, 1) -) -> plt.Figure: +) -> Figure: """ Generate a sample plot for a given colormap. @@ -33,6 +34,8 @@ def cmap_sample_plot( # Create the figure and axis fig, ax = plt.subplots(figsize=figsize) + fig.patch.set_alpha(0) + ax.set_facecolor("none") # Create normalization if custom colormap or custom bounds norm = Normalize(vmin=vmin, vmax=vmax) diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py index 2b23c63..946788e 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py @@ -4,7 +4,7 @@ import tkinter as tk from python_toolkit.plot.cmap_sample import cmap_sample_plot -from FigureContainer import FigureContainer +from python_toolkit.tkinter.widgets.FigureContainer import FigureContainer class CmapSelector(ttk.Frame): """ @@ -108,7 +108,7 @@ def __init__( self.figure_widget.pack_propagate(False) if self._uses_explicit_colormaps: - current_colormaps = sorted(set(colormaps or [])) + current_colormaps = self._with_reversed(self._filter_available(colormaps or [])) else: current_colormaps = self._preset_colormaps(self.cmap_set_var.get()) @@ -120,21 +120,27 @@ def _get_all_colormaps(self) -> List[str]: return sorted(cm.datad.keys()) def _filter_available(self, names: List[str]) -> List[str]: - """Filter a candidate list to available names and include reversed variants when present.""" + """Filter a candidate list to names available in the current matplotlib build.""" + available = set(self._all_colormaps) + return [name for name in names if name in available] + + def _with_reversed(self, names: List[str]) -> List[str]: + """Return colormap names with reversed variants added next to each base map when available.""" available = set(self._all_colormaps) selected: List[str] = [] for name in names: - if name in available: + if name in available and name not in selected: selected.append(name) - reversed_name = f"{name}_r" - if reversed_name in available: - selected.append(reversed_name) + if not name.endswith("_r"): + reversed_name = f"{name}_r" + if reversed_name in available and reversed_name not in selected: + selected.append(reversed_name) return selected def _preset_colormaps(self, cmap_set: str) -> List[str]: """Resolve a preset colormap set name to a colormap list.""" key = (cmap_set or "all").lower() - return self._preset_map.get(key, self._preset_map["all"]) + return self._with_reversed(self._preset_map.get(key, self._preset_map["all"])) def _populate_cmap_list(self, colormaps: List[str]) -> None: """Replace the combobox options with the provided colormap names.""" diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/DefaultRoot.py b/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py similarity index 92% rename from Python_Engine/Python/src/python_toolkit/tkinter-ui/DefaultRoot.py rename to Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py index 8b12905..1a8254f 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter-ui/DefaultRoot.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py @@ -304,30 +304,47 @@ def refresh_sizing(self) -> None: """Recalculate and apply window sizing (useful after adding widgets).""" self._apply_sizing() + def _destroy_root(self) -> None: + """Safely terminate and destroy the Tk root window.""" + if not hasattr(self, "root") or self.root is None: + return + + try: + if self.root.winfo_exists(): + self.root.quit() + self.root.destroy() + except tk.TclError: + pass + + def _exit(self, result: str, callback: Optional[Callable] = None) -> None: + """Handle any exit path and always destroy the root window.""" + self.result = result + try: + if callback: + callback() + except Exception as ex: + print(f"Warning: Exit callback raised an exception: {ex}") + finally: + self._destroy_root() + def _on_submit(self) -> None: """Handle submit button click.""" - self.result = "submit" - if self.submit_command: - self.submit_command() - self.root.destroy() + self._exit("submit", self.submit_command) def _on_close(self) -> None: """Handle close button click.""" - self.result = "close" - if self.close_command: - self.close_command() - self.root.destroy() + self._exit("close", self.close_command) def _on_close_window(self, callback: Optional[Callable]) -> None: """Handle window X button click.""" - self.result = "window_closed" - if callback: - callback() - self.root.destroy() + self._exit("window_closed", callback) def run(self) -> Optional[str]: """Show the window and return the result.""" - self.root.mainloop() + try: + self.root.mainloop() + finally: + self._destroy_root() return self.result @@ -336,6 +353,7 @@ def run(self) -> Optional[str]: from widgets.RadioSelection import RadioSelection from widgets.ValidatedEntryBox import ValidatedEntryBox from widgets.ListBox import ScrollableListBox + from widgets.CmapSelector import CmapSelector # Store form state form_data = {} @@ -347,6 +365,7 @@ def on_submit(): form_data["file_path"] = file_selector.get() form_data["priority"] = priority_radio.get() form_data["selected_items"] = listbox.get_selection() + form_data["cmap"] = cmap_selector.colormap_var.get() print("\nForm submitted with data:") for key, value in form_data.items(): print(f" {key}: {value}") @@ -415,6 +434,9 @@ def on_close(): listbox.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) listbox.set_selections(["Item 2", "Item 5"]) + cmap_selector = CmapSelector(window.content_frame, cmap_set="categorical") + cmap_selector.pack(anchor="w", pady=(0, 10)) + # Refresh window sizing after adding all widgets window.refresh_sizing() diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/DirectoryFileSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter/DirectoryFileSelector.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/tkinter-ui/DirectoryFileSelector.py rename to Python_Engine/Python/src/python_toolkit/tkinter/DirectoryFileSelector.py diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/LandingPage.py b/Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/tkinter-ui/LandingPage.py rename to Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/ProcessingWindow.py b/Python_Engine/Python/src/python_toolkit/tkinter/ProcessingWindow.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/tkinter-ui/ProcessingWindow.py rename to Python_Engine/Python/src/python_toolkit/tkinter/ProcessingWindow.py diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/style.tcl b/Python_Engine/Python/src/python_toolkit/tkinter/style.tcl similarity index 100% rename from Python_Engine/Python/src/python_toolkit/tkinter-ui/style.tcl rename to Python_Engine/Python/src/python_toolkit/tkinter/style.tcl diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/Calender.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/Calender.py rename to Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py new file mode 100644 index 0000000..946788e --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py @@ -0,0 +1,182 @@ +from typing import Dict, List, Optional +from matplotlib import cm +from tkinter import ttk +import tkinter as tk + +from python_toolkit.plot.cmap_sample import cmap_sample_plot +from python_toolkit.tkinter.widgets.FigureContainer import FigureContainer + +class CmapSelector(ttk.Frame): + """ + A widget for selecting and previewing a matplotlib colormap. + """ + + CATEGORICAL_CMAPS = [ + "Accent", + "Dark2", + "Paired", + "Pastel1", + "Pastel2", + "Set1", + "Set2", + "Set3", + "tab10", + "tab20", + "tab20b", + "tab20c", + ] + + CONTINUOUS_CMAPS = [ + "viridis", + "plasma", + "inferno", + "magma", + "cividis", + "turbo", + "Blues", + "Greens", + "Greys", + "Oranges", + "Purples", + "Reds", + "YlGn", + "YlGnBu", + "YlOrBr", + "YlOrRd", + "coolwarm", + "seismic", + "Spectral", + "RdYlBu", + "RdYlGn", + "twilight", + "twilight_shifted", + "hsv", + ] + + def __init__( + self, + parent: tk.Widget, + colormaps: Optional[List[str]] = None, + cmap_set: str = "all", + **kwargs + ) -> None: + """ + 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". + **kwargs: Additional Frame options + """ + super().__init__(parent, **kwargs) + + self.colormap_var = tk.StringVar(value="viridis") + self._all_colormaps = self._get_all_colormaps() + self._preset_map: Dict[str, List[str]] = { + "all": self._all_colormaps, + "continuous": self._filter_available(self.CONTINUOUS_CMAPS), + "categorical": self._filter_available(self.CATEGORICAL_CMAPS), + } + self._uses_explicit_colormaps = colormaps is not None + + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + content = ttk.Frame(self, width=440, height=130) + content.grid(row=0, column=0, padx=4, pady=4) + content.grid_propagate(False) + + header = ttk.Frame(content) + header.pack(fill=tk.X, padx=8, pady=(8, 4)) + + self.cmap_set_var = tk.StringVar(value=cmap_set.lower()) + + ttk.Label(header, text="Select cmap").pack(side=tk.LEFT, padx=(0, 8)) + self.cmap_combobox = ttk.Combobox( + header, + textvariable=self.colormap_var, + state="readonly", + ) + self.cmap_combobox.pack(side=tk.LEFT, fill=tk.X, expand=True) + self.cmap_combobox.bind("<>", self._on_cmap_selected) + + self.figure_widget = FigureContainer(content, width=420, height=90) + self.figure_widget.pack(anchor="w", padx=8, pady=(0, 8)) + self.figure_widget.pack_propagate(False) + + if self._uses_explicit_colormaps: + current_colormaps = self._with_reversed(self._filter_available(colormaps or [])) + else: + current_colormaps = self._preset_colormaps(self.cmap_set_var.get()) + + self._populate_cmap_list(current_colormaps) + self._select_default_cmap(current_colormaps) + + def _get_all_colormaps(self) -> List[str]: + """Return all registered colormap names, including reversed variants.""" + return sorted(cm.datad.keys()) + + def _filter_available(self, names: List[str]) -> List[str]: + """Filter a candidate list to names available in the current matplotlib build.""" + available = set(self._all_colormaps) + return [name for name in names if name in available] + + def _with_reversed(self, names: List[str]) -> List[str]: + """Return colormap names with reversed variants added next to each base map when available.""" + available = set(self._all_colormaps) + selected: List[str] = [] + for name in names: + if name in available and name not in selected: + selected.append(name) + if not name.endswith("_r"): + reversed_name = f"{name}_r" + if reversed_name in available and reversed_name not in selected: + selected.append(reversed_name) + return selected + + def _preset_colormaps(self, cmap_set: str) -> List[str]: + """Resolve a preset colormap set name to a colormap list.""" + key = (cmap_set or "all").lower() + return self._with_reversed(self._preset_map.get(key, self._preset_map["all"])) + + def _populate_cmap_list(self, colormaps: List[str]) -> None: + """Replace the combobox options with the provided colormap names.""" + self.cmap_combobox["values"] = tuple(colormaps) + + def _select_default_cmap(self, colormaps: List[str]) -> None: + """Select an initial colormap and render its preview.""" + if not colormaps: + self.figure_widget.clear() + self.colormap_var.set("") + return + + default_cmap = "viridis" if "viridis" in colormaps else colormaps[0] + self.colormap_var.set(default_cmap) + self._update_cmap_sample() + + def _on_cmap_selected(self, event=None) -> None: + """Handle combobox selection changes.""" + self._update_cmap_sample() + + def _update_cmap_sample(self, *args) -> None: + """Update the colormap sample plot.""" + cmap_name = self.colormap_var.get() + if not cmap_name: + self.figure_widget.clear() + return + + fig = cmap_sample_plot(cmap_name, figsize=(4, 1)) + self.figure_widget.embed_figure(fig) + +if __name__ == "__main__": + root = tk.Tk() + root.title("Colormap Selector Test") + root.geometry("800x600") + + cmap_selector = CmapSelector(root, cmap_set="categorical") + cmap_selector.pack(fill=tk.BOTH, expand=True) + + root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/FigureContainer.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/FigureContainer.py rename to Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ListBox.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ListBox.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ListBox.py rename to Python_Engine/Python/src/python_toolkit/tkinter/widgets/ListBox.py diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/PathSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/PathSelector.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/PathSelector.py rename to Python_Engine/Python/src/python_toolkit/tkinter/widgets/PathSelector.py diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/RadioSelection.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/RadioSelection.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/RadioSelection.py rename to Python_Engine/Python/src/python_toolkit/tkinter/widgets/RadioSelection.py diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ValidatedEntryBox.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/ValidatedEntryBox.py rename to Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py From 7b916646e5baef0155202e7f4f5fe7666bf48538 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 20 Feb 2026 16:32:10 +0000 Subject: [PATCH 03/28] included bhom branding --- .../python_toolkit/bhom/assets/BHoM_Logo.png | Bin 0 -> 74514 bytes .../python_toolkit/bhom/assets/bhom_icon.png | Bin 0 -> 8718 bytes .../style.tcl => bhom/bhom_style.tcl} | 48 ++--- .../src/python_toolkit/plot/cmap_sample.py | 6 +- .../tkinter-ui/widgets/CmapSelector.py | 182 ------------------ .../src/python_toolkit/tkinter/DefaultRoot.py | 88 +++++++-- .../tkinter/widgets/CmapSelector.py | 19 +- .../tkinter/widgets/FigureContainer.py | 31 ++- 8 files changed, 151 insertions(+), 223 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/assets/BHoM_Logo.png create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/assets/bhom_icon.png rename Python_Engine/Python/src/python_toolkit/{tkinter/style.tcl => bhom/bhom_style.tcl} (97%) delete mode 100644 Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom/assets/BHoM_Logo.png b/Python_Engine/Python/src/python_toolkit/bhom/assets/BHoM_Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3c2841061ea82f54a2bb129f18eae46d7186067e GIT binary patch literal 74514 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 AF8}}l literal 0 HcmV?d00001 diff --git a/Python_Engine/Python/src/python_toolkit/bhom/assets/bhom_icon.png b/Python_Engine/Python/src/python_toolkit/bhom/assets/bhom_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a9a6a86ab40ea8151b7270090042268632ae86bd GIT binary patch literal 8718 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 Yw}w{7eDfLvzw>~Q+Se`@YuVrb55Sa;$p8QV literal 0 HcmV?d00001 diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/style.tcl b/Python_Engine/Python/src/python_toolkit/bhom/bhom_style.tcl similarity index 97% rename from Python_Engine/Python/src/python_toolkit/tkinter/style.tcl rename to Python_Engine/Python/src/python_toolkit/bhom/bhom_style.tcl index 9160f00..31c74cc 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/style.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_style.tcl @@ -98,51 +98,51 @@ namespace eval ttk::theme::bhom_dark { -padding {0 0} ttk::style configure Heading.TLabel \ - -font {{Segoe UI} 12 bold} \ + -font {{Segoe UI} 24 bold} \ -foreground $colors(-primary) \ -padding {0 0} - ttk::style configure Body.TLabel \ + -font {{Segoe UI} 10 bold} \ -font {{Segoe UI} 10} \ -foreground $colors(-fg) \ -padding {6 4} - + -font {{Segoe UI} 12 bold} \ ttk::style configure Caption.TLabel \ -font {{Segoe UI} 9} \ -foreground $colors(-text-secondary) \ - -padding {6 4} + -font {{Segoe UI} 8 bold} \ ttk::style configure Small.TLabel \ -font {{Segoe UI} 8} \ - -foreground $colors(-text-secondary) \ + -font {{Segoe UI} 10 bold} \ -padding {4 2} ttk::style configure Success.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-success) ttk::style configure Warning.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-warning) ttk::style configure Error.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-error) ttk::style configure Info.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-info) # Button - soft rounded design - ttk::style configure TButton \ + -font {{Segoe UI} 10 bold} \ -font {{Segoe UI} 11 bold} \ -background $colors(-active-bg) \ -foreground $colors(-fg) \ - -bordercolor $colors(-border-light) \ + -font {{Segoe UI} 10 bold} \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ -borderwidth 2 \ - -focuscolor "" \ + -font {{Segoe UI} 10 bold} \ -padding {16 8} \ -relief raised @@ -710,51 +710,51 @@ namespace eval ttk::theme::bhom_light { -padding {0 0} ttk::style configure Heading.TLabel \ - -font {{Segoe UI} 12 bold} \ + -font {{Segoe UI} 24 bold} \ -foreground $colors(-primary) \ -padding {0 0} - ttk::style configure Body.TLabel \ + -font {{Segoe UI} 10 bold} \ -font {{Segoe UI} 10} \ -foreground $colors(-fg) \ -padding {6 4} - + -font {{Segoe UI} 12 bold} \ ttk::style configure Caption.TLabel \ -font {{Segoe UI} 9} \ -foreground $colors(-text-secondary) \ - -padding {6 4} + -font {{Segoe UI} 8 bold} \ ttk::style configure Small.TLabel \ -font {{Segoe UI} 8} \ - -foreground $colors(-text-secondary) \ + -font {{Segoe UI} 10 bold} \ -padding {4 2} ttk::style configure Success.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-success) ttk::style configure Warning.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-warning) ttk::style configure Error.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-error) ttk::style configure Info.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-info) # Button - soft rounded design - ttk::style configure TButton \ + -font {{Segoe UI} 10 bold} \ -font {{Segoe UI} 11 bold} \ -background $colors(-active-bg) \ -foreground $colors(-fg) \ - -bordercolor $colors(-border-light) \ + -font {{Segoe UI} 10 bold} \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ -borderwidth 2 \ - -focuscolor "" \ + -font {{Segoe UI} 10 bold} \ -padding {16 8} \ -relief raised diff --git a/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py index 5272221..e246bdc 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py +++ b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py @@ -1,10 +1,11 @@ from matplotlib import pyplot as plt -from matplotlib import cm from matplotlib.colors import Colormap, Normalize from matplotlib.figure import Figure import numpy as np from typing import Union, Optional +plt.rcParams["figure.max_open_warning"] = 0 + def cmap_sample_plot( cmap: Union[str, Colormap], bounds: Optional[tuple] = None, @@ -35,13 +36,14 @@ def cmap_sample_plot( # Create the figure and axis fig, ax = plt.subplots(figsize=figsize) fig.patch.set_alpha(0) + ax.patch.set_alpha(0) ax.set_facecolor("none") # Create normalization if custom colormap or custom bounds norm = Normalize(vmin=vmin, vmax=vmax) # Display the gradient with the specified colormap - im = ax.imshow(gradient, aspect='auto', cmap=cmap, norm=norm) + ax.imshow(gradient, aspect='auto', cmap=cmap, norm=norm) # Remove axes for a cleaner look ax.set_axis_off() diff --git a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py deleted file mode 100644 index 946788e..0000000 --- a/Python_Engine/Python/src/python_toolkit/tkinter-ui/widgets/CmapSelector.py +++ /dev/null @@ -1,182 +0,0 @@ -from typing import Dict, List, Optional -from matplotlib import cm -from tkinter import ttk -import tkinter as tk - -from python_toolkit.plot.cmap_sample import cmap_sample_plot -from python_toolkit.tkinter.widgets.FigureContainer import FigureContainer - -class CmapSelector(ttk.Frame): - """ - A widget for selecting and previewing a matplotlib colormap. - """ - - CATEGORICAL_CMAPS = [ - "Accent", - "Dark2", - "Paired", - "Pastel1", - "Pastel2", - "Set1", - "Set2", - "Set3", - "tab10", - "tab20", - "tab20b", - "tab20c", - ] - - CONTINUOUS_CMAPS = [ - "viridis", - "plasma", - "inferno", - "magma", - "cividis", - "turbo", - "Blues", - "Greens", - "Greys", - "Oranges", - "Purples", - "Reds", - "YlGn", - "YlGnBu", - "YlOrBr", - "YlOrRd", - "coolwarm", - "seismic", - "Spectral", - "RdYlBu", - "RdYlGn", - "twilight", - "twilight_shifted", - "hsv", - ] - - def __init__( - self, - parent: tk.Widget, - colormaps: Optional[List[str]] = None, - cmap_set: str = "all", - **kwargs - ) -> None: - """ - 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". - **kwargs: Additional Frame options - """ - super().__init__(parent, **kwargs) - - self.colormap_var = tk.StringVar(value="viridis") - self._all_colormaps = self._get_all_colormaps() - self._preset_map: Dict[str, List[str]] = { - "all": self._all_colormaps, - "continuous": self._filter_available(self.CONTINUOUS_CMAPS), - "categorical": self._filter_available(self.CATEGORICAL_CMAPS), - } - self._uses_explicit_colormaps = colormaps is not None - - self.columnconfigure(0, weight=1) - self.rowconfigure(0, weight=1) - - content = ttk.Frame(self, width=440, height=130) - content.grid(row=0, column=0, padx=4, pady=4) - content.grid_propagate(False) - - header = ttk.Frame(content) - header.pack(fill=tk.X, padx=8, pady=(8, 4)) - - self.cmap_set_var = tk.StringVar(value=cmap_set.lower()) - - ttk.Label(header, text="Select cmap").pack(side=tk.LEFT, padx=(0, 8)) - self.cmap_combobox = ttk.Combobox( - header, - textvariable=self.colormap_var, - state="readonly", - ) - self.cmap_combobox.pack(side=tk.LEFT, fill=tk.X, expand=True) - self.cmap_combobox.bind("<>", self._on_cmap_selected) - - self.figure_widget = FigureContainer(content, width=420, height=90) - self.figure_widget.pack(anchor="w", padx=8, pady=(0, 8)) - self.figure_widget.pack_propagate(False) - - if self._uses_explicit_colormaps: - current_colormaps = self._with_reversed(self._filter_available(colormaps or [])) - else: - current_colormaps = self._preset_colormaps(self.cmap_set_var.get()) - - self._populate_cmap_list(current_colormaps) - self._select_default_cmap(current_colormaps) - - def _get_all_colormaps(self) -> List[str]: - """Return all registered colormap names, including reversed variants.""" - return sorted(cm.datad.keys()) - - def _filter_available(self, names: List[str]) -> List[str]: - """Filter a candidate list to names available in the current matplotlib build.""" - available = set(self._all_colormaps) - return [name for name in names if name in available] - - def _with_reversed(self, names: List[str]) -> List[str]: - """Return colormap names with reversed variants added next to each base map when available.""" - available = set(self._all_colormaps) - selected: List[str] = [] - for name in names: - if name in available and name not in selected: - selected.append(name) - if not name.endswith("_r"): - reversed_name = f"{name}_r" - if reversed_name in available and reversed_name not in selected: - selected.append(reversed_name) - return selected - - def _preset_colormaps(self, cmap_set: str) -> List[str]: - """Resolve a preset colormap set name to a colormap list.""" - key = (cmap_set or "all").lower() - return self._with_reversed(self._preset_map.get(key, self._preset_map["all"])) - - def _populate_cmap_list(self, colormaps: List[str]) -> None: - """Replace the combobox options with the provided colormap names.""" - self.cmap_combobox["values"] = tuple(colormaps) - - def _select_default_cmap(self, colormaps: List[str]) -> None: - """Select an initial colormap and render its preview.""" - if not colormaps: - self.figure_widget.clear() - self.colormap_var.set("") - return - - default_cmap = "viridis" if "viridis" in colormaps else colormaps[0] - self.colormap_var.set(default_cmap) - self._update_cmap_sample() - - def _on_cmap_selected(self, event=None) -> None: - """Handle combobox selection changes.""" - self._update_cmap_sample() - - def _update_cmap_sample(self, *args) -> None: - """Update the colormap sample plot.""" - cmap_name = self.colormap_var.get() - if not cmap_name: - self.figure_widget.clear() - return - - fig = cmap_sample_plot(cmap_name, figsize=(4, 1)) - self.figure_widget.embed_figure(fig) - -if __name__ == "__main__": - root = tk.Tk() - root.title("Colormap Selector Test") - root.geometry("800x600") - - cmap_selector = CmapSelector(root, cmap_set="categorical") - cmap_selector.pack(fill=tk.BOTH, expand=True) - - root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py b/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py index 1a8254f..fd8b1b3 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py @@ -20,6 +20,7 @@ def __init__( self, title: str = "Application", logo_path: Optional[Path] = None, + icon_path: Optional[Path] = None, min_width: int = 500, min_height: int = 400, width: Optional[int] = None, @@ -42,6 +43,7 @@ def __init__( Args: title (str): Window and banner title text. logo_path (Path, optional): Path to logo image file. + icon_path (Path, optional): Path to window icon file (.ico recommended on Windows). min_width (int): Minimum window width. min_height (int): Minimum window height. width (int, optional): Fixed width (overrides dynamic sizing). @@ -60,6 +62,8 @@ def __init__( """ self.root = tk.Tk() self.root.title(title) + self._icon_image = None + self._set_window_icon(icon_path) self.root.minsize(min_width, min_height) self.root.resizable(resizable, resizable) @@ -68,10 +72,9 @@ def __init__( # Determine theme based on mode and system preference theme_name = self._determine_theme_name(theme_mode) - self.theme_name = theme_name # Load custom dark theme - self._load_theme(theme_path, theme_name) + self.theme_name = self._load_theme(theme_path, theme_name) self.min_width = min_width self.min_height = min_height @@ -103,6 +106,42 @@ def __init__( # Apply sizing self._apply_sizing() + def _set_window_icon(self, icon_path: Optional[Path]) -> None: + """Set a custom window icon, replacing the default Tk icon when possible.""" + if icon_path is None: + return + + path = Path(icon_path) + if not path.exists(): + print(f"Warning: Icon file not found at {path}") + return + + # Windows prefers .ico for titlebar/taskbar icons. + if path.suffix.lower() == ".ico": + try: + self.root.iconbitmap(default=str(path)) + return + except tk.TclError: + pass + + # Fallback for image formats supported by Tk PhotoImage (png/gif/etc.). + try: + self._icon_image = tk.PhotoImage(file=str(path)) + self.root.iconphoto(True, self._icon_image) + return + except tk.TclError: + pass + + # Final fallback using PIL if available. + try: + from PIL import Image, ImageTk + + img = Image.open(path) + self._icon_image = ImageTk.PhotoImage(img) + self.root.iconphoto(True, self._icon_image) + except Exception as ex: + print(f"Warning: Could not set window icon from {path}: {ex}") + def _determine_theme_name(self, theme_mode: str) -> str: """ Determine the theme name based on the specified mode and system preference. @@ -163,7 +202,7 @@ def _apply_titlebar_theme(self, theme_name: str) -> None: """ self._set_titlebar_theme(theme_name) - def _load_theme(self, custom_theme_path: Optional[Path] = None, theme_name: str = "bhom_dark") -> None: + def _load_theme(self, custom_theme_path: Optional[Path] = None, theme_name: str = "bhom_dark") -> str: """ Load a custom theme from a TCL file. @@ -171,27 +210,50 @@ def _load_theme(self, custom_theme_path: Optional[Path] = None, theme_name: str custom_theme_path (Path, optional): Path to custom TCL theme file. If None, uses default style.tcl in same directory. theme_name (str): Name of the theme to apply from the TCL file. + + Returns: + str: Name of the theme that ended up being applied. """ + style = ttk.Style() + + def _pick_theme_name(available_theme_names: tuple) -> str: + if theme_name in available_theme_names: + return theme_name + + for candidate in ("bhom_dark", "bhom_light"): + if candidate in available_theme_names: + return candidate + + return style.theme_use() + try: # Determine which theme file to use if custom_theme_path and custom_theme_path.exists(): theme_path = custom_theme_path else: - # Use default theme in same directory as this file - current_dir = Path(__file__).parent - theme_path = current_dir / "style.tcl" + # Use default theme + theme_path = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_style.tcl") if theme_path.exists(): # Load the TCL theme file self.root.tk.call('source', str(theme_path)) - - # Apply the specified theme - style = ttk.Style() - style.theme_use(theme_name) + + available_theme_names = style.theme_names() + selected_theme = _pick_theme_name(available_theme_names) + style.theme_use(selected_theme) + return selected_theme else: print(f"Warning: Theme file not found at {theme_path}") + available_theme_names = style.theme_names() + selected_theme = _pick_theme_name(available_theme_names) + style.theme_use(selected_theme) + return selected_theme except Exception as e: print(f"Warning: Could not load custom theme: {e}") + try: + return style.theme_use() + except Exception: + return theme_name def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path]) -> None: """Build the branded banner section.""" @@ -209,7 +271,7 @@ def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path] img.thumbnail((40, 40), Image.Resampling.LANCZOS) self.logo_image = ImageTk.PhotoImage(img) logo_label = ttk.Label(banner_content, image=self.logo_image) - logo_label.pack(side=tk.LEFT, padx=(0, 10)) + logo_label.pack(side=tk.RIGHT) except ImportError: pass # PIL not available, skip logo @@ -379,6 +441,10 @@ def on_close(): min_height=500, submit_command=on_submit, close_command=on_close, + logo_path= Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\bhom_logo.png"), + icon_path= Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\bhom_icon.png"), + theme_mode="dark", + ) # Add form widgets to the content area diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py index 946788e..cb5be32 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py @@ -117,7 +117,19 @@ def __init__( def _get_all_colormaps(self) -> List[str]: """Return all registered colormap names, including reversed variants.""" - return sorted(cm.datad.keys()) + # Base names available in this matplotlib build + try: + base_names = set(cm.cmap_d.keys()) + except Exception: + base_names = set(cm.datad.keys()) + + # Include reversed variants (name_r) next to each base map + all_names = set(base_names) + for name in list(base_names): + if not name.endswith("_r"): + all_names.add(f"{name}_r") + + return sorted(all_names) def _filter_available(self, names: List[str]) -> List[str]: """Filter a candidate list to names available in the current matplotlib build.""" @@ -176,7 +188,8 @@ def _update_cmap_sample(self, *args) -> None: root.title("Colormap Selector Test") root.geometry("800x600") - cmap_selector = CmapSelector(root, cmap_set="categorical") + cmap_selector = CmapSelector(root, cmap_set="all") cmap_selector.pack(fill=tk.BOTH, expand=True) - root.mainloop() \ No newline at end of file + root.mainloop() + root.destroy() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py index 3abeb5c..7b97035 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py @@ -3,6 +3,7 @@ from typing import Optional from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure +import matplotlib.pyplot as plt class FigureContainer(ttk.Frame): @@ -34,6 +35,20 @@ def _clear_children(self) -> None: for widget in self.winfo_children(): widget.destroy() + def _resolved_background(self) -> str: + """Resolve a background colour suitable for embedded Tk canvas widgets.""" + try: + bg = ttk.Style().lookup("TFrame", "background") + if bg: + return bg + except Exception: + pass + + try: + return self.winfo_toplevel().cget("bg") + except Exception: + return "white" + def embed_figure(self, figure: Figure) -> None: """ Embed a matplotlib figure in the figure container. @@ -45,8 +60,16 @@ def embed_figure(self, figure: Figure) -> None: self.figure = figure self.figure_canvas = FigureCanvasTkAgg(figure, master=self) - self.figure_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + canvas_widget = self.figure_canvas.get_tk_widget() + bg = self._resolved_background() + canvas_widget.configure(bg=bg, highlightthickness=0, bd=0) + canvas_widget.pack(fill=tk.BOTH, expand=True) self.figure_canvas.draw() + # Close the figure in pyplot to avoid accumulating open-figure state + try: + plt.close(figure) + except Exception: + pass self.image = None self.image_label = None @@ -79,6 +102,12 @@ def embed_image_file(self, file_path: str) -> None: def clear(self) -> None: """Clear the figure container.""" self._clear_children() + # Close any held figure to free matplotlib resources + if self.figure is not None: + try: + plt.close(self.figure) + except Exception: + pass self.figure = None self.figure_canvas = None self.image = None From 3c8b30efe7d7aec9a4d5152551da73901ed76a7b Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 20 Feb 2026 16:46:51 +0000 Subject: [PATCH 04/28] fixed style sheet --- .../src/python_toolkit/bhom/bhom_style.tcl | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_style.tcl b/Python_Engine/Python/src/python_toolkit/bhom/bhom_style.tcl index 31c74cc..e81d27c 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_style.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_style.tcl @@ -83,7 +83,7 @@ namespace eval ttk::theme::bhom_dark { -padding {0 0} ttk::style configure Title.TLabel \ - -font {{Segoe UI} 20 bold} \ + -font {{Segoe UI} 24 bold} \ -foreground $colors(-fg) \ -padding {0 0} @@ -98,63 +98,63 @@ namespace eval ttk::theme::bhom_dark { -padding {0 0} ttk::style configure Heading.TLabel \ - -font {{Segoe UI} 24 bold} \ + -font {{Segoe UI} 12 bold} \ -foreground $colors(-primary) \ -padding {0 0} - -font {{Segoe UI} 10 bold} \ - -font {{Segoe UI} 10} \ + ttk::style configure Body.TLabel \ + -font {{Segoe UI} 10} \ -foreground $colors(-fg) \ -padding {6 4} - -font {{Segoe UI} 12 bold} \ + ttk::style configure Caption.TLabel \ -font {{Segoe UI} 9} \ -foreground $colors(-text-secondary) \ - -font {{Segoe UI} 8 bold} \ + -padding {6 4} ttk::style configure Small.TLabel \ -font {{Segoe UI} 8} \ - -font {{Segoe UI} 10 bold} \ + -foreground $colors(-text-secondary) \ -padding {4 2} ttk::style configure Success.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-success) ttk::style configure Warning.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-warning) ttk::style configure Error.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-error) ttk::style configure Info.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-info) # Button - soft rounded design - -font {{Segoe UI} 10 bold} \ - -font {{Segoe UI} 11 bold} \ + ttk::style configure TButton \ + -font {{Segoe UI} 10 bold} \ -background $colors(-active-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ + -bordercolor $colors(-border-light) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ -borderwidth 2 \ - -font {{Segoe UI} 10 bold} \ + -focuscolor "" \ -padding {16 8} \ -relief raised # Large Button variant ttk::style configure Large.TButton \ - -font {{Segoe UI} 13 bold} \ + -font {{Segoe UI} 12 bold} \ -padding {20 12} \ -borderwidth 2 # Small Button variant ttk::style configure Small.TButton \ - -font {{Segoe UI} 9 bold} \ + -font {{Segoe UI} 8 bold} \ -padding {12 6} \ -borderwidth 2 @@ -180,7 +180,7 @@ namespace eval ttk::theme::bhom_dark { # Primary Button - accent color with soft rounded edges ttk::style configure Primary.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-primary) \ -foreground $colors(-fg) \ -bordercolor $colors(-primary-light) \ @@ -206,7 +206,7 @@ namespace eval ttk::theme::bhom_dark { # Secondary Button - soft rounded edges ttk::style configure Secondary.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-secondary) \ -foreground $colors(-fg) \ -bordercolor $colors(-secondary) \ @@ -226,7 +226,7 @@ namespace eval ttk::theme::bhom_dark { # Accent Button - lime green from app.css ttk::style configure Accent.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-tertiary) \ -foreground "#000000" \ -bordercolor $colors(-tertiary) \ @@ -248,7 +248,7 @@ namespace eval ttk::theme::bhom_dark { # Success Button - green from app.css ttk::style configure Success.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-success) \ -foreground $colors(-fg) \ -bordercolor $colors(-success) \ @@ -268,7 +268,7 @@ namespace eval ttk::theme::bhom_dark { # Link Button - blue link from app.css ttk::style configure Link.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-bg) \ -foreground $colors(-info) \ -borderwidth 0 \ @@ -283,7 +283,7 @@ namespace eval ttk::theme::bhom_dark { # Outline Button - soft rounded with bold font ttk::style configure Outline.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-bg) \ -foreground $colors(-primary) \ -bordercolor $colors(-primary) \ @@ -305,7 +305,7 @@ namespace eval ttk::theme::bhom_dark { # Text Button - bold font with padding ttk::style configure Text.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-bg) \ -foreground $colors(-primary) \ -borderwidth 0 \ @@ -573,8 +573,8 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure TSizegrip \ -background $colors(-bg) - # Spinbox - # Spinbox - soft rounded design\n ttk::style configure TSpinbox \ + # Spinbox - soft rounded design + ttk::style configure TSpinbox \ -fieldbackground $colors(-inputbg) \ -foreground $colors(-inputfg) \ -bordercolor $colors(-border-light) \ @@ -695,7 +695,7 @@ namespace eval ttk::theme::bhom_light { -padding {0 0} ttk::style configure Title.TLabel \ - -font {{Segoe UI} 20 bold} \ + -font {{Segoe UI} 24 bold} \ -foreground $colors(-fg) \ -padding {0 0} @@ -710,63 +710,63 @@ namespace eval ttk::theme::bhom_light { -padding {0 0} ttk::style configure Heading.TLabel \ - -font {{Segoe UI} 24 bold} \ + -font {{Segoe UI} 12 bold} \ -foreground $colors(-primary) \ -padding {0 0} - -font {{Segoe UI} 10 bold} \ + ttk::style configure Body.TLabel \ -font {{Segoe UI} 10} \ -foreground $colors(-fg) \ -padding {6 4} - -font {{Segoe UI} 12 bold} \ + ttk::style configure Caption.TLabel \ -font {{Segoe UI} 9} \ -foreground $colors(-text-secondary) \ - -font {{Segoe UI} 8 bold} \ + -padding {6 4} ttk::style configure Small.TLabel \ -font {{Segoe UI} 8} \ - -font {{Segoe UI} 10 bold} \ + -foreground $colors(-text-secondary) \ -padding {4 2} ttk::style configure Success.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-success) ttk::style configure Warning.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-warning) ttk::style configure Error.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-error) ttk::style configure Info.TLabel \ - -font {{Segoe UI} 10 bold} \ + -font {{Segoe UI} 10 bold} \ -foreground $colors(-info) # Button - soft rounded design - -font {{Segoe UI} 10 bold} \ - -font {{Segoe UI} 11 bold} \ + ttk::style configure TButton \ + -font {{Segoe UI} 10 bold} \ -background $colors(-active-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ + -bordercolor $colors(-border-light) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ -borderwidth 2 \ - -font {{Segoe UI} 10 bold} \ + -focuscolor "" \ -padding {16 8} \ -relief raised # Large Button variant ttk::style configure Large.TButton \ - -font {{Segoe UI} 13 bold} \ + -font {{Segoe UI} 12 bold} \ -padding {20 12} \ -borderwidth 2 # Small Button variant ttk::style configure Small.TButton \ - -font {{Segoe UI} 9 bold} \ + -font {{Segoe UI} 8 bold} \ -padding {12 6} \ -borderwidth 2 @@ -792,7 +792,7 @@ namespace eval ttk::theme::bhom_light { # Primary Button - accent color with soft rounded edges ttk::style configure Primary.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-primary) \ -foreground "#ffffff" \ -bordercolor $colors(-primary-light) \ @@ -818,7 +818,7 @@ namespace eval ttk::theme::bhom_light { # Secondary Button - soft rounded edges ttk::style configure Secondary.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-secondary) \ -foreground "#ffffff" \ -bordercolor $colors(-secondary) \ @@ -838,7 +838,7 @@ namespace eval ttk::theme::bhom_light { # Accent Button - muted yellow for light mode ttk::style configure Accent.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-tertiary) \ -foreground "#ffffff" \ -bordercolor $colors(-tertiary) \ @@ -860,7 +860,7 @@ namespace eval ttk::theme::bhom_light { # Success Button - green from app.css ttk::style configure Success.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-success) \ -foreground "#ffffff" \ -bordercolor $colors(-success) \ @@ -880,7 +880,7 @@ namespace eval ttk::theme::bhom_light { # Link Button - blue link from app.css ttk::style configure Link.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-bg) \ -foreground $colors(-info) \ -borderwidth 0 \ @@ -895,7 +895,7 @@ namespace eval ttk::theme::bhom_light { # Outline Button - soft rounded with bold font ttk::style configure Outline.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-bg) \ -foreground $colors(-primary) \ -bordercolor $colors(-primary) \ @@ -917,7 +917,7 @@ namespace eval ttk::theme::bhom_light { # Text Button - bold font with padding ttk::style configure Text.TButton \ - -font {{Segoe UI} 11 bold} \ + -font {{Segoe UI} 10 bold} \ -background $colors(-bg) \ -foreground $colors(-primary) \ -borderwidth 0 \ From c1fb71d279a8fd0345b319f407d62aa3402d83f9 Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Mon, 23 Feb 2026 10:02:01 +0000 Subject: [PATCH 05/28] wip hand over to felix --- .../src/python_toolkit/tkinter/DefaultRoot.py | 64 +++++++++---------- .../src/python_toolkit/tkinter/LandingPage.py | 24 ++++--- .../tkinter/widgets/Calender.py | 2 + .../tkinter/widgets/ValidatedEntryBox.py | 16 ++--- 4 files changed, 56 insertions(+), 50 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py b/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py index fd8b1b3..5c5cca0 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py @@ -10,7 +10,7 @@ import ctypes -class DefaultRoot: +class DefaultRoot(tk.Tk): """ A reusable default root window template for tkinter applications. Includes a branded banner, content area, and optional action buttons. @@ -60,15 +60,15 @@ def __init__( 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"). """ - self.root = tk.Tk() - self.root.title(title) + super().__init__() + self.title(title) self._icon_image = None self._set_window_icon(icon_path) - self.root.minsize(min_width, min_height) - self.root.resizable(resizable, resizable) + self.minsize(min_width, min_height) + self.resizable(resizable, resizable) # Hide window during setup to prevent flash - self.root.withdraw() + self.withdraw() # Determine theme based on mode and system preference theme_name = self._determine_theme_name(theme_mode) @@ -86,10 +86,10 @@ def __init__( self.result = None # Handle window close (X button) - self.root.protocol("WM_DELETE_WINDOW", lambda: self._on_close_window(on_close_window)) + self.protocol("WM_DELETE_WINDOW", lambda: self._on_close_window(on_close_window)) # Main container - main_container = ttk.Frame(self.root) + main_container = ttk.Frame(self) main_container.pack(fill=tk.BOTH, expand=True) # Banner section @@ -119,7 +119,7 @@ def _set_window_icon(self, icon_path: Optional[Path]) -> None: # Windows prefers .ico for titlebar/taskbar icons. if path.suffix.lower() == ".ico": try: - self.root.iconbitmap(default=str(path)) + self.iconbitmap(default=str(path)) return except tk.TclError: pass @@ -127,7 +127,7 @@ def _set_window_icon(self, icon_path: Optional[Path]) -> None: # Fallback for image formats supported by Tk PhotoImage (png/gif/etc.). try: self._icon_image = tk.PhotoImage(file=str(path)) - self.root.iconphoto(True, self._icon_image) + self.iconphoto(True, self._icon_image) return except tk.TclError: pass @@ -138,7 +138,7 @@ def _set_window_icon(self, icon_path: Optional[Path]) -> None: img = Image.open(path) self._icon_image = ImageTk.PhotoImage(img) - self.root.iconphoto(True, self._icon_image) + self.iconphoto(True, self._icon_image) except Exception as ex: print(f"Warning: Could not set window icon from {path}: {ex}") @@ -178,9 +178,9 @@ def _set_titlebar_theme(self, theme_name: str) -> None: """ try: import platform - if platform.system() == "Windows" and ctypes is not None and self.root.winfo_exists(): - hwnd = self.root.winfo_id() - hwnd = ctypes.windll.user32.GetParent(self.root.winfo_id()) + if platform.system() == "Windows" and ctypes is not None and self.winfo_exists(): + hwnd = self.winfo_id() + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) if hwnd: DWMWA_USE_IMMERSIVE_DARK_MODE = 20 use_dark = 1 if theme_name == "bhom_dark" else 0 @@ -236,7 +236,7 @@ def _pick_theme_name(available_theme_names: tuple) -> str: if theme_path.exists(): # Load the TCL theme file - self.root.tk.call('source', str(theme_path)) + self.tk.call('source', str(theme_path)) available_theme_names = style.theme_names() selected_theme = _pick_theme_name(available_theme_names) @@ -324,7 +324,7 @@ def _build_buttons( def _apply_sizing(self) -> None: """Apply window sizing and positioning.""" - self.root.update_idletasks() + self.update_idletasks() # Determine final dimensions if self.fixed_width and self.fixed_height: @@ -332,27 +332,27 @@ def _apply_sizing(self) -> None: final_height = self.fixed_height elif self.fixed_width: final_width = self.fixed_width - final_height = max(self.min_height, self.root.winfo_reqheight()) + final_height = max(self.min_height, self.winfo_reqheight()) elif self.fixed_height: - final_width = max(self.min_width, self.root.winfo_reqwidth()) + final_width = max(self.min_width, self.winfo_reqwidth()) final_height = self.fixed_height else: # Dynamic sizing - final_width = max(self.min_width, self.root.winfo_reqwidth()) - final_height = max(self.min_height, self.root.winfo_reqheight()) + final_width = max(self.min_width, self.winfo_reqwidth()) + final_height = max(self.min_height, self.winfo_reqheight()) # Position if self.center_on_screen: - screen_width = self.root.winfo_screenwidth() - screen_height = self.root.winfo_screenheight() + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() x = (screen_width - final_width) // 2 y = (screen_height - final_height) // 2 - self.root.geometry(f"{final_width}x{final_height}+{x}+{y}") + self.geometry(f"{final_width}x{final_height}+{x}+{y}") else: - self.root.geometry(f"{final_width}x{final_height}") + self.geometry(f"{final_width}x{final_height}") # Defer window display until after styling is applied - self.root.after(0, self._show_window_with_styling) + self.after(0, self._show_window_with_styling) def _show_window_with_styling(self) -> None: """Apply titlebar styling and show the window.""" @@ -360,21 +360,21 @@ def _show_window_with_styling(self) -> None: self._apply_titlebar_theme(self.theme_name) # Show window after styling - self.root.deiconify() + self.deiconify() def refresh_sizing(self) -> None: """Recalculate and apply window sizing (useful after adding widgets).""" self._apply_sizing() - def _destroy_root(self) -> None: + def destroy_root(self) -> None: """Safely terminate and destroy the Tk root window.""" - if not hasattr(self, "root") or self.root is None: + if not hasattr(self, "root") or self is None: return try: - if self.root.winfo_exists(): - self.root.quit() - self.root.destroy() + if self.winfo_exists(): + self.quit() + self.destroy() except tk.TclError: pass @@ -404,7 +404,7 @@ def _on_close_window(self, callback: Optional[Callable]) -> None: def run(self) -> Optional[str]: """Show the window and return the result.""" try: - self.root.mainloop() + self.mainloop() finally: self._destroy_root() return self.result diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py b/Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py index 21fd20c..c70c872 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py @@ -4,7 +4,7 @@ from DefaultRoot import DefaultRoot -class LandingPage: +class LandingPage(DefaultRoot): """ A reusable landing page GUI with configurable title, message, and buttons. Uses DefaultRoot as the base template. @@ -41,9 +41,7 @@ def __init__( # Store callbacks self.continue_command = continue_command self.close_command = close_command - - # Initialize DefaultRoot with continue mapped to submit - self.window = DefaultRoot( + super().__init__( title=title, min_width=min_width, min_height=min_height, @@ -53,12 +51,13 @@ def __init__( show_close=show_close, close_text=close_text, close_command=self._on_close, - ) + ) + # Initialize DefaultRoot with continue mapped to submit # Optional message/commentary if message: message_label = ttk.Label( - self.window.content_frame, + self.content_frame, text=message, justify=tk.LEFT, wraplength=min_width - 80, @@ -66,7 +65,7 @@ def __init__( message_label.pack(anchor="w", pady=(0, 20)) # Custom buttons container - self.custom_buttons_frame = ttk.Frame(self.window.content_frame) + self.custom_buttons_frame = ttk.Frame(self.content_frame) self.custom_buttons_frame.pack(fill=tk.X, pady=(0, 20)) def add_custom_button(self, text: str, command: Callable, **kwargs) -> ttk.Button: @@ -84,7 +83,7 @@ def add_custom_button(self, text: str, command: Callable, **kwargs) -> ttk.Butto button = ttk.Button(self.custom_buttons_frame, text=text, command=command, **kwargs) button.pack(pady=5, fill=tk.X) # Recalculate window size after adding button - self.window.refresh_sizing() + self.refresh_sizing() return button def _on_continue(self): @@ -99,7 +98,7 @@ def _on_close(self): def run(self) -> Optional[str]: """Show the landing page and return the result.""" - result = self.window.run() + result = self.run() # Map DefaultRoot results to LandingPage convention if result == "submit": return "continue" @@ -128,5 +127,10 @@ def on_close(): "Option B", lambda: print("Option B selected") ) - result = landing.run() + landing.mainloop() + landing.destroy_root() + + result = landing.result + + print(f"Result: {result}") diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py index 4f9f418..b700208 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py @@ -87,6 +87,8 @@ def get_date(self): if __name__ == "__main__": root = tk.Tk() + + root = DefaultRoot() root.title("Calendar Widget Test") # Example without year selector diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py index 7e16b39..4b09c41 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py @@ -2,7 +2,7 @@ from tkinter import ttk from typing import Optional, Callable, Any, Union -class ValidatedEntryBox: +class ValidatedEntryBox(ttk.Frame): """ A reusable entry box component with built-in validation for different data types. @@ -26,6 +26,7 @@ def __init__( required: bool = True, custom_validator: Optional[Callable[[Any], tuple[bool, str]]] = None, on_validate: Optional[Callable[[bool], None]] = None, + **kwargs ) -> None: """ Initialize the ValidatedEntryBox. @@ -43,7 +44,7 @@ def __init__( custom_validator: Custom validation function that returns (is_valid, error_message) on_validate: Callback function called after validation with validation result """ - self.parent = parent + super().__init__(parent, **kwargs) self.value_type = value_type self.min_value = min_value self.max_value = max_value @@ -54,13 +55,12 @@ def __init__( self.on_validate = on_validate # Create frame to hold entry and error label - self.frame = ttk.Frame(parent) # Create or use provided StringVar self.variable = variable if variable is not None else tk.StringVar(value="") # Create entry widget - self.entry = ttk.Entry(self.frame, textvariable=self.variable, width=width) + self.entry = ttk.Entry(self, textvariable=self.variable, width=width) self.entry.pack(side="left", fill="x", expand=True) # Bind validation events @@ -68,20 +68,20 @@ def __init__( self.entry.bind("", lambda _: self.validate()) # Create error label - self.error_label = ttk.Label(self.frame, text="", style="Error.TLabel") + self.error_label = ttk.Label(self, text="", style="Error.TLabel") self.error_label.pack(side="left", padx=(10, 0)) def pack(self, **kwargs) -> None: """Pack the entry box frame.""" - self.frame.pack(**kwargs) + self.pack(**kwargs) def grid(self, **kwargs) -> None: """Grid the entry box frame.""" - self.frame.grid(**kwargs) + self.grid(**kwargs) def place(self, **kwargs) -> None: """Place the entry box frame.""" - self.frame.place(**kwargs) + self.place(**kwargs) def get(self) -> str: """Get the current value as a string.""" From a1b97d9ec5b33c3d12a4c4ab065aa76d6ed12e24 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Mon, 23 Feb 2026 11:30:23 +0000 Subject: [PATCH 06/28] improved theme methodology --- .../python_toolkit/bhom/bhom_dark_theme.tcl | 683 ++++++++++++++++++ .../python_toolkit/bhom/bhom_light_theme.tcl | 679 +++++++++++++++++ .../src/python_toolkit/tkinter/DefaultRoot.py | 175 ++--- 3 files changed, 1442 insertions(+), 95 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl new file mode 100644 index 0000000..1654dd5 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl @@ -0,0 +1,683 @@ +# Dark and Light Mode Theme for Python Toolkit +# Professional themes inspired by modern IDEs +# Font: Segoe UI, Roboto, Helvetica, Arial, sans-serif + +# ============================================================================ +# DARK MODE THEME +# ============================================================================ + +# Split theme file generated from bhom_style.tcl +namespace eval ttk::theme::bhom_dark { + variable colors + array set colors { + -bg "#1e1e1e" + -fg "#ffffff" + -dark "#2d2d2d" + -darker "#252526" + -selectbg "#1b6ec2" + -selectfg "#ffffff" + -primary "#1b6ec2" + -primary-hover "#1861ac" + -primary-light "#258cfb" + -secondary "#ff4081" + -secondary-hover "#ff1f69" + -tertiary "#c4d600" + -info "#006bb7" + -success "#26b050" + -warning "#eb671c" + -error "#e50000" + -border "#3d3d3d" + -border-light "#555555" + -disabled-bg "#2d2d2d" + -disabled-fg "#666666" + -inputbg "#2d2d2d" + -inputfg "#ffffff" + -hover-bg "#2a2d2e" + -active-bg "#383838" + -text-secondary "#999999" + } + + ttk::style theme create bhom_dark -parent clam -settings { + # General font settings - Segoe UI for Windows, fallback to Roboto/Helvetica + ttk::style configure . \ + -font {{Segoe UI} 10} \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border) \ + -darkcolor $colors(-border) \ + -lightcolor $colors(-border) \ + -troughcolor $colors(-darker) \ + -focuscolor $colors(-primary) \ + -selectbackground $colors(-selectbg) \ + -selectforeground $colors(-selectfg) \ + -selectborderwidth 0 \ + -insertwidth 1 \ + -insertcolor $colors(-primary) \ + -relief flat + + # Frame + ttk::style configure TFrame \ + -background $colors(-bg) \ + -borderwidth 0 \ + -relief flat + + ttk::style configure Card.TFrame \ + -background $colors(-dark) \ + -borderwidth 2 \ + -relief groove \ + -bordercolor $colors(-border-light) + + # Label - Extended dynamic typography system + ttk::style configure TLabel \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {8 6} + + ttk::style configure Display.TLabel \ + -font {{Segoe UI} 28 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure LargeTitle.TLabel \ + -font {{Segoe UI} 24 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Title.TLabel \ + -font {{Segoe UI} 24 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Headline.TLabel \ + -font {{Segoe UI} 16 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Subtitle.TLabel \ + -font {{Segoe UI} 14 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Heading.TLabel \ + -font {{Segoe UI} 12 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Body.TLabel \ + -font {{Segoe UI} 10} \ + -foreground $colors(-fg) \ + -padding {6 4} + + ttk::style configure Caption.TLabel \ + -font {{Segoe UI} 9} \ + -foreground $colors(-text-secondary) \ + -padding {6 4} + + ttk::style configure Small.TLabel \ + -font {{Segoe UI} 8} \ + -foreground $colors(-text-secondary) \ + -padding {4 2} + + ttk::style configure Success.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-success) + + ttk::style configure Warning.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-warning) + + ttk::style configure Error.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-error) + + ttk::style configure Info.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-info) + + # Button - soft rounded design + ttk::style configure TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-active-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -focuscolor "" \ + -padding {16 8} \ + -relief raised + + # Large Button variant + ttk::style configure Large.TButton \ + -font {{Segoe UI} 12 bold} \ + -padding {20 12} \ + -borderwidth 2 + + # Small Button variant + ttk::style configure Small.TButton \ + -font {{Segoe UI} 8 bold} \ + -padding {12 6} \ + -borderwidth 2 + + ttk::style map TButton \ + -background [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + active $colors(-fg) \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + active $colors(-primary-hover) \ + disabled $colors(-border)] \ + -lightcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg)] \ + -darkcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg)] \ + -relief [list \ + pressed sunken] + + # Primary Button - accent color with soft rounded edges + ttk::style configure Primary.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-primary) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-primary-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Primary.TButton \ + -background [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-bg)] \ + -lightcolor [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -darkcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover)] \ + -relief [list \ + pressed sunken] + + # Secondary Button - soft rounded edges + ttk::style configure Secondary.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-secondary) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-secondary) \ + -lightcolor $colors(-secondary) \ + -darkcolor $colors(-secondary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Secondary.TButton \ + -background [list \ + active $colors(-secondary-hover) \ + pressed $colors(-secondary-hover) \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Accent Button - lime green from app.css + ttk::style configure Accent.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-tertiary) \ + -foreground "#000000" \ + -bordercolor $colors(-tertiary) \ + -lightcolor $colors(-tertiary) \ + -darkcolor "#9fad00" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Accent.TButton \ + -background [list \ + active "#9fad00" \ + pressed "#9fad00" \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + + # Success Button - green from app.css + ttk::style configure Success.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-success) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-success) \ + -lightcolor $colors(-success) \ + -darkcolor "#1e9038" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Success.TButton \ + -background [list \ + active "#1e9038" \ + pressed "#1e9038" \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Link Button - blue link from app.css + ttk::style configure Link.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-info) \ + -borderwidth 0 \ + -padding {14 8} \ + -relief flat + + ttk::style map Link.TButton \ + -foreground [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-fg)] + + # Outline Button - soft rounded with bold font + ttk::style configure Outline.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -bordercolor $colors(-primary) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Outline.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -bordercolor [list \ + active $colors(-primary-light) \ + disabled $colors(-border)] \ + -relief [list \ + pressed sunken] + + # Text Button - bold font with padding + ttk::style configure Text.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -borderwidth 0 \ + -padding {14 8} + + ttk::style map Text.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] + + # Entry - soft rounded design with subtle depth + ttk::style configure TEntry \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border) \ + -darkcolor $colors(-hover-bg) \ + -insertcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TEntry \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary) \ + invalid $colors(-error)] + + # Combobox - soft rounded design + ttk::style configure TCombobox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TCombobox \ + -fieldbackground [list \ + readonly $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] \ + -arrowcolor [list \ + disabled $colors(-disabled-fg)] + + # Checkbutton - bold font + ttk::style configure TCheckbutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TCheckbutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Radiobutton - sleek hover effect with bold font + ttk::style configure TRadiobutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TRadiobutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + selected $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Scrollbar - minimal sleek design without arrows + ttk::style configure TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -relief flat \ + -width 10 + + ttk::style map TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Vertical.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Vertical.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Horizontal.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Horizontal.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + # Scale - minimal sleek design + ttk::style configure TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-bg) \ + -bordercolor $colors(-bg) \ + -slidercolor $colors(-primary) \ + -borderwidth 0 \ + -sliderrelief flat + + ttk::style map TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] + + # Progressbar - soft rounded design + ttk::style configure TProgressbar \ + -background $colors(-primary) \ + -troughcolor $colors(-darker) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -thickness 24 \ + -relief raised + + # Notebook - soft rounded tabs + ttk::style configure TNotebook \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -tabmargins {2 5 2 0} \ + -borderwidth 2 + + ttk::style configure TNotebook.Tab \ + -background $colors(-dark) \ + -foreground $colors(-text-secondary) \ + -bordercolor $colors(-border-light) \ + -font {{Segoe UI} 11 bold} \ + -padding {18 10} \ + -borderwidth 2 + + ttk::style map TNotebook.Tab \ + -background [list \ + selected $colors(-bg) \ + active $colors(-hover-bg)] \ + -foreground [list \ + selected $colors(-primary) \ + active $colors(-fg)] \ + -expand [list \ + selected {2 2 2 0}] + + # Treeview - soft design with bold headings + ttk::style configure Treeview \ + -background $colors(-inputbg) \ + -foreground $colors(-fg) \ + -fieldbackground $colors(-inputbg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border-light) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -rowheight 32 \ + -padding {6 4} + + ttk::style map Treeview \ + -background [list selected $colors(-primary)] \ + -foreground [list selected $colors(-selectfg)] + + ttk::style configure Treeview.Heading \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -relief raised \ + -padding {10 8} \ + -font {{Segoe UI} 11 bold} + + ttk::style map Treeview.Heading \ + -background [list active $colors(-hover-bg)] \ + -relief [list pressed sunken] + + # Separator + ttk::style configure TSeparator \ + -background $colors(-border) + + ttk::style configure Horizontal.TSeparator \ + -background $colors(-border) + + ttk::style configure Vertical.TSeparator \ + -background $colors(-border) + + # Labelframe - soft rounded design with depth + ttk::style configure TLabelframe \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -relief groove \ + -padding {16 12} + + ttk::style configure TLabelframe.Label \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 12 bold} \ + -padding {10 -8} + + # Panedwindow + ttk::style configure TPanedwindow \ + -background $colors(-bg) + + ttk::style configure Sash \ + -sashthickness 8 \ + -gripcount 0 \ + -background $colors(-border) + + # Sizegrip + ttk::style configure TSizegrip \ + -background $colors(-bg) + + # Spinbox - soft rounded design + ttk::style configure TSpinbox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TSpinbox \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] + + # Menubutton - soft rounded design + ttk::style configure TMenubutton \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {14 8} \ + -borderwidth 2 \ + -relief raised + + ttk::style map TMenubutton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + } +} + +# ============================================================================ +# LIGHT MODE THEME +# ============================================================================ + +# Set default options for tk widgets (non-ttk) +option add *Background "#1e1e1e" +option add *Foreground "#ffffff" +option add *Font {{Segoe UI} 10 bold} +option add *selectBackground "#1b6ec2" +option add *selectForeground "#ffffff" +option add *activeBackground "#2a2d2e" +option add *activeForeground "#ffffff" +option add *highlightColor "#1b6ec2" +option add *highlightBackground "#1e1e1e" +option add *disabledForeground "#666666" +option add *insertBackground "#ffffff" +option add *troughColor "#2d2d2d" +option add *borderWidth 1 +option add *relief flat + +# Listbox specific - matches design theme +option add *Listbox.background "#2d2d2d" +option add *Listbox.foreground "#ffffff" +option add *Listbox.selectBackground "#1b6ec2" +option add *Listbox.selectForeground "#ffffff" +option add *Listbox.font {{Segoe UI} 10} +option add *Listbox.borderWidth 1 +option add *Listbox.relief flat +option add *Listbox.highlightThickness 1 +option add *Listbox.highlightColor "#3d3d3d" +option add *Listbox.highlightBackground "#3d3d3d" + +# Text widget specific +option add *Text.background "#2d2d2d" +option add *Text.foreground "#ffffff" +option add *Text.insertBackground "#ffffff" +option add *Text.selectBackground "#1b6ec2" +option add *Text.selectForeground "#ffffff" +option add *Text.font {{Segoe UI} 10} +option add *Text.borderWidth 1 +option add *Text.relief flat +option add *Text.highlightThickness 1 +option add *Text.highlightColor "#1b6ec2" + +# Canvas specific +option add *Canvas.background "#1e1e1e" +option add *Canvas.highlightThickness 0 + +# Menu specific +option add *Menu.background "#2d2d2d" +option add *Menu.foreground "#ffffff" +option add *Menu.activeBackground "#1b6ec2" +option add *Menu.activeForeground "#ffffff" +option add *Menu.activeBorderWidth 0 +option add *Menu.borderWidth 1 +option add *Menu.relief flat +option add *Menu.font {{Segoe UI} 10} + +# Toplevel/window specific +option add *Toplevel.background "#1e1e1e" + +# Message widget +option add *Message.background "#1e1e1e" +option add *Message.foreground "#ffffff" +option add *Message.font {{Segoe UI} 10} diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl new file mode 100644 index 0000000..e436e0b --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl @@ -0,0 +1,679 @@ +# Dark and Light Mode Theme for Python Toolkit +# Professional themes inspired by modern IDEs +# Font: Segoe UI, Roboto, Helvetica, Arial, sans-serif + +# ============================================================================ +# DARK MODE THEME +# ============================================================================ + +# Split theme file generated from bhom_style.tcl +namespace eval ttk::theme::bhom_light { + variable colors + array set colors { + -bg "#ffffff" + -fg "#1a1a1a" + -dark "#f3f3f3" + -darker "#ececec" + -selectbg "#1b6ec2" + -selectfg "#ffffff" + -primary "#1b6ec2" + -primary-hover "#1861ac" + -primary-light "#258cfb" + -secondary "#ff4081" + -secondary-hover "#ff1f69" + -tertiary "#a4a900" + -info "#006bb7" + -success "#26b050" + -warning "#eb671c" + -error "#e50000" + -border "#d0d0d0" + -border-light "#e5e5e5" + -disabled-bg "#f3f3f3" + -disabled-fg "#999999" + -inputbg "#ffffff" + -inputfg "#1a1a1a" + -hover-bg "#f5f5f5" + -active-bg "#ececec" + -text-secondary "#666666" + } + + ttk::style theme create bhom_light -parent clam -settings { + # General font settings - Segoe UI for Windows, fallback to Roboto/Helvetica + ttk::style configure . \ + -font {{Segoe UI} 10} \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border) \ + -darkcolor $colors(-border) \ + -lightcolor $colors(-border) \ + -troughcolor $colors(-darker) \ + -focuscolor $colors(-primary) \ + -selectbackground $colors(-selectbg) \ + -selectforeground $colors(-selectfg) \ + -selectborderwidth 0 \ + -insertwidth 1 \ + -insertcolor $colors(-primary) \ + -relief flat + + # Frame + ttk::style configure TFrame \ + -background $colors(-bg) \ + -borderwidth 0 \ + -relief flat + + ttk::style configure Card.TFrame \ + -background $colors(-dark) \ + -borderwidth 2 \ + -relief groove \ + -bordercolor $colors(-border-light) + + # Label - Extended dynamic typography system + ttk::style configure TLabel \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {8 6} + + ttk::style configure Display.TLabel \ + -font {{Segoe UI} 28 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure LargeTitle.TLabel \ + -font {{Segoe UI} 24 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Title.TLabel \ + -font {{Segoe UI} 24 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Headline.TLabel \ + -font {{Segoe UI} 16 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Subtitle.TLabel \ + -font {{Segoe UI} 14 bold} \ + -foreground $colors(-fg) \ + -padding {0 0} + + ttk::style configure Heading.TLabel \ + -font {{Segoe UI} 12 bold} \ + -foreground $colors(-primary) \ + -padding {0 0} + + ttk::style configure Body.TLabel \ + -font {{Segoe UI} 10} \ + -foreground $colors(-fg) \ + -padding {6 4} + + ttk::style configure Caption.TLabel \ + -font {{Segoe UI} 9} \ + -foreground $colors(-text-secondary) \ + -padding {6 4} + + ttk::style configure Small.TLabel \ + -font {{Segoe UI} 8} \ + -foreground $colors(-text-secondary) \ + -padding {4 2} + + ttk::style configure Success.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-success) + + ttk::style configure Warning.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-warning) + + ttk::style configure Error.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-error) + + ttk::style configure Info.TLabel \ + -font {{Segoe UI} 10 bold} \ + -foreground $colors(-info) + + # Button - soft rounded design + ttk::style configure TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-active-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -focuscolor "" \ + -padding {16 8} \ + -relief raised + + # Large Button variant + ttk::style configure Large.TButton \ + -font {{Segoe UI} 12 bold} \ + -padding {20 12} \ + -borderwidth 2 + + # Small Button variant + ttk::style configure Small.TButton \ + -font {{Segoe UI} 8 bold} \ + -padding {12 6} \ + -borderwidth 2 + + ttk::style map TButton \ + -background [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + active $colors(-fg) \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + active $colors(-primary-hover) \ + disabled $colors(-border)] \ + -lightcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg)] \ + -darkcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-active-bg)] \ + -relief [list \ + pressed sunken] + + # Primary Button - accent color with soft rounded edges + ttk::style configure Primary.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-primary) \ + -foreground "#ffffff" \ + -bordercolor $colors(-primary-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Primary.TButton \ + -background [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-bg)] \ + -lightcolor [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -darkcolor [list \ + active $colors(-primary-hover) \ + pressed $colors(-primary-hover)] \ + -relief [list \ + pressed sunken] + + # Secondary Button - soft rounded edges + ttk::style configure Secondary.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-secondary) \ + -foreground "#ffffff" \ + -bordercolor $colors(-secondary) \ + -lightcolor $colors(-secondary) \ + -darkcolor $colors(-secondary-hover) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Secondary.TButton \ + -background [list \ + active $colors(-secondary-hover) \ + pressed $colors(-secondary-hover) \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Accent Button - muted yellow for light mode + ttk::style configure Accent.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-tertiary) \ + -foreground "#ffffff" \ + -bordercolor $colors(-tertiary) \ + -lightcolor $colors(-tertiary) \ + -darkcolor "#8a8a00" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Accent.TButton \ + -background [list \ + active "#8a8a00" \ + pressed "#8a8a00" \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + + # Success Button - green from app.css + ttk::style configure Success.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-success) \ + -foreground "#ffffff" \ + -bordercolor $colors(-success) \ + -lightcolor $colors(-success) \ + -darkcolor "#1e9038" \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Success.TButton \ + -background [list \ + active "#1e9038" \ + pressed "#1e9038" \ + disabled $colors(-disabled-bg)] \ + -relief [list \ + pressed sunken] + + # Link Button - blue link from app.css + ttk::style configure Link.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-info) \ + -borderwidth 0 \ + -padding {14 8} \ + -relief flat + + ttk::style map Link.TButton \ + -foreground [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover) \ + disabled $colors(-disabled-fg)] + + # Outline Button - soft rounded with bold font + ttk::style configure Outline.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -bordercolor $colors(-primary) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -padding {16 8} \ + -relief raised + + ttk::style map Outline.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -bordercolor [list \ + active $colors(-primary-light) \ + disabled $colors(-border)] \ + -relief [list \ + pressed sunken] + + # Text Button - bold font with padding + ttk::style configure Text.TButton \ + -font {{Segoe UI} 10 bold} \ + -background $colors(-bg) \ + -foreground $colors(-primary) \ + -borderwidth 0 \ + -padding {14 8} + + ttk::style map Text.TButton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] + + # Entry - soft rounded design with subtle depth + ttk::style configure TEntry \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border) \ + -darkcolor $colors(-hover-bg) \ + -insertcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TEntry \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary) \ + invalid $colors(-error)] + + # Combobox - soft rounded design + ttk::style configure TCombobox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TCombobox \ + -fieldbackground [list \ + readonly $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] \ + -arrowcolor [list \ + disabled $colors(-disabled-fg)] + + # Checkbutton - bold font + ttk::style configure TCheckbutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TCheckbutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Radiobutton - sleek hover effect with bold font + ttk::style configure TRadiobutton \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 10 bold} \ + -padding {10 6} \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) \ + -indicatormargin {0 0 10 0} \ + -borderwidth 0 \ + -relief flat + + ttk::style map TRadiobutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + selected $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-hover-bg) \ + disabled $colors(-disabled-bg)] + + # Scrollbar - minimal sleek design without arrows + ttk::style configure TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -relief flat \ + -width 10 + + ttk::style map TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Vertical.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Vertical.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + ttk::style configure Horizontal.TScrollbar \ + -background $colors(-border) \ + -bordercolor $colors(-bg) \ + -troughcolor $colors(-bg) \ + -arrowsize 0 \ + -borderwidth 0 \ + -width 10 + + ttk::style map Horizontal.TScrollbar \ + -background [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + + # Scale - minimal sleek design + ttk::style configure TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-bg) \ + -bordercolor $colors(-bg) \ + -slidercolor $colors(-primary) \ + -borderwidth 0 \ + -sliderrelief flat + + ttk::style map TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] + + # Progressbar - soft rounded design + ttk::style configure TProgressbar \ + -background $colors(-primary) \ + -troughcolor $colors(-darker) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-primary-light) \ + -darkcolor $colors(-primary-hover) \ + -borderwidth 2 \ + -thickness 24 \ + -relief raised + + # Notebook - soft rounded tabs + ttk::style configure TNotebook \ + -background $colors(-bg) \ + -bordercolor $colors(-border-light) \ + -tabmargins {2 5 2 0} \ + -borderwidth 2 + + ttk::style configure TNotebook.Tab \ + -background $colors(-dark) \ + -foreground $colors(-text-secondary) \ + -bordercolor $colors(-border-light) \ + -font {{Segoe UI} 11 bold} \ + -padding {18 10} \ + -borderwidth 2 + + ttk::style map TNotebook.Tab \ + -background [list \ + selected $colors(-bg) \ + active $colors(-hover-bg)] \ + -foreground [list \ + selected $colors(-primary) \ + active $colors(-fg)] \ + -expand [list \ + selected {2 2 2 0}] + + # Treeview - soft design with bold headings + ttk::style configure Treeview \ + -background $colors(-inputbg) \ + -foreground $colors(-fg) \ + -fieldbackground $colors(-inputbg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-border-light) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -rowheight 32 \ + -padding {6 4} + + ttk::style map Treeview \ + -background [list selected $colors(-primary)] \ + -foreground [list selected $colors(-selectfg)] + + ttk::style configure Treeview.Heading \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -relief raised \ + -padding {10 8} \ + -font {{Segoe UI} 11 bold} + + ttk::style map Treeview.Heading \ + -background [list active $colors(-hover-bg)] \ + -relief [list pressed sunken] + + # Separator + ttk::style configure TSeparator \ + -background $colors(-border) + + ttk::style configure Horizontal.TSeparator \ + -background $colors(-border) + + ttk::style configure Vertical.TSeparator \ + -background $colors(-border) + + # Labelframe - soft rounded design with depth + ttk::style configure TLabelframe \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -lightcolor $colors(-hover-bg) \ + -darkcolor $colors(-border) \ + -borderwidth 2 \ + -relief groove \ + -padding {16 12} + + ttk::style configure TLabelframe.Label \ + -background $colors(-bg) \ + -foreground $colors(-fg) \ + -font {{Segoe UI} 12 bold} \ + -padding {10 -8} + + # Panedwindow + ttk::style configure TPanedwindow \ + -background $colors(-bg) + + ttk::style configure Sash \ + -sashthickness 8 \ + -gripcount 0 \ + -background $colors(-border) + + # Sizegrip + ttk::style configure TSizegrip \ + -background $colors(-bg) + + # Spinbox - soft rounded design + ttk::style configure TSpinbox \ + -fieldbackground $colors(-inputbg) \ + -foreground $colors(-inputfg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {10 8} \ + -borderwidth 2 \ + -relief sunken + + ttk::style map TSpinbox \ + -fieldbackground [list \ + readonly $colors(-disabled-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -bordercolor [list \ + focus $colors(-primary)] + + # Menubutton - soft rounded design + ttk::style configure TMenubutton \ + -background $colors(-dark) \ + -foreground $colors(-fg) \ + -bordercolor $colors(-border-light) \ + -arrowcolor $colors(-fg) \ + -padding {14 8} \ + -borderwidth 2 \ + -relief raised + + ttk::style map TMenubutton \ + -background [list \ + active $colors(-hover-bg) \ + pressed $colors(-active-bg) \ + disabled $colors(-disabled-bg)] \ + -foreground [list \ + disabled $colors(-disabled-fg)] \ + -relief [list \ + pressed sunken] + } +} + +# Set default options for tk widgets (non-ttk) +option add *Background "#1e1e1e" +option add *Foreground "#ffffff" +option add *Font {{Segoe UI} 10 bold} +option add *selectBackground "#1b6ec2" +option add *selectForeground "#ffffff" +option add *activeBackground "#2a2d2e" +option add *activeForeground "#ffffff" +option add *highlightColor "#1b6ec2" +option add *highlightBackground "#1e1e1e" +option add *disabledForeground "#666666" +option add *insertBackground "#ffffff" +option add *troughColor "#2d2d2d" +option add *borderWidth 1 +option add *relief flat + +# Listbox specific - matches design theme +option add *Listbox.background "#2d2d2d" +option add *Listbox.foreground "#ffffff" +option add *Listbox.selectBackground "#1b6ec2" +option add *Listbox.selectForeground "#ffffff" +option add *Listbox.font {{Segoe UI} 10} +option add *Listbox.borderWidth 1 +option add *Listbox.relief flat +option add *Listbox.highlightThickness 1 +option add *Listbox.highlightColor "#3d3d3d" +option add *Listbox.highlightBackground "#3d3d3d" + +# Text widget specific +option add *Text.background "#2d2d2d" +option add *Text.foreground "#ffffff" +option add *Text.insertBackground "#ffffff" +option add *Text.selectBackground "#1b6ec2" +option add *Text.selectForeground "#ffffff" +option add *Text.font {{Segoe UI} 10} +option add *Text.borderWidth 1 +option add *Text.relief flat +option add *Text.highlightThickness 1 +option add *Text.highlightColor "#1b6ec2" + +# Canvas specific +option add *Canvas.background "#1e1e1e" +option add *Canvas.highlightThickness 0 + +# Menu specific +option add *Menu.background "#2d2d2d" +option add *Menu.foreground "#ffffff" +option add *Menu.activeBackground "#1b6ec2" +option add *Menu.activeForeground "#ffffff" +option add *Menu.activeBorderWidth 0 +option add *Menu.borderWidth 1 +option add *Menu.relief flat +option add *Menu.font {{Segoe UI} 10} + +# Toplevel/window specific +option add *Toplevel.background "#1e1e1e" + +# Message widget +option add *Message.background "#1e1e1e" +option add *Message.foreground "#ffffff" +option add *Message.font {{Segoe UI} 10} diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py b/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py index 5c5cca0..f1d6eb9 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py @@ -2,14 +2,10 @@ from tkinter import ttk from pathlib import Path from typing import Optional, Callable, Literal - -try: - import darkdetect -except ImportError: - darkdetect = None +import darkdetect +import platform import ctypes - class DefaultRoot(tk.Tk): """ A reusable default root window template for tkinter applications. @@ -19,8 +15,10 @@ class DefaultRoot(tk.Tk): def __init__( self, title: str = "Application", - logo_path: Optional[Path] = None, - icon_path: Optional[Path] = None, + logo_path: Optional[Path] = Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\BHoM_Logo.png"), + icon_path: Optional[Path] = Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\bhom_icon.png"), + dark_logo_path: Optional[Path] = None, + dark_icon_path: Optional[Path] = None, min_width: int = 500, min_height: int = 400, width: Optional[int] = None, @@ -34,8 +32,10 @@ def __init__( close_text: str = "Close", close_command: Optional[Callable] = None, on_close_window: Optional[Callable] = None, - theme_path: Optional[Path] = None, + theme_path: Optional[Path] = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_light_theme.tcl"), + theme_path_dark: Optional[Path] = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_dark_theme.tcl"), theme_mode: Literal["light", "dark", "auto"] = "auto", + **kwargs ): """ Initialize the default root window. @@ -59,22 +59,23 @@ 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"). + **kwargs """ - super().__init__() + super().__init__(**kwargs) self.title(title) self._icon_image = None - self._set_window_icon(icon_path) self.minsize(min_width, min_height) self.resizable(resizable, resizable) # Hide window during setup to prevent flash self.withdraw() - # Determine theme based on mode and system preference - theme_name = self._determine_theme_name(theme_mode) - - # Load custom dark theme - self.theme_name = self._load_theme(theme_path, theme_name) + # Load custom themes + _theme_path, _icon_path, _logo_path, _theme_style = self._determine_theme(logo_path, dark_logo_path, icon_path, dark_icon_path, theme_mode, theme_path, theme_path_dark) + + self._set_window_icon(_icon_path) + self.theme = self._load_theme(_theme_path) + self.titlebar_theme = self._set_titlebar_theme(_theme_style) self.min_width = min_width self.min_height = min_height @@ -93,7 +94,7 @@ def __init__( main_container.pack(fill=tk.BOTH, expand=True) # Banner section - self._build_banner(main_container, title, logo_path) + self._build_banner(main_container, title, _logo_path) # Content area (public access for adding widgets) self.content_frame = ttk.Frame(main_container, padding=20) @@ -106,85 +107,73 @@ def __init__( # Apply sizing self._apply_sizing() - def _set_window_icon(self, icon_path: Optional[Path]) -> None: - """Set a custom window icon, replacing the default Tk icon when possible.""" - if icon_path is None: - return + def _set_window_icon(self, icon_path: Path) -> None: + """Set a custom window icon, replacing the default uk icon.""" - path = Path(icon_path) - if not path.exists(): - print(f"Warning: Icon file not found at {path}") + if not icon_path.exists(): + print(f"Warning: Icon file not found at {icon_path}") return # Windows prefers .ico for titlebar/taskbar icons. - if path.suffix.lower() == ".ico": + if icon_path.suffix.lower() == ".ico": try: - self.iconbitmap(default=str(path)) + self.iconbitmap(default=str(icon_path)) return except tk.TclError: pass # Fallback for image formats supported by Tk PhotoImage (png/gif/etc.). try: - self._icon_image = tk.PhotoImage(file=str(path)) + self._icon_image = tk.PhotoImage(file=str(icon_path)) self.iconphoto(True, self._icon_image) return except tk.TclError: pass - # Final fallback using PIL if available. - try: - from PIL import Image, ImageTk - - img = Image.open(path) - self._icon_image = ImageTk.PhotoImage(img) - self.iconphoto(True, self._icon_image) except Exception as ex: - print(f"Warning: Could not set window icon from {path}: {ex}") + print(f"Warning: Could not set window icon from {icon_path}: {ex}") + + def _determine_theme( + self, + logo_path: Optional[Path], + dark_logo_path: Optional[Path], + icon_path: Optional[Path], + dark_icon_path: Optional[Path], + theme_mode: str, + theme_path_light: Optional[Path], + theme_path_dark: Optional[Path]) -> tuple[Path|None, Path|None, Path|None, str]: + + """determin the light or dark mode usage""" - def _determine_theme_name(self, theme_mode: str) -> str: - """ - Determine the theme name based on the specified mode and system preference. + if theme_mode == "light": + return theme_path_light, logo_path, icon_path, "light" - Args: - theme_mode (str): "light", "dark", or "auto" - - Returns: - str: Theme name ("bhom_light" or "bhom_dark") - """ - if theme_mode == "auto": - # Try to detect system theme - if darkdetect is not None: - try: - is_dark = darkdetect.is_dark() - return "bhom_dark" if is_dark else "bhom_light" - except Exception: - # Fall back to dark if detection fails - return "bhom_dark" - else: - # Default to dark if darkdetect not available - return "bhom_dark" - elif theme_mode == "light": - return "bhom_light" - else: # "dark" - return "bhom_dark" - - def _set_titlebar_theme(self, theme_name: str) -> None: + if dark_logo_path is None: + dark_logo_path = logo_path + if dark_icon_path is None: + dark_icon_path = icon_path + + if theme_mode == "dark": + return theme_path_dark, dark_logo_path, dark_icon_path, "dark" + + #case == auto - detect system theme preference + if darkdetect.isDark(): + return theme_path_dark, dark_logo_path, dark_icon_path, "dark" + else: + return theme_path_light, logo_path, icon_path, "light" + + def _set_titlebar_theme(self, theme_style: str) -> None: """ Apply titlebar theme using Windows API. - - Args: - theme_name (str): Theme name ("bhom_light" or "bhom_dark") """ + try: - import platform if platform.system() == "Windows" and ctypes is not None and self.winfo_exists(): hwnd = self.winfo_id() hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) if hwnd: DWMWA_USE_IMMERSIVE_DARK_MODE = 20 - use_dark = 1 if theme_name == "bhom_dark" else 0 - print(f"Applying titlebar theme: {theme_name} (dark={use_dark})") + use_dark = 1 if theme_style == "dark" else 0 ctypes.windll.dwmapi.DwmSetWindowAttribute( hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(ctypes.c_int(use_dark)), @@ -193,16 +182,7 @@ def _set_titlebar_theme(self, theme_name: str) -> None: except Exception: pass - def _apply_titlebar_theme(self, theme_name: str) -> None: - """ - Apply Windows titlebar theme using DWM API (deferred version). - - Args: - theme_name (str): Theme name ("bhom_light" or "bhom_dark") - """ - self._set_titlebar_theme(theme_name) - - def _load_theme(self, custom_theme_path: Optional[Path] = None, theme_name: str = "bhom_dark") -> str: + def _load_theme(self, _theme_path: Path) -> str: """ Load a custom theme from a TCL file. @@ -216,20 +196,10 @@ def _load_theme(self, custom_theme_path: Optional[Path] = None, theme_name: str """ style = ttk.Style() - def _pick_theme_name(available_theme_names: tuple) -> str: - if theme_name in available_theme_names: - return theme_name - - for candidate in ("bhom_dark", "bhom_light"): - if candidate in available_theme_names: - return candidate - - return style.theme_use() - try: # Determine which theme file to use - if custom_theme_path and custom_theme_path.exists(): - theme_path = custom_theme_path + if _theme_path and _theme_path.exists(): + theme_path = _theme_path else: # Use default theme theme_path = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_style.tcl") @@ -239,21 +209,22 @@ def _pick_theme_name(available_theme_names: tuple) -> str: self.tk.call('source', str(theme_path)) available_theme_names = style.theme_names() - selected_theme = _pick_theme_name(available_theme_names) + selected_theme = available_theme_names[0] if available_theme_names else "default" style.theme_use(selected_theme) return selected_theme else: print(f"Warning: Theme file not found at {theme_path}") available_theme_names = style.theme_names() - selected_theme = _pick_theme_name(available_theme_names) + selected_theme = available_theme_names[0] if available_theme_names else "default" style.theme_use(selected_theme) return selected_theme + except Exception as e: print(f"Warning: Could not load custom theme: {e}") try: return style.theme_use() except Exception: - return theme_name + return "default" def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path]) -> None: """Build the branded banner section.""" @@ -387,7 +358,7 @@ def _exit(self, result: str, callback: Optional[Callable] = None) -> None: except Exception as ex: print(f"Warning: Exit callback raised an exception: {ex}") finally: - self._destroy_root() + self.destroy_root() def _on_submit(self) -> None: """Handle submit button click.""" @@ -406,11 +377,22 @@ def run(self) -> Optional[str]: try: self.mainloop() finally: - self._destroy_root() + self.destroy_root() return self.result if __name__ == "__main__": + + + ### TEST SIMPLE + + test = DefaultRoot( + title="Test Window", + theme_mode="auto", + ) + + + """ from widgets.PathSelector import PathSelector from widgets.RadioSelection import RadioSelection from widgets.ValidatedEntryBox import ValidatedEntryBox @@ -511,3 +493,6 @@ def on_close(): print(f"\nWindow result: {result}") if result == "submit": print("Final form data:", form_data) + + + """ From 0dbb249131779823d7b511814f75c8564a83b3df Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Mon, 23 Feb 2026 14:34:48 +0000 Subject: [PATCH 07/28] Improved default root use set all sub windows to inherit as type defaultroot, set all widgets to be frame object, with a parent frame to be packed into (expected default root content frame) --- .../src/python_toolkit/bhom/bhom_style.tcl | 1289 ----------------- .../src/python_toolkit/tkinter/DefaultRoot.py | 77 +- .../tkinter/DirectoryFileSelector.py | 28 +- .../src/python_toolkit/tkinter/LandingPage.py | 23 +- .../tkinter/ProcessingWindow.py | 36 +- .../tkinter/widgets/Calender.py | 30 +- .../tkinter/widgets/CmapSelector.py | 12 +- .../tkinter/widgets/FigureContainer.py | 85 +- .../python_toolkit/tkinter/widgets/ListBox.py | 24 +- .../tkinter/widgets/PathSelector.py | 28 +- .../tkinter/widgets/RadioSelection.py | 36 +- .../tkinter/widgets/ValidatedEntryBox.py | 60 +- 12 files changed, 280 insertions(+), 1448 deletions(-) delete mode 100644 Python_Engine/Python/src/python_toolkit/bhom/bhom_style.tcl diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_style.tcl b/Python_Engine/Python/src/python_toolkit/bhom/bhom_style.tcl deleted file mode 100644 index e81d27c..0000000 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_style.tcl +++ /dev/null @@ -1,1289 +0,0 @@ -# Dark and Light Mode Theme for Python Toolkit -# Professional themes inspired by modern IDEs -# Font: Segoe UI, Roboto, Helvetica, Arial, sans-serif - -# ============================================================================ -# DARK MODE THEME -# ============================================================================ -namespace eval ttk::theme::bhom_dark { - variable colors - array set colors { - -bg "#1e1e1e" - -fg "#ffffff" - -dark "#2d2d2d" - -darker "#252526" - -selectbg "#1b6ec2" - -selectfg "#ffffff" - -primary "#1b6ec2" - -primary-hover "#1861ac" - -primary-light "#258cfb" - -secondary "#ff4081" - -secondary-hover "#ff1f69" - -tertiary "#c4d600" - -info "#006bb7" - -success "#26b050" - -warning "#eb671c" - -error "#e50000" - -border "#3d3d3d" - -border-light "#555555" - -disabled-bg "#2d2d2d" - -disabled-fg "#666666" - -inputbg "#2d2d2d" - -inputfg "#ffffff" - -hover-bg "#2a2d2e" - -active-bg "#383838" - -text-secondary "#999999" - } - - ttk::style theme create bhom_dark -parent clam -settings { - # General font settings - Segoe UI for Windows, fallback to Roboto/Helvetica - ttk::style configure . \ - -font {{Segoe UI} 10} \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-border) \ - -darkcolor $colors(-border) \ - -lightcolor $colors(-border) \ - -troughcolor $colors(-darker) \ - -focuscolor $colors(-primary) \ - -selectbackground $colors(-selectbg) \ - -selectforeground $colors(-selectfg) \ - -selectborderwidth 0 \ - -insertwidth 1 \ - -insertcolor $colors(-primary) \ - -relief flat - - # Frame - ttk::style configure TFrame \ - -background $colors(-bg) \ - -borderwidth 0 \ - -relief flat - - ttk::style configure Card.TFrame \ - -background $colors(-dark) \ - -borderwidth 2 \ - -relief groove \ - -bordercolor $colors(-border-light) - - # Label - Extended dynamic typography system - ttk::style configure TLabel \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {8 6} - - ttk::style configure Display.TLabel \ - -font {{Segoe UI} 28 bold} \ - -foreground $colors(-primary) \ - -padding {0 0} - - ttk::style configure LargeTitle.TLabel \ - -font {{Segoe UI} 24 bold} \ - -foreground $colors(-fg) \ - -padding {0 0} - - ttk::style configure Title.TLabel \ - -font {{Segoe UI} 24 bold} \ - -foreground $colors(-fg) \ - -padding {0 0} - - ttk::style configure Headline.TLabel \ - -font {{Segoe UI} 16 bold} \ - -foreground $colors(-primary) \ - -padding {0 0} - - ttk::style configure Subtitle.TLabel \ - -font {{Segoe UI} 14 bold} \ - -foreground $colors(-fg) \ - -padding {0 0} - - ttk::style configure Heading.TLabel \ - -font {{Segoe UI} 12 bold} \ - -foreground $colors(-primary) \ - -padding {0 0} - - ttk::style configure Body.TLabel \ - -font {{Segoe UI} 10} \ - -foreground $colors(-fg) \ - -padding {6 4} - - ttk::style configure Caption.TLabel \ - -font {{Segoe UI} 9} \ - -foreground $colors(-text-secondary) \ - -padding {6 4} - - ttk::style configure Small.TLabel \ - -font {{Segoe UI} 8} \ - -foreground $colors(-text-secondary) \ - -padding {4 2} - - ttk::style configure Success.TLabel \ - -font {{Segoe UI} 10 bold} \ - -foreground $colors(-success) - - ttk::style configure Warning.TLabel \ - -font {{Segoe UI} 10 bold} \ - -foreground $colors(-warning) - - ttk::style configure Error.TLabel \ - -font {{Segoe UI} 10 bold} \ - -foreground $colors(-error) - - ttk::style configure Info.TLabel \ - -font {{Segoe UI} 10 bold} \ - -foreground $colors(-info) - - # Button - soft rounded design - ttk::style configure TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-active-bg) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-border-light) \ - -lightcolor $colors(-hover-bg) \ - -darkcolor $colors(-border) \ - -borderwidth 2 \ - -focuscolor "" \ - -padding {16 8} \ - -relief raised - - # Large Button variant - ttk::style configure Large.TButton \ - -font {{Segoe UI} 12 bold} \ - -padding {20 12} \ - -borderwidth 2 - - # Small Button variant - ttk::style configure Small.TButton \ - -font {{Segoe UI} 8 bold} \ - -padding {12 6} \ - -borderwidth 2 - - ttk::style map TButton \ - -background [list \ - active $colors(-primary-hover) \ - pressed $colors(-active-bg) \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - active $colors(-fg) \ - disabled $colors(-disabled-fg)] \ - -bordercolor [list \ - active $colors(-primary-hover) \ - disabled $colors(-border)] \ - -lightcolor [list \ - active $colors(-primary-hover) \ - pressed $colors(-active-bg)] \ - -darkcolor [list \ - active $colors(-primary-hover) \ - pressed $colors(-active-bg)] \ - -relief [list \ - pressed sunken] - - # Primary Button - accent color with soft rounded edges - ttk::style configure Primary.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-primary) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-primary-light) \ - -lightcolor $colors(-primary-light) \ - -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised - - ttk::style map Primary.TButton \ - -background [list \ - active $colors(-primary-hover) \ - pressed $colors(-primary-hover) \ - disabled $colors(-disabled-bg)] \ - -lightcolor [list \ - active $colors(-primary-light) \ - pressed $colors(-primary-hover)] \ - -darkcolor [list \ - active $colors(-primary-hover) \ - pressed $colors(-primary-hover)] \ - -relief [list \ - pressed sunken] - - # Secondary Button - soft rounded edges - ttk::style configure Secondary.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-secondary) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-secondary) \ - -lightcolor $colors(-secondary) \ - -darkcolor $colors(-secondary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised - - ttk::style map Secondary.TButton \ - -background [list \ - active $colors(-secondary-hover) \ - pressed $colors(-secondary-hover) \ - disabled $colors(-disabled-bg)] \ - -relief [list \ - pressed sunken] - - # Accent Button - lime green from app.css - ttk::style configure Accent.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-tertiary) \ - -foreground "#000000" \ - -bordercolor $colors(-tertiary) \ - -lightcolor $colors(-tertiary) \ - -darkcolor "#9fad00" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised - - ttk::style map Accent.TButton \ - -background [list \ - active "#9fad00" \ - pressed "#9fad00" \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] \ - -relief [list \ - pressed sunken] - - # Success Button - green from app.css - ttk::style configure Success.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-success) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-success) \ - -lightcolor $colors(-success) \ - -darkcolor "#1e9038" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised - - ttk::style map Success.TButton \ - -background [list \ - active "#1e9038" \ - pressed "#1e9038" \ - disabled $colors(-disabled-bg)] \ - -relief [list \ - pressed sunken] - - # Link Button - blue link from app.css - ttk::style configure Link.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-bg) \ - -foreground $colors(-info) \ - -borderwidth 0 \ - -padding {14 8} \ - -relief flat - - ttk::style map Link.TButton \ - -foreground [list \ - active $colors(-primary-light) \ - pressed $colors(-primary-hover) \ - disabled $colors(-disabled-fg)] - - # Outline Button - soft rounded with bold font - ttk::style configure Outline.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-bg) \ - -foreground $colors(-primary) \ - -bordercolor $colors(-primary) \ - -lightcolor $colors(-hover-bg) \ - -darkcolor $colors(-border) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised - - ttk::style map Outline.TButton \ - -background [list \ - active $colors(-hover-bg) \ - pressed $colors(-active-bg)] \ - -bordercolor [list \ - active $colors(-primary-light) \ - disabled $colors(-border)] \ - -relief [list \ - pressed sunken] - - # Text Button - bold font with padding - ttk::style configure Text.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-bg) \ - -foreground $colors(-primary) \ - -borderwidth 0 \ - -padding {14 8} - - ttk::style map Text.TButton \ - -background [list \ - active $colors(-hover-bg) \ - pressed $colors(-active-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] - - # Entry - soft rounded design with subtle depth - ttk::style configure TEntry \ - -fieldbackground $colors(-inputbg) \ - -foreground $colors(-inputfg) \ - -bordercolor $colors(-border-light) \ - -lightcolor $colors(-border) \ - -darkcolor $colors(-hover-bg) \ - -insertcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ - -relief sunken - - ttk::style map TEntry \ - -fieldbackground [list \ - readonly $colors(-disabled-bg) \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] \ - -bordercolor [list \ - focus $colors(-primary) \ - invalid $colors(-error)] - - # Combobox - soft rounded design - ttk::style configure TCombobox \ - -fieldbackground $colors(-inputbg) \ - -foreground $colors(-inputfg) \ - -background $colors(-bg) \ - -bordercolor $colors(-border-light) \ - -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ - -relief sunken - - ttk::style map TCombobox \ - -fieldbackground [list \ - readonly $colors(-inputbg) \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] \ - -bordercolor [list \ - focus $colors(-primary)] \ - -arrowcolor [list \ - disabled $colors(-disabled-fg)] - - # Checkbutton - bold font - ttk::style configure TCheckbutton \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ - -indicatorcolor $colors(-inputbg) \ - -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ - -borderwidth 0 \ - -relief flat - - ttk::style map TCheckbutton \ - -background [list \ - active $colors(-bg) \ - selected $colors(-bg)] \ - -foreground [list \ - active $colors(-primary) \ - disabled $colors(-disabled-fg)] \ - -indicatorcolor [list \ - selected $colors(-primary) \ - active $colors(-hover-bg) \ - disabled $colors(-disabled-bg)] - - # Radiobutton - sleek hover effect with bold font - ttk::style configure TRadiobutton \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ - -indicatorcolor $colors(-inputbg) \ - -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ - -borderwidth 0 \ - -relief flat - - ttk::style map TRadiobutton \ - -background [list \ - active $colors(-bg) \ - selected $colors(-bg)] \ - -foreground [list \ - active $colors(-primary) \ - selected $colors(-primary) \ - disabled $colors(-disabled-fg)] \ - -indicatorcolor [list \ - selected $colors(-primary) \ - active $colors(-hover-bg) \ - disabled $colors(-disabled-bg)] - - # Scrollbar - minimal sleek design without arrows - ttk::style configure TScrollbar \ - -background $colors(-border) \ - -bordercolor $colors(-bg) \ - -troughcolor $colors(-bg) \ - -arrowsize 0 \ - -borderwidth 0 \ - -relief flat \ - -width 10 - - ttk::style map TScrollbar \ - -background [list \ - active $colors(-primary) \ - pressed $colors(-primary-hover)] - - ttk::style configure Vertical.TScrollbar \ - -background $colors(-border) \ - -bordercolor $colors(-bg) \ - -troughcolor $colors(-bg) \ - -arrowsize 0 \ - -borderwidth 0 \ - -width 10 - - ttk::style map Vertical.TScrollbar \ - -background [list \ - active $colors(-primary) \ - pressed $colors(-primary-hover)] - - ttk::style configure Horizontal.TScrollbar \ - -background $colors(-border) \ - -bordercolor $colors(-bg) \ - -troughcolor $colors(-bg) \ - -arrowsize 0 \ - -borderwidth 0 \ - -width 10 - - ttk::style map Horizontal.TScrollbar \ - -background [list \ - active $colors(-primary) \ - pressed $colors(-primary-hover)] - - # Scale - minimal sleek design - ttk::style configure TScale \ - -background $colors(-primary) \ - -troughcolor $colors(-bg) \ - -bordercolor $colors(-bg) \ - -slidercolor $colors(-primary) \ - -borderwidth 0 \ - -sliderrelief flat - - ttk::style map TScale \ - -background [list \ - active $colors(-primary-light) \ - pressed $colors(-primary-hover)] \ - -slidercolor [list \ - active $colors(-primary-light) \ - pressed $colors(-primary-hover)] - - # Progressbar - soft rounded design - ttk::style configure TProgressbar \ - -background $colors(-primary) \ - -troughcolor $colors(-darker) \ - -bordercolor $colors(-border-light) \ - -lightcolor $colors(-primary-light) \ - -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -thickness 24 \ - -relief raised - - # Notebook - soft rounded tabs - ttk::style configure TNotebook \ - -background $colors(-bg) \ - -bordercolor $colors(-border-light) \ - -tabmargins {2 5 2 0} \ - -borderwidth 2 - - ttk::style configure TNotebook.Tab \ - -background $colors(-dark) \ - -foreground $colors(-text-secondary) \ - -bordercolor $colors(-border-light) \ - -font {{Segoe UI} 11 bold} \ - -padding {18 10} \ - -borderwidth 2 - - ttk::style map TNotebook.Tab \ - -background [list \ - selected $colors(-bg) \ - active $colors(-hover-bg)] \ - -foreground [list \ - selected $colors(-primary) \ - active $colors(-fg)] \ - -expand [list \ - selected {2 2 2 0}] - - # Treeview - soft design with bold headings - ttk::style configure Treeview \ - -background $colors(-inputbg) \ - -foreground $colors(-fg) \ - -fieldbackground $colors(-inputbg) \ - -bordercolor $colors(-border-light) \ - -lightcolor $colors(-border-light) \ - -darkcolor $colors(-border) \ - -borderwidth 2 \ - -rowheight 32 \ - -padding {6 4} - - ttk::style map Treeview \ - -background [list selected $colors(-primary)] \ - -foreground [list selected $colors(-selectfg)] - - ttk::style configure Treeview.Heading \ - -background $colors(-dark) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-border-light) \ - -relief raised \ - -padding {10 8} \ - -font {{Segoe UI} 11 bold} - - ttk::style map Treeview.Heading \ - -background [list active $colors(-hover-bg)] \ - -relief [list pressed sunken] - - # Separator - ttk::style configure TSeparator \ - -background $colors(-border) - - ttk::style configure Horizontal.TSeparator \ - -background $colors(-border) - - ttk::style configure Vertical.TSeparator \ - -background $colors(-border) - - # Labelframe - soft rounded design with depth - ttk::style configure TLabelframe \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-border-light) \ - -lightcolor $colors(-hover-bg) \ - -darkcolor $colors(-border) \ - -borderwidth 2 \ - -relief groove \ - -padding {16 12} - - ttk::style configure TLabelframe.Label \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -font {{Segoe UI} 12 bold} \ - -padding {10 -8} - - # Panedwindow - ttk::style configure TPanedwindow \ - -background $colors(-bg) - - ttk::style configure Sash \ - -sashthickness 8 \ - -gripcount 0 \ - -background $colors(-border) - - # Sizegrip - ttk::style configure TSizegrip \ - -background $colors(-bg) - - # Spinbox - soft rounded design - ttk::style configure TSpinbox \ - -fieldbackground $colors(-inputbg) \ - -foreground $colors(-inputfg) \ - -bordercolor $colors(-border-light) \ - -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ - -relief sunken - - ttk::style map TSpinbox \ - -fieldbackground [list \ - readonly $colors(-disabled-bg) \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] \ - -bordercolor [list \ - focus $colors(-primary)] - - # Menubutton - soft rounded design - ttk::style configure TMenubutton \ - -background $colors(-dark) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-border-light) \ - -arrowcolor $colors(-fg) \ - -padding {14 8} \ - -borderwidth 2 \ - -relief raised - - ttk::style map TMenubutton \ - -background [list \ - active $colors(-hover-bg) \ - pressed $colors(-active-bg) \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] \ - -relief [list \ - pressed sunken] - } -} - -# ============================================================================ -# LIGHT MODE THEME -# ============================================================================ -namespace eval ttk::theme::bhom_light { - variable colors - array set colors { - -bg "#ffffff" - -fg "#1a1a1a" - -dark "#f3f3f3" - -darker "#ececec" - -selectbg "#1b6ec2" - -selectfg "#ffffff" - -primary "#1b6ec2" - -primary-hover "#1861ac" - -primary-light "#258cfb" - -secondary "#ff4081" - -secondary-hover "#ff1f69" - -tertiary "#a4a900" - -info "#006bb7" - -success "#26b050" - -warning "#eb671c" - -error "#e50000" - -border "#d0d0d0" - -border-light "#e5e5e5" - -disabled-bg "#f3f3f3" - -disabled-fg "#999999" - -inputbg "#ffffff" - -inputfg "#1a1a1a" - -hover-bg "#f5f5f5" - -active-bg "#ececec" - -text-secondary "#666666" - } - - ttk::style theme create bhom_light -parent clam -settings { - # General font settings - Segoe UI for Windows, fallback to Roboto/Helvetica - ttk::style configure . \ - -font {{Segoe UI} 10} \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-border) \ - -darkcolor $colors(-border) \ - -lightcolor $colors(-border) \ - -troughcolor $colors(-darker) \ - -focuscolor $colors(-primary) \ - -selectbackground $colors(-selectbg) \ - -selectforeground $colors(-selectfg) \ - -selectborderwidth 0 \ - -insertwidth 1 \ - -insertcolor $colors(-primary) \ - -relief flat - - # Frame - ttk::style configure TFrame \ - -background $colors(-bg) \ - -borderwidth 0 \ - -relief flat - - ttk::style configure Card.TFrame \ - -background $colors(-dark) \ - -borderwidth 2 \ - -relief groove \ - -bordercolor $colors(-border-light) - - # Label - Extended dynamic typography system - ttk::style configure TLabel \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {8 6} - - ttk::style configure Display.TLabel \ - -font {{Segoe UI} 28 bold} \ - -foreground $colors(-primary) \ - -padding {0 0} - - ttk::style configure LargeTitle.TLabel \ - -font {{Segoe UI} 24 bold} \ - -foreground $colors(-fg) \ - -padding {0 0} - - ttk::style configure Title.TLabel \ - -font {{Segoe UI} 24 bold} \ - -foreground $colors(-fg) \ - -padding {0 0} - - ttk::style configure Headline.TLabel \ - -font {{Segoe UI} 16 bold} \ - -foreground $colors(-primary) \ - -padding {0 0} - - ttk::style configure Subtitle.TLabel \ - -font {{Segoe UI} 14 bold} \ - -foreground $colors(-fg) \ - -padding {0 0} - - ttk::style configure Heading.TLabel \ - -font {{Segoe UI} 12 bold} \ - -foreground $colors(-primary) \ - -padding {0 0} - - ttk::style configure Body.TLabel \ - -font {{Segoe UI} 10} \ - -foreground $colors(-fg) \ - -padding {6 4} - - ttk::style configure Caption.TLabel \ - -font {{Segoe UI} 9} \ - -foreground $colors(-text-secondary) \ - -padding {6 4} - - ttk::style configure Small.TLabel \ - -font {{Segoe UI} 8} \ - -foreground $colors(-text-secondary) \ - -padding {4 2} - - ttk::style configure Success.TLabel \ - -font {{Segoe UI} 10 bold} \ - -foreground $colors(-success) - - ttk::style configure Warning.TLabel \ - -font {{Segoe UI} 10 bold} \ - -foreground $colors(-warning) - - ttk::style configure Error.TLabel \ - -font {{Segoe UI} 10 bold} \ - -foreground $colors(-error) - - ttk::style configure Info.TLabel \ - -font {{Segoe UI} 10 bold} \ - -foreground $colors(-info) - - # Button - soft rounded design - ttk::style configure TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-active-bg) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-border-light) \ - -lightcolor $colors(-hover-bg) \ - -darkcolor $colors(-border) \ - -borderwidth 2 \ - -focuscolor "" \ - -padding {16 8} \ - -relief raised - - # Large Button variant - ttk::style configure Large.TButton \ - -font {{Segoe UI} 12 bold} \ - -padding {20 12} \ - -borderwidth 2 - - # Small Button variant - ttk::style configure Small.TButton \ - -font {{Segoe UI} 8 bold} \ - -padding {12 6} \ - -borderwidth 2 - - ttk::style map TButton \ - -background [list \ - active $colors(-primary-hover) \ - pressed $colors(-active-bg) \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - active $colors(-fg) \ - disabled $colors(-disabled-fg)] \ - -bordercolor [list \ - active $colors(-primary-hover) \ - disabled $colors(-border)] \ - -lightcolor [list \ - active $colors(-primary-hover) \ - pressed $colors(-active-bg)] \ - -darkcolor [list \ - active $colors(-primary-hover) \ - pressed $colors(-active-bg)] \ - -relief [list \ - pressed sunken] - - # Primary Button - accent color with soft rounded edges - ttk::style configure Primary.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-primary) \ - -foreground "#ffffff" \ - -bordercolor $colors(-primary-light) \ - -lightcolor $colors(-primary-light) \ - -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised - - ttk::style map Primary.TButton \ - -background [list \ - active $colors(-primary-hover) \ - pressed $colors(-primary-hover) \ - disabled $colors(-disabled-bg)] \ - -lightcolor [list \ - active $colors(-primary-light) \ - pressed $colors(-primary-hover)] \ - -darkcolor [list \ - active $colors(-primary-hover) \ - pressed $colors(-primary-hover)] \ - -relief [list \ - pressed sunken] - - # Secondary Button - soft rounded edges - ttk::style configure Secondary.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-secondary) \ - -foreground "#ffffff" \ - -bordercolor $colors(-secondary) \ - -lightcolor $colors(-secondary) \ - -darkcolor $colors(-secondary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised - - ttk::style map Secondary.TButton \ - -background [list \ - active $colors(-secondary-hover) \ - pressed $colors(-secondary-hover) \ - disabled $colors(-disabled-bg)] \ - -relief [list \ - pressed sunken] - - # Accent Button - muted yellow for light mode - ttk::style configure Accent.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-tertiary) \ - -foreground "#ffffff" \ - -bordercolor $colors(-tertiary) \ - -lightcolor $colors(-tertiary) \ - -darkcolor "#8a8a00" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised - - ttk::style map Accent.TButton \ - -background [list \ - active "#8a8a00" \ - pressed "#8a8a00" \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] \ - -relief [list \ - pressed sunken] - - # Success Button - green from app.css - ttk::style configure Success.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-success) \ - -foreground "#ffffff" \ - -bordercolor $colors(-success) \ - -lightcolor $colors(-success) \ - -darkcolor "#1e9038" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised - - ttk::style map Success.TButton \ - -background [list \ - active "#1e9038" \ - pressed "#1e9038" \ - disabled $colors(-disabled-bg)] \ - -relief [list \ - pressed sunken] - - # Link Button - blue link from app.css - ttk::style configure Link.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-bg) \ - -foreground $colors(-info) \ - -borderwidth 0 \ - -padding {14 8} \ - -relief flat - - ttk::style map Link.TButton \ - -foreground [list \ - active $colors(-primary-light) \ - pressed $colors(-primary-hover) \ - disabled $colors(-disabled-fg)] - - # Outline Button - soft rounded with bold font - ttk::style configure Outline.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-bg) \ - -foreground $colors(-primary) \ - -bordercolor $colors(-primary) \ - -lightcolor $colors(-hover-bg) \ - -darkcolor $colors(-border) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised - - ttk::style map Outline.TButton \ - -background [list \ - active $colors(-hover-bg) \ - pressed $colors(-active-bg)] \ - -bordercolor [list \ - active $colors(-primary-light) \ - disabled $colors(-border)] \ - -relief [list \ - pressed sunken] - - # Text Button - bold font with padding - ttk::style configure Text.TButton \ - -font {{Segoe UI} 10 bold} \ - -background $colors(-bg) \ - -foreground $colors(-primary) \ - -borderwidth 0 \ - -padding {14 8} - - ttk::style map Text.TButton \ - -background [list \ - active $colors(-hover-bg) \ - pressed $colors(-active-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] - - # Entry - soft rounded design with subtle depth - ttk::style configure TEntry \ - -fieldbackground $colors(-inputbg) \ - -foreground $colors(-inputfg) \ - -bordercolor $colors(-border-light) \ - -lightcolor $colors(-border) \ - -darkcolor $colors(-hover-bg) \ - -insertcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ - -relief sunken - - ttk::style map TEntry \ - -fieldbackground [list \ - readonly $colors(-disabled-bg) \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] \ - -bordercolor [list \ - focus $colors(-primary) \ - invalid $colors(-error)] - - # Combobox - soft rounded design - ttk::style configure TCombobox \ - -fieldbackground $colors(-inputbg) \ - -foreground $colors(-inputfg) \ - -background $colors(-bg) \ - -bordercolor $colors(-border-light) \ - -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ - -relief sunken - - ttk::style map TCombobox \ - -fieldbackground [list \ - readonly $colors(-inputbg) \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] \ - -bordercolor [list \ - focus $colors(-primary)] \ - -arrowcolor [list \ - disabled $colors(-disabled-fg)] - - # Checkbutton - bold font - ttk::style configure TCheckbutton \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ - -indicatorcolor $colors(-inputbg) \ - -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ - -borderwidth 0 \ - -relief flat - - ttk::style map TCheckbutton \ - -background [list \ - active $colors(-bg) \ - selected $colors(-bg)] \ - -foreground [list \ - active $colors(-primary) \ - disabled $colors(-disabled-fg)] \ - -indicatorcolor [list \ - selected $colors(-primary) \ - active $colors(-hover-bg) \ - disabled $colors(-disabled-bg)] - - # Radiobutton - sleek hover effect with bold font - ttk::style configure TRadiobutton \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ - -indicatorcolor $colors(-inputbg) \ - -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ - -borderwidth 0 \ - -relief flat - - ttk::style map TRadiobutton \ - -background [list \ - active $colors(-bg) \ - selected $colors(-bg)] \ - -foreground [list \ - active $colors(-primary) \ - selected $colors(-primary) \ - disabled $colors(-disabled-fg)] \ - -indicatorcolor [list \ - selected $colors(-primary) \ - active $colors(-hover-bg) \ - disabled $colors(-disabled-bg)] - - # Scrollbar - minimal sleek design without arrows - ttk::style configure TScrollbar \ - -background $colors(-border) \ - -bordercolor $colors(-bg) \ - -troughcolor $colors(-bg) \ - -arrowsize 0 \ - -borderwidth 0 \ - -relief flat \ - -width 10 - - ttk::style map TScrollbar \ - -background [list \ - active $colors(-primary) \ - pressed $colors(-primary-hover)] - - ttk::style configure Vertical.TScrollbar \ - -background $colors(-border) \ - -bordercolor $colors(-bg) \ - -troughcolor $colors(-bg) \ - -arrowsize 0 \ - -borderwidth 0 \ - -width 10 - - ttk::style map Vertical.TScrollbar \ - -background [list \ - active $colors(-primary) \ - pressed $colors(-primary-hover)] - - ttk::style configure Horizontal.TScrollbar \ - -background $colors(-border) \ - -bordercolor $colors(-bg) \ - -troughcolor $colors(-bg) \ - -arrowsize 0 \ - -borderwidth 0 \ - -width 10 - - ttk::style map Horizontal.TScrollbar \ - -background [list \ - active $colors(-primary) \ - pressed $colors(-primary-hover)] - - # Scale - minimal sleek design - ttk::style configure TScale \ - -background $colors(-primary) \ - -troughcolor $colors(-bg) \ - -bordercolor $colors(-bg) \ - -slidercolor $colors(-primary) \ - -borderwidth 0 \ - -sliderrelief flat - - ttk::style map TScale \ - -background [list \ - active $colors(-primary-light) \ - pressed $colors(-primary-hover)] \ - -slidercolor [list \ - active $colors(-primary-light) \ - pressed $colors(-primary-hover)] - - # Progressbar - soft rounded design - ttk::style configure TProgressbar \ - -background $colors(-primary) \ - -troughcolor $colors(-darker) \ - -bordercolor $colors(-border-light) \ - -lightcolor $colors(-primary-light) \ - -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -thickness 24 \ - -relief raised - - # Notebook - soft rounded tabs - ttk::style configure TNotebook \ - -background $colors(-bg) \ - -bordercolor $colors(-border-light) \ - -tabmargins {2 5 2 0} \ - -borderwidth 2 - - ttk::style configure TNotebook.Tab \ - -background $colors(-dark) \ - -foreground $colors(-text-secondary) \ - -bordercolor $colors(-border-light) \ - -font {{Segoe UI} 11 bold} \ - -padding {18 10} \ - -borderwidth 2 - - ttk::style map TNotebook.Tab \ - -background [list \ - selected $colors(-bg) \ - active $colors(-hover-bg)] \ - -foreground [list \ - selected $colors(-primary) \ - active $colors(-fg)] \ - -expand [list \ - selected {2 2 2 0}] - - # Treeview - soft design with bold headings - ttk::style configure Treeview \ - -background $colors(-inputbg) \ - -foreground $colors(-fg) \ - -fieldbackground $colors(-inputbg) \ - -bordercolor $colors(-border-light) \ - -lightcolor $colors(-border-light) \ - -darkcolor $colors(-border) \ - -borderwidth 2 \ - -rowheight 32 \ - -padding {6 4} - - ttk::style map Treeview \ - -background [list selected $colors(-primary)] \ - -foreground [list selected $colors(-selectfg)] - - ttk::style configure Treeview.Heading \ - -background $colors(-dark) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-border-light) \ - -relief raised \ - -padding {10 8} \ - -font {{Segoe UI} 11 bold} - - ttk::style map Treeview.Heading \ - -background [list active $colors(-hover-bg)] \ - -relief [list pressed sunken] - - # Separator - ttk::style configure TSeparator \ - -background $colors(-border) - - ttk::style configure Horizontal.TSeparator \ - -background $colors(-border) - - ttk::style configure Vertical.TSeparator \ - -background $colors(-border) - - # Labelframe - soft rounded design with depth - ttk::style configure TLabelframe \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-border-light) \ - -lightcolor $colors(-hover-bg) \ - -darkcolor $colors(-border) \ - -borderwidth 2 \ - -relief groove \ - -padding {16 12} - - ttk::style configure TLabelframe.Label \ - -background $colors(-bg) \ - -foreground $colors(-fg) \ - -font {{Segoe UI} 12 bold} \ - -padding {10 -8} - - # Panedwindow - ttk::style configure TPanedwindow \ - -background $colors(-bg) - - ttk::style configure Sash \ - -sashthickness 8 \ - -gripcount 0 \ - -background $colors(-border) - - # Sizegrip - ttk::style configure TSizegrip \ - -background $colors(-bg) - - # Spinbox - soft rounded design - ttk::style configure TSpinbox \ - -fieldbackground $colors(-inputbg) \ - -foreground $colors(-inputfg) \ - -bordercolor $colors(-border-light) \ - -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ - -relief sunken - - ttk::style map TSpinbox \ - -fieldbackground [list \ - readonly $colors(-disabled-bg) \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] \ - -bordercolor [list \ - focus $colors(-primary)] - - # Menubutton - soft rounded design - ttk::style configure TMenubutton \ - -background $colors(-dark) \ - -foreground $colors(-fg) \ - -bordercolor $colors(-border-light) \ - -arrowcolor $colors(-fg) \ - -padding {14 8} \ - -borderwidth 2 \ - -relief raised - - ttk::style map TMenubutton \ - -background [list \ - active $colors(-hover-bg) \ - pressed $colors(-active-bg) \ - disabled $colors(-disabled-bg)] \ - -foreground [list \ - disabled $colors(-disabled-fg)] \ - -relief [list \ - pressed sunken] - } -} - -# Set default options for tk widgets (non-ttk) -option add *Background "#1e1e1e" -option add *Foreground "#ffffff" -option add *Font {{Segoe UI} 10 bold} -option add *selectBackground "#1b6ec2" -option add *selectForeground "#ffffff" -option add *activeBackground "#2a2d2e" -option add *activeForeground "#ffffff" -option add *highlightColor "#1b6ec2" -option add *highlightBackground "#1e1e1e" -option add *disabledForeground "#666666" -option add *insertBackground "#ffffff" -option add *troughColor "#2d2d2d" -option add *borderWidth 1 -option add *relief flat - -# Listbox specific - matches design theme -option add *Listbox.background "#2d2d2d" -option add *Listbox.foreground "#ffffff" -option add *Listbox.selectBackground "#1b6ec2" -option add *Listbox.selectForeground "#ffffff" -option add *Listbox.font {{Segoe UI} 10} -option add *Listbox.borderWidth 1 -option add *Listbox.relief flat -option add *Listbox.highlightThickness 1 -option add *Listbox.highlightColor "#3d3d3d" -option add *Listbox.highlightBackground "#3d3d3d" - -# Text widget specific -option add *Text.background "#2d2d2d" -option add *Text.foreground "#ffffff" -option add *Text.insertBackground "#ffffff" -option add *Text.selectBackground "#1b6ec2" -option add *Text.selectForeground "#ffffff" -option add *Text.font {{Segoe UI} 10} -option add *Text.borderWidth 1 -option add *Text.relief flat -option add *Text.highlightThickness 1 -option add *Text.highlightColor "#1b6ec2" - -# Canvas specific -option add *Canvas.background "#1e1e1e" -option add *Canvas.highlightThickness 0 - -# Menu specific -option add *Menu.background "#2d2d2d" -option add *Menu.foreground "#ffffff" -option add *Menu.activeBackground "#1b6ec2" -option add *Menu.activeForeground "#ffffff" -option add *Menu.activeBorderWidth 0 -option add *Menu.borderWidth 1 -option add *Menu.relief flat -option add *Menu.font {{Segoe UI} 10} - -# Toplevel/window specific -option add *Toplevel.background "#1e1e1e" - -# Message widget -option add *Message.background "#1e1e1e" -option add *Message.foreground "#ffffff" -option add *Message.font {{Segoe UI} 10} diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py b/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py index f1d6eb9..82e3736 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py @@ -15,8 +15,8 @@ class DefaultRoot(tk.Tk): def __init__( self, title: str = "Application", - logo_path: Optional[Path] = Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\BHoM_Logo.png"), - icon_path: Optional[Path] = Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\bhom_icon.png"), + logo_path: Path = Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\BHoM_Logo.png"), + icon_path: Path = Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\bhom_icon.png"), dark_logo_path: Optional[Path] = None, dark_icon_path: Optional[Path] = None, min_width: int = 500, @@ -32,8 +32,8 @@ def __init__( close_text: str = "Close", close_command: Optional[Callable] = None, on_close_window: Optional[Callable] = None, - theme_path: Optional[Path] = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_light_theme.tcl"), - theme_path_dark: Optional[Path] = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_dark_theme.tcl"), + theme_path: Path = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_light_theme.tcl"), + theme_path_dark: Path = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_dark_theme.tcl"), theme_mode: Literal["light", "dark", "auto"] = "auto", **kwargs ): @@ -71,7 +71,7 @@ def __init__( self.withdraw() # Load custom themes - _theme_path, _icon_path, _logo_path, _theme_style = self._determine_theme(logo_path, dark_logo_path, icon_path, dark_icon_path, theme_mode, theme_path, theme_path_dark) + _theme_path, _logo_path, _icon_path, _theme_style = self._determine_theme(logo_path, dark_logo_path, icon_path, dark_icon_path, theme_mode, theme_path, theme_path_dark) self._set_window_icon(_icon_path) self.theme = self._load_theme(_theme_path) @@ -135,13 +135,13 @@ def _set_window_icon(self, icon_path: Path) -> None: def _determine_theme( self, - logo_path: Optional[Path], + logo_path: Path, dark_logo_path: Optional[Path], - icon_path: Optional[Path], + icon_path: Path, dark_icon_path: Optional[Path], theme_mode: str, - theme_path_light: Optional[Path], - theme_path_dark: Optional[Path]) -> tuple[Path|None, Path|None, Path|None, str]: + theme_path_light: Path, + theme_path_dark: Path) -> tuple[Path, Path, Path, str]: """determin the light or dark mode usage""" @@ -162,25 +162,32 @@ def _determine_theme( else: return theme_path_light, logo_path, icon_path, "light" - def _set_titlebar_theme(self, theme_style: str) -> None: + def _set_titlebar_theme(self, theme_style: str) -> str: """ Apply titlebar theme using Windows API. """ - try: + + use_dark = 1 if theme_style == "dark" else 0 + if platform.system() == "Windows" and ctypes is not None and self.winfo_exists(): hwnd = self.winfo_id() hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) if hwnd: DWMWA_USE_IMMERSIVE_DARK_MODE = 20 - use_dark = 1 if theme_style == "dark" else 0 ctypes.windll.dwmapi.DwmSetWindowAttribute( hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(ctypes.c_int(use_dark)), ctypes.sizeof(ctypes.c_int) ) + + if use_dark: + return "dark" + else: + return "light" + except Exception: - pass + return "light" def _load_theme(self, _theme_path: Path) -> str: """ @@ -197,6 +204,8 @@ def _load_theme(self, _theme_path: Path) -> str: style = ttk.Style() try: + current_themes = set(style.theme_names()) + # Determine which theme file to use if _theme_path and _theme_path.exists(): theme_path = _theme_path @@ -209,7 +218,9 @@ def _load_theme(self, _theme_path: Path) -> str: self.tk.call('source', str(theme_path)) available_theme_names = style.theme_names() - selected_theme = available_theme_names[0] if available_theme_names else "default" + newly_added = [name for name in available_theme_names if name not in current_themes] + selected_theme = newly_added[-1] if newly_added else (available_theme_names[0] if available_theme_names else "default") + style.theme_use(selected_theme) return selected_theme else: @@ -229,27 +240,30 @@ def _load_theme(self, _theme_path: Path) -> str: def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path]) -> None: """Build the branded banner section.""" banner = ttk.Frame(parent, relief=tk.RIDGE, borderwidth=1) - banner.pack(fill=tk.X, padx=0, pady=0) + banner.pack(fill=tk.BOTH, padx=0, pady=0) banner_content = ttk.Frame(banner, padding=10) - banner_content.pack(fill=tk.X) + banner_content.pack(fill=tk.BOTH, expand=True) + + # Text container + text_container = ttk.Frame(banner_content) + text_container.pack(side=tk.LEFT, fill=tk.Y) + + logo_container = ttk.Frame(banner_content, width=80) + logo_container.pack(side=tk.RIGHT, fill=tk.Y) # Logo (if provided) if logo_path and logo_path.exists(): try: from PIL import Image, ImageTk img = Image.open(logo_path) - img.thumbnail((40, 40), Image.Resampling.LANCZOS) + img.thumbnail((80, 80), Image.Resampling.LANCZOS) self.logo_image = ImageTk.PhotoImage(img) - logo_label = ttk.Label(banner_content, image=self.logo_image) - logo_label.pack(side=tk.RIGHT) + logo_label = ttk.Label(logo_container, image=self.logo_image) + logo_label.pack(fill=tk.BOTH, expand=True) except ImportError: pass # PIL not available, skip logo - # Text container - text_container = ttk.Frame(banner_content) - text_container.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - # Title title_label = ttk.Label( text_container, @@ -328,7 +342,7 @@ def _apply_sizing(self) -> None: def _show_window_with_styling(self) -> None: """Apply titlebar styling and show the window.""" # Apply titlebar theme - self._apply_titlebar_theme(self.theme_name) + self._set_titlebar_theme(self.titlebar_theme) # Show window after styling self.deiconify() @@ -339,8 +353,6 @@ def refresh_sizing(self) -> None: def destroy_root(self) -> None: """Safely terminate and destroy the Tk root window.""" - if not hasattr(self, "root") or self is None: - return try: if self.winfo_exists(): @@ -372,14 +384,6 @@ def _on_close_window(self, callback: Optional[Callable]) -> None: """Handle window X button click.""" self._exit("window_closed", callback) - def run(self) -> Optional[str]: - """Show the window and return the result.""" - try: - self.mainloop() - finally: - self.destroy_root() - return self.result - if __name__ == "__main__": @@ -391,8 +395,9 @@ def run(self) -> Optional[str]: theme_mode="auto", ) - - """ + test.mainloop() + + r""" from widgets.PathSelector import PathSelector from widgets.RadioSelection import RadioSelection from widgets.ValidatedEntryBox import ValidatedEntryBox diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/DirectoryFileSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter/DirectoryFileSelector.py index 4b8b97a..1980fc5 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/DirectoryFileSelector.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/DirectoryFileSelector.py @@ -6,16 +6,16 @@ from widgets.ListBox import ScrollableListBox from DefaultRoot import DefaultRoot -class DirectoryFileSelector: +class DirectoryFileSelector(DefaultRoot): def __init__(self, directory: Path, file_types: Iterable[str], selection_label: str = "file(s)") -> None: self.directory = Path(directory) self.file_types = self._normalise_file_types(file_types) self.selection_label = selection_label self._cancelled = False - self._selected_files = [] + self.selected_files = [] # Create DefaultRoot window - self.window = DefaultRoot( + super().__init__( title=f"Select {selection_label}", min_width=600, min_height=400, @@ -34,13 +34,13 @@ def __init__(self, directory: Path, file_types: Iterable[str], selection_label: # Add content to the window's content frame ttk.Label( - self.window.content_frame, + self.content_frame, text=f"Select the {self.selection_label} to analyse.", justify=tk.LEFT, ).pack(anchor="w", pady=(0, 10)) self.listbox = ScrollableListBox( - self.window.content_frame, + self.content_frame, items=self.display_items, selectmode=tk.MULTIPLE, height=12, @@ -50,7 +50,7 @@ def __init__(self, directory: Path, file_types: Iterable[str], selection_label: self.listbox.select_all() # Refresh sizing after adding widgets - self.window.refresh_sizing() + self.refresh_sizing() def _normalise_file_types(self, file_types: Iterable[str]) -> List[str]: normalised = [] @@ -79,20 +79,16 @@ def _display_value(self, path: Path) -> str: except ValueError: return str(path) - def run(self) -> List[Path]: - result = self.window.run() - if self._cancelled or result != "submit": - return [] - return self._selected_files - def _on_submit(self): """Handle OK button - capture selection before window closes.""" selected = self.listbox.get_selection() - self._selected_files = [self.file_lookup[item] for item in selected if item in self.file_lookup] + self.selected_files = [self.file_lookup[item] for item in selected if item in self.file_lookup] + self.destroy_root() def _on_cancel(self): """Handle Cancel button or window close.""" self._cancelled = True + self.destroy_root() if __name__ == "__main__": @@ -102,7 +98,7 @@ def _on_cancel(self): file_types=[".py", ".txt"], selection_label="scripts and text files", ) - selected_files = selector.run() - print("Selected files:") - for file in selected_files: + + selector.mainloop() + for file in selector.selected_files: print(file) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py b/Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py index c70c872..964aeb9 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py @@ -13,7 +13,9 @@ class LandingPage(DefaultRoot): def __init__( self, title: str = "Landing Page", + header: Optional[str] = None, message: Optional[str] = None, + sub_title: Optional[str] = None, min_width: int = 400, min_height: int = 200, show_continue: bool = True, @@ -54,15 +56,19 @@ def __init__( ) # Initialize DefaultRoot with continue mapped to submit + if header: + header_label = ttk.Label(self.content_frame, text=header, style="Header.TLabel") + header_label.pack(side="top", anchor="w", pady=(0, 10)) + # Optional message/commentary if message: - message_label = ttk.Label( - self.content_frame, - text=message, - justify=tk.LEFT, - wraplength=min_width - 80, - ) - message_label.pack(anchor="w", pady=(0, 20)) + message_label = ttk.Label(self.content_frame, text=message, style="Body.TLabel", justify=tk.LEFT) + message_label.pack(side="top", anchor="w", pady=(0, 10)) + + # Optional sub-title + if sub_title: + sub_title_label = ttk.Label(self.content_frame, text=sub_title, style="Caption.TLabel") + sub_title_label.pack(side="top", anchor="w", pady=(0, 10)) # Custom buttons container self.custom_buttons_frame = ttk.Frame(self.content_frame) @@ -116,6 +122,8 @@ def on_close(): landing = LandingPage( title="Example Application", message="Welcome to the landing page example.\n\nThis demonstrates a configurable landing page with custom buttons.\n\nPlease select an option below to proceed.", + header="Welcome!", + sub_title="Please choose an option to continue:", continue_text="Proceed", continue_command=on_continue, close_command=on_close, @@ -128,7 +136,6 @@ def on_close(): ) landing.mainloop() - landing.destroy_root() result = landing.result diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/ProcessingWindow.py b/Python_Engine/Python/src/python_toolkit/tkinter/ProcessingWindow.py index cb1f71e..15e71d5 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/ProcessingWindow.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/ProcessingWindow.py @@ -2,8 +2,10 @@ from tkinter import ttk import os import time +from typing import Optional +from python_toolkit.tkinter.DefaultRoot import DefaultRoot -class ProcessingWindow: +class ProcessingWindow(DefaultRoot): """A simple processing window with animated indicator.""" def __init__(self, title="Processing", message="Processing..."): @@ -12,14 +14,14 @@ def __init__(self, title="Processing", message="Processing..."): title (str): Window title. message (str): Message to display. """ - self.root = tk.Tk() + super().__init__(title=title, min_width=300, min_height=150, show_submit=False, show_close=False) - self.root.title(title) - self.root.attributes("-topmost", True) - self.root.resizable(False, False) + self.title(title) + self.attributes("-topmost", True) + self.resizable(False, False) # Container - container = ttk.Frame(self.root, padding=20) + container = ttk.Frame(self, padding=20) container.pack(fill="both", expand=True) # Constant title label @@ -59,11 +61,11 @@ def __init__(self, title="Processing", message="Processing..."): self.is_running = False # Update to calculate the required size - self.root.update_idletasks() + self.update_idletasks() # Get the required width and height - required_width = self.root.winfo_reqwidth() - required_height = self.root.winfo_reqheight() + required_width = self.winfo_reqwidth() + required_height = self.winfo_reqheight() # Set minimum size min_width = 300 @@ -72,11 +74,11 @@ def __init__(self, title="Processing", message="Processing..."): window_height = max(required_height, min_height) # Center on screen - screen_width = self.root.winfo_screenwidth() - screen_height = self.root.winfo_screenheight() + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() x = (screen_width - window_width) // 2 y = (screen_height - window_height) // 2 - self.root.geometry(f"{window_width}x{window_height}+{x}+{y}") + self.geometry(f"{window_width}x{window_height}+{x}+{y}") def start(self): """Start the processing window and animation.""" @@ -85,15 +87,15 @@ def start(self): def keep_alive(self): """Call this repeatedly to process animation updates. Returns False when done.""" - if self.is_running and self.root.winfo_exists(): - self.root.update() + if self.is_running and self.winfo_exists(): + self.update() return True return False def stop(self): """Stop the animation and close the window.""" self.is_running = False - self.root.destroy() + self.destroy() def _animate(self): """Update animation frames.""" @@ -102,12 +104,12 @@ def _animate(self): dots = ["◐", "◓", "◑", "◒"] self.animation_label.config(text=dots[self.current_frame % len(dots)]) self.current_frame += 1 - self.root.after(200, self._animate) + self.after(200, self._animate) def update_message(self, message: str): """Update the message text.""" self.message_label.config(text=message) - self.root.update() + self.update() if __name__ == "__main__": diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py index b700208..91ced09 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py @@ -1,16 +1,32 @@ import tkinter as tk +from typing import Optional +from tkinter import ttk import calendar import datetime class CalendarWidget(tk.Frame): - def __init__(self, parent, def_year: int, def_month: int, def_day: int, show_year_selector: bool = False, year_min: int = 1900, year_max: int = 2100, **kwargs): + def __init__(self, parent, def_year: int, def_month: int, def_day: int, show_year_selector: bool = False, year_min: int = 1900, year_max: int = 2100, item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): super().__init__(parent, **kwargs) + self.year = def_year self.month = def_month self.show_year_selector = show_year_selector self.year_min = year_min self.year_max = year_max + self.item_title = item_title + self.helper_text = helper_text + + # Optional header/title label at the top of the widget + if self.item_title: + self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") + self.title_label.pack(side="top", anchor="w") + + # Optional helper/requirements label above the input + if self.helper_text: + self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") + self.helper_label.pack(side="top", anchor="w") + self.cal_frame = tk.Frame(self) self.cal_frame.pack(side="top", fill="x") @@ -86,18 +102,14 @@ def get_date(self): return datetime.date(self.year, self.month, self.day) if __name__ == "__main__": - root = tk.Tk() - root = DefaultRoot() + from python_toolkit.tkinter.DefaultRoot import DefaultRoot + + root = DefaultRoot(min_height=500) root.title("Calendar Widget Test") # Example without year selector - cal_widget1 = CalendarWidget(root, def_year=2024, def_month=6, def_day=15) + cal_widget1 = CalendarWidget(root.content_frame, def_year=2024, def_month=6, def_day=15, item_title="Select Start Date") cal_widget1.pack(padx=20, pady=20) - # Example with year selector - tk.Label(root, text="With Year Selector:").pack(pady=(20, 0)) - cal_widget2 = CalendarWidget(root, def_year=2026, def_month=2, def_day=20, show_year_selector=True, year_min=2000, year_max=2030) - cal_widget2.pack(padx=20, pady=20) - root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py index cb5be32..c13fb13 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py @@ -2,7 +2,7 @@ from matplotlib import cm from tkinter import ttk import tkinter as tk - +import matplotlib as mpl from python_toolkit.plot.cmap_sample import cmap_sample_plot from python_toolkit.tkinter.widgets.FigureContainer import FigureContainer @@ -73,6 +73,8 @@ def __init__( """ super().__init__(parent, **kwargs) + mpl.use("Agg") # Use non-interactive backend for embedding in Tkinter + self.colormap_var = tk.StringVar(value="viridis") self._all_colormaps = self._get_all_colormaps() self._preset_map: Dict[str, List[str]] = { @@ -184,11 +186,11 @@ def _update_cmap_sample(self, *args) -> None: self.figure_widget.embed_figure(fig) if __name__ == "__main__": - root = tk.Tk() - root.title("Colormap Selector Test") - root.geometry("800x600") + from python_toolkit.tkinter.DefaultRoot import DefaultRoot + root = DefaultRoot() + parent_container = root.content_frame - cmap_selector = CmapSelector(root, cmap_set="all") + cmap_selector = CmapSelector(parent_container, cmap_set="all") cmap_selector.pack(fill=tk.BOTH, expand=True) root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py index 7b97035..cdf4cfa 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py @@ -6,14 +6,16 @@ import matplotlib.pyplot as plt -class FigureContainer(ttk.Frame): +class FigureContainer(tk.Frame): """ A reusable widget for embedding matplotlib figures and images. """ def __init__( self, - parent: tk.Widget, + parent, + item_title: Optional[str] = None, + helper_text: Optional[str] = None, **kwargs ) -> None: """ @@ -21,19 +23,51 @@ def __init__( Args: parent: Parent widget + item_title: Optional header text shown at the top of the widget frame. + helper_text: Optional helper text shown above the entry box. **kwargs: Additional Frame options """ super().__init__(parent, **kwargs) self.figure: Optional[Figure] = None - self.figure_canvas: Optional[FigureCanvasTkAgg] = None self.image: Optional[tk.PhotoImage] = None - self.image_label: Optional[ttk.Label] = None + self.image_file: Optional[str] = None + + self.item_title = item_title + self.helper_text = helper_text + + # Optional header/title label at the top of the widget + if self.item_title: + self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") + self.title_label.pack(side="top", anchor="w") + + # Optional helper/requirements label above the input + if self.helper_text: + self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") + self.helper_label.pack(side="top", anchor="w") + + # Container frame for embedded content (not title/helper) + self.content_frame = ttk.Frame(self) + self.content_frame.pack(side="top", fill=tk.BOTH, expand=True) + + self.canvas = None + self.image_label = None + + 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 this frame.""" - for widget in self.winfo_children(): + """Destroy any child widgets hosted by the content frame only.""" + for widget in self.content_frame.winfo_children(): widget.destroy() + self.canvas = None + self.image_label = None def _resolved_background(self) -> str: """Resolve a background colour suitable for embedded Tk canvas widgets.""" @@ -50,28 +84,19 @@ def _resolved_background(self) -> str: return "white" def embed_figure(self, figure: Figure) -> None: - """ - Embed a matplotlib figure in the figure container. + """ add matplotlib figure to the container, replacing any existing content. + Args: figure: Matplotlib Figure object to embed """ + self._clear_children() self.figure = figure - self.figure_canvas = FigureCanvasTkAgg(figure, master=self) - canvas_widget = self.figure_canvas.get_tk_widget() - bg = self._resolved_background() - canvas_widget.configure(bg=bg, highlightthickness=0, bd=0) - canvas_widget.pack(fill=tk.BOTH, expand=True) - self.figure_canvas.draw() - # Close the figure in pyplot to avoid accumulating open-figure state - try: - plt.close(figure) - except Exception: - pass - self.image = None - self.image_label = None + self.canvas = FigureCanvasTkAgg(figure, master=self.content_frame) + self.canvas.draw() + self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) def embed_image(self, image: tk.PhotoImage) -> None: """ @@ -83,12 +108,11 @@ def embed_image(self, image: tk.PhotoImage) -> None: self._clear_children() self.image = image - self.image_label = ttk.Label(self, image=image) + + # Create label to display the image + self.image_label = ttk.Label(self.content_frame, image=image) self.image_label.pack(fill=tk.BOTH, expand=True) - self.figure = None - self.figure_canvas = None - def embed_image_file(self, file_path: str) -> None: """ Load and embed an image file supported by Tk PhotoImage. @@ -97,6 +121,7 @@ def embed_image_file(self, file_path: str) -> None: file_path: Path to image file """ image = tk.PhotoImage(file=file_path) + self.image_file = file_path self.embed_image(image) def clear(self) -> None: @@ -109,20 +134,18 @@ def clear(self) -> None: except Exception: pass self.figure = None - self.figure_canvas = None self.image = None self.image_label = None if __name__ == "__main__": - import matplotlib.pyplot as plt - root = tk.Tk() - root.title("Figure Container Test") - root.geometry("500x400") + from python_toolkit.tkinter.DefaultRoot import DefaultRoot + root = DefaultRoot() + parent_container = root.content_frame # Create figure container - figure_container = FigureContainer(root) + figure_container = FigureContainer(parent=parent_container, item_title="Figure Container", helper_text="This widget can embed matplotlib figures or images.") figure_container.pack(padx=10, pady=10, fill=tk.BOTH, expand=True) # Create and embed a matplotlib figure diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ListBox.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ListBox.py index 6550fbd..0da6c1f 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ListBox.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ListBox.py @@ -1,10 +1,11 @@ import tkinter as tk from tkinter import filedialog, ttk +from typing import Optional class ScrollableListBox(ttk.Frame): """A reusable listbox widget with auto-hiding scrollbar.""" - def __init__(self, parent, items=None, selectmode=tk.MULTIPLE, height=None, show_selection_controls=False, **kwargs): + def __init__(self, parent, items=None, selectmode=tk.MULTIPLE, height=None, show_selection_controls=False, item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): """ Args: parent (tk.Widget): The parent widget. @@ -19,6 +20,19 @@ def __init__(self, parent, items=None, selectmode=tk.MULTIPLE, height=None, show self.items = items or [] if height is None: height = len(self.items) if self.items else 5 + + self.item_title = item_title + self.helper_text = helper_text + + # Optional header/title label at the top of the widget + if self.item_title: + self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") + self.title_label.pack(side="top", anchor="w") + + # Optional helper/requirements label above the input + if self.helper_text: + self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") + self.helper_label.pack(side="top", anchor="w") self.content_frame = ttk.Frame(self) self.content_frame.pack(fill=tk.BOTH, expand=True) @@ -104,11 +118,13 @@ def clear(self): if __name__ == "__main__": - root = tk.Tk() - root.title("Scrollable ListBox Example") + + from python_toolkit.tkinter.DefaultRoot import DefaultRoot + root = DefaultRoot() + parent_container = root.content_frame items = [f"Item {i}" for i in range(1, 21)] - listbox = ScrollableListBox(root, items=items, height=10, show_selection_controls=True) + listbox = ScrollableListBox(parent_container, items=items, height=10, show_selection_controls=True, item_title="List Box", helper_text="Select items from the list.") listbox.pack(padx=20, pady=20) print("Selected items:", listbox.get_selection()) diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/PathSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/PathSelector.py index be64c77..b0a54bf 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/PathSelector.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/PathSelector.py @@ -1,11 +1,12 @@ import tkinter as tk from tkinter import filedialog, ttk from pathlib import Path +from typing import Optional class PathSelector(ttk.Frame): """A reusable path/file selector widget with a button and a readonly entry.""" - def __init__(self, parent, button_text="Browse...", filetypes=None, command=None, initialdir=None, mode="file", **kwargs): + def __init__(self, parent, button_text="Browse...", filetypes=None, command=None, initialdir=None, mode="file", item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): """ Args: parent (tk.Widget): The parent widget. @@ -14,6 +15,8 @@ def __init__(self, parent, button_text="Browse...", filetypes=None, command=None command (callable, optional): Called with the file path after selection. initialdir (str, optional): Initial directory for the file dialog. mode (str): Either "file" or "directory" to select files or directories. + item_title (str, optional): Optional header text shown at the top of the widget frame. + helper_text (str, optional): Optional helper text shown above the entry box. **kwargs: Additional Frame options. """ super().__init__(parent, **kwargs) @@ -23,6 +26,19 @@ def __init__(self, parent, button_text="Browse...", filetypes=None, command=None self.initialdir = initialdir self.mode = mode + self.item_title = item_title + self.helper_text = helper_text + + # Optional header/title label at the top of the widget + if self.item_title: + self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") + self.title_label.pack(side="top", anchor="w") + + # Optional helper/requirements label above the input + if self.helper_text: + self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") + self.helper_label.pack(side="top", anchor="w") + self.display_name = tk.StringVar() self.entry = ttk.Entry(self, textvariable=self.display_name, width=40) self.entry.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True) @@ -66,14 +82,16 @@ def set(self, path): if __name__ == "__main__": - # Test the file selector widget - root = tk.Tk() - root.title("File Selector Test") + + from python_toolkit.tkinter.DefaultRoot import DefaultRoot + + root = DefaultRoot() + parent_container = root.content_frame def on_file_selected(path): print(f"Selected: {path}") - path_selector = PathSelector(root, button_text="Select File", filetypes=[("All Files", "*.*")], command=on_file_selected) + path_selector = PathSelector(parent_container, button_text="Select File", filetypes=[("All Files", "*.*")], command=on_file_selected, item_title="File Selector", helper_text="Please select a file to process.") path_selector.pack(padx=20, pady=20) root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/RadioSelection.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/RadioSelection.py index eae9927..1124fda 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/RadioSelection.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/RadioSelection.py @@ -1,10 +1,11 @@ import tkinter as tk from tkinter import ttk +from typing import Optional -class RadioSelection(ttk.Frame): +class RadioSelection(tk.Frame): """A reusable radio selection widget built from a list of fields.""" - def __init__(self, parent, fields=None, command=None, default=None, orient="vertical", max_per_line=None, **kwargs): + def __init__(self, parent, fields=None, command=None, default=None, orient="vertical", max_per_line=None, item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): """ Args: parent (tk.Widget): The parent widget. @@ -13,6 +14,8 @@ def __init__(self, parent, fields=None, command=None, default=None, orient="vert default (str, optional): Default selected value. orient (str): Either "vertical" or "horizontal". max_per_line (int, optional): Maximum items per row/column before wrapping. + item_title (str, optional): Optional header text shown at the top of the widget frame. + helper_text (str, optional): Optional helper text shown above the entry box. **kwargs: Additional Frame options. """ super().__init__(parent, **kwargs) @@ -21,9 +24,25 @@ def __init__(self, parent, fields=None, command=None, default=None, orient="vert self.command = command self.orient = orient.lower() self.max_per_line = max_per_line + self.item_title = item_title + self.helper_text = helper_text self.value_var = tk.StringVar() self._buttons = [] + # Optional header/title label at the top of the widget + if self.item_title: + self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") + self.title_label.pack(side="top", anchor="w") + + # Optional helper/requirements label above the input + if self.helper_text: + self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") + self.helper_label.pack(side="top", anchor="w") + + # Sub-frame for radio button controls + self.buttons_frame = ttk.Frame(self) + self.buttons_frame.pack(side="top", fill="x", expand=True) + self._build_buttons() if default is not None: @@ -39,7 +58,7 @@ def _build_buttons(self): for index, field in enumerate(self.fields): button = ttk.Radiobutton( - self, + self.buttons_frame, text=field, value=field, variable=self.value_var, @@ -88,19 +107,24 @@ def set_fields(self, fields, default=None): if __name__ == "__main__": - root = tk.Tk() - root.title("Radio Selection Test") + + from python_toolkit.tkinter.DefaultRoot import DefaultRoot def on_selection(value): print(f"Selected: {value}") + root = DefaultRoot() + parent_frame = root.content_frame + widget = RadioSelection( - root, + parent_frame, fields=["Option A", "Option B", "Option C", "Option D", "Option E", "Option F", "Option G"], command=on_selection, default="Option B", orient="vertical", max_per_line=6, + item_title="Choose an Option", + helper_text="Select one of the options below:" ) widget.pack(padx=20, pady=20) diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py index 4b09c41..488e759 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py @@ -2,7 +2,7 @@ from tkinter import ttk from typing import Optional, Callable, Any, Union -class ValidatedEntryBox(ttk.Frame): +class ValidatedEntryBox(tk.Frame): """ A reusable entry box component with built-in validation for different data types. @@ -15,7 +15,9 @@ class ValidatedEntryBox(ttk.Frame): def __init__( self, - parent: tk.Widget, + parent, + item_title: Optional[str] = None, + helper_text: Optional[str] = None, variable: Optional[tk.StringVar] = None, width: int = 15, value_type: type = str, @@ -32,7 +34,9 @@ def __init__( Initialize the ValidatedEntryBox. Args: - parent: Parent widget + 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) width: Width of the entry widget value_type: Type to validate against (str, int, float) @@ -53,14 +57,28 @@ def __init__( self.required = required self.custom_validator = custom_validator self.on_validate = on_validate - - # Create frame to hold entry and error label + self.item_title = item_title + self.helper_text = helper_text + + # Optional header/title label at the top of the widget + if self.item_title: + self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") + self.title_label.pack(side="top", anchor="w") + + # Optional helper/requirements label above the input + if self.helper_text: + self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") + self.helper_label.pack(side="top", anchor="w") + + # Create frame to hold entry and error label on one row + self.input_row = ttk.Frame(self) + self.input_row.pack(side="top", fill="x", expand=True) # Create or use provided StringVar self.variable = variable if variable is not None else tk.StringVar(value="") # Create entry widget - self.entry = ttk.Entry(self, textvariable=self.variable, width=width) + self.entry = ttk.Entry(self.input_row, textvariable=self.variable, width=width) self.entry.pack(side="left", fill="x", expand=True) # Bind validation events @@ -68,21 +86,9 @@ def __init__( self.entry.bind("", lambda _: self.validate()) # Create error label - self.error_label = ttk.Label(self, text="", style="Error.TLabel") + self.error_label = ttk.Label(self.input_row, text="", style="Error.TLabel") self.error_label.pack(side="left", padx=(10, 0)) - def pack(self, **kwargs) -> None: - """Pack the entry box frame.""" - self.pack(**kwargs) - - def grid(self, **kwargs) -> None: - """Grid the entry box frame.""" - self.grid(**kwargs) - - def place(self, **kwargs) -> None: - """Place the entry box frame.""" - self.place(**kwargs) - def get(self) -> str: """Get the current value as a string.""" return self.variable.get().strip() @@ -256,13 +262,23 @@ def _call_validate_callback(self, is_valid: bool) -> None: if __name__ == "__main__": # Test the ValidatedEntryBox - root = tk.Tk() - root.title("Validated Entry Box Test") + + from python_toolkit.tkinter.DefaultRoot import DefaultRoot + root = DefaultRoot(title="Validated Entry Box Test") + parent_container = getattr(root, "content_frame", root) def on_validate(is_valid): print(f"Validation result: {is_valid}") - entry_box = ValidatedEntryBox(root, value_type=int, min_value=0, max_value=100, on_validate=on_validate) + entry_box = ValidatedEntryBox( + parent_container, + item_title="Integer Field", + helper_text="Enter an integer from 0 to 100", + value_type=int, + min_value=0, + max_value=100, + on_validate=on_validate + ) entry_box.pack(padx=20, pady=20) root.mainloop() \ No newline at end of file From f05f88a9f6523ba20df6958e420497131e0c644a Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Mon, 23 Feb 2026 18:26:46 +0000 Subject: [PATCH 08/28] add a multi tick selection widget --- .../src/python_toolkit/plot/cmap_sample.py | 1 + .../tkinter/widgets/CheckBoxSelection.py | 202 ++++++++++++++++++ .../tkinter/widgets/CmapSelector.py | 30 ++- .../tkinter/widgets/FigureContainer.py | 73 ++++++- .../tkinter/widgets/MultiBoxSelection.py | 196 +++++++++++++++++ 5 files changed, 494 insertions(+), 8 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter/widgets/CheckBoxSelection.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter/widgets/MultiBoxSelection.py diff --git a/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py index e246bdc..a5cbf85 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py +++ b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py @@ -36,6 +36,7 @@ def cmap_sample_plot( # Create the figure and axis fig, ax = plt.subplots(figsize=figsize) fig.patch.set_alpha(0) + fig.patch.set_facecolor("none") ax.patch.set_alpha(0) ax.set_facecolor("none") diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CheckBoxSelection.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CheckBoxSelection.py new file mode 100644 index 0000000..62d8c16 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CheckBoxSelection.py @@ -0,0 +1,202 @@ +import tkinter as tk +from tkinter import ttk +from typing import Optional, List, Callable + +class CheckboxSelection(tk.Frame): + """A reusable checkbox selection widget built from a list of fields, allowing multiple selections.""" + + def __init__(self, parent, fields=None, command: Optional[Callable[[List[str]], None]] = None, defaults: Optional[List[str]] = None, orient="vertical", max_per_line=None, item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + fields (list, optional): List of selectable fields. + command (callable, optional): Called with list of selected values when changed. + defaults (list, optional): List of default selected values. + orient (str): Either "vertical" or "horizontal". + max_per_line (int, optional): Maximum items per row/column before wrapping. + item_title (str, optional): Optional header text shown at the top of the widget frame. + helper_text (str, optional): Optional helper text shown above the checkboxes. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.fields = [str(field) for field in (fields or [])] + self.command = command + self.orient = orient.lower() + self.max_per_line = max_per_line + self.item_title = item_title + self.helper_text = helper_text + self.value_vars = {} # Dictionary mapping field names to BooleanVars + self._buttons = [] + + # Optional header/title label at the top of the widget + if self.item_title: + self.title_label = ttk.Label(self, text=self.item_title) + self.title_label.pack(side="top", anchor="w") + + # Optional helper/requirements label above the input + if self.helper_text: + self.helper_label = ttk.Label(self, text=self.helper_text) + self.helper_label.pack(side="top", anchor="w") + + # Sub-frame for checkbox controls + self.buttons_frame = ttk.Frame(self) + self.buttons_frame.pack(side="top", fill="x", expand=True) + + self._build_buttons() + + if defaults: + self.set(defaults) + + def _build_buttons(self): + """Create checkboxes from current fields.""" + for button in self._buttons: + button.destroy() + self._buttons.clear() + self.value_vars.clear() + + for index, field in enumerate(self.fields): + var = tk.BooleanVar(value=False) + self.value_vars[field] = var + + button = ttk.Label( + self.buttons_frame, + text=f"□ {field}", + cursor="hand2" + ) + button.bind("", lambda e, f=field: self._toggle_field(f)) + + if self.max_per_line and self.max_per_line > 0: + if self.orient == "horizontal": + row = index // self.max_per_line + column = index % self.max_per_line + else: + row = index % self.max_per_line + column = index // self.max_per_line + button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky="w") + elif self.orient == "horizontal": + button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky="w") + else: + button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky="w") + self._buttons.append(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.""" + # Find the button for this field and update its text + for button in self._buttons: + button_text = button.cget("text") + # Extract field name (remove the box indicator) + current_field = button_text[2:] # Skip "□ " or "■ " + if current_field == field: + if self.value_vars[field].get(): + button.configure(text=f"■ {field}") + else: + button.configure(text=f"□ {field}") + break + + if self.command: + self.command(self.get()) + + def get(self) -> List[str]: + """Return a list of currently selected values.""" + return [field for field, var in self.value_vars.items() if var.get()] + + def set(self, values: List[str]): + """Set the selected values. Accepts a list of field names to check.""" + values = [str(v) for v in (values or [])] + for field, var in self.value_vars.items(): + var.set(field in values) + + # Update visual indicators + for button in self._buttons: + button_text = button.cget("text") + current_field = button_text[2:] # Skip "□ " or "■ " + if current_field in values: + button.configure(text=f"■ {current_field}") + else: + button.configure(text=f"□ {current_field}") + + def select_all(self): + """Select all checkboxes.""" + for var in self.value_vars.values(): + var.set(True) + # Update visual indicators + for button in self._buttons: + button_text = button.cget("text") + field = button_text[2:] # Skip box indicator + button.configure(text=f"■ {field}") + if self.command: + self.command(self.get()) + + def deselect_all(self): + """Deselect all checkboxes.""" + for var in self.value_vars.values(): + var.set(False) + # Update visual indicators + for button in self._buttons: + button_text = button.cget("text") + field = button_text[2:] # Skip box indicator + button.configure(text=f"□ {field}") + if self.command: + self.command(self.get()) + + def toggle_all(self): + """Toggle all checkbox states.""" + for var in self.value_vars.values(): + var.set(not var.get()) + # Update visual indicators + for button in self._buttons: + button_text = button.cget("text") + field = button_text[2:] # Skip box indicator + if self.value_vars[field].get(): + button.configure(text=f"■ {field}") + else: + button.configure(text=f"□ {field}") + if self.command: + self.command(self.get()) + + def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): + """Replace the available fields and rebuild the widget.""" + self.fields = [str(field) for field in (fields or [])] + self._build_buttons() + + if defaults: + self.set(defaults) + + +if __name__ == "__main__": + + from python_toolkit.tkinter.DefaultRoot import DefaultRoot + + def on_selection(values): + print(f"Selected: {values}") + + root = DefaultRoot() + parent_frame = root.content_frame + + widget = CheckboxSelection( + parent_frame, + fields=["Option A", "Option B", "Option C", "Option D", "Option E", "Option F", "Option G"], + command=on_selection, + defaults=["Option B", "Option D"], + orient="vertical", + max_per_line=6, + item_title="Choose Options", + helper_text="Select one or more options below:" + ) + widget.pack(padx=20, pady=20) + + # Add control buttons for demonstration + control_frame = ttk.Frame(parent_frame) + control_frame.pack(padx=20, pady=10) + + ttk.Button(control_frame, text="Select All", command=widget.select_all).pack(side="left", padx=5) + ttk.Button(control_frame, text="Deselect All", command=widget.deselect_all).pack(side="left", padx=5) + ttk.Button(control_frame, text="Toggle All", command=widget.toggle_all).pack(side="left", padx=5) + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py index c13fb13..ca180e8 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py @@ -58,6 +58,8 @@ def __init__( parent: tk.Widget, colormaps: Optional[List[str]] = None, cmap_set: str = "all", + item_title: Optional[str] = None, + helper_text: Optional[str] = None, **kwargs ) -> None: """ @@ -73,8 +75,22 @@ def __init__( """ super().__init__(parent, **kwargs) + # Optional header/title label at the top of the widget + if item_title: + self.title_label = ttk.Label(self, text=item_title, style="Header.TLabel") + self.title_label.pack(side="top", anchor="w", pady=(0, 4)) + + # Optional helper/requirements label above the input + if helper_text: + self.helper_label = ttk.Label(self, text=helper_text, style="Caption.TLabel") + self.helper_label.pack(side="top", anchor="w", pady=(0, 8)) + mpl.use("Agg") # Use non-interactive backend for embedding in Tkinter + # Create frame for cmap selection content + self.cmap_frame = ttk.Frame(self) + self.cmap_frame.pack(side="top", fill="both", expand=True) + self.colormap_var = tk.StringVar(value="viridis") self._all_colormaps = self._get_all_colormaps() self._preset_map: Dict[str, List[str]] = { @@ -84,10 +100,10 @@ def __init__( } self._uses_explicit_colormaps = colormaps is not None - self.columnconfigure(0, weight=1) - self.rowconfigure(0, weight=1) + self.cmap_frame.columnconfigure(0, weight=1) + self.cmap_frame.rowconfigure(0, weight=1) - content = ttk.Frame(self, width=440, height=130) + content = ttk.Frame(self.cmap_frame, width=440, height=130) content.grid(row=0, column=0, padx=4, pady=4) content.grid_propagate(False) @@ -96,7 +112,6 @@ def __init__( self.cmap_set_var = tk.StringVar(value=cmap_set.lower()) - ttk.Label(header, text="Select cmap").pack(side=tk.LEFT, padx=(0, 8)) self.cmap_combobox = ttk.Combobox( header, textvariable=self.colormap_var, @@ -185,12 +200,17 @@ def _update_cmap_sample(self, *args) -> None: fig = cmap_sample_plot(cmap_name, figsize=(4, 1)) self.figure_widget.embed_figure(fig) + def get_selected_cmap(self) -> Optional[str]: + """Return the currently selected colormap name, or None if no selection.""" + cmap_name = self.colormap_var.get() + return cmap_name if cmap_name else None + if __name__ == "__main__": from python_toolkit.tkinter.DefaultRoot import DefaultRoot root = DefaultRoot() parent_container = root.content_frame - cmap_selector = CmapSelector(parent_container, cmap_set="all") + cmap_selector = CmapSelector(parent_container, cmap_set="all", item_title="Colormap Selector", helper_text="Select a colormap from the list.") cmap_selector.pack(fill=tk.BOTH, expand=True) root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py index cdf4cfa..d8df77c 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py @@ -6,6 +6,7 @@ import matplotlib.pyplot as plt + class FigureContainer(tk.Frame): """ A reusable widget for embedding matplotlib figures and images. @@ -32,6 +33,7 @@ def __init__( self.figure: Optional[Figure] = None self.image: Optional[tk.PhotoImage] = None self.image_file: Optional[str] = None + self._original_pil_image = None self.item_title = item_title self.helper_text = helper_text @@ -95,12 +97,18 @@ def embed_figure(self, figure: Figure) -> None: self.figure = figure self.canvas = FigureCanvasTkAgg(figure, master=self.content_frame) + + # Set canvas background to match the frame background for transparency + bg_color = self._resolved_background() + self.canvas.get_tk_widget().configure(bg=bg_color, highlightthickness=0) + self.canvas.draw() self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) def embed_image(self, image: tk.PhotoImage) -> None: """ Embed a Tk image in the figure container. + Note: For automatic scaling, use embed_image_file() instead with PIL support. Args: image: Tk PhotoImage object to embed @@ -108,6 +116,7 @@ def embed_image(self, image: tk.PhotoImage) -> None: self._clear_children() self.image = image + self._original_pil_image = None # Create label to display the image self.image_label = ttk.Label(self.content_frame, image=image) @@ -115,14 +124,71 @@ def embed_image(self, image: tk.PhotoImage) -> None: def embed_image_file(self, file_path: str) -> None: """ - Load and embed an image file supported by Tk PhotoImage. + Load and embed an image file, scaled to fit the container. Args: file_path: Path to image file """ - image = tk.PhotoImage(file=file_path) self.image_file = file_path - self.embed_image(image) + self._clear_children() + + try: + from PIL import Image, ImageTk + + # Load with PIL for better scaling + pil_image = Image.open(file_path) + self._original_pil_image = pil_image + + # Create label + self.image_label = ttk.Label(self.content_frame) + self.image_label.pack(fill=tk.BOTH, expand=True) + + # Scale and display + self.content_frame.bind("", lambda e: self._scale_image_to_fit()) + self._scale_image_to_fit() + + except ImportError: + # Fallback to basic PhotoImage without scaling + image = tk.PhotoImage(file=file_path) + self.image = image + self.image_label = ttk.Label(self.content_frame, image=image) + self.image_label.pack(fill=tk.BOTH, expand=True) + + def _scale_image_to_fit(self): + """Scale the image to fit within the content frame while maintaining aspect ratio.""" + if not hasattr(self, '_original_pil_image') or self._original_pil_image is None: + return + + # Get current frame dimensions + frame_width = self.content_frame.winfo_width() + frame_height = self.content_frame.winfo_height() + + # Skip if frame not yet sized + if frame_width <= 1 or frame_height <= 1: + return + + try: + from PIL import Image, ImageTk + + # Calculate scaling factor to fit + img_width, img_height = self._original_pil_image.size + scale_width = frame_width / img_width + scale_height = frame_height / img_height + scale = min(scale_width, scale_height) + + # Calculate new dimensions + new_width = int(img_width * scale) + new_height = int(img_height * scale) + + # Resize image + resized = self._original_pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Convert to PhotoImage and update label + self.image = ImageTk.PhotoImage(resized) + if self.image_label: + self.image_label.configure(image=self.image) + except Exception as e: + pass # Silently handle scaling errors def clear(self) -> None: """Clear the figure container.""" @@ -136,6 +202,7 @@ def clear(self) -> None: self.figure = None self.image = None self.image_label = None + self._original_pil_image = None if __name__ == "__main__": diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/MultiBoxSelection.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/MultiBoxSelection.py new file mode 100644 index 0000000..7dd20c0 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/MultiBoxSelection.py @@ -0,0 +1,196 @@ +import tkinter as tk +from tkinter import ttk +from typing import Optional, List, Callable + +class CheckboxSelection(tk.Frame): + """A reusable checkbox selection widget built from a list of fields, allowing multiple selections.""" + + def __init__(self, parent, fields=None, command: Optional[Callable[[List[str]], None]] = None, defaults: Optional[List[str]] = None, orient="vertical", max_per_line=None, item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + fields (list, optional): List of selectable fields. + command (callable, optional): Called with list of selected values when changed. + defaults (list, optional): List of default selected values. + orient (str): Either "vertical" or "horizontal". + max_per_line (int, optional): Maximum items per row/column before wrapping. + item_title (str, optional): Optional header text shown at the top of the widget frame. + helper_text (str, optional): Optional helper text shown above the checkboxes. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.fields = [str(field) for field in (fields or [])] + self.command = command + self.orient = orient.lower() + self.max_per_line = max_per_line + self.item_title = item_title + self.helper_text = helper_text + self.value_vars = {} # Dictionary mapping field names to BooleanVars + self._buttons = [] + + # Optional header/title label at the top of the widget + if self.item_title: + self.title_label = ttk.Label(self, text=self.item_title) + self.title_label.pack(side="top", anchor="w") + + # Optional helper/requirements label above the input + if self.helper_text: + self.helper_label = ttk.Label(self, text=self.helper_text) + self.helper_label.pack(side="top", anchor="w") + + # Sub-frame for checkbox controls + self.buttons_frame = ttk.Frame(self) + self.buttons_frame.pack(side="top", fill="x", expand=True) + + self._build_buttons() + + if defaults: + self.set(defaults) + + def _build_buttons(self): + """Create checkboxes from current fields.""" + for button in self._buttons: + button.destroy() + self._buttons.clear() + self.value_vars.clear() + + for index, field in enumerate(self.fields): + var = tk.BooleanVar(value=False) + self.value_vars[field] = var + + button = ttk.Checkbutton( + self.buttons_frame, + text=f"□ {field}", + variable=var, + command=lambda f=field: self._on_select_field(f), + ) + if self.max_per_line and self.max_per_line > 0: + if self.orient == "horizontal": + row = index // self.max_per_line + column = index % self.max_per_line + else: + row = index % self.max_per_line + column = index // self.max_per_line + button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky="w") + elif self.orient == "horizontal": + button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky="w") + else: + button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky="w") + self._buttons.append(button) + + def _on_select_field(self, field): + """Handle checkbox selection change and update visual indicator.""" + # Find the button for this field and update its text + for button in self._buttons: + button_text = button.cget("text") + # Extract field name (remove the box indicator) + current_field = button_text[2:] # Skip "□ " or "■ " + if current_field == field: + if self.value_vars[field].get(): + button.configure(text=f"■ {field}") + else: + button.configure(text=f"□ {field}") + break + + if self.command: + self.command(self.get()) + + def get(self) -> List[str]: + """Return a list of currently selected values.""" + return [field for field, var in self.value_vars.items() if var.get()] + + def set(self, values: List[str]): + """Set the selected values. Accepts a list of field names to check.""" + values = [str(v) for v in (values or [])] + for field, var in self.value_vars.items(): + var.set(field in values) + + # Update visual indicators + for button in self._buttons: + button_text = button.cget("text") + current_field = button_text[2:] # Skip "□ " or "■ " + if current_field in values: + button.configure(text=f"■ {current_field}") + else: + button.configure(text=f"□ {current_field}") + + def select_all(self): + """Select all checkboxes.""" + for var in self.value_vars.values(): + var.set(True) + # Update visual indicators + for button in self._buttons: + button_text = button.cget("text") + field = button_text[2:] # Skip box indicator + button.configure(text=f"■ {field}") + if self.command: + self.command(self.get()) + + def deselect_all(self): + """Deselect all checkboxes.""" + for var in self.value_vars.values(): + var.set(False) + # Update visual indicators + for button in self._buttons: + button_text = button.cget("text") + field = button_text[2:] # Skip box indicator + button.configure(text=f"□ {field}") + if self.command: + self.command(self.get()) + + def toggle_all(self): + """Toggle all checkbox states.""" + for var in self.value_vars.values(): + var.set(not var.get()) + # Update visual indicators + for button in self._buttons: + button_text = button.cget("text") + field = button_text[2:] # Skip box indicator + if self.value_vars[field].get(): + button.configure(text=f"■ {field}") + else: + button.configure(text=f"□ {field}") + if self.command: + self.command(self.get()) + + def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): + """Replace the available fields and rebuild the widget.""" + self.fields = [str(field) for field in (fields or [])] + self._build_buttons() + + if defaults: + self.set(defaults) + + +if __name__ == "__main__": + + from python_toolkit.tkinter.DefaultRoot import DefaultRoot + + def on_selection(values): + print(f"Selected: {values}") + + root = DefaultRoot() + parent_frame = root.content_frame + + widget = CheckboxSelection( + parent_frame, + fields=["Option A", "Option B", "Option C", "Option D", "Option E", "Option F", "Option G"], + command=on_selection, + defaults=["Option B", "Option D"], + orient="vertical", + max_per_line=6, + item_title="Choose Options", + helper_text="Select one or more options below:" + ) + widget.pack(padx=20, pady=20) + + # Add control buttons for demonstration + control_frame = ttk.Frame(parent_frame) + control_frame.pack(padx=20, pady=10) + + ttk.Button(control_frame, text="Select All", command=widget.select_all).pack(side="left", padx=5) + ttk.Button(control_frame, text="Deselect All", command=widget.deselect_all).pack(side="left", padx=5) + ttk.Button(control_frame, text="Toggle All", command=widget.toggle_all).pack(side="left", padx=5) + + root.mainloop() From 784a6a5c8d1e11dfefd2e9e4884060622bf0dc63 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Tue, 24 Feb 2026 10:13:13 +0000 Subject: [PATCH 09/28] Added colour picker tool changes to mpl renderering and small tcl tweaks --- .../python_toolkit/bhom/bhom_dark_theme.tcl | 29 ++- .../python_toolkit/bhom/bhom_light_theme.tcl | 29 ++- .../src/python_toolkit/tkinter/WarningBox.py | 1 + .../tkinter/widgets/CmapSelector.py | 2 + .../tkinter/widgets/ColourPicker.py | 245 ++++++++++++++++++ .../tkinter/widgets/FigureContainer.py | 3 +- .../tkinter/widgets/MultiBoxSelection.py | 5 +- 7 files changed, 301 insertions(+), 13 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter/WarningBox.py create mode 100644 Python_Engine/Python/src/python_toolkit/tkinter/widgets/ColourPicker.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl index 1654dd5..c956446 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl @@ -454,14 +454,15 @@ namespace eval ttk::theme::bhom_dark { active $colors(-primary) \ pressed $colors(-primary-hover)] - # Scale - minimal sleek design + # Scale - clearer track and larger thumb ttk::style configure TScale \ -background $colors(-primary) \ - -troughcolor $colors(-bg) \ - -bordercolor $colors(-bg) \ + -troughcolor $colors(-border) \ + -bordercolor $colors(-border-light) \ -slidercolor $colors(-primary) \ - -borderwidth 0 \ - -sliderrelief flat + -borderwidth 1 \ + -sliderrelief raised \ + -sliderlength 20 ttk::style map TScale \ -background [list \ @@ -471,6 +472,24 @@ namespace eval ttk::theme::bhom_dark { active $colors(-primary-light) \ pressed $colors(-primary-hover)] + # Colour picker scale variant - stronger contrast and larger grab handle + ttk::style configure ColourPicker.Horizontal.TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-darker) \ + -bordercolor $colors(-border-light) \ + -slidercolor $colors(-primary-light) \ + -borderwidth 1 \ + -sliderrelief raised \ + -sliderlength 24 + + ttk::style map ColourPicker.Horizontal.TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + # Progressbar - soft rounded design ttk::style configure TProgressbar \ -background $colors(-primary) \ diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl index e436e0b..64fd4ec 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl @@ -454,14 +454,15 @@ namespace eval ttk::theme::bhom_light { active $colors(-primary) \ pressed $colors(-primary-hover)] - # Scale - minimal sleek design + # Scale - clearer track and larger thumb ttk::style configure TScale \ -background $colors(-primary) \ - -troughcolor $colors(-bg) \ - -bordercolor $colors(-bg) \ + -troughcolor $colors(-border-light) \ + -bordercolor $colors(-border) \ -slidercolor $colors(-primary) \ - -borderwidth 0 \ - -sliderrelief flat + -borderwidth 1 \ + -sliderrelief raised \ + -sliderlength 20 ttk::style map TScale \ -background [list \ @@ -471,6 +472,24 @@ namespace eval ttk::theme::bhom_light { active $colors(-primary-light) \ pressed $colors(-primary-hover)] + # Colour picker scale variant - stronger contrast and larger grab handle + ttk::style configure ColourPicker.Horizontal.TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-darker) \ + -bordercolor $colors(-border) \ + -slidercolor $colors(-primary-light) \ + -borderwidth 1 \ + -sliderrelief raised \ + -sliderlength 24 + + ttk::style map ColourPicker.Horizontal.TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + active $colors(-primary) \ + pressed $colors(-primary-hover)] + # Progressbar - soft rounded design ttk::style configure TProgressbar \ -background $colors(-primary) \ diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/WarningBox.py b/Python_Engine/Python/src/python_toolkit/tkinter/WarningBox.py new file mode 100644 index 0000000..8d2d1f2 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter/WarningBox.py @@ -0,0 +1 @@ +"""This will be used in place of warning / pop up boxes, to ensure consistent style""" \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py index ca180e8..8570608 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py @@ -6,6 +6,8 @@ from python_toolkit.plot.cmap_sample import cmap_sample_plot from python_toolkit.tkinter.widgets.FigureContainer import FigureContainer +mpl.use("Agg") # Use non-interactive backend for embedding in Tkinter + class CmapSelector(ttk.Frame): """ A widget for selecting and previewing a matplotlib colormap. diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ColourPicker.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ColourPicker.py new file mode 100644 index 0000000..2cd5f2a --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ColourPicker.py @@ -0,0 +1,245 @@ +import tkinter as tk +from tkinter import ttk +from typing import Callable, Optional + + +class ColourPicker(ttk.Frame): + """A simple colour picker widget where the swatch opens a themed colour dialog.""" + + def __init__( + self, + parent: tk.Widget, + default_colour: str = "#ffffff", + swatch_width: int = 48, + swatch_height: int = 28, + command: Optional[Callable[[str], None]] = None, + item_title: Optional[str] = None, + helper_text: Optional[str] = None, + **kwargs, + ) -> None: + """ + Args: + parent: Parent widget. + default_colour: Initial colour value in hex format. + swatch_width: Width of the clickable colour swatch in pixels. + swatch_height: Height of the clickable colour swatch in pixels. + command: Optional callback called with selected hex colour. + item_title: Optional header text shown at the top of the widget frame. + helper_text: Optional helper text shown above the controls. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.command = command + self.colour_var = tk.StringVar(value=default_colour) + self.swatch_width = max(1, int(swatch_width)) + self.swatch_height = max(1, int(swatch_height)) + self._picker_window: Optional[tk.Toplevel] = None + self._popup_preview: Optional[tk.Canvas] = None + self._popup_swatch = None + self._popup_hex_var: Optional[tk.StringVar] = None + self._popup_red_var: Optional[tk.IntVar] = None + self._popup_green_var: Optional[tk.IntVar] = None + self._popup_blue_var: Optional[tk.IntVar] = None + + if item_title: + self.title_label = ttk.Label(self, text=item_title, style="Header.TLabel") + self.title_label.pack(side="top", anchor="w", pady=(0, 4)) + + if helper_text: + self.helper_label = ttk.Label(self, text=helper_text, style="Caption.TLabel") + self.helper_label.pack(side="top", anchor="center", pady=(0, 8)) + + controls = ttk.Frame(self) + controls.pack(side="top", anchor="center") + + self.preview = tk.Canvas( + controls, + width=self.swatch_width, + height=self.swatch_height, + highlightthickness=1, + cursor="hand2", + ) + self.preview.pack() + self._swatch = self.preview.create_rectangle(0, 0, self.swatch_width, self.swatch_height, outline="#666666") + self.preview.bind("", lambda _event: self._select_colour()) + + self._update_preview(default_colour) + + def _select_colour(self) -> None: + """Open the themed colour picker popup.""" + if self._picker_window and self._picker_window.winfo_exists(): + self._picker_window.focus_force() + return + + current_colour = self.get() + red, green, blue = self._hex_to_rgb(current_colour) + + root_window = self.winfo_toplevel() + window = tk.Toplevel(root_window) + window.title("Select Colour") + window.transient(root_window) + window.resizable(False, False) + window.protocol("WM_DELETE_WINDOW", self._close_picker) + + self._picker_window = window + self._popup_red_var = tk.IntVar(value=red) + self._popup_green_var = tk.IntVar(value=green) + self._popup_blue_var = tk.IntVar(value=blue) + self._popup_hex_var = tk.StringVar(value=current_colour) + + container = ttk.Frame(window, padding=10) + container.pack(fill=tk.BOTH, expand=True) + + self._build_slider_row(container, "R", self._popup_red_var, 0) + self._build_slider_row(container, "G", self._popup_green_var, 1) + self._build_slider_row(container, "B", self._popup_blue_var, 2) + + hex_row = ttk.Frame(container) + hex_row.grid(row=3, column=0, sticky="ew", pady=(8, 4)) + ttk.Label(hex_row, text="Hex").pack(side=tk.LEFT) + hex_entry = ttk.Entry(hex_row, textvariable=self._popup_hex_var, width=10) + hex_entry.pack(side=tk.LEFT, padx=(8, 0)) + hex_entry.bind("", lambda _event: self._on_hex_entered()) + + preview_row = ttk.Frame(container) + preview_row.grid(row=4, column=0, sticky="w", pady=(4, 8)) + ttk.Label(preview_row, text="Preview").pack(side=tk.LEFT) + self._popup_preview = tk.Canvas(preview_row, width=48, height=28, highlightthickness=1) + self._popup_preview.pack(side=tk.LEFT, padx=(8, 0)) + self._popup_swatch = self._popup_preview.create_rectangle(0, 0, 48, 28, outline="#666666") + + button_row = ttk.Frame(container) + button_row.grid(row=5, column=0, sticky="e") + ttk.Button(button_row, text="Cancel", command=self._close_picker).pack(side=tk.LEFT, padx=(0, 6)) + ttk.Button(button_row, text="Apply", command=self._apply_popup_colour).pack(side=tk.LEFT) + + container.columnconfigure(0, weight=1) + + self._on_rgb_changed() + window.grab_set() + hex_entry.focus_set() + + def _update_preview(self, colour: str) -> None: + """Render the selected colour in the preview swatch.""" + self.preview.itemconfig(self._swatch, fill=colour) + + def _build_slider_row(self, parent: ttk.Frame, label_text: str, value_var: tk.IntVar, row: int) -> None: + row_frame = ttk.Frame(parent) + row_frame.grid(row=row, column=0, sticky="ew", pady=2) + + ttk.Label(row_frame, text=label_text).pack(side=tk.LEFT) + slider = ttk.Scale( + row_frame, + from_=0, + to=255, + orient=tk.HORIZONTAL, + style="ColourPicker.Horizontal.TScale", + command=lambda value, var=value_var: self._on_slider_move(var, value), + ) + slider.set(value_var.get()) + slider.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(8, 8)) + value_label = ttk.Label(row_frame, textvariable=value_var, width=4, anchor="e") + value_label.pack(side=tk.LEFT) + + def _on_slider_move(self, value_var: tk.IntVar, value: str) -> None: + value_var.set(int(float(value))) + self._on_rgb_changed() + + def _on_rgb_changed(self) -> None: + if not (self._popup_red_var and self._popup_green_var and self._popup_blue_var and self._popup_hex_var): + return + + colour = self._rgb_to_hex(self._popup_red_var.get(), self._popup_green_var.get(), self._popup_blue_var.get()) + self._popup_hex_var.set(colour) + self._update_popup_preview(colour) + + def _on_hex_entered(self) -> None: + if not self._popup_hex_var: + return + + red, green, blue = self._hex_to_rgb(self._popup_hex_var.get()) + if self._popup_red_var and self._popup_green_var and self._popup_blue_var: + self._popup_red_var.set(red) + self._popup_green_var.set(green) + self._popup_blue_var.set(blue) + self._popup_hex_var.set(self._rgb_to_hex(red, green, blue)) + self._update_popup_preview(self._popup_hex_var.get()) + + def _update_popup_preview(self, colour: str) -> None: + if self._popup_preview and self._popup_swatch is not None: + self._popup_preview.itemconfig(self._popup_swatch, fill=colour) + + def _apply_popup_colour(self) -> None: + if not self._popup_hex_var: + return + + selected = self._rgb_to_hex(*self._hex_to_rgb(self._popup_hex_var.get())) + self.set(selected) + if self.command: + self.command(selected) + self._close_picker() + + def _close_picker(self) -> None: + if self._picker_window and self._picker_window.winfo_exists(): + try: + self._picker_window.grab_release() + except tk.TclError: + pass + self._picker_window.destroy() + + self._picker_window = None + self._popup_preview = None + self._popup_swatch = None + self._popup_hex_var = None + self._popup_red_var = None + self._popup_green_var = None + self._popup_blue_var = None + + @staticmethod + def _rgb_to_hex(red: int, green: int, blue: int) -> str: + red = max(0, min(255, int(red))) + green = max(0, min(255, int(green))) + blue = max(0, min(255, int(blue))) + return f"#{red:02x}{green:02x}{blue:02x}" + + @staticmethod + def _hex_to_rgb(colour: str) -> tuple[int, int, int]: + value = (colour or "").strip().lstrip("#") + if len(value) == 3: + value = "".join(ch * 2 for ch in value) + if len(value) != 6: + return 255, 255, 255 + try: + return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16) + except ValueError: + return 255, 255, 255 + + def get(self) -> str: + """Return the currently selected hex colour.""" + return self.colour_var.get() + + def set(self, colour: str) -> None: + """Set the current colour and refresh the preview swatch.""" + self.colour_var.set(colour) + self._update_preview(colour) + + +if __name__ == "__main__": + from python_toolkit.tkinter.DefaultRoot import DefaultRoot + + root = DefaultRoot() + parent_container = root.content_frame + + def on_colour_changed(colour: str) -> None: + print(f"Selected colour: {colour}") + + colour_picker = ColourPicker( + parent_container, + command=on_colour_changed, + item_title="Colour Picker", + helper_text="Pick a colour for plotting.", + ) + colour_picker.pack(padx=20, pady=20, anchor="w") + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py index d8df77c..f0a7497 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py @@ -5,7 +5,8 @@ from matplotlib.figure import Figure import matplotlib.pyplot as plt - +import matplotlib as mpl +mpl.use("Agg") # Use non-interactive backend for embedding in Tkinter class FigureContainer(tk.Frame): """ diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/MultiBoxSelection.py b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/MultiBoxSelection.py index 7dd20c0..4f84327 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/MultiBoxSelection.py +++ b/Python_Engine/Python/src/python_toolkit/tkinter/widgets/MultiBoxSelection.py @@ -31,12 +31,12 @@ def __init__(self, parent, fields=None, command: Optional[Callable[[List[str]], # Optional header/title label at the top of the widget if self.item_title: - self.title_label = ttk.Label(self, text=self.item_title) + self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") self.title_label.pack(side="top", anchor="w") # Optional helper/requirements label above the input if self.helper_text: - self.helper_label = ttk.Label(self, text=self.helper_text) + self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") self.helper_label.pack(side="top", anchor="w") # Sub-frame for checkbox controls @@ -64,6 +64,7 @@ def _build_buttons(self): text=f"□ {field}", variable=var, command=lambda f=field: self._on_select_field(f), + style="Checkbox.TCheckbutton" ) if self.max_per_line and self.max_per_line > 0: if self.orient == "horizontal": From 5a475064480463fe83d7c48852fe1d88d2a0162a Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Tue, 24 Feb 2026 13:06:35 +0000 Subject: [PATCH 10/28] Improved widget config with _widget_base inherited class --- .../bhom_base_window.py} | 15 ++- .../widgets/Calender.py | 75 +++++++++------ .../widgets/CheckBoxSelection.py | 43 +++++---- .../widgets/CmapSelector.py | 58 ++++++------ .../widgets/ColourPicker.py | 29 ++---- .../widgets/FigureContainer.py | 69 +++++++------- .../widgets/ListBox.py | 69 +++++++++----- .../widgets/MultiBoxSelection.py | 93 ++++++++----------- .../widgets/PathSelector.py | 49 +++++----- .../widgets/RadioSelection.py | 59 +++++++----- .../widgets/ValidatedEntryBox.py | 53 +++++------ .../bhom_tkinter/widgets/_packing_options.py | 26 ++++++ .../bhom_tkinter/widgets/_widgets_base.py | 83 +++++++++++++++++ .../windows}/DirectoryFileSelector.py | 16 ++-- .../windows}/LandingPage.py | 6 +- .../windows}/ProcessingWindow.py | 4 +- .../windows}/WarningBox.py | 0 17 files changed, 445 insertions(+), 302 deletions(-) rename Python_Engine/Python/src/python_toolkit/{tkinter/DefaultRoot.py => bhom_tkinter/bhom_base_window.py} (98%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter}/widgets/Calender.py (60%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter}/widgets/CheckBoxSelection.py (85%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter}/widgets/CmapSelector.py (83%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter}/widgets/ColourPicker.py (90%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter}/widgets/FigureContainer.py (80%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter}/widgets/ListBox.py (70%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter}/widgets/MultiBoxSelection.py (67%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter}/widgets/PathSelector.py (71%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter}/widgets/RadioSelection.py (74%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter}/widgets/ValidatedEntryBox.py (87%) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter/windows}/DirectoryFileSelector.py (89%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter/windows}/LandingPage.py (96%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter/windows}/ProcessingWindow.py (97%) rename Python_Engine/Python/src/python_toolkit/{tkinter => bhom_tkinter/windows}/WarningBox.py (100%) diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py similarity index 98% rename from Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py index 82e3736..054b33b 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/DefaultRoot.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py @@ -1,12 +1,14 @@ import tkinter as tk from tkinter import ttk from pathlib import Path -from typing import Optional, Callable, Literal +from typing import Optional, Callable, Literal, List import darkdetect import platform import ctypes -class DefaultRoot(tk.Tk): +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +class BHoMBaseWindow(tk.Tk): """ A reusable default root window template for tkinter applications. Includes a branded banner, content area, and optional action buttons. @@ -35,6 +37,7 @@ def __init__( theme_path: Path = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_light_theme.tcl"), theme_path_dark: Path = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_dark_theme.tcl"), theme_mode: Literal["light", "dark", "auto"] = "auto", + widgets: List[BHoMBaseWidget] = [], **kwargs ): """ @@ -66,6 +69,8 @@ def __init__( self._icon_image = None self.minsize(min_width, min_height) self.resizable(resizable, resizable) + + self.widgets = widgets # Hide window during setup to prevent flash self.withdraw() @@ -390,9 +395,9 @@ def _on_close_window(self, callback: Optional[Callable]) -> None: ### TEST SIMPLE - test = DefaultRoot( + test = BHoMBaseWindow( title="Test Window", - theme_mode="auto", + theme_mode="light", ) test.mainloop() @@ -422,7 +427,7 @@ def on_submit(): def on_close(): print("Window closed without submitting") - window = DefaultRoot( + window = BHoMBaseWindow( title="Example Form Application", min_width=600, min_height=500, diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py similarity index 60% rename from Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py index 91ced09..9688a30 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/Calender.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py @@ -4,8 +4,20 @@ import calendar import datetime -class CalendarWidget(tk.Frame): - def __init__(self, parent, def_year: int, def_month: int, def_day: int, show_year_selector: bool = False, year_min: int = 1900, year_max: int = 2100, item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +class CalendarWidget(BHoMBaseWidget): + def __init__( + self, + parent: ttk.Frame, + def_year: int, + def_month: int, + def_day: int, + show_year_selector: bool = True, + year_min: int = 1900, + year_max: int = 2100, + **kwargs): + super().__init__(parent, **kwargs) self.year = def_year @@ -14,26 +26,13 @@ def __init__(self, parent, def_year: int, def_month: int, def_day: int, show_yea self.year_min = year_min self.year_max = year_max - self.item_title = item_title - self.helper_text = helper_text - - # Optional header/title label at the top of the widget - if self.item_title: - self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") - self.title_label.pack(side="top", anchor="w") - - # Optional helper/requirements label above the input - if self.helper_text: - self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") - self.helper_label.pack(side="top", anchor="w") - - self.cal_frame = tk.Frame(self) + self.cal_frame = ttk.Frame(self) self.cal_frame.pack(side="top", fill="x") - self.month_frame = tk.Frame(self) + self.month_frame = ttk.Frame(self) self.month_frame.pack(side="top", fill="x") - self.date_frame = tk.Frame(self) + self.date_frame = ttk.Frame(self) self.date_frame.pack(side="top", fill="x") if self.show_year_selector: @@ -43,10 +42,10 @@ def __init__(self, parent, def_year: int, def_month: int, def_day: int, show_yea self.redraw() def year_selector(self): - year_var = tk.IntVar() - year_var.set(self.year) + year_var = tk.StringVar() + year_var.set(str(self.year)) - years = list(range(self.year_min, self.year_max + 1)) + years = [str(year) for year in range(self.year_min, self.year_max + 1)] drop = tk.OptionMenu(self.month_frame, year_var, *years) year_var.trace_add("write", lambda *args: self.set_year(year_var)) drop.pack(side="left", padx=4, pady=4) @@ -62,7 +61,7 @@ def month_selector(self): drop.pack(side="left", padx=4, pady=4) def set_year(self, var): - year = var.get() + year = int(var.get()) self.year = year self.redraw() @@ -76,7 +75,7 @@ def redraw(self): child.destroy() for col, day in enumerate(("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su")): - label = tk.Label(self.cal_frame, text=day) + label = ttk.Label(self.cal_frame, text=day) label.grid(row=0, column=col, sticky="nsew") cal = calendar.monthcalendar(self.year, self.month) @@ -85,7 +84,7 @@ def redraw(self): for col, day in enumerate(week): text = "" if day == 0 else day state = "normal" if day > 0 else "disabled" - cell = tk.Button(self.cal_frame, text=text, state=state, command=lambda day=day: self.set_day(day)) + cell = ttk.Button(self.cal_frame, text=text, state=state, command=lambda day=day: self.set_day(day)) cell.grid(row=row+1, column=col, sticky="nsew") def set_day(self, num): @@ -95,21 +94,41 @@ def set_day(self, num): child.destroy() date = self.months[self.month-1] + " " + str(self.day) - label = tk.Label(self.date_frame, text=f"Selected Date: {date}") + label = ttk.Label(self.date_frame, text=f"Selected Date: {date}") label.pack(padx=4, pady=4) def get_date(self): return datetime.date(self.year, self.month, self.day) + def get(self): + return datetime.date(self.year, self.month, self.day) + + def set(self, value: datetime.date): + self.year = value.year + self.month = value.month + self.day = value.day + self.redraw() + + def pack(self, **kwargs): + super().pack(**kwargs) + self.redraw() + if __name__ == "__main__": - from python_toolkit.tkinter.DefaultRoot import DefaultRoot + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow - root = DefaultRoot(min_height=500) + root = BHoMBaseWindow(min_height=500) root.title("Calendar Widget Test") # Example without year selector - cal_widget1 = CalendarWidget(root.content_frame, def_year=2024, def_month=6, def_day=15, item_title="Select Start Date") + cal_widget1 = CalendarWidget( + root.content_frame, + def_year=2024, + def_month=6, + def_day=15, + item_title="Select a Date", + helper_text="Choose a date from the calendar below.", + ) cal_widget1.pack(padx=20, pady=20) root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CheckBoxSelection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CheckBoxSelection.py similarity index 85% rename from Python_Engine/Python/src/python_toolkit/tkinter/widgets/CheckBoxSelection.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CheckBoxSelection.py index 62d8c16..ea5e4c5 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CheckBoxSelection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CheckBoxSelection.py @@ -2,10 +2,22 @@ from tkinter import ttk from typing import Optional, List, Callable -class CheckboxSelection(tk.Frame): +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +class CheckboxSelection(BHoMBaseWidget): """A reusable checkbox selection widget built from a list of fields, allowing multiple selections.""" - def __init__(self, parent, fields=None, command: Optional[Callable[[List[str]], None]] = None, defaults: Optional[List[str]] = None, orient="vertical", max_per_line=None, item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): + def __init__( + self, + parent, + fields=None, + command: Optional[Callable[[List[str]], None]] = None, + defaults: Optional[List[str]] = None, + orient="vertical", + max_per_line=None, + item_title: Optional[str] = None, + helper_text: Optional[str] = None, + **kwargs): """ Args: parent (tk.Widget): The parent widget. @@ -18,29 +30,17 @@ def __init__(self, parent, fields=None, command: Optional[Callable[[List[str]], helper_text (str, optional): Optional helper text shown above the checkboxes. **kwargs: Additional Frame options. """ - super().__init__(parent, **kwargs) + super().__init__(parent, item_title=item_title, helper_text=helper_text, **kwargs) self.fields = [str(field) for field in (fields or [])] self.command = command self.orient = orient.lower() self.max_per_line = max_per_line - self.item_title = item_title - self.helper_text = helper_text self.value_vars = {} # Dictionary mapping field names to BooleanVars self._buttons = [] - # Optional header/title label at the top of the widget - if self.item_title: - self.title_label = ttk.Label(self, text=self.item_title) - self.title_label.pack(side="top", anchor="w") - - # Optional helper/requirements label above the input - if self.helper_text: - self.helper_label = ttk.Label(self, text=self.helper_text) - self.helper_label.pack(side="top", anchor="w") - # Sub-frame for checkbox controls - self.buttons_frame = ttk.Frame(self) + self.buttons_frame = ttk.Frame(self.content_frame) self.buttons_frame.pack(side="top", fill="x", expand=True) self._build_buttons() @@ -106,9 +106,9 @@ def get(self) -> List[str]: """Return a list of currently selected values.""" return [field for field, var in self.value_vars.items() if var.get()] - def set(self, values: List[str]): + def set(self, value: List[str]): """Set the selected values. Accepts a list of field names to check.""" - values = [str(v) for v in (values or [])] + values = [str(v) for v in (value or [])] for field, var in self.value_vars.items(): var.set(field in values) @@ -168,15 +168,18 @@ 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.""" + super().pack(**kwargs) if __name__ == "__main__": - from python_toolkit.tkinter.DefaultRoot import DefaultRoot + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow def on_selection(values): print(f"Selected: {values}") - root = DefaultRoot() + root = BHoMBaseWindow() parent_frame = root.content_frame widget = CheckboxSelection( diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CmapSelector.py similarity index 83% rename from Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CmapSelector.py index 8570608..867ed30 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/CmapSelector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CmapSelector.py @@ -1,14 +1,15 @@ from typing import Dict, List, Optional -from matplotlib import cm from tkinter import ttk import tkinter as tk import matplotlib as mpl from python_toolkit.plot.cmap_sample import cmap_sample_plot -from python_toolkit.tkinter.widgets.FigureContainer import FigureContainer +from python_toolkit.bhom_tkinter.widgets.FigureContainer import FigureContainer +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions mpl.use("Agg") # Use non-interactive backend for embedding in Tkinter -class CmapSelector(ttk.Frame): +class CmapSelector(BHoMBaseWidget): """ A widget for selecting and previewing a matplotlib colormap. """ @@ -57,11 +58,9 @@ class CmapSelector(ttk.Frame): def __init__( self, - parent: tk.Widget, + parent: ttk.Frame, colormaps: Optional[List[str]] = None, cmap_set: str = "all", - item_title: Optional[str] = None, - helper_text: Optional[str] = None, **kwargs ) -> None: """ @@ -77,16 +76,6 @@ def __init__( """ super().__init__(parent, **kwargs) - # Optional header/title label at the top of the widget - if item_title: - self.title_label = ttk.Label(self, text=item_title, style="Header.TLabel") - self.title_label.pack(side="top", anchor="w", pady=(0, 4)) - - # Optional helper/requirements label above the input - if helper_text: - self.helper_label = ttk.Label(self, text=helper_text, style="Caption.TLabel") - self.helper_label.pack(side="top", anchor="w", pady=(0, 8)) - mpl.use("Agg") # Use non-interactive backend for embedding in Tkinter # Create frame for cmap selection content @@ -105,7 +94,7 @@ def __init__( self.cmap_frame.columnconfigure(0, weight=1) self.cmap_frame.rowconfigure(0, weight=1) - content = ttk.Frame(self.cmap_frame, width=440, height=130) + content = tk.Frame(self.cmap_frame, width=440, height=130) content.grid(row=0, column=0, padx=4, pady=4) content.grid_propagate(False) @@ -122,9 +111,13 @@ def __init__( self.cmap_combobox.pack(side=tk.LEFT, fill=tk.X, expand=True) self.cmap_combobox.bind("<>", self._on_cmap_selected) - self.figure_widget = FigureContainer(content, width=420, height=90) - self.figure_widget.pack(anchor="w", padx=8, pady=(0, 8)) - self.figure_widget.pack_propagate(False) + self.figure_widget = FigureContainer( + content, + width=420, + height=90, + packing_options=PackingOptions(anchor="w", padx=8, pady=(0, 8)), + ) + self.figure_widget.build() if self._uses_explicit_colormaps: current_colormaps = self._with_reversed(self._filter_available(colormaps or [])) @@ -137,10 +130,7 @@ def __init__( def _get_all_colormaps(self) -> List[str]: """Return all registered colormap names, including reversed variants.""" # Base names available in this matplotlib build - try: - base_names = set(cm.cmap_d.keys()) - except Exception: - base_names = set(cm.datad.keys()) + base_names = set(mpl.colormaps()) # Include reversed variants (name_r) next to each base map all_names = set(base_names) @@ -207,13 +197,25 @@ def get_selected_cmap(self) -> Optional[str]: cmap_name = self.colormap_var.get() return cmap_name if cmap_name else None + def get(self) -> Optional[str]: + """Get the currently selected colormap name.""" + return self.get_selected_cmap() + + def set(self, value: Optional[str]): + """Set the selected colormap by name.""" + if value and value in self.cmap_combobox["values"]: + self.colormap_var.set(value) + self._update_cmap_sample() + else: + self.figure_widget.clear() + self.colormap_var.set("") + if __name__ == "__main__": - from python_toolkit.tkinter.DefaultRoot import DefaultRoot - root = DefaultRoot() + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + 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.") cmap_selector.pack(fill=tk.BOTH, expand=True) - root.mainloop() - root.destroy() \ No newline at end of file + root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ColourPicker.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ColourPicker.py similarity index 90% rename from Python_Engine/Python/src/python_toolkit/tkinter/widgets/ColourPicker.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ColourPicker.py index 2cd5f2a..a77e568 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ColourPicker.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ColourPicker.py @@ -2,19 +2,18 @@ from tkinter import ttk from typing import Callable, Optional +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget -class ColourPicker(ttk.Frame): +class ColourPicker(BHoMBaseWidget): """A simple colour picker widget where the swatch opens a themed colour dialog.""" def __init__( self, - parent: tk.Widget, + parent: ttk.Frame, default_colour: str = "#ffffff", swatch_width: int = 48, swatch_height: int = 28, command: Optional[Callable[[str], None]] = None, - item_title: Optional[str] = None, - helper_text: Optional[str] = None, **kwargs, ) -> None: """ @@ -24,8 +23,6 @@ def __init__( swatch_width: Width of the clickable colour swatch in pixels. swatch_height: Height of the clickable colour swatch in pixels. command: Optional callback called with selected hex colour. - item_title: Optional header text shown at the top of the widget frame. - helper_text: Optional helper text shown above the controls. **kwargs: Additional Frame options. """ super().__init__(parent, **kwargs) @@ -36,20 +33,12 @@ def __init__( self.swatch_height = max(1, int(swatch_height)) self._picker_window: Optional[tk.Toplevel] = None self._popup_preview: Optional[tk.Canvas] = None - self._popup_swatch = None + self._popup_swatch: Optional[int] = None self._popup_hex_var: Optional[tk.StringVar] = None self._popup_red_var: Optional[tk.IntVar] = None self._popup_green_var: Optional[tk.IntVar] = None self._popup_blue_var: Optional[tk.IntVar] = None - if item_title: - self.title_label = ttk.Label(self, text=item_title, style="Header.TLabel") - self.title_label.pack(side="top", anchor="w", pady=(0, 4)) - - if helper_text: - self.helper_label = ttk.Label(self, text=helper_text, style="Caption.TLabel") - self.helper_label.pack(side="top", anchor="center", pady=(0, 8)) - controls = ttk.Frame(self) controls.pack(side="top", anchor="center") @@ -219,16 +208,16 @@ def get(self) -> str: """Return the currently selected hex colour.""" return self.colour_var.get() - def set(self, colour: str) -> None: + def set(self, value: str) -> None: """Set the current colour and refresh the preview swatch.""" - self.colour_var.set(colour) - self._update_preview(colour) + self.colour_var.set(value) + self._update_preview(value) if __name__ == "__main__": - from python_toolkit.tkinter.DefaultRoot import DefaultRoot + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow - root = DefaultRoot() + root = BHoMBaseWindow() parent_container = root.content_frame def on_colour_changed(colour: str) -> None: diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/FigureContainer.py similarity index 80% rename from Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/FigureContainer.py index f0a7497..2367cac 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/FigureContainer.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/FigureContainer.py @@ -1,23 +1,22 @@ import tkinter as tk from tkinter import ttk -from typing import Optional +from typing import Optional, Any from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure import matplotlib.pyplot as plt - +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget import matplotlib as mpl + mpl.use("Agg") # Use non-interactive backend for embedding in Tkinter -class FigureContainer(tk.Frame): +class FigureContainer(BHoMBaseWidget): """ A reusable widget for embedding matplotlib figures and images. """ def __init__( self, - parent, - item_title: Optional[str] = None, - helper_text: Optional[str] = None, + parent: ttk.Frame, **kwargs ) -> None: """ @@ -32,29 +31,12 @@ def __init__( super().__init__(parent, **kwargs) self.figure: Optional[Figure] = None - self.image: Optional[tk.PhotoImage] = None + self.image: Optional[Any] = None self.image_file: Optional[str] = None - self._original_pil_image = None - - self.item_title = item_title - self.helper_text = helper_text + self._original_pil_image: Optional[Any] = None - # Optional header/title label at the top of the widget - if self.item_title: - self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") - self.title_label.pack(side="top", anchor="w") - - # Optional helper/requirements label above the input - if self.helper_text: - self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") - self.helper_label.pack(side="top", anchor="w") - - # Container frame for embedded content (not title/helper) - self.content_frame = ttk.Frame(self) - self.content_frame.pack(side="top", fill=tk.BOTH, expand=True) - - self.canvas = None - self.image_label = None + self.canvas: Optional[FigureCanvasTkAgg] = None + self.image_label: Optional[ttk.Label] = None if self.image: self.embed_image(self.image) @@ -157,7 +139,7 @@ def embed_image_file(self, file_path: str) -> None: def _scale_image_to_fit(self): """Scale the image to fit within the content frame while maintaining aspect ratio.""" - if not hasattr(self, '_original_pil_image') or self._original_pil_image is None: + if self._original_pil_image is None: return # Get current frame dimensions @@ -188,7 +170,7 @@ def _scale_image_to_fit(self): self.image = ImageTk.PhotoImage(resized) if self.image_label: self.image_label.configure(image=self.image) - except Exception as e: + except Exception: pass # Silently handle scaling errors def clear(self) -> None: @@ -205,15 +187,38 @@ def clear(self) -> None: self.image_label = None self._original_pil_image = None + def get(self): + """Return the currently embedded figure or image.""" + if self.figure is not None: + return self.figure + elif self.image is not None: + return self.image + else: + return None + + def set(self, value): + """Set the content of the figure container, accepting either a Figure or PhotoImage.""" + if isinstance(value, Figure): + self.embed_figure(value) + elif isinstance(value, tk.PhotoImage): + self.embed_image(value) + elif isinstance(value, str): + self.embed_image_file(value) + else: + raise ValueError("Unsupported value type for FigureContainer. Must be Figure, PhotoImage, or file path string.") + if __name__ == "__main__": - from python_toolkit.tkinter.DefaultRoot import DefaultRoot - root = DefaultRoot() + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + root = BHoMBaseWindow() parent_container = root.content_frame # Create figure container - figure_container = FigureContainer(parent=parent_container, item_title="Figure Container", helper_text="This widget can embed matplotlib figures or images.") + figure_container = FigureContainer( + parent=parent_container, + item_title="Figure Container", + helper_text="This widget can embed matplotlib figures or images.") figure_container.pack(padx=10, pady=10, fill=tk.BOTH, expand=True) # Create and embed a matplotlib figure diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ListBox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ListBox.py similarity index 70% rename from Python_Engine/Python/src/python_toolkit/tkinter/widgets/ListBox.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ListBox.py index 0da6c1f..236d833 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ListBox.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ListBox.py @@ -2,13 +2,22 @@ from tkinter import filedialog, ttk from typing import Optional -class ScrollableListBox(ttk.Frame): +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +class ScrollableListBox(BHoMBaseWidget): """A reusable listbox widget with auto-hiding scrollbar.""" - def __init__(self, parent, items=None, selectmode=tk.MULTIPLE, height=None, show_selection_controls=False, item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): + def __init__( + self, + parent: ttk.Frame, + items=None, + selectmode=tk.MULTIPLE, + height=None, + show_selection_controls=False, + **kwargs): """ Args: - parent (tk.Widget): The parent widget. + parent (ttk.Frame): The parent widget. 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. @@ -21,22 +30,6 @@ def __init__(self, parent, items=None, selectmode=tk.MULTIPLE, height=None, show if height is None: height = len(self.items) if self.items else 5 - self.item_title = item_title - self.helper_text = helper_text - - # Optional header/title label at the top of the widget - if self.item_title: - self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") - self.title_label.pack(side="top", anchor="w") - - # Optional helper/requirements label above the input - if self.helper_text: - self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") - self.helper_label.pack(side="top", anchor="w") - - self.content_frame = ttk.Frame(self) - self.content_frame.pack(fill=tk.BOTH, expand=True) - # Create scrollbar self.scrollbar = ttk.Scrollbar(self.content_frame) self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y) @@ -116,17 +109,43 @@ def clear(self): self.listbox.delete(0, tk.END) self._on_configure() + def pack(self, **kwargs): + """Pack the widget with the given options.""" + super().pack(**kwargs) + self._on_configure() # Ensure scrollbar visibility is updated when packed + + def set(self, value): + """Set the listbox items to the provided list.""" + self.clear() + for item in value: + self.listbox.insert(tk.END, item) + self._on_configure( ) + + def get(self): + """Get the current list of items in the listbox.""" + return [self.listbox.get(i) for i in range(self.listbox.size())] + if __name__ == "__main__": - from python_toolkit.tkinter.DefaultRoot import DefaultRoot - root = DefaultRoot() + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + root = BHoMBaseWindow() parent_container = root.content_frame items = [f"Item {i}" for i in range(1, 21)] - listbox = ScrollableListBox(parent_container, items=items, height=10, show_selection_controls=True, item_title="List Box", helper_text="Select items from the list.") - listbox.pack(padx=20, pady=20) - - print("Selected items:", listbox.get_selection()) + root.widgets.append(ScrollableListBox( + parent_container, + items=items, + height=10, + show_selection_controls=True, + item_title="List Box", + helper_text="Select items from the list.", + packing_options=PackingOptions(padx=10, pady=10) + )) + root.widgets[-1].build() + + print("Selected items:", root.widgets[-1].get()) root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/MultiBoxSelection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/MultiBoxSelection.py similarity index 67% rename from Python_Engine/Python/src/python_toolkit/tkinter/widgets/MultiBoxSelection.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/MultiBoxSelection.py index 4f84327..66cdf6d 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/MultiBoxSelection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/MultiBoxSelection.py @@ -2,10 +2,20 @@ from tkinter import ttk from typing import Optional, List, Callable -class CheckboxSelection(tk.Frame): +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +class CheckboxSelection(BHoMBaseWidget): """A reusable checkbox selection widget built from a list of fields, allowing multiple selections.""" - def __init__(self, parent, fields=None, command: Optional[Callable[[List[str]], None]] = None, defaults: Optional[List[str]] = None, orient="vertical", max_per_line=None, item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): + def __init__( + self, + parent, + fields=None, + command: Optional[Callable[[List[str]], None]] = None, + defaults: Optional[List[str]] = None, + orient="vertical", + max_per_line=None, + **kwargs): """ Args: parent (tk.Widget): The parent widget. @@ -24,23 +34,11 @@ def __init__(self, parent, fields=None, command: Optional[Callable[[List[str]], self.command = command self.orient = orient.lower() self.max_per_line = max_per_line - self.item_title = item_title - self.helper_text = helper_text self.value_vars = {} # Dictionary mapping field names to BooleanVars self._buttons = [] - # Optional header/title label at the top of the widget - if self.item_title: - self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") - self.title_label.pack(side="top", anchor="w") - - # Optional helper/requirements label above the input - if self.helper_text: - self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") - self.helper_label.pack(side="top", anchor="w") - # Sub-frame for checkbox controls - self.buttons_frame = ttk.Frame(self) + self.buttons_frame = ttk.Frame(self.content_frame) self.buttons_frame.pack(side="top", fill="x", expand=True) self._build_buttons() @@ -59,13 +57,12 @@ def _build_buttons(self): var = tk.BooleanVar(value=False) self.value_vars[field] = var - button = ttk.Checkbutton( + button = ttk.Label( self.buttons_frame, text=f"□ {field}", - variable=var, - command=lambda f=field: self._on_select_field(f), - style="Checkbox.TCheckbutton" + cursor="hand2" ) + button.bind("", lambda _event, f=field: self._toggle_field(f)) if self.max_per_line and self.max_per_line > 0: if self.orient == "horizontal": row = index // self.max_per_line @@ -80,20 +77,23 @@ def _build_buttons(self): button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky="w") self._buttons.append(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.""" - # Find the button for this field and update its text for button in self._buttons: button_text = button.cget("text") - # Extract field name (remove the box indicator) - current_field = button_text[2:] # Skip "□ " or "■ " + current_field = button_text[2:] if current_field == field: if self.value_vars[field].get(): button.configure(text=f"■ {field}") else: button.configure(text=f"□ {field}") break - + if self.command: self.command(self.get()) @@ -101,16 +101,15 @@ def get(self) -> List[str]: """Return a list of currently selected values.""" return [field for field, var in self.value_vars.items() if var.get()] - def set(self, values: List[str]): + def set(self, value: List[str]): """Set the selected values. Accepts a list of field names to check.""" - values = [str(v) for v in (values or [])] + 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 button in self._buttons: button_text = button.cget("text") - current_field = button_text[2:] # Skip "□ " or "■ " + current_field = button_text[2:] if current_field in values: button.configure(text=f"■ {current_field}") else: @@ -120,10 +119,9 @@ def select_all(self): """Select all checkboxes.""" for var in self.value_vars.values(): var.set(True) - # Update visual indicators for button in self._buttons: button_text = button.cget("text") - field = button_text[2:] # Skip box indicator + field = button_text[2:] button.configure(text=f"■ {field}") if self.command: self.command(self.get()) @@ -132,29 +130,13 @@ def deselect_all(self): """Deselect all checkboxes.""" for var in self.value_vars.values(): var.set(False) - # Update visual indicators for button in self._buttons: button_text = button.cget("text") - field = button_text[2:] # Skip box indicator + field = button_text[2:] button.configure(text=f"□ {field}") if self.command: self.command(self.get()) - def toggle_all(self): - """Toggle all checkbox states.""" - for var in self.value_vars.values(): - var.set(not var.get()) - # Update visual indicators - for button in self._buttons: - button_text = button.cget("text") - field = button_text[2:] # Skip box indicator - if self.value_vars[field].get(): - button.configure(text=f"■ {field}") - else: - button.configure(text=f"□ {field}") - if self.command: - self.command(self.get()) - def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): """Replace the available fields and rebuild the widget.""" self.fields = [str(field) for field in (fields or [])] @@ -163,15 +145,15 @@ def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): if defaults: self.set(defaults) - if __name__ == "__main__": - from python_toolkit.tkinter.DefaultRoot import DefaultRoot + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions def on_selection(values): print(f"Selected: {values}") - root = DefaultRoot() + root = BHoMBaseWindow() parent_frame = root.content_frame widget = CheckboxSelection( @@ -179,12 +161,14 @@ def on_selection(values): fields=["Option A", "Option B", "Option C", "Option D", "Option E", "Option F", "Option G"], command=on_selection, defaults=["Option B", "Option D"], - orient="vertical", - max_per_line=6, + orient="horizontal", + max_per_line=3, item_title="Choose Options", - helper_text="Select one or more options below:" + helper_text="Select one or more options below:", + packing_options=PackingOptions(padx=20, pady=20) + ) - widget.pack(padx=20, pady=20) + widget.build() # Add control buttons for demonstration control_frame = ttk.Frame(parent_frame) @@ -192,6 +176,5 @@ def on_selection(values): ttk.Button(control_frame, text="Select All", command=widget.select_all).pack(side="left", padx=5) ttk.Button(control_frame, text="Deselect All", command=widget.deselect_all).pack(side="left", padx=5) - ttk.Button(control_frame, text="Toggle All", command=widget.toggle_all).pack(side="left", padx=5) root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/PathSelector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/PathSelector.py similarity index 71% rename from Python_Engine/Python/src/python_toolkit/tkinter/widgets/PathSelector.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/PathSelector.py index b0a54bf..a69e63c 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/PathSelector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/PathSelector.py @@ -3,10 +3,20 @@ from pathlib import Path from typing import Optional -class PathSelector(ttk.Frame): +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +class PathSelector(BHoMBaseWidget): """A reusable path/file selector widget with a button and a readonly entry.""" - def __init__(self, parent, button_text="Browse...", filetypes=None, command=None, initialdir=None, mode="file", item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): + def __init__( + self, + parent: ttk.Frame, + button_text="Browse...", + filetypes=None, + command=None, + initialdir=None, + mode="file", + **kwargs): """ Args: parent (tk.Widget): The parent widget. @@ -22,23 +32,9 @@ def __init__(self, parent, button_text="Browse...", filetypes=None, command=None super().__init__(parent, **kwargs) self.path_var = tk.StringVar() self.command = command - self.filetypes = filetypes - self.initialdir = initialdir self.mode = mode - - self.item_title = item_title - self.helper_text = helper_text - - # Optional header/title label at the top of the widget - if self.item_title: - self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") - self.title_label.pack(side="top", anchor="w") - - # Optional helper/requirements label above the input - if self.helper_text: - self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") - self.helper_label.pack(side="top", anchor="w") - + self.initialdir = initialdir + self.filetypes = filetypes if filetypes is not None else [("All Files", "*.*")] self.display_name = tk.StringVar() self.entry = ttk.Entry(self, textvariable=self.display_name, width=40) self.entry.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True) @@ -66,13 +62,18 @@ def _on_click(self): if self.command: self.command(str(selected_path)) - def get(self): + def get(self) -> str: """Return the currently selected file path.""" return self.path_var.get() - def set(self, path): + def set(self, value: Optional[str]): """Set the file path in the entry.""" - selected_path = Path(path) + 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)) @@ -83,15 +84,15 @@ def set(self, path): if __name__ == "__main__": - from python_toolkit.tkinter.DefaultRoot import DefaultRoot + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow - root = DefaultRoot() + root = BHoMBaseWindow() parent_container = root.content_frame def on_file_selected(path): print(f"Selected: {path}") - path_selector = PathSelector(parent_container, button_text="Select File", filetypes=[("All Files", "*.*")], command=on_file_selected, item_title="File Selector", helper_text="Please select a file to process.") + path_selector = PathSelector(parent_container, button_text="Select File", filetypes=[("All Files", "*.*")], command=on_file_selected) path_selector.pack(padx=20, pady=20) root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/RadioSelection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/RadioSelection.py similarity index 74% rename from Python_Engine/Python/src/python_toolkit/tkinter/widgets/RadioSelection.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/RadioSelection.py index 1124fda..0d054b1 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/RadioSelection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/RadioSelection.py @@ -2,10 +2,20 @@ from tkinter import ttk from typing import Optional -class RadioSelection(tk.Frame): +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +class RadioSelection(BHoMBaseWidget): """A reusable radio selection widget built from a list of fields.""" - def __init__(self, parent, fields=None, command=None, default=None, orient="vertical", max_per_line=None, item_title: Optional[str] = None, helper_text: Optional[str] = None, **kwargs): + def __init__( + self, + parent, + fields=None, + command=None, + default=None, + orient="vertical", + max_per_line=None, + **kwargs): """ Args: parent (tk.Widget): The parent widget. @@ -24,21 +34,9 @@ def __init__(self, parent, fields=None, command=None, default=None, orient="vert self.command = command self.orient = orient.lower() self.max_per_line = max_per_line - self.item_title = item_title - self.helper_text = helper_text self.value_var = tk.StringVar() self._buttons = [] - # Optional header/title label at the top of the widget - if self.item_title: - self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") - self.title_label.pack(side="top", anchor="w") - - # Optional helper/requirements label above the input - if self.helper_text: - self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") - self.helper_label.pack(side="top", anchor="w") - # Sub-frame for radio button controls self.buttons_frame = ttk.Frame(self) self.buttons_frame.pack(side="top", fill="x", expand=True) @@ -57,13 +55,12 @@ def _build_buttons(self): self._buttons.clear() for index, field in enumerate(self.fields): - button = ttk.Radiobutton( + button = ttk.Label( self.buttons_frame, - text=field, - value=field, - variable=self.value_var, - command=self._on_select, + text=f"○ {field}", + cursor="hand2" ) + button.bind("", lambda _event, f=field: self._select_field(f)) if self.max_per_line and self.max_per_line > 0: if self.orient == "horizontal": row = index // self.max_per_line @@ -78,11 +75,26 @@ def _build_buttons(self): button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky="w") self._buttons.append(button) - def _on_select(self): - """Handle option selection.""" + self._update_visual_state() + + def _select_field(self, field): + """Select a field when clicked.""" + self.value_var.set(field) + self._update_visual_state() 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.cget("text") + current_field = button_text[2:] + if current_field == selected_value: + button.configure(text=f"● {current_field}") + else: + button.configure(text=f"○ {current_field}") + def get(self): """Return the currently selected value.""" return self.value_var.get() @@ -92,6 +104,7 @@ 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.""" @@ -108,12 +121,12 @@ def set_fields(self, fields, default=None): if __name__ == "__main__": - from python_toolkit.tkinter.DefaultRoot import DefaultRoot + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow def on_selection(value): print(f"Selected: {value}") - root = DefaultRoot() + root = BHoMBaseWindow() parent_frame = root.content_frame widget = RadioSelection( diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ValidatedEntryBox.py similarity index 87% rename from Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ValidatedEntryBox.py index 488e759..f789550 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/widgets/ValidatedEntryBox.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ValidatedEntryBox.py @@ -2,7 +2,9 @@ from tkinter import ttk from typing import Optional, Callable, Any, Union -class ValidatedEntryBox(tk.Frame): +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +class ValidatedEntryBox(BHoMBaseWidget): """ A reusable entry box component with built-in validation for different data types. @@ -16,8 +18,6 @@ class ValidatedEntryBox(tk.Frame): def __init__( self, parent, - item_title: Optional[str] = None, - helper_text: Optional[str] = None, variable: Optional[tk.StringVar] = None, width: int = 15, value_type: type = str, @@ -57,38 +57,30 @@ def __init__( self.required = required self.custom_validator = custom_validator self.on_validate = on_validate - self.item_title = item_title - self.helper_text = helper_text - - # Optional header/title label at the top of the widget - if self.item_title: - self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") - self.title_label.pack(side="top", anchor="w") - - # Optional helper/requirements label above the input - if self.helper_text: - self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") - self.helper_label.pack(side="top", anchor="w") - # Create frame to hold entry and error label on one row - self.input_row = ttk.Frame(self) - self.input_row.pack(side="top", fill="x", expand=True) - # Create or use provided StringVar self.variable = variable if variable is not None else tk.StringVar(value="") + # Create frame for entry and success indicator + self.entry_frame = ttk.Frame(self.content_frame) + self.entry_frame.pack(side="top", fill="x") + # Create entry widget - self.entry = ttk.Entry(self.input_row, textvariable=self.variable, width=width) + self.entry = ttk.Entry(self.entry_frame, textvariable=self.variable, width=width) self.entry.pack(side="left", fill="x", expand=True) + # Create success indicator label at end of entry + self.success_label = ttk.Label(self.entry_frame, text=" ", foreground="#4bb543", width=2) + self.success_label.pack(side="left", padx=(5, 0)) + + # Create error label below entry with fixed height to prevent layout shifts + self.error_label = ttk.Label(self.content_frame, text=" ", style="Caption.TLabel", anchor="w") + self.error_label.pack(side="top", fill="x") + # Bind validation events self.entry.bind("", lambda _: self.validate()) self.entry.bind("", lambda _: self.validate()) - # Create error label - self.error_label = ttk.Label(self.input_row, text="", style="Error.TLabel") - self.error_label.pack(side="left", padx=(10, 0)) - def get(self) -> str: """Get the current value as a string.""" return self.variable.get().strip() @@ -244,14 +236,17 @@ def _validate_float(self, value_str: str) -> bool: def _show_error(self, message: str) -> None: """Display error message.""" self.error_label.config(text=message, foreground="#ff4444") + self.success_label.config(text=" ") def _show_success(self) -> None: """Display success indicator.""" - self.error_label.config(text="✓", foreground="#4bb543") + self.error_label.config(text=" ") + self.success_label.config(text="✓") def clear_error(self) -> None: """Clear the error message.""" - self.error_label.config(text="") + self.error_label.config(text=" ") + self.success_label.config(text=" ") def _call_validate_callback(self, is_valid: bool) -> None: """Call the validation callback if provided.""" @@ -263,10 +258,10 @@ def _call_validate_callback(self, is_valid: bool) -> None: if __name__ == "__main__": # Test the ValidatedEntryBox - from python_toolkit.tkinter.DefaultRoot import DefaultRoot - root = DefaultRoot(title="Validated Entry Box Test") + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + root = BHoMBaseWindow(title="Validated Entry Box Test") parent_container = getattr(root, "content_frame", root) - + def on_validate(is_valid): print(f"Validation result: {is_valid}") diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py new file mode 100644 index 0000000..5ac4872 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, asdict +from typing import Any, Literal + +@dataclass +class PackingOptions: + + after: Any = None + anchor: Literal['nw', 'n', 'ne', 'w', 'center', 'e', 'sw', 's', 'se'] = 'center' + before: Any = None + expand: bool | Literal[0, 1] = 0 + fill: Literal['none', 'x', 'y', 'both'] = 'none' + side: Literal['left', 'right', 'top', 'bottom'] = 'top' + ipadx: float | str = 0.0 + ipady: float | str = 0.0 + padx: float | str | tuple[float | str, float | str] = 0.0 + pady: float | str | tuple[float | str, float | str] = 0.0 + in_: Any = None + + def to_dict(self) -> dict: + """Convert the dataclass to a dictionary, excluding None values.""" + return {k: v for k, v in asdict(self).items() if v is not None} + +if __name__ == "__main__": + + d = PackingOptions(side='left', fill='x', expand=True, padx=5, pady=5, anchor='w') + print(d.to_dict()) \ No newline at end of file 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 new file mode 100644 index 0000000..7e4d6ea --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py @@ -0,0 +1,83 @@ +from abc import ABC, abstractmethod + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Literal, Union + +from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + +from uuid import uuid4 + +class BHoMBaseWidget(ttk.Frame, ABC): + """ + Base class for all widgets in the BHoM Tkinter toolkit. + Provides common structure and functionality for consistent design. + """ + + def __init__( + self, + parent: ttk.Frame, + id: Optional[str] = None, + item_title: Optional[str] = None, + helper_text: Optional[str] = None, + packing_options: PackingOptions = PackingOptions(), + **kwargs): + """ + Initialize the widget base. + + Args: + parent: Parent widget + id: Optional unique identifier for the widget + item_title: Optional header text shown at the top of the widget frame. + helper_text: Optional helper text shown above the entry box. + **kwargs: Additional Frame options + """ + super().__init__(parent, **kwargs) + + self.item_title = item_title + self.helper_text = helper_text + self.packing_options = packing_options + + if id is None: + self.id = str(uuid4()) + else: + self.id = id + + # Optional header/title label at the top of the widget + if self.item_title: + self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") + self.title_label.pack(side="top", anchor="w") + + # Optional helper/requirements label above the input + if self.helper_text: + self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") + self.helper_label.pack(side="top", anchor="w") + + # Container frame for embedded content (not title/helper) + self.content_frame = ttk.Frame(self) + self.content_frame.pack(side="top", fill=tk.BOTH, expand=True) + + @abstractmethod + def get(self): + """Get the current value of the widget.""" + raise NotImplementedError("Subclasses must implement the get() method.") + + @abstractmethod + def set(self, value): + """Set the value of the widget.""" + raise NotImplementedError("Subclasses must implement the set() method.") + + def build( + self, + build_type: Literal['pack', 'grid', 'place'] = 'pack', + **kwargs + ): + """Pack the widget into the parent container.""" + if build_type == 'pack': + self.pack(**self.packing_options.to_dict()) + + elif build_type == 'grid': + raise NotImplementedError("Grid packing is not yet implemented for BHoMBaseWidget.") + + elif build_type == 'place': + raise NotImplementedError("Place packing is not yet implemented for BHoMBaseWidget.") diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/DirectoryFileSelector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py similarity index 89% rename from Python_Engine/Python/src/python_toolkit/tkinter/DirectoryFileSelector.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py index 1980fc5..8e8b70e 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/DirectoryFileSelector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py @@ -4,17 +4,18 @@ from typing import Iterable, List from widgets.ListBox import ScrollableListBox -from DefaultRoot import DefaultRoot +from widgets._packing_options import PackingOptions +from Python_Engine.Python.src.python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + +class DirectoryFileSelector(BHoMBaseWindow): -class DirectoryFileSelector(DefaultRoot): def __init__(self, directory: Path, file_types: Iterable[str], selection_label: str = "file(s)") -> None: self.directory = Path(directory) self.file_types = self._normalise_file_types(file_types) self.selection_label = selection_label self._cancelled = False self.selected_files = [] - - # Create DefaultRoot window + # Create BHoMBaseWindow super().__init__( title=f"Select {selection_label}", min_width=600, @@ -39,15 +40,14 @@ def __init__(self, directory: Path, file_types: Iterable[str], selection_label: justify=tk.LEFT, ).pack(anchor="w", pady=(0, 10)) - self.listbox = ScrollableListBox( + self.widgets.append(ScrollableListBox( self.content_frame, items=self.display_items, selectmode=tk.MULTIPLE, height=12, show_selection_controls=True, - ) - self.listbox.pack(fill=tk.BOTH, expand=True) - self.listbox.select_all() + packing_options=PackingOptions(fill='both', expand=True), + )) # Refresh sizing after adding widgets self.refresh_sizing() diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py similarity index 96% rename from Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py index 964aeb9..897ed5d 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/LandingPage.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py @@ -1,13 +1,13 @@ import tkinter as tk from tkinter import ttk from typing import Optional, Callable -from DefaultRoot import DefaultRoot +from Python_Engine.Python.src.python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow -class LandingPage(DefaultRoot): +class LandingPage(BHoMBaseWindow): """ A reusable landing page GUI with configurable title, message, and buttons. - Uses DefaultRoot as the base template. + Uses BHoMBaseWindow as the base template. """ def __init__( diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/ProcessingWindow.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py similarity index 97% rename from Python_Engine/Python/src/python_toolkit/tkinter/ProcessingWindow.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py index 15e71d5..d2d3c76 100644 --- a/Python_Engine/Python/src/python_toolkit/tkinter/ProcessingWindow.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py @@ -3,9 +3,9 @@ import os import time from typing import Optional -from python_toolkit.tkinter.DefaultRoot import DefaultRoot +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow -class ProcessingWindow(DefaultRoot): +class ProcessingWindow(BHoMBaseWindow): """A simple processing window with animated indicator.""" def __init__(self, title="Processing", message="Processing..."): diff --git a/Python_Engine/Python/src/python_toolkit/tkinter/WarningBox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/tkinter/WarningBox.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py From 169143104a6329a5d245a58868ecf541f39afbbb Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Tue, 24 Feb 2026 15:55:02 +0000 Subject: [PATCH 11/28] Add warning message box window --- .../bhom_tkinter/bhom_base_window.py | 80 +++++++++++++---- .../windows/DirectoryFileSelector.py | 8 +- .../bhom_tkinter/windows/WarningBox.py | 85 ++++++++++++++++++- 3 files changed, 153 insertions(+), 20 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 054b33b..79ed234 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 @@ -90,24 +90,32 @@ def __init__( self.submit_command = submit_command self.close_command = close_command self.result = None + self.button_bar: Optional[ttk.Frame] = None + self._has_been_shown = False + self._pending_resize_job: Optional[str] = None + self._is_resizing = False + self._auto_fit_width = width is None + self._auto_fit_height = height is None # Handle window close (X button) self.protocol("WM_DELETE_WINDOW", lambda: self._on_close_window(on_close_window)) # Main container - main_container = ttk.Frame(self) - main_container.pack(fill=tk.BOTH, expand=True) + self.main_container = ttk.Frame(self) + self.main_container.pack(fill=tk.BOTH, expand=True) # Banner section - self._build_banner(main_container, title, _logo_path) + self._build_banner(self.main_container, title, _logo_path) # Content area (public access for adding widgets) - self.content_frame = ttk.Frame(main_container, padding=20) + self.content_frame = ttk.Frame(self.main_container, padding=20) self.content_frame.pack(fill=tk.BOTH, expand=True) # Bottom button frame (if needed) if show_submit or show_close: - self._build_buttons(main_container, show_submit, submit_text, show_close, close_text) + self._build_buttons(self.main_container, show_submit, submit_text, show_close, close_text) + + self._bind_dynamic_sizing() # Apply sizing self._apply_sizing() @@ -294,10 +302,10 @@ def _build_buttons( close_text: str, ) -> None: """Build the bottom button bar.""" - button_bar = ttk.Frame(parent, padding=(20, 10)) - button_bar.pack(side=tk.BOTTOM, fill=tk.X) + self.button_bar = ttk.Frame(parent, padding=(20, 10)) + self.button_bar.pack(side=tk.BOTTOM, fill=tk.X) - button_container = ttk.Frame(button_bar) + button_container = ttk.Frame(self.button_bar) button_container.pack(anchor=tk.E) if show_close: @@ -312,37 +320,74 @@ def _build_buttons( ) self.submit_button.pack(side=tk.LEFT, padx=5) + def _bind_dynamic_sizing(self) -> None: + """Bind layout changes to schedule auto sizing updates.""" + self.main_container.bind("", self._schedule_dynamic_sizing) + self.content_frame.bind("", self._schedule_dynamic_sizing) + if self.button_bar is not None: + self.button_bar.bind("", self._schedule_dynamic_sizing) + + def _schedule_dynamic_sizing(self, _event=None) -> None: + """Debounce dynamic sizing updates triggered by layout changes.""" + if self._is_resizing: + return + if not (self._auto_fit_width or self._auto_fit_height): + return + if self._pending_resize_job is not None: + try: + self.after_cancel(self._pending_resize_job) + except Exception: + pass + self._pending_resize_job = self.after(30, self._apply_sizing) + def _apply_sizing(self) -> None: """Apply window sizing and positioning.""" + self._pending_resize_job = None + self._is_resizing = True self.update_idletasks() + required_width = self.winfo_reqwidth() + required_height = self.winfo_reqheight() + + if hasattr(self, "main_container"): + required_width = max(required_width, self.main_container.winfo_reqwidth()) + required_height = max(required_height, self.main_container.winfo_reqheight()) + + if self.button_bar is not None and self.button_bar.winfo_manager(): + required_height = max(required_height, self.button_bar.winfo_reqheight() + self.content_frame.winfo_reqheight()) + # Determine final dimensions if self.fixed_width and self.fixed_height: - final_width = self.fixed_width - final_height = self.fixed_height + final_width = max(self.min_width, self.fixed_width, required_width) + final_height = max(self.min_height, self.fixed_height, required_height) elif self.fixed_width: - final_width = self.fixed_width - final_height = max(self.min_height, self.winfo_reqheight()) + final_width = max(self.min_width, self.fixed_width, required_width) + final_height = max(self.min_height, required_height) elif self.fixed_height: - final_width = max(self.min_width, self.winfo_reqwidth()) - final_height = self.fixed_height + final_width = max(self.min_width, required_width) + final_height = max(self.min_height, self.fixed_height, required_height) else: # Dynamic sizing - final_width = max(self.min_width, self.winfo_reqwidth()) - final_height = max(self.min_height, self.winfo_reqheight()) + final_width = max(self.min_width, required_width) + final_height = max(self.min_height, required_height) # Position - if self.center_on_screen: + if self.center_on_screen and not self._has_been_shown: screen_width = self.winfo_screenwidth() screen_height = self.winfo_screenheight() x = (screen_width - final_width) // 2 y = (screen_height - final_height) // 2 self.geometry(f"{final_width}x{final_height}+{x}+{y}") + elif self._has_been_shown: + x = self.winfo_x() + y = self.winfo_y() + self.geometry(f"{final_width}x{final_height}+{x}+{y}") else: self.geometry(f"{final_width}x{final_height}") # Defer window display until after styling is applied self.after(0, self._show_window_with_styling) + self._is_resizing = False def _show_window_with_styling(self) -> None: """Apply titlebar styling and show the window.""" @@ -351,6 +396,7 @@ def _show_window_with_styling(self) -> None: # Show window after styling self.deiconify() + self._has_been_shown = True def refresh_sizing(self) -> None: """Recalculate and apply window sizing (useful after adding widgets).""" diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py index 8e8b70e..ebcd7a5 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py @@ -41,7 +41,8 @@ def __init__(self, directory: Path, file_types: Iterable[str], selection_label: ).pack(anchor="w", pady=(0, 10)) self.widgets.append(ScrollableListBox( - self.content_frame, + id = "file_selector_listbox", + parent=self.content_frame, items=self.display_items, selectmode=tk.MULTIPLE, height=12, @@ -81,7 +82,10 @@ def _display_value(self, path: Path) -> str: def _on_submit(self): """Handle OK button - capture selection before window closes.""" - selected = self.listbox.get_selection() + for widget in self.widgets: + if isinstance(widget, ScrollableListBox): + selected = widget.get_selection() + break self.selected_files = [self.file_lookup[item] for item in selected if item in self.file_lookup] self.destroy_root() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py index 8d2d1f2..6c35385 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py @@ -1 +1,84 @@ -"""This will be used in place of warning / pop up boxes, to ensure consistent style""" \ No newline at end of file +import tkinter as tk +from tkinter import ttk +import os +import time +from typing import Optional, Literal +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + +"""This will be used in place of warning / pop up boxes, to ensure consistent style""" + + +class WarningBox(BHoMBaseWindow): + + def __init__( + self, + title: str= 'Warning', + warnings: list[str]|str = [], + errors: list[str]|str = [], + infos: list[str]|str = [], + **kwargs + ): + super().__init__( + title=title, + show_submit=False, + close_text="Continue", + min_width=250, + min_height=150, + **kwargs + ) + + if isinstance(warnings, str): + warnings = [warnings] + if isinstance(errors, str): + errors = [errors] + if isinstance(infos, str): + infos = [infos] + + self.warnings = warnings + self.errors = errors + self.infos = infos + + for message in self.errors: + ttk.Label(self.content_frame, text=message, style="Error.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) + for message in self.warnings: + ttk.Label(self.content_frame, text=message, style="Warning.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) + for message in self.infos: + ttk.Label(self.content_frame, text=message, style="Caption.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) + + def update_messages(self): + """Clear and re-render all messages in the warning box.""" + for widget in self.content_frame.winfo_children(): + widget.destroy() + + for message in self.errors: + ttk.Label(self.content_frame, text=message, style="Error.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) + for message in self.warnings: + ttk.Label(self.content_frame, text=message, style="Warning.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) + for message in self.infos: + ttk.Label(self.content_frame, text=message, style="Caption.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) + + + def add_error_message(self, message: str): + """Add an error message to the warning box.""" + self.errors.append(message) + self.update_messages() + + def add_warning_message(self, message: str): + """Add a warning message to the warning box.""" + self.warnings.append(message) + self.update_messages() + + def add_info_message(self, message: str): + """Add an informational message to the warning box.""" + self.infos.append(message) + self.update_messages() + + +if __name__ == "__main__": + root = WarningBox(title="Validation Error", warnings="This is a warning message to alert the user about something important.") + + for message in ["This is the first error message.", "This is the second error message."]: + root.add_error_message(message) + + root.add_info_message("This is some additional information for the user.") + root.mainloop() \ No newline at end of file From 1f44ff8d28fbd95bad8b392157a4968940c9ff67 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Tue, 24 Feb 2026 16:23:16 +0000 Subject: [PATCH 12/28] Improved default methods uses in window tools --- .../bhom_tkinter/bhom_base_window.py | 10 ++ .../windows/DirectoryFileSelector.py | 76 +++++----- .../bhom_tkinter/windows/LandingPage.py | 132 ++++++------------ .../bhom_tkinter/windows/ProcessingWindow.py | 82 +++++------ .../bhom_tkinter/windows/WarningBox.py | 48 +++---- 5 files changed, 150 insertions(+), 198 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 79ed234..4a247f0 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 @@ -119,6 +119,16 @@ def __init__( # Apply sizing self._apply_sizing() + self.build() + + def build(self): + """Call build on all child widgets that have it (for deferred widget construction).""" + for widget in self.widgets: + if hasattr(widget, "build") and callable(getattr(widget, "build")): + widget.build() + + + self.refresh_sizing() def _set_window_icon(self, icon_path: Path) -> None: """Set a custom window icon, replacing the default uk icon.""" diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py index ebcd7a5..e91ce11 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py @@ -1,57 +1,63 @@ import tkinter as tk from tkinter import ttk from pathlib import Path -from typing import Iterable, List +from typing import Iterable, List, Optional -from widgets.ListBox import ScrollableListBox -from widgets._packing_options import PackingOptions -from Python_Engine.Python.src.python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.widgets.ListBox import ScrollableListBox +from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow class DirectoryFileSelector(BHoMBaseWindow): - def __init__(self, directory: Path, file_types: Iterable[str], selection_label: str = "file(s)") -> None: + def __init__( + self, + directory: Path, + file_types: Iterable[str], + selection_label: str = "file(s)", + **kwargs, + ) -> None: self.directory = Path(directory) self.file_types = self._normalise_file_types(file_types) self.selection_label = selection_label self._cancelled = False - self.selected_files = [] - # Create BHoMBaseWindow - super().__init__( - title=f"Select {selection_label}", - min_width=600, - min_height=400, - show_submit=True, - submit_text="OK", - submit_command=self._on_submit, - show_close=True, - close_text="Cancel", - close_command=self._on_cancel, - on_close_window=self._on_cancel, - ) + self.selected_files: List[Path] = [] self.files = self._discover_files() self.display_items = [self._display_value(file) for file in self.files] self.file_lookup = dict(zip(self.display_items, self.files)) + self.file_selector_listbox: Optional[ScrollableListBox] = None - # Add content to the window's content frame + kwargs.setdefault("title", f"Select {selection_label}") + kwargs.setdefault("min_width", 600) + kwargs.setdefault("min_height", 400) + kwargs.setdefault("submit_text", "OK") + kwargs.setdefault("submit_command", self._on_submit) + kwargs.setdefault("close_text", "Cancel") + kwargs.setdefault("close_command", self._on_cancel) + kwargs.setdefault("on_close_window", self._on_cancel) + + super().__init__(**kwargs) + + def build(self): ttk.Label( self.content_frame, text=f"Select the {self.selection_label} to analyse.", justify=tk.LEFT, ).pack(anchor="w", pady=(0, 10)) - self.widgets.append(ScrollableListBox( - id = "file_selector_listbox", - parent=self.content_frame, - items=self.display_items, - selectmode=tk.MULTIPLE, - height=12, - show_selection_controls=True, - packing_options=PackingOptions(fill='both', expand=True), - )) + if self.file_selector_listbox is None: + self.file_selector_listbox = ScrollableListBox( + id="file_selector_listbox", + parent=self.content_frame, + items=self.display_items, + selectmode=tk.MULTIPLE, + height=12, + show_selection_controls=True, + packing_options=PackingOptions(fill='both', expand=True), + ) + self.widgets.append(self.file_selector_listbox) - # Refresh sizing after adding widgets - self.refresh_sizing() + super().build() def _normalise_file_types(self, file_types: Iterable[str]) -> List[str]: normalised = [] @@ -82,10 +88,7 @@ def _display_value(self, path: Path) -> str: def _on_submit(self): """Handle OK button - capture selection before window closes.""" - for widget in self.widgets: - if isinstance(widget, ScrollableListBox): - selected = widget.get_selection() - break + selected = self.file_selector_listbox.get_selection() if self.file_selector_listbox else [] self.selected_files = [self.file_lookup[item] for item in selected if item in self.file_lookup] self.destroy_root() @@ -94,11 +97,10 @@ def _on_cancel(self): self._cancelled = True self.destroy_root() - if __name__ == "__main__": # Example usage selector = DirectoryFileSelector( - directory=Path.cwd(), + directory=Path.home(), file_types=[".py", ".txt"], selection_label="scripts and text files", ) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py index 897ed5d..3c81ba3 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py @@ -1,7 +1,7 @@ import tkinter as tk from tkinter import ttk from typing import Optional, Callable -from Python_Engine.Python.src.python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow class LandingPage(BHoMBaseWindow): @@ -13,17 +13,10 @@ class LandingPage(BHoMBaseWindow): def __init__( self, title: str = "Landing Page", - header: Optional[str] = None, + header: Optional[str] = None, message: Optional[str] = None, sub_title: Optional[str] = None, - min_width: int = 400, - min_height: int = 200, - show_continue: bool = True, - continue_text: str = "Continue", - continue_command: Optional[Callable] = None, - show_close: bool = True, - close_text: str = "Close", - close_command: Optional[Callable] = None, + **kwargs, ): """ Initializes the landing page GUI. @@ -31,49 +24,42 @@ def __init__( Args: title (str): Window and header title text. message (str, optional): Commentary/message text to display. - min_width (int): Minimum window width in pixels. - min_height (int): Minimum window height in pixels. - show_continue (bool): Whether to show the continue button. - continue_text (str): Text for the continue button. - continue_command (callable, optional): Command to run on continue. - show_close (bool): Whether to show the close button. - close_text (str): Text for the close button. - close_command (callable, optional): Command to run on close. """ - # Store callbacks - self.continue_command = continue_command - self.close_command = close_command + self.header = header + self.message = message + self.sub_title = sub_title + self.custom_buttons_frame: Optional[ttk.Frame] = None + super().__init__( title=title, - min_width=min_width, - min_height=min_height, - show_submit=show_continue, - submit_text=continue_text, - submit_command=self._on_continue, - show_close=show_close, - close_text=close_text, - close_command=self._on_close, + **kwargs, + ) + + def build(self): + """Build landing-page content using the base window's content area.""" + if self.header: + ttk.Label(self.content_frame, text=self.header, style="Header.TLabel").pack( + side="top", anchor="w", pady=(0, 10) ) - # Initialize DefaultRoot with continue mapped to submit - - if header: - header_label = ttk.Label(self.content_frame, text=header, style="Header.TLabel") - header_label.pack(side="top", anchor="w", pady=(0, 10)) - # Optional message/commentary - if message: - message_label = ttk.Label(self.content_frame, text=message, style="Body.TLabel", justify=tk.LEFT) - message_label.pack(side="top", anchor="w", pady=(0, 10)) + if self.message: + ttk.Label( + self.content_frame, + text=self.message, + style="Body.TLabel", + justify=tk.LEFT, + ).pack(side="top", anchor="w", pady=(0, 10)) - # Optional sub-title - if sub_title: - sub_title_label = ttk.Label(self.content_frame, text=sub_title, style="Caption.TLabel") - sub_title_label.pack(side="top", anchor="w", pady=(0, 10)) + if self.sub_title: + ttk.Label(self.content_frame, text=self.sub_title, style="Caption.TLabel").pack( + side="top", anchor="w", pady=(0, 10) + ) - # Custom buttons container self.custom_buttons_frame = ttk.Frame(self.content_frame) self.custom_buttons_frame.pack(fill=tk.X, pady=(0, 20)) + super().build() + def add_custom_button(self, text: str, command: Callable, **kwargs) -> ttk.Button: """ Add a custom button to the landing page. @@ -86,58 +72,28 @@ def add_custom_button(self, text: str, command: Callable, **kwargs) -> ttk.Butto Returns: ttk.Button: The created button widget. """ + if self.custom_buttons_frame is None: + self.custom_buttons_frame = ttk.Frame(self.content_frame) + self.custom_buttons_frame.pack(fill=tk.X, pady=(0, 20)) + button = ttk.Button(self.custom_buttons_frame, text=text, command=command, **kwargs) button.pack(pady=5, fill=tk.X) # Recalculate window size after adding button self.refresh_sizing() return button - def _on_continue(self): - """Handle continue button click.""" - if self.continue_command: - self.continue_command() - - def _on_close(self): - """Handle close button click.""" - if self.close_command: - self.close_command() - - def run(self) -> Optional[str]: - """Show the landing page and return the result.""" - result = self.run() - # Map DefaultRoot results to LandingPage convention - if result == "submit": - return "continue" - return result - if __name__ == "__main__": - # Basic example - def on_continue(): - print("Continue clicked!") - - def on_close(): - print("Close clicked!") - - landing = LandingPage( - title="Example Application", - message="Welcome to the landing page example.\n\nThis demonstrates a configurable landing page with custom buttons.\n\nPlease select an option below to proceed.", - header="Welcome!", - sub_title="Please choose an option to continue:", - continue_text="Proceed", - continue_command=on_continue, - close_command=on_close, - ) - - # Add custom buttons - landing.add_custom_button("Option A", lambda: print("Option A selected")) - landing.add_custom_button( - "Option B", lambda: print("Option B selected") - ) - landing.mainloop() + #simple example of using the landing page + def on_button_click(): + print("Button clicked!") - result = landing.result - - - print(f"Result: {result}") + 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.", + ) + landing_page.add_custom_button(text="Click Me", command=on_button_click) + landing_page.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py index d2d3c76..c2b4184 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py @@ -1,8 +1,6 @@ import tkinter as tk from tkinter import ttk -import os import time -from typing import Optional from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow class ProcessingWindow(BHoMBaseWindow): @@ -14,71 +12,54 @@ def __init__(self, title="Processing", message="Processing..."): title (str): Window title. message (str): Message to display. """ - super().__init__(title=title, min_width=300, min_height=150, show_submit=False, show_close=False) - - self.title(title) + self.window_title = title + self.message_text = message + + super().__init__( + title=title, + min_width=300, + min_height=150, + show_submit=False, + show_close=False, + resizable=False, + ) + self.attributes("-topmost", True) - self.resizable(False, False) - # Container - container = ttk.Frame(self, padding=20) - container.pack(fill="both", expand=True) + self.title_label = None + self.message_label = None + self.animation_label = None + + # Animation state + self.current_frame = 0 + self.is_running = False - # Constant title label + def build(self): self.title_label = ttk.Label( - container, - text=title, + self.content_frame, + text=self.window_title, style="Title.TLabel", justify="center", - wraplength=400 + wraplength=400, ) self.title_label.pack(pady=(0, 8)) - # Updatable message label self.message_label = ttk.Label( - container, - text=message, + self.content_frame, + text=self.message_text, justify="center", - wraplength=400 + wraplength=400, ) self.message_label.pack(pady=(0, 20)) - # Animation frame - animation_frame = ttk.Frame(container) - animation_frame.pack(expand=True) - self.animation_label = ttk.Label( - animation_frame, + self.content_frame, text="●", style="Title.TLabel", - foreground="#0078d4" ) self.animation_label.pack() - # Animation state - self.animation_frames = ["●", "●", "●"] - self.current_frame = 0 - self.is_running = False - - # Update to calculate the required size - self.update_idletasks() - - # Get the required width and height - required_width = self.winfo_reqwidth() - required_height = self.winfo_reqheight() - - # Set minimum size - min_width = 300 - min_height = 150 - window_width = max(required_width, min_width) - window_height = max(required_height, min_height) - - # Center on screen - screen_width = self.winfo_screenwidth() - screen_height = self.winfo_screenheight() - x = (screen_width - window_width) // 2 - y = (screen_height - window_height) // 2 - self.geometry(f"{window_width}x{window_height}+{x}+{y}") + super().build() def start(self): """Start the processing window and animation.""" @@ -102,13 +83,16 @@ def _animate(self): if self.is_running: # Create rotating dot animation dots = ["◐", "◓", "◑", "◒"] - self.animation_label.config(text=dots[self.current_frame % len(dots)]) + if self.animation_label is not None: + self.animation_label.config(text=dots[self.current_frame % len(dots)]) self.current_frame += 1 self.after(200, self._animate) def update_message(self, message: str): """Update the message text.""" - self.message_label.config(text=message) + self.message_text = message + if self.message_label is not None: + self.message_label.config(text=message) self.update() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py index 6c35385..b3539b7 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py @@ -1,8 +1,6 @@ import tkinter as tk from tkinter import ttk -import os -import time -from typing import Optional, Literal +from typing import Optional from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow """This will be used in place of warning / pop up boxes, to ensure consistent style""" @@ -13,11 +11,16 @@ class WarningBox(BHoMBaseWindow): def __init__( self, title: str= 'Warning', - warnings: list[str]|str = [], - errors: list[str]|str = [], - infos: list[str]|str = [], + warnings: list[str] | str | None = None, + errors: list[str] | str | None = None, + infos: list[str] | str | None = None, **kwargs ): + + self.warnings = self._normalise_messages(warnings) + self.errors = self._normalise_messages(errors) + self.infos = self._normalise_messages(infos) + super().__init__( title=title, show_submit=False, @@ -27,26 +30,18 @@ def __init__( **kwargs ) - if isinstance(warnings, str): - warnings = [warnings] - if isinstance(errors, str): - errors = [errors] - if isinstance(infos, str): - infos = [infos] + def build(self): + self._render_messages() + super().build() - self.warnings = warnings - self.errors = errors - self.infos = infos - - for message in self.errors: - ttk.Label(self.content_frame, text=message, style="Error.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) - for message in self.warnings: - ttk.Label(self.content_frame, text=message, style="Warning.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) - for message in self.infos: - ttk.Label(self.content_frame, text=message, style="Caption.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) + def _normalise_messages(self, messages: list[str] | str | None) -> list[str]: + if messages is None: + return [] + if isinstance(messages, str): + return [messages] + return list(messages) - def update_messages(self): - """Clear and re-render all messages in the warning box.""" + def _render_messages(self): for widget in self.content_frame.winfo_children(): widget.destroy() @@ -57,6 +52,11 @@ def update_messages(self): for message in self.infos: ttk.Label(self.content_frame, text=message, style="Caption.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) + def update_messages(self): + """Clear and re-render all messages in the warning box.""" + self._render_messages() + self.refresh_sizing() + def add_error_message(self, message: str): """Add an error message to the warning box.""" From 2eb28c4721f53a03204c1bf359042a5206fd06ce Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Tue, 24 Feb 2026 17:50:46 +0000 Subject: [PATCH 13/28] Improved packing, added documentation, added drop down widget --- .../bhom_tkinter/bhom_base_window.py | 50 ++++++- .../bhom_tkinter/widgets/Calender.py | 56 ++++++- .../bhom_tkinter/widgets/CheckBoxSelection.py | 41 +++-- .../bhom_tkinter/widgets/CmapSelector.py | 73 +++++++-- .../bhom_tkinter/widgets/ColourPicker.py | 24 ++- .../bhom_tkinter/widgets/DropDownSelection.py | 140 ++++++++++++++++++ .../bhom_tkinter/widgets/FigureContainer.py | 33 ++++- .../bhom_tkinter/widgets/ListBox.py | 51 ++++++- .../bhom_tkinter/widgets/MultiBoxSelection.py | 29 +++- .../bhom_tkinter/widgets/PathSelector.py | 32 +++- .../bhom_tkinter/widgets/RadioSelection.py | 47 ++++-- .../bhom_tkinter/widgets/ValidatedEntryBox.py | 61 ++++++-- .../bhom_tkinter/widgets/_packing_options.py | 9 +- .../bhom_tkinter/widgets/_widgets_base.py | 140 +++++++++++++++++- .../windows/DirectoryFileSelector.py | 31 ++-- .../bhom_tkinter/windows/LandingPage.py | 3 + .../bhom_tkinter/windows/ProcessingWindow.py | 15 +- .../bhom_tkinter/windows/WarningBox.py | 24 ++- tmp_doc_audit.py | 36 +++++ 19 files changed, 783 insertions(+), 112 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/DropDownSelection.py create mode 100644 tmp_doc_audit.py 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 4a247f0..d0ba4cb 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 @@ -1,3 +1,5 @@ +"""Base themed Tk window used by BHoM toolkit GUI windows.""" + import tkinter as tk from tkinter import ttk from pathlib import Path @@ -131,7 +133,7 @@ def build(self): self.refresh_sizing() def _set_window_icon(self, icon_path: Path) -> None: - """Set a custom window icon, replacing the default uk icon.""" + """Set a custom window icon, replacing Tk's default icon.""" if not icon_path.exists(): print(f"Warning: Icon file not found at {icon_path}") @@ -166,7 +168,20 @@ def _determine_theme( theme_path_light: Path, theme_path_dark: Path) -> tuple[Path, Path, Path, str]: - """determin the light or dark mode usage""" + """Determine light or dark assets and theme based on configured mode. + + Args: + logo_path: Light-mode logo path. + dark_logo_path: Optional dark-mode logo path. + icon_path: Light-mode icon path. + dark_icon_path: Optional dark-mode icon path. + theme_mode: Theme mode (`light`, `dark`, or `auto`). + theme_path_light: Light theme Tcl path. + theme_path_dark: Dark theme Tcl path. + + Returns: + tuple[Path, Path, Path, str]: Theme path, logo path, icon path, and style key. + """ if theme_mode == "light": return theme_path_light, logo_path, icon_path, "light" @@ -188,6 +203,12 @@ def _determine_theme( def _set_titlebar_theme(self, theme_style: str) -> str: """ Apply titlebar theme using Windows API. + + Args: + theme_style: Theme style key (`light` or `dark`). + + Returns: + str: Applied titlebar style key. """ try: @@ -261,7 +282,13 @@ def _load_theme(self, _theme_path: Path) -> str: return "default" def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path]) -> None: - """Build the branded banner section.""" + """Build the branded banner section. + + Args: + parent: Parent frame to host the banner. + title: Banner title text. + logo_path: Optional logo image path. + """ banner = ttk.Frame(parent, relief=tk.RIDGE, borderwidth=1) banner.pack(fill=tk.BOTH, padx=0, pady=0) @@ -311,7 +338,15 @@ def _build_buttons( show_close: bool, close_text: str, ) -> None: - """Build the bottom button bar.""" + """Build the bottom button bar. + + Args: + parent: Parent frame for the button bar. + show_submit: Whether to create submit button. + submit_text: Submit button label. + show_close: Whether to create close button. + close_text: Close button label. + """ self.button_bar = ttk.Frame(parent, padding=(20, 10)) self.button_bar.pack(side=tk.BOTTOM, fill=tk.X) @@ -423,7 +458,12 @@ def destroy_root(self) -> None: pass def _exit(self, result: str, callback: Optional[Callable] = None) -> None: - """Handle any exit path and always destroy the root window.""" + """Handle any exit path and always destroy the root window. + + Args: + result: Result token to store before closing. + callback: Optional callback invoked before destruction. + """ self.result = result try: if callback: diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py index 9688a30..d23c9d1 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py @@ -1,3 +1,5 @@ +"""Calendar date-picker widget with optional year selector.""" + import tkinter as tk from typing import Optional from tkinter import ttk @@ -7,6 +9,8 @@ from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget class CalendarWidget(BHoMBaseWidget): + """Render a month grid and allow date selection.""" + def __init__( self, parent: ttk.Frame, @@ -26,13 +30,13 @@ def __init__( self.year_min = year_min self.year_max = year_max - self.cal_frame = ttk.Frame(self) + self.cal_frame = ttk.Frame(self.content_frame) self.cal_frame.pack(side="top", fill="x") - self.month_frame = ttk.Frame(self) + self.month_frame = ttk.Frame(self.content_frame) self.month_frame.pack(side="top", fill="x") - self.date_frame = ttk.Frame(self) + self.date_frame = ttk.Frame(self.content_frame) self.date_frame.pack(side="top", fill="x") if self.show_year_selector: @@ -42,6 +46,7 @@ def __init__( self.redraw() def year_selector(self): + """Build the year dropdown selector.""" year_var = tk.StringVar() year_var.set(str(self.year)) @@ -51,6 +56,7 @@ def year_selector(self): drop.pack(side="left", padx=4, pady=4) def month_selector(self): + """Build the month dropdown selector.""" self.months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] clicked = tk.StringVar() @@ -61,21 +67,33 @@ def month_selector(self): drop.pack(side="left", padx=4, pady=4) def set_year(self, var): + """Update the selected year and redraw the calendar. + + Args: + var: Tk variable containing the selected year. + """ year = int(var.get()) self.year = year self.redraw() def set_month(self, var): + """Update the selected month and redraw the calendar. + + Args: + var: Tk variable containing the selected month name. + """ month = var.get() self.month = self.months.index(month) + 1 self.redraw() def redraw(self): + """Rebuild the month grid buttons for the current month and year.""" for child in self.cal_frame.winfo_children(): child.destroy() for col, day in enumerate(("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su")): label = ttk.Label(self.cal_frame, text=day) + getattr(self, "align_child_text")(label) label.grid(row=0, column=col, sticky="nsew") cal = calendar.monthcalendar(self.year, self.month) @@ -88,6 +106,11 @@ def redraw(self): cell.grid(row=row+1, column=col, sticky="nsew") def set_day(self, num): + """Set the selected day and refresh the date summary label. + + Args: + num: Day of month to mark as selected. + """ self.day = num for child in self.date_frame.winfo_children(): @@ -95,27 +118,49 @@ def set_day(self, num): date = self.months[self.month-1] + " " + str(self.day) label = ttk.Label(self.date_frame, text=f"Selected Date: {date}") - label.pack(padx=4, pady=4) + getattr(self, "align_child_text")(label) + label.pack(anchor=getattr(self, "_pack_anchor"), padx=4, pady=4) def get_date(self): + """Return the selected date as a `datetime.date` instance. + + Returns: + datetime.date: Currently selected date. + """ return datetime.date(self.year, self.month, self.day) def get(self): + """Return the selected date value. + + Returns: + datetime.date: Currently selected date. + """ return datetime.date(self.year, self.month, self.day) def set(self, value: datetime.date): + """Set the selected date from a `datetime.date` value. + + Args: + value: Date to apply to the widget. + """ self.year = value.year self.month = value.month self.day = value.day self.redraw() def pack(self, **kwargs): + """Pack the widget and ensure the calendar grid is rendered. + + Args: + **kwargs: Pack geometry manager options. + """ super().pack(**kwargs) self.redraw() if __name__ == "__main__": from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions root = BHoMBaseWindow(min_height=500) root.title("Calendar Widget Test") @@ -128,7 +173,8 @@ def pack(self, **kwargs): def_day=15, item_title="Select a Date", helper_text="Choose a date from the calendar below.", + packing_options=PackingOptions(padx=20, pady=20) ) - cal_widget1.pack(padx=20, pady=20) + cal_widget1.build() root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CheckBoxSelection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CheckBoxSelection.py index ea5e4c5..8bdb96c 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CheckBoxSelection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CheckBoxSelection.py @@ -1,3 +1,5 @@ +"""Checkbox selection widget with multi-select support and state helpers.""" + import tkinter as tk from tkinter import ttk from typing import Optional, List, Callable @@ -64,6 +66,7 @@ def _build_buttons(self): text=f"□ {field}", cursor="hand2" ) + getattr(self, "align_child_text")(button) button.bind("", lambda e, f=field: self._toggle_field(f)) if self.max_per_line and self.max_per_line > 0: @@ -73,11 +76,11 @@ def _build_buttons(self): else: row = index % self.max_per_line column = index // self.max_per_line - button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky="w") + button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky=getattr(self, "_grid_sticky")) elif self.orient == "horizontal": - button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky="w") + button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky=getattr(self, "_grid_sticky")) else: - button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky="w") + button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky=getattr(self, "_grid_sticky")) self._buttons.append(button) def _toggle_field(self, field): @@ -103,11 +106,19 @@ def _on_select_field(self, field): self.command(self.get()) def get(self) -> List[str]: - """Return a list of currently selected values.""" + """Return a list of currently selected values. + + Returns: + List[str]: Selected field labels. + """ return [field for field, var in self.value_vars.items() if var.get()] def set(self, value: List[str]): - """Set the selected values. Accepts a list of field names to check.""" + """Set the selected values. + + Args: + value: Field names to mark as selected. + """ values = [str(v) for v in (value or [])] for field, var in self.value_vars.items(): var.set(field in values) @@ -161,7 +172,12 @@ def toggle_all(self): self.command(self.get()) def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): - """Replace the available fields and rebuild the widget.""" + """Replace the available fields and rebuild the widget. + + Args: + fields: New available field names. + defaults: Optional field names to preselect after rebuild. + """ self.fields = [str(field) for field in (fields or [])] self._build_buttons() @@ -169,14 +185,20 @@ def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): self.set(defaults) def pack(self, **kwargs): - """Pack the widget with the given options.""" + """Pack the widget with the given options. + + Args: + **kwargs: Pack geometry manager options. + """ super().pack(**kwargs) 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_selection(values): + """Print selected values in the standalone example.""" print(f"Selected: {values}") root = BHoMBaseWindow() @@ -190,9 +212,10 @@ def on_selection(values): orient="vertical", max_per_line=6, item_title="Choose Options", - helper_text="Select one or more options below:" + helper_text="Select one or more options below:", + packing_options=PackingOptions(padx=20, pady=20) ) - widget.pack(padx=20, pady=20) + widget.build() # Add control buttons for demonstration control_frame = ttk.Frame(parent_frame) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CmapSelector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CmapSelector.py index 867ed30..96f5773 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CmapSelector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CmapSelector.py @@ -1,3 +1,5 @@ +"""Colormap selector widget with embedded matplotlib preview.""" + from typing import Dict, List, Optional from tkinter import ttk import tkinter as tk @@ -79,7 +81,7 @@ def __init__( mpl.use("Agg") # Use non-interactive backend for embedding in Tkinter # Create frame for cmap selection content - self.cmap_frame = ttk.Frame(self) + self.cmap_frame = ttk.Frame(self.content_frame) self.cmap_frame.pack(side="top", fill="both", expand=True) self.colormap_var = tk.StringVar(value="viridis") @@ -94,7 +96,7 @@ def __init__( self.cmap_frame.columnconfigure(0, weight=1) self.cmap_frame.rowconfigure(0, weight=1) - content = tk.Frame(self.cmap_frame, width=440, height=130) + content = ttk.Frame(self.cmap_frame, width=440, height=130) content.grid(row=0, column=0, padx=4, pady=4) content.grid_propagate(False) @@ -128,7 +130,11 @@ def __init__( self._select_default_cmap(current_colormaps) def _get_all_colormaps(self) -> List[str]: - """Return all registered colormap names, including reversed variants.""" + """Return all registered colormap names, including reversed variants. + + Returns: + List[str]: Sorted list of available colormap names. + """ # Base names available in this matplotlib build base_names = set(mpl.colormaps()) @@ -141,12 +147,26 @@ def _get_all_colormaps(self) -> List[str]: return sorted(all_names) def _filter_available(self, names: List[str]) -> List[str]: - """Filter a candidate list to names available in the current matplotlib build.""" + """Filter a candidate list to names available in the current matplotlib build. + + Args: + names: Candidate colormap names. + + Returns: + List[str]: Candidate names available in the current environment. + """ available = set(self._all_colormaps) return [name for name in names if name in available] def _with_reversed(self, names: List[str]) -> List[str]: - """Return colormap names with reversed variants added next to each base map when available.""" + """Return colormap names with reversed variants next to base maps. + + Args: + names: Base colormap names. + + Returns: + List[str]: Ordered list with available `_r` variants inserted. + """ available = set(self._all_colormaps) selected: List[str] = [] for name in names: @@ -159,7 +179,14 @@ def _with_reversed(self, names: List[str]) -> List[str]: return selected def _preset_colormaps(self, cmap_set: str) -> List[str]: - """Resolve a preset colormap set name to a colormap list.""" + """Resolve a preset colormap set name to a colormap list. + + Args: + cmap_set: Preset set key. + + Returns: + List[str]: Colormap names for the requested preset. + """ key = (cmap_set or "all").lower() return self._with_reversed(self._preset_map.get(key, self._preset_map["all"])) @@ -183,7 +210,11 @@ def _on_cmap_selected(self, event=None) -> None: self._update_cmap_sample() def _update_cmap_sample(self, *args) -> None: - """Update the colormap sample plot.""" + """Update the colormap sample plot. + + Args: + *args: Unused callback arguments from Tk traces/events. + """ cmap_name = self.colormap_var.get() if not cmap_name: self.figure_widget.clear() @@ -193,16 +224,28 @@ def _update_cmap_sample(self, *args) -> None: self.figure_widget.embed_figure(fig) def get_selected_cmap(self) -> Optional[str]: - """Return the currently selected colormap name, or None if no selection.""" + """Return the currently selected colormap name, or `None` if empty. + + Returns: + Optional[str]: Current colormap name. + """ cmap_name = self.colormap_var.get() return cmap_name if cmap_name else None def get(self) -> Optional[str]: - """Get the currently selected colormap name.""" + """Get the currently selected colormap name. + + Returns: + Optional[str]: Current colormap name. + """ return self.get_selected_cmap() def set(self, value: Optional[str]): - """Set the selected colormap by name.""" + """Set the selected colormap by name. + + Args: + value: Colormap name to select. Clears selection when invalid. + """ if value and value in self.cmap_combobox["values"]: self.colormap_var.set(value) self._update_cmap_sample() @@ -215,7 +258,13 @@ def set(self, value: Optional[str]): 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.") - cmap_selector.pack(fill=tk.BOTH, expand=True) + cmap_selector = CmapSelector( + parent_container, + cmap_set="all", + item_title="Colormap Selector", + helper_text="Select a colormap from the list.", + packing_options=PackingOptions(fill='both', expand=True) + ) + cmap_selector.build() root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ColourPicker.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ColourPicker.py index a77e568..5c291c1 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ColourPicker.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ColourPicker.py @@ -1,3 +1,5 @@ +"""Colour picker widget with themed popup and live RGB/hex preview.""" + import tkinter as tk from tkinter import ttk from typing import Callable, Optional @@ -39,8 +41,8 @@ def __init__( self._popup_green_var: Optional[tk.IntVar] = None self._popup_blue_var: Optional[tk.IntVar] = None - controls = ttk.Frame(self) - controls.pack(side="top", anchor="center") + controls = ttk.Frame(self.content_frame) + controls.pack(side="top", anchor=getattr(self, "_pack_anchor")) self.preview = tk.Canvas( controls, @@ -205,22 +207,32 @@ def _hex_to_rgb(colour: str) -> tuple[int, int, int]: return 255, 255, 255 def get(self) -> str: - """Return the currently selected hex colour.""" + """Return the currently selected hex colour. + + Returns: + str: Selected colour in hex format. + """ return self.colour_var.get() def set(self, value: str) -> None: - """Set the current colour and refresh the preview swatch.""" + """Set the current colour and refresh the preview swatch. + + Args: + value: Colour value in hex format. + """ self.colour_var.set(value) self._update_preview(value) if __name__ == "__main__": from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions root = BHoMBaseWindow() parent_container = root.content_frame def on_colour_changed(colour: str) -> None: + """Print selected colour in the standalone example.""" print(f"Selected colour: {colour}") colour_picker = ColourPicker( @@ -228,7 +240,9 @@ def on_colour_changed(colour: str) -> None: command=on_colour_changed, item_title="Colour Picker", helper_text="Pick a colour for plotting.", + alignment="center", + packing_options=PackingOptions(padx=10, pady=10), ) - colour_picker.pack(padx=20, pady=20, anchor="w") + colour_picker.build() root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/DropDownSelection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/DropDownSelection.py new file mode 100644 index 0000000..f448ecf --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/DropDownSelection.py @@ -0,0 +1,140 @@ +"""Dropdown selection widget built from ttk.Combobox.""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable, List + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + +class DropDownSelection(BHoMBaseWidget): + """A reusable dropdown selection widget built from a list of options.""" + + def __init__( + self, + parent, + options: Optional[List[str]] = None, + command: Optional[Callable[[str], None]] = None, + default: Optional[str] = None, + width: int = 20, + state: str = "readonly", + **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + options (list, optional): List of selectable options. + command (callable, optional): Called with selected value when changed. + default (str, optional): Default selected value. + width (int): Width of the dropdown widget in characters. + state (str): Combobox state ("normal", "readonly", or "disabled"). + item_title (str, optional): Optional header text shown at the top of the widget frame. + helper_text (str, optional): Optional helper text shown above the dropdown. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.options = [str(option) for option in (options or [])] + self.command = command + self.value_var = tk.StringVar() + + # Create the combobox in the content frame + self.combobox = ttk.Combobox( + self.content_frame, + textvariable=self.value_var, + values=self.options, + width=width, + state=state + ) + self.combobox.pack(side="top", anchor=getattr(self, "_pack_anchor"), fill="x") + + # Bind selection event + self.combobox.bind("<>", self._on_select) + + # Set default value + if default is not None and default in self.options: + self.set(default) + elif self.options: + self.value_var.set(self.options[0]) + + def _on_select(self, event=None): + """Handle selection change event. + + Args: + event: Tkinter event (optional). + """ + if self.command: + self.command(self.get()) + + def get(self) -> str: + """Return the currently selected value. + + Returns: + str: Selected option value. + """ + return self.value_var.get() + + def set(self, value: str): + """Set the selected value. + + 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. + + Args: + options: New list of selectable options. + default: Optional value to select after updating options. + """ + self.options = [str(option) for option in (options or [])] + self.combobox['values'] = self.options + + if default is not None and default in self.options: + self.set(default) + elif self.options: + self.value_var.set(self.options[0]) + else: + self.value_var.set("") + + def get_selected_index(self) -> int: + """Return the index of the currently selected option. + + Returns: + int: Index of selected option, or -1 if not found. + """ + try: + return self.options.index(self.get()) + except ValueError: + return -1 + + +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_selection(value): + """Print selected value in the standalone example.""" + print(f"Selected: {value}") + + root = BHoMBaseWindow() + parent_frame = root.content_frame + + widget = DropDownSelection( + parent_frame, + options=["Option A", "Option B", "Option C", "Option D", "Option E"], + command=on_selection, + default="Option C", + width=30, + item_title="Choose an Option", + helper_text="Select one option from the dropdown list.", + alignment="center", + packing_options=PackingOptions(padx=20, pady=20) + ) + widget.build() + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/FigureContainer.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/FigureContainer.py index 2367cac..8d99251 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/FigureContainer.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/FigureContainer.py @@ -1,3 +1,5 @@ +"""Container widget for embedding matplotlib figures or image content.""" + import tkinter as tk from tkinter import ttk from typing import Optional, Any @@ -55,7 +57,11 @@ def _clear_children(self) -> None: self.image_label = None def _resolved_background(self) -> str: - """Resolve a background colour suitable for embedded Tk canvas widgets.""" + """Resolve a background colour suitable for embedded Tk canvas widgets. + + Returns: + str: Resolved background colour string. + """ try: bg = ttk.Style().lookup("TFrame", "background") if bg: @@ -69,11 +75,10 @@ def _resolved_background(self) -> str: return "white" def embed_figure(self, figure: Figure) -> None: + """Embed a matplotlib figure in the container, replacing existing content. - """ add matplotlib figure to the container, replacing any existing content. - Args: - figure: Matplotlib Figure object to embed + figure: Matplotlib Figure object to embed. """ self._clear_children() @@ -188,7 +193,11 @@ def clear(self) -> None: self._original_pil_image = None def get(self): - """Return the currently embedded figure or image.""" + """Return the currently embedded figure or image. + + Returns: + Optional[Any]: Embedded figure/image, or `None` when empty. + """ if self.figure is not None: return self.figure elif self.image is not None: @@ -197,7 +206,11 @@ def get(self): return None def set(self, value): - """Set the content of the figure container, accepting either a Figure or PhotoImage.""" + """Set the content of the figure container. + + Args: + value: `Figure`, `PhotoImage`, or image file path string. + """ if isinstance(value, Figure): self.embed_figure(value) elif isinstance(value, tk.PhotoImage): @@ -211,6 +224,8 @@ def set(self, value): if __name__ == "__main__": from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + root = BHoMBaseWindow() parent_container = root.content_frame @@ -218,8 +233,10 @@ def set(self, value): figure_container = FigureContainer( parent=parent_container, item_title="Figure Container", - helper_text="This widget can embed matplotlib figures or images.") - figure_container.pack(padx=10, pady=10, fill=tk.BOTH, expand=True) + helper_text="This widget can embed matplotlib figures or images.", + packing_options=PackingOptions(padx=10, pady=10, fill='both', expand=True) + ) + figure_container.build() # Create and embed a matplotlib figure fig, ax = plt.subplots(figsize=(5, 4), dpi=80) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ListBox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ListBox.py index 236d833..c640c53 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ListBox.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ListBox.py @@ -1,3 +1,5 @@ +"""Scrollable listbox widget with optional selection helper controls.""" + import tkinter as tk from tkinter import filedialog, ttk from typing import Optional @@ -71,19 +73,31 @@ def _on_configure(self, event=None): self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y) def set_selections(self, items): - """Set the selection to the specified items.""" + """Set the selection to the specified items. + + Args: + items: Item values to select. + """ self.listbox.selection_clear(0, tk.END) for index, item in enumerate(self.items): if item in items: self.listbox.selection_set(index) def get_selection(self): - """Return a list of selected items.""" + """Return a list of selected items. + + Returns: + list: Selected item values. + """ selected_indices = self.listbox.curselection() return [self.listbox.get(i) for i in selected_indices] def get_selection_indices(self): - """Return tuple of selected indices.""" + """Return tuple of selected indices. + + Returns: + tuple: Indices of selected entries. + """ return self.listbox.curselection() def select_all(self): @@ -95,12 +109,21 @@ def deselect_all(self): self.listbox.selection_clear(0, tk.END) def insert(self, index, item): - """Insert an item at the specified index.""" + """Insert an item at the specified index. + + Args: + index: Position at which to insert. + item: Item value to insert. + """ self.listbox.insert(index, item) self._on_configure() def delete(self, index): - """Delete an item at the specified index.""" + """Delete an item at the specified index. + + Args: + index: Position of item to delete. + """ self.listbox.delete(index) self._on_configure() @@ -110,19 +133,31 @@ def clear(self): self._on_configure() def pack(self, **kwargs): - """Pack the widget with the given options.""" + """Pack the widget with the given options. + + Args: + **kwargs: Pack geometry manager options. + """ super().pack(**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 listbox items to the provided list. + + Args: + value: Iterable of item values to display. + """ self.clear() for item in value: self.listbox.insert(tk.END, item) self._on_configure( ) def get(self): - """Get the current list of items in the listbox.""" + """Get the current list of items in the listbox. + + Returns: + list: Current listbox items in display order. + """ return [self.listbox.get(i) for i in range(self.listbox.size())] diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/MultiBoxSelection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/MultiBoxSelection.py index 66cdf6d..41727fa 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/MultiBoxSelection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/MultiBoxSelection.py @@ -1,3 +1,5 @@ +"""Multi-select checkbox widget with configurable orientation and wrapping.""" + import tkinter as tk from tkinter import ttk from typing import Optional, List, Callable @@ -62,6 +64,7 @@ def _build_buttons(self): text=f"□ {field}", cursor="hand2" ) + getattr(self, "align_child_text")(button) button.bind("", lambda _event, f=field: self._toggle_field(f)) if self.max_per_line and self.max_per_line > 0: if self.orient == "horizontal": @@ -70,11 +73,11 @@ def _build_buttons(self): else: row = index % self.max_per_line column = index // self.max_per_line - button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky="w") + button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky=getattr(self, "_grid_sticky")) elif self.orient == "horizontal": - button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky="w") + button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky=getattr(self, "_grid_sticky")) else: - button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky="w") + button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky=getattr(self, "_grid_sticky")) self._buttons.append(button) def _toggle_field(self, field): @@ -98,11 +101,19 @@ def _on_select_field(self, field): self.command(self.get()) def get(self) -> List[str]: - """Return a list of currently selected values.""" + """Return a list of currently selected values. + + Returns: + List[str]: Selected field labels. + """ return [field for field, var in self.value_vars.items() if var.get()] def set(self, value: List[str]): - """Set the selected values. Accepts a list of field names to check.""" + """Set the selected values. + + Args: + value: Field names to mark as selected. + """ values = [str(v) for v in (value or [])] for field, var in self.value_vars.items(): var.set(field in values) @@ -138,7 +149,12 @@ def deselect_all(self): self.command(self.get()) def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): - """Replace the available fields and rebuild the widget.""" + """Replace the available fields and rebuild the widget. + + Args: + fields: New available field names. + defaults: Optional field names to preselect after rebuild. + """ self.fields = [str(field) for field in (fields or [])] self._build_buttons() @@ -151,6 +167,7 @@ def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions def on_selection(values): + """Print selected values in the standalone example.""" print(f"Selected: {values}") root = BHoMBaseWindow() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/PathSelector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/PathSelector.py index a69e63c..d07c2f3 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/PathSelector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/PathSelector.py @@ -1,3 +1,5 @@ +"""Path selection widget for file or directory browsing.""" + import tkinter as tk from tkinter import filedialog, ttk from pathlib import Path @@ -36,10 +38,10 @@ def __init__( self.initialdir = initialdir self.filetypes = filetypes if filetypes is not None else [("All Files", "*.*")] self.display_name = tk.StringVar() - self.entry = ttk.Entry(self, textvariable=self.display_name, width=40) + self.entry = ttk.Entry(self.content_frame, textvariable=self.display_name, width=40) self.entry.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True) - self.button = ttk.Button(self, text=button_text, command=self._on_click) + self.button = ttk.Button(self.content_frame, text=button_text, command=self._on_click) self.button.pack(side=tk.LEFT) def _on_click(self): @@ -63,11 +65,19 @@ def _on_click(self): self.command(str(selected_path)) def get(self) -> str: - """Return the currently selected file path.""" + """Return the currently selected file path. + + Returns: + str: Selected file or directory path. + """ return self.path_var.get() def set(self, value: Optional[str]): - """Set the file path in the entry.""" + """Set the file path in the entry. + + Args: + value: File or directory path to display. + """ if not value: self.path_var.set("") self.display_name.set("") @@ -85,14 +95,24 @@ def set(self, value: Optional[str]): if __name__ == "__main__": from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions root = BHoMBaseWindow() parent_container = root.content_frame def on_file_selected(path): + """Print selected path in the standalone example.""" print(f"Selected: {path}") - path_selector = PathSelector(parent_container, button_text="Select File", filetypes=[("All Files", "*.*")], command=on_file_selected) - path_selector.pack(padx=20, pady=20) + path_selector = PathSelector( + parent_container, + button_text="Select File", + filetypes=[("All Files", "*.*")], + command=on_file_selected, + item_title="Path Selector", + helper_text="Select a file from your system.", + packing_options=PackingOptions(padx=20, pady=20) + ) + path_selector.build() root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/RadioSelection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/RadioSelection.py index 0d054b1..93de1b5 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/RadioSelection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/RadioSelection.py @@ -1,6 +1,8 @@ +"""Single-select radio-style widget built from clickable labels.""" + import tkinter as tk from tkinter import ttk -from typing import Optional +from typing import Optional, cast from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget @@ -38,7 +40,7 @@ def __init__( self._buttons = [] # Sub-frame for radio button controls - self.buttons_frame = ttk.Frame(self) + self.buttons_frame = ttk.Frame(self.content_frame) self.buttons_frame.pack(side="top", fill="x", expand=True) self._build_buttons() @@ -55,11 +57,15 @@ def _build_buttons(self): self._buttons.clear() for index, field in enumerate(self.fields): + sticky = cast(str, getattr(self, "_grid_sticky", "w")) + align_child_text = getattr(self, "align_child_text", None) button = ttk.Label( self.buttons_frame, text=f"○ {field}", cursor="hand2" ) + if callable(align_child_text): + align_child_text(button) button.bind("", lambda _event, f=field: self._select_field(f)) if self.max_per_line and self.max_per_line > 0: if self.orient == "horizontal": @@ -68,15 +74,13 @@ def _build_buttons(self): else: row = index % self.max_per_line column = index // self.max_per_line - button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky="w") + button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky=sticky) elif self.orient == "horizontal": - button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky="w") + button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky=sticky) else: - button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky="w") + button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky=sticky) self._buttons.append(button) - self._update_visual_state() - def _select_field(self, field): """Select a field when clicked.""" self.value_var.set(field) @@ -96,18 +100,31 @@ def _update_visual_state(self): button.configure(text=f"○ {current_field}") def get(self): - """Return the currently selected value.""" + """Return the currently selected value. + + Returns: + str: Currently selected field label. + """ return self.value_var.get() def set(self, value): - """Set the selected value if it exists in fields.""" + """Set the selected value if it exists in fields. + + Args: + value: Field label to select. + """ 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.""" + """Replace the available fields and rebuild the widget. + + Args: + fields: New available field names. + default: Optional default field to select. + """ self.fields = [str(field) for field in (fields or [])] self._build_buttons() @@ -122,8 +139,10 @@ def set_fields(self, fields, default=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_selection(value): + """Print selected option in the standalone example.""" print(f"Selected: {value}") root = BHoMBaseWindow() @@ -134,11 +153,13 @@ def on_selection(value): fields=["Option A", "Option B", "Option C", "Option D", "Option E", "Option F", "Option G"], command=on_selection, default="Option B", + alignment="center", orient="vertical", - max_per_line=6, + max_per_line=3, item_title="Choose an Option", - helper_text="Select one of the options below:" + helper_text="Select one of the options below:", + packing_options=PackingOptions(padx=20, pady=20) ) - widget.pack(padx=20, pady=20) + widget.build() root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ValidatedEntryBox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ValidatedEntryBox.py index f789550..469928e 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ValidatedEntryBox.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ValidatedEntryBox.py @@ -1,3 +1,5 @@ +"""Validated entry widget supporting typed value and constraint checks.""" + import tkinter as tk from tkinter import ttk from typing import Optional, Callable, Any, Union @@ -71,22 +73,32 @@ def __init__( # Create success indicator label at end of entry self.success_label = ttk.Label(self.entry_frame, text=" ", foreground="#4bb543", width=2) + getattr(self, "align_child_text")(self.success_label) self.success_label.pack(side="left", padx=(5, 0)) # Create error label below entry with fixed height to prevent layout shifts - self.error_label = ttk.Label(self.content_frame, text=" ", style="Caption.TLabel", anchor="w") - self.error_label.pack(side="top", fill="x") + self.error_label = ttk.Label(self.content_frame, text=" ", style="Caption.TLabel") + getattr(self, "align_child_text")(self.error_label) + self.error_label.pack(side="top", fill="x", anchor=getattr(self, "_pack_anchor")) # Bind validation events self.entry.bind("", lambda _: self.validate()) self.entry.bind("", lambda _: self.validate()) def get(self) -> str: - """Get the current value as a string.""" + """Get the current value as a string. + + Returns: + str: Trimmed entry value. + """ return self.variable.get().strip() def get_value(self) -> Optional[Union[str, int, float]]: - """Get the current value converted to the specified type.""" + """Get the current value converted to the specified type. + + Returns: + Optional[Union[str, int, float]]: Parsed value, or `None` when empty/invalid. + """ value_str = self.get() if not value_str: return None @@ -102,7 +114,11 @@ def get_value(self) -> Optional[Union[str, int, float]]: return None def set(self, value: Union[str, int, float]) -> None: - """Set the entry value.""" + """Set the entry value. + + Args: + value: Value to display in the entry. + """ self.variable.set(str(value)) def validate(self) -> bool: @@ -139,7 +155,14 @@ def validate(self) -> bool: return False def _validate_string(self, value: str) -> bool: - """Validate string value.""" + """Validate string value. + + Args: + value: String value to validate. + + Returns: + bool: `True` when valid, otherwise `False`. + """ # 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}") @@ -164,7 +187,14 @@ def _validate_string(self, value: str) -> bool: return True def _validate_int(self, value_str: str) -> bool: - """Validate integer value.""" + """Validate integer value. + + Args: + value_str: Raw entry text to parse as integer. + + Returns: + bool: `True` when valid, otherwise `False`. + """ try: value = int(value_str) except ValueError: @@ -196,7 +226,14 @@ def _validate_int(self, value_str: str) -> bool: return True def _validate_float(self, value_str: str) -> bool: - """Validate float value.""" + """Validate float value. + + Args: + value_str: Raw entry text to parse as float. + + Returns: + bool: `True` when valid, otherwise `False`. + """ try: value = float(value_str) except ValueError: @@ -259,10 +296,13 @@ def _call_validate_callback(self, is_valid: bool) -> None: # Test the ValidatedEntryBox from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + root = BHoMBaseWindow(title="Validated Entry Box Test") parent_container = getattr(root, "content_frame", root) def on_validate(is_valid): + """Print validation state in the standalone example.""" print(f"Validation result: {is_valid}") entry_box = ValidatedEntryBox( @@ -272,8 +312,9 @@ def on_validate(is_valid): value_type=int, min_value=0, max_value=100, - on_validate=on_validate + on_validate=on_validate, + packing_options=PackingOptions(padx=20, pady=20) ) - entry_box.pack(padx=20, pady=20) + entry_box.build() root.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py index 5ac4872..f75efd8 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py @@ -1,8 +1,11 @@ +"""Typed packing options shared by BHoM Tkinter widgets.""" + from dataclasses import dataclass, asdict from typing import Any, Literal @dataclass class PackingOptions: + """Container for `pack` geometry keyword arguments with type hints.""" after: Any = None anchor: Literal['nw', 'n', 'ne', 'w', 'center', 'e', 'sw', 's', 'se'] = 'center' @@ -17,7 +20,11 @@ class PackingOptions: in_: Any = None def to_dict(self) -> dict: - """Convert the dataclass to a dictionary, excluding None values.""" + """Convert the dataclass to a dictionary, excluding `None` values. + + Returns: + dict: Packing options filtered to keys with concrete values. + """ return {k: v for k, v in asdict(self).items() if v is not None} if __name__ == "__main__": 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 7e4d6ea..87d1492 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 @@ -1,4 +1,7 @@ +"""Abstract base widget primitives used across BHoM Tkinter components.""" + from abc import ABC, abstractmethod +from typing import cast import tkinter as tk from tkinter import ttk @@ -20,6 +23,7 @@ def __init__( id: Optional[str] = None, item_title: Optional[str] = None, helper_text: Optional[str] = None, + alignment: Literal['left', 'center', 'right'] = 'left', packing_options: PackingOptions = PackingOptions(), **kwargs): """ @@ -30,6 +34,7 @@ def __init__( id: Optional unique identifier for the widget item_title: Optional header text shown at the top of the widget frame. helper_text: Optional helper text shown above the entry box. + alignment: Horizontal alignment for built-in text elements. **kwargs: Additional Frame options """ super().__init__(parent, **kwargs) @@ -37,6 +42,7 @@ def __init__( self.item_title = item_title self.helper_text = helper_text self.packing_options = packing_options + self.alignment: Literal['left', 'center', 'right'] = self._normalise_alignment(alignment) if id is None: self.id = str(uuid4()) @@ -46,17 +52,134 @@ def __init__( # Optional header/title label at the top of the widget if self.item_title: self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") - self.title_label.pack(side="top", anchor="w") + self.title_label.pack(side="top", anchor=self._pack_anchor) + self._apply_text_alignment(self.title_label) # Optional helper/requirements label above the input if self.helper_text: self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") - self.helper_label.pack(side="top", anchor="w") + self.helper_label.pack(side="top", anchor=self._pack_anchor) + self._apply_text_alignment(self.helper_label) # Container frame for embedded content (not title/helper) self.content_frame = ttk.Frame(self) self.content_frame.pack(side="top", fill=tk.BOTH, expand=True) + @property + def _pack_anchor(self) -> Literal['w', 'center', 'e']: + """Return the pack anchor string for the current alignment. + + Returns: + str: Pack anchor token (`w`, `center`, or `e`). + """ + return cast(Literal['w', 'center', 'e'], { + "left": "w", + "center": "center", + "right": "e", + }[self.alignment]) + + @property + def _text_anchor(self) -> Literal['w', 'center', 'e']: + """Return the text anchor string for the current alignment. + + Returns: + str: Text anchor token (`w`, `center`, or `e`). + """ + return cast(Literal['w', 'center', 'e'], { + "left": "w", + "center": "center", + "right": "e", + }[self.alignment]) + + @property + def _text_justify(self) -> Literal['left', 'center', 'right']: + """Return the Tk justify token for the current alignment. + + Returns: + str: Justification token (`left`, `center`, or `right`). + """ + return cast(Literal['left', 'center', 'right'], self.alignment) + + @property + def _grid_sticky(self) -> Literal['w', '', 'e']: + """Return the grid sticky token for the current alignment. + + Returns: + str: Grid sticky token (`w`, empty string for centered, or `e`). + """ + return cast(Literal['w', '', 'e'], { + "left": "w", + "center": "", + "right": "e", + }[self.alignment]) + + def _normalise_alignment(self, alignment: Optional[str]) -> Literal['left', 'center', 'right']: + """Normalise alignment input and fallback safely to `left`. + + Args: + alignment: Candidate alignment value. + + Returns: + Literal['left', 'center', 'right']: Normalised alignment. + """ + candidate = str(alignment or "left").strip().lower() + if candidate not in {"left", "center", "right"}: + return "left" + return cast(Literal['left', 'center', 'right'], candidate) + + def _apply_text_alignment(self, widget: tk.Widget) -> None: + """Apply current alignment settings to a text-capable Tk widget. + + Args: + widget: Tk/ttk widget that may support `anchor` and/or `justify`. + """ + try: + configure_keys = set(widget.configure().keys()) + except Exception: + return + + options = {} + if "anchor" in configure_keys: + options["anchor"] = self._text_anchor + if "justify" in configure_keys: + options["justify"] = self._text_justify + + if options: + try: + widget.configure(**options) + except Exception: + pass + + def align_child_text(self, widget: tk.Widget, alignment: Optional[Literal['left', 'center', 'right']] = None) -> None: + """Apply left/center/right text alignment to a child widget. + + Args: + widget: Child text widget to align. + alignment: Optional override alignment for this call. + """ + previous_alignment = self.alignment + 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 + + def set_alignment(self, alignment: Literal['left', 'center', 'right']) -> None: + """Set widget-wide alignment and refresh built-in labels. + + Args: + alignment: Horizontal alignment (`left`, `center`, or `right`). + """ + self.alignment = self._normalise_alignment(alignment) + + for label_name in ("title_label", "helper_label"): + label = getattr(self, label_name, None) + if label is not None: + label.pack_configure(anchor=self._pack_anchor) + self._apply_text_alignment(label) + @abstractmethod def get(self): """Get the current value of the widget.""" @@ -64,7 +187,11 @@ def get(self): @abstractmethod def set(self, value): - """Set the value of the widget.""" + """Set the value of the widget. + + Args: + value: Value to apply to the widget state. + """ raise NotImplementedError("Subclasses must implement the set() method.") def build( @@ -72,7 +199,12 @@ def build( build_type: Literal['pack', 'grid', 'place'] = 'pack', **kwargs ): - """Pack the widget into the parent container.""" + """Place the widget in its parent container. + + Args: + build_type: Geometry manager strategy (`pack`, `grid`, or `place`). + **kwargs: Additional geometry manager options. + """ if build_type == 'pack': self.pack(**self.packing_options.to_dict()) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py index e91ce11..c9ec07c 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py @@ -1,3 +1,5 @@ +"""Window for selecting multiple files by extension from a directory tree.""" + import tkinter as tk from tkinter import ttk from pathlib import Path @@ -8,6 +10,7 @@ from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow class DirectoryFileSelector(BHoMBaseWindow): + """Display matching files and return the user's multi-selection.""" def __init__( self, @@ -27,23 +30,25 @@ def __init__( self.file_lookup = dict(zip(self.display_items, self.files)) self.file_selector_listbox: Optional[ScrollableListBox] = None - kwargs.setdefault("title", f"Select {selection_label}") - kwargs.setdefault("min_width", 600) - kwargs.setdefault("min_height", 400) - kwargs.setdefault("submit_text", "OK") - kwargs.setdefault("submit_command", self._on_submit) - kwargs.setdefault("close_text", "Cancel") - kwargs.setdefault("close_command", self._on_cancel) - kwargs.setdefault("on_close_window", self._on_cancel) - - super().__init__(**kwargs) + super().__init__( + title=f"Select {selection_label}", + min_width=600, + min_height=400, + submit_text="OK", + submit_command=self._on_submit, + close_text="Cancel", + close_command=self._on_cancel, + on_close_window=self._on_cancel, + **kwargs, + ) def build(self): - ttk.Label( + """Build the list-based file selection UI.""" + instruction_label = ttk.Label( self.content_frame, text=f"Select the {self.selection_label} to analyse.", - justify=tk.LEFT, - ).pack(anchor="w", pady=(0, 10)) + ) + instruction_label.pack(anchor="w", pady=(0, 10)) if self.file_selector_listbox is None: self.file_selector_listbox = ScrollableListBox( diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py index 3c81ba3..e81f224 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py @@ -1,3 +1,5 @@ +"""Landing page window with configurable header, message text, and custom actions.""" + import tkinter as tk from tkinter import ttk from typing import Optional, Callable @@ -87,6 +89,7 @@ def add_custom_button(self, text: str, command: Callable, **kwargs) -> ttk.Butto #simple example of using the landing page def on_button_click(): + """Handle demo button clicks in the standalone example.""" print("Button clicked!") landing_page = LandingPage( diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py index c2b4184..67bfc53 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py @@ -1,3 +1,5 @@ +"""Window that displays a status message with a lightweight animated indicator.""" + import tkinter as tk from tkinter import ttk import time @@ -35,6 +37,7 @@ def __init__(self, title="Processing", message="Processing..."): self.is_running = False def build(self): + """Build processing labels and the animation indicator.""" self.title_label = ttk.Label( self.content_frame, text=self.window_title, @@ -67,7 +70,11 @@ def start(self): self._animate() def keep_alive(self): - """Call this repeatedly to process animation updates. Returns False when done.""" + """Call repeatedly to process animation updates. + + Returns: + bool: `True` while running and window exists, else `False`. + """ if self.is_running and self.winfo_exists(): self.update() return True @@ -89,7 +96,11 @@ def _animate(self): self.after(200, self._animate) def update_message(self, message: str): - """Update the message text.""" + """Update the message text. + + Args: + message: New status message to display. + """ self.message_text = message if self.message_label is not None: self.message_label.config(text=message) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py index b3539b7..57b3509 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py @@ -1,12 +1,13 @@ +"""Standardized warning dialog window for errors, warnings, and info messages.""" + import tkinter as tk from tkinter import ttk from typing import Optional from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow -"""This will be used in place of warning / pop up boxes, to ensure consistent style""" - class WarningBox(BHoMBaseWindow): + """Show categorized messages using the shared BHoM window styling.""" def __init__( self, @@ -31,6 +32,7 @@ def __init__( ) def build(self): + """Render current messages into the content area.""" self._render_messages() super().build() @@ -59,17 +61,29 @@ def update_messages(self): def add_error_message(self, message: str): - """Add an error message to the warning box.""" + """Add an error message to the warning box. + + Args: + message: Error text to append. + """ self.errors.append(message) self.update_messages() def add_warning_message(self, message: str): - """Add a warning message to the warning box.""" + """Add a warning message to the warning box. + + Args: + message: Warning text to append. + """ self.warnings.append(message) self.update_messages() def add_info_message(self, message: str): - """Add an informational message to the warning box.""" + """Add an informational message to the warning box. + + Args: + message: Informational text to append. + """ self.infos.append(message) self.update_messages() diff --git a/tmp_doc_audit.py b/tmp_doc_audit.py new file mode 100644 index 0000000..ea4bad2 --- /dev/null +++ b/tmp_doc_audit.py @@ -0,0 +1,36 @@ +import ast +from pathlib import Path + +root = Path("c:/GitHub_Files/Python_Toolkit/Python_Engine/Python/src/python_toolkit/bhom_tkinter") +files = sorted(root.rglob("*.py")) + +for path in files: + rel = path.relative_to(root) + src = path.read_text(encoding="utf-8") + try: + tree = ast.parse(src) + except SyntaxError as error: + print(f"{rel}|SYNTAX_ERROR|{error}") + continue + + issues = [] + + if ast.get_docstring(tree) is None: + issues.append(("module", "")) + + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if not node.name.startswith("_") and ast.get_docstring(node) is None: + issues.append(("function", node.name)) + elif isinstance(node, ast.ClassDef): + if not node.name.startswith("_") and ast.get_docstring(node) is None: + issues.append(("class", node.name)) + for member in node.body: + if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)): + if member.name.startswith("_"): + continue + if ast.get_docstring(member) is None: + issues.append(("method", f"{node.name}.{member.name}")) + + for kind, name in issues: + print(f"{rel}|{kind}|{name}") From 5975e3ec8c92a8b8c0a5b90cce05bbba0c7c8b28 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 25 Feb 2026 09:53:04 +0000 Subject: [PATCH 14/28] updated file naming to snake case, added init file imports, improved import of mpl agg backend --- .../python_toolkit/bhom_tkinter/__init__.py | 41 +++++++++++++++++++ .../bhom_tkinter/bhom_base_window.py | 23 ++++++++--- .../bhom_tkinter/widgets/__init__.py | 29 +++++++++++++ ...BoxSelection.py => check_box_selection.py} | 0 .../{CmapSelector.py => cmap_selector.py} | 6 +-- .../{ColourPicker.py => colour_picker.py} | 0 ...ownSelection.py => drop_down_selection.py} | 0 ...FigureContainer.py => figure_container.py} | 1 - .../widgets/{ListBox.py => list_box.py} | 0 ...BoxSelection.py => multi_box_selection.py} | 0 .../{PathSelector.py => path_selector.py} | 0 .../{RadioSelection.py => radio_selection.py} | 0 ...atedEntryBox.py => validated_entry_box.py} | 0 .../bhom_tkinter/windows/__init__.py | 11 +++++ ...Selector.py => directory_file_selector.py} | 2 +- .../{LandingPage.py => landing_page.py} | 0 ...ocessingWindow.py => processing_window.py} | 0 .../windows/{WarningBox.py => warning_box.py} | 0 18 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{CheckBoxSelection.py => check_box_selection.py} (100%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{CmapSelector.py => cmap_selector.py} (97%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{ColourPicker.py => colour_picker.py} (100%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{DropDownSelection.py => drop_down_selection.py} (100%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{FigureContainer.py => figure_container.py} (99%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{ListBox.py => list_box.py} (100%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{MultiBoxSelection.py => multi_box_selection.py} (100%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{PathSelector.py => path_selector.py} (100%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{RadioSelection.py => radio_selection.py} (100%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{ValidatedEntryBox.py => validated_entry_box.py} (100%) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/{DirectoryFileSelector.py => directory_file_selector.py} (98%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/{LandingPage.py => landing_page.py} (100%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/{ProcessingWindow.py => processing_window.py} (100%) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/{WarningBox.py => warning_box.py} (100%) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py new file mode 100644 index 0000000..8a5bc0a --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py @@ -0,0 +1,41 @@ +from .widgets import ( + BHoMBaseWidget, + PackingOptions, + CalendarWidget, + CheckboxSelection, + MultiBoxSelection, + CmapSelector, + ColourPicker, + DropDownSelection, + FigureContainer, + ScrollableListBox, + PathSelector, + RadioSelection, + ValidatedEntryBox, +) +from .windows import ( + DirectoryFileSelector, + LandingPage, + ProcessingWindow, + WarningBox, +) + +__all__ = [ + "BHoMBaseWidget", + "PackingOptions", + "CalendarWidget", + "CheckboxSelection", + "MultiBoxSelection", + "CmapSelector", + "ColourPicker", + "DropDownSelection", + "FigureContainer", + "ScrollableListBox", + "PathSelector", + "RadioSelection", + "ValidatedEntryBox", + "DirectoryFileSelector", + "LandingPage", + "ProcessingWindow", + "WarningBox", +] 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 d0ba4cb..a798543 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 @@ -7,6 +7,19 @@ import darkdetect import platform import ctypes +import os +import matplotlib as mpl + +# Centralized matplotlib backend selection: +# - Allow override via `MPLBACKEND` environment variable (e.g. set to 'Agg' for headless CI). +# - Default to 'TkAgg' for Tkinter embedding; fallback to 'Agg' if the requested backend is unavailable. +backend = os.environ.get("MPLBACKEND") +if not backend: + backend = "TkAgg" +try: + mpl.use(backend, force=True) +except Exception: + mpl.use("Agg", force=True) from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget @@ -499,11 +512,11 @@ def _on_close_window(self, callback: Optional[Callable]) -> None: test.mainloop() r""" - from widgets.PathSelector import PathSelector - from widgets.RadioSelection import RadioSelection - from widgets.ValidatedEntryBox import ValidatedEntryBox - from widgets.ListBox import ScrollableListBox - from widgets.CmapSelector import CmapSelector + from widgets.path_selector import PathSelector + from widgets.radio_selection import RadioSelection + from widgets.validated_entry_box import ValidatedEntryBox + from widgets.list_box import ScrollableListBox + from widgets.cmap_selector import CmapSelector # Store form state form_data = {} 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 new file mode 100644 index 0000000..5cf9b28 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py @@ -0,0 +1,29 @@ +from ._packing_options import PackingOptions +from ._widgets_base import BHoMBaseWidget +from .calender import CalendarWidget +from .check_box_selection import CheckboxSelection +from .cmap_selector import CmapSelector +from .colour_picker import ColourPicker +from .drop_down_selection import DropDownSelection +from .figure_container import FigureContainer +from .list_box import ScrollableListBox +from .multi_box_selection import CheckboxSelection as MultiBoxSelection +from .path_selector import PathSelector +from .radio_selection import RadioSelection +from .validated_entry_box import ValidatedEntryBox + +__all__ = [ + "BHoMBaseWidget", + "PackingOptions", + "CalendarWidget", + "CheckboxSelection", + "MultiBoxSelection", + "CmapSelector", + "ColourPicker", + "DropDownSelection", + "FigureContainer", + "ScrollableListBox", + "PathSelector", + "RadioSelection", + "ValidatedEntryBox", +] \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CheckBoxSelection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CheckBoxSelection.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CmapSelector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py similarity index 97% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CmapSelector.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py index 96f5773..6d078ed 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/CmapSelector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py @@ -5,12 +5,10 @@ import tkinter as tk import matplotlib as mpl from python_toolkit.plot.cmap_sample import cmap_sample_plot -from python_toolkit.bhom_tkinter.widgets.FigureContainer import FigureContainer +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 -mpl.use("Agg") # Use non-interactive backend for embedding in Tkinter - class CmapSelector(BHoMBaseWidget): """ A widget for selecting and previewing a matplotlib colormap. @@ -78,8 +76,6 @@ def __init__( """ super().__init__(parent, **kwargs) - mpl.use("Agg") # Use non-interactive backend for embedding in Tkinter - # Create frame for cmap selection content self.cmap_frame = ttk.Frame(self.content_frame) self.cmap_frame.pack(side="top", fill="both", expand=True) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ColourPicker.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ColourPicker.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/DropDownSelection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/DropDownSelection.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/FigureContainer.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py similarity index 99% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/FigureContainer.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py index 8d99251..cee5b61 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/FigureContainer.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py @@ -9,7 +9,6 @@ from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget import matplotlib as mpl -mpl.use("Agg") # Use non-interactive backend for embedding in Tkinter class FigureContainer(BHoMBaseWidget): """ diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ListBox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ListBox.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/MultiBoxSelection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/MultiBoxSelection.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/PathSelector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/PathSelector.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/RadioSelection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/RadioSelection.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ValidatedEntryBox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/ValidatedEntryBox.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py new file mode 100644 index 0000000..c372f4b --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py @@ -0,0 +1,11 @@ +from .directory_file_selector import DirectoryFileSelector +from .landing_page import LandingPage +from .processing_window import ProcessingWindow +from .warning_box import WarningBox + +__all__ = [ + "DirectoryFileSelector", + "LandingPage", + "ProcessingWindow", + "WarningBox", +] diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py similarity index 98% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py index c9ec07c..232533b 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/DirectoryFileSelector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Iterable, List, Optional -from python_toolkit.bhom_tkinter.widgets.ListBox import ScrollableListBox +from python_toolkit.bhom_tkinter.widgets.list_box import ScrollableListBox from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/LandingPage.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/ProcessingWindow.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/WarningBox.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py From bb5fc6adfe01ce0ff66d41e764c85177463320b3 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 25 Feb 2026 10:44:28 +0000 Subject: [PATCH 15/28] added button and labels unsure about label --- .../python_toolkit/bhom/bhom_dark_theme.tcl | 14 +-- .../python_toolkit/bhom/bhom_light_theme.tcl | 12 +- .../bhom_tkinter/bhom_base_window.py | 34 +++--- .../bhom_tkinter/widgets/Calender.py | 10 +- .../bhom_tkinter/widgets/__init__.py | 4 + .../bhom_tkinter/widgets/button.py | 79 +++++++++++++ .../widgets/check_box_selection.py | 10 +- .../bhom_tkinter/widgets/colour_picker.py | 14 ++- .../bhom_tkinter/widgets/figure_container.py | 7 +- .../bhom_tkinter/widgets/label.py | 109 ++++++++++++++++++ .../bhom_tkinter/widgets/list_box.py | 11 +- .../widgets/multi_box_selection.py | 8 +- .../bhom_tkinter/widgets/path_selector.py | 7 +- .../bhom_tkinter/widgets/radio_selection.py | 3 +- .../widgets/validated_entry_box.py | 5 +- .../windows/directory_file_selector.py | 3 +- .../bhom_tkinter/windows/landing_page.py | 15 ++- .../bhom_tkinter/windows/processing_window.py | 7 +- .../bhom_tkinter/windows/warning_box.py | 7 +- 19 files changed, 284 insertions(+), 75 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/button.py create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl index c956446..89fb25d 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl @@ -71,8 +71,7 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure TLabel \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {8 6} + -font {{Segoe UI} 10 bold} ttk::style configure Display.TLabel \ -font {{Segoe UI} 28 bold} \ @@ -105,19 +104,16 @@ namespace eval ttk::theme::bhom_dark { -padding {0 0} ttk::style configure Body.TLabel \ - -font {{Segoe UI} 10} \ - -foreground $colors(-fg) \ - -padding {6 4} + -font {{Segoe UI} 10} \ + -foreground $colors(-fg) ttk::style configure Caption.TLabel \ -font {{Segoe UI} 9} \ - -foreground $colors(-text-secondary) \ - -padding {6 4} + -foreground $colors(-text-secondary) ttk::style configure Small.TLabel \ -font {{Segoe UI} 8} \ - -foreground $colors(-text-secondary) \ - -padding {4 2} + -foreground $colors(-text-secondary) ttk::style configure Success.TLabel \ -font {{Segoe UI} 10 bold} \ diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl index 64fd4ec..dc80f08 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl @@ -71,8 +71,7 @@ namespace eval ttk::theme::bhom_light { ttk::style configure TLabel \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {8 6} + -font {{Segoe UI} 10 bold} ttk::style configure Display.TLabel \ -font {{Segoe UI} 28 bold} \ @@ -106,18 +105,15 @@ namespace eval ttk::theme::bhom_light { ttk::style configure Body.TLabel \ -font {{Segoe UI} 10} \ - -foreground $colors(-fg) \ - -padding {6 4} + -foreground $colors(-fg) ttk::style configure Caption.TLabel \ -font {{Segoe UI} 9} \ - -foreground $colors(-text-secondary) \ - -padding {6 4} + -foreground $colors(-text-secondary) ttk::style configure Small.TLabel \ -font {{Segoe UI} 8} \ - -foreground $colors(-text-secondary) \ - -padding {4 2} + -foreground $colors(-text-secondary) ttk::style configure Success.TLabel \ -font {{Segoe UI} 10 bold} \ 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 a798543..c61ba50 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 @@ -2,6 +2,7 @@ import tkinter as tk from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label from pathlib import Path from typing import Optional, Callable, Literal, List import darkdetect @@ -22,6 +23,7 @@ mpl.use("Agg", force=True) from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button class BHoMBaseWindow(tk.Tk): """ @@ -322,13 +324,13 @@ def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path] img = Image.open(logo_path) img.thumbnail((80, 80), Image.Resampling.LANCZOS) self.logo_image = ImageTk.PhotoImage(img) - logo_label = ttk.Label(logo_container, image=self.logo_image) + logo_label = Label(logo_container, image=self.logo_image) logo_label.pack(fill=tk.BOTH, expand=True) except ImportError: pass # PIL not available, skip logo # Title - title_label = ttk.Label( + title_label = Label( text_container, text=title, style="Title.TLabel" @@ -336,7 +338,7 @@ def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path] title_label.pack(anchor="w") # Subtitle - subtitle_label = ttk.Label( + subtitle_label = Label( text_container, text="powered by BHoM", style="Caption.TLabel" @@ -367,16 +369,16 @@ def _build_buttons( button_container.pack(anchor=tk.E) if show_close: - self.close_button = ttk.Button( - button_container, text=close_text, command=self._on_close - ) - self.close_button.pack(side=tk.LEFT, padx=5) + close_widget = Button(button_container, text=close_text, command=self._on_close) + close_widget.pack(side=tk.LEFT, padx=5) + # expose inner ttk.Button for compatibility + self.close_button = close_widget.button if show_submit: - self.submit_button = ttk.Button( - button_container, text=submit_text, command=self._on_submit - ) - self.submit_button.pack(side=tk.LEFT, padx=5) + submit_widget = Button(button_container, text=submit_text, command=self._on_submit) + submit_widget.pack(side=tk.LEFT, padx=5) + # expose inner ttk.Button for compatibility + self.submit_button = submit_widget.button def _bind_dynamic_sizing(self) -> None: """Bind layout changes to schedule auto sizing updates.""" @@ -549,7 +551,7 @@ def on_close(): ) # Add form widgets to the content area - ttk.Label(window.content_frame, text="Name:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) + Label(window.content_frame, text="Name:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) name_entry = ValidatedEntryBox( window.content_frame, value_type=str, @@ -559,7 +561,7 @@ def on_close(): ) name_entry.pack(fill=tk.X, pady=(0, 15)) - ttk.Label(window.content_frame, text="Age:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) + Label(window.content_frame, text="Age:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) age_entry = ValidatedEntryBox( window.content_frame, value_type=int, @@ -570,7 +572,7 @@ def on_close(): age_entry.pack(fill=tk.X, pady=(0, 15)) age_entry.set(25) - ttk.Label(window.content_frame, text="Select a file:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) + Label(window.content_frame, text="Select a file:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) file_selector = PathSelector( window.content_frame, button_text="Browse...", @@ -579,7 +581,7 @@ def on_close(): ) file_selector.pack(fill=tk.X, pady=(0, 15)) - ttk.Label(window.content_frame, text="Priority:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) + Label(window.content_frame, text="Priority:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) priority_radio = RadioSelection( window.content_frame, fields=["Low", "Medium", "High", "Critical"], @@ -589,7 +591,7 @@ def on_close(): ) priority_radio.pack(anchor="w", pady=(0, 15)) - ttk.Label(window.content_frame, text="Select items:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) + Label(window.content_frame, text="Select items:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) items = [f"Item {i}" for i in range(1, 11)] listbox = ScrollableListBox( window.content_frame, diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py index d23c9d1..c665ecc 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py @@ -3,10 +3,12 @@ import tkinter as tk from typing import Optional from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label import calendar import datetime from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button class CalendarWidget(BHoMBaseWidget): """Render a month grid and allow date selection.""" @@ -92,7 +94,7 @@ def redraw(self): child.destroy() for col, day in enumerate(("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su")): - label = ttk.Label(self.cal_frame, text=day) + label = Label(self.cal_frame, text=day) getattr(self, "align_child_text")(label) label.grid(row=0, column=col, sticky="nsew") @@ -102,8 +104,8 @@ def redraw(self): for col, day in enumerate(week): text = "" if day == 0 else day state = "normal" if day > 0 else "disabled" - cell = ttk.Button(self.cal_frame, text=text, state=state, command=lambda day=day: self.set_day(day)) - cell.grid(row=row+1, column=col, sticky="nsew") + cell_widget = Button(self.cal_frame, text=str(text) if text != "" else "", command=(lambda d=day: self.set_day(d))) + cell_widget.grid(row=row+1, column=col, sticky="nsew") def set_day(self, num): """Set the selected day and refresh the date summary label. @@ -117,7 +119,7 @@ def set_day(self, num): child.destroy() date = self.months[self.month-1] + " " + str(self.day) - label = ttk.Label(self.date_frame, text=f"Selected Date: {date}") + label = Label(self.date_frame, text=f"Selected Date: {date}") getattr(self, "align_child_text")(label) label.pack(anchor=getattr(self, "_pack_anchor"), padx=4, pady=4) 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 5cf9b28..d68c79e 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 @@ -11,6 +11,8 @@ from .path_selector import PathSelector from .radio_selection import RadioSelection from .validated_entry_box import ValidatedEntryBox +from .button import Button +from .label import Label __all__ = [ "BHoMBaseWidget", @@ -26,4 +28,6 @@ "PathSelector", "RadioSelection", "ValidatedEntryBox", + "Button", + "Label", ] \ No newline at end of file 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 new file mode 100644 index 0000000..052331c --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/button.py @@ -0,0 +1,79 @@ +"""Simple button widget with action callback following BHoM toolkit patterns.""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + + +class Button(BHoMBaseWidget): + """A minimal button widget that invokes a callback when clicked. + + - `get()` returns the number of times the button has been clicked. + - `set()` can update the button label when passed a string. + """ + + def __init__( + self, + parent, + text: str = "Click", + command: Optional[Callable[[], None]] = None, + width: int = 25, + **kwargs, + ): + super().__init__(parent, **kwargs) + + self._click_count = 0 + self._user_command = command + + self.button = ttk.Button( + self.content_frame, + text=text, + command=self._on_click, + width=width, + ) + self.button.pack(side="top", anchor=getattr(self, "_pack_anchor")) + + 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: + # Keep widget robust; do not propagate callback errors + pass + + def get(self) -> int: + """Return the number of clicks recorded.""" + return self._click_count + + def set(self, value): + """If given a string, set the button's label; otherwise ignore.""" + if isinstance(value, str): + self.button.configure(text=value) + + +if __name__ == "__main__": + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + def demo_action(): + print("Button pressed!") + + root = BHoMBaseWindow() + parent_frame = root.content_frame + + widget = Button( + parent_frame, + text="Press me", + command=demo_action, + item_title="Demo Button", + helper_text="A minimal clickable button.", + packing_options=PackingOptions(padx=20, pady=20), + ) + widget.build() + + root.mainloop() + 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 8bdb96c..6cdcad6 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,6 +5,8 @@ from typing import Optional, List, Callable 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.""" @@ -61,7 +63,7 @@ def _build_buttons(self): var = tk.BooleanVar(value=False) self.value_vars[field] = var - button = ttk.Label( + button = Label( self.buttons_frame, text=f"□ {field}", cursor="hand2" @@ -221,8 +223,8 @@ def on_selection(values): control_frame = ttk.Frame(parent_frame) control_frame.pack(padx=20, pady=10) - ttk.Button(control_frame, text="Select All", command=widget.select_all).pack(side="left", padx=5) - ttk.Button(control_frame, text="Deselect All", command=widget.deselect_all).pack(side="left", padx=5) - ttk.Button(control_frame, text="Toggle All", command=widget.toggle_all).pack(side="left", padx=5) + Button(control_frame, text="Select All", command=widget.select_all).pack(side="left", padx=5) + Button(control_frame, text="Deselect All", command=widget.deselect_all).pack(side="left", padx=5) + Button(control_frame, text="Toggle All", command=widget.toggle_all).pack(side="left", padx=5) root.mainloop() 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 5c291c1..6a9f0b4 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 @@ -2,9 +2,11 @@ import tkinter as tk from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label from typing import Callable, Optional from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button class ColourPicker(BHoMBaseWidget): """A simple colour picker widget where the swatch opens a themed colour dialog.""" @@ -88,22 +90,22 @@ def _select_colour(self) -> None: hex_row = ttk.Frame(container) hex_row.grid(row=3, column=0, sticky="ew", pady=(8, 4)) - ttk.Label(hex_row, text="Hex").pack(side=tk.LEFT) + Label(hex_row, text="Hex").pack(side=tk.LEFT) hex_entry = ttk.Entry(hex_row, textvariable=self._popup_hex_var, width=10) hex_entry.pack(side=tk.LEFT, padx=(8, 0)) hex_entry.bind("", lambda _event: self._on_hex_entered()) preview_row = ttk.Frame(container) preview_row.grid(row=4, column=0, sticky="w", pady=(4, 8)) - ttk.Label(preview_row, text="Preview").pack(side=tk.LEFT) + Label(preview_row, text="Preview").pack(side=tk.LEFT) self._popup_preview = tk.Canvas(preview_row, width=48, height=28, highlightthickness=1) self._popup_preview.pack(side=tk.LEFT, padx=(8, 0)) self._popup_swatch = self._popup_preview.create_rectangle(0, 0, 48, 28, outline="#666666") button_row = ttk.Frame(container) button_row.grid(row=5, column=0, sticky="e") - ttk.Button(button_row, text="Cancel", command=self._close_picker).pack(side=tk.LEFT, padx=(0, 6)) - ttk.Button(button_row, text="Apply", command=self._apply_popup_colour).pack(side=tk.LEFT) + Button(button_row, text="Cancel", command=self._close_picker).pack(side=tk.LEFT, padx=(0, 6)) + Button(button_row, text="Apply", command=self._apply_popup_colour).pack(side=tk.LEFT) container.columnconfigure(0, weight=1) @@ -119,7 +121,7 @@ def _build_slider_row(self, parent: ttk.Frame, label_text: str, value_var: tk.In row_frame = ttk.Frame(parent) row_frame.grid(row=row, column=0, sticky="ew", pady=2) - ttk.Label(row_frame, text=label_text).pack(side=tk.LEFT) + Label(row_frame, text=label_text).pack(side=tk.LEFT) slider = ttk.Scale( row_frame, from_=0, @@ -130,7 +132,7 @@ def _build_slider_row(self, parent: ttk.Frame, label_text: str, value_var: tk.In ) slider.set(value_var.get()) slider.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(8, 8)) - value_label = ttk.Label(row_frame, textvariable=value_var, width=4, anchor="e") + value_label = Label(row_frame, textvariable=value_var, width=4, anchor="e") value_label.pack(side=tk.LEFT) def _on_slider_move(self, value_var: tk.IntVar, value: str) -> None: 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 cee5b61..c0193cc 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 @@ -2,6 +2,7 @@ import tkinter as tk from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label from typing import Optional, Any from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure @@ -106,7 +107,7 @@ def embed_image(self, image: tk.PhotoImage) -> None: self._original_pil_image = None # Create label to display the image - self.image_label = ttk.Label(self.content_frame, image=image) + self.image_label = Label(self.content_frame, image=image) self.image_label.pack(fill=tk.BOTH, expand=True) def embed_image_file(self, file_path: str) -> None: @@ -127,7 +128,7 @@ def embed_image_file(self, file_path: str) -> None: self._original_pil_image = pil_image # Create label - self.image_label = ttk.Label(self.content_frame) + self.image_label = Label(self.content_frame) self.image_label.pack(fill=tk.BOTH, expand=True) # Scale and display @@ -138,7 +139,7 @@ def embed_image_file(self, file_path: str) -> None: # Fallback to basic PhotoImage without scaling image = tk.PhotoImage(file=file_path) self.image = image - self.image_label = ttk.Label(self.content_frame, image=image) + self.image_label = Label(self.content_frame, image=image) self.image_label.pack(fill=tk.BOTH, expand=True) def _scale_image_to_fit(self): 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 new file mode 100644 index 0000000..10de7e4 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py @@ -0,0 +1,109 @@ +from tkinter import ttk +from typing import Optional + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + + +class Label(BHoMBaseWidget): + """Default BHoM label widget. + + This is a Frame-based widget (inherits `BHoMBaseWidget`) which contains + an inner `ttk.Label` stored at `self.label`. Using a Frame wrapper allows + built-in `item_title` and `helper_text` and consistent alignment handling + across BHoM widgets. + """ + + def __init__( + self, + parent, + text: str = "", + item_title: Optional[str] = None, + helper_text: Optional[str] = None, + alignment: Optional[str] = None, + image=None, + style=None, + justify=None, + wraplength=None, + anchor=None, + width=None, + compound=None, + textvariable=None, + foreground=None, + **kwargs): + def resolve(explicit_value, key: str, default=None): + fallback = kwargs.pop(key, default) + return explicit_value if explicit_value is not None else fallback + + base_options = { + "item_title": resolve(item_title, "item_title"), + "helper_text": resolve(helper_text, "helper_text"), + "alignment": resolve(alignment, "alignment", "left") or "left", + } + + label_options = { + "text": resolve(text, "text", ""), + "image": resolve(image, "image"), + "style": resolve(style, "style"), + "justify": resolve(justify, "justify"), + "wraplength": resolve(wraplength, "wraplength"), + "anchor": resolve(anchor, "anchor"), + "width": resolve(width, "width"), + "compound": resolve(compound, "compound"), + "textvariable": resolve(textvariable, "textvariable"), + "foreground": resolve(foreground, "foreground"), + } + label_options = {key: value for key, value in label_options.items() if value is not None} + + # Initialize frame base without label-specific options to avoid passing + # unknown options like '-image' to ttk.Frame + super().__init__( + parent, + **base_options, + **kwargs, + ) + + self.text = label_options.get("text", "") + # Create inner ttk.Label with the collected options + self.label = ttk.Label(self.content_frame, **label_options) + self.align_child_text(self.label) + self.label.pack(side="top", anchor=self._pack_anchor) + + def get(self) -> str: + """Return the current label text.""" + try: + return str(self.label.cget("text")) + except Exception: + return "" + + def set(self, value: str): + """Set the label text.""" + if isinstance(value, str): + try: + self.text = value + self.label.configure(text=self.text) + except Exception: + pass + + def update_text(self, new_text: str): + """Backward-compatible method name used previously to update label text.""" + self.set(new_text) + + # Provide a generic `update` alias for convenience (matches previous API). + def update(self, new_text: str): + self.update_text(new_text) + + +if __name__ == "__main__": + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + root = BHoMBaseWindow() + parent_frame = root.content_frame + + label_widget = Label(parent_frame, text="Hello, World!", packing_options=PackingOptions(anchor="e", padx=10, pady=10)) + label_widget.build() + + label_widget2 = Label(parent_frame, text="This is a BHoM Label widget.", packing_options=PackingOptions(anchor="w", padx=10, pady=10)) + label_widget2.build() + + root.mainloop() \ No newline at end of file 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 c640c53..f32cdb7 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 @@ -5,6 +5,7 @@ from typing import Optional from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button class ScrollableListBox(BHoMBaseWidget): """A reusable listbox widget with auto-hiding scrollbar.""" @@ -59,11 +60,13 @@ def __init__( controls = ttk.Frame(self) controls.pack(fill=tk.X, pady=(8, 0)) - self.select_all_button = ttk.Button(controls, text="Select All", command=self.select_all) - self.select_all_button.pack(side=tk.LEFT) + select_widget = Button(controls, text="Select All", command=self.select_all) + select_widget.pack(side=tk.LEFT) + self.select_all_button = select_widget.button - self.deselect_all_button = ttk.Button(controls, text="Deselect All", command=self.deselect_all) - self.deselect_all_button.pack(side=tk.LEFT, padx=(8, 0)) + deselect_widget = Button(controls, text="Deselect All", command=self.deselect_all) + deselect_widget.pack(side=tk.LEFT, padx=(8, 0)) + self.deselect_all_button = deselect_widget.button def _on_configure(self, event=None): """Hide scrollbar if all items fit in the visible area.""" diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py index 41727fa..819246c 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py @@ -5,6 +5,8 @@ from typing import Optional, List, Callable 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.""" @@ -59,7 +61,7 @@ def _build_buttons(self): var = tk.BooleanVar(value=False) self.value_vars[field] = var - button = ttk.Label( + button = Label( self.buttons_frame, text=f"□ {field}", cursor="hand2" @@ -191,7 +193,7 @@ def on_selection(values): control_frame = ttk.Frame(parent_frame) control_frame.pack(padx=20, pady=10) - ttk.Button(control_frame, text="Select All", command=widget.select_all).pack(side="left", padx=5) - ttk.Button(control_frame, text="Deselect All", command=widget.deselect_all).pack(side="left", padx=5) + Button(control_frame, text="Select All", command=widget.select_all).pack(side="left", padx=5) + Button(control_frame, text="Deselect All", command=widget.deselect_all).pack(side="left", padx=5) root.mainloop() 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 d07c2f3..da51d37 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 @@ -6,6 +6,7 @@ from typing import Optional from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button class PathSelector(BHoMBaseWidget): """A reusable path/file selector widget with a button and a readonly entry.""" @@ -41,8 +42,10 @@ def __init__( self.entry = ttk.Entry(self.content_frame, textvariable=self.display_name, width=40) self.entry.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True) - self.button = ttk.Button(self.content_frame, text=button_text, command=self._on_click) - self.button.pack(side=tk.LEFT) + # Use Button wrapper but expose inner ttk.Button for backward compatibility + button_widget = Button(self.content_frame, text=button_text, command=self._on_click) + button_widget.pack(side=tk.LEFT) + self.button = button_widget.button def _on_click(self): if self.mode == "directory": 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 93de1b5..d4596f7 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 @@ -2,6 +2,7 @@ import tkinter as tk from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label from typing import Optional, cast from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget @@ -59,7 +60,7 @@ def _build_buttons(self): for index, field in enumerate(self.fields): sticky = cast(str, getattr(self, "_grid_sticky", "w")) align_child_text = getattr(self, "align_child_text", None) - button = ttk.Label( + button = Label( self.buttons_frame, text=f"○ {field}", cursor="hand2" 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 469928e..bb6aa63 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 @@ -2,6 +2,7 @@ import tkinter as tk from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label from typing import Optional, Callable, Any, Union from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget @@ -72,12 +73,12 @@ def __init__( self.entry.pack(side="left", fill="x", expand=True) # Create success indicator label at end of entry - self.success_label = ttk.Label(self.entry_frame, text=" ", foreground="#4bb543", width=2) + self.success_label = Label(self.entry_frame, text=" ", foreground="#4bb543", width=2) getattr(self, "align_child_text")(self.success_label) self.success_label.pack(side="left", padx=(5, 0)) # Create error label below entry with fixed height to prevent layout shifts - self.error_label = ttk.Label(self.content_frame, text=" ", style="Caption.TLabel") + self.error_label = Label(self.content_frame, text=" ", style="Caption.TLabel") getattr(self, "align_child_text")(self.error_label) self.error_label.pack(side="top", fill="x", anchor=getattr(self, "_pack_anchor")) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py index 232533b..6408335 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py @@ -2,6 +2,7 @@ import tkinter as tk from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label from pathlib import Path from typing import Iterable, List, Optional @@ -44,7 +45,7 @@ def __init__( def build(self): """Build the list-based file selection UI.""" - instruction_label = ttk.Label( + instruction_label = Label( self.content_frame, text=f"Select the {self.selection_label} to analyse.", ) 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 e81f224..3d2315d 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 @@ -4,6 +4,8 @@ from tkinter import ttk from typing import Optional, Callable from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.widgets.label import Label class LandingPage(BHoMBaseWindow): @@ -40,12 +42,12 @@ def __init__( def build(self): """Build landing-page content using the base window's content area.""" if self.header: - ttk.Label(self.content_frame, text=self.header, style="Header.TLabel").pack( + Label(self.content_frame, text=self.header, style="Header.TLabel").pack( side="top", anchor="w", pady=(0, 10) ) if self.message: - ttk.Label( + Label( self.content_frame, text=self.message, style="Body.TLabel", @@ -53,7 +55,7 @@ def build(self): ).pack(side="top", anchor="w", pady=(0, 10)) if self.sub_title: - ttk.Label(self.content_frame, text=self.sub_title, style="Caption.TLabel").pack( + Label(self.content_frame, text=self.sub_title, style="Caption.TLabel").pack( side="top", anchor="w", pady=(0, 10) ) @@ -78,11 +80,12 @@ def add_custom_button(self, text: str, command: Callable, **kwargs) -> ttk.Butto self.custom_buttons_frame = ttk.Frame(self.content_frame) self.custom_buttons_frame.pack(fill=tk.X, pady=(0, 20)) - button = ttk.Button(self.custom_buttons_frame, text=text, command=command, **kwargs) - button.pack(pady=5, fill=tk.X) + button_widget = Button(self.custom_buttons_frame, text=text, command=command, **kwargs) + button_widget.pack(pady=5, fill=tk.X) # Recalculate window size after adding button self.refresh_sizing() - return button + # Return inner ttk.Button for compatibility + return button_widget.button if __name__ == "__main__": diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py index 67bfc53..286ffde 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py @@ -2,6 +2,7 @@ import tkinter as tk from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label import time from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow @@ -38,7 +39,7 @@ def __init__(self, title="Processing", message="Processing..."): def build(self): """Build processing labels and the animation indicator.""" - self.title_label = ttk.Label( + self.title_label = Label( self.content_frame, text=self.window_title, style="Title.TLabel", @@ -47,7 +48,7 @@ def build(self): ) self.title_label.pack(pady=(0, 8)) - self.message_label = ttk.Label( + self.message_label = Label( self.content_frame, text=self.message_text, justify="center", @@ -55,7 +56,7 @@ def build(self): ) self.message_label.pack(pady=(0, 20)) - self.animation_label = ttk.Label( + self.animation_label = Label( self.content_frame, text="●", style="Title.TLabel", diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py index 57b3509..57a7126 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py @@ -2,6 +2,7 @@ import tkinter as tk from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label from typing import Optional from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow @@ -48,11 +49,11 @@ def _render_messages(self): widget.destroy() for message in self.errors: - ttk.Label(self.content_frame, text=message, style="Error.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) + Label(self.content_frame, text=message, style="Error.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) for message in self.warnings: - ttk.Label(self.content_frame, text=message, style="Warning.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) + Label(self.content_frame, text=message, style="Warning.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) for message in self.infos: - ttk.Label(self.content_frame, text=message, style="Caption.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) + Label(self.content_frame, text=message, style="Caption.TLabel", wraplength=400, justify=tk.LEFT).pack(anchor="w", pady=(0, 5)) def update_messages(self): """Clear and re-render all messages in the warning box.""" From a146acffc998fe60284a4f773c5f33df5d09af35 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 25 Feb 2026 11:01:37 +0000 Subject: [PATCH 16/28] fixed small errors, added testing script, added adaptability to calander size --- .../bhom_tkinter/bhom_base_window.py | 107 +------- .../bhom_tkinter/widgets/Calender.py | 24 +- .../widgets/check_box_selection.py | 50 ++-- .../Python/tests/test_bhom_tkinter_ui.py | 231 ++++++++++++++++++ 4 files changed, 274 insertions(+), 138 deletions(-) create mode 100644 Python_Engine/Python/tests/test_bhom_tkinter_ui.py 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 c61ba50..4f76bd9 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 @@ -508,112 +508,7 @@ def _on_close_window(self, callback: Optional[Callable]) -> None: test = BHoMBaseWindow( title="Test Window", - theme_mode="light", - ) - - test.mainloop() - - r""" - from widgets.path_selector import PathSelector - from widgets.radio_selection import RadioSelection - from widgets.validated_entry_box import ValidatedEntryBox - from widgets.list_box import ScrollableListBox - from widgets.cmap_selector import CmapSelector - - # Store form state - form_data = {} - - def on_submit(): - # Collect form data from widgets - form_data["name"] = name_entry.get() - form_data["age"] = age_entry.get_value() - form_data["file_path"] = file_selector.get() - form_data["priority"] = priority_radio.get() - form_data["selected_items"] = listbox.get_selection() - form_data["cmap"] = cmap_selector.colormap_var.get() - print("\nForm submitted with data:") - for key, value in form_data.items(): - print(f" {key}: {value}") - - def on_close(): - print("Window closed without submitting") - - window = BHoMBaseWindow( - title="Example Form Application", - min_width=600, - min_height=500, - submit_command=on_submit, - close_command=on_close, - logo_path= Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\bhom_logo.png"), - icon_path= Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\bhom_icon.png"), theme_mode="dark", - - ) - - # Add form widgets to the content area - Label(window.content_frame, text="Name:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) - name_entry = ValidatedEntryBox( - window.content_frame, - value_type=str, - min_length=2, - max_length=50, - required=True, - ) - name_entry.pack(fill=tk.X, pady=(0, 15)) - - Label(window.content_frame, text="Age:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) - age_entry = ValidatedEntryBox( - window.content_frame, - value_type=int, - min_value=1, - max_value=120, - required=True, - ) - age_entry.pack(fill=tk.X, pady=(0, 15)) - age_entry.set(25) - - Label(window.content_frame, text="Select a file:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) - file_selector = PathSelector( - window.content_frame, - button_text="Browse...", - filetypes=[("All Files", "*.*")], - mode="file", ) - file_selector.pack(fill=tk.X, pady=(0, 15)) - - Label(window.content_frame, text="Priority:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) - priority_radio = RadioSelection( - window.content_frame, - fields=["Low", "Medium", "High", "Critical"], - default="Medium", - orient="horizontal", - max_per_line=4, - ) - priority_radio.pack(anchor="w", pady=(0, 15)) - - Label(window.content_frame, text="Select items:", style="Subtitle.TLabel").pack(anchor="w", pady=(0, 5)) - items = [f"Item {i}" for i in range(1, 11)] - listbox = ScrollableListBox( - window.content_frame, - items=items, - selectmode=tk.MULTIPLE, - height=6, - show_selection_controls=True, - ) - listbox.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) - listbox.set_selections(["Item 2", "Item 5"]) - - cmap_selector = CmapSelector(window.content_frame, cmap_set="categorical") - cmap_selector.pack(anchor="w", pady=(0, 10)) - - # Refresh window sizing after adding all widgets - window.refresh_sizing() - # Run the window - result = window.run() - print(f"\nWindow result: {result}") - if result == "submit": - print("Final form data:", form_data) - - - """ + test.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py index c665ecc..33f0484 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py @@ -22,6 +22,9 @@ def __init__( show_year_selector: bool = True, year_min: int = 1900, year_max: int = 2100, + day_button_width: int = 4, + day_button_padx: int = 1, + day_button_pady: int = 1, **kwargs): super().__init__(parent, **kwargs) @@ -31,6 +34,9 @@ def __init__( self.show_year_selector = show_year_selector self.year_min = year_min self.year_max = year_max + self.day_button_width = max(1, int(day_button_width)) + self.day_button_padx = int(day_button_padx) + self.day_button_pady = int(day_button_pady) self.cal_frame = ttk.Frame(self.content_frame) self.cal_frame.pack(side="top", fill="x") @@ -104,8 +110,19 @@ def redraw(self): for col, day in enumerate(week): text = "" if day == 0 else day state = "normal" if day > 0 else "disabled" - cell_widget = Button(self.cal_frame, text=str(text) if text != "" else "", command=(lambda d=day: self.set_day(d))) - cell_widget.grid(row=row+1, column=col, sticky="nsew") + cell_widget = Button( + self.cal_frame, + text=str(text) if text != "" else "", + command=(lambda d=day: self.set_day(d)), + width=self.day_button_width, + ) + cell_widget.grid( + row=row+1, + column=col, + sticky="nsew", + padx=self.day_button_padx, + pady=self.day_button_pady, + ) def set_day(self, num): """Set the selected day and refresh the date summary label. @@ -173,6 +190,9 @@ def pack(self, **kwargs): def_year=2024, def_month=6, def_day=15, + day_button_width=1, + day_button_padx=2, + day_button_pady=2, item_title="Select a Date", helper_text="Choose a date from the calendar below.", packing_options=PackingOptions(padx=20, pady=20) 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 6cdcad6..1fba5c4 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 @@ -42,6 +42,7 @@ def __init__( self.max_per_line = max_per_line self.value_vars = {} # Dictionary mapping field names to BooleanVars self._buttons = [] + self._field_buttons = {} # Sub-frame for checkbox controls self.buttons_frame = ttk.Frame(self.content_frame) @@ -57,6 +58,7 @@ def _build_buttons(self): for button in self._buttons: button.destroy() self._buttons.clear() + self._field_buttons.clear() self.value_vars.clear() for index, field in enumerate(self.fields): @@ -84,6 +86,7 @@ def _build_buttons(self): else: button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky=getattr(self, "_grid_sticky")) self._buttons.append(button) + self._field_buttons[field] = button def _toggle_field(self, field): """Toggle a field's state when clicked.""" @@ -92,17 +95,12 @@ def _toggle_field(self, field): def _on_select_field(self, field): """Handle checkbox selection change and update visual indicator.""" - # Find the button for this field and update its text - for button in self._buttons: - button_text = button.cget("text") - # Extract field name (remove the box indicator) - current_field = button_text[2:] # Skip "□ " or "■ " - if current_field == field: - if self.value_vars[field].get(): - button.configure(text=f"■ {field}") - else: - button.configure(text=f"□ {field}") - break + 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}") if self.command: self.command(self.get()) @@ -126,23 +124,19 @@ def set(self, value: List[str]): var.set(field in values) # Update visual indicators - for button in self._buttons: - button_text = button.cget("text") - current_field = button_text[2:] # Skip "□ " or "■ " - if current_field in values: - button.configure(text=f"■ {current_field}") + for field, button in self._field_buttons.items(): + if field in values: + button.set(f"■ {field}") else: - button.configure(text=f"□ {current_field}") + 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 button in self._buttons: - button_text = button.cget("text") - field = button_text[2:] # Skip box indicator - button.configure(text=f"■ {field}") + for field, button in self._field_buttons.items(): + button.set(f"■ {field}") if self.command: self.command(self.get()) @@ -151,10 +145,8 @@ def deselect_all(self): for var in self.value_vars.values(): var.set(False) # Update visual indicators - for button in self._buttons: - button_text = button.cget("text") - field = button_text[2:] # Skip box indicator - button.configure(text=f"□ {field}") + for field, button in self._field_buttons.items(): + button.set(f"□ {field}") if self.command: self.command(self.get()) @@ -163,13 +155,11 @@ def toggle_all(self): for var in self.value_vars.values(): var.set(not var.get()) # Update visual indicators - for button in self._buttons: - button_text = button.cget("text") - field = button_text[2:] # Skip box indicator + for field, button in self._field_buttons.items(): if self.value_vars[field].get(): - button.configure(text=f"■ {field}") + button.set(f"■ {field}") else: - button.configure(text=f"□ {field}") + button.set(f"□ {field}") if self.command: self.command(self.get()) diff --git a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py new file mode 100644 index 0000000..0383371 --- /dev/null +++ b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path + +import matplotlib.pyplot as plt + +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.widgets import ( + Button, + CalendarWidget, + CheckboxSelection, + CmapSelector, + ColourPicker, + DropDownSelection, + FigureContainer, + Label, + MultiBoxSelection, + PackingOptions, + PathSelector, + RadioSelection, + ScrollableListBox, + ValidatedEntryBox, +) +from python_toolkit.bhom_tkinter.windows import ( + DirectoryFileSelector, + LandingPage, + ProcessingWindow, + WarningBox, +) + + +def _demo_callback(*_args, **_kwargs): + return None + + +def _show_brief(window: BHoMBaseWindow, milliseconds: int = 900) -> None: + window.after(milliseconds, window.destroy_root) + window.mainloop() + + +def run_widget_gallery(auto_close_ms: int | None = None) -> None: + root = BHoMBaseWindow( + title="BHoM Tkinter Widget Gallery", + min_width=1200, + min_height=900, + show_submit=False, + show_close=True, + close_text="Close Gallery", + ) + + parent = root.content_frame + alignments = ["left", "center", "right"] + + Label( + parent, + text="Label widget (left)", + item_title="Label", + helper_text="Basic BHoM Label wrapper", + alignment=alignments[0], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + Button( + parent, + text="Button widget", + command=_demo_callback, + item_title="Button", + helper_text="Simple action button", + alignment=alignments[1], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + ValidatedEntryBox( + parent, + item_title="ValidatedEntryBox", + helper_text="Integer 0..100", + value_type=int, + min_value=0, + max_value=100, + alignment=alignments[2], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + DropDownSelection( + parent, + item_title="DropDownSelection", + helper_text="Pick an option", + options=["A", "B", "C"], + default="B", + alignment=alignments[0], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + RadioSelection( + parent, + item_title="RadioSelection", + helper_text="Single select", + fields=["Red", "Green", "Blue"], + orient="horizontal", + max_per_line=3, + alignment=alignments[1], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + CheckboxSelection( + parent, + item_title="CheckboxSelection", + helper_text="Multi select", + fields=["One", "Two", "Three", "Four"], + defaults=["Two"], + max_per_line=4, + alignment=alignments[2], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + MultiBoxSelection( + parent, + item_title="MultiBoxSelection", + helper_text="Legacy alias variant", + fields=["North", "South", "East", "West"], + orient="horizontal", + max_per_line=2, + alignment=alignments[0], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + ScrollableListBox( + parent, + item_title="ScrollableListBox", + helper_text="List with selection controls", + items=[f"Item {i}" for i in range(1, 13)], + height=5, + show_selection_controls=True, + alignment=alignments[1], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + PathSelector( + parent, + item_title="PathSelector", + helper_text="Browse file path", + button_text="Browse", + mode="file", + alignment=alignments[2], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + ColourPicker( + parent, + item_title="ColourPicker", + helper_text="Pick a colour", + default_colour="#4A90E2", + alignment=alignments[0], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + CalendarWidget( + parent, + item_title="CalendarWidget", + helper_text="Date picker", + def_year=date.today().year, + def_month=date.today().month, + def_day=max(1, min(date.today().day, 28)), + show_year_selector=True, + alignment=alignments[1], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + figure_container = FigureContainer( + parent, + item_title="FigureContainer", + helper_text="Embedded matplotlib figure", + alignment=alignments[2], + packing_options=PackingOptions(fill="x", pady=6), + ) + figure_container.build() + figure, axis = plt.subplots(figsize=(3.2, 1.2)) + axis.plot([0, 1, 2, 3], [1, 3, 2, 4]) + axis.set_title("Sample") + figure_container.embed_figure(figure) + + CmapSelector( + parent, + item_title="CmapSelector", + helper_text="Colormap preview", + cmap_set="continuous", + alignment=alignments[0], + packing_options=PackingOptions(fill="x", pady=6), + ).build() + + if auto_close_ms is not None: + root.after(auto_close_ms, root.destroy_root) + root.mainloop() + + +def run_predefined_windows_demo() -> None: + landing = LandingPage( + title="LandingPage demo", + header="Landing page", + message="Smoke-checking predefined windows.", + sub_title="This window auto-closes.", + show_submit=False, + ) + landing.add_custom_button("No-op Action", _demo_callback) + _show_brief(landing) + + warning = WarningBox( + title="WarningBox demo", + warnings=["Sample warning message"], + errors=["Sample error message"], + infos=["Sample informational message"], + ) + _show_brief(warning) + + processing = ProcessingWindow(title="ProcessingWindow demo", message="Working...") + processing.start() + processing.after(900, processing.stop) + processing.mainloop() + + selector = DirectoryFileSelector( + directory=Path(__file__).resolve().parent, + file_types=[".py"], + selection_label="test files", + ) + _show_brief(selector) + + +if __name__ == "__main__": + run_widget_gallery() + run_predefined_windows_demo() From 78df46c6c3be98a90278fc0b081cb67ed637a0c0 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 25 Feb 2026 11:05:52 +0000 Subject: [PATCH 17/28] smaller calendar changes --- .../widgets/{Calender.py => calendar.py} | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{Calender.py => calendar.py} (88%) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/calendar.py similarity index 88% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/calendar.py index 33f0484..0d8b1eb 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/Calender.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/calendar.py @@ -1,7 +1,7 @@ """Calendar date-picker widget with optional year selector.""" import tkinter as tk -from typing import Optional +from typing import Optional, Literal from tkinter import ttk from python_toolkit.bhom_tkinter.widgets.label import Label import calendar @@ -25,6 +25,7 @@ def __init__( day_button_width: int = 4, day_button_padx: int = 1, day_button_pady: int = 1, + day_button_text_alignment: Literal["left", "center", "right"] = "center", **kwargs): super().__init__(parent, **kwargs) @@ -37,6 +38,18 @@ def __init__( self.day_button_width = max(1, int(day_button_width)) self.day_button_padx = int(day_button_padx) self.day_button_pady = int(day_button_pady) + alignment_candidate = str(day_button_text_alignment).strip().lower() + if alignment_candidate not in {"left", "center", "right"}: + alignment_candidate = "center" + self.day_button_text_alignment = alignment_candidate + self.day_button_style = f"CalendarDay.{id(self)}.TButton" + + anchor_map = { + "left": "w", + "center": "center", + "right": "e", + } + ttk.Style(self).configure(self.day_button_style, anchor=anchor_map[self.day_button_text_alignment]) self.cal_frame = ttk.Frame(self.content_frame) self.cal_frame.pack(side="top", fill="x") @@ -116,6 +129,7 @@ def redraw(self): command=(lambda d=day: self.set_day(d)), width=self.day_button_width, ) + cell_widget.button.configure(style=self.day_button_style, state=state) cell_widget.grid( row=row+1, column=col, @@ -190,9 +204,10 @@ def pack(self, **kwargs): def_year=2024, def_month=6, def_day=15, - day_button_width=1, + day_button_width=3, day_button_padx=2, day_button_pady=2, + day_button_text_alignment="center", item_title="Select a Date", helper_text="Choose a date from the calendar below.", packing_options=PackingOptions(padx=20, pady=20) From 66f7e84a6109644bdf78d8f65909f50ff15cc9f2 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 25 Feb 2026 11:07:41 +0000 Subject: [PATCH 18/28] init change --- .../Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d68c79e..ec27267 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 @@ -1,6 +1,6 @@ from ._packing_options import PackingOptions from ._widgets_base import BHoMBaseWidget -from .calender import CalendarWidget +from .calendar import CalendarWidget from .check_box_selection import CheckboxSelection from .cmap_selector import CmapSelector from .colour_picker import ColourPicker From 09f6020ceca5c8c212ae1056a528aa0689159cc6 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 25 Feb 2026 12:43:26 +0000 Subject: [PATCH 19/28] Added widget level validation --- .../bhom_tkinter/bhom_base_window.py | 2 +- .../bhom_tkinter/widgets/_widgets_base.py | 67 ++++++++++++++++++- .../bhom_tkinter/widgets/button.py | 6 +- .../bhom_tkinter/widgets/calendar.py | 14 ++++ .../widgets/check_box_selection.py | 24 ++++++- .../bhom_tkinter/widgets/cmap_selector.py | 16 ++++- .../bhom_tkinter/widgets/colour_picker.py | 20 +++++- .../widgets/drop_down_selection.py | 16 ++++- .../bhom_tkinter/widgets/figure_container.py | 15 ++++- .../bhom_tkinter/widgets/label.py | 22 +++++- .../bhom_tkinter/widgets/list_box.py | 17 ++++- .../widgets/multi_box_selection.py | 19 +++++- .../bhom_tkinter/widgets/path_selector.py | 24 ++++++- .../bhom_tkinter/widgets/radio_selection.py | 18 ++++- .../widgets/validated_entry_box.py | 50 +++++++++++--- .../bhom_tkinter/windows/warning_box.py | 1 - 16 files changed, 304 insertions(+), 27 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 4f76bd9..41fc86d 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 @@ -333,7 +333,7 @@ def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path] title_label = Label( text_container, text=title, - style="Title.TLabel" + style="LargeTitle.TLabel" ) title_label.pack(anchor="w") 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 87d1492..18159dc 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 @@ -5,7 +5,7 @@ import tkinter as tk from tkinter import ttk -from typing import Optional, Literal, Union +from typing import Optional, Literal, Union, Tuple, Callable from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions @@ -24,6 +24,8 @@ def __init__( item_title: Optional[str] = None, helper_text: Optional[str] = None, alignment: Literal['left', 'center', 'right'] = 'left', + custom_validation: Optional[Callable[[object], Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]]] = None, + disable_validation: bool = False, packing_options: PackingOptions = PackingOptions(), **kwargs): """ @@ -35,6 +37,10 @@ def __init__( item_title: Optional header text shown at the top of the widget frame. helper_text: Optional helper text shown above the entry box. alignment: Horizontal alignment for built-in text elements. + custom_validation: Optional callable used to extend widget validation. + Receives the current widget value and must return + `(is_valid, message, severity)`. + disable_validation: When `True`, all validation returns valid. **kwargs: Additional Frame options """ super().__init__(parent, **kwargs) @@ -43,6 +49,8 @@ def __init__( self.helper_text = helper_text self.packing_options = packing_options self.alignment: Literal['left', 'center', 'right'] = self._normalise_alignment(alignment) + self.custom_validation = custom_validation + self.disable_validation = bool(disable_validation) if id is None: self.id = str(uuid4()) @@ -51,7 +59,7 @@ def __init__( # Optional header/title label at the top of the widget if self.item_title: - self.title_label = ttk.Label(self, text=self.item_title, style="Header.TLabel") + self.title_label = ttk.Label(self, text=self.item_title, style="Subtitle.TLabel") self.title_label.pack(side="top", anchor=self._pack_anchor) self._apply_text_alignment(self.title_label) @@ -193,6 +201,61 @@ def set(self, value): value: Value to apply to the widget state. """ raise NotImplementedError("Subclasses must implement the set() method.") + + @abstractmethod + def validate(self) -> Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current widget value. + + Returns: + bool: True if the current value is valid, False otherwise. + Optional[str]: Error message if the value is invalid, None otherwise. + Optional[Literal['info', 'warning', 'error']]: Severity level of the validation result, or None if valid. + """ + raise NotImplementedError("Subclasses must implement the validate() method.") + + def apply_validation( + self, + base_result: Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]] + ) -> Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Apply global validation switches and optional custom validation. + + Subclasses should call this from their `validate()` implementation, + passing built-in validation output as `base_result`. + + Args: + base_result: Built-in widget validation result in the form + `(is_valid, message, severity)`. + + Returns: + Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + Final validation result after applying disable/custom rules. + """ + if self.disable_validation: + return True, None, None + + is_valid, message, severity = base_result + if not is_valid: + return is_valid, message, severity or "error" + + if self.custom_validation is None: + return is_valid, message, severity + + try: + custom_result = self.custom_validation(self.get()) + except Exception as ex: + return False, f"Custom validation failed: {ex}", "error" + + if not isinstance(custom_result, tuple) or len(custom_result) != 3: + return False, "Custom validation must return (is_valid, message, severity).", "error" + + custom_valid, custom_message, custom_severity = custom_result + if not custom_valid: + return False, custom_message, custom_severity or "error" + + if custom_message is not None or custom_severity is not None: + return True, custom_message, custom_severity + + return is_valid, message, severity def build( self, 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 052331c..45e1b6e 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 @@ -2,7 +2,7 @@ import tkinter as tk from tkinter import ttk -from typing import Optional, Callable +from typing import Optional, Callable, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget @@ -54,6 +54,10 @@ def set(self, value): 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.""" + return self.apply_validation((True, None, None)) + if __name__ == "__main__": from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/calendar.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/calendar.py index 0d8b1eb..af6de55 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/calendar.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/calendar.py @@ -180,6 +180,20 @@ def set(self, value: datetime.date): self.month = value.month self.day = value.day self.redraw() + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the currently selected date. + + 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 date. + """ + try: + datetime.date(self.year, self.month, self.day) + return self.apply_validation((True, None, None)) + except ValueError as ex: + return self.apply_validation((False, f"Invalid date: {ex}", "error")) def pack(self, **kwargs): """Pack the widget and ensure the calendar grid is rendered. 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 1fba5c4..e7035ff 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 @@ -2,7 +2,7 @@ import tkinter as tk from tkinter import ttk -from typing import Optional, List, Callable +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 @@ -21,6 +21,8 @@ def __init__( max_per_line=None, item_title: Optional[str] = None, helper_text: Optional[str] = None, + min_selections: Optional[int] = None, + max_selections: Optional[int] = None, **kwargs): """ Args: @@ -32,6 +34,8 @@ def __init__( max_per_line (int, optional): Maximum items per row/column before wrapping. item_title (str, optional): Optional header text shown at the top of the widget frame. helper_text (str, optional): Optional helper text shown above the checkboxes. + min_selections (int, optional): Minimum number of selections required. + max_selections (int, optional): Maximum number of selections allowed. **kwargs: Additional Frame options. """ super().__init__(parent, item_title=item_title, helper_text=helper_text, **kwargs) @@ -43,6 +47,8 @@ def __init__( self.value_vars = {} # Dictionary mapping field names to BooleanVars self._buttons = [] self._field_buttons = {} + self.min_selections = min_selections + self.max_selections = max_selections # Sub-frame for checkbox controls self.buttons_frame = ttk.Frame(self.content_frame) @@ -68,6 +74,7 @@ def _build_buttons(self): button = Label( self.buttons_frame, text=f"□ {field}", + style="Caption.TLabel", cursor="hand2" ) getattr(self, "align_child_text")(button) @@ -184,6 +191,21 @@ def pack(self, **kwargs): """ super().pack(**kwargs) + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current selection against min/max constraints. + + 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. + """ + selected_count = len(self.get()) + if self.min_selections is not None and selected_count < self.min_selections: + return getattr(self, "apply_validation")((False, f"Select at least {self.min_selections} options.", "error")) + if self.max_selections is not None and selected_count > self.max_selections: + return getattr(self, "apply_validation")((False, f"Select no more than {self.max_selections} options.", "error")) + return getattr(self, "apply_validation")((True, None, None)) + if __name__ == "__main__": from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow 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 6d078ed..6c8c81c 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,6 +1,6 @@ """Colormap selector widget with embedded matplotlib preview.""" -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Literal from tkinter import ttk import tkinter as tk import matplotlib as mpl @@ -248,6 +248,20 @@ def set(self, value: Optional[str]): else: 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. + """ + selected_cmap = self.get_selected_cmap() + if selected_cmap is None: + 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")) + return self.apply_validation((True, None, None)) if __name__ == "__main__": from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow 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 6a9f0b4..715476c 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 @@ -3,7 +3,7 @@ import tkinter as tk from tkinter import ttk from python_toolkit.bhom_tkinter.widgets.label import Label -from typing import Callable, Optional +from typing import Callable, Optional, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets.button import Button @@ -225,6 +225,24 @@ def set(self, value: str) -> None: self.colour_var.set(value) self._update_preview(value) + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current colour value. + + 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 colour. + """ + + colour = self.get() + if not colour: + return self.apply_validation((False, "No colour selected.", "error")) + try: + self._hex_to_rgb(colour) + return self.apply_validation((True, None, None)) + except ValueError: + return self.apply_validation((False, f"Invalid colour value: '{colour}'.", "error")) + if __name__ == "__main__": from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow 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 f448ecf..6b1b6fe 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 @@ -2,7 +2,7 @@ import tkinter as tk from tkinter import ttk -from typing import Optional, Callable, List +from typing import Optional, Callable, List, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget @@ -110,7 +110,21 @@ def get_selected_index(self) -> int: return self.options.index(self.get()) except ValueError: return -1 + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current 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. + """ + selected = self.get() + if not selected: + return getattr(self, "apply_validation")((False, "No option selected.", "error")) + if selected not in self.options: + return getattr(self, "apply_validation")((False, f"Selected option '{selected}' is not available.", "error")) + return getattr(self, "apply_validation")((True, None, None)) if __name__ == "__main__": 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 c0193cc..714d895 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 @@ -3,7 +3,7 @@ import tkinter as tk from tkinter import ttk from python_toolkit.bhom_tkinter.widgets.label import Label -from typing import Optional, Any +from typing import Optional, Any, Literal from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure import matplotlib.pyplot as plt @@ -220,6 +220,19 @@ def set(self, value): else: raise ValueError("Unsupported value type for FigureContainer. Must be Figure, PhotoImage, or file path string.") + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current content of the figure container. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for invalid content. + """ + if self.figure is not None: + return self.apply_validation((True, None, None)) + if self.image is not None: + return self.apply_validation((True, None, None)) + return self.apply_validation((False, "FigureContainer is empty. Please embed a figure or image.", "error")) if __name__ == "__main__": 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 10de7e4..168853d 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,5 +1,5 @@ from tkinter import ttk -from typing import Optional +from typing import Optional, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget @@ -88,10 +88,26 @@ def update_text(self, new_text: str): """Backward-compatible method name used previously to update label text.""" self.set(new_text) - # Provide a generic `update` alias for convenience (matches previous API). - def update(self, new_text: str): + # Provide a generic `update` alias for convenience (matches previous API), + # while remaining compatible with tkinter's parameterless `update()`. + def update(self, new_text: Optional[str] = None): + if new_text is None: + super().update() + return self.update_text(new_text) + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current content of the label. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for invalid content. + """ + + #always true + return getattr(self, "apply_validation")((True, None, None)) + if __name__ == "__main__": from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow 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 f32cdb7..5673254 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 @@ -1,8 +1,10 @@ """Scrollable listbox widget with optional selection helper controls.""" +from __future__ import annotations + import tkinter as tk from tkinter import filedialog, ttk -from typing import Optional +from typing import Optional, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets.button import Button @@ -153,7 +155,7 @@ def set(self, value): self.clear() for item in value: self.listbox.insert(tk.END, item) - self._on_configure( ) + self._on_configure() def get(self): """Get the current list of items in the listbox. @@ -162,6 +164,17 @@ def get(self): list: Current listbox items in display order. """ return [self.listbox.get(i) for i in range(self.listbox.size())] + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current listbox state. + + 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 state. + """ + # In this simple implementation, all states are valid, so we return True. + return self.apply_validation((True, None, None)) if __name__ == "__main__": diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py index 819246c..c4c40d4 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py @@ -2,7 +2,7 @@ import tkinter as tk from tkinter import ttk -from typing import Optional, List, Callable +from typing import Optional, List, Callable, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets.button import Button @@ -64,6 +64,7 @@ def _build_buttons(self): button = Label( self.buttons_frame, text=f"□ {field}", + style="Caption.TLabel", cursor="hand2" ) getattr(self, "align_child_text")(button) @@ -163,6 +164,22 @@ def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): if defaults: self.set(defaults) + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate checkbox state against available field definitions. + + 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 state. + """ + available_fields = set(self.fields) + selected_fields = set(self.get()) + invalid_fields = selected_fields - available_fields + if invalid_fields: + invalid_text = ", ".join(sorted(invalid_fields)) + return self.apply_validation((False, f"Invalid selected fields: {invalid_text}", "error")) + return self.apply_validation((True, None, None)) + if __name__ == "__main__": from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow 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 da51d37..3342bbb 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 @@ -3,7 +3,7 @@ import tkinter as tk from tkinter import filedialog, ttk from pathlib import Path -from typing import Optional +from typing import Optional, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets.button import Button @@ -94,6 +94,28 @@ def set(self, value: Optional[str]): else: self.display_name.set(selected_path.name) + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the currently selected path. + + 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 path selection. + """ + selected_path = self.get().strip() + if not selected_path: + return self.apply_validation((False, "No path selected.", "error")) + + path = Path(selected_path) + if self.mode == "directory": + if not path.is_dir(): + return self.apply_validation((False, f"Directory does not exist: {selected_path}", "error")) + else: + if not path.is_file(): + return self.apply_validation((False, f"File does not exist: {selected_path}", "error")) + + return self.apply_validation((True, None, None)) + if __name__ == "__main__": 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 d4596f7..c8dc9a6 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 @@ -3,7 +3,7 @@ import tkinter as tk from tkinter import ttk from python_toolkit.bhom_tkinter.widgets.label import Label -from typing import Optional, cast +from typing import Optional, cast, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget @@ -63,6 +63,7 @@ def _build_buttons(self): button = Label( self.buttons_frame, text=f"○ {field}", + style="Caption.TLabel", cursor="hand2" ) if callable(align_child_text): @@ -136,6 +137,21 @@ def set_fields(self, fields, default=None): else: self.value_var.set("") + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the currently selected radio option. + + 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. + """ + selected = self.get() + if not selected: + return self.apply_validation((False, "No option selected.", "error")) + if selected not in self.fields: + return self.apply_validation((False, f"Selected option '{selected}' is not available.", "error")) + return self.apply_validation((True, None, None)) + if __name__ == "__main__": 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 bb6aa63..f6da8ab 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 @@ -3,7 +3,7 @@ import tkinter as tk from tkinter import ttk from python_toolkit.bhom_tkinter.widgets.label import Label -from typing import Optional, Callable, Any, Union +from typing import Optional, Callable, Any, Union, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget @@ -122,38 +122,70 @@ def set(self, value: Union[str, int, float]) -> None: """ self.variable.set(str(value)) - def validate(self) -> bool: + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: """ Validate the current entry value. Returns: - bool: True if valid, False otherwise + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + `(is_valid, message, severity)` where severity is `None` when + valid, or `"error"` for invalid input. """ + if self.disable_validation: + self._show_success() + self._call_validate_callback(True) + return self.apply_validation((True, None, None)) + value_str = self.get() + base_result: tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]] # Check if required if self.required and not value_str: self._show_error("Required") self._call_validate_callback(False) - return False + base_result = (False, "Required", "error") + return self.apply_validation(base_result) # If not required and empty, it's valid if not self.required and not value_str: self._show_success() self._call_validate_callback(True) - return True + base_result = (True, None, None) + return self.apply_validation(base_result) # Type-specific validation if self.value_type == str: - return self._validate_string(value_str) + is_valid = self._validate_string(value_str) elif self.value_type == int: - return self._validate_int(value_str) + is_valid = self._validate_int(value_str) elif self.value_type == float: - return self._validate_float(value_str) + is_valid = self._validate_float(value_str) else: self._show_error(f"Unsupported type: {self.value_type}") self._call_validate_callback(False) - return False + base_result = (False, f"Unsupported type: {self.value_type}", "error") + return self.apply_validation(base_result) + + if is_valid: + base_result = (True, None, None) + else: + message = self.error_label.get().strip() + base_result = (False, message if message else "Validation failed.", "error") + + final_result = self.apply_validation(base_result) + final_valid, final_message, final_severity = final_result + + if final_valid: + self._show_success() + self._call_validate_callback(True) + else: + if final_message: + self._show_error(final_message) + else: + self._show_error("Validation failed.") + self._call_validate_callback(False) + + return final_result def _validate_string(self, value: str) -> bool: """Validate string value. diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py index 57a7126..d18ca53 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py @@ -60,7 +60,6 @@ def update_messages(self): self._render_messages() self.refresh_sizing() - def add_error_message(self, message: str): """Add an error message to the warning box. From 6f3bee6aa0c757785bcdec2d4c9991b349d14473 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 25 Feb 2026 14:45:31 +0000 Subject: [PATCH 20/28] improved label support --- .../bhom_tkinter/widgets/__init__.py | 2 +- .../widgets/check_box_selection.py | 12 +- .../bhom_tkinter/widgets/label.py | 25 ++-- .../widgets/multi_box_selection.py | 30 +++-- .../bhom_tkinter/widgets/radio_selection.py | 16 ++- .../{calendar.py => widget_calendar.py} | 2 +- .../bhom_tkinter/windows/processing_window.py | 110 +++++++++--------- .../Python/tests/test_bhom_tkinter_ui.py | 47 ++++++++ tmp_label_test.py | 15 +++ 9 files changed, 176 insertions(+), 83 deletions(-) rename Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/{calendar.py => widget_calendar.py} (99%) create mode 100644 tmp_label_test.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py index ec27267..130ce0a 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 @@ -1,6 +1,6 @@ from ._packing_options import PackingOptions from ._widgets_base import BHoMBaseWidget -from .calendar import CalendarWidget +from .widget_calendar import CalendarWidget from .check_box_selection import CheckboxSelection from .cmap_selector import CmapSelector from .colour_picker import ColourPicker 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 e7035ff..a767a09 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 @@ -75,10 +75,19 @@ def _build_buttons(self): self.buttons_frame, text=f"□ {field}", style="Caption.TLabel", - cursor="hand2" ) getattr(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": @@ -237,6 +246,5 @@ def on_selection(values): Button(control_frame, text="Select All", command=widget.select_all).pack(side="left", padx=5) Button(control_frame, text="Deselect All", command=widget.deselect_all).pack(side="left", padx=5) - Button(control_frame, text="Toggle All", command=widget.toggle_all).pack(side="left", padx=5) root.mainloop() 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 168853d..5c10447 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 @@ -71,18 +71,27 @@ def resolve(explicit_value, key: str, default=None): def get(self) -> str: """Return the current label text.""" try: - return str(self.label.cget("text")) + # Prefer text if present, otherwise return any image reference + text = self.label.cget("text") + if text: + return str(text) + # Fall back to any stored image reference + return getattr(self, "_image_ref", "") except Exception: return "" - def set(self, value: str): - """Set the label text.""" + def set(self, value): + """Set the label text or image.""" if isinstance(value, str): - try: - self.text = value - self.label.configure(text=self.text) - except Exception: - pass + self.text = value + self.label.configure(text=self.text, image="") + if hasattr(self, "_image_ref"): + delattr(self, "_image_ref") + else: + # Assume it's an image object + self._image_ref = value + self.label.configure(image=self._image_ref, text="") + def update_text(self, new_text: str): """Backward-compatible method name used previously to update label text.""" diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py index c4c40d4..8755c46 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py @@ -65,10 +65,18 @@ def _build_buttons(self): self.buttons_frame, text=f"□ {field}", style="Caption.TLabel", - cursor="hand2" ) getattr(self, "align_child_text")(button) + # Bind clicks on both the wrapper and the inner ttk.Label so clicks register button.bind("", lambda _event, f=field: self._toggle_field(f)) + try: + button.label.configure(cursor="hand2") + except Exception: + pass + try: + button.label.bind("", lambda _event, 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 @@ -91,13 +99,13 @@ def _toggle_field(self, field): def _on_select_field(self, field): """Handle checkbox selection change and update visual indicator.""" for button in self._buttons: - button_text = button.cget("text") + button_text = button.get() current_field = button_text[2:] if current_field == field: if self.value_vars[field].get(): - button.configure(text=f"■ {field}") + button.set(f"■ {field}") else: - button.configure(text=f"□ {field}") + button.set(f"□ {field}") break if self.command: @@ -122,21 +130,21 @@ def set(self, value: List[str]): var.set(field in values) for button in self._buttons: - button_text = button.cget("text") + button_text = button.get() current_field = button_text[2:] if current_field in values: - button.configure(text=f"■ {current_field}") + button.set(f"■ {current_field}") else: - button.configure(text=f"□ {current_field}") + button.set(f"□ {current_field}") def select_all(self): """Select all checkboxes.""" for var in self.value_vars.values(): var.set(True) for button in self._buttons: - button_text = button.cget("text") + button_text = button.get() field = button_text[2:] - button.configure(text=f"■ {field}") + button.set(f"■ {field}") if self.command: self.command(self.get()) @@ -145,9 +153,9 @@ def deselect_all(self): for var in self.value_vars.values(): var.set(False) for button in self._buttons: - button_text = button.cget("text") + button_text = button.get() field = button_text[2:] - button.configure(text=f"□ {field}") + button.set(f"□ {field}") if self.command: self.command(self.get()) 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 c8dc9a6..930a4dd 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 @@ -64,11 +64,19 @@ def _build_buttons(self): self.buttons_frame, text=f"○ {field}", style="Caption.TLabel", - cursor="hand2" ) if callable(align_child_text): 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 @@ -94,12 +102,12 @@ 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.cget("text") + button_text = button.get() current_field = button_text[2:] if current_field == selected_value: - button.configure(text=f"● {current_field}") + button.set(f"● {current_field}") else: - button.configure(text=f"○ {current_field}") + button.set(f"○ {current_field}") def get(self): """Return the currently selected value. diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/calendar.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py similarity index 99% rename from Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/calendar.py rename to Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py index af6de55..7bf138a 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/calendar.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py @@ -228,4 +228,4 @@ def pack(self, **kwargs): ) cal_widget1.build() - root.mainloop() \ No newline at end of file + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py index 286ffde..0be8ff6 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py @@ -2,21 +2,23 @@ import tkinter as tk from tkinter import ttk -from python_toolkit.bhom_tkinter.widgets.label import Label import time + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.widgets.label import Label + class ProcessingWindow(BHoMBaseWindow): - """A simple processing window with animated indicator.""" + """A processing window with animated indicator, built on the BHoM window protocol.""" def __init__(self, title="Processing", message="Processing..."): - """ - Args: - title (str): Window title. - message (str): Message to display. - """ self.window_title = title self.message_text = message + self.message_label = None + self.animation_label = None + self.current_frame = 0 + self.is_running = False + self._after_id = None super().__init__( title=title, @@ -27,39 +29,33 @@ def __init__(self, title="Processing", message="Processing..."): resizable=False, ) + self.root = self self.attributes("-topmost", True) - self.title_label = None - self.message_label = None - self.animation_label = None - - # Animation state - self.current_frame = 0 - self.is_running = False - def build(self): """Build processing labels and the animation indicator.""" - self.title_label = Label( - self.content_frame, - text=self.window_title, - style="Title.TLabel", - justify="center", - wraplength=400, - ) - self.title_label.pack(pady=(0, 8)) + container = ttk.Frame(self.content_frame) + container.pack(fill=tk.BOTH, expand=True) self.message_label = Label( - self.content_frame, + container, text=self.message_text, + style="Title.TLabel", justify="center", wraplength=400, + alignment="center", ) - self.message_label.pack(pady=(0, 20)) + self.message_label.pack(fill=tk.X, pady=(0, 20)) + + animation_frame = ttk.Frame(container) + animation_frame.pack(expand=True) - self.animation_label = Label( - self.content_frame, + self.animation_label = ttk.Label( + animation_frame, text="●", style="Title.TLabel", + justify="center", + anchor="center", ) self.animation_label.pack() @@ -67,55 +63,57 @@ def build(self): def start(self): """Start the processing window and animation.""" + if self.is_running: + return self.is_running = True + self.current_frame = 0 self._animate() def keep_alive(self): - """Call repeatedly to process animation updates. - - Returns: - bool: `True` while running and window exists, else `False`. - """ - if self.is_running and self.winfo_exists(): - self.update() - return True + """Call this repeatedly to process animation updates. Returns False when done.""" + try: + if self.is_running and self.root.winfo_exists(): + self.root.update_idletasks() + self.root.update() + return True + except tk.TclError: + return False return False def stop(self): """Stop the animation and close the window.""" self.is_running = False - self.destroy() + if self._after_id is not None: + try: + self.root.after_cancel(self._after_id) + except Exception: + pass + self._after_id = None + self.destroy_root() def _animate(self): """Update animation frames.""" - if self.is_running: - # Create rotating dot animation + if self.is_running and self.root.winfo_exists(): dots = ["◐", "◓", "◑", "◒"] - if self.animation_label is not None: - self.animation_label.config(text=dots[self.current_frame % len(dots)]) + self.animation_label.config(text=dots[self.current_frame % len(dots)]) self.current_frame += 1 - self.after(200, self._animate) + self._after_id = self.root.after(200, self._animate) def update_message(self, message: str): - """Update the message text. - - Args: - message: New status message to display. - """ + """Update the message text.""" self.message_text = message - if self.message_label is not None: - self.message_label.config(text=message) - self.update() + if self.root.winfo_exists() and self.message_label is not None: + self.message_label.set(message) + self.root.update_idletasks() + self.root.update() if __name__ == "__main__": - # Test the processing window - - processing = ProcessingWindow(title="Test Processing", message="Running Test Calculation...") + processing = ProcessingWindow(title="Test Processing", message="Running Comfort and Safety Calculation...") processing.start() - - # Simulate some work - for i in range(50): + + for _ in range(50): time.sleep(0.1) - processing.update_message(f"Running Test Calculation... {i+1}/50") + processing.keep_alive() + processing.stop() diff --git a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py index 0383371..fd7d62b 100644 --- a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py +++ b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py @@ -229,3 +229,50 @@ def run_predefined_windows_demo() -> None: if __name__ == "__main__": run_widget_gallery() run_predefined_windows_demo() + + +def test_widget_validation(): + """Ensure widget validation logic returns expected results.""" + # Integer field valid range + root = BHoMBaseWindow(title="Validation test") + parent = root.content_frame + + int_box = ValidatedEntryBox( + parent, + value_type=int, + min_value=0, + max_value=100, + required=True, + ) + int_box.build() + + int_box.set(50) + ok, msg, sev = int_box.validate() + assert ok is True and msg is None + + int_box.set(150) + ok, msg, sev = int_box.validate() + assert ok is False and sev == "error" + + # Required field empty + int_box.set("") + ok, msg, sev = int_box.validate() + assert ok is False and msg == "Required" + + # Non-required empty is valid + opt_box = ValidatedEntryBox(parent, value_type=str, required=False) + opt_box.build() + opt_box.set("") + ok, msg, sev = opt_box.validate() + assert ok is True + + # Custom validator overriding + def fail_validator(_v): + return False, "Custom failed" + + custom_box = ValidatedEntryBox(parent, value_type=str, required=True, custom_validator=fail_validator) + custom_box.build() + custom_box.set("abc") + ok, msg, sev = custom_box.validate() + assert ok is False and msg == "Custom failed" + diff --git a/tmp_label_test.py b/tmp_label_test.py new file mode 100644 index 0000000..0eb7341 --- /dev/null +++ b/tmp_label_test.py @@ -0,0 +1,15 @@ +from python_toolkit.bhom_tkinter.widgets.label import Label +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + +root = BHoMBaseWindow() +root.update() +label = Label(root.content_frame, text='initial') +label.build() +print('before:', label.label.cget('text')) +label.set('updated') +print('after set:', label.label.cget('text')) +frames=['◐','◓','◑','◒'] +for i,ch in enumerate(frames): + label.set(ch) + print(f'frame {i} =>', label.label.cget('text')) +root.destroy() From 2a891563b75c8a9d2c4d67e749b2b178b9bbd1c2 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 25 Feb 2026 15:54:17 +0000 Subject: [PATCH 21/28] bug fixes --- .../python_toolkit/bhom_tkinter/bhom_base_window.py | 8 ++++++++ .../bhom_tkinter/widgets/validated_entry_box.py | 13 +++++++------ 2 files changed, 15 insertions(+), 6 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 41fc86d..cfcb55d 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 @@ -107,6 +107,7 @@ def __init__( self.submit_command = submit_command self.close_command = close_command self.result = None + self._is_exiting = False self.button_bar: Optional[ttk.Frame] = None self._has_been_shown = False self._pending_resize_job: Optional[str] = None @@ -479,10 +480,17 @@ def _exit(self, result: str, callback: Optional[Callable] = None) -> None: result: Result token to store before closing. callback: Optional callback invoked before destruction. """ + if self._is_exiting: + return + self._is_exiting = True self.result = result try: if callback: callback() + except tk.TclError as ex: + message = str(ex).lower() + if not ("image" in message and "doesn't exist" in message): + print(f"Warning: Exit callback raised an exception: {ex}") except Exception as ex: print(f"Warning: Exit callback raised an exception: {ex}") finally: 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 f6da8ab..3a0fd30 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 @@ -305,18 +305,19 @@ def _validate_float(self, value_str: str) -> bool: def _show_error(self, message: str) -> None: """Display error message.""" - self.error_label.config(text=message, foreground="#ff4444") - self.success_label.config(text=" ") + self.error_label.set(message) + self.error_label.label.configure(foreground="#ff4444") + self.success_label.set(" ") def _show_success(self) -> None: """Display success indicator.""" - self.error_label.config(text=" ") - self.success_label.config(text="✓") + self.error_label.set(" ") + self.success_label.set("✓") def clear_error(self) -> None: """Clear the error message.""" - self.error_label.config(text=" ") - self.success_label.config(text=" ") + self.error_label.set(" ") + self.success_label.set(" ") def _call_validate_callback(self, is_valid: bool) -> None: """Call the validation callback if provided.""" From 8b9419a09e95159e24a797b0bae5fff11082782a Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 25 Feb 2026 15:57:26 +0000 Subject: [PATCH 22/28] top_most default arg --- .../src/python_toolkit/bhom_tkinter/bhom_base_window.py | 5 +++++ 1 file changed, 5 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 cfcb55d..2c3547f 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 @@ -55,6 +55,7 @@ def __init__( theme_path_dark: Path = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_dark_theme.tcl"), theme_mode: Literal["light", "dark", "auto"] = "auto", widgets: List[BHoMBaseWidget] = [], + top_most: bool = True, **kwargs ): """ @@ -87,6 +88,10 @@ def __init__( self.minsize(min_width, min_height) self.resizable(resizable, resizable) + self.top_most = top_most + if self.top_most: + self.attributes("-topmost", True) + self.widgets = widgets # Hide window during setup to prevent flash From 0b36be4fea6901f09f4761c00a2c1a703c15b180 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 25 Feb 2026 17:52:37 +0000 Subject: [PATCH 23/28] fixed processing window --- .../bhom_tkinter/widgets/_widgets_base.py | 49 ++++- .../bhom_tkinter/widgets/figure_container.py | 29 ++- .../bhom_tkinter/widgets/radio_selection.py | 41 +++- .../bhom_tkinter/windows/processing_window.py | 190 ++++++++++++------ 4 files changed, 231 insertions(+), 78 deletions(-) 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 18159dc..82d7d62 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 @@ -24,6 +24,7 @@ def __init__( item_title: Optional[str] = None, helper_text: Optional[str] = None, alignment: Literal['left', 'center', 'right'] = 'left', + fill_extents: bool = False, custom_validation: Optional[Callable[[object], Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]]] = None, disable_validation: bool = False, packing_options: PackingOptions = PackingOptions(), @@ -37,6 +38,8 @@ def __init__( item_title: Optional header text shown at the top of the widget frame. helper_text: Optional helper text shown above the entry box. alignment: Horizontal alignment for built-in text elements. + fill_extents: If `True`, built-in title/helper labels fill horizontal + extent (`fill='x'`). If `False`, labels use natural width. custom_validation: Optional callable used to extend widget validation. Receives the current widget value and must return `(is_valid, message, severity)`. @@ -49,6 +52,7 @@ def __init__( self.helper_text = helper_text self.packing_options = packing_options self.alignment: Literal['left', 'center', 'right'] = self._normalise_alignment(alignment) + self.fill_extents = self._normalise_bool(fill_extents) self.custom_validation = custom_validation self.disable_validation = bool(disable_validation) @@ -60,13 +64,19 @@ def __init__( # Optional header/title label at the top of the widget if self.item_title: self.title_label = ttk.Label(self, text=self.item_title, style="Subtitle.TLabel") - self.title_label.pack(side="top", anchor=self._pack_anchor) + if self.fill_extents: + self.title_label.pack(side="top", anchor=self._pack_anchor, fill=tk.X) + else: + self.title_label.pack(side="top", anchor=self._pack_anchor) self._apply_text_alignment(self.title_label) # Optional helper/requirements label above the input if self.helper_text: self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") - self.helper_label.pack(side="top", anchor=self._pack_anchor) + if self.fill_extents: + self.helper_label.pack(side="top", anchor=self._pack_anchor, fill=tk.X) + else: + self.helper_label.pack(side="top", anchor=self._pack_anchor) self._apply_text_alignment(self.helper_label) # Container frame for embedded content (not title/helper) @@ -135,6 +145,25 @@ def _normalise_alignment(self, alignment: Optional[str]) -> Literal['left', 'cen return "left" return cast(Literal['left', 'center', 'right'], candidate) + def _normalise_bool(self, value: object) -> bool: + """Normalise bool-like input with safe defaults. + + Args: + value: Candidate boolean input. + + Returns: + bool: Parsed boolean value. + """ + if isinstance(value, bool): + return value + if isinstance(value, str): + candidate = value.strip().lower() + if candidate in {"1", "true", "yes", "y", "on"}: + return True + if candidate in {"0", "false", "no", "n", "off", ""}: + return False + return bool(value) + def _apply_text_alignment(self, widget: tk.Widget) -> None: """Apply current alignment settings to a text-capable Tk widget. @@ -188,6 +217,22 @@ def set_alignment(self, alignment: Literal['left', 'center', 'right']) -> None: label.pack_configure(anchor=self._pack_anchor) self._apply_text_alignment(label) + def set_fill_extents(self, fill_extents: bool) -> None: + """Set whether built-in title/helper labels fill horizontal extent. + + Args: + fill_extents: `True` for `fill='x'`, else natural-width packing. + """ + self.fill_extents = self._normalise_bool(fill_extents) + + for label_name in ("title_label", "helper_label"): + label = getattr(self, label_name, None) + if label is None: + continue + label.pack_configure(fill=(tk.X if self.fill_extents else "none")) + label.pack_configure(anchor=self._pack_anchor) + self._apply_text_alignment(label) + @abstractmethod def get(self): """Get the current value of the widget.""" 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 714d895..7227e71 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 @@ -26,8 +26,6 @@ def __init__( Args: parent: Parent widget - item_title: Optional header text shown at the top of the widget frame. - helper_text: Optional helper text shown above the entry box. **kwargs: Additional Frame options """ super().__init__(parent, **kwargs) @@ -174,7 +172,9 @@ def _scale_image_to_fit(self): # Convert to PhotoImage and update label self.image = ImageTk.PhotoImage(resized) if self.image_label: - self.image_label.configure(image=self.image) + # `image_label` is a BHoM Label wrapper; use wrapper API so + # the inner ttk.Label gets updated and image references persist. + self.image_label.set(self.image) except Exception: pass # Silently handle scaling errors @@ -251,13 +251,20 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warni ) figure_container.build() - # Create and embed a matplotlib figure - fig, ax = plt.subplots(figsize=(5, 4), dpi=80) - ax.plot([1, 2, 3, 4], [1, 4, 2, 3], marker='o') - ax.set_title("Sample Plot") - ax.set_xlabel("X") - ax.set_ylabel("Y") - - figure_container.embed_figure(fig) + # 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") + figure_container.embed_figure(fig_initial) + + def push_new_plot() -> None: + """Replace the existing plot with a new one after a delay.""" + image_path = r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\assets\BHoM_Logo.png" + figure_container.embed_image_file(image_path) + + # Push a new plot after 10 seconds + root.after(4_000, push_new_plot) root.mainloop() 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 930a4dd..f8a9400 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 @@ -17,6 +17,7 @@ def __init__( command=None, default=None, orient="vertical", + options_fill_extents: bool = False, max_per_line=None, **kwargs): """ @@ -26,6 +27,8 @@ def __init__( command (callable, optional): Called with selected value when changed. default (str, optional): Default selected value. orient (str): Either "vertical" or "horizontal". + options_fill_extents (bool): If `True`, radio option rows expand to + fill their available width in the options frame. max_per_line (int, optional): Maximum items per row/column before wrapping. item_title (str, optional): Optional header text shown at the top of the widget frame. helper_text (str, optional): Optional helper text shown above the entry box. @@ -36,6 +39,7 @@ def __init__( self.fields = [str(field) for field in (fields or [])] self.command = command self.orient = orient.lower() + self.options_fill_extents = self._normalise_bool(options_fill_extents) self.max_per_line = max_per_line self.value_var = tk.StringVar() self._buttons = [] @@ -56,6 +60,8 @@ def _build_buttons(self): for button in self._buttons: button.destroy() self._buttons.clear() + for index in range(self.buttons_frame.grid_size()[0] + 1): + self.buttons_frame.grid_columnconfigure(index, weight=0) for index, field in enumerate(self.fields): sticky = cast(str, getattr(self, "_grid_sticky", "w")) @@ -84,11 +90,35 @@ def _build_buttons(self): else: row = index % self.max_per_line column = index // self.max_per_line - button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky=sticky) + button.grid( + row=row, + column=column, + padx=(0, 10), + pady=(0, 4), + sticky=("ew" if self.options_fill_extents else sticky), + ) + if self.options_fill_extents: + self.buttons_frame.grid_columnconfigure(column, weight=1) elif self.orient == "horizontal": - button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky=sticky) + button.grid( + row=0, + column=index, + padx=(0, 10), + pady=(0, 4), + sticky=("ew" if self.options_fill_extents else sticky), + ) + if self.options_fill_extents: + self.buttons_frame.grid_columnconfigure(index, weight=1) else: - button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky=sticky) + button.grid( + row=index, + column=0, + padx=(0, 10), + pady=(0, 4), + sticky=("ew" if self.options_fill_extents else sticky), + ) + if self.options_fill_extents: + self.buttons_frame.grid_columnconfigure(0, weight=1) self._buttons.append(button) def _select_field(self, field): @@ -175,11 +205,12 @@ def on_selection(value): widget = RadioSelection( parent_frame, - fields=["Option A", "Option B", "Option C", "Option D", "Option E", "Option F", "Option G"], + fields=["Option A", "Option B", "Option C"], command=on_selection, default="Option B", alignment="center", - orient="vertical", + options_fill_extents=True, + orient="horizontal", max_per_line=3, item_title="Choose an Option", helper_text="Select one of the options below:", diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py index 0be8ff6..38665e4 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py @@ -1,52 +1,53 @@ -"""Window that displays a status message with a lightweight animated indicator.""" - import tkinter as tk from tkinter import ttk +import os + import time +import threading from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow -from python_toolkit.bhom_tkinter.widgets.label import Label - class ProcessingWindow(BHoMBaseWindow): - """A processing window with animated indicator, built on the BHoM window protocol.""" - - def __init__(self, title="Processing", message="Processing..."): - self.window_title = title - self.message_text = message - self.message_label = None - self.animation_label = None - self.current_frame = 0 - self.is_running = False - self._after_id = None - + """A simple processing window with animated indicator.""" + + def __init__(self, title="Processing", message="Processing...", *args, **kwargs): + """ + Args: + title (str): Window title. + message (str): Message to display. + """ super().__init__( title=title, min_width=300, min_height=150, - show_submit=False, + width=400, + height=200, + theme_mode="auto", show_close=False, - resizable=False, + show_submit=False, + *args, + **kwargs ) - - self.root = self + + self.title(title) self.attributes("-topmost", True) + self.resizable(False, False) - def build(self): - """Build processing labels and the animation indicator.""" - container = ttk.Frame(self.content_frame) - container.pack(fill=tk.BOTH, expand=True) + # Container + container = ttk.Frame(self, padding=20) + container.pack(fill="both", expand=True) - self.message_label = Label( + # Message label (to calculate size) + self.message_label = ttk.Label( container, - text=self.message_text, + text=message, style="Title.TLabel", justify="center", - wraplength=400, - alignment="center", + wraplength=400 ) - self.message_label.pack(fill=tk.X, pady=(0, 20)) + self.message_label.pack(pady=(0, 20)) + # Animation frame animation_frame = ttk.Frame(container) animation_frame.pack(expand=True) @@ -54,66 +55,135 @@ def build(self): animation_frame, text="●", style="Title.TLabel", - justify="center", - anchor="center", + foreground="#0078d4" ) self.animation_label.pack() - super().build() + # Animation state + self.animation_frames = ["●", "●", "●"] + self.current_frame = 0 + self.is_running = False + + # Update to calculate the required size + self.update_idletasks() + + # Get the required width and height + required_width = self.winfo_reqwidth() + required_height = self.winfo_reqheight() + + # Set minimum size + min_width = 300 + min_height = 150 + window_width = max(required_width, min_width) + window_height = max(required_height, min_height) + + # Center on screen + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + x = (screen_width - window_width) // 2 + y = (screen_height - window_height) // 2 + self.geometry(f"{window_width}x{window_height}+{x}+{y}") + def start(self): """Start the processing window and animation.""" if self.is_running: return self.is_running = True - self.current_frame = 0 - self._animate() + + # Run the Tk mainloop on the calling thread (must be main thread on many platforms). + try: + self._animate() + self.mainloop() + except Exception as e: + print("ProcessingWindow mainloop error:", e) + raise + + def start_with_worker(self, worker, args=(), kwargs=None): + """Start the GUI mainloop on this (main) thread and run `worker` in a background thread. + + The worker should not call Tkinter methods directly. When the worker finishes, + the window is closed via a call scheduled on the Tk event loop. + """ + if kwargs is None: + kwargs = {} + + if self.is_running: + return + self.is_running = True + + def run_worker(): + try: + worker(*args, **kwargs) + finally: + try: + self.after(0, self.stop) + except Exception: + pass + + t = threading.Thread(target=run_worker, daemon=True) + t.start() + + try: + self._animate() + self.mainloop() + except Exception as e: + print("ProcessingWindow mainloop error:", e) + raise def keep_alive(self): """Call this repeatedly to process animation updates. Returns False when done.""" - try: - if self.is_running and self.root.winfo_exists(): - self.root.update_idletasks() - self.root.update() - return True - except tk.TclError: - return False + if self.is_running and self.winfo_exists(): + self.update() + return True return False def stop(self): """Stop the animation and close the window.""" self.is_running = False - if self._after_id is not None: - try: - self.root.after_cancel(self._after_id) - except Exception: - pass - self._after_id = None - self.destroy_root() + try: + # Stop the mainloop if running and then destroy the window + if self.winfo_exists(): + try: + self.quit() + except Exception: + pass + try: + self.destroy() + except Exception: + pass + except Exception: + pass def _animate(self): """Update animation frames.""" - if self.is_running and self.root.winfo_exists(): + if self.is_running: + # Create rotating dot animation dots = ["◐", "◓", "◑", "◒"] self.animation_label.config(text=dots[self.current_frame % len(dots)]) self.current_frame += 1 - self._after_id = self.root.after(200, self._animate) + self.after(200, self._animate) def update_message(self, message: str): """Update the message text.""" - self.message_text = message - if self.root.winfo_exists() and self.message_label is not None: - self.message_label.set(message) - self.root.update_idletasks() - self.root.update() + try: + self.message_label.config(text=message) + # schedule an idle update so the UI refreshes promptly + self.update_idletasks() + except Exception: + pass if __name__ == "__main__": + # Test the processing window + processing = ProcessingWindow(title="Test Processing", message="Running Comfort and Safety Calculation...") - processing.start() - - for _ in range(50): - time.sleep(0.1) - processing.keep_alive() + def worker(): + for i in range(50): + time.sleep(0.1) + try: + processing.after(0, processing.update_message, f"Step {i+1}/50") + except Exception: + pass - processing.stop() + processing.start_with_worker(worker) From bd67979c485d109010b1e8ec1428b093a5c277b1 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Thu, 26 Feb 2026 11:15:57 +0000 Subject: [PATCH 24/28] updates to cmap selector & relative paths for images & icons --- .../bhom_tkinter/bhom_base_window.py | 16 +++++++---- .../bhom_tkinter/widgets/cmap_selector.py | 17 +++++++---- .../bhom_tkinter/widgets/label.py | 5 +++- .../bhom_tkinter/widgets/radio_selection.py | 2 +- .../src/python_toolkit/plot/cmap_sample.py | 28 ++++++++++++++----- 5 files changed, 48 insertions(+), 20 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 2c3547f..383efeb 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,6 +24,7 @@ from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets.button import Button +import python_toolkit class BHoMBaseWindow(tk.Tk): """ @@ -34,8 +35,8 @@ class BHoMBaseWindow(tk.Tk): def __init__( self, title: str = "Application", - logo_path: Path = Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\BHoM_Logo.png"), - icon_path: Path = Path(r"C:\ProgramData\BHoM\Extensions\PythonCode\Python_Toolkit\src\python_toolkit\bhom\assets\bhom_icon.png"), + logo_path: Path = Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "assets" / "BHoM_Logo.png", + icon_path: Path = Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "assets" / "bhom_icon.png", dark_logo_path: Optional[Path] = None, dark_icon_path: Optional[Path] = None, min_width: int = 500, @@ -51,11 +52,12 @@ def __init__( close_text: str = "Close", close_command: Optional[Callable] = None, on_close_window: Optional[Callable] = None, - theme_path: Path = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_light_theme.tcl"), - theme_path_dark: Path = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_dark_theme.tcl"), + theme_path: Path = Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "bhom_light_theme.tcl", + theme_path_dark: Path = Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "bhom_dark_theme.tcl", theme_mode: Literal["light", "dark", "auto"] = "auto", widgets: List[BHoMBaseWidget] = [], top_most: bool = True, + buttons_side: Literal["left", "right"] = "right", **kwargs ): """ @@ -80,6 +82,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"). + buttons_side (str): Side for buttons - "left" or "right" (default: "right"). **kwargs """ super().__init__(**kwargs) @@ -136,7 +139,7 @@ def __init__( # Bottom button frame (if needed) if show_submit or show_close: - self._build_buttons(self.main_container, show_submit, submit_text, show_close, close_text) + self._build_buttons(self.main_container, show_submit, submit_text, show_close, close_text, buttons_side) self._bind_dynamic_sizing() @@ -358,6 +361,7 @@ def _build_buttons( submit_text: str, show_close: bool, close_text: str, + buttons_side: Literal["left", "right"] = "right" ) -> None: """Build the bottom button bar. @@ -372,7 +376,7 @@ def _build_buttons( self.button_bar.pack(side=tk.BOTTOM, fill=tk.X) button_container = ttk.Frame(self.button_bar) - button_container.pack(anchor=tk.E) + button_container.pack(anchor=tk.E if buttons_side == "right" else tk.W) if show_close: close_widget = Button(button_container, text=close_text, command=self._on_close) 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 6c8c81c..9b20e07 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 @@ -61,6 +61,8 @@ def __init__( parent: ttk.Frame, colormaps: Optional[List[str]] = None, cmap_set: str = "all", + cmap_bins: int = 256, + default_cmap: Optional[str] = None, **kwargs ) -> None: """ @@ -72,10 +74,15 @@ def __init__( 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. **kwargs: Additional Frame options """ super().__init__(parent, **kwargs) + #set custom cmap args + self.cmap_bins = cmap_bins + self.default_cmap = default_cmap + # Create frame for cmap selection content self.cmap_frame = ttk.Frame(self.content_frame) self.cmap_frame.pack(side="top", fill="both", expand=True) @@ -196,9 +203,8 @@ def _select_default_cmap(self, colormaps: List[str]) -> None: self.figure_widget.clear() self.colormap_var.set("") return - - default_cmap = "viridis" if "viridis" in colormaps else colormaps[0] - self.colormap_var.set(default_cmap) + + self.colormap_var.set(self.default_cmap if self.default_cmap in colormaps else colormaps[0]) self._update_cmap_sample() def _on_cmap_selected(self, event=None) -> None: @@ -216,7 +222,7 @@ def _update_cmap_sample(self, *args) -> None: self.figure_widget.clear() return - fig = cmap_sample_plot(cmap_name, figsize=(4, 1)) + fig = cmap_sample_plot(cmap_name, figsize=(4, 1), bins = self.cmap_bins) self.figure_widget.embed_figure(fig) def get_selected_cmap(self) -> Optional[str]: @@ -273,7 +279,8 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warni cmap_set="all", item_title="Colormap Selector", helper_text="Select a colormap from the list.", - packing_options=PackingOptions(fill='both', expand=True) + packing_options=PackingOptions(fill='both', expand=True), + cmap_bins=2 ) cmap_selector.build() 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 5c10447..46b4369 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 @@ -66,7 +66,10 @@ def resolve(explicit_value, key: str, default=None): # Create inner ttk.Label with the collected options self.label = ttk.Label(self.content_frame, **label_options) self.align_child_text(self.label) - self.label.pack(side="top", anchor=self._pack_anchor) + # Allow the inner label to expand horizontally so parent frames + # using grid/pack with `fill='x'` will cause this label to fill + # the available width (useful for option rows that should stretch). + self.label.pack(side="top", anchor=self._pack_anchor, fill="x", expand=True) def get(self) -> str: """Return the current label text.""" 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 f8a9400..ac2eccf 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 @@ -214,7 +214,7 @@ def on_selection(value): max_per_line=3, item_title="Choose an Option", helper_text="Select one of the options below:", - packing_options=PackingOptions(padx=20, pady=20) + packing_options=PackingOptions(pady=20) ) widget.build() diff --git a/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py index a5cbf85..8907921 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py +++ b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py @@ -9,7 +9,8 @@ def cmap_sample_plot( cmap: Union[str, Colormap], bounds: Optional[tuple] = None, - figsize: tuple = (9, 1) + figsize: tuple = (9, 1), + bins: int = 256 ) -> Figure: """ @@ -19,7 +20,7 @@ def cmap_sample_plot( cmap: Either a colormap string (e.g., 'viridis') or a custom Colormap object bounds: Optional tuple of (vmin, vmax) for normalization. If None, uses (0, 1) figsize: Figure size as (width, height) - + bins: Number of discrete bins for the gradient. Defaults to 256. Returns: Matplotlib Figure object """ @@ -30,21 +31,34 @@ def cmap_sample_plot( vmin, vmax = bounds - # Create a gradient image with appropriate range - gradient = np.linspace(vmin, vmax, 256).reshape(1, -1) - + # Create a gradient image with appropriate range. + # Use endpoint=False to avoid an emphasized terminal color column at the + # right edge when rasterized into a small preview. + gradient_row = np.linspace(vmin, vmax, bins, endpoint=False) + gradient = np.vstack([gradient_row, gradient_row]) + # Create the figure and axis fig, ax = plt.subplots(figsize=figsize) fig.patch.set_alpha(0) fig.patch.set_facecolor("none") + fig.subplots_adjust(left=0, right=1, bottom=0, top=1) ax.patch.set_alpha(0) ax.set_facecolor("none") + ax.set_position([0, 0, 1, 1]) + ax.margins(x=0, y=0) # Create normalization if custom colormap or custom bounds norm = Normalize(vmin=vmin, vmax=vmax) # Display the gradient with the specified colormap - ax.imshow(gradient, aspect='auto', cmap=cmap, norm=norm) + ax.imshow( + gradient, + aspect="auto", + cmap=cmap, + norm=norm, + interpolation="nearest", + resample=False, + ) # Remove axes for a cleaner look ax.set_axis_off() @@ -56,7 +70,7 @@ def cmap_sample_plot( fig1 = cmap_sample_plot('viridis') # Example 2: Using a custom colormap with bounds - fig2 = cmap_sample_plot('plasma', bounds=(0, 100)) + fig2 = cmap_sample_plot('plasma', bounds=(0, 100), bins=6) # Example 3: Creating and using a custom colormap from matplotlib.colors import ListedColormap From d29aad98e799e1555c2241f6c6be6378986e1021 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 27 Feb 2026 09:42:59 +0000 Subject: [PATCH 25/28] small update --- .../bhom_tkinter/widgets/button.py | 3 ++- .../bhom_tkinter/widgets/cmap_selector.py | 2 +- .../bhom_tkinter/widgets/figure_container.py | 20 ++++++++++++------- 3 files changed, 16 insertions(+), 9 deletions(-) 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 45e1b6e..9e40e34 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 @@ -75,7 +75,8 @@ def demo_action(): command=demo_action, item_title="Demo Button", helper_text="A minimal clickable button.", - packing_options=PackingOptions(padx=20, pady=20), + packing_options=PackingOptions(anchor="w", padx=20, pady=20), + alignment="center", ) widget.build() 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 9b20e07..4a10c4c 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 @@ -280,7 +280,7 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warni item_title="Colormap Selector", helper_text="Select a colormap from the list.", packing_options=PackingOptions(fill='both', expand=True), - cmap_bins=2 + cmap_bins=6 ) cmap_selector.build() 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 7227e71..64ddf63 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 @@ -54,6 +54,15 @@ def _clear_children(self) -> None: self.canvas = None self.image_label = None + def _close_held_figure(self) -> None: + """Close any currently held matplotlib figure to release resources.""" + if self.figure is not None: + try: + plt.close(self.figure) + except Exception: + pass + self.figure = None + def _resolved_background(self) -> str: """Resolve a background colour suitable for embedded Tk canvas widgets. @@ -79,6 +88,7 @@ def embed_figure(self, figure: Figure) -> None: figure: Matplotlib Figure object to embed. """ + self._close_held_figure() self._clear_children() self.figure = figure @@ -99,6 +109,7 @@ def embed_image(self, image: tk.PhotoImage) -> None: Args: image: Tk PhotoImage object to embed """ + self._close_held_figure() self._clear_children() self.image = image @@ -116,6 +127,7 @@ def embed_image_file(self, file_path: str) -> None: file_path: Path to image file """ self.image_file = file_path + self._close_held_figure() self._clear_children() try: @@ -180,14 +192,8 @@ def _scale_image_to_fit(self): def clear(self) -> None: """Clear the figure container.""" + self._close_held_figure() self._clear_children() - # Close any held figure to free matplotlib resources - if self.figure is not None: - try: - plt.close(self.figure) - except Exception: - pass - self.figure = None self.image = None self.image_label = None self._original_pil_image = None From f7b86f78656399b099746b57c2129a736aace7ed Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Fri, 27 Feb 2026 09:55:00 +0000 Subject: [PATCH 26/28] added init to use bhom.mplstyle for plots --- .../Python/src/python_toolkit/__init__.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 Python_Engine/Python/src/python_toolkit/__init__.py diff --git a/Python_Engine/Python/src/python_toolkit/__init__.py b/Python_Engine/Python/src/python_toolkit/__init__.py new file mode 100644 index 0000000..3127261 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/__init__.py @@ -0,0 +1,24 @@ +"""Base module for the python_toolkit package.""" +# pylint: disable=E0401 +import getpass +import os +from pathlib import Path + +import matplotlib.pyplot as plt + +# pylint: disable=E0401 + +# get common paths +DATA_DIRECTORY = (Path(__file__).parent.parent / "data").absolute() +BHOM_DIRECTORY = (Path(__file__).parent / "bhom").absolute() +HOME_DIRECTORY = (Path("C:/Users/") / getpass.getuser()).absolute() + +TOOLKIT_NAME = "Python_Toolkit" + +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") From 72047efb0ced28b792cf7b0106f899fc6c1e10b7 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 27 Feb 2026 10:46:02 +0000 Subject: [PATCH 27/28] delete temp test files --- tmp_doc_audit.py | 36 ------------------------------------ tmp_label_test.py | 15 --------------- 2 files changed, 51 deletions(-) delete mode 100644 tmp_doc_audit.py delete mode 100644 tmp_label_test.py diff --git a/tmp_doc_audit.py b/tmp_doc_audit.py deleted file mode 100644 index ea4bad2..0000000 --- a/tmp_doc_audit.py +++ /dev/null @@ -1,36 +0,0 @@ -import ast -from pathlib import Path - -root = Path("c:/GitHub_Files/Python_Toolkit/Python_Engine/Python/src/python_toolkit/bhom_tkinter") -files = sorted(root.rglob("*.py")) - -for path in files: - rel = path.relative_to(root) - src = path.read_text(encoding="utf-8") - try: - tree = ast.parse(src) - except SyntaxError as error: - print(f"{rel}|SYNTAX_ERROR|{error}") - continue - - issues = [] - - if ast.get_docstring(tree) is None: - issues.append(("module", "")) - - for node in tree.body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - if not node.name.startswith("_") and ast.get_docstring(node) is None: - issues.append(("function", node.name)) - elif isinstance(node, ast.ClassDef): - if not node.name.startswith("_") and ast.get_docstring(node) is None: - issues.append(("class", node.name)) - for member in node.body: - if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)): - if member.name.startswith("_"): - continue - if ast.get_docstring(member) is None: - issues.append(("method", f"{node.name}.{member.name}")) - - for kind, name in issues: - print(f"{rel}|{kind}|{name}") diff --git a/tmp_label_test.py b/tmp_label_test.py deleted file mode 100644 index 0eb7341..0000000 --- a/tmp_label_test.py +++ /dev/null @@ -1,15 +0,0 @@ -from python_toolkit.bhom_tkinter.widgets.label import Label -from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow - -root = BHoMBaseWindow() -root.update() -label = Label(root.content_frame, text='initial') -label.build() -print('before:', label.label.cget('text')) -label.set('updated') -print('after set:', label.label.cget('text')) -frames=['◐','◓','◑','◒'] -for i,ch in enumerate(frames): - label.set(ch) - print(f'frame {i} =>', label.label.cget('text')) -root.destroy() From 28c9945578867b0a3b609dd626e7e8207e2aad79 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 27 Feb 2026 11:36:48 +0000 Subject: [PATCH 28/28] fixed font styling issue. removed duplicate tick box widgets --- .../bhom_tkinter/bhom_base_window.py | 95 +++++++- .../bhom_tkinter/widgets/__init__.py | 2 +- .../bhom_tkinter/widgets/_widgets_base.py | 12 + .../bhom_tkinter/widgets/button.py | 2 + .../widgets/check_box_selection.py | 2 +- .../bhom_tkinter/widgets/label.py | 10 + .../widgets/multi_box_selection.py | 224 ------------------ .../bhom_tkinter/widgets/radio_selection.py | 2 +- .../bhom_tkinter/windows/landing_page.py | 2 +- .../bhom_tkinter/windows/processing_window.py | 12 + Python_Engine/Python/tests/test_styling.py | 106 +++++++++ 11 files changed, 231 insertions(+), 238 deletions(-) delete mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py create mode 100644 Python_Engine/Python/tests/test_styling.py 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 383efeb..f6a48db 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 @@ -282,29 +282,85 @@ def _load_theme(self, _theme_path: Path) -> str: theme_path = Path(r"C:\GitHub_Files\Python_Toolkit\Python_Engine\Python\src\python_toolkit\bhom\bhom_style.tcl") if theme_path.exists(): + expected_theme = theme_path.stem.replace("_theme", "") # Load the TCL theme file - self.tk.call('source', str(theme_path)) + try: + self.tk.call('source', str(theme_path)) + except tk.TclError as source_error: + if "already exists" not in str(source_error).lower(): + raise available_theme_names = style.theme_names() newly_added = [name for name in available_theme_names if name not in current_themes] - selected_theme = newly_added[-1] if newly_added else (available_theme_names[0] if available_theme_names else "default") + if expected_theme in available_theme_names: + selected_theme = expected_theme + elif newly_added: + selected_theme = newly_added[-1] + else: + selected_theme = style.theme_use() if available_theme_names else "default" style.theme_use(selected_theme) + self._ensure_typography_styles(style) return selected_theme else: print(f"Warning: Theme file not found at {theme_path}") available_theme_names = style.theme_names() selected_theme = available_theme_names[0] if available_theme_names else "default" style.theme_use(selected_theme) + self._ensure_typography_styles(style) return selected_theme except Exception as e: print(f"Warning: Could not load custom theme: {e}") try: - return style.theme_use() + active_theme = style.theme_use() + self._ensure_typography_styles(style) + return active_theme except Exception: return "default" + def _ensure_typography_styles(self, style: ttk.Style) -> None: + """Ensure key typography styles exist and remain visually distinct.""" + defaults = { + "TLabel": ("Segoe UI", 10, "bold"), + "Body.TLabel": ("Segoe UI", 10), + "Caption.TLabel": ("Segoe UI", 9), + "Small.TLabel": ("Segoe UI", 8), + "Heading.TLabel": ("Segoe UI", 12, "bold"), + "Subtitle.TLabel": ("Segoe UI", 14, "bold"), + "Headline.TLabel": ("Segoe UI", 16, "bold"), + "Title.TLabel": ("Segoe UI", 24, "bold"), + "LargeTitle.TLabel": ("Segoe UI", 24, "bold"), + "Display.TLabel": ("Segoe UI", 28, "bold"), + } + + def _lookup_font(style_name: str) -> str: + try: + return str(style.lookup(style_name, "font") or "").strip() + except Exception: + return "" + + for style_name, font_spec in defaults.items(): + if not _lookup_font(style_name): + try: + style.configure(style_name, font=font_spec) + except Exception: + pass + + base_font = _lookup_font("TLabel") + for style_name, font_spec in ( + ("Caption.TLabel", defaults["Caption.TLabel"]), + ("Subtitle.TLabel", defaults["Subtitle.TLabel"]), + ("Headline.TLabel", defaults["Headline.TLabel"]), + ("LargeTitle.TLabel", defaults["LargeTitle.TLabel"]), + ): + resolved = _lookup_font(style_name) + if not resolved or resolved == base_font: + try: + style.configure(style_name, font=font_spec) + except Exception: + pass + def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path]) -> None: """Build the branded banner section. @@ -378,18 +434,31 @@ def _build_buttons( button_container = ttk.Frame(self.button_bar) button_container.pack(anchor=tk.E if buttons_side == "right" else tk.W) - if show_close: - close_widget = Button(button_container, text=close_text, command=self._on_close) - close_widget.pack(side=tk.LEFT, padx=5) - # expose inner ttk.Button for compatibility - self.close_button = close_widget.button - if show_submit: - submit_widget = Button(button_container, text=submit_text, command=self._on_submit) + submit_widget = Button( + button_container, + text=submit_text, + command=self._on_submit, + style="Primary.TButton", + width=12, + alignment="center", + ) submit_widget.pack(side=tk.LEFT, padx=5) # expose inner ttk.Button for compatibility self.submit_button = submit_widget.button + if show_close: + close_widget = Button( + button_container, + text=close_text, + command=self._on_close, + width=12, + alignment="center", + ) + close_widget.pack(side=tk.LEFT, padx=5) + # expose inner ttk.Button for compatibility + self.close_button = close_widget.button + def _bind_dynamic_sizing(self) -> None: """Bind layout changes to schedule auto sizing updates.""" self.main_container.bind("", self._schedule_dynamic_sizing) @@ -523,9 +592,15 @@ def _on_close_window(self, callback: Optional[Callable]) -> None: ### TEST SIMPLE + from python_toolkit.bhom_tkinter.widgets import Label, Button + test = BHoMBaseWindow( title="Test Window", theme_mode="dark", ) + test.widgets.append(Label(test.content_frame, text="Hello, World!")) + test.widgets.append(Button(test.content_frame, text="Click Me", command=lambda: print("Button Clicked!"), helper_text="This is a button.", item_title="Button Widget Title")) + + test.build() test.mainloop() 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 130ce0a..21aa253 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 @@ -7,7 +7,7 @@ from .drop_down_selection import DropDownSelection from .figure_container import FigureContainer from .list_box import ScrollableListBox -from .multi_box_selection import CheckboxSelection as MultiBoxSelection +from .check_box_selection import CheckboxSelection as MultiBoxSelection from .path_selector import PathSelector from .radio_selection import RadioSelection from .validated_entry_box import ValidatedEntryBox 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 82d7d62..cf26b48 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 @@ -64,6 +64,12 @@ def __init__( # Optional header/title label at the top of the widget if self.item_title: self.title_label = ttk.Label(self, text=self.item_title, style="Subtitle.TLabel") + try: + title_font = ttk.Style(self).lookup("Subtitle.TLabel", "font") + if title_font: + self.title_label.configure(font=title_font) + except Exception: + pass if self.fill_extents: self.title_label.pack(side="top", anchor=self._pack_anchor, fill=tk.X) else: @@ -73,6 +79,12 @@ def __init__( # Optional helper/requirements label above the input if self.helper_text: self.helper_label = ttk.Label(self, text=self.helper_text, style="Caption.TLabel") + try: + helper_font = ttk.Style(self).lookup("Caption.TLabel", "font") + if helper_font: + self.helper_label.configure(font=helper_font) + except Exception: + pass if self.fill_extents: self.helper_label.pack(side="top", anchor=self._pack_anchor, fill=tk.X) else: 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 9e40e34..63886c2 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 @@ -20,6 +20,7 @@ def __init__( text: str = "Click", command: Optional[Callable[[], None]] = None, width: int = 25, + style: Optional[str] = None, **kwargs, ): super().__init__(parent, **kwargs) @@ -32,6 +33,7 @@ def __init__( text=text, command=self._on_click, width=width, + style=style, ) self.button.pack(side="top", anchor=getattr(self, "_pack_anchor")) 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 a767a09..5ad1f7a 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 @@ -74,7 +74,7 @@ def _build_buttons(self): button = Label( self.buttons_frame, text=f"□ {field}", - style="Caption.TLabel", + style="Body.TLabel", ) getattr(self, "align_child_text")(button) # Make both the wrapper frame and the inner ttk.Label clickable 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 46b4369..9192b93 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 @@ -29,6 +29,7 @@ def __init__( compound=None, textvariable=None, foreground=None, + font=None, **kwargs): def resolve(explicit_value, key: str, default=None): fallback = kwargs.pop(key, default) @@ -51,6 +52,7 @@ def resolve(explicit_value, key: str, default=None): "compound": resolve(compound, "compound"), "textvariable": resolve(textvariable, "textvariable"), "foreground": resolve(foreground, "foreground"), + "font": resolve(font, "font"), } label_options = {key: value for key, value in label_options.items() if value is not None} @@ -63,6 +65,14 @@ def resolve(explicit_value, key: str, default=None): ) self.text = label_options.get("text", "") + style_name = label_options.get("style") + if "font" not in label_options and style_name: + try: + style_font = ttk.Style(self).lookup(style_name, "font") + if style_font: + 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) self.align_child_text(self.label) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py deleted file mode 100644 index 8755c46..0000000 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/multi_box_selection.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Multi-select checkbox widget with configurable orientation and wrapping.""" - -import tkinter as tk -from tkinter import ttk -from typing import Optional, List, Callable, Literal - -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.""" - - def __init__( - self, - parent, - fields=None, - command: Optional[Callable[[List[str]], None]] = None, - defaults: Optional[List[str]] = None, - orient="vertical", - max_per_line=None, - **kwargs): - """ - Args: - parent (tk.Widget): The parent widget. - fields (list, optional): List of selectable fields. - command (callable, optional): Called with list of selected values when changed. - defaults (list, optional): List of default selected values. - orient (str): Either "vertical" or "horizontal". - max_per_line (int, optional): Maximum items per row/column before wrapping. - item_title (str, optional): Optional header text shown at the top of the widget frame. - helper_text (str, optional): Optional helper text shown above the checkboxes. - **kwargs: Additional Frame options. - """ - super().__init__(parent, **kwargs) - - self.fields = [str(field) for field in (fields or [])] - self.command = command - self.orient = orient.lower() - self.max_per_line = max_per_line - self.value_vars = {} # Dictionary mapping field names to BooleanVars - self._buttons = [] - - # Sub-frame for checkbox controls - self.buttons_frame = ttk.Frame(self.content_frame) - self.buttons_frame.pack(side="top", fill="x", expand=True) - - self._build_buttons() - - if defaults: - self.set(defaults) - - def _build_buttons(self): - """Create checkboxes from current fields.""" - for button in self._buttons: - button.destroy() - self._buttons.clear() - self.value_vars.clear() - - for index, field in enumerate(self.fields): - var = tk.BooleanVar(value=False) - self.value_vars[field] = var - - button = Label( - self.buttons_frame, - text=f"□ {field}", - style="Caption.TLabel", - ) - getattr(self, "align_child_text")(button) - # Bind clicks on both the wrapper and the inner ttk.Label so clicks register - button.bind("", lambda _event, f=field: self._toggle_field(f)) - try: - button.label.configure(cursor="hand2") - except Exception: - pass - try: - button.label.bind("", lambda _event, 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 - column = index % self.max_per_line - else: - row = index % self.max_per_line - column = index // self.max_per_line - button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky=getattr(self, "_grid_sticky")) - elif self.orient == "horizontal": - button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky=getattr(self, "_grid_sticky")) - else: - button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky=getattr(self, "_grid_sticky")) - self._buttons.append(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.""" - for button in self._buttons: - button_text = button.get() - current_field = button_text[2:] - if current_field == field: - if self.value_vars[field].get(): - button.set(f"■ {field}") - else: - button.set(f"□ {field}") - break - - if self.command: - self.command(self.get()) - - def get(self) -> List[str]: - """Return a list of currently selected values. - - Returns: - List[str]: Selected field labels. - """ - return [field for field, var in self.value_vars.items() if var.get()] - - def set(self, value: List[str]): - """Set the selected values. - - Args: - value: Field names to mark as selected. - """ - values = [str(v) for v in (value or [])] - for field, var in self.value_vars.items(): - var.set(field in values) - - for button in self._buttons: - button_text = button.get() - current_field = button_text[2:] - if current_field in values: - button.set(f"■ {current_field}") - else: - button.set(f"□ {current_field}") - - def select_all(self): - """Select all checkboxes.""" - for var in self.value_vars.values(): - var.set(True) - for button in self._buttons: - button_text = button.get() - field = button_text[2:] - button.set(f"■ {field}") - if self.command: - self.command(self.get()) - - def deselect_all(self): - """Deselect all checkboxes.""" - for var in self.value_vars.values(): - var.set(False) - for button in self._buttons: - button_text = button.get() - field = button_text[2:] - button.set(f"□ {field}") - if self.command: - self.command(self.get()) - - def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): - """Replace the available fields and rebuild the widget. - - Args: - fields: New available field names. - defaults: Optional field names to preselect after rebuild. - """ - self.fields = [str(field) for field in (fields or [])] - self._build_buttons() - - if defaults: - self.set(defaults) - - def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: - """Validate checkbox state against available field definitions. - - 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 state. - """ - available_fields = set(self.fields) - selected_fields = set(self.get()) - invalid_fields = selected_fields - available_fields - if invalid_fields: - invalid_text = ", ".join(sorted(invalid_fields)) - return self.apply_validation((False, f"Invalid selected fields: {invalid_text}", "error")) - return self.apply_validation((True, None, None)) - -if __name__ == "__main__": - - from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow - from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions - - def on_selection(values): - """Print selected values in the standalone example.""" - print(f"Selected: {values}") - - root = BHoMBaseWindow() - parent_frame = root.content_frame - - widget = CheckboxSelection( - parent_frame, - fields=["Option A", "Option B", "Option C", "Option D", "Option E", "Option F", "Option G"], - command=on_selection, - defaults=["Option B", "Option D"], - orient="horizontal", - max_per_line=3, - item_title="Choose Options", - helper_text="Select one or more options below:", - packing_options=PackingOptions(padx=20, pady=20) - - ) - widget.build() - - # Add control buttons for demonstration - control_frame = ttk.Frame(parent_frame) - control_frame.pack(padx=20, pady=10) - - Button(control_frame, text="Select All", command=widget.select_all).pack(side="left", padx=5) - Button(control_frame, text="Deselect All", command=widget.deselect_all).pack(side="left", padx=5) - - root.mainloop() 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 ac2eccf..e6a92e9 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 @@ -69,7 +69,7 @@ def _build_buttons(self): button = Label( self.buttons_frame, text=f"○ {field}", - style="Caption.TLabel", + style="Body.TLabel", ) if callable(align_child_text): align_child_text(button) 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 3d2315d..2a34e5b 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 @@ -42,7 +42,7 @@ def __init__( def build(self): """Build landing-page content using the base window's content area.""" if self.header: - Label(self.content_frame, text=self.header, style="Header.TLabel").pack( + Label(self.content_frame, text=self.header, style="Headline.TLabel").pack( side="top", anchor="w", pady=(0, 10) ) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py index 38665e4..b958dfa 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py @@ -45,6 +45,12 @@ def __init__(self, title="Processing", message="Processing...", *args, **kwargs) justify="center", wraplength=400 ) + try: + title_font = ttk.Style(self).lookup("Title.TLabel", "font") + if title_font: + self.message_label.configure(font=title_font) + except Exception: + pass self.message_label.pack(pady=(0, 20)) # Animation frame @@ -57,6 +63,12 @@ def __init__(self, title="Processing", message="Processing...", *args, **kwargs) style="Title.TLabel", foreground="#0078d4" ) + try: + title_font = ttk.Style(self).lookup("Title.TLabel", "font") + if title_font: + self.animation_label.configure(font=title_font) + except Exception: + pass self.animation_label.pack() # Animation state diff --git a/Python_Engine/Python/tests/test_styling.py b/Python_Engine/Python/tests/test_styling.py new file mode 100644 index 0000000..a6b8e49 --- /dev/null +++ b/Python_Engine/Python/tests/test_styling.py @@ -0,0 +1,106 @@ +"""Typography style guide and verification sample. + +Run directly: + python tests/test_styling.py + +This opens a guide window with one label sample per typography style so it can +be used both as a visual reference and as a quick regression check. +""" + +from __future__ import annotations + +import os +from tkinter import ttk +from tkinter import font as tkfont + +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.widgets.label import Label as BHoMLabel + + +def _style_rows() -> list[tuple[str, str]]: + return [ + ("Display.TLabel", "Display — 0123456789"), + ("LargeTitle.TLabel", "LargeTitle sample"), + ("Title.TLabel", "Title sample"), + ("Headline.TLabel", "Headline sample"), + ("Subtitle.TLabel", "Subtitle sample"), + ("Heading.TLabel", "Heading sample"), + ("Body.TLabel", "Body sample"), + ("TLabel", "TLabel sample"), + ("Caption.TLabel", "Caption sample"), + ("Small.TLabel", "Small sample"), + ("Success.TLabel", "Success sample"), + ("Warning.TLabel", "Warning sample"), + ("Error.TLabel", "Error sample"), + ("Info.TLabel", "Info sample"), + ] + + +def run_styling_guide( + theme_mode: str = "dark", + print_metrics: bool = True, + auto_close_ms: int | None = None, +) -> None: + os.environ.setdefault("BHOM_TK_DEBUG_STYLES", "1") + + window = BHoMBaseWindow( + title="Typography Style Guide", + theme_mode=theme_mode, + show_submit=False, + show_close=True, + top_most=False, + min_width=780, + min_height=700, + ) + + style = ttk.Style() + rows = _style_rows() + + BHoMLabel( + window.content_frame, + text="Typography Guide (BHoM Label wrapper)", + style="Title.TLabel", + ).pack(anchor="w", pady=(2, 10)) + + BHoMLabel( + window.content_frame, + text=f"Theme: {style.theme_use()}", + style="Caption.TLabel", + ).pack(anchor="w", pady=(0, 12)) + + guide_rows: list[tuple[str, BHoMLabel]] = [] + for style_name, sample_text in rows: + row = ttk.Frame(window.content_frame) + row.pack(fill="x", anchor="w", pady=2) + + style_tag = BHoMLabel(row, text=style_name, style="Caption.TLabel", width=22) + style_tag.pack(side="left", anchor="w", padx=(0, 10)) + + sample = BHoMLabel(row, text=sample_text, style=style_name) + sample.pack(side="left", anchor="w") + guide_rows.append((style_name, sample)) + + window.update_idletasks() + + if print_metrics: + print("theme", style.theme_use()) + print("--- typography styles ---") + for style_name, sample in guide_rows: + font_lookup = style.lookup(style_name, "font") or style.lookup("TLabel", "font") + parsed = tkfont.Font(root=window, font=font_lookup) + print( + style_name, + "lookup=", font_lookup, + "actual-size=", parsed.actual("size"), + "linespace=", parsed.metrics("linespace"), + "rendered-height=", sample.label.winfo_reqheight(), + ) + + if auto_close_ms is not None and auto_close_ms > 0: + window.after(auto_close_ms, window.destroy) + + window.mainloop() + + +if __name__ == "__main__": + run_styling_guide()