diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py index 9e615fd..a1ffc69 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py @@ -10,7 +10,9 @@ import python_toolkit 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._slim_scrollbar import SlimScrollbar, blend from python_toolkit.bhom_tkinter.theming.theme import ThemeManager +from python_toolkit.bhom_tkinter.theming.scaling import init_scaling, scale, get_work_area from python_toolkit.bhom import BHOM_VERSION @@ -47,6 +49,12 @@ def _init_bhom_shell( content_padding: int = 20, ) -> None: self.defer_show = defer_show + # Must run before any widget/theme is created so pixel values use the right factor. + init_scaling(self) + min_width, min_height = scale(min_width), scale(min_height) + width = scale(width) if width is not None else None + height = scale(height) if height is not None else None + content_padding = scale(content_padding) self.title(title) self._icon_image = None self.minsize(min_width, min_height) @@ -95,8 +103,7 @@ def _init_bhom_shell( if show_banner: self._build_banner(self.main_container, title, self.theme.logo_path) - self.content_frame = ttk.Frame(self.main_container, padding=content_padding) - self.content_frame.pack(fill=tk.BOTH, expand=True) + self._build_scroll_host(content_padding) if self.grid_dimensions: self.grid_content_frame(*self.grid_dimensions) @@ -286,16 +293,17 @@ def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path] logo_path: Optional logo image path. """ banner = ttk.Frame(parent, relief=tk.RIDGE, borderwidth=1) + self._banner = banner banner.pack(fill=tk.BOTH, padx=0, pady=0) - banner_content = ttk.Frame(banner, padding=10) + banner_content = ttk.Frame(banner, padding=scale(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 = ttk.Frame(banner_content, width=scale(80)) logo_container.pack(side=tk.RIGHT, fill=tk.Y) # Logo (if provided) @@ -303,7 +311,7 @@ def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path] try: from PIL import Image, ImageTk img = Image.open(logo_path) - img.thumbnail((80, 80), Image.Resampling.LANCZOS) + img.thumbnail((scale(80), scale(80)), Image.Resampling.LANCZOS) # Bind image to this root explicitly to avoid stale image handles # when previous runs failed and tore down a different Tk interpreter. self.logo_image = ImageTk.PhotoImage(img, master=self) @@ -348,8 +356,8 @@ def _build_buttons( 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) + self.button_bar = ttk.Frame(parent, padding=(scale(20), scale(10))) + self.button_bar.pack(side=tk.BOTTOM, fill=tk.X, before=self._scroll_host) button_container = ttk.Frame(self.button_bar) button_container.pack(anchor=tk.E if buttons_side == "right" else tk.W) @@ -363,7 +371,7 @@ def _build_buttons( width=12, alignment="center", ) - submit_widget.pack(side=tk.LEFT, padx=5) + submit_widget.pack(side=tk.LEFT, padx=scale(5)) # expose inner ttk.Button for compatibility self.submit_button = submit_widget.button @@ -375,7 +383,7 @@ def _build_buttons( width=12, alignment="center", ) - close_widget.pack(side=tk.LEFT, padx=5) + close_widget.pack(side=tk.LEFT, padx=scale(5)) # expose inner ttk.Button for compatibility self.close_button = close_widget.button @@ -403,11 +411,147 @@ def _schedule_dynamic_sizing(self, _event=None) -> None: pass self._pending_resize_job = self.after(30, self._apply_sizing) + def _build_scroll_host(self, content_padding: int) -> None: + """Create the content area inside a scroll host. + + The content normally sizes the window. If it cannot fit on screen (e.g. at + high display scales) the host caps its height and shows a scrollbar, so the + button bar below always stays reachable. + """ + self._scroll_host = ttk.Frame(self.main_container) + self._scroll_host.pack(fill=tk.BOTH, expand=True) + + background = ttk.Style().lookup("TFrame", "background") or self.cget("background") + self._scroll_canvas = tk.Canvas( + self._scroll_host, highlightthickness=0, borderwidth=0, background=background + ) + background = ttk.Style().lookup("TFrame", "background") or "#1e1e1e" + foreground = ttk.Style().lookup("TLabel", "foreground") or "#cccccc" + try: + colours = { + "track": background, + "thumb": blend(background, foreground, 0.30), + "active": blend(background, foreground, 0.55), + } + except Exception: + colours = {} + bar_options = dict(thickness=scale(12), padding=scale(3), **colours) + self._scroll_bar = SlimScrollbar( + self._scroll_host, tk.VERTICAL, self._scroll_canvas.yview, **bar_options + ) + self._scroll_hbar = SlimScrollbar( + self._scroll_host, tk.HORIZONTAL, self._scroll_canvas.xview, **bar_options + ) + self._scroll_canvas.configure( + yscrollcommand=self._scroll_bar.set, xscrollcommand=self._scroll_hbar.set + ) + self._scroll_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self._scroll_bar_visible = False + self._scroll_hbar_visible = False + + self.content_frame = ttk.Frame(self._scroll_canvas, padding=content_padding) + self._content_window = self._scroll_canvas.create_window( + (0, 0), window=self.content_frame, anchor="nw" + ) + self._scroll_canvas.bind("", self._on_scroll_canvas_configure) + self.content_frame.bind("", self._on_content_configure, add="+") + self.bind_all("", self._on_mousewheel, add="+") + + def _on_content_configure(self, _event=None) -> None: + self._update_scroll_region() + + def _update_scroll_region(self) -> None: + """Scroll region always covers the content or the viewport, whichever is larger.""" + self._scroll_canvas.configure( + scrollregion=( + 0, + 0, + max(self._scroll_canvas.winfo_width(), self.content_frame.winfo_reqwidth()), + max(self._scroll_canvas.winfo_height(), self.content_frame.winfo_reqheight()), + ) + ) + + def _on_scroll_canvas_configure(self, event=None) -> None: + """Keep the content at least as large as the viewport and toggle the scrollbar.""" + width = self._scroll_canvas.winfo_width() + height = self._scroll_canvas.winfo_height() + req_height = self.content_frame.winfo_reqheight() + self._scroll_canvas.itemconfigure( + self._content_window, + width=max(width, self.content_frame.winfo_reqwidth()), + height=max(height, req_height), + ) + self._update_scroll_region() + self._scroll_host.update_idletasks() + req_width = self.content_frame.winfo_reqwidth() + # The bars take space from the viewport, so judge against the full host size. + host_width = self._scroll_host.winfo_width() + host_height = self._scroll_host.winfo_height() + need_h = req_width > host_width + 1 + bar_h = self._scroll_hbar.winfo_reqheight() if need_h else 0 + need_v = req_height > host_height - bar_h + 1 + bar_w = self._scroll_bar.winfo_reqwidth() if need_v else 0 + need_h = req_width > host_width - bar_w + 1 + + if need_h != self._scroll_hbar_visible: + if need_h: + self._scroll_hbar.pack(side=tk.BOTTOM, fill=tk.X, before=self._scroll_canvas) + else: + self._scroll_hbar.pack_forget() + self._scroll_canvas.xview_moveto(0) + self._scroll_hbar_visible = need_h + if need_v != self._scroll_bar_visible: + if need_v: + self._scroll_bar.pack(side=tk.RIGHT, fill=tk.Y, before=self._scroll_canvas) + else: + self._scroll_bar.pack_forget() + self._scroll_canvas.yview_moveto(0) + self._scroll_bar_visible = need_v + + def _on_mousewheel(self, event) -> None: + if not self._scroll_bar_visible: + return + # Let widgets with their own scrolling (list boxes, text, popdowns) handle it. + if isinstance(event.widget, (tk.Listbox, tk.Text)) or "popdown" in str(event.widget): + return + try: + if not str(event.widget).startswith(str(self)): + return + except Exception: + return + self._scroll_canvas.yview_scroll(int(-event.delta / 120), "units") + + def _update_scroll_host(self) -> None: + """Request a viewport sized to the content, capped to the usable screen height.""" + self.update_idletasks() + content_width = self.content_frame.winfo_reqwidth() + content_height = self.content_frame.winfo_reqheight() + + work_width, work_height = get_work_area(self) + chrome_height = scale(40) # title bar + borders + for part in (getattr(self, "button_bar", None),): + if part is not None and part.winfo_manager(): + chrome_height += part.winfo_reqheight() + banner = getattr(self, "_banner", None) + if banner is not None: + chrome_height += banner.winfo_reqheight() + + viewport_height = max(scale(120), min(content_height, work_height - chrome_height)) + viewport_width = min(content_width, work_width - scale(10)) + # Reserve room for scrollbars that will be needed. + if content_width > viewport_width: + viewport_height = max(scale(120), viewport_height - self._scroll_hbar.winfo_reqheight()) + if content_height > viewport_height: + viewport_width = min(content_width + self._scroll_bar.winfo_reqwidth(), work_width - scale(10)) + self._scroll_canvas.configure(width=viewport_width, height=viewport_height) + self._update_scroll_region() + def _apply_sizing(self) -> None: """Apply window sizing and positioning.""" self._pending_resize_job = None self._is_resizing = True self.update_idletasks() + self._update_scroll_host() required_width = self.winfo_reqwidth() required_height = self.winfo_reqheight() @@ -417,7 +561,7 @@ def _apply_sizing(self) -> None: 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()) + required_height = max(required_height, self.button_bar.winfo_reqheight() + self._scroll_canvas.winfo_reqheight()) # Determine final dimensions: auto-size unless a rigid dimension is explicitly provided. if self._rigid_width: @@ -430,6 +574,10 @@ def _apply_sizing(self) -> None: else: final_height = max(self.min_height, required_height) + # Never exceed the usable screen area (scaled windows can outgrow small displays). + final_width = min(final_width, self.winfo_screenwidth()) + final_height = min(final_height, self.winfo_screenheight() - scale(40)) + # Fullscreen overrides normal sizing/positioning if self.fullscreen: self.attributes("-fullscreen", True) @@ -480,7 +628,7 @@ def _apply_post_show_sizing(self) -> None: 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()) + required_height = max(required_height, self.button_bar.winfo_reqheight() + self._scroll_canvas.winfo_reqheight()) current_width = self.winfo_width() current_height = self.winfo_height() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl index cf3798f..52b487e 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl @@ -7,6 +7,18 @@ # ============================================================================ # Split theme file generated from bhom_style.tcl +# DPI scaling: pixel values are authored for 96 DPI. `::bhom_scale` is set from +# Python (see theming/scaling.py) before this file is sourced. +if {![info exists ::bhom_scale]} { set ::bhom_scale 1.0 } +if {![llength [info commands ::bhom_px]]} { + proc ::bhom_px {n} { + if {$n == 0} { return 0 } + set v [expr {int(round($n * $::bhom_scale))}] + if {$v == 0} { return [expr {$n > 0 ? 1 : -1}] } + return $v + } +} + namespace eval ttk::theme::bhom_dark { variable colors array set colors { @@ -76,32 +88,32 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure Display.TLabel \ -font {{Segoe UI} 20 bold} \ -foreground $colors(-primary) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure LargeTitle.TLabel \ -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure Title.TLabel \ -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure Headline.TLabel \ -font {{Segoe UI} 14 bold} \ -foreground $colors(-primary) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure Subtitle.TLabel \ -font {{Segoe UI} 12 bold} \ -foreground $colors(-fg) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure Heading.TLabel \ -font {{Segoe UI} 12 bold} \ -foreground $colors(-primary) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure Body.TLabel \ -font {{Segoe UI} 10} \ @@ -142,19 +154,19 @@ namespace eval ttk::theme::bhom_dark { -darkcolor $colors(-border) \ -borderwidth 1 \ -focuscolor "" \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat # Large Button variant ttk::style configure Large.TButton \ -font {{Segoe UI} 12 bold} \ - -padding {14 8} \ + -padding [list [::bhom_px 14] [::bhom_px 8]] \ -borderwidth 1 # Small Button variant ttk::style configure Small.TButton \ -font {{Segoe UI} 8 bold} \ - -padding {8 4} \ + -padding [list [::bhom_px 8] [::bhom_px 4]] \ -borderwidth 1 ttk::style map TButton \ @@ -186,7 +198,7 @@ namespace eval ttk::theme::bhom_dark { -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ -borderwidth 1 \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat ttk::style map Primary.TButton \ @@ -212,7 +224,7 @@ namespace eval ttk::theme::bhom_dark { -lightcolor $colors(-secondary) \ -darkcolor $colors(-secondary-hover) \ -borderwidth 1 \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat ttk::style map Secondary.TButton \ @@ -232,7 +244,7 @@ namespace eval ttk::theme::bhom_dark { -lightcolor $colors(-tertiary) \ -darkcolor "#9fad00" \ -borderwidth 1 \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat ttk::style map Accent.TButton \ @@ -254,7 +266,7 @@ namespace eval ttk::theme::bhom_dark { -lightcolor $colors(-success) \ -darkcolor "#1e9038" \ -borderwidth 1 \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat ttk::style map Success.TButton \ @@ -271,7 +283,7 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-bg) \ -foreground $colors(-info) \ -borderwidth 0 \ - -padding {10 4} \ + -padding [list [::bhom_px 10] [::bhom_px 4]] \ -relief flat ttk::style map Link.TButton \ @@ -289,7 +301,7 @@ namespace eval ttk::theme::bhom_dark { -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ -borderwidth 1 \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat ttk::style map Outline.TButton \ @@ -308,7 +320,7 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-bg) \ -foreground $colors(-primary) \ -borderwidth 0 \ - -padding {10 4} + -padding [list [::bhom_px 10] [::bhom_px 4]] ttk::style map Text.TButton \ -background [list \ @@ -325,7 +337,7 @@ namespace eval ttk::theme::bhom_dark { -lightcolor $colors(-border) \ -darkcolor $colors(-hover-bg) \ -insertcolor $colors(-fg) \ - -padding {8 5} \ + -padding [list [::bhom_px 8] [::bhom_px 5]] \ -borderwidth 1 \ -relief sunken @@ -346,7 +358,7 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-bg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {8 5} \ + -padding [list [::bhom_px 8] [::bhom_px 5]] \ -borderwidth 1 \ -relief sunken @@ -366,10 +378,10 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-bg) \ -foreground $colors(-fg) \ -font {{Segoe UI} 10} \ - -padding {6 4} \ + -padding [list [::bhom_px 6] [::bhom_px 4]] \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 6 0} \ + -indicatormargin [list [::bhom_px 0] [::bhom_px 0] [::bhom_px 6] [::bhom_px 0]] \ -borderwidth 0 \ -relief flat @@ -395,10 +407,10 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure Checkbox.TCheckbutton \ -font {{Segoe UI} 10} \ - -padding {4 4} \ - -indicatormargin {0 0 6 0} \ + -padding [list [::bhom_px 4] [::bhom_px 4]] \ + -indicatormargin [list [::bhom_px 0] [::bhom_px 0] [::bhom_px 6] [::bhom_px 0]] \ -indicatorrelief flat \ - -indicatorsize 16 \ + -indicatorsize [::bhom_px 16] \ -borderwidth 0 \ -relief flat \ -focusthickness 0 \ @@ -424,10 +436,10 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-bg) \ -foreground $colors(-fg) \ -font {{Segoe UI} 10} \ - -padding {6 4} \ + -padding [list [::bhom_px 6] [::bhom_px 4]] \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 6 0} \ + -indicatormargin [list [::bhom_px 0] [::bhom_px 0] [::bhom_px 6] [::bhom_px 0]] \ -borderwidth 0 \ -relief flat @@ -454,9 +466,9 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure Radio.TRadiobutton \ -font {{Segoe UI} 10} \ - -padding {4 4} \ - -indicatormargin {0 0 6 0} \ - -indicatorsize 15 \ + -padding [list [::bhom_px 4] [::bhom_px 4]] \ + -indicatormargin [list [::bhom_px 0] [::bhom_px 0] [::bhom_px 6] [::bhom_px 0]] \ + -indicatorsize [::bhom_px 15] \ -borderwidth 0 \ -relief flat \ -focusthickness 0 \ @@ -497,7 +509,7 @@ namespace eval ttk::theme::bhom_dark { -arrowsize 0 \ -borderwidth 0 \ -relief flat \ - -width 8 + -width [::bhom_px 8] ttk::style map TScrollbar \ -background [list \ @@ -510,7 +522,7 @@ namespace eval ttk::theme::bhom_dark { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 8 + -width [::bhom_px 8] ttk::style map Vertical.TScrollbar \ -background [list \ @@ -523,7 +535,7 @@ namespace eval ttk::theme::bhom_dark { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 8 + -width [::bhom_px 8] ttk::style map Horizontal.TScrollbar \ -background [list \ @@ -538,7 +550,7 @@ namespace eval ttk::theme::bhom_dark { -slidercolor $colors(-primary) \ -borderwidth 1 \ -sliderrelief raised \ - -sliderlength 20 + -sliderlength [::bhom_px 20] ttk::style map TScale \ -background [list \ @@ -556,7 +568,7 @@ namespace eval ttk::theme::bhom_dark { -slidercolor $colors(-primary-light) \ -borderwidth 1 \ -sliderrelief raised \ - -sliderlength 24 + -sliderlength [::bhom_px 24] ttk::style map ColourPicker.Horizontal.TScale \ -background [list \ @@ -574,7 +586,7 @@ namespace eval ttk::theme::bhom_dark { -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ -borderwidth 1 \ - -thickness 16 \ + -thickness [::bhom_px 16] \ -relief flat # Notebook - soft rounded tabs @@ -589,7 +601,7 @@ namespace eval ttk::theme::bhom_dark { -foreground $colors(-text-secondary) \ -bordercolor $colors(-border-light) \ -font {{Segoe UI} 10 bold} \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -borderwidth 1 ttk::style map TNotebook.Tab \ @@ -612,7 +624,7 @@ namespace eval ttk::theme::bhom_dark { -darkcolor $colors(-border) \ -borderwidth 1 \ -rowheight 26 \ - -padding {4 2} + -padding [list [::bhom_px 4] [::bhom_px 2]] ttk::style map Treeview \ -background [list selected $colors(-primary)] \ @@ -623,7 +635,7 @@ namespace eval ttk::theme::bhom_dark { -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ -relief flat \ - -padding {8 6} \ + -padding [list [::bhom_px 8] [::bhom_px 6]] \ -font {{Segoe UI} 10 bold} ttk::style map Treeview.Heading \ @@ -649,20 +661,20 @@ namespace eval ttk::theme::bhom_dark { -darkcolor $colors(-border) \ -borderwidth 1 \ -relief groove \ - -padding {10 8} + -padding [list [::bhom_px 10] [::bhom_px 8]] ttk::style configure TLabelframe.Label \ -background $colors(-bg) \ -foreground $colors(-fg) \ -font {{Segoe UI} 10 bold} \ - -padding {8 -6} + -padding [list [::bhom_px 8] [::bhom_px -6]] # Panedwindow ttk::style configure TPanedwindow \ -background $colors(-bg) ttk::style configure Sash \ - -sashthickness 6 \ + -sashthickness [::bhom_px 6] \ -gripcount 0 \ -background $colors(-border) @@ -676,7 +688,7 @@ namespace eval ttk::theme::bhom_dark { -foreground $colors(-inputfg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {8 5} \ + -padding [list [::bhom_px 8] [::bhom_px 5]] \ -borderwidth 1 \ -relief sunken @@ -695,7 +707,7 @@ namespace eval ttk::theme::bhom_dark { -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -borderwidth 1 \ -relief flat diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl index 8b52cc3..80dce34 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl @@ -7,6 +7,18 @@ # ============================================================================ # Split theme file generated from bhom_style.tcl +# DPI scaling: pixel values are authored for 96 DPI. `::bhom_scale` is set from +# Python (see theming/scaling.py) before this file is sourced. +if {![info exists ::bhom_scale]} { set ::bhom_scale 1.0 } +if {![llength [info commands ::bhom_px]]} { + proc ::bhom_px {n} { + if {$n == 0} { return 0 } + set v [expr {int(round($n * $::bhom_scale))}] + if {$v == 0} { return [expr {$n > 0 ? 1 : -1}] } + return $v + } +} + namespace eval ttk::theme::bhom_light { variable colors array set colors { @@ -76,32 +88,32 @@ namespace eval ttk::theme::bhom_light { ttk::style configure Display.TLabel \ -font {{Segoe UI} 20 bold} \ -foreground $colors(-primary) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure LargeTitle.TLabel \ -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure Title.TLabel \ -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure Headline.TLabel \ -font {{Segoe UI} 14 bold} \ -foreground $colors(-primary) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure Subtitle.TLabel \ -font {{Segoe UI} 12 bold} \ -foreground $colors(-fg) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure Heading.TLabel \ -font {{Segoe UI} 12 bold} \ -foreground $colors(-primary) \ - -padding {0 0} + -padding [list [::bhom_px 0] [::bhom_px 0]] ttk::style configure Body.TLabel \ -font {{Segoe UI} 10} \ @@ -142,19 +154,19 @@ namespace eval ttk::theme::bhom_light { -darkcolor $colors(-border) \ -borderwidth 1 \ -focuscolor "" \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat # Large Button variant ttk::style configure Large.TButton \ -font {{Segoe UI} 12 bold} \ - -padding {14 8} \ + -padding [list [::bhom_px 14] [::bhom_px 8]] \ -borderwidth 1 # Small Button variant ttk::style configure Small.TButton \ -font {{Segoe UI} 8 bold} \ - -padding {8 4} \ + -padding [list [::bhom_px 8] [::bhom_px 4]] \ -borderwidth 1 ttk::style map TButton \ @@ -186,7 +198,7 @@ namespace eval ttk::theme::bhom_light { -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ -borderwidth 1 \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat ttk::style map Primary.TButton \ @@ -215,7 +227,7 @@ namespace eval ttk::theme::bhom_light { -lightcolor $colors(-secondary) \ -darkcolor $colors(-secondary-hover) \ -borderwidth 1 \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat ttk::style map Secondary.TButton \ @@ -235,7 +247,7 @@ namespace eval ttk::theme::bhom_light { -lightcolor $colors(-tertiary) \ -darkcolor "#8a8a00" \ -borderwidth 1 \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat ttk::style map Accent.TButton \ @@ -257,7 +269,7 @@ namespace eval ttk::theme::bhom_light { -lightcolor $colors(-success) \ -darkcolor "#1e9038" \ -borderwidth 1 \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat ttk::style map Success.TButton \ @@ -274,7 +286,7 @@ namespace eval ttk::theme::bhom_light { -background $colors(-bg) \ -foreground $colors(-info) \ -borderwidth 0 \ - -padding {10 4} \ + -padding [list [::bhom_px 10] [::bhom_px 4]] \ -relief flat ttk::style map Link.TButton \ @@ -292,7 +304,7 @@ namespace eval ttk::theme::bhom_light { -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ -borderwidth 1 \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -relief flat ttk::style map Outline.TButton \ @@ -311,7 +323,7 @@ namespace eval ttk::theme::bhom_light { -background $colors(-bg) \ -foreground $colors(-primary) \ -borderwidth 0 \ - -padding {10 4} + -padding [list [::bhom_px 10] [::bhom_px 4]] ttk::style map Text.TButton \ -background [list \ @@ -328,7 +340,7 @@ namespace eval ttk::theme::bhom_light { -lightcolor $colors(-border) \ -darkcolor $colors(-hover-bg) \ -insertcolor $colors(-fg) \ - -padding {8 5} \ + -padding [list [::bhom_px 8] [::bhom_px 5]] \ -borderwidth 1 \ -relief sunken @@ -349,7 +361,7 @@ namespace eval ttk::theme::bhom_light { -background $colors(-bg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {8 5} \ + -padding [list [::bhom_px 8] [::bhom_px 5]] \ -borderwidth 1 \ -relief sunken @@ -369,10 +381,10 @@ namespace eval ttk::theme::bhom_light { -background $colors(-bg) \ -foreground $colors(-fg) \ -font {{Segoe UI} 10} \ - -padding {6 4} \ + -padding [list [::bhom_px 6] [::bhom_px 4]] \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 6 0} \ + -indicatormargin [list [::bhom_px 0] [::bhom_px 0] [::bhom_px 6] [::bhom_px 0]] \ -borderwidth 0 \ -relief flat @@ -398,10 +410,10 @@ namespace eval ttk::theme::bhom_light { ttk::style configure Checkbox.TCheckbutton \ -font {{Segoe UI} 10} \ - -padding {4 4} \ - -indicatormargin {0 0 6 0} \ + -padding [list [::bhom_px 4] [::bhom_px 4]] \ + -indicatormargin [list [::bhom_px 0] [::bhom_px 0] [::bhom_px 6] [::bhom_px 0]] \ -indicatorrelief flat \ - -indicatorsize 16 \ + -indicatorsize [::bhom_px 16] \ -borderwidth 0 \ -relief flat \ -focusthickness 0 \ @@ -427,10 +439,10 @@ namespace eval ttk::theme::bhom_light { -background $colors(-bg) \ -foreground $colors(-fg) \ -font {{Segoe UI} 10} \ - -padding {6 4} \ + -padding [list [::bhom_px 6] [::bhom_px 4]] \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 6 0} \ + -indicatormargin [list [::bhom_px 0] [::bhom_px 0] [::bhom_px 6] [::bhom_px 0]] \ -borderwidth 0 \ -relief flat @@ -457,9 +469,9 @@ namespace eval ttk::theme::bhom_light { ttk::style configure Radio.TRadiobutton \ -font {{Segoe UI} 10} \ - -padding {4 4} \ - -indicatormargin {0 0 6 0} \ - -indicatorsize 15 \ + -padding [list [::bhom_px 4] [::bhom_px 4]] \ + -indicatormargin [list [::bhom_px 0] [::bhom_px 0] [::bhom_px 6] [::bhom_px 0]] \ + -indicatorsize [::bhom_px 15] \ -borderwidth 0 \ -relief flat \ -focusthickness 0 \ @@ -500,7 +512,7 @@ namespace eval ttk::theme::bhom_light { -arrowsize 0 \ -borderwidth 0 \ -relief flat \ - -width 8 + -width [::bhom_px 8] ttk::style map TScrollbar \ -background [list \ @@ -513,7 +525,7 @@ namespace eval ttk::theme::bhom_light { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 8 + -width [::bhom_px 8] ttk::style map Vertical.TScrollbar \ -background [list \ @@ -526,7 +538,7 @@ namespace eval ttk::theme::bhom_light { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 8 + -width [::bhom_px 8] ttk::style map Horizontal.TScrollbar \ -background [list \ @@ -541,7 +553,7 @@ namespace eval ttk::theme::bhom_light { -slidercolor $colors(-primary) \ -borderwidth 1 \ -sliderrelief raised \ - -sliderlength 20 + -sliderlength [::bhom_px 20] ttk::style map TScale \ -background [list \ @@ -559,7 +571,7 @@ namespace eval ttk::theme::bhom_light { -slidercolor $colors(-primary-light) \ -borderwidth 1 \ -sliderrelief raised \ - -sliderlength 24 + -sliderlength [::bhom_px 24] ttk::style map ColourPicker.Horizontal.TScale \ -background [list \ @@ -577,7 +589,7 @@ namespace eval ttk::theme::bhom_light { -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ -borderwidth 1 \ - -thickness 16 \ + -thickness [::bhom_px 16] \ -relief flat # Notebook - soft rounded tabs @@ -592,7 +604,7 @@ namespace eval ttk::theme::bhom_light { -foreground $colors(-text-secondary) \ -bordercolor $colors(-border-light) \ -font {{Segoe UI} 10 bold} \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -borderwidth 1 ttk::style map TNotebook.Tab \ @@ -615,7 +627,7 @@ namespace eval ttk::theme::bhom_light { -darkcolor $colors(-border) \ -borderwidth 1 \ -rowheight 26 \ - -padding {4 2} + -padding [list [::bhom_px 4] [::bhom_px 2]] ttk::style map Treeview \ -background [list selected $colors(-primary)] \ @@ -626,7 +638,7 @@ namespace eval ttk::theme::bhom_light { -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ -relief flat \ - -padding {8 6} \ + -padding [list [::bhom_px 8] [::bhom_px 6]] \ -font {{Segoe UI} 10 bold} ttk::style map Treeview.Heading \ @@ -652,20 +664,20 @@ namespace eval ttk::theme::bhom_light { -darkcolor $colors(-border) \ -borderwidth 1 \ -relief groove \ - -padding {10 8} + -padding [list [::bhom_px 10] [::bhom_px 8]] ttk::style configure TLabelframe.Label \ -background $colors(-bg) \ -foreground $colors(-fg) \ -font {{Segoe UI} 10 bold} \ - -padding {8 -6} + -padding [list [::bhom_px 8] [::bhom_px -6]] # Panedwindow ttk::style configure TPanedwindow \ -background $colors(-bg) ttk::style configure Sash \ - -sashthickness 6 \ + -sashthickness [::bhom_px 6] \ -gripcount 0 \ -background $colors(-border) @@ -679,7 +691,7 @@ namespace eval ttk::theme::bhom_light { -foreground $colors(-inputfg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {8 5} \ + -padding [list [::bhom_px 8] [::bhom_px 5]] \ -borderwidth 1 \ -relief sunken @@ -698,7 +710,7 @@ namespace eval ttk::theme::bhom_light { -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {12 6} \ + -padding [list [::bhom_px 12] [::bhom_px 6]] \ -borderwidth 1 \ -relief flat diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/scaling.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/scaling.py new file mode 100644 index 0000000..8ee126a --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/scaling.py @@ -0,0 +1,106 @@ +"""DPI scaling helpers for BHoM Tkinter windows. + +Tk scales font sizes (points) with the display DPI but leaves pixel values +untouched, so hard-coded paddings, minimum sizes and images look cramped at +display scales above 100%. All pixel values in the toolkit are authored for a +96 DPI (100%) display and are converted through `scale` / `scale_pad`. +""" + +import ctypes +import platform +import tkinter as tk +from typing import Any + +BASE_DPI = 96.0 + +_scale_factor: float = 1.0 + + +def enable_dpi_awareness() -> None: + """Opt the process in to DPI awareness on Windows (no-op elsewhere). + + Must run before the first Tk window is created. Safe to call repeatedly or + when the host process (or matplotlib) has already set an awareness level. + """ + if platform.system() != "Windows": + return + try: + # Per-monitor v2 (Windows 10 1703+). + ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4)) + return + except Exception: + pass + try: + ctypes.windll.shcore.SetProcessDpiAwareness(2) + return + except Exception: + pass + try: + ctypes.windll.user32.SetProcessDPIAware() + except Exception: + pass + + +def init_scaling(root: tk.Misc) -> float: + """Detect the display scale from `root`, sync Tk's own scaling and cache the factor. + + Returns: + float: Scale factor relative to 96 DPI (1.25 for a 125% display). + """ + global _scale_factor + try: + dpi = float(root.winfo_fpixels("1i")) + except Exception: + dpi = BASE_DPI + if dpi <= 0: + dpi = BASE_DPI + _scale_factor = max(1.0, dpi / BASE_DPI) + + # Keep points->pixels (fonts) consistent with the pixel scale. + try: + root.tk.call("tk", "scaling", dpi / 72.0) + except Exception: + pass + # Expose the factor to the Tcl theme files (see `px` proc in the themes). + try: + root.tk.call("set", "::bhom_scale", _scale_factor) + except Exception: + pass + return _scale_factor + + +def get_work_area(root: tk.Misc) -> tuple[int, int]: + """Return the usable (width, height) of the primary screen, excluding the taskbar.""" + if platform.system() == "Windows": + try: + from ctypes import wintypes + rect = wintypes.RECT() + # SPI_GETWORKAREA = 0x0030 + if ctypes.windll.user32.SystemParametersInfoW(0x0030, 0, ctypes.byref(rect), 0): + return rect.right - rect.left, rect.bottom - rect.top + except Exception: + pass + return root.winfo_screenwidth(), root.winfo_screenheight() + + +def get_scale_factor() -> float: + """Return the cached scale factor (1.0 until `init_scaling` has run).""" + return _scale_factor + + +def scale(value: Any) -> Any: + """Scale a 96 DPI pixel value. Non-numeric values (e.g. '5p') pass through.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return value + if value == 0: + return value + scaled = int(round(value * _scale_factor)) + # Never let a non-zero distance collapse to zero. + return scaled if scaled != 0 else (1 if value > 0 else -1) + + +def scale_pad(value: Any) -> Any: + """Scale a pad value that may be a single value or a (before, after) tuple.""" + if isinstance(value, (tuple, list)): + return tuple(scale(v) for v in value) + return scale(value) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_grid_options.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_grid_options.py index 5bdd9c5..1ec68e1 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_grid_options.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_grid_options.py @@ -4,6 +4,7 @@ from typing import Any, Literal from python_toolkit.bhom_tkinter.widgets._build_options import BuildOptions +from python_toolkit.bhom_tkinter.theming.scaling import scale, scale_pad @dataclass class GridOptions(BuildOptions): @@ -27,5 +28,13 @@ def to_dict(self) -> dict: Returns: dict: Gridding options filtered to keys with concrete values. """ - return {k: v for k, v in asdict(self).items() if v is not None} + options = {k: v for k, v in asdict(self).items() if v is not None} + # Paddings are authored in 96 DPI pixels; scale for the current display. + for key in ("padx", "pady"): + if key in options: + options[key] = scale_pad(options[key]) + for key in ("ipadx", "ipady"): + if key in options: + options[key] = scale(options[key]) + return options diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py index 8115569..d7a6570 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_packing_options.py @@ -4,6 +4,7 @@ from typing import Any, Literal from python_toolkit.bhom_tkinter.widgets._build_options import BuildOptions +from python_toolkit.bhom_tkinter.theming.scaling import scale, scale_pad @dataclass class PackingOptions(BuildOptions): @@ -27,7 +28,15 @@ def to_dict(self) -> dict: 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} + options = {k: v for k, v in asdict(self).items() if v is not None} + # Paddings are authored in 96 DPI pixels; scale for the current display. + for key in ("padx", "pady"): + if key in options: + options[key] = scale_pad(options[key]) + for key in ("ipadx", "ipady"): + if key in options: + options[key] = scale(options[key]) + return options if __name__ == "__main__": diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_slim_scrollbar.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_slim_scrollbar.py new file mode 100644 index 0000000..10aa028 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_slim_scrollbar.py @@ -0,0 +1,114 @@ +"""Slim, rounded scrollbar drawn on a canvas (themed, DPI-aware).""" + +import tkinter as tk +from typing import Callable, Optional + + +def blend(base: str, other: str, amount: float) -> str: + """Blend two '#rrggbb' colours; amount=0 gives `base`, 1 gives `other`.""" + b = [int(base[i:i + 2], 16) for i in (1, 3, 5)] + o = [int(other[i:i + 2], 16) for i in (1, 3, 5)] + return "#%02x%02x%02x" % tuple(int(x + (y - x) * amount) for x, y in zip(b, o)) + + +class SlimScrollbar(tk.Canvas): + """Minimal scrollbar with a rounded thumb that brightens on hover. + + Implements the Tk scrollbar protocol (`set` + a scroll `command`), so it can be + wired to `yscrollcommand` / `xscrollcommand` of any scrollable widget. + """ + + def __init__( + self, + parent: tk.Misc, + orient: str = tk.VERTICAL, + command: Optional[Callable] = None, + *, + track: str = "#1e1e1e", + thumb: str = "#5a5a5a", + active: str = "#8a8a8a", + thickness: int = 12, + padding: int = 3, + ) -> None: + self._vertical = orient == tk.VERTICAL + self._command = command + self._track, self._thumb, self._active = track, thumb, active + self._thickness = thickness + self._padding = padding + self._first, self._last = 0.0, 1.0 + self._hover = False + self._drag_offset: Optional[float] = None + + size = {"width": thickness} if self._vertical else {"height": thickness} + super().__init__(parent, highlightthickness=0, borderwidth=0, background=track, **size) + + self.bind("", lambda _e: self._redraw()) + self.bind("", lambda _e: self._set_hover(True)) + self.bind("", lambda _e: self._set_hover(False)) + self.bind("", self._on_press) + self.bind("", self._on_drag) + self.bind("", self._on_release) + + # -- Tk scrollbar protocol ------------------------------------------------- + def set(self, first, last) -> None: + self._first, self._last = float(first), float(last) + self._redraw() + + # -- geometry ---------------------------------------------------------------- + def _length(self) -> float: + return float(self.winfo_height() if self._vertical else self.winfo_width()) + + def _thumb_span(self) -> tuple[float, float]: + usable = max(1.0, self._length() - 2 * self._padding) + start = self._padding + self._first * usable + end = self._padding + self._last * usable + min_len = self._thickness * 2 + if end - start < min_len: + end = min(self._padding + usable, start + min_len) + start = end - min_len + return start, end + + def _redraw(self) -> None: + self.delete("thumb") + if self._first <= 0.0 and self._last >= 1.0: + return + start, end = self._thumb_span() + width = max(2, self._thickness - 2 * self._padding) + half = width / 2 + centre = self._thickness / 2 + colour = self._active if (self._hover or self._drag_offset is not None) else self._thumb + if self._vertical: + coords = (centre, start + half, centre, max(start + half, end - half)) + else: + coords = (start + half, centre, max(start + half, end - half), centre) + self.create_line(*coords, width=width, fill=colour, capstyle=tk.ROUND, tags="thumb") + + # -- interaction ------------------------------------------------------------- + def _set_hover(self, hover: bool) -> None: + self._hover = hover + self._redraw() + + def _position(self, event) -> float: + return float(event.y if self._vertical else event.x) + + def _on_press(self, event) -> None: + if self._command is None: + return + start, end = self._thumb_span() + pos = self._position(event) + if start <= pos <= end: + self._drag_offset = pos - start + self._redraw() + else: + self._command("scroll", -1 if pos < start else 1, "pages") + + def _on_drag(self, event) -> None: + if self._drag_offset is None or self._command is None: + return + usable = max(1.0, self._length() - 2 * self._padding) + fraction = (self._position(event) - self._drag_offset - self._padding) / usable + self._command("moveto", max(0.0, min(1.0, fraction))) + + def _on_release(self, _event) -> None: + self._drag_offset = None + self._redraw() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py index c3f9651..ddac1ba 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py @@ -5,6 +5,7 @@ from typing import Optional, List, Callable, Tuple, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.theming.scaling import scale_pad class CheckboxSelection(BHoMBaseWidget): """A reusable checkbox selection widget built from a list of fields, allowing multiple selections.""" @@ -85,11 +86,11 @@ def _build_buttons(self): else: row = index % self.max_per_line column = index // self.max_per_line - button.grid(row=row, column=column, padx=(0, 10), pady=(0, 4), sticky=self._grid_sticky) + button.grid(row=row, column=column, padx=scale_pad((0, 10)), pady=scale_pad((0, 4)), sticky=self._grid_sticky) elif self.orient == "horizontal": - button.grid(row=0, column=index, padx=(0, 10), pady=(0, 4), sticky=self._grid_sticky) + button.grid(row=0, column=index, padx=scale_pad((0, 10)), pady=scale_pad((0, 4)), sticky=self._grid_sticky) else: - button.grid(row=index, column=0, padx=(0, 10), pady=(0, 4), sticky=self._grid_sticky) + button.grid(row=index, column=0, padx=scale_pad((0, 10)), pady=scale_pad((0, 4)), sticky=self._grid_sticky) self._buttons.append(button) self._field_buttons[field] = button @@ -197,9 +198,9 @@ def on_selection(values): # Add control buttons for demonstration control_frame = ttk.Frame(parent_frame) - control_frame.pack(padx=20, pady=10) + control_frame.pack(padx=scale_pad(20), pady=scale_pad(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) + Button(control_frame, text="Select All", command=widget.select_all).pack(side="left", padx=scale_pad(5)) + Button(control_frame, text="Deselect All", command=widget.deselect_all).pack(side="left", padx=scale_pad(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 index effb20f..4d53594 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py @@ -11,6 +11,7 @@ from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions from python_toolkit.bhom.analytics import CONSOLE_LOGGER +from python_toolkit.bhom_tkinter.theming.scaling import scale_pad # Accepted colormap input types for colormaps parameter and set()/add_cmap() CmapInput = Union[str, Colormap, List[str]] @@ -124,7 +125,7 @@ def __init__( self.cmap_frame.rowconfigure(0, weight=1) content = ttk.Frame(self.cmap_frame, width=plot_size[0], height=plot_size[1]) - content.grid(row=0, column=0, padx=0, pady=4, sticky=self._grid_sticky) + content.grid(row=0, column=0, padx=scale_pad(0), pady=scale_pad(4), sticky=self._grid_sticky) content.grid_propagate(False) self.cmap_set_var = tk.StringVar(value=cmap_set.lower()) @@ -146,7 +147,7 @@ def __init__( state="readonly", justify=self._text_justify, ) - self.cmap_combobox.pack(side=tk.TOP, anchor=self._pack_anchor, padx=0) + self.cmap_combobox.pack(side=tk.TOP, anchor=self._pack_anchor, padx=scale_pad(0)) self.cmap_combobox.bind("<>", self._on_cmap_selected) fill_mode: Literal["x", "y"] = "y" if is_horizontal else "x" diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py index 952aab2..503b64b 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py @@ -7,6 +7,7 @@ from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.theming.scaling import scale, scale_pad class ColourPicker(BHoMBaseWidget): """A simple colour picker widget where the swatch opens a themed colour dialog.""" @@ -81,7 +82,7 @@ def _select_colour(self) -> None: self._popup_blue_var = tk.IntVar(value=blue) self._popup_hex_var = tk.StringVar(value=current_colour) - container = ttk.Frame(window, padding=10) + container = ttk.Frame(window, padding=scale(10)) container.pack(fill=tk.BOTH, expand=True) self._build_slider_row(container, "R", self._popup_red_var, 0) @@ -89,22 +90,22 @@ def _select_colour(self) -> None: 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)) + hex_row.grid(row=3, column=0, sticky="ew", pady=scale_pad((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.pack(side=tk.LEFT, padx=scale_pad((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)) + preview_row.grid(row=4, column=0, sticky="w", pady=scale_pad((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_preview = tk.Canvas(preview_row, width=scale(48), height=scale(28), highlightthickness=1) + self._popup_preview.pack(side=tk.LEFT, padx=scale_pad((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="Cancel", command=self._close_picker).pack(side=tk.LEFT, padx=scale_pad((0, 6))) Button(button_row, text="Apply", command=self._apply_popup_colour).pack(side=tk.LEFT) container.columnconfigure(0, weight=1) @@ -119,7 +120,7 @@ def _update_preview(self, colour: str) -> None: 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) + row_frame.grid(row=row, column=0, sticky="ew", pady=scale_pad(2)) Label(row_frame, text=label_text).pack(side=tk.LEFT) slider = ttk.Scale( @@ -131,7 +132,7 @@ def _build_slider_row(self, parent: ttk.Frame, label_text: str, value_var: tk.In 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)) + slider.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=scale_pad((8, 8))) value_label = Label(row_frame, textvariable=value_var, width=4, anchor="e") value_label.pack(side=tk.LEFT) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py index 0f0f06d..2cf03d8 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py @@ -9,6 +9,7 @@ import matplotlib.pyplot as plt from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget import matplotlib as mpl +from python_toolkit.bhom_tkinter.theming.scaling import scale class FigureContainer(BHoMBaseWidget): @@ -37,8 +38,8 @@ def __init__( super().__init__(parent, **kwargs) self.auto_size = bool(auto_size) - self.rigid_width = int(rigid_width) if rigid_width is not None else None - self.rigid_height = int(rigid_height) if rigid_height is not None else None + self.rigid_width = scale(int(rigid_width)) if rigid_width is not None else None + self.rigid_height = scale(int(rigid_height)) if rigid_height is not None else None if self.rigid_width is not None: self.content_frame.config(width=self.rigid_width) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py index 3dd7ebf..0493e0a 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py @@ -8,6 +8,7 @@ from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.theming.scaling import scale_pad class ScrollableListBox(BHoMBaseWidget): """A reusable listbox widget with auto-hiding scrollbar.""" @@ -80,11 +81,11 @@ def __init__( if show_selection_controls: select_widget = Button(self.content_frame, text="Select All", command=self.select_all) - select_widget.grid(row=1, column=0, sticky="ns", padx=(0, 4), pady=(8, 0)) + select_widget.grid(row=1, column=0, sticky="ns", padx=scale_pad((0, 4)), pady=scale_pad((8, 0))) self.select_all_button = select_widget.button deselect_widget = Button(self.content_frame, text="Deselect All", command=self.deselect_all) - deselect_widget.grid(row=1, column=1, sticky="ns", padx=(4, 0), pady=(8, 0)) + deselect_widget.grid(row=1, column=1, sticky="ns", padx=scale_pad((4, 0)), pady=scale_pad((8, 0))) self.deselect_all_button = deselect_widget.button def _on_configure(self, event=None): diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py index 663a16f..6e9e732 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py @@ -7,6 +7,7 @@ from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.theming.scaling import scale_pad class PathSelector(BHoMBaseWidget): """A reusable path/file selector widget with a button and a readonly entry.""" @@ -41,7 +42,7 @@ def __init__( self.initialdir = initialdir self.filetypes = filetypes if filetypes is not None else [("All Files", "*.*")] self.entry = ttk.Entry(self.content_frame, textvariable=self.path_var, width=entry_width) - self.entry.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True) + self.entry.pack(side=tk.LEFT, padx=scale_pad((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, width=button_width) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py index b01b6c2..95c573d 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py @@ -5,6 +5,7 @@ from typing import Optional, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.theming.scaling import scale_pad class RadioSelection(BHoMBaseWidget): """A reusable radio selection widget built from a list of fields.""" @@ -87,8 +88,8 @@ def _build_buttons(self): button.grid( row=row, column=column, - padx=(0, 10), - pady=(0, 4), + padx=scale_pad((0, 10)), + pady=scale_pad((0, 4)), sticky=("ew" if self.options_fill_extents else sticky), ) if self.options_fill_extents: @@ -97,8 +98,8 @@ def _build_buttons(self): button.grid( row=0, column=index, - padx=(0, 10), - pady=(0, 4), + padx=scale_pad((0, 10)), + pady=scale_pad((0, 4)), sticky=("ew" if self.options_fill_extents else sticky), ) if self.options_fill_extents: @@ -107,8 +108,8 @@ def _build_buttons(self): button.grid( row=index, column=0, - padx=(0, 10), - pady=(0, 4), + padx=scale_pad((0, 10)), + pady=scale_pad((0, 4)), sticky=("ew" if self.options_fill_extents else sticky), ) if self.options_fill_extents: diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py index 3f6e7c5..a6b8818 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py @@ -6,6 +6,7 @@ from typing import Optional, Callable, Any, Union, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.theming.scaling import scale_pad _TkVar = Union[tk.StringVar, tk.IntVar, tk.DoubleVar, tk.BooleanVar] @@ -115,7 +116,7 @@ def __init__( width=2, alignment=self.alignment, ) - self.success_label.pack(side="left", padx=(5, 0)) + self.success_label.pack(side="left", padx=scale_pad((5, 0))) # Create error label below entry with fixed height to prevent layout shifts self.error_label = Label( diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py index 2e89977..078d973 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py @@ -10,6 +10,7 @@ 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.drop_down_selection import DropDownSelection +from python_toolkit.bhom_tkinter.theming.scaling import scale_pad class CalendarWidget(BHoMBaseWidget): """Render a month grid and allow date selection.""" @@ -91,7 +92,7 @@ def year_selector(self): command=lambda val: self.set_year(val), state="readonly", ) - self.year_dropdown.pack(side="left", padx=4, pady=4) + self.year_dropdown.pack(side="left", padx=scale_pad(4), pady=scale_pad(4)) def month_selector(self): """Build the month dropdown selector.""" @@ -103,7 +104,7 @@ def month_selector(self): command=lambda val: self.set_month(val), state="readonly", ) - self.month_dropdown.pack(side="left", padx=4, pady=4) + self.month_dropdown.pack(side="left", padx=scale_pad(4), pady=scale_pad(4)) def _pack_sections(self) -> None: """Pack month selectors and calendar sections in the configured order.""" @@ -141,7 +142,7 @@ def _refresh_selection_label(self) -> None: label = Label(self.date_frame, text=text) self.align_child_text(label) - label.pack(anchor=self._pack_anchor, padx=4, pady=4) + label.pack(anchor=self._pack_anchor, padx=scale_pad(4), pady=scale_pad(4)) def set_year(self, value): """Update the selected year and redraw the calendar. diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py index f708d53..431a74d 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py @@ -9,6 +9,7 @@ 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_child_window import BHoMBaseChildWindow +from python_toolkit.bhom_tkinter.theming.scaling import scale_pad class DirectoryFileSelector(BHoMBaseChildWindow): """Display matching files and return the user's multi-selection.""" @@ -50,7 +51,7 @@ def build(self): self.content_frame, text=f"Select the {self.selection_label} to analyse.", ) - instruction_label.pack(anchor="w", pady=(0, 10)) + instruction_label.pack(anchor="w", pady=scale_pad((0, 10))) if self.file_selector_listbox is None: self.file_selector_listbox = ScrollableListBox( diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py index bebabbf..2ffbc0f 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py @@ -6,6 +6,7 @@ from python_toolkit.bhom_tkinter.widgets.button import Button from python_toolkit.bhom_tkinter.widgets.label import Label from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions +from python_toolkit.bhom_tkinter.theming.scaling import scale_pad class LandingPage(BHoMBaseWindow): @@ -73,7 +74,7 @@ def build(self): sub_title_label.build() self.custom_buttons_frame = ttk.Frame(self.content_frame) - self.custom_buttons_frame.pack(fill="x", pady=(0, 20)) + self.custom_buttons_frame.pack(fill="x", pady=scale_pad((0, 20))) super().build() @@ -91,10 +92,10 @@ def add_custom_button(self, text: str, command: Callable, **kwargs) -> ttk.Butto """ if self.custom_buttons_frame is None: self.custom_buttons_frame = ttk.Frame(self.content_frame) - self.custom_buttons_frame.pack(fill="x", pady=(0, 20)) + self.custom_buttons_frame.pack(fill="x", pady=scale_pad((0, 20))) button_widget = Button(self.custom_buttons_frame, text=text, command=command, **kwargs) - button_widget.pack(pady=5, fill="x") + button_widget.pack(pady=scale_pad(5), fill="x") # Recalculate window size after adding button self.refresh_sizing() # Return inner ttk.Button for compatibility diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py index 3cb342e..3649528 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py @@ -6,6 +6,7 @@ import threading from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow +from python_toolkit.bhom_tkinter.theming.scaling import scale, scale_pad class ProcessingWindow(BHoMBaseChildWindow): """A simple processing window with animated indicator.""" @@ -34,7 +35,7 @@ def __init__(self, title="Processing", message="Processing...", *args, **kwargs) self.attributes("-topmost", True) self.resizable(False, False) - container = ttk.Frame(self.content_frame, padding=20) + container = ttk.Frame(self.content_frame, padding=scale(20)) container.pack(fill="both", expand=True) self.message_label = ttk.Label( @@ -42,7 +43,7 @@ def __init__(self, title="Processing", message="Processing...", *args, **kwargs) text=message, style="Title.TLabel", justify="center", - wraplength=400 + wraplength=scale(400) ) try: title_font = ttk.Style(self).lookup("Title.TLabel", "font") @@ -50,7 +51,7 @@ def __init__(self, title="Processing", message="Processing...", *args, **kwargs) self.message_label.configure(font=title_font) except Exception: pass - self.message_label.pack(pady=(0, 20)) + self.message_label.pack(pady=scale_pad((0, 20))) animation_frame = ttk.Frame(container) animation_frame.pack(expand=True) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py index 1ae67fd..0667bb6 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py @@ -3,6 +3,7 @@ from python_toolkit.bhom_tkinter.widgets.label import Label from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions +from python_toolkit.bhom_tkinter.theming.scaling import scale class WarningBox(BHoMBaseChildWindow): @@ -51,7 +52,7 @@ def _render_messages(self): self.content_frame, text=message, style="Error.TLabel", - wraplength=400, + wraplength=scale(400), justify="left", alignment="left", build_options=PackingOptions(anchor="w", pady=(0, 5)), @@ -62,7 +63,7 @@ def _render_messages(self): self.content_frame, text=message, style="Warning.TLabel", - wraplength=400, + wraplength=scale(400), justify="left", alignment="left", build_options=PackingOptions(anchor="w", pady=(0, 5)), @@ -73,7 +74,7 @@ def _render_messages(self): self.content_frame, text=message, style="Caption.TLabel", - wraplength=400, + wraplength=scale(400), justify="left", alignment="left", build_options=PackingOptions(anchor="w", pady=(0, 5)),