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/__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") 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 0000000..3c28410 Binary files /dev/null and b/Python_Engine/Python/src/python_toolkit/bhom/assets/BHoM_Logo.png differ 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 0000000..a9a6a86 Binary files /dev/null and b/Python_Engine/Python/src/python_toolkit/bhom/assets/bhom_icon.png differ 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..89fb25d --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_dark_theme.tcl @@ -0,0 +1,698 @@ +# 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} + + 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) + + ttk::style configure Caption.TLabel \ + -font {{Segoe UI} 9} \ + -foreground $colors(-text-secondary) + + ttk::style configure Small.TLabel \ + -font {{Segoe UI} 8} \ + -foreground $colors(-text-secondary) + + 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 - clearer track and larger thumb + ttk::style configure TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-border) \ + -bordercolor $colors(-border-light) \ + -slidercolor $colors(-primary) \ + -borderwidth 1 \ + -sliderrelief raised \ + -sliderlength 20 + + ttk::style map TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + 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) \ + -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..dc80f08 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_light_theme.tcl @@ -0,0 +1,694 @@ +# 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} + + 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) + + ttk::style configure Caption.TLabel \ + -font {{Segoe UI} 9} \ + -foreground $colors(-text-secondary) + + ttk::style configure Small.TLabel \ + -font {{Segoe UI} 8} \ + -foreground $colors(-text-secondary) + + 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 - clearer track and larger thumb + ttk::style configure TScale \ + -background $colors(-primary) \ + -troughcolor $colors(-border-light) \ + -bordercolor $colors(-border) \ + -slidercolor $colors(-primary) \ + -borderwidth 1 \ + -sliderrelief raised \ + -sliderlength 20 + + ttk::style map TScale \ + -background [list \ + active $colors(-primary-light) \ + pressed $colors(-primary-hover)] \ + -slidercolor [list \ + 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) \ + -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/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 new file mode 100644 index 0000000..f6a48db --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py @@ -0,0 +1,606 @@ +"""Base themed Tk window used by BHoM toolkit GUI windows.""" + +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 +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 +from python_toolkit.bhom_tkinter.widgets.button import Button +import python_toolkit + +class BHoMBaseWindow(tk.Tk): + """ + 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: 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, + 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: 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 + ): + """ + Initialize the default root window. + + 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). + 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"). + buttons_side (str): Side for buttons - "left" or "right" (default: "right"). + **kwargs + """ + super().__init__(**kwargs) + self.title(title) + self._icon_image = None + 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 + self.withdraw() + + # Load custom themes + _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) + self.titlebar_theme = self._set_titlebar_theme(_theme_style) + + 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 + self._is_exiting = False + 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 + self.main_container = ttk.Frame(self) + self.main_container.pack(fill=tk.BOTH, expand=True) + + # Banner section + self._build_banner(self.main_container, title, _logo_path) + + # Content area (public access for adding widgets) + 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(self.main_container, show_submit, submit_text, show_close, close_text, buttons_side) + + self._bind_dynamic_sizing() + + # 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 Tk's default icon.""" + + if not icon_path.exists(): + print(f"Warning: Icon file not found at {icon_path}") + return + + # Windows prefers .ico for titlebar/taskbar icons. + if icon_path.suffix.lower() == ".ico": + try: + 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(icon_path)) + self.iconphoto(True, self._icon_image) + return + except tk.TclError: + pass + + except Exception as ex: + print(f"Warning: Could not set window icon from {icon_path}: {ex}") + + def _determine_theme( + self, + logo_path: Path, + dark_logo_path: Optional[Path], + icon_path: Path, + dark_icon_path: Optional[Path], + theme_mode: str, + theme_path_light: Path, + theme_path_dark: Path) -> tuple[Path, Path, Path, str]: + + """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" + + 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) -> str: + """ + Apply titlebar theme using Windows API. + + Args: + theme_style: Theme style key (`light` or `dark`). + + Returns: + str: Applied titlebar style key. + """ + 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 + 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: + return "light" + + def _load_theme(self, _theme_path: Path) -> str: + """ + 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. + + Returns: + str: Name of the theme that ended up being applied. + """ + 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 + else: + # 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(): + expected_theme = theme_path.stem.replace("_theme", "") + # Load the TCL theme file + 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] + 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: + 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. + + 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) + + banner_content = ttk.Frame(banner, padding=10) + 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((80, 80), Image.Resampling.LANCZOS) + self.logo_image = ImageTk.PhotoImage(img) + 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 = Label( + text_container, + text=title, + style="LargeTitle.TLabel" + ) + title_label.pack(anchor="w") + + # Subtitle + subtitle_label = 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, + buttons_side: Literal["left", "right"] = "right" + ) -> None: + """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) + + button_container = ttk.Frame(self.button_bar) + button_container.pack(anchor=tk.E if buttons_side == "right" else tk.W) + + if show_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) + 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 = 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 = 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, required_width) + final_height = max(self.min_height, self.fixed_height, required_height) + else: + # Dynamic sizing + final_width = max(self.min_width, required_width) + final_height = max(self.min_height, required_height) + + # Position + 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.""" + # Apply titlebar theme + self._set_titlebar_theme(self.titlebar_theme) + + # 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).""" + self._apply_sizing() + + def destroy_root(self) -> None: + """Safely terminate and destroy the Tk root window.""" + + try: + if self.winfo_exists(): + self.quit() + self.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. + + Args: + 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: + self.destroy_root() + + def _on_submit(self) -> None: + """Handle submit button click.""" + self._exit("submit", self.submit_command) + + def _on_close(self) -> None: + """Handle close button click.""" + self._exit("close", self.close_command) + + def _on_close_window(self, callback: Optional[Callable]) -> None: + """Handle window X button click.""" + self._exit("window_closed", callback) + + +if __name__ == "__main__": + + + ### 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 new file mode 100644 index 0000000..21aa253 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py @@ -0,0 +1,33 @@ +from ._packing_options import PackingOptions +from ._widgets_base import BHoMBaseWidget +from .widget_calendar 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 .check_box_selection import CheckboxSelection as MultiBoxSelection +from .path_selector import PathSelector +from .radio_selection import RadioSelection +from .validated_entry_box import ValidatedEntryBox +from .button import Button +from .label import Label + +__all__ = [ + "BHoMBaseWidget", + "PackingOptions", + "CalendarWidget", + "CheckboxSelection", + "MultiBoxSelection", + "CmapSelector", + "ColourPicker", + "DropDownSelection", + "FigureContainer", + "ScrollableListBox", + "PathSelector", + "RadioSelection", + "ValidatedEntryBox", + "Button", + "Label", +] \ 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 new file mode 100644 index 0000000..f75efd8 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py @@ -0,0 +1,33 @@ +"""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' + 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. + + 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__": + + 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..cf26b48 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py @@ -0,0 +1,335 @@ +"""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 +from typing import Optional, Literal, Union, Tuple, Callable + +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, + 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(), + **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. + 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)`. + disable_validation: When `True`, all validation returns valid. + **kwargs: Additional Frame options + """ + super().__init__(parent, **kwargs) + + 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) + self.fill_extents = self._normalise_bool(fill_extents) + self.custom_validation = custom_validation + self.disable_validation = bool(disable_validation) + + 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="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: + 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") + 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: + 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 _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. + + 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) + + 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.""" + raise NotImplementedError("Subclasses must implement the get() method.") + + @abstractmethod + def set(self, value): + """Set the value of the widget. + + Args: + 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, + build_type: Literal['pack', 'grid', 'place'] = 'pack', + **kwargs + ): + """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()) + + 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/bhom_tkinter/widgets/button.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/button.py new file mode 100644 index 0000000..63886c2 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/button.py @@ -0,0 +1,86 @@ +"""Simple button widget with action callback following BHoM toolkit patterns.""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable, Literal + +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, + style: Optional[str] = None, + **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, + style=style, + ) + 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) + + 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 + 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(anchor="w", padx=20, pady=20), + alignment="center", + ) + 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 new file mode 100644 index 0000000..5ad1f7a --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py @@ -0,0 +1,250 @@ +"""Checkbox selection widget with multi-select support and state helpers.""" + +import tkinter as tk +from tkinter import ttk +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 +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, + item_title: Optional[str] = None, + helper_text: Optional[str] = None, + min_selections: Optional[int] = None, + max_selections: Optional[int] = 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. + 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) + + 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 = [] + 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) + 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._field_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="Body.TLabel", + ) + 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": + 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) + self._field_buttons[field] = button + + def _toggle_field(self, field): + """Toggle a field's state when clicked.""" + self.value_vars[field].set(not self.value_vars[field].get()) + self._on_select_field(field) + + def _on_select_field(self, field): + """Handle checkbox selection change and update visual indicator.""" + button = self._field_buttons.get(field) + if button is not None: + if self.value_vars[field].get(): + button.set(f"■ {field}") + else: + button.set(f"□ {field}") + + 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) + + # Update visual indicators + for field, button in self._field_buttons.items(): + if field in values: + button.set(f"■ {field}") + else: + button.set(f"□ {field}") + + def select_all(self): + """Select all checkboxes.""" + for var in self.value_vars.values(): + var.set(True) + # Update visual indicators + for field, button in self._field_buttons.items(): + button.set(f"■ {field}") + if self.command: + self.command(self.get()) + + def deselect_all(self): + """Deselect all checkboxes.""" + for var in self.value_vars.values(): + var.set(False) + # Update visual indicators + for field, button in self._field_buttons.items(): + button.set(f"□ {field}") + if self.command: + self.command(self.get()) + + def toggle_all(self): + """Toggle all checkbox states.""" + for var in self.value_vars.values(): + var.set(not var.get()) + # Update visual indicators + for field, button in self._field_buttons.items(): + if self.value_vars[field].get(): + button.set(f"■ {field}") + else: + button.set(f"□ {field}") + if self.command: + self.command(self.get()) + + 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 pack(self, **kwargs): + """Pack the widget with the given options. + + Args: + **kwargs: Pack geometry manager options. + """ + super().pack(**kwargs) + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate the current selection against min/max constraints. + + 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 + 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="vertical", + max_per_line=6, + 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/cmap_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py new file mode 100644 index 0000000..4a10c4c --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py @@ -0,0 +1,287 @@ +"""Colormap selector widget with embedded matplotlib preview.""" + +from typing import Dict, List, Optional, Literal +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.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 + +class CmapSelector(BHoMBaseWidget): + """ + 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: ttk.Frame, + colormaps: Optional[List[str]] = None, + cmap_set: str = "all", + cmap_bins: int = 256, + default_cmap: Optional[str] = None, + **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". + 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) + + 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.cmap_frame.columnconfigure(0, weight=1) + self.cmap_frame.rowconfigure(0, weight=1) + + content = ttk.Frame(self.cmap_frame, 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()) + + 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, + 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 [])) + 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. + + Returns: + List[str]: Sorted list of available colormap names. + """ + # Base names available in this matplotlib build + base_names = set(mpl.colormaps()) + + # 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. + + 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 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: + 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. + + 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"])) + + 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 + + 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: + """Handle combobox selection changes.""" + self._update_cmap_sample() + + def _update_cmap_sample(self, *args) -> None: + """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() + return + + 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]: + """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. + + Returns: + Optional[str]: Current colormap name. + """ + return self.get_selected_cmap() + + def set(self, value: Optional[str]): + """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() + 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 + 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.", + packing_options=PackingOptions(fill='both', expand=True), + cmap_bins=6 + ) + cmap_selector.build() + + root.mainloop() \ No newline at end of file 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 new file mode 100644 index 0000000..715476c --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py @@ -0,0 +1,268 @@ +"""Colour picker widget with themed popup and live RGB/hex preview.""" + +import tkinter as tk +from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label +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 + +class ColourPicker(BHoMBaseWidget): + """A simple colour picker widget where the swatch opens a themed colour dialog.""" + + def __init__( + self, + parent: ttk.Frame, + default_colour: str = "#ffffff", + swatch_width: int = 48, + swatch_height: int = 28, + command: Optional[Callable[[str], None]] = 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. + **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: 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 + + controls = ttk.Frame(self.content_frame) + controls.pack(side="top", anchor=getattr(self, "_pack_anchor")) + + 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)) + 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)) + 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") + 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) + + 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) + + 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 = 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. + + 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. + + Args: + value: Colour value in hex format. + """ + 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 + 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( + parent_container, + 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.build() + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py new file mode 100644 index 0000000..6b1b6fe --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py @@ -0,0 +1,154 @@ +"""Dropdown selection widget built from ttk.Combobox.""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable, List, Literal + +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 + + 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__": + + 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/figure_container.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py new file mode 100644 index 0000000..64ddf63 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py @@ -0,0 +1,276 @@ +"""Container widget for embedding matplotlib figures or image content.""" + +import tkinter as tk +from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label +from typing import Optional, Any, Literal +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 + + +class FigureContainer(BHoMBaseWidget): + """ + A reusable widget for embedding matplotlib figures and images. + """ + + def __init__( + self, + parent: ttk.Frame, + **kwargs + ) -> None: + """ + Initialize the FigureContainer widget. + + Args: + parent: Parent widget + **kwargs: Additional Frame options + """ + super().__init__(parent, **kwargs) + + self.figure: Optional[Figure] = None + self.image: Optional[Any] = None + self.image_file: Optional[str] = None + self._original_pil_image: Optional[Any] = None + + self.canvas: Optional[FigureCanvasTkAgg] = None + self.image_label: Optional[ttk.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 the content frame only.""" + for widget in self.content_frame.winfo_children(): + widget.destroy() + 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. + + Returns: + str: Resolved background colour string. + """ + 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 container, replacing existing content. + + Args: + figure: Matplotlib Figure object to embed. + """ + + self._close_held_figure() + self._clear_children() + + 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 + """ + self._close_held_figure() + self._clear_children() + + self.image = image + self._original_pil_image = None + + # Create label to display the 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: + """ + Load and embed an image file, scaled to fit the container. + + Args: + file_path: Path to image file + """ + self.image_file = file_path + self._close_held_figure() + 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 = 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 = 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 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: + # `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 + + def clear(self) -> None: + """Clear the figure container.""" + self._close_held_figure() + self._clear_children() + self.image = None + self.image_label = None + self._original_pil_image = None + + def get(self): + """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: + return self.image + else: + return None + + def set(self, value): + """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): + 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.") + + 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__": + + 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 + + # Create figure container + figure_container = FigureContainer( + parent=parent_container, + item_title="Figure Container", + 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 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/label.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py new file mode 100644 index 0000000..9192b93 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py @@ -0,0 +1,147 @@ +from tkinter import ttk +from typing import Optional, Literal + +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, + font=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"), + "font": resolve(font, "font"), + } + 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", "") + 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) + # 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.""" + try: + # 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): + """Set the label text or image.""" + if isinstance(value, str): + 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.""" + self.set(new_text) + + # 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 + 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 new file mode 100644 index 0000000..5673254 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py @@ -0,0 +1,202 @@ +"""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, Literal + +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.""" + + def __init__( + self, + parent: ttk.Frame, + items=None, + selectmode=tk.MULTIPLE, + height=None, + show_selection_controls=False, + **kwargs): + """ + Args: + 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. + 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 + + # 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)) + + select_widget = Button(controls, text="Select All", command=self.select_all) + select_widget.pack(side=tk.LEFT) + self.select_all_button = select_widget.button + + 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.""" + 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. + + 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. + + 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. + + Returns: + tuple: Indices of selected entries. + """ + 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. + + 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. + + Args: + index: Position of item to delete. + """ + 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() + + def pack(self, **kwargs): + """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. + + 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. + + Returns: + 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__": + + 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)] + 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/bhom_tkinter/widgets/path_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py new file mode 100644 index 0000000..3342bbb --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py @@ -0,0 +1,143 @@ +"""Path selection widget for file or directory browsing.""" + +import tkinter as tk +from tkinter import filedialog, ttk +from pathlib import Path +from typing import Optional, Literal + +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.""" + + 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. + 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. + 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) + self.path_var = tk.StringVar() + self.command = command + self.mode = mode + self.initialdir = initialdir + self.filetypes = filetypes if filetypes is not None else [("All Files", "*.*")] + self.display_name = tk.StringVar() + self.entry = ttk.Entry(self.content_frame, textvariable=self.display_name, width=40) + self.entry.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True) + + # Use Button wrapper but expose inner ttk.Button for backward compatibility + 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": + 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) -> str: + """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. + + Args: + value: File or directory path to display. + """ + if not value: + self.path_var.set("") + self.display_name.set("") + return + + selected_path = Path(value) + self.path_var.set(str(selected_path)) + if self.mode == "directory": + self.display_name.set(str(selected_path)) + + else: + self.display_name.set(selected_path.name) + + 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__": + + 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, + 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/radio_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py new file mode 100644 index 0000000..e6a92e9 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py @@ -0,0 +1,221 @@ +"""Single-select radio-style widget built from clickable labels.""" + +import tkinter as tk +from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label +from typing import Optional, cast, Literal + +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", + options_fill_extents: bool = False, + 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". + 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. + **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.options_fill_extents = self._normalise_bool(options_fill_extents) + self.max_per_line = max_per_line + self.value_var = tk.StringVar() + self._buttons = [] + + # Sub-frame for radio button controls + self.buttons_frame = ttk.Frame(self.content_frame) + self.buttons_frame.pack(side="top", fill="x", expand=True) + + 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 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")) + align_child_text = getattr(self, "align_child_text", None) + button = Label( + self.buttons_frame, + text=f"○ {field}", + style="Body.TLabel", + ) + 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 + 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=("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=("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=("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): + """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.get() + current_field = button_text[2:] + if current_field == selected_value: + button.set(f"● {current_field}") + else: + button.set(f"○ {current_field}") + + def get(self): + """Return the currently selected value. + + Returns: + str: Currently selected field label. + """ + return self.value_var.get() + + def set(self, value): + """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. + + Args: + fields: New available field names. + default: Optional default field to select. + """ + 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("") + + 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__": + + 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() + parent_frame = root.content_frame + + widget = RadioSelection( + parent_frame, + fields=["Option A", "Option B", "Option C"], + command=on_selection, + default="Option B", + alignment="center", + options_fill_extents=True, + orient="horizontal", + max_per_line=3, + item_title="Choose an Option", + helper_text="Select one of the options below:", + packing_options=PackingOptions(pady=20) + ) + widget.build() + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py new file mode 100644 index 0000000..3a0fd30 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py @@ -0,0 +1,354 @@ +"""Validated entry widget supporting typed value and constraint checks.""" + +import tkinter as tk +from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label +from typing import Optional, Callable, Any, Union, Literal + +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. + + 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, + 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, + **kwargs + ) -> None: + """ + Initialize the ValidatedEntryBox. + + Args: + 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) + 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 + """ + super().__init__(parent, **kwargs) + 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 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.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 = 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 = 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. + + 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. + + Returns: + Optional[Union[str, int, float]]: Parsed value, or `None` when empty/invalid. + """ + 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. + + Args: + value: Value to display in the entry. + """ + self.variable.set(str(value)) + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """ + Validate the current entry value. + + Returns: + 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) + 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) + base_result = (True, None, None) + return self.apply_validation(base_result) + + # Type-specific validation + if self.value_type == str: + is_valid = self._validate_string(value_str) + elif self.value_type == int: + is_valid = self._validate_int(value_str) + elif self.value_type == float: + is_valid = self._validate_float(value_str) + else: + self._show_error(f"Unsupported type: {self.value_type}") + self._call_validate_callback(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. + + 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}") + 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. + + Args: + value_str: Raw entry text to parse as integer. + + Returns: + bool: `True` when valid, otherwise `False`. + """ + 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. + + Args: + value_str: Raw entry text to parse as float. + + Returns: + bool: `True` when valid, otherwise `False`. + """ + 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.set(message) + self.error_label.label.configure(foreground="#ff4444") + self.success_label.set(" ") + + def _show_success(self) -> None: + """Display success indicator.""" + self.error_label.set(" ") + self.success_label.set("✓") + + def clear_error(self) -> None: + """Clear the error message.""" + self.error_label.set(" ") + self.success_label.set(" ") + + 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 + + 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( + 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, + packing_options=PackingOptions(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/widget_calendar.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py new file mode 100644 index 0000000..7bf138a --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py @@ -0,0 +1,231 @@ +"""Calendar date-picker widget with optional year selector.""" + +import tkinter as tk +from typing import Optional, Literal +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.""" + + 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, + 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) + + 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.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") + + self.month_frame = ttk.Frame(self.content_frame) + self.month_frame.pack(side="top", fill="x") + + self.date_frame = ttk.Frame(self.content_frame) + 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): + """Build the year dropdown selector.""" + year_var = tk.StringVar() + year_var.set(str(self.year)) + + 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) + + 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() + 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): + """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 = 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) + + 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_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.button.configure(style=self.day_button_style, state=state) + 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. + + Args: + num: Day of month to mark as selected. + """ + self.day = num + + for child in self.date_frame.winfo_children(): + child.destroy() + + date = self.months[self.month-1] + " " + str(self.day) + 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) + + 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 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. + + 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") + + # Example without year selector + cal_widget1 = CalendarWidget( + root.content_frame, + def_year=2024, + def_month=6, + def_day=15, + 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) + ) + cal_widget1.build() + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py 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/directory_file_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py new file mode 100644 index 0000000..6408335 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py @@ -0,0 +1,116 @@ +"""Window for selecting multiple files by extension from a directory tree.""" + +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 + +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 + +class DirectoryFileSelector(BHoMBaseWindow): + """Display matching files and return the user's multi-selection.""" + + 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: 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 + + 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): + """Build the list-based file selection UI.""" + instruction_label = Label( + self.content_frame, + text=f"Select the {self.selection_label} to analyse.", + ) + instruction_label.pack(anchor="w", pady=(0, 10)) + + 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) + + super().build() + + 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 _on_submit(self): + """Handle OK button - capture selection before window closes.""" + 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() + + def _on_cancel(self): + """Handle Cancel button or window close.""" + self._cancelled = True + self.destroy_root() + +if __name__ == "__main__": + # Example usage + selector = DirectoryFileSelector( + directory=Path.home(), + file_types=[".py", ".txt"], + selection_label="scripts and text 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/bhom_tkinter/windows/landing_page.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py new file mode 100644 index 0000000..2a34e5b --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py @@ -0,0 +1,105 @@ +"""Landing page window with configurable header, message text, and custom actions.""" + +import tkinter as tk +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): + """ + A reusable landing page GUI with configurable title, message, and buttons. + Uses BHoMBaseWindow as the base template. + """ + + def __init__( + self, + title: str = "Landing Page", + header: Optional[str] = None, + message: Optional[str] = None, + sub_title: Optional[str] = None, + **kwargs, + ): + """ + Initializes the landing page GUI. + + Args: + title (str): Window and header title text. + message (str, optional): Commentary/message text to display. + """ + self.header = header + self.message = message + self.sub_title = sub_title + self.custom_buttons_frame: Optional[ttk.Frame] = None + + super().__init__( + title=title, + **kwargs, + ) + + 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="Headline.TLabel").pack( + side="top", anchor="w", pady=(0, 10) + ) + + if self.message: + Label( + self.content_frame, + text=self.message, + style="Body.TLabel", + justify=tk.LEFT, + ).pack(side="top", anchor="w", pady=(0, 10)) + + if self.sub_title: + Label(self.content_frame, text=self.sub_title, style="Caption.TLabel").pack( + side="top", anchor="w", pady=(0, 10) + ) + + 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. + + 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. + """ + 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_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 inner ttk.Button for compatibility + return button_widget.button + + +if __name__ == "__main__": + + #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( + 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/processing_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py new file mode 100644 index 0000000..b958dfa --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py @@ -0,0 +1,201 @@ +import tkinter as tk +from tkinter import ttk +import os + +import time +import threading + +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + +class ProcessingWindow(BHoMBaseWindow): + """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, + width=400, + height=200, + theme_mode="auto", + show_close=False, + show_submit=False, + *args, + **kwargs + ) + + self.title(title) + self.attributes("-topmost", True) + self.resizable(False, False) + + # Container + container = ttk.Frame(self, padding=20) + container.pack(fill="both", expand=True) + + # Message label (to calculate size) + self.message_label = ttk.Label( + container, + text=message, + style="Title.TLabel", + 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 + animation_frame = ttk.Frame(container) + animation_frame.pack(expand=True) + + self.animation_label = ttk.Label( + animation_frame, + text="●", + 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 + 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 + + # 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.""" + 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 + 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: + # Create rotating dot animation + dots = ["◐", "◓", "◑", "◒"] + 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.""" + 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...") + 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.start_with_worker(worker) 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 new file mode 100644 index 0000000..d18ca53 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py @@ -0,0 +1,98 @@ +"""Standardized warning dialog window for errors, warnings, and info messages.""" + +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 + + +class WarningBox(BHoMBaseWindow): + """Show categorized messages using the shared BHoM window styling.""" + + def __init__( + self, + title: str= 'Warning', + 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, + close_text="Continue", + min_width=250, + min_height=150, + **kwargs + ) + + def build(self): + """Render current messages into the content area.""" + self._render_messages() + super().build() + + 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 _render_messages(self): + for widget in self.content_frame.winfo_children(): + widget.destroy() + + for message in self.errors: + 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: + 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: + 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. + + 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. + + 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. + + Args: + message: Informational text to append. + """ + 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 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..8907921 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/plot/cmap_sample.py @@ -0,0 +1,81 @@ +from matplotlib import pyplot as plt +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, + figsize: tuple = (9, 1), + bins: int = 256 +) -> 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) + bins: Number of discrete bins for the gradient. Defaults to 256. + Returns: + Matplotlib Figure object + """ + + # Set bounds + if bounds is None: + bounds = (0, 1) + + vmin, vmax = bounds + + # 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, + interpolation="nearest", + resample=False, + ) + + # 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), bins=6) + + # 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/tests/test_bhom_tkinter_ui.py b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py new file mode 100644 index 0000000..fd7d62b --- /dev/null +++ b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py @@ -0,0 +1,278 @@ +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() + + +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/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()