From 184d5d4629187745165a4266e09e03b6165d1689 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 15 Oct 2023 09:19:10 -0700 Subject: [PATCH 01/80] Fixed bug in call of SetProcessDpiAwarenessContext --- winforms/src/toga_winforms/app.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 7633a37611..00be87ee16 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -2,7 +2,7 @@ import re import sys import threading -from ctypes import windll +from ctypes import c_bool, c_void_p, windll import System.Windows.Forms as WinForms from System import Environment, Threading @@ -79,7 +79,11 @@ def create(self): # Represents Windows 10 Build 1703 and beyond which should use # SetProcessDpiAwarenessContext(-2) elif win_version.Major == 10 and win_version.Build >= 15063: - windll.user32.SetProcessDpiAwarenessContext(-2) + windll.user32.SetProcessDpiAwarenessContext.restype = c_bool + windll.user32.SetProcessDpiAwarenessContext.argtypes = [c_void_p] + # SetProcessDpiAwarenessContext returns False on Failure + if not windll.user32.SetProcessDpiAwarenessContext(-2): + print("WARNING: Failed to set the DPI Awareness mode for the app.") # Any other version of windows should use SetProcessDPIAware() else: windll.user32.SetProcessDPIAware() From 986804fdfef40edabea0ce79ab1396e1f7dc4632 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 15 Oct 2023 09:44:10 -0700 Subject: [PATCH 02/80] Added a changelog. --- changes/2155.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/2155.bugfix.rst diff --git a/changes/2155.bugfix.rst b/changes/2155.bugfix.rst new file mode 100644 index 0000000000..0ab70d16bc --- /dev/null +++ b/changes/2155.bugfix.rst @@ -0,0 +1 @@ +A bug in the setting of the DPI Awareness mode of the app was fixed. From 5967db9e6a0ff410d182ec7347bdbf68e456d97f Mon Sep 17 00:00:00 2001 From: proneon267 Date: Mon, 16 Oct 2023 05:49:46 -0700 Subject: [PATCH 03/80] Fixed winforms scaling bugs. --- winforms/src/toga_winforms/app.py | 63 ++++++++++++---------- winforms/src/toga_winforms/widgets/base.py | 4 +- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 00be87ee16..3d3b4837da 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -34,6 +34,40 @@ def winforms_FormClosing(self, sender, event): class App: _MAIN_WINDOW_CLASS = MainWindow + # ------------------- Set the DPI Awareness mode for the process ------------------- + # This needs to be done at the earliest and doing this in __init__() or + # in create() doesn't work + # + # Check the version of windows and make sure we are setting the DPI mode + # with the most up to date API + # Windows Versioning Check Sources : https://www.lifewire.com/windows-version-numbers-2625171 + # and https://docs.microsoft.com/en-us/windows/release-information/ + win_version = Environment.OSVersion.Version + if win_version.Major >= 6: # Checks for Windows Vista or later + # Represents Windows 8.1 up to Windows 10 before Build 1703 which should use + # SetProcessDpiAwareness(True) + if (win_version.Major == 6 and win_version.Minor == 3) or ( + win_version.Major == 10 and win_version.Build < 15063 + ): + windll.shcore.SetProcessDpiAwareness(True) + print( + "WARNING: Your Windows version doesn't support DPI-independent rendering. " + "We recommend you upgrade to at least Windows 10 Build 1703." + ) + # Represents Windows 10 Build 1703 and beyond which should use + # SetProcessDpiAwarenessContext(-4) for DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 + # Valid values: https://learn.microsoft.com/en-us/windows/win32/hidpi/dpi-awareness-context + elif win_version.Major == 10 and win_version.Build >= 15063: + windll.user32.SetProcessDpiAwarenessContext.restype = c_bool + windll.user32.SetProcessDpiAwarenessContext.argtypes = [c_void_p] + # SetProcessDpiAwarenessContext returns False on Failure + if not windll.user32.SetProcessDpiAwarenessContext(-4): + print("WARNING: Failed to set the DPI Awareness mode for the app.") + # Any other version of windows should use SetProcessDPIAware() + else: + windll.user32.SetProcessDPIAware() + # ---------------------------------------------------------------------------------- + def __init__(self, interface): self.interface = interface self.interface._impl = self @@ -60,34 +94,7 @@ def create(self): self.app_context = WinForms.ApplicationContext() self.app_dispatcher = Dispatcher.CurrentDispatcher - # Check the version of windows and make sure we are setting the DPI mode - # with the most up to date API - # Windows Versioning Check Sources : https://www.lifewire.com/windows-version-numbers-2625171 - # and https://docs.microsoft.com/en-us/windows/release-information/ - win_version = Environment.OSVersion.Version - if win_version.Major >= 6: # Checks for Windows Vista or later - # Represents Windows 8.1 up to Windows 10 before Build 1703 which should use - # SetProcessDpiAwareness(True) - if (win_version.Major == 6 and win_version.Minor == 3) or ( - win_version.Major == 10 and win_version.Build < 15063 - ): - windll.shcore.SetProcessDpiAwareness(True) - print( - "WARNING: Your Windows version doesn't support DPI-independent rendering. " - "We recommend you upgrade to at least Windows 10 Build 1703." - ) - # Represents Windows 10 Build 1703 and beyond which should use - # SetProcessDpiAwarenessContext(-2) - elif win_version.Major == 10 and win_version.Build >= 15063: - windll.user32.SetProcessDpiAwarenessContext.restype = c_bool - windll.user32.SetProcessDpiAwarenessContext.argtypes = [c_void_p] - # SetProcessDpiAwarenessContext returns False on Failure - if not windll.user32.SetProcessDpiAwarenessContext(-2): - print("WARNING: Failed to set the DPI Awareness mode for the app.") - # Any other version of windows should use SetProcessDPIAware() - else: - windll.user32.SetProcessDPIAware() - + # These are required for properly setting up DPI mode self.native.EnableVisualStyles() self.native.SetCompatibleTextRenderingDefault(False) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index b4c8068aae..d0d947390f 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -17,7 +17,9 @@ class Scalable: SCALE_DEFAULT_ROUNDING = ROUND_HALF_EVEN def init_scale(self, native): - self.dpi_scale = native.CreateGraphics().DpiX / 96 + graphics = native.CreateGraphics() + self.dpi_scale = graphics.DpiX / 96 + graphics.Dispose() # Convert CSS pixels to native pixels def scale_in(self, value, rounding=SCALE_DEFAULT_ROUNDING): From a9f740bee7947da30c2dfb20456b8b24e890e1ec Mon Sep 17 00:00:00 2001 From: proneon267 <45512885+proneon267@users.noreply.github.com> Date: Mon, 16 Oct 2023 06:07:38 -0700 Subject: [PATCH 04/80] Updated changelog. --- changes/2155.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/2155.bugfix.rst b/changes/2155.bugfix.rst index 0ab70d16bc..d864b8b2ce 100644 --- a/changes/2155.bugfix.rst +++ b/changes/2155.bugfix.rst @@ -1 +1 @@ -A bug in the setting of the DPI Awareness mode of the app was fixed. +Bugs related to the DPI scaling on Windows were fixed. From c1bdf9d10d3c666a5560d675095eed27d34a52e6 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 18 Oct 2023 04:50:04 -0700 Subject: [PATCH 05/80] Added event handler to detect dpi change when live. --- winforms/src/toga_winforms/app.py | 12 ++++++++- winforms/src/toga_winforms/container.py | 1 - winforms/src/toga_winforms/widgets/base.py | 30 +++++++++++++++++----- winforms/src/toga_winforms/window.py | 1 - 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 3d3b4837da..e32a29f0e9 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -5,6 +5,7 @@ from ctypes import c_bool, c_void_p, windll import System.Windows.Forms as WinForms +from Microsoft.Win32 import SystemEvents from System import Environment, Threading from System.Media import SystemSounds from System.Net import SecurityProtocolType, ServicePointManager @@ -15,6 +16,7 @@ from .keys import toga_to_winforms_key from .libs.proactor import WinformsProactorEventLoop +from .widgets.base import Scalable from .window import Window @@ -31,7 +33,7 @@ def winforms_FormClosing(self, sender, event): event.Cancel = True -class App: +class App(Scalable): _MAIN_WINDOW_CLASS = MainWindow # ------------------- Set the DPI Awareness mode for the process ------------------- @@ -98,6 +100,9 @@ def create(self): self.native.EnableVisualStyles() self.native.SetCompatibleTextRenderingDefault(False) + # Register the DisplaySettingsChanged event handler + SystemEvents.DisplaySettingsChanged += self.winforms_DisplaySettingsChanged + # Ensure that TLS1.2 and TLS1.3 are enabled for HTTPS connections. # For some reason, some Windows installs have these protocols # turned off by default. SSL3, TLS1.0 and TLS1.1 are *not* enabled @@ -340,6 +345,11 @@ def hide_cursor(self): WinForms.Cursor.Hide() self._cursor_visible = False + def winforms_DisplaySettingsChanged(self, sender, event): + self.update_scale() + for window in self.interface.windows: + window.content.refresh() + class DocumentApp(App): def _create_app_commands(self): diff --git a/winforms/src/toga_winforms/container.py b/winforms/src/toga_winforms/container.py index 061ef38d83..398c4e1160 100644 --- a/winforms/src/toga_winforms/container.py +++ b/winforms/src/toga_winforms/container.py @@ -6,7 +6,6 @@ class Container(Scalable): def __init__(self, native_parent): - self.init_scale(native_parent) self.native_parent = native_parent self.native_width = self.native_height = 0 self.content = None diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index d0d947390f..ca082c93db 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from ctypes import byref, c_void_p, windll, wintypes from decimal import ROUND_HALF_EVEN, ROUND_UP, Decimal from System.Drawing import ( @@ -7,6 +8,7 @@ Size, SystemColors, ) +from System.Windows.Forms import Screen from travertino.size import at_least from toga.colors import TRANSPARENT @@ -16,21 +18,36 @@ class Scalable: SCALE_DEFAULT_ROUNDING = ROUND_HALF_EVEN - def init_scale(self, native): - graphics = native.CreateGraphics() - self.dpi_scale = graphics.DpiX / 96 - graphics.Dispose() + def update_scale(self): + screen = Screen.PrimaryScreen + screen_rect = wintypes.RECT( + screen.Bounds.Left, + screen.Bounds.Top, + screen.Bounds.Right, + screen.Bounds.Bottom, + ) + windll.user32.MonitorFromRect.restype = c_void_p + windll.user32.MonitorFromRect.argtypes = [wintypes.RECT, wintypes.DWORD] + # MONITOR_DEFAULTTONEAREST = 2 + hMonitor = windll.user32.MonitorFromRect(screen_rect, 2) + pScale = wintypes.UINT() + windll.shcore.GetScaleFactorForMonitor(c_void_p(hMonitor), byref(pScale)) + Scalable.dpi_scale = pScale.value / 100 # Convert CSS pixels to native pixels def scale_in(self, value, rounding=SCALE_DEFAULT_ROUNDING): - return self.scale_round(value * self.dpi_scale, rounding) + if not hasattr(Scalable, "dpi_scale"): + self.update_scale() + return self.scale_round(value * Scalable.dpi_scale, rounding) # Convert native pixels to CSS pixels def scale_out(self, value, rounding=SCALE_DEFAULT_ROUNDING): + if not hasattr(Scalable, "dpi_scale"): + self.update_scale() if isinstance(value, at_least): return at_least(self.scale_out(value.value, rounding)) else: - return self.scale_round(value / self.dpi_scale, rounding) + return self.scale_round(value / Scalable.dpi_scale, rounding) def scale_round(self, value, rounding): if rounding is None: @@ -51,7 +68,6 @@ def __init__(self, interface): self._container = None self.native = None self.create() - self.init_scale(self.native) self.interface.style.reapply() @abstractmethod diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index f488639f3b..748341eadb 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -25,7 +25,6 @@ def __init__(self, interface, title, position, size): self.native._impl = self self.native.FormClosing += self.winforms_FormClosing super().__init__(self.native) - self.init_scale(self.native) self.native.MinimizeBox = self.native.interface.minimizable From ff23b175ab94490846e96e451a4c3c187c73575d Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 18 Oct 2023 05:23:26 -0700 Subject: [PATCH 06/80] Empty commit for CI/CD From afccfc6102fad0eb841cc645a3e900ce1c95e568 Mon Sep 17 00:00:00 2001 From: proneon267 <45512885+proneon267@users.noreply.github.com> Date: Wed, 18 Oct 2023 06:54:10 -0700 Subject: [PATCH 07/80] Updated changelog --- changes/2155.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/2155.bugfix.rst b/changes/2155.bugfix.rst index d864b8b2ce..0293e281e4 100644 --- a/changes/2155.bugfix.rst +++ b/changes/2155.bugfix.rst @@ -1 +1 @@ -Bugs related to the DPI scaling on Windows were fixed. +Bugs affecting DPI scaling on Windows were fixed. From 94dce84d7cd21e4fa4527a97d8b2d2b86aa3909f Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sat, 21 Oct 2023 03:57:34 -0700 Subject: [PATCH 08/80] Added support for font scaling based on DPI change --- winforms/src/toga_winforms/app.py | 57 ++++++++++++---------- winforms/src/toga_winforms/widgets/base.py | 25 +++++++++- winforms/src/toga_winforms/window.py | 7 +++ 3 files changed, 61 insertions(+), 28 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 7375069548..31a9f83b38 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -4,11 +4,12 @@ import threading from ctypes import c_bool, c_void_p, windll -import System.Windows.Forms as WinForms from Microsoft.Win32 import SystemEvents from System import Environment, Threading +from System.Drawing import Font as WinFont from System.Media import SystemSounds from System.Net import SecurityProtocolType, ServicePointManager +import System.Windows.Forms as WinForms from System.Windows.Threading import Dispatcher import toga @@ -22,6 +23,13 @@ class MainWindow(Window): + def update_menubar_font_scale(self): + self.native.MainMenuStrip.Font = WinFont( + self.original_menubar_font.FontFamily, + self.scale_font(self.original_menubar_font.Size), + self.original_menubar_font.Style, + ) + def winforms_FormClosing(self, sender, event): # Differentiate between the handling that occurs when the user # requests the app to exit, and the actual application exiting. @@ -46,29 +54,21 @@ class App(Scalable): # Windows Versioning Check Sources : https://www.lifewire.com/windows-version-numbers-2625171 # and https://docs.microsoft.com/en-us/windows/release-information/ win_version = Environment.OSVersion.Version - if win_version.Major >= 6: # Checks for Windows Vista or later - # Represents Windows 8.1 up to Windows 10 before Build 1703 which should use - # SetProcessDpiAwareness(True) - if (win_version.Major == 6 and win_version.Minor == 3) or ( - win_version.Major == 10 and win_version.Build < 15063 - ): - windll.shcore.SetProcessDpiAwareness(True) - print( - "WARNING: Your Windows version doesn't support DPI-independent rendering. " - "We recommend you upgrade to at least Windows 10 Build 1703." - ) - # Represents Windows 10 Build 1703 and beyond which should use - # SetProcessDpiAwarenessContext(-4) for DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 - # Valid values: https://learn.microsoft.com/en-us/windows/win32/hidpi/dpi-awareness-context - elif win_version.Major == 10 and win_version.Build >= 15063: - windll.user32.SetProcessDpiAwarenessContext.restype = c_bool - windll.user32.SetProcessDpiAwarenessContext.argtypes = [c_void_p] - # SetProcessDpiAwarenessContext returns False on Failure - if not windll.user32.SetProcessDpiAwarenessContext(-4): - print("WARNING: Failed to set the DPI Awareness mode for the app.") - # Any other version of windows should use SetProcessDPIAware() - else: - windll.user32.SetProcessDPIAware() + # Represents Windows 10 Build 1703 and beyond which should use + # SetProcessDpiAwarenessContext(-4) for DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 + # Valid values: https://learn.microsoft.com/en-us/windows/win32/hidpi/dpi-awareness-context + if win_version.Major == 10 and win_version.Build >= 15063: + windll.user32.SetProcessDpiAwarenessContext.restype = c_bool + windll.user32.SetProcessDpiAwarenessContext.argtypes = [c_void_p] + # SetProcessDpiAwarenessContext returns False on Failure + if not windll.user32.SetProcessDpiAwarenessContext(-4): + print("WARNING: Failed to set the DPI Awareness mode for the app.") + # Any other version of windows should use SetProcessDPIAware() + else: + print( + "WARNING: Your Windows version doesn't support DPI Awareness setting. " + "We recommend you upgrade to at least Windows 10 Build 1703." + ) # ---------------------------------------------------------------------------------- def __init__(self, interface): @@ -188,6 +188,8 @@ def create_menus(self): # defaults to `Top`. self.interface.main_window._impl.native.Controls.Add(menubar) self.interface.main_window._impl.native.MainMenuStrip = menubar + # Required for font scaling on DPI changes + self.interface.main_window._impl.original_menubar_font = menubar.Font self.interface.main_window._impl.resize_content() def _submenu(self, group, menubar): @@ -349,9 +351,12 @@ def hide_cursor(self): self._cursor_visible = False def winforms_DisplaySettingsChanged(self, sender, event): - self.update_scale() for window in self.interface.windows: - window.content.refresh() + window._impl.update_scale() + if isinstance(window._impl, MainWindow): + window._impl.update_menubar_font_scale() + for widget in window.widgets: + widget.refresh() class DocumentApp(App): diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index ca082c93db..3a2397049a 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -7,6 +7,7 @@ Point, Size, SystemColors, + Font as WinFont, ) from System.Windows.Forms import Screen from travertino.size import at_least @@ -18,8 +19,12 @@ class Scalable: SCALE_DEFAULT_ROUNDING = ROUND_HALF_EVEN - def update_scale(self): - screen = Screen.PrimaryScreen + def update_scale(self, screen=None): + # Doing screen=Screen.PrimaryScreen in method signature will make + # the app to become unresponsive when DPI settings are changed. + if screen is None: + screen = Screen.PrimaryScreen + screen_rect = wintypes.RECT( screen.Bounds.Left, screen.Bounds.Top, @@ -33,6 +38,8 @@ def update_scale(self): pScale = wintypes.UINT() windll.shcore.GetScaleFactorForMonitor(c_void_p(hMonitor), byref(pScale)) Scalable.dpi_scale = pScale.value / 100 + if not hasattr(Scalable, "original_dpi_scale"): + Scalable.original_dpi_scale = Scalable.dpi_scale # Convert CSS pixels to native pixels def scale_in(self, value, rounding=SCALE_DEFAULT_ROUNDING): @@ -54,6 +61,11 @@ def scale_round(self, value, rounding): return value return int(Decimal(value).to_integral(rounding)) + def scale_font(self, value): + if not hasattr(Scalable, "dpi_scale"): + self.update_scale() + return value * Scalable.dpi_scale / Scalable.original_dpi_scale + class Widget(ABC, Scalable): # In some widgets, attempting to set a background color with any alpha value other @@ -130,6 +142,8 @@ def set_hidden(self, hidden): def set_font(self, font): self.native.Font = font._impl.native + # Required for font scaling on DPI changes + self.original_font = font._impl.native def set_color(self, color): if color is None: @@ -162,6 +176,13 @@ def remove_child(self, child): child.container = None def refresh(self): + # Update the scaling of the font + if hasattr(self, "original_font"): + self.native.Font = WinFont( + self.original_font.FontFamily, + self.scale_font(self.original_font.Size), + self.original_font.Style, + ) intrinsic = self.interface.intrinsic intrinsic.width = intrinsic.height = None self.rehint() diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 413f6bc8c3..d405543204 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -23,6 +23,8 @@ def __init__(self, interface, title, position, size): self.native.FormClosing += WeakrefCallable(self.winforms_FormClosing) super().__init__(self.native) + self.update_scale() + self.native.MinimizeBox = self.interface.minimizable self.native.MaximizeBox = self.interface.resizable @@ -37,6 +39,11 @@ def __init__(self, interface, title, position, size): self.set_full_screen(self.interface.full_screen) + def update_scale(self): + Scalable.update_scale( + self=self, screen=WinForms.Screen.FromControl(self.native) + ) + def create_toolbar(self): if self.interface.toolbar: if self.toolbar_native: From 65c6bd791a632aea8b3565e378687d71097a52a8 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sat, 21 Oct 2023 04:01:33 -0700 Subject: [PATCH 09/80] Empty commit From 21d1f561c8894b84555712ca6f71479a15e0ffc0 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sat, 21 Oct 2023 04:03:24 -0700 Subject: [PATCH 10/80] Miscellaneous fixes --- winforms/src/toga_winforms/app.py | 2 +- winforms/src/toga_winforms/widgets/base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 31a9f83b38..05ad6d99f8 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -4,12 +4,12 @@ import threading from ctypes import c_bool, c_void_p, windll +import System.Windows.Forms as WinForms from Microsoft.Win32 import SystemEvents from System import Environment, Threading from System.Drawing import Font as WinFont from System.Media import SystemSounds from System.Net import SecurityProtocolType, ServicePointManager -import System.Windows.Forms as WinForms from System.Windows.Threading import Dispatcher import toga diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 3a2397049a..edcd8135eb 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -4,10 +4,10 @@ from System.Drawing import ( Color, + Font as WinFont, Point, Size, SystemColors, - Font as WinFont, ) from System.Windows.Forms import Screen from travertino.size import at_least From ebd1c2993bec19019ddf4fd4ff6fed8b6560ad19 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 22 Oct 2023 23:10:20 -0700 Subject: [PATCH 11/80] Fixed Hwnd Related Bugs. --- winforms/src/toga_winforms/widgets/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index edcd8135eb..095b09ec97 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -80,6 +80,8 @@ def __init__(self, interface): self._container = None self.native = None self.create() + # Required to prevent Hwnd Related Bugs + self.native.CreateGraphics().Dispose() self.interface.style.reapply() @abstractmethod From 4e4addc5297b9f9a9917b5e09dad62cf14ac70ff Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 22 Oct 2023 23:53:56 -0700 Subject: [PATCH 12/80] Fixed menubar clipping bug. --- winforms/src/toga_winforms/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 05ad6d99f8..72bf47b784 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -357,6 +357,7 @@ def winforms_DisplaySettingsChanged(self, sender, event): window._impl.update_menubar_font_scale() for widget in window.widgets: widget.refresh() + window._impl.resize_content() class DocumentApp(App): From 7aeeb7c9801b1898ebb1cff64354eb8f3920d404 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 22 Oct 2023 23:55:01 -0700 Subject: [PATCH 13/80] Miscellaneous fixes --- winforms/src/toga_winforms/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 72bf47b784..ce64db0436 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -63,7 +63,6 @@ class App(Scalable): # SetProcessDpiAwarenessContext returns False on Failure if not windll.user32.SetProcessDpiAwarenessContext(-4): print("WARNING: Failed to set the DPI Awareness mode for the app.") - # Any other version of windows should use SetProcessDPIAware() else: print( "WARNING: Your Windows version doesn't support DPI Awareness setting. " From 0ff2ec60b1f4fe727b83b07942ce16f91c807f12 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Mon, 23 Oct 2023 00:11:32 -0700 Subject: [PATCH 14/80] Empty commit for CI From 1393b7d4ed1832e3c4e382b2f1d28d0615fdce86 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 25 Oct 2023 17:32:11 -0700 Subject: [PATCH 15/80] Empty commit for CI From 796db51187256e604377129e1504f9ee0b8b36e2 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Thu, 26 Oct 2023 17:42:59 -0700 Subject: [PATCH 16/80] Added Support for Scaling Stack Trace Dialogs. --- winforms/src/toga_winforms/app.py | 4 ++ winforms/src/toga_winforms/dialogs.py | 78 ++++++++++++++++++++------- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 8456473316..01743a9b1b 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -24,6 +24,8 @@ class MainWindow(Window): def update_menubar_font_scale(self): + # Directly using self.native.MainMenuStrip.Font instead of + # original_menubar_font makes the menubar font to not scale down. self.native.MainMenuStrip.Font = WinFont( self.original_menubar_font.FontFamily, self.scale_font(self.original_menubar_font.Size), @@ -357,6 +359,8 @@ def winforms_DisplaySettingsChanged(self, sender, event): for widget in window.widgets: widget.refresh() window._impl.resize_content() + if hasattr(window._impl, "current_stack_trace_dialog_impl"): + window._impl.current_stack_trace_dialog_impl.resize_content() class DocumentApp(App): diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 5b82d9e5ca..9315b9006b 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -8,11 +8,13 @@ Font as WinFont, FontFamily, FontStyle, + Rectangle, SystemFonts, ) from System.Windows.Forms import DialogResult, MessageBoxButtons, MessageBoxIcon from .libs.wrapper import WeakrefCallable +from .widgets.base import Scalable class BaseDialog(ABC): @@ -96,24 +98,25 @@ def __init__(self, interface, title, message, on_result=None): ) -class StackTraceDialog(BaseDialog): +class StackTraceDialog(BaseDialog, Scalable): def __init__(self, interface, title, message, content, retry, on_result): super().__init__(interface, on_result) self.native = WinForms.Form() + self.interface.window._impl.current_stack_trace_dialog_impl = self self.native.MinimizeBox = False self.native.FormBorderStyle = self.native.FormBorderStyle.FixedSingle self.native.MaximizeBox = False self.native.FormClosing += WeakrefCallable(self.winforms_FormClosing) - self.native.Width = 540 - self.native.Height = 320 + self.native.Width = self.scale_in(540) + self.native.Height = self.scale_in(320) self.native.Text = title # The top-of-page introductory message textLabel = WinForms.Label() - textLabel.Left = 10 - textLabel.Top = 10 - textLabel.Width = 520 + textLabel.Left = self.scale_in(10) + textLabel.Top = self.scale_in(10) + textLabel.Width = self.scale_in(520) textLabel.Alignment = ContentAlignment.MiddleCenter textLabel.Text = message @@ -121,15 +124,15 @@ def __init__(self, interface, title, message, content, retry, on_result): # A scrolling text box for the stack trace. trace = WinForms.RichTextBox() - trace.Left = 10 - trace.Top = 30 - trace.Width = 504 - trace.Height = 210 + trace.Left = self.scale_in(10) + trace.Top = self.scale_in(30) + trace.Width = self.scale_in(504) + trace.Height = self.scale_in(210) trace.Multiline = True trace.ReadOnly = True trace.Font = WinFont( FontFamily.GenericMonospace, - float(SystemFonts.DefaultFont.Size), + float(SystemFonts.MessageBoxFont.Size), FontStyle.Regular, ) trace.Text = content @@ -139,32 +142,52 @@ def __init__(self, interface, title, message, content, retry, on_result): # Add acceptance/close buttons if retry: retry = WinForms.Button() - retry.Left = 290 - retry.Top = 250 - retry.Width = 100 + retry.Left = self.scale_in(290) + retry.Top = self.scale_in(250) + retry.Width = self.scale_in(100) retry.Text = "&Retry" retry.Click += WeakrefCallable(self.winforms_Click_retry) self.native.Controls.Add(retry) quit = WinForms.Button() - quit.Left = 400 - quit.Top = 250 - quit.Width = 100 + quit.Left = self.scale_in(400) + quit.Top = self.scale_in(250) + quit.Width = self.scale_in(100) quit.Text = "&Quit" quit.Click += WeakrefCallable(self.winforms_Click_quit) self.native.Controls.Add(quit) else: accept = WinForms.Button() - accept.Left = 400 - accept.Top = 250 - accept.Width = 100 + accept.Left = self.scale_in(400) + accept.Top = self.scale_in(250) + accept.Width = self.scale_in(100) accept.Text = "&OK" accept.Click += WeakrefCallable(self.winforms_Click_accept) self.native.Controls.Add(accept) + self.original_control_fonts = dict() + self.original_control_bounds = dict() + for control in self.native.Controls: + self.original_control_fonts[control] = control.Font + + if isinstance(control, WinForms.Button): + self.original_control_bounds[control] = Rectangle( + self.scale_out(control.Bounds.X), + self.scale_out(control.Bounds.Y), + self.scale_out(control.PreferredSize.Width), + self.scale_out(control.PreferredSize.Height), + ) + else: + self.original_control_bounds[control] = Rectangle( + self.scale_out(control.Bounds.X), + self.scale_out(control.Bounds.Y), + self.scale_out(control.Bounds.Width), + self.scale_out(control.Bounds.Height), + ) + self.start_inner_loop(self.native.ShowDialog) def winforms_FormClosing(self, sender, event): @@ -180,6 +203,7 @@ def winforms_FormClosing(self, sender, event): def set_result(self, result): super().set_result(result) self.native.Close() + del self.interface.window._impl.current_stack_trace_dialog_impl def winforms_Click_quit(self, sender, event): self.set_result(False) @@ -190,6 +214,20 @@ def winforms_Click_retry(self, sender, event): def winforms_Click_accept(self, sender, event): self.set_result(None) + def resize_content(self): + for control in self.native.Controls: + control.Font = WinFont( + self.original_control_fonts[control].FontFamily, + self.scale_font(self.original_control_fonts[control].Size), + self.original_control_fonts[control].Style, + ) + control.Bounds = Rectangle( + self.scale_in(self.original_control_bounds[control].X), + self.scale_in(self.original_control_bounds[control].Y), + self.scale_in(self.original_control_bounds[control].Width), + self.scale_in(self.original_control_bounds[control].Height), + ) + class FileDialog(BaseDialog): def __init__( From ec7617dda9da52f03ba29e57e7f421595e3df745 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Fri, 27 Oct 2023 08:45:28 -0700 Subject: [PATCH 17/80] Miscellaneous fixes --- changes/2155.bugfix.rst | 2 +- winforms/src/toga_winforms/app.py | 4 +++- winforms/src/toga_winforms/dialogs.py | 6 +++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/changes/2155.bugfix.rst b/changes/2155.bugfix.rst index 0293e281e4..43d7fceae6 100644 --- a/changes/2155.bugfix.rst +++ b/changes/2155.bugfix.rst @@ -1 +1 @@ -Bugs affecting DPI scaling on Windows were fixed. +DPI scaling on Windows was improved and related bugs were fixed. diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 01743a9b1b..c45db253b9 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -103,7 +103,9 @@ def create(self): self.native.SetCompatibleTextRenderingDefault(False) # Register the DisplaySettingsChanged event handler - SystemEvents.DisplaySettingsChanged += self.winforms_DisplaySettingsChanged + SystemEvents.DisplaySettingsChanged += WeakrefCallable( + self.winforms_DisplaySettingsChanged + ) # Ensure that TLS1.2 and TLS1.3 are enabled for HTTPS connections. # For some reason, some Windows installs have these protocols diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 9315b9006b..a7c13856fe 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -103,7 +103,10 @@ def __init__(self, interface, title, message, content, retry, on_result): super().__init__(interface, on_result) self.native = WinForms.Form() + + # Required for scaling on DPI changes self.interface.window._impl.current_stack_trace_dialog_impl = self + self.native.MinimizeBox = False self.native.FormBorderStyle = self.native.FormBorderStyle.FixedSingle self.native.MaximizeBox = False @@ -168,11 +171,11 @@ def __init__(self, interface, title, message, content, retry, on_result): self.native.Controls.Add(accept) + # Required for scaling self.original_control_fonts = dict() self.original_control_bounds = dict() for control in self.native.Controls: self.original_control_fonts[control] = control.Font - if isinstance(control, WinForms.Button): self.original_control_bounds[control] = Rectangle( self.scale_out(control.Bounds.X), @@ -203,6 +206,7 @@ def winforms_FormClosing(self, sender, event): def set_result(self, result): super().set_result(result) self.native.Close() + # Remove the attribute when the dialog closes del self.interface.window._impl.current_stack_trace_dialog_impl def winforms_Click_quit(self, sender, event): From a4ff1966d534436f92196bf5d1c90d6604aa36a5 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Fri, 27 Oct 2023 08:49:41 -0700 Subject: [PATCH 18/80] Miscellaneous fixes --- winforms/src/toga_winforms/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index c45db253b9..086bfdf6bd 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -59,7 +59,7 @@ class App(Scalable): # Represents Windows 10 Build 1703 and beyond which should use # SetProcessDpiAwarenessContext(-4) for DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 # Valid values: https://learn.microsoft.com/en-us/windows/win32/hidpi/dpi-awareness-context - if win_version.Major == 10 and win_version.Build >= 15063: + if win_version.Major >= 10 and win_version.Build >= 15063: windll.user32.SetProcessDpiAwarenessContext.restype = c_bool windll.user32.SetProcessDpiAwarenessContext.argtypes = [c_void_p] # SetProcessDpiAwarenessContext returns False on Failure From 9e3685aa5a14a758539b04a7504cd28fe3b540b8 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Fri, 27 Oct 2023 09:06:38 -0700 Subject: [PATCH 19/80] Miscellaneous fixes --- winforms/src/toga_winforms/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 086bfdf6bd..b87cc6b3cf 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -59,7 +59,9 @@ class App(Scalable): # Represents Windows 10 Build 1703 and beyond which should use # SetProcessDpiAwarenessContext(-4) for DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 # Valid values: https://learn.microsoft.com/en-us/windows/win32/hidpi/dpi-awareness-context - if win_version.Major >= 10 and win_version.Build >= 15063: + if (win_version.Major > 10) or ( + win_version.Major == 10 and win_version.Build >= 15063 + ): windll.user32.SetProcessDpiAwarenessContext.restype = c_bool windll.user32.SetProcessDpiAwarenessContext.argtypes = [c_void_p] # SetProcessDpiAwarenessContext returns False on Failure From 6f8019caa55975cad9874edb4ea174a1691999c8 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Thu, 2 Nov 2023 22:47:56 -0700 Subject: [PATCH 20/80] Empty commit for CI --- winforms/src/toga_winforms/widgets/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index fce97e5bc9..c4494d5f14 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -185,7 +185,7 @@ def refresh(self): self.scale_font(self.original_font.Size), self.original_font.Style, ) - + # Default values; may be overwritten by rehint(). self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) From 407156e1781b4444d16c71ea344458a6755feff9 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Fri, 3 Nov 2023 05:10:30 -0700 Subject: [PATCH 21/80] Added support for scaling window toolbar --- winforms/src/toga_winforms/app.py | 2 ++ winforms/src/toga_winforms/window.py | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 8c98fefa36..0bdddd9f5a 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -364,6 +364,8 @@ def hide_cursor(self): def winforms_DisplaySettingsChanged(self, sender, event): for window in self.interface.windows: window._impl.update_scale() + if window._impl.toolbar_native is not None: + window._impl.update_toolbar_font_scale() if isinstance(window._impl, MainWindow): window._impl.update_menubar_font_scale() for widget in window.widgets: diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 2fda2d151e..4d9f7422c4 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -1,5 +1,5 @@ import System.Windows.Forms as WinForms -from System.Drawing import Bitmap, Graphics, Point, Size +from System.Drawing import Bitmap, Font as WinFont, Graphics, Point, Size from System.Drawing.Imaging import ImageFormat from System.IO import MemoryStream @@ -72,13 +72,21 @@ def create_toolbar(self): item.Click += WeakrefCallable(cmd._impl.winforms_handler) cmd._impl.native.append(item) self.toolbar_native.Items.Add(item) - + self.original_toolbar_font = self.toolbar_native.Font elif self.toolbar_native: self.native.Controls.Remove(self.toolbar_native) self.toolbar_native = None self.resize_content() + def update_toolbar_font_scale(self): + if self.toolbar_native is not None: + self.toolbar_native.Font = WinFont( + self.original_toolbar_font.FontFamily, + self.scale_font(self.original_toolbar_font.Size), + self.original_toolbar_font.Style, + ) + def get_position(self): location = self.native.Location return tuple(map(self.scale_out, (location.X, location.Y))) From 550839cd96494ff9351efee2e2fb09532148f70b Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 5 Nov 2023 09:17:57 -0800 Subject: [PATCH 22/80] Added tests --- testbed/tests/test_app.py | 84 +++++++++++++++++++ winforms/src/toga_winforms/app.py | 16 +++- winforms/src/toga_winforms/widgets/base.py | 27 +++--- winforms/src/toga_winforms/window.py | 18 ++-- winforms/tests_backend/app.py | 97 +++++++++++++++++++++- 5 files changed, 217 insertions(+), 25 deletions(-) diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 326860a6a8..423d2a1e1c 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -1,3 +1,5 @@ +import io +import traceback from unittest.mock import Mock import pytest @@ -550,3 +552,85 @@ async def test_beep(app): # can be invoked without raising an error, but there's no way to verify that the app # actually made a noise. app.beep() + + +async def test_system_dpi_change( + monkeypatch, app, app_probe, main_window, main_window_probe +): + # For restoring original behavior after completion of test. + original_values = dict() + # For toolbar + main_window.toolbar.add(app.cmd1, app.cmd2) + # For stack trace dialog + on_result_handler = Mock() + stack = io.StringIO() + traceback.print_stack(file=stack) + dialog_result = main_window.stack_trace_dialog( + "Stack Trace", + "Some stack trace", + stack.getvalue(), + retry=True, + on_result=on_result_handler, + ) + + # Setup Mock values for testing + original_values["update_scale"] = main_window._impl.update_scale + update_scale_mock = Mock() + monkeypatch.setattr(main_window._impl, "update_scale", update_scale_mock) + original_values["resize_content"] = main_window._impl.resize_content + resize_content_mock = Mock() + monkeypatch.setattr(main_window._impl, "resize_content", resize_content_mock) + original_values["dpi_scale"] = main_window._impl.dpi_scale + # Explicitly set the dpi_scale for testing + main_window._impl.dpi_scale = 1.5 + + await main_window_probe.redraw( + "Triggering DPI change event for testing property changes" + ) + app_probe.trigger_dpi_change_event() + + # Test out properties which should change on dpi change + main_window._impl.update_scale.assert_called_once() + main_window._impl.update_scale.reset_mock() + app_probe.assert_main_window_toolbar_font_scale_updated() + app_probe.assert_main_window_menubar_font_scale_updated() + app_probe.assert_main_window_widgets_font_scale_updated() + main_window._impl.resize_content.assert_called_once() + main_window._impl.resize_content.reset_mock() + app_probe.assert_main_window_stack_trace_dialog_scale_updated() + + # Test if widget.refresh is called once on each widget + for widget in main_window.widgets: + original_values[id(widget)] = widget.refresh + monkeypatch.setattr(widget, "refresh", Mock()) + + await main_window_probe.redraw( + "Triggering DPI change event for testing widget refresh calls" + ) + app_probe.trigger_dpi_change_event() + + for widget in main_window.widgets: + widget.refresh.assert_called_once() + + # Restore original state + for widget in main_window.widgets: + monkeypatch.setattr(widget, "refresh", original_values[id(widget)]) + monkeypatch.setattr( + main_window._impl, "resize_content", original_values["resize_content"] + ) + monkeypatch.setattr( + main_window._impl, "update_scale", original_values["update_scale"] + ) + + # When dpi_scale is None then calculates dpi_scale should be equal to + # dpi scale of Primary Screen + main_window._impl.dpi_scale = None + app_probe.assert_dpi_scale_equal_to_primary_screen_dpi_scale() + + # Restore original state + await main_window_probe.redraw( + "Triggering DPI change event for restoring original state" + ) + app_probe.trigger_dpi_change_event() + await main_window_probe.close_stack_trace_dialog(dialog_result._impl, True) + app.main_window.toolbar.clear() diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 0bdddd9f5a..5a78dfcf32 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -362,17 +362,31 @@ def hide_cursor(self): self._cursor_visible = False def winforms_DisplaySettingsChanged(self, sender, event): + # Print statements added only for testing, will be removed + # in final code cleanup. for window in self.interface.windows: - window._impl.update_scale() + window._impl.update_scale( + screen=WinForms.Screen.FromControl(window._impl.native) + ) if window._impl.toolbar_native is not None: + print("About to update toolbar font scale...") window._impl.update_toolbar_font_scale() + print("Done...") if isinstance(window._impl, MainWindow): + print("About to update menubar font scale...") window._impl.update_menubar_font_scale() + print("Done...") for widget in window.widgets: + print("About to update menubar font scale...") widget.refresh() + print("Done...") + print("About to resize window content...") window._impl.resize_content() + print("Done...") if hasattr(window._impl, "current_stack_trace_dialog_impl"): + print("About to resize stack trace dialog...") window._impl.current_stack_trace_dialog_impl.resize_content() + print("Done...") class DocumentApp(App): # pragma: no cover diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index c4494d5f14..cbee5c2241 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -18,6 +18,17 @@ class Scalable: SCALE_DEFAULT_ROUNDING = ROUND_HALF_EVEN + _dpi_scale = None + + @property + def dpi_scale(self): + if Scalable._dpi_scale is None: + self.update_scale() + return Scalable._dpi_scale + + @dpi_scale.setter + def dpi_scale(self, value): + Scalable._dpi_scale = value def update_scale(self, screen=None): # Doing screen=Screen.PrimaryScreen in method signature will make @@ -37,24 +48,20 @@ def update_scale(self, screen=None): hMonitor = windll.user32.MonitorFromRect(screen_rect, 2) pScale = wintypes.UINT() windll.shcore.GetScaleFactorForMonitor(c_void_p(hMonitor), byref(pScale)) - Scalable.dpi_scale = pScale.value / 100 + Scalable._dpi_scale = pScale.value / 100 if not hasattr(Scalable, "original_dpi_scale"): - Scalable.original_dpi_scale = Scalable.dpi_scale + Scalable.original_dpi_scale = Scalable._dpi_scale # Convert CSS pixels to native pixels def scale_in(self, value, rounding=SCALE_DEFAULT_ROUNDING): - if not hasattr(Scalable, "dpi_scale"): - self.update_scale() - return self.scale_round(value * Scalable.dpi_scale, rounding) + return self.scale_round(value * self.dpi_scale, rounding) # Convert native pixels to CSS pixels def scale_out(self, value, rounding=SCALE_DEFAULT_ROUNDING): - if not hasattr(Scalable, "dpi_scale"): - self.update_scale() if isinstance(value, at_least): return at_least(self.scale_out(value.value, rounding)) else: - return self.scale_round(value / Scalable.dpi_scale, rounding) + return self.scale_round(value / self.dpi_scale, rounding) def scale_round(self, value, rounding): if rounding is None: @@ -62,9 +69,7 @@ def scale_round(self, value, rounding): return int(Decimal(value).to_integral(rounding)) def scale_font(self, value): - if not hasattr(Scalable, "dpi_scale"): - self.update_scale() - return value * Scalable.dpi_scale / Scalable.original_dpi_scale + return value * self.dpi_scale / Scalable.original_dpi_scale class Widget(ABC, Scalable): diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 4d9f7422c4..4be8242531 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -25,7 +25,7 @@ def __init__(self, interface, title, position, size): self.native.FormClosing += WeakrefCallable(self.winforms_FormClosing) super().__init__(self.native) - self.update_scale() + self.update_scale(screen=WinForms.Screen.FromControl(self.native)) self.native.MinimizeBox = self.interface.minimizable self.native.MaximizeBox = self.interface.resizable @@ -41,11 +41,6 @@ def __init__(self, interface, title, position, size): self.set_full_screen(self.interface.full_screen) - def update_scale(self): - Scalable.update_scale( - self=self, screen=WinForms.Screen.FromControl(self.native) - ) - def create_toolbar(self): if self.interface.toolbar: if self.toolbar_native: @@ -80,12 +75,11 @@ def create_toolbar(self): self.resize_content() def update_toolbar_font_scale(self): - if self.toolbar_native is not None: - self.toolbar_native.Font = WinFont( - self.original_toolbar_font.FontFamily, - self.scale_font(self.original_toolbar_font.Size), - self.original_toolbar_font.Style, - ) + self.toolbar_native.Font = WinFont( + self.original_toolbar_font.FontFamily, + self.scale_font(self.original_toolbar_font.Size), + self.original_toolbar_font.Style, + ) def get_position(self): location = self.native.Location diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 986fe574f7..18b6249d2c 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -1,11 +1,12 @@ import ctypes +from ctypes import byref, c_void_p, windll, wintypes from pathlib import Path from time import sleep import pytest from System import EventArgs from System.Drawing import Point -from System.Windows.Forms import Application, Cursor +from System.Windows.Forms import Application, Cursor, Screen as WinScreen from .probe import BaseProbe from .window import WindowProbe @@ -153,3 +154,97 @@ def activate_menu_minimize(self): def keystroke(self, combination): pytest.xfail("Not applicable to this backend") + + def trigger_dpi_change_event(self): + self.app._impl.winforms_DisplaySettingsChanged(None, None) + + def assert_main_window_menubar_font_scale_updated(self): + main_window_impl = self.main_window._impl + assert ( + main_window_impl.native.MainMenuStrip.Font.FontFamily.Name + == main_window_impl.original_menubar_font.FontFamily.Name + ) + assert ( + main_window_impl.native.MainMenuStrip.Font.Size + == main_window_impl.scale_font(main_window_impl.original_menubar_font.Size) + ) + assert ( + main_window_impl.native.MainMenuStrip.Font.Style + == main_window_impl.original_menubar_font.Style + ) + + def assert_main_window_toolbar_font_scale_updated(self): + main_window_impl = self.main_window._impl + assert ( + main_window_impl.toolbar_native.Font.FontFamily.Name + == main_window_impl.original_toolbar_font.FontFamily.Name + ) + assert main_window_impl.toolbar_native.Font.Size == main_window_impl.scale_font( + main_window_impl.original_toolbar_font.Size + ) + assert ( + main_window_impl.toolbar_native.Font.Style + == main_window_impl.original_toolbar_font.Style + ) + + def assert_main_window_widgets_font_scale_updated(self): + for widget in self.main_window.widgets: + assert ( + widget._impl.native.Font.FontFamily.Name + == widget._impl.original_font.FontFamily.Name + ) + assert widget._impl.native.Font.Size == widget._impl.scale_font( + widget._impl.original_font.Size + ) + assert widget._impl.native.Font.Style == widget._impl.original_font.Style + + def assert_main_window_stack_trace_dialog_scale_updated(self): + stack_trace_dialog_impl = ( + self.app.main_window._impl.current_stack_trace_dialog_impl + ) + for control in stack_trace_dialog_impl.native.Controls: + # Assert Font + assert ( + control.Font.FontFamily.Name + == stack_trace_dialog_impl.original_control_fonts[ + control + ].FontFamily.Name + ) + assert control.Font.Size == stack_trace_dialog_impl.scale_font( + stack_trace_dialog_impl.original_control_fonts[control].Size + ) + assert ( + control.Font.Style + == stack_trace_dialog_impl.original_control_fonts[control].Style + ) + + # Assert Bounds + assert control.Bounds.X == stack_trace_dialog_impl.scale_in( + stack_trace_dialog_impl.original_control_bounds[control].X + ) + assert control.Bounds.Y == stack_trace_dialog_impl.scale_in( + stack_trace_dialog_impl.original_control_bounds[control].Y + ) + assert control.Bounds.Width == stack_trace_dialog_impl.scale_in( + stack_trace_dialog_impl.original_control_bounds[control].Width + ) + assert control.Bounds.Height == stack_trace_dialog_impl.scale_in( + stack_trace_dialog_impl.original_control_bounds[control].Height + ) + + def assert_dpi_scale_equal_to_primary_screen_dpi_scale(self): + screen = WinScreen.PrimaryScreen + screen_rect = wintypes.RECT( + screen.Bounds.Left, + screen.Bounds.Top, + screen.Bounds.Right, + screen.Bounds.Bottom, + ) + windll.user32.MonitorFromRect.restype = c_void_p + windll.user32.MonitorFromRect.argtypes = [wintypes.RECT, wintypes.DWORD] + # MONITOR_DEFAULTTONEAREST = 2 + hMonitor = windll.user32.MonitorFromRect(screen_rect, 2) + pScale = wintypes.UINT() + windll.shcore.GetScaleFactorForMonitor(c_void_p(hMonitor), byref(pScale)) + + assert self.main_window._impl.dpi_scale == pScale.value / 100 From 392487a22a79e4bf157041d6fc3b2877d2b74a7b Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 5 Nov 2023 09:28:24 -0800 Subject: [PATCH 23/80] Miscellaneous fixes --- testbed/tests/test_app.py | 163 +++++++++++++++++++------------------- 1 file changed, 83 insertions(+), 80 deletions(-) diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 423d2a1e1c..0c203ea5e8 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -554,83 +554,86 @@ async def test_beep(app): app.beep() -async def test_system_dpi_change( - monkeypatch, app, app_probe, main_window, main_window_probe -): - # For restoring original behavior after completion of test. - original_values = dict() - # For toolbar - main_window.toolbar.add(app.cmd1, app.cmd2) - # For stack trace dialog - on_result_handler = Mock() - stack = io.StringIO() - traceback.print_stack(file=stack) - dialog_result = main_window.stack_trace_dialog( - "Stack Trace", - "Some stack trace", - stack.getvalue(), - retry=True, - on_result=on_result_handler, - ) - - # Setup Mock values for testing - original_values["update_scale"] = main_window._impl.update_scale - update_scale_mock = Mock() - monkeypatch.setattr(main_window._impl, "update_scale", update_scale_mock) - original_values["resize_content"] = main_window._impl.resize_content - resize_content_mock = Mock() - monkeypatch.setattr(main_window._impl, "resize_content", resize_content_mock) - original_values["dpi_scale"] = main_window._impl.dpi_scale - # Explicitly set the dpi_scale for testing - main_window._impl.dpi_scale = 1.5 - - await main_window_probe.redraw( - "Triggering DPI change event for testing property changes" - ) - app_probe.trigger_dpi_change_event() - - # Test out properties which should change on dpi change - main_window._impl.update_scale.assert_called_once() - main_window._impl.update_scale.reset_mock() - app_probe.assert_main_window_toolbar_font_scale_updated() - app_probe.assert_main_window_menubar_font_scale_updated() - app_probe.assert_main_window_widgets_font_scale_updated() - main_window._impl.resize_content.assert_called_once() - main_window._impl.resize_content.reset_mock() - app_probe.assert_main_window_stack_trace_dialog_scale_updated() - - # Test if widget.refresh is called once on each widget - for widget in main_window.widgets: - original_values[id(widget)] = widget.refresh - monkeypatch.setattr(widget, "refresh", Mock()) - - await main_window_probe.redraw( - "Triggering DPI change event for testing widget refresh calls" - ) - app_probe.trigger_dpi_change_event() - - for widget in main_window.widgets: - widget.refresh.assert_called_once() - - # Restore original state - for widget in main_window.widgets: - monkeypatch.setattr(widget, "refresh", original_values[id(widget)]) - monkeypatch.setattr( - main_window._impl, "resize_content", original_values["resize_content"] - ) - monkeypatch.setattr( - main_window._impl, "update_scale", original_values["update_scale"] - ) - - # When dpi_scale is None then calculates dpi_scale should be equal to - # dpi scale of Primary Screen - main_window._impl.dpi_scale = None - app_probe.assert_dpi_scale_equal_to_primary_screen_dpi_scale() - - # Restore original state - await main_window_probe.redraw( - "Triggering DPI change event for restoring original state" - ) - app_probe.trigger_dpi_change_event() - await main_window_probe.close_stack_trace_dialog(dialog_result._impl, True) - app.main_window.toolbar.clear() +# This test is windows specific +if toga.platform.current_platform == "windows": + + async def test_system_dpi_change( + monkeypatch, app, app_probe, main_window, main_window_probe + ): + # For restoring original behavior after completion of test. + original_values = dict() + # For toolbar + main_window.toolbar.add(app.cmd1, app.cmd2) + # For stack trace dialog + on_result_handler = Mock() + stack = io.StringIO() + traceback.print_stack(file=stack) + dialog_result = main_window.stack_trace_dialog( + "Stack Trace", + "Some stack trace", + stack.getvalue(), + retry=True, + on_result=on_result_handler, + ) + + # Setup Mock values for testing + original_values["update_scale"] = main_window._impl.update_scale + update_scale_mock = Mock() + monkeypatch.setattr(main_window._impl, "update_scale", update_scale_mock) + original_values["resize_content"] = main_window._impl.resize_content + resize_content_mock = Mock() + monkeypatch.setattr(main_window._impl, "resize_content", resize_content_mock) + original_values["dpi_scale"] = main_window._impl.dpi_scale + # Explicitly set the dpi_scale for testing + main_window._impl.dpi_scale = 1.5 + + await main_window_probe.redraw( + "Triggering DPI change event for testing property changes" + ) + app_probe.trigger_dpi_change_event() + + # Test out properties which should change on dpi change + main_window._impl.update_scale.assert_called_once() + main_window._impl.update_scale.reset_mock() + app_probe.assert_main_window_toolbar_font_scale_updated() + app_probe.assert_main_window_menubar_font_scale_updated() + app_probe.assert_main_window_widgets_font_scale_updated() + main_window._impl.resize_content.assert_called_once() + main_window._impl.resize_content.reset_mock() + app_probe.assert_main_window_stack_trace_dialog_scale_updated() + + # Test if widget.refresh is called once on each widget + for widget in main_window.widgets: + original_values[id(widget)] = widget.refresh + monkeypatch.setattr(widget, "refresh", Mock()) + + await main_window_probe.redraw( + "Triggering DPI change event for testing widget refresh calls" + ) + app_probe.trigger_dpi_change_event() + + for widget in main_window.widgets: + widget.refresh.assert_called_once() + + # Restore original state + for widget in main_window.widgets: + monkeypatch.setattr(widget, "refresh", original_values[id(widget)]) + monkeypatch.setattr( + main_window._impl, "resize_content", original_values["resize_content"] + ) + monkeypatch.setattr( + main_window._impl, "update_scale", original_values["update_scale"] + ) + + # When dpi_scale is None then calculates dpi_scale should be equal to + # dpi scale of Primary Screen + main_window._impl.dpi_scale = None + app_probe.assert_dpi_scale_equal_to_primary_screen_dpi_scale() + + # Restore original state + await main_window_probe.redraw( + "Triggering DPI change event for restoring original state" + ) + app_probe.trigger_dpi_change_event() + await main_window_probe.close_stack_trace_dialog(dialog_result._impl, True) + app.main_window.toolbar.clear() From 2d5907820a18dc66c8574eb5d87d8027da07e398 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Mon, 6 Nov 2023 13:22:57 -0800 Subject: [PATCH 24/80] Fixed tests --- testbed/tests/test_app.py | 108 ++++++++++++++++----- winforms/src/toga_winforms/app.py | 16 +-- winforms/src/toga_winforms/widgets/base.py | 2 +- winforms/tests_backend/app.py | 8 +- 4 files changed, 93 insertions(+), 41 deletions(-) diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 0c203ea5e8..8620d2f4b0 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -562,6 +562,7 @@ async def test_system_dpi_change( ): # For restoring original behavior after completion of test. original_values = dict() + # --------------------------------- Set up for testing --------------------------------- # For toolbar main_window.toolbar.add(app.cmd1, app.cmd2) # For stack trace dialog @@ -576,17 +577,48 @@ async def test_system_dpi_change( on_result=on_result_handler, ) - # Setup Mock values for testing - original_values["update_scale"] = main_window._impl.update_scale - update_scale_mock = Mock() - monkeypatch.setattr(main_window._impl, "update_scale", update_scale_mock) - original_values["resize_content"] = main_window._impl.resize_content - resize_content_mock = Mock() - monkeypatch.setattr(main_window._impl, "resize_content", resize_content_mock) - original_values["dpi_scale"] = main_window._impl.dpi_scale + # ----------------------- Setup Mock values for testing ----------------------- + # For main_window + original_values["main_window_update_scale"] = main_window._impl.update_scale + main_window_update_scale_mock = Mock() + monkeypatch.setattr( + main_window._impl, "update_scale", main_window_update_scale_mock + ) + original_values["main_window_resize_content"] = main_window._impl.resize_content + main_window_resize_content_mock = Mock() + monkeypatch.setattr( + main_window._impl, "resize_content", main_window_resize_content_mock + ) + + window1 = toga.Window("Test Window 1") + window1.content = toga.Box() + window1_probe = window_probe(app, window1) + window1.show() + await window1_probe.wait_for_window("Extra windows added") + + # For window1 + original_values["window1_update_scale"] = window1._impl.update_scale + window1_update_scale_mock = Mock() + monkeypatch.setattr(window1._impl, "update_scale", window1_update_scale_mock) + original_values["window1_resize_content"] = window1._impl.resize_content + window1_resize_content_mock = Mock() + monkeypatch.setattr( + window1._impl, "resize_content", window1_resize_content_mock + ) + original_values[ + "window1_update_toolbar_font_scale" + ] = window1._impl.update_toolbar_font_scale + window1_update_toolbar_font_scale_mock = Mock() + monkeypatch.setattr( + window1._impl, + "update_toolbar_font_scale", + window1_update_toolbar_font_scale_mock, + ) + # ----------------------------------------------------------------------------- # Explicitly set the dpi_scale for testing main_window._impl.dpi_scale = 1.5 - + window1._impl.dpi_scale = 1.5 + # -------------------------------------------------------------------------------------- await main_window_probe.redraw( "Triggering DPI change event for testing property changes" ) @@ -594,46 +626,74 @@ async def test_system_dpi_change( # Test out properties which should change on dpi change main_window._impl.update_scale.assert_called_once() - main_window._impl.update_scale.reset_mock() + window1._impl.update_scale.assert_called_once() + assert main_window_probe.has_toolbar() app_probe.assert_main_window_toolbar_font_scale_updated() + assert not window1_probe.has_toolbar() + window1._impl.update_toolbar_font_scale.assert_not_called() app_probe.assert_main_window_menubar_font_scale_updated() + assert not hasattr(window1._impl, "update_menubar_font_scale") app_probe.assert_main_window_widgets_font_scale_updated() main_window._impl.resize_content.assert_called_once() - main_window._impl.resize_content.reset_mock() + window1._impl.resize_content.assert_called_once() app_probe.assert_main_window_stack_trace_dialog_scale_updated() + assert not hasattr(window1._impl, "current_stack_trace_dialog_impl") # Test if widget.refresh is called once on each widget - for widget in main_window.widgets: - original_values[id(widget)] = widget.refresh - monkeypatch.setattr(widget, "refresh", Mock()) + for window in app.windows: + for widget in window.widgets: + original_values[id(widget)] = widget.refresh + monkeypatch.setattr(widget, "refresh", Mock()) await main_window_probe.redraw( "Triggering DPI change event for testing widget refresh calls" ) app_probe.trigger_dpi_change_event() - for widget in main_window.widgets: - widget.refresh.assert_called_once() + for window in app.windows: + for widget in main_window.widgets: + widget.refresh.assert_called_once() # Restore original state - for widget in main_window.widgets: - monkeypatch.setattr(widget, "refresh", original_values[id(widget)]) + for window in app.windows: + for widget in window.widgets: + monkeypatch.setattr(widget, "refresh", original_values[id(widget)]) + monkeypatch.setattr( + window1._impl, + "update_toolbar_font_scale", + original_values["window1_update_toolbar_font_scale"], + ) + monkeypatch.setattr( + window1._impl, "resize_content", original_values["window1_resize_content"] + ) + monkeypatch.setattr( + window1._impl, "update_scale", original_values["window1_update_scale"] + ) monkeypatch.setattr( - main_window._impl, "resize_content", original_values["resize_content"] + main_window._impl, + "resize_content", + original_values["main_window_resize_content"], ) monkeypatch.setattr( - main_window._impl, "update_scale", original_values["update_scale"] + main_window._impl, + "update_scale", + original_values["main_window_update_scale"], ) - # When dpi_scale is None then calculates dpi_scale should be equal to + # When dpi_scale is None then calculated dpi_scale should be equal to # dpi scale of Primary Screen - main_window._impl.dpi_scale = None - app_probe.assert_dpi_scale_equal_to_primary_screen_dpi_scale() + for window in app.windows: + window._impl.dpi_scale = None + app_probe.assert_dpi_scale_equal_to_primary_screen_dpi_scale(window) # Restore original state + for window in app.windows: + window._impl.dpi_scale = 1.0 await main_window_probe.redraw( "Triggering DPI change event for restoring original state" ) + app_probe.trigger_dpi_change_event() await main_window_probe.close_stack_trace_dialog(dialog_result._impl, True) - app.main_window.toolbar.clear() + main_window.toolbar.clear() + window1.close() diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 5a78dfcf32..fc00681e5a 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -66,9 +66,9 @@ class App(Scalable): windll.user32.SetProcessDpiAwarenessContext.restype = c_bool windll.user32.SetProcessDpiAwarenessContext.argtypes = [c_void_p] # SetProcessDpiAwarenessContext returns False on Failure - if not windll.user32.SetProcessDpiAwarenessContext(-4): + if not windll.user32.SetProcessDpiAwarenessContext(-4): # pragma: no cover print("WARNING: Failed to set the DPI Awareness mode for the app.") - else: + else: # pragma: no cover print( "WARNING: Your Windows version doesn't support DPI Awareness setting. " "We recommend you upgrade to at least Windows 10 Build 1703." @@ -362,31 +362,19 @@ def hide_cursor(self): self._cursor_visible = False def winforms_DisplaySettingsChanged(self, sender, event): - # Print statements added only for testing, will be removed - # in final code cleanup. for window in self.interface.windows: window._impl.update_scale( screen=WinForms.Screen.FromControl(window._impl.native) ) if window._impl.toolbar_native is not None: - print("About to update toolbar font scale...") window._impl.update_toolbar_font_scale() - print("Done...") if isinstance(window._impl, MainWindow): - print("About to update menubar font scale...") window._impl.update_menubar_font_scale() - print("Done...") for widget in window.widgets: - print("About to update menubar font scale...") widget.refresh() - print("Done...") - print("About to resize window content...") window._impl.resize_content() - print("Done...") if hasattr(window._impl, "current_stack_trace_dialog_impl"): - print("About to resize stack trace dialog...") window._impl.current_stack_trace_dialog_impl.resize_content() - print("Done...") class DocumentApp(App): # pragma: no cover diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index cbee5c2241..a853dcfcc6 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -184,7 +184,7 @@ def remove_child(self, child): def refresh(self): # Update the scaling of the font - if hasattr(self, "original_font"): + if hasattr(self, "original_font"): # pragma: no branch self.native.Font = WinFont( self.original_font.FontFamily, self.scale_font(self.original_font.Size), diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 18b6249d2c..67e55bf411 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -155,6 +155,8 @@ def activate_menu_minimize(self): def keystroke(self, combination): pytest.xfail("Not applicable to this backend") + # ------------------- Functions specific to test_system_dpi_change ------------------- + def trigger_dpi_change_event(self): self.app._impl.winforms_DisplaySettingsChanged(None, None) @@ -232,7 +234,7 @@ def assert_main_window_stack_trace_dialog_scale_updated(self): stack_trace_dialog_impl.original_control_bounds[control].Height ) - def assert_dpi_scale_equal_to_primary_screen_dpi_scale(self): + def assert_dpi_scale_equal_to_primary_screen_dpi_scale(self, window): screen = WinScreen.PrimaryScreen screen_rect = wintypes.RECT( screen.Bounds.Left, @@ -247,4 +249,6 @@ def assert_dpi_scale_equal_to_primary_screen_dpi_scale(self): pScale = wintypes.UINT() windll.shcore.GetScaleFactorForMonitor(c_void_p(hMonitor), byref(pScale)) - assert self.main_window._impl.dpi_scale == pScale.value / 100 + assert window._impl.dpi_scale == pScale.value / 100 + + # ------------------------------------------------------------------------------------ From c45aa2e56e4a7b0a98128cdca9c01717fb72de43 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Tue, 7 Nov 2023 10:37:12 -0800 Subject: [PATCH 25/80] Modified scaling code --- winforms/src/toga_winforms/app.py | 8 +-- winforms/src/toga_winforms/dialogs.py | 5 ++ winforms/src/toga_winforms/widgets/base.py | 65 ++++++++++++++-------- 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index fc00681e5a..e70be48009 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -48,6 +48,10 @@ def winforms_FormClosing(self, sender, event): class App(Scalable): _MAIN_WINDOW_CLASS = MainWindow + # These are required for properly setting up DPI mode + WinForms.Application.EnableVisualStyles() + WinForms.Application.SetCompatibleTextRenderingDefault(False) + # ------------------- Set the DPI Awareness mode for the process ------------------- # This needs to be done at the earliest and doing this in __init__() or # in create() doesn't work @@ -101,10 +105,6 @@ def create(self): self.app_context = WinForms.ApplicationContext() self.app_dispatcher = Dispatcher.CurrentDispatcher - # These are required for properly setting up DPI mode - self.native.EnableVisualStyles() - self.native.SetCompatibleTextRenderingDefault(False) - # Register the DisplaySettingsChanged event handler SystemEvents.DisplaySettingsChanged += WeakrefCallable( self.winforms_DisplaySettingsChanged diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 66e0e59651..5345ea0847 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -176,6 +176,9 @@ def __init__(self, interface, title, message, content, retry, on_result): self.original_control_bounds = dict() for control in self.native.Controls: self.original_control_fonts[control] = control.Font + # For Button controls, we use PreferredSize to determine the size because + # Buttons often adjust their preferred size based on their content (text). + # Using Bounds.Width and Bounds.Height may not reflect the actual preferred size. if isinstance(control, WinForms.Button): self.original_control_bounds[control] = Rectangle( self.scale_out(control.Bounds.X), @@ -183,6 +186,8 @@ def __init__(self, interface, title, message, content, retry, on_result): self.scale_out(control.PreferredSize.Width), self.scale_out(control.PreferredSize.Height), ) + # For other controls, we use the Bounds property to determine the size, + # which represents the actual dimensions of the control within its container. else: self.original_control_bounds[control] = Rectangle( self.scale_out(control.Bounds.X), diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index a853dcfcc6..bb28cca04e 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -12,30 +12,18 @@ from System.Windows.Forms import Screen from travertino.size import at_least +# Importing the implementation Window class will cause circular +# import error, hence we are using the interface Window class +# to find out the Window instance +from toga import Window from toga.colors import TRANSPARENT from toga_winforms.colors import native_color class Scalable: SCALE_DEFAULT_ROUNDING = ROUND_HALF_EVEN - _dpi_scale = None - - @property - def dpi_scale(self): - if Scalable._dpi_scale is None: - self.update_scale() - return Scalable._dpi_scale - - @dpi_scale.setter - def dpi_scale(self, value): - Scalable._dpi_scale = value - - def update_scale(self, screen=None): - # Doing screen=Screen.PrimaryScreen in method signature will make - # the app to become unresponsive when DPI settings are changed. - if screen is None: - screen = Screen.PrimaryScreen + def get_dpi_scale(self, screen=None): screen_rect = wintypes.RECT( screen.Bounds.Left, screen.Bounds.Top, @@ -48,9 +36,37 @@ def update_scale(self, screen=None): hMonitor = windll.user32.MonitorFromRect(screen_rect, 2) pScale = wintypes.UINT() windll.shcore.GetScaleFactorForMonitor(c_void_p(hMonitor), byref(pScale)) - Scalable._dpi_scale = pScale.value / 100 - if not hasattr(Scalable, "original_dpi_scale"): - Scalable.original_dpi_scale = Scalable._dpi_scale + return pScale.value / 100 + + @property + def dpi_scale(self): + if (self.interface is not None) and hasattr(self, "interface"): + if issubclass(type(self), Widget) and (self.interface.window is not None): + self._original_dpi_scale = ( + self.interface.window._impl._original_dpi_scale + ) + return self.interface.window._impl._dpi_scale + else: + _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) + if not hasattr(self, "_original_dpi_scale"): + self._original_dpi_scale = _dpi_scale + return _dpi_scale + elif issubclass(type(self.interface), Window): + self._dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) + if not hasattr(self, "_original_dpi_scale"): + self._original_dpi_scale = self._dpi_scale + return self._dpi_scale + else: + _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) + if not hasattr(self, "_original_dpi_scale"): + self._original_dpi_scale = _dpi_scale + return _dpi_scale + + def update_scale(self, screen=None): + if issubclass(type(self.interface), Window): + self._dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) + else: + print("WARNING: Only subclasses of Window can call this method.") # Convert CSS pixels to native pixels def scale_in(self, value, rounding=SCALE_DEFAULT_ROUNDING): @@ -69,7 +85,7 @@ def scale_round(self, value, rounding): return int(Decimal(value).to_integral(rounding)) def scale_font(self, value): - return value * self.dpi_scale / Scalable.original_dpi_scale + return value * self.dpi_scale / self._original_dpi_scale class Widget(ABC, Scalable): @@ -85,8 +101,13 @@ def __init__(self, interface): self._container = None self.native = None self.create() - # Required to prevent Hwnd Related Bugs + + # Obtain a Graphics object and immediately dispose of it.This is + # done to trigger the control's Paint event and force it to redraw. + # Since in toga, Hwnds are could be created at inappropriate times. + # This is required to prevent Hwnd Related Bugs. self.native.CreateGraphics().Dispose() + self.interface.style.reapply() @abstractmethod From 5c512cb02e80b519b431724e28338519e0c0edae Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 8 Nov 2023 17:57:16 -0800 Subject: [PATCH 26/80] Fixed Stack Trace Dialog Scaling issues --- winforms/src/toga_winforms/dialogs.py | 40 +++++++++++++++++++--- winforms/src/toga_winforms/widgets/base.py | 37 ++++++++++---------- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 5345ea0847..533adb7faf 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -119,9 +119,16 @@ def __init__(self, interface, title, message, content, retry, on_result): textLabel = WinForms.Label() textLabel.Left = self.scale_in(10) textLabel.Top = self.scale_in(10) + # Explicitly set width and Height to prevent scaling issues textLabel.Width = self.scale_in(520) + textLabel.Height = self.scale_in(20) textLabel.Alignment = ContentAlignment.MiddleCenter textLabel.Text = message + textLabel.Font = WinFont( + SystemFonts.MessageBoxFont.FontFamily, + self.scale_font(float(SystemFonts.MessageBoxFont.Size)), + SystemFonts.MessageBoxFont.Style, + ) self.native.Controls.Add(textLabel) @@ -135,7 +142,7 @@ def __init__(self, interface, title, message, content, retry, on_result): trace.ReadOnly = True trace.Font = WinFont( FontFamily.GenericMonospace, - float(SystemFonts.MessageBoxFont.Size), + self.scale_font(float(SystemFonts.MessageBoxFont.Size)), FontStyle.Regular, ) trace.Text = content @@ -147,7 +154,14 @@ def __init__(self, interface, title, message, content, retry, on_result): retry = WinForms.Button() retry.Left = self.scale_in(290) retry.Top = self.scale_in(250) + # Explicitly set width and Height to prevent scaling issues retry.Width = self.scale_in(100) + retry.Height = self.scale_in(retry.PreferredSize.Height) + retry.Font = WinFont( + SystemFonts.MessageBoxFont.FontFamily, + self.scale_font(float(SystemFonts.MessageBoxFont.Size)), + SystemFonts.MessageBoxFont.Style, + ) retry.Text = "&Retry" retry.Click += WeakrefCallable(self.winforms_Click_retry) @@ -156,7 +170,14 @@ def __init__(self, interface, title, message, content, retry, on_result): quit = WinForms.Button() quit.Left = self.scale_in(400) quit.Top = self.scale_in(250) + # Explicitly set width and Height to prevent scaling issues quit.Width = self.scale_in(100) + quit.Height = self.scale_in(quit.PreferredSize.Height) + quit.Font = WinFont( + SystemFonts.MessageBoxFont.FontFamily, + self.scale_font(float(SystemFonts.MessageBoxFont.Size)), + SystemFonts.MessageBoxFont.Style, + ) quit.Text = "&Quit" quit.Click += WeakrefCallable(self.winforms_Click_quit) @@ -165,7 +186,14 @@ def __init__(self, interface, title, message, content, retry, on_result): accept = WinForms.Button() accept.Left = self.scale_in(400) accept.Top = self.scale_in(250) + # Explicitly set width and Height to prevent scaling issues accept.Width = self.scale_in(100) + accept.Height = self.scale_in(accept.PreferredSize.Height) + accept.Font = WinFont( + SystemFonts.MessageBoxFont.FontFamily, + self.scale_font(float(SystemFonts.MessageBoxFont.Size)), + SystemFonts.MessageBoxFont.Style, + ) accept.Text = "&OK" accept.Click += WeakrefCallable(self.winforms_Click_accept) @@ -176,10 +204,12 @@ def __init__(self, interface, title, message, content, retry, on_result): self.original_control_bounds = dict() for control in self.native.Controls: self.original_control_fonts[control] = control.Font - # For Button controls, we use PreferredSize to determine the size because - # Buttons often adjust their preferred size based on their content (text). + # For Button & Label controls, we use PreferredSize to determine the size + # because they often adjust their preferred size based on their content (text). # Using Bounds.Width and Bounds.Height may not reflect the actual preferred size. - if isinstance(control, WinForms.Button): + if isinstance(control, WinForms.Button): # or isinstance( + # control, WinForms.Label + # ): self.original_control_bounds[control] = Rectangle( self.scale_out(control.Bounds.X), self.scale_out(control.Bounds.Y), @@ -227,7 +257,7 @@ def resize_content(self): for control in self.native.Controls: control.Font = WinFont( self.original_control_fonts[control].FontFamily, - self.scale_font(self.original_control_fonts[control].Size), + self.scale_font(float(self.original_control_fonts[control].Size)), self.original_control_fonts[control].Style, ) control.Bounds = Rectangle( diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index bb28cca04e..a08727cf41 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -40,33 +40,34 @@ def get_dpi_scale(self, screen=None): @property def dpi_scale(self): - if (self.interface is not None) and hasattr(self, "interface"): - if issubclass(type(self), Widget) and (self.interface.window is not None): + if ( + hasattr(self, "interface") + and (self.interface is not None) + and hasattr(self.interface, "window") + and (self.interface.window is not None) + ): + # For Widgets and Stack Trace Dialogs + if issubclass(type(self), Widget) or ( + hasattr(self.interface.window._impl, "current_stack_trace_dialog_impl") + and ( + self.interface.window._impl.current_stack_trace_dialog_impl == self + ) + ): self._original_dpi_scale = ( self.interface.window._impl._original_dpi_scale ) return self.interface.window._impl._dpi_scale - else: - _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) - if not hasattr(self, "_original_dpi_scale"): - self._original_dpi_scale = _dpi_scale - return _dpi_scale - elif issubclass(type(self.interface), Window): - self._dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) - if not hasattr(self, "_original_dpi_scale"): - self._original_dpi_scale = self._dpi_scale - return self._dpi_scale - else: - _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) - if not hasattr(self, "_original_dpi_scale"): - self._original_dpi_scale = _dpi_scale - return _dpi_scale + # For Windows and others + _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) + if not hasattr(self, "_original_dpi_scale"): + self._original_dpi_scale = _dpi_scale + return _dpi_scale def update_scale(self, screen=None): if issubclass(type(self.interface), Window): self._dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) else: - print("WARNING: Only subclasses of Window can call this method.") + print("WARNING: Only subclasses of Window can call update_scale() method.") # Convert CSS pixels to native pixels def scale_in(self, value, rounding=SCALE_DEFAULT_ROUNDING): From cbdc69a541642e7c5c28a11c06d54ab562c58784 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 8 Nov 2023 18:04:57 -0800 Subject: [PATCH 27/80] Miscellaneous Fixes --- winforms/src/toga_winforms/app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index e70be48009..92a29b552f 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -2,7 +2,7 @@ import re import sys import threading -from ctypes import c_bool, c_void_p, windll +from ctypes import c_void_p, windll, wintypes import System.Windows.Forms as WinForms from Microsoft.Win32 import SystemEvents @@ -67,10 +67,10 @@ class App(Scalable): if (win_version.Major > 10) or ( win_version.Major == 10 and win_version.Build >= 15063 ): - windll.user32.SetProcessDpiAwarenessContext.restype = c_bool + windll.user32.SetProcessDpiAwarenessContext.restype = wintypes.BOOL windll.user32.SetProcessDpiAwarenessContext.argtypes = [c_void_p] - # SetProcessDpiAwarenessContext returns False on Failure - if not windll.user32.SetProcessDpiAwarenessContext(-4): # pragma: no cover + # SetProcessDpiAwarenessContext returns False(0) on Failure + if windll.user32.SetProcessDpiAwarenessContext(-4) == 0: # pragma: no cover print("WARNING: Failed to set the DPI Awareness mode for the app.") else: # pragma: no cover print( From c5fb1b0c09bc1bcd320f84e552a647a15b1435b2 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 8 Nov 2023 18:09:45 -0800 Subject: [PATCH 28/80] Miscellaneous Fixes --- winforms/tests_backend/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 73e2352119..096f89e68f 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -158,8 +158,8 @@ def activate_menu_minimize(self): pytest.xfail("This platform doesn't have a window management menu") def keystroke(self, combination): - return winforms_to_toga_key(toga_to_winforms_key(combination)) - + return winforms_to_toga_key(toga_to_winforms_key(combination)) + # ------------------- Functions specific to test_system_dpi_change ------------------- def trigger_dpi_change_event(self): @@ -256,4 +256,4 @@ def assert_dpi_scale_equal_to_primary_screen_dpi_scale(self, window): assert window._impl.dpi_scale == pScale.value / 100 - # ------------------------------------------------------------------------------------ \ No newline at end of file + # ------------------------------------------------------------------------------------ From a670a2a4dae18317e66bb71532f3fe7a3092728b Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 8 Nov 2023 18:25:16 -0800 Subject: [PATCH 29/80] Miscellaneous Fixes From d2e5948263a2c90b9e6a87a42642244adb22a599 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Thu, 9 Nov 2023 01:49:19 -0800 Subject: [PATCH 30/80] Fixed tests --- testbed/tests/test_app.py | 12 +++------- winforms/src/toga_winforms/widgets/base.py | 27 +++++++++++----------- winforms/tests_backend/app.py | 20 +--------------- 3 files changed, 18 insertions(+), 41 deletions(-) diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 8620d2f4b0..f90d059283 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -616,8 +616,8 @@ async def test_system_dpi_change( ) # ----------------------------------------------------------------------------- # Explicitly set the dpi_scale for testing - main_window._impl.dpi_scale = 1.5 - window1._impl.dpi_scale = 1.5 + for window in app.windows: + window._impl._dpi_scale = 1.5 # -------------------------------------------------------------------------------------- await main_window_probe.redraw( "Triggering DPI change event for testing property changes" @@ -680,15 +680,9 @@ async def test_system_dpi_change( original_values["main_window_update_scale"], ) - # When dpi_scale is None then calculated dpi_scale should be equal to - # dpi scale of Primary Screen - for window in app.windows: - window._impl.dpi_scale = None - app_probe.assert_dpi_scale_equal_to_primary_screen_dpi_scale(window) - # Restore original state for window in app.windows: - window._impl.dpi_scale = 1.0 + window._impl._dpi_scale = 1.0 await main_window_probe.redraw( "Triggering DPI change event for restoring original state" ) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index a08727cf41..adf8fa63f6 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -12,10 +12,6 @@ from System.Windows.Forms import Screen from travertino.size import at_least -# Importing the implementation Window class will cause circular -# import error, hence we are using the interface Window class -# to find out the Window instance -from toga import Window from toga.colors import TRANSPARENT from toga_winforms.colors import native_color @@ -47,7 +43,7 @@ def dpi_scale(self): and (self.interface.window is not None) ): # For Widgets and Stack Trace Dialogs - if issubclass(type(self), Widget) or ( + if issubclass(type(self), Widget) or ( # pragma: no branch hasattr(self.interface.window._impl, "current_stack_trace_dialog_impl") and ( self.interface.window._impl.current_stack_trace_dialog_impl == self @@ -57,17 +53,20 @@ def dpi_scale(self): self.interface.window._impl._original_dpi_scale ) return self.interface.window._impl._dpi_scale - # For Windows and others - _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) + # For Containers + if hasattr(self, "native_content"): + _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native_content)) + else: + # For Windows and others + _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) if not hasattr(self, "_original_dpi_scale"): self._original_dpi_scale = _dpi_scale return _dpi_scale def update_scale(self, screen=None): - if issubclass(type(self.interface), Window): - self._dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) - else: - print("WARNING: Only subclasses of Window can call update_scale() method.") + # Should be called from Window class as Widgets use the dpi scale + # of the Window on which they are present. + self._dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) # Convert CSS pixels to native pixels def scale_in(self, value, rounding=SCALE_DEFAULT_ROUNDING): @@ -85,8 +84,10 @@ def scale_round(self, value, rounding): return value return int(Decimal(value).to_integral(rounding)) - def scale_font(self, value): - return value * self.dpi_scale / self._original_dpi_scale + def scale_font(self, value, rounding=SCALE_DEFAULT_ROUNDING): + return self.scale_round( + value * (self.dpi_scale / self._original_dpi_scale), rounding + ) class Widget(ABC, Scalable): diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 096f89e68f..70e5e7691d 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -1,12 +1,11 @@ import ctypes -from ctypes import byref, c_void_p, windll, wintypes from pathlib import Path from time import sleep import pytest from System import EventArgs from System.Drawing import Point -from System.Windows.Forms import Application, Cursor, Screen as WinScreen +from System.Windows.Forms import Application, Cursor from toga_winforms.keys import toga_to_winforms_key, winforms_to_toga_key @@ -239,21 +238,4 @@ def assert_main_window_stack_trace_dialog_scale_updated(self): stack_trace_dialog_impl.original_control_bounds[control].Height ) - def assert_dpi_scale_equal_to_primary_screen_dpi_scale(self, window): - screen = WinScreen.PrimaryScreen - screen_rect = wintypes.RECT( - screen.Bounds.Left, - screen.Bounds.Top, - screen.Bounds.Right, - screen.Bounds.Bottom, - ) - windll.user32.MonitorFromRect.restype = c_void_p - windll.user32.MonitorFromRect.argtypes = [wintypes.RECT, wintypes.DWORD] - # MONITOR_DEFAULTTONEAREST = 2 - hMonitor = windll.user32.MonitorFromRect(screen_rect, 2) - pScale = wintypes.UINT() - windll.shcore.GetScaleFactorForMonitor(c_void_p(hMonitor), byref(pScale)) - - assert window._impl.dpi_scale == pScale.value / 100 - # ------------------------------------------------------------------------------------ From 6aa68d793300a884a4c5619734294801ea609a77 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Thu, 9 Nov 2023 02:29:50 -0800 Subject: [PATCH 31/80] Empty commit for CI From 083d9da495227b2cd425b6acab26d29ecfc37848 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Thu, 9 Nov 2023 06:16:27 -0800 Subject: [PATCH 32/80] Added scaling support for moving between screens. --- winforms/src/toga_winforms/app.py | 13 +-------- winforms/src/toga_winforms/widgets/base.py | 4 +-- winforms/src/toga_winforms/window.py | 31 +++++++++++++++++++++- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 99975c32e3..e6b14dcce7 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -363,18 +363,7 @@ def hide_cursor(self): def winforms_DisplaySettingsChanged(self, sender, event): for window in self.interface.windows: - window._impl.update_scale( - screen=WinForms.Screen.FromControl(window._impl.native) - ) - if window._impl.toolbar_native is not None: - window._impl.update_toolbar_font_scale() - if isinstance(window._impl, MainWindow): - window._impl.update_menubar_font_scale() - for widget in window.widgets: - widget.refresh() - window._impl.resize_content() - if hasattr(window._impl, "current_stack_trace_dialog_impl"): - window._impl.current_stack_trace_dialog_impl.resize_content() + window._impl.update_window_dpi_changed() class DocumentApp(App): # pragma: no cover diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index adf8fa63f6..7cc8388ff9 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -63,8 +63,8 @@ def dpi_scale(self): self._original_dpi_scale = _dpi_scale return _dpi_scale - def update_scale(self, screen=None): - # Should be called from Window class as Widgets use the dpi scale + def update_scale(self): + # Should be called only from Window class as Widgets use the dpi scale # of the Window on which they are present. self._dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 156f4d6d83..606d0589e1 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -3,6 +3,7 @@ from System.Drawing.Imaging import ImageFormat from System.IO import MemoryStream +from toga import MainWindow from toga.command import GROUP_BREAK, SECTION_BREAK from .container import Container @@ -25,7 +26,10 @@ def __init__(self, interface, title, position, size): self.native.FormClosing += WeakrefCallable(self.winforms_FormClosing) super().__init__(self.native) - self.update_scale(screen=WinForms.Screen.FromControl(self.native)) + self.update_scale() + # # Required for detecting window moving to other screens with different dpi + # self._previous_screen = WinForms.Screen.FromPoint(self.native.Location) + self.native.LocationChanged += WeakrefCallable(self.winforms_LocationChanged) self.native.MinimizeBox = self.interface.minimizable self.native.MaximizeBox = self.interface.resizable @@ -81,6 +85,31 @@ def update_toolbar_font_scale(self): self.original_toolbar_font.Style, ) + # This method is called when the dpi scaling changes + def update_window_dpi_changed(self): + self.update_scale() + if self.toolbar_native is not None: + self.update_toolbar_font_scale() + if isinstance(self.interface, MainWindow): + self.update_menubar_font_scale() + for widget in self.interface.widgets: + widget.refresh() + self.refreshed() + self.resize_content() + if hasattr(self, "current_stack_trace_dialog_impl"): + self.current_stack_trace_dialog_impl.resize_content() + + def winforms_LocationChanged(self, sender, event): # pragma: no cover + # Check if the window has moved from one screen to another and if the new + # screen has a different dpi scale than the previous screen then rescale + current_screen = WinForms.Screen.FromControl(self.native) + if not hasattr(self, "_previous_screen"): + self._previous_screen = current_screen + if current_screen != self._previous_screen: + if self._dpi_scale != self.get_dpi_scale(current_screen): + self.update_window_dpi_changed() + self._previous_screen = current_screen + def get_position(self): location = self.native.Location return tuple(map(self.scale_out, (location.X, location.Y))) From f1ddd6fcdd9f99b5d3160f1e6d5a6684efe60acc Mon Sep 17 00:00:00 2001 From: proneon267 Date: Thu, 9 Nov 2023 07:19:58 -0800 Subject: [PATCH 33/80] Empty commit for CI From 0325388a97a2e4777a988a451fb338cb2fc66a61 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Thu, 9 Nov 2023 07:47:28 -0800 Subject: [PATCH 34/80] Empty commit for CI From 7527b392a1eb6bc75ba45e63b2f4f5d7003df21b Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sat, 11 Nov 2023 01:21:57 -0800 Subject: [PATCH 35/80] Miscellaneous Fixes --- winforms/src/toga_winforms/dialogs.py | 6 +++++- winforms/src/toga_winforms/window.py | 3 +-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 533adb7faf..174f9a05fd 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -8,6 +8,7 @@ Font as WinFont, FontFamily, FontStyle, + Point, Rectangle, SystemFonts, ) @@ -106,7 +107,10 @@ def __init__(self, interface, title, message, content, retry, on_result): # Required for scaling on DPI changes self.interface.window._impl.current_stack_trace_dialog_impl = self - + self.native.StartPosition = WinForms.FormStartPosition.Manual + self.native.Location = Point( + *map(self.scale_in, self.interface.window._impl.get_position()) + ) self.native.MinimizeBox = False self.native.FormBorderStyle = self.native.FormBorderStyle.FixedSingle self.native.MaximizeBox = False diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 606d0589e1..e51349a7c9 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -27,8 +27,7 @@ def __init__(self, interface, title, position, size): super().__init__(self.native) self.update_scale() - # # Required for detecting window moving to other screens with different dpi - # self._previous_screen = WinForms.Screen.FromPoint(self.native.Location) + # Required for detecting window moving to other screens with different dpi self.native.LocationChanged += WeakrefCallable(self.winforms_LocationChanged) self.native.MinimizeBox = self.interface.minimizable From 4b7791b34984a4df1ca4de9b0ef7fea8bf236625 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sat, 11 Nov 2023 01:54:46 -0800 Subject: [PATCH 36/80] Miscellaneous Fixes --- docs/reference/api/widgets/webview.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/api/widgets/webview.rst b/docs/reference/api/widgets/webview.rst index 6fbcf2c2a6..a4db92cbe4 100644 --- a/docs/reference/api/widgets/webview.rst +++ b/docs/reference/api/widgets/webview.rst @@ -70,7 +70,7 @@ Notes * Using WebView on Windows 10 requires that your users have installed the `Edge WebView2 Evergreen Runtime - `__. + `__. This is installed by default on Windows 11. * Using WebView on Linux requires that the user has installed the system packages From 8c422e89d9ebc98f37be0cd8c8768487395481d0 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sat, 11 Nov 2023 02:01:59 -0800 Subject: [PATCH 37/80] Miscellaneous Fixes --- docs/reference/platforms/windows.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/platforms/windows.rst b/docs/reference/platforms/windows.rst index 556606f565..6b5cec5916 100644 --- a/docs/reference/platforms/windows.rst +++ b/docs/reference/platforms/windows.rst @@ -18,7 +18,7 @@ Prerequisites If you are using Windows 10 and want to use a WebView to display web content, you will also need to install the `Edge WebView2 Evergreen Runtime. -`__ +`__ Windows 11 has this runtime installed by default. Installation From ee76a146d4b65a71bf979212de9632f3c5c2f20f Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 29 Nov 2023 09:36:36 -0800 Subject: [PATCH 38/80] Miscellaneous Fixes --- winforms/src/toga_winforms/dialogs.py | 33 ++++++++-------------- winforms/src/toga_winforms/widgets/base.py | 29 +++++++------------ 2 files changed, 22 insertions(+), 40 deletions(-) diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 174f9a05fd..f733f91f1c 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -111,6 +111,7 @@ def __init__(self, interface, title, message, content, retry, on_result): self.native.Location = Point( *map(self.scale_in, self.interface.window._impl.get_position()) ) + self.native.Move += WeakrefCallable(self.winforms_Move) self.native.MinimizeBox = False self.native.FormBorderStyle = self.native.FormBorderStyle.FixedSingle self.native.MaximizeBox = False @@ -208,27 +209,12 @@ def __init__(self, interface, title, message, content, retry, on_result): self.original_control_bounds = dict() for control in self.native.Controls: self.original_control_fonts[control] = control.Font - # For Button & Label controls, we use PreferredSize to determine the size - # because they often adjust their preferred size based on their content (text). - # Using Bounds.Width and Bounds.Height may not reflect the actual preferred size. - if isinstance(control, WinForms.Button): # or isinstance( - # control, WinForms.Label - # ): - self.original_control_bounds[control] = Rectangle( - self.scale_out(control.Bounds.X), - self.scale_out(control.Bounds.Y), - self.scale_out(control.PreferredSize.Width), - self.scale_out(control.PreferredSize.Height), - ) - # For other controls, we use the Bounds property to determine the size, - # which represents the actual dimensions of the control within its container. - else: - self.original_control_bounds[control] = Rectangle( - self.scale_out(control.Bounds.X), - self.scale_out(control.Bounds.Y), - self.scale_out(control.Bounds.Width), - self.scale_out(control.Bounds.Height), - ) + self.original_control_bounds[control] = Rectangle( + self.scale_out(control.Bounds.X), + self.scale_out(control.Bounds.Y), + self.scale_out(control.Bounds.Width), + self.scale_out(control.Bounds.Height), + ) self.start_inner_loop(self.native.ShowDialog) @@ -257,6 +243,11 @@ def winforms_Click_retry(self, sender, event): def winforms_Click_accept(self, sender, event): self.set_result(None) + def winforms_Move(self, sender, event): + self.native.Location = Point( + *map(self.scale_in, self.interface.window._impl.get_position()) + ) + def resize_content(self): for control in self.native.Controls: control.Font = WinFont( diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 7cc8388ff9..11c25dd24f 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -19,7 +19,7 @@ class Scalable: SCALE_DEFAULT_ROUNDING = ROUND_HALF_EVEN - def get_dpi_scale(self, screen=None): + def get_dpi_scale(self, screen): screen_rect = wintypes.RECT( screen.Bounds.Left, screen.Bounds.Top, @@ -36,24 +36,14 @@ def get_dpi_scale(self, screen=None): @property def dpi_scale(self): + # For Widgets and Stack Trace Dialogs which have an assigned window if ( - hasattr(self, "interface") - and (self.interface is not None) - and hasattr(self.interface, "window") - and (self.interface.window is not None) + getattr(self, "interface", None) is not None + and getattr(self.interface, "window", None) is not None ): - # For Widgets and Stack Trace Dialogs - if issubclass(type(self), Widget) or ( # pragma: no branch - hasattr(self.interface.window._impl, "current_stack_trace_dialog_impl") - and ( - self.interface.window._impl.current_stack_trace_dialog_impl == self - ) - ): - self._original_dpi_scale = ( - self.interface.window._impl._original_dpi_scale - ) - return self.interface.window._impl._dpi_scale - # For Containers + self._original_dpi_scale = self.interface.window._impl._original_dpi_scale + return self.interface.window._impl._dpi_scale + # For Container Widgets when not assigned to a window if hasattr(self, "native_content"): _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native_content)) else: @@ -104,10 +94,11 @@ def __init__(self, interface): self.native = None self.create() - # Obtain a Graphics object and immediately dispose of it.This is + # Obtain a Graphics object and immediately dispose of it. This is # done to trigger the control's Paint event and force it to redraw. # Since in toga, Hwnds are could be created at inappropriate times. - # This is required to prevent Hwnd Related Bugs. + # This is required to prevent Hwnd Related Bugs. Removing this will + # cause the OptionContainer test to fail. self.native.CreateGraphics().Dispose() self.interface.style.reapply() From d92d92955d01de53431250caab4511d5e4a1dba7 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 29 Nov 2023 10:03:26 -0800 Subject: [PATCH 39/80] Miscellaneous Fixes --- winforms/src/toga_winforms/dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index f733f91f1c..7a798e207c 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -244,7 +244,7 @@ def winforms_Click_accept(self, sender, event): self.set_result(None) def winforms_Move(self, sender, event): - self.native.Location = Point( + self.native.Location = Point( # pragma: no cover *map(self.scale_in, self.interface.window._impl.get_position()) ) From 81a04aea246c5969bf0e0d77baf8fbb4f133d8f7 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 17 Dec 2023 00:48:25 -0800 Subject: [PATCH 40/80] Miscellaneous Fixes --- winforms/src/toga_winforms/dialogs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 076f4d0ae7..678b1b3464 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -99,10 +99,10 @@ def __init__(self, interface, title, message=None): ) -class StackTraceDialog(BaseDialog): +class StackTraceDialog(BaseDialog, Scalable): def __init__(self, interface, title, message, content, retry): super().__init__(interface) - + self.native = WinForms.Form() # Required for scaling on DPI changes From acf13d39294c905d4715d06f2793c3c21eb93fba Mon Sep 17 00:00:00 2001 From: proneon267 Date: Fri, 22 Dec 2023 05:41:28 -0800 Subject: [PATCH 41/80] Miscellaneous Fixes --- testbed/tests/test_app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index f90d059283..41a8092424 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -566,7 +566,6 @@ async def test_system_dpi_change( # For toolbar main_window.toolbar.add(app.cmd1, app.cmd2) # For stack trace dialog - on_result_handler = Mock() stack = io.StringIO() traceback.print_stack(file=stack) dialog_result = main_window.stack_trace_dialog( @@ -574,7 +573,6 @@ async def test_system_dpi_change( "Some stack trace", stack.getvalue(), retry=True, - on_result=on_result_handler, ) # ----------------------- Setup Mock values for testing ----------------------- From 7842aff3011de5f3242e8563a0cb804304d9135c Mon Sep 17 00:00:00 2001 From: proneon267 <45512885+proneon267@users.noreply.github.com> Date: Sun, 21 Jan 2024 06:31:56 -0800 Subject: [PATCH 42/80] Empty commit for CI --- changes/2155.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/2155.bugfix.rst b/changes/2155.bugfix.rst index 43d7fceae6..4c12d4e5aa 100644 --- a/changes/2155.bugfix.rst +++ b/changes/2155.bugfix.rst @@ -1 +1 @@ -DPI scaling on Windows was improved and related bugs were fixed. +DPI scaling on Windows is now improved and related bugs are fixed. From 46eb4a9db11ebe1bf23f1ff49ba891fc007d6bdc Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sat, 3 Feb 2024 06:42:49 -0800 Subject: [PATCH 43/80] Misc Fixes --- testbed/tests/test_app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index 41a8092424..d7f56518d0 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -603,9 +603,9 @@ async def test_system_dpi_change( monkeypatch.setattr( window1._impl, "resize_content", window1_resize_content_mock ) - original_values[ - "window1_update_toolbar_font_scale" - ] = window1._impl.update_toolbar_font_scale + original_values["window1_update_toolbar_font_scale"] = ( + window1._impl.update_toolbar_font_scale + ) window1_update_toolbar_font_scale_mock = Mock() monkeypatch.setattr( window1._impl, From 3090935a5415ad2513791b0b34d6cd08bf823433 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Tue, 6 Feb 2024 08:57:39 -0800 Subject: [PATCH 44/80] Removed dialog scaling --- testbed/tests/test_app.py | 15 --- winforms/src/toga_winforms/dialogs.py | 112 ++++----------------- winforms/src/toga_winforms/widgets/base.py | 9 +- winforms/tests_backend/app.py | 34 ------- 4 files changed, 21 insertions(+), 149 deletions(-) diff --git a/testbed/tests/test_app.py b/testbed/tests/test_app.py index d7f56518d0..55f8479fd5 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/test_app.py @@ -1,5 +1,3 @@ -import io -import traceback from unittest.mock import Mock import pytest @@ -565,15 +563,6 @@ async def test_system_dpi_change( # --------------------------------- Set up for testing --------------------------------- # For toolbar main_window.toolbar.add(app.cmd1, app.cmd2) - # For stack trace dialog - stack = io.StringIO() - traceback.print_stack(file=stack) - dialog_result = main_window.stack_trace_dialog( - "Stack Trace", - "Some stack trace", - stack.getvalue(), - retry=True, - ) # ----------------------- Setup Mock values for testing ----------------------- # For main_window @@ -634,8 +623,6 @@ async def test_system_dpi_change( app_probe.assert_main_window_widgets_font_scale_updated() main_window._impl.resize_content.assert_called_once() window1._impl.resize_content.assert_called_once() - app_probe.assert_main_window_stack_trace_dialog_scale_updated() - assert not hasattr(window1._impl, "current_stack_trace_dialog_impl") # Test if widget.refresh is called once on each widget for window in app.windows: @@ -684,8 +671,6 @@ async def test_system_dpi_change( await main_window_probe.redraw( "Triggering DPI change event for restoring original state" ) - app_probe.trigger_dpi_change_event() - await main_window_probe.close_stack_trace_dialog(dialog_result._impl, True) main_window.toolbar.clear() window1.close() diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 678b1b3464..cd09b6e98e 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -8,14 +8,11 @@ Font as WinFont, FontFamily, FontStyle, - Point, - Rectangle, SystemFonts, ) from System.Windows.Forms import DialogResult, MessageBoxButtons, MessageBoxIcon from .libs.wrapper import WeakrefCallable -from .widgets.base import Scalable class BaseDialog(ABC): @@ -99,55 +96,40 @@ def __init__(self, interface, title, message=None): ) -class StackTraceDialog(BaseDialog, Scalable): +class StackTraceDialog(BaseDialog): def __init__(self, interface, title, message, content, retry): super().__init__(interface) self.native = WinForms.Form() - - # Required for scaling on DPI changes - self.interface.window._impl.current_stack_trace_dialog_impl = self - self.native.StartPosition = WinForms.FormStartPosition.Manual - self.native.Location = Point( - *map(self.scale_in, self.interface.window._impl.get_position()) - ) - self.native.Move += WeakrefCallable(self.winforms_Move) self.native.MinimizeBox = False self.native.FormBorderStyle = self.native.FormBorderStyle.FixedSingle self.native.MaximizeBox = False self.native.FormClosing += WeakrefCallable(self.winforms_FormClosing) - self.native.Width = self.scale_in(540) - self.native.Height = self.scale_in(320) + self.native.Width = 540 + self.native.Height = 320 self.native.Text = title # The top-of-page introductory message textLabel = WinForms.Label() - textLabel.Left = self.scale_in(10) - textLabel.Top = self.scale_in(10) - # Explicitly set width and Height to prevent scaling issues - textLabel.Width = self.scale_in(520) - textLabel.Height = self.scale_in(20) + textLabel.Left = 10 + textLabel.Top = 10 + textLabel.Width = 520 textLabel.Alignment = ContentAlignment.MiddleCenter textLabel.Text = message - textLabel.Font = WinFont( - SystemFonts.MessageBoxFont.FontFamily, - self.scale_font(float(SystemFonts.MessageBoxFont.Size)), - SystemFonts.MessageBoxFont.Style, - ) self.native.Controls.Add(textLabel) # A scrolling text box for the stack trace. trace = WinForms.RichTextBox() - trace.Left = self.scale_in(10) - trace.Top = self.scale_in(30) - trace.Width = self.scale_in(504) - trace.Height = self.scale_in(210) + trace.Left = 10 + trace.Top = 30 + trace.Width = 504 + trace.Height = 210 trace.Multiline = True trace.ReadOnly = True trace.Font = WinFont( FontFamily.GenericMonospace, - self.scale_font(float(SystemFonts.MessageBoxFont.Size)), + float(SystemFonts.DefaultFont.Size), FontStyle.Regular, ) trace.Text = content @@ -157,65 +139,32 @@ def __init__(self, interface, title, message, content, retry): # Add acceptance/close buttons if retry: retry = WinForms.Button() - retry.Left = self.scale_in(290) - retry.Top = self.scale_in(250) - # Explicitly set width and Height to prevent scaling issues - retry.Width = self.scale_in(100) - retry.Height = self.scale_in(retry.PreferredSize.Height) - retry.Font = WinFont( - SystemFonts.MessageBoxFont.FontFamily, - self.scale_font(float(SystemFonts.MessageBoxFont.Size)), - SystemFonts.MessageBoxFont.Style, - ) + retry.Left = 290 + retry.Top = 250 + retry.Width = 100 retry.Text = "&Retry" retry.Click += WeakrefCallable(self.winforms_Click_retry) self.native.Controls.Add(retry) quit = WinForms.Button() - quit.Left = self.scale_in(400) - quit.Top = self.scale_in(250) - # Explicitly set width and Height to prevent scaling issues - quit.Width = self.scale_in(100) - quit.Height = self.scale_in(quit.PreferredSize.Height) - quit.Font = WinFont( - SystemFonts.MessageBoxFont.FontFamily, - self.scale_font(float(SystemFonts.MessageBoxFont.Size)), - SystemFonts.MessageBoxFont.Style, - ) + quit.Left = 400 + quit.Top = 250 + quit.Width = 100 quit.Text = "&Quit" quit.Click += WeakrefCallable(self.winforms_Click_quit) self.native.Controls.Add(quit) else: accept = WinForms.Button() - accept.Left = self.scale_in(400) - accept.Top = self.scale_in(250) - # Explicitly set width and Height to prevent scaling issues - accept.Width = self.scale_in(100) - accept.Height = self.scale_in(accept.PreferredSize.Height) - accept.Font = WinFont( - SystemFonts.MessageBoxFont.FontFamily, - self.scale_font(float(SystemFonts.MessageBoxFont.Size)), - SystemFonts.MessageBoxFont.Style, - ) + accept.Left = 400 + accept.Top = 250 + accept.Width = 100 accept.Text = "&OK" accept.Click += WeakrefCallable(self.winforms_Click_accept) self.native.Controls.Add(accept) - # Required for scaling - self.original_control_fonts = dict() - self.original_control_bounds = dict() - for control in self.native.Controls: - self.original_control_fonts[control] = control.Font - self.original_control_bounds[control] = Rectangle( - self.scale_out(control.Bounds.X), - self.scale_out(control.Bounds.Y), - self.scale_out(control.Bounds.Width), - self.scale_out(control.Bounds.Height), - ) - self.start_inner_loop(self.native.ShowDialog) def winforms_FormClosing(self, sender, event): @@ -231,8 +180,6 @@ def winforms_FormClosing(self, sender, event): def set_result(self, result): super().set_result(result) self.native.Close() - # Remove the attribute when the dialog closes - del self.interface.window._impl.current_stack_trace_dialog_impl def winforms_Click_quit(self, sender, event): self.set_result(False) @@ -243,25 +190,6 @@ def winforms_Click_retry(self, sender, event): def winforms_Click_accept(self, sender, event): self.set_result(None) - def winforms_Move(self, sender, event): - self.native.Location = Point( # pragma: no cover - *map(self.scale_in, self.interface.window._impl.get_position()) - ) - - def resize_content(self): - for control in self.native.Controls: - control.Font = WinFont( - self.original_control_fonts[control].FontFamily, - self.scale_font(float(self.original_control_fonts[control].Size)), - self.original_control_fonts[control].Style, - ) - control.Bounds = Rectangle( - self.scale_in(self.original_control_bounds[control].X), - self.scale_in(self.original_control_bounds[control].Y), - self.scale_in(self.original_control_bounds[control].Width), - self.scale_in(self.original_control_bounds[control].Height), - ) - class FileDialog(BaseDialog): def __init__( diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 48673a2847..cc8d3f6f25 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -36,7 +36,7 @@ def get_dpi_scale(self, screen): @property def dpi_scale(self): - # For Widgets and Stack Trace Dialogs which have an assigned window + # For Widgets which have an assigned window if ( getattr(self, "interface", None) is not None and getattr(self.interface, "window", None) is not None @@ -94,13 +94,6 @@ def __init__(self, interface): self.native = None self.create() - # Obtain a Graphics object and immediately dispose of it. This is - # done to trigger the control's Paint event and force it to redraw. - # Since in toga, Hwnds are could be created at inappropriate times. - # This is required to prevent Hwnd Related Bugs. Removing this will - # cause the OptionContainer test to fail. - self.native.CreateGraphics().Dispose() - self.interface.style.reapply() @abstractmethod diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 68f83cfc8a..e0e8a83795 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -200,38 +200,4 @@ def assert_main_window_widgets_font_scale_updated(self): ) assert widget._impl.native.Font.Style == widget._impl.original_font.Style - def assert_main_window_stack_trace_dialog_scale_updated(self): - stack_trace_dialog_impl = ( - self.app.main_window._impl.current_stack_trace_dialog_impl - ) - for control in stack_trace_dialog_impl.native.Controls: - # Assert Font - assert ( - control.Font.FontFamily.Name - == stack_trace_dialog_impl.original_control_fonts[ - control - ].FontFamily.Name - ) - assert control.Font.Size == stack_trace_dialog_impl.scale_font( - stack_trace_dialog_impl.original_control_fonts[control].Size - ) - assert ( - control.Font.Style - == stack_trace_dialog_impl.original_control_fonts[control].Style - ) - - # Assert Bounds - assert control.Bounds.X == stack_trace_dialog_impl.scale_in( - stack_trace_dialog_impl.original_control_bounds[control].X - ) - assert control.Bounds.Y == stack_trace_dialog_impl.scale_in( - stack_trace_dialog_impl.original_control_bounds[control].Y - ) - assert control.Bounds.Width == stack_trace_dialog_impl.scale_in( - stack_trace_dialog_impl.original_control_bounds[control].Width - ) - assert control.Bounds.Height == stack_trace_dialog_impl.scale_in( - stack_trace_dialog_impl.original_control_bounds[control].Height - ) - # ------------------------------------------------------------------------------------ From a99b83a11aab8c4d42e6c8c5172eaaf06dae4e6d Mon Sep 17 00:00:00 2001 From: proneon267 Date: Tue, 6 Feb 2024 09:46:45 -0800 Subject: [PATCH 45/80] Misc Fixes --- winforms/src/toga_winforms/window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 5a619c5d13..54cea5e6fc 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -94,6 +94,7 @@ def update_toolbar_font_scale(self): # This method is called when the dpi scaling changes def update_window_dpi_changed(self): self.update_scale() + if self.toolbar_native is not None: self.update_toolbar_font_scale() if isinstance(self.interface, MainWindow): @@ -102,8 +103,7 @@ def update_window_dpi_changed(self): widget.refresh() self.refreshed() self.resize_content() - if hasattr(self, "current_stack_trace_dialog_impl"): - self.current_stack_trace_dialog_impl.resize_content() + self.native.Size = Size(100, 500) def winforms_LocationChanged(self, sender, event): # pragma: no cover # Check if the window has moved from one screen to another and if the new From 71d06b53f06c87312e5ef6a836dee3e85ad90823 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Tue, 6 Feb 2024 10:06:46 -0800 Subject: [PATCH 46/80] Misc Fixes --- winforms/src/toga_winforms/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 54cea5e6fc..a7035d90e9 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -103,7 +103,6 @@ def update_window_dpi_changed(self): widget.refresh() self.refreshed() self.resize_content() - self.native.Size = Size(100, 500) def winforms_LocationChanged(self, sender, event): # pragma: no cover # Check if the window has moved from one screen to another and if the new From d0b7f0e55035a7e6db3f0be5cf77897c0671e8fd Mon Sep 17 00:00:00 2001 From: proneon267 Date: Fri, 9 Feb 2024 18:14:41 -0800 Subject: [PATCH 47/80] Corrected windows implementation --- winforms/src/toga_winforms/widgets/scrollcontainer.py | 5 +++++ winforms/src/toga_winforms/window.py | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/scrollcontainer.py b/winforms/src/toga_winforms/widgets/scrollcontainer.py index 66bf51fc21..415a149484 100644 --- a/winforms/src/toga_winforms/widgets/scrollcontainer.py +++ b/winforms/src/toga_winforms/widgets/scrollcontainer.py @@ -85,6 +85,11 @@ def apply_insets(): # `refresh` or `resize_content`. self.native_width, self.native_height = full_width, full_height + # Do this to prevent horizontal scroll bar from becoming permanently visible + # on dpi scaling changes. + self.native.AutoScroll = False + self.native.AutoScroll = True + def get_horizontal(self): return self.horizontal diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index a7035d90e9..a5c9e21fbe 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -94,15 +94,15 @@ def update_toolbar_font_scale(self): # This method is called when the dpi scaling changes def update_window_dpi_changed(self): self.update_scale() - if self.toolbar_native is not None: self.update_toolbar_font_scale() if isinstance(self.interface, MainWindow): self.update_menubar_font_scale() for widget in self.interface.widgets: widget.refresh() - self.refreshed() self.resize_content() + # self.interface.content.refresh() + self.refreshed() def winforms_LocationChanged(self, sender, event): # pragma: no cover # Check if the window has moved from one screen to another and if the new From 03062b9d97b57863c7a288790f23b53691b9fd66 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 11 Feb 2024 01:49:43 -0800 Subject: [PATCH 48/80] Corrected winforms screens dpi scaling --- winforms/src/toga_winforms/screens.py | 20 ++++++++++++++------ winforms/src/toga_winforms/widgets/base.py | 8 ++++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/winforms/src/toga_winforms/screens.py b/winforms/src/toga_winforms/screens.py index 7a0001e9e1..96f33b4441 100644 --- a/winforms/src/toga_winforms/screens.py +++ b/winforms/src/toga_winforms/screens.py @@ -9,8 +9,10 @@ from toga.screens import Screen as ScreenInterface +from .widgets.base import Scalable -class Screen: + +class Screen(Scalable): _instances = {} def __new__(cls, native): @@ -30,17 +32,23 @@ def get_name(self): return name.split("\\")[-1] def get_origin(self): - return self.native.Bounds.X, self.native.Bounds.Y + return ( + self.scale_out(self.native.Bounds.X), + self.scale_out(self.native.Bounds.Y), + ) def get_size(self): - return self.native.Bounds.Width, self.native.Bounds.Height + return ( + self.scale_out(self.native.Bounds.Width), + self.scale_out(self.native.Bounds.Height), + ) def get_image_data(self): - bitmap = Bitmap(*self.get_size()) + bitmap = Bitmap(*map(self.scale_in, self.get_size())) graphics = Graphics.FromImage(bitmap) - source_point = Point(*self.get_origin()) + source_point = Point(*map(self.scale_in, self.get_origin())) destination_point = Point(0, 0) - copy_size = Size(*self.get_size()) + copy_size = Size(*map(self.scale_in, self.get_size())) graphics.CopyFromScreen(source_point, destination_point, copy_size) stream = MemoryStream() bitmap.Save(stream, Imaging.ImageFormat.Png) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index cc8d3f6f25..b75cc607c2 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -47,8 +47,12 @@ def dpi_scale(self): if hasattr(self, "native_content"): _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native_content)) else: - # For Windows and others - _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) + if isinstance(self.native, Screen): + # For Screen + _dpi_scale = self.get_dpi_scale(self.native) + else: + # For Windows and others + _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) if not hasattr(self, "_original_dpi_scale"): self._original_dpi_scale = _dpi_scale return _dpi_scale From 69f83cc34314ec5e841250d485671784ae818268 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 11 Feb 2024 02:01:14 -0800 Subject: [PATCH 49/80] Corrected winforms tests_backend to detect dpi scale --- iOS/tests_backend/probe.py | 4 ++-- winforms/tests_backend/probe.py | 24 +++++++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/iOS/tests_backend/probe.py b/iOS/tests_backend/probe.py index 1b101dc6bd..4ca3487be0 100644 --- a/iOS/tests_backend/probe.py +++ b/iOS/tests_backend/probe.py @@ -1,7 +1,7 @@ import asyncio import toga -from toga_iOS.libs import NSRunLoop, UIScreen +from toga_iOS.libs import NSRunLoop class BaseProbe: @@ -21,5 +21,5 @@ async def redraw(self, message=None, delay=None): def assert_image_size(self, image_size, size, screen): # Retina displays render images at a higher resolution than their reported size. - scale = int(UIScreen.mainScreen.scale) + scale = int(screen._impl.native.scale) assert image_size == (size[0] * scale, size[1] * scale) diff --git a/winforms/tests_backend/probe.py b/winforms/tests_backend/probe.py index 77bb06fd34..920a042d50 100644 --- a/winforms/tests_backend/probe.py +++ b/winforms/tests_backend/probe.py @@ -1,7 +1,6 @@ import asyncio +from ctypes import byref, c_void_p, windll, wintypes -from System import IntPtr -from System.Drawing import Graphics from System.Windows.Forms import SendKeys import toga @@ -30,10 +29,20 @@ async def redraw(self, message=None, delay=None): print("Waiting for redraw" if message is None else message) await asyncio.sleep(delay) - @property - def scale_factor(self): - # Does the same thing as `return self.native.CreateGraphics().DpiX / 96` - return Graphics.FromHdc(Graphics.FromHwnd(IntPtr.Zero).GetHdc()).DpiX / 96 + def get_scale_factor(self, native_screen): + screen_rect = wintypes.RECT( + native_screen.Bounds.Left, + native_screen.Bounds.Top, + native_screen.Bounds.Right, + native_screen.Bounds.Bottom, + ) + windll.user32.MonitorFromRect.restype = c_void_p + windll.user32.MonitorFromRect.argtypes = [wintypes.RECT, wintypes.DWORD] + # MONITOR_DEFAULTTONEAREST = 2 + hMonitor = windll.user32.MonitorFromRect(screen_rect, 2) + pScale = wintypes.UINT() + windll.shcore.GetScaleFactorForMonitor(c_void_p(hMonitor), byref(pScale)) + return pScale.value / 100 async def type_character(self, char, *, shift=False, ctrl=False, alt=False): try: @@ -55,4 +64,5 @@ async def type_character(self, char, *, shift=False, ctrl=False, alt=False): SendKeys.SendWait(key_code) def assert_image_size(self, image_size, size, screen): - assert image_size == (size[0] * self.scale_factor, size[1] * self.scale_factor) + scale_factor = self.get_scale_factor(native_screen=screen._impl.native) + assert image_size == (size[0] * scale_factor, size[1] * scale_factor) From 65e61448d0085f84a8eae9fe33c521a405d91445 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 11 Feb 2024 02:28:51 -0800 Subject: [PATCH 50/80] Corrected winforms dpi scaling --- winforms/src/toga_winforms/widgets/base.py | 12 ++++++------ winforms/src/toga_winforms/window.py | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index b75cc607c2..cf9d4e57e9 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -19,12 +19,12 @@ class Scalable: SCALE_DEFAULT_ROUNDING = ROUND_HALF_EVEN - def get_dpi_scale(self, screen): + def get_dpi_scale(self, native_screen): screen_rect = wintypes.RECT( - screen.Bounds.Left, - screen.Bounds.Top, - screen.Bounds.Right, - screen.Bounds.Bottom, + native_screen.Bounds.Left, + native_screen.Bounds.Top, + native_screen.Bounds.Right, + native_screen.Bounds.Bottom, ) windll.user32.MonitorFromRect.restype = c_void_p windll.user32.MonitorFromRect.argtypes = [wintypes.RECT, wintypes.DWORD] @@ -49,7 +49,7 @@ def dpi_scale(self): else: if isinstance(self.native, Screen): # For Screen - _dpi_scale = self.get_dpi_scale(self.native) + return self.get_dpi_scale(self.native) else: # For Windows and others _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 7696421c7a..90912766e6 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -102,7 +102,6 @@ def update_window_dpi_changed(self): for widget in self.interface.widgets: widget.refresh() self.resize_content() - # self.interface.content.refresh() self.refreshed() def winforms_LocationChanged(self, sender, event): # pragma: no cover From b6451f5baff7a45fe5cac80f850b0c21cf94b305 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 11 Feb 2024 02:42:23 -0800 Subject: [PATCH 51/80] Corrected winforms tests_backend dpi scaling --- winforms/tests_backend/probe.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/winforms/tests_backend/probe.py b/winforms/tests_backend/probe.py index 920a042d50..a4f9b4b1e7 100644 --- a/winforms/tests_backend/probe.py +++ b/winforms/tests_backend/probe.py @@ -1,7 +1,7 @@ import asyncio from ctypes import byref, c_void_p, windll, wintypes -from System.Windows.Forms import SendKeys +from System.Windows.Forms import Screen, SendKeys import toga @@ -29,6 +29,17 @@ async def redraw(self, message=None, delay=None): print("Waiting for redraw" if message is None else message) await asyncio.sleep(delay) + @property + def scale_factor(self): + # For ScrollContainer + if hasattr(self, "native_content"): + return self.get_scale_factor( + native_screen=Screen.FromControl(self.native_content) + ) + # For Windows and others + else: + return self.get_scale_factor(native_screen=Screen.FromControl(self.native)) + def get_scale_factor(self, native_screen): screen_rect = wintypes.RECT( native_screen.Bounds.Left, From 8cf5eef644c9f650a4a284e3ec558cd7e07cc6bd Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 11 Feb 2024 05:26:42 -0800 Subject: [PATCH 52/80] Corrected winforms tests_backend dpi scaling --- winforms/tests_backend/probe.py | 5 ++++- winforms/tests_backend/widgets/base.py | 4 ++-- winforms/tests_backend/widgets/multilinetextinput.py | 4 ++-- winforms/tests_backend/widgets/table.py | 8 +++++--- winforms/tests_backend/window.py | 4 ++-- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/winforms/tests_backend/probe.py b/winforms/tests_backend/probe.py index a4f9b4b1e7..781ca00c4f 100644 --- a/winforms/tests_backend/probe.py +++ b/winforms/tests_backend/probe.py @@ -76,4 +76,7 @@ async def type_character(self, char, *, shift=False, ctrl=False, alt=False): def assert_image_size(self, image_size, size, screen): scale_factor = self.get_scale_factor(native_screen=screen._impl.native) - assert image_size == (size[0] * scale_factor, size[1] * scale_factor) + assert image_size == ( + round(size[0] * scale_factor), + round(size[1] * scale_factor), + ) diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 178a7ec5db..2d29554a1d 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -100,8 +100,8 @@ def assert_layout(self, size, position): # size and position is as expected. assert (self.width, self.height) == approx(size, abs=1) assert ( - self.native.Left / self.scale_factor, - self.native.Top / self.scale_factor, + round(self.native.Left / self.scale_factor), + round(self.native.Top / self.scale_factor), ) == approx(position, abs=1) async def press(self): diff --git a/winforms/tests_backend/widgets/multilinetextinput.py b/winforms/tests_backend/widgets/multilinetextinput.py index f94fdb5e51..bd49a1506b 100644 --- a/winforms/tests_backend/widgets/multilinetextinput.py +++ b/winforms/tests_backend/widgets/multilinetextinput.py @@ -39,7 +39,7 @@ def document_height(self): assert height > 0 height *= line_count / (line_count - 1) - return height / self.scale_factor + return round(height / self.scale_factor) @property def document_width(self): @@ -47,7 +47,7 @@ def document_width(self): @property def vertical_scroll_position(self): - return -(self._char_pos(0).Y) / self.scale_factor + return -round((self._char_pos(0).Y) / self.scale_factor) async def wait_for_scroll_completion(self): pass diff --git a/winforms/tests_backend/widgets/table.py b/winforms/tests_backend/widgets/table.py index 99619506a8..b8af3be648 100644 --- a/winforms/tests_backend/widgets/table.py +++ b/winforms/tests_backend/widgets/table.py @@ -56,11 +56,13 @@ def max_scroll_position(self): self.native.Items[self.row_count - 1].Bounds.Bottom - self.native.Items[0].Bounds.Top ) - return (document_height - self.native.ClientSize.Height) / self.scale_factor + return round( + (document_height - self.native.ClientSize.Height) / self.scale_factor + ) @property def scroll_position(self): - return -(self.native.Items[0].Bounds.Top) / self.scale_factor + return -round((self.native.Items[0].Bounds.Top) / self.scale_factor) async def wait_for_scroll_completion(self): # No animation associated with scroll, so this is a no-op @@ -75,7 +77,7 @@ def header_titles(self): return [col.Text for col in self.native.Columns] def column_width(self, index): - return self.native.Columns[index].Width / self.scale_factor + return round(self.native.Columns[index].Width / self.scale_factor) async def select_row(self, row, add=False): item = self.native.Items[row] diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index 5fc6ae3c94..20257aad0c 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -41,8 +41,8 @@ def close(self): @property def content_size(self): return ( - (self.native.ClientSize.Width) / self.scale_factor, - ( + round(self.native.ClientSize.Width / self.scale_factor), + round( (self.native.ClientSize.Height - self.impl.top_bars_height()) / self.scale_factor ), From 6a0b6d7cd444505d54902fe10d359acdb187e205 Mon Sep 17 00:00:00 2001 From: proneon267 <45512885+proneon267@users.noreply.github.com> Date: Sun, 11 Feb 2024 05:37:34 -0800 Subject: [PATCH 53/80] Empty commit for CI From 18bc25dc07939b9a05dd3803756cf5bd3537b915 Mon Sep 17 00:00:00 2001 From: proneon267 <45512885+proneon267@users.noreply.github.com> Date: Sun, 11 Feb 2024 05:49:00 -0800 Subject: [PATCH 54/80] Empty commit for CI From 67134908a8cf966648bad4abdca8f83a4f99a0e4 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 20 Mar 2024 19:02:01 -0700 Subject: [PATCH 55/80] updated to latest main branch --- winforms/src/toga_winforms/window.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index a150f9dafd..7a99ef0a02 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -63,7 +63,7 @@ def winforms_FormClosing(self, sender, event): # See _is_closing comment in __init__. self.interface.on_close() event.Cancel = True - + def winforms_LocationChanged(self, sender, event): # pragma: no cover # Check if the window has moved from one screen to another and if the new # screen has a different dpi scale than the previous screen then rescale @@ -132,7 +132,7 @@ def create_toolbar(self): self.toolbar_native = None self.resize_content() - + def set_app(self, app): icon_impl = app.interface.icon._impl self.native.Icon = icon_impl.native @@ -202,7 +202,6 @@ def update_window_dpi_changed(self): self.resize_content() self.refreshed() - ###################################################################### # Window size ###################################################################### From c873366e6603af7e04cde8ca8c07b50b20dd707b Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 20 Mar 2024 19:15:09 -0700 Subject: [PATCH 56/80] updated to latest main branch --- winforms/src/toga_winforms/app.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 05feacf4de..68a2149226 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -171,6 +171,14 @@ def create(self): self.create_menus() self.interface.main_window._impl.set_app(self) + ###################################################################### + # Native event handlers + ###################################################################### + + def winforms_DisplaySettingsChanged(self, sender, event): + for window in self.interface.windows: + window._impl.update_window_dpi_changed() + ###################################################################### # Commands and menus ###################################################################### From 31e54c900ea157d59fd63fa6c82926bfa2ef7b7d Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 3 Apr 2024 16:59:13 +0100 Subject: [PATCH 57/80] Fix StackTraceDialog scaling --- winforms/src/toga_winforms/__init__.py | 20 +++++++++ winforms/src/toga_winforms/app.py | 34 +--------------- winforms/src/toga_winforms/dialogs.py | 49 ++++++++++++++++++----- winforms/src/toga_winforms/libs/user32.py | 28 +++++++++++++ 4 files changed, 88 insertions(+), 43 deletions(-) create mode 100644 winforms/src/toga_winforms/libs/user32.py diff --git a/winforms/src/toga_winforms/__init__.py b/winforms/src/toga_winforms/__init__.py index 4cf94ccde4..7e47fa5698 100644 --- a/winforms/src/toga_winforms/__init__.py +++ b/winforms/src/toga_winforms/__init__.py @@ -2,6 +2,11 @@ import toga +from .libs.user32 import ( + DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, + SetProcessDpiAwarenessContext, +) + # Add a reference to the Winforms assembly clr.AddReference("System.Windows.Forms") @@ -16,4 +21,19 @@ "WindowsBase, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" ) + +# Enable DPI awareness. This must be done before calling any other UI-related code +# (https://learn.microsoft.com/en-us/dotnet/desktop/winforms/high-dpi-support-in-windows-forms). +import System.Windows.Forms as WinForms # noqa: E402 + +WinForms.Application.EnableVisualStyles() +WinForms.Application.SetCompatibleTextRenderingDefault(False) + +if SetProcessDpiAwarenessContext is not None: + if not SetProcessDpiAwarenessContext( + DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 + ): # pragma: no cover + print("WARNING: Failed to set the DPI Awareness mode for the app.") + + __version__ = toga._package_version(__file__, __name__) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 68a2149226..c5ab993231 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -2,11 +2,10 @@ import re import sys import threading -from ctypes import c_void_p, windll, wintypes import System.Windows.Forms as WinForms from Microsoft.Win32 import SystemEvents -from System import Environment, Threading +from System import Threading from System.ComponentModel import InvalidEnumArgumentException from System.Drawing import Font as WinFont from System.Media import SystemSounds @@ -83,37 +82,6 @@ def print_stack_trace(stack_trace_line): # pragma: no cover class App(Scalable): _MAIN_WINDOW_CLASS = MainWindow - # These are required for properly setting up DPI mode - WinForms.Application.EnableVisualStyles() - WinForms.Application.SetCompatibleTextRenderingDefault(False) - - # ------------------- Set the DPI Awareness mode for the process ------------------- - # This needs to be done at the earliest and doing this in __init__() or - # in create() doesn't work - # - # Check the version of windows and make sure we are setting the DPI mode - # with the most up to date API - # Windows Versioning Check Sources : https://www.lifewire.com/windows-version-numbers-2625171 - # and https://docs.microsoft.com/en-us/windows/release-information/ - win_version = Environment.OSVersion.Version - # Represents Windows 10 Build 1703 and beyond which should use - # SetProcessDpiAwarenessContext(-4) for DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 - # Valid values: https://learn.microsoft.com/en-us/windows/win32/hidpi/dpi-awareness-context - if (win_version.Major > 10) or ( - win_version.Major == 10 and win_version.Build >= 15063 - ): - windll.user32.SetProcessDpiAwarenessContext.restype = wintypes.BOOL - windll.user32.SetProcessDpiAwarenessContext.argtypes = [c_void_p] - # SetProcessDpiAwarenessContext returns False(0) on Failure - if windll.user32.SetProcessDpiAwarenessContext(-4) == 0: # pragma: no cover - print("WARNING: Failed to set the DPI Awareness mode for the app.") - else: # pragma: no cover - print( - "WARNING: Your Windows version doesn't support DPI Awareness setting. " - "We recommend you upgrade to at least Windows 10 Build 1703." - ) - # ---------------------------------------------------------------------------------- - def __init__(self, interface): self.interface = interface self.interface._impl = self diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 3e68b7807e..2b26384c33 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -7,11 +7,10 @@ ContentAlignment, Font as WinFont, FontFamily, - FontStyle, - SystemFonts, ) from System.Windows.Forms import DialogResult, MessageBoxButtons, MessageBoxIcon +from .libs.user32 import DPI_AWARENESS_CONTEXT_UNAWARE, SetThreadDpiAwarenessContext from .libs.wrapper import WeakrefCallable @@ -100,6 +99,24 @@ class StackTraceDialog(BaseDialog): def __init__(self, interface, title, message, content, retry): super().__init__(interface) + # This dialog uses a fixed layout, so we create it as DPI-unaware so it will be + # scaled by the system. "When a window is created, its DPI awareness is defined + # as the DPI awareness of the calling thread at that time." + # (https://learn.microsoft.com/en-us/windows/win32/hidpi/high-dpi-improvements-for-desktop-applications). + self.prev_dpi_context = None + if SetThreadDpiAwarenessContext is not None: + self.prev_dpi_context = SetThreadDpiAwarenessContext( + DPI_AWARENESS_CONTEXT_UNAWARE + ) + if not self.prev_dpi_context: # pragma: no cover + print("WARNING: Failed to set DPI Awareness for StackTraceDialog") + + # Changing the DPI awareness re-scales all pre-existing Font objects, including + # the system fonts. + font_size = 8.25 + message_font = WinFont(FontFamily.GenericSansSerif, font_size) + monospace_font = WinFont(FontFamily.GenericMonospace, font_size) + self.native = WinForms.Form() self.native.MinimizeBox = False self.native.FormBorderStyle = self.native.FormBorderStyle.FixedSingle @@ -116,22 +133,18 @@ def __init__(self, interface, title, message, content, retry): textLabel.Width = 520 textLabel.Alignment = ContentAlignment.MiddleCenter textLabel.Text = message - + textLabel.Font = message_font self.native.Controls.Add(textLabel) # A scrolling text box for the stack trace. trace = WinForms.RichTextBox() trace.Left = 10 - trace.Top = 30 + trace.Top = 35 trace.Width = 504 - trace.Height = 210 + trace.Height = 205 trace.Multiline = True trace.ReadOnly = True - trace.Font = WinFont( - FontFamily.GenericMonospace, - float(SystemFonts.DefaultFont.Size), - FontStyle.Regular, - ) + trace.Font = monospace_font trace.Text = content self.native.Controls.Add(trace) @@ -143,6 +156,7 @@ def __init__(self, interface, title, message, content, retry): retry.Top = 250 retry.Width = 100 retry.Text = "&Retry" + retry.Font = message_font retry.Click += WeakrefCallable(self.winforms_Click_retry) self.native.Controls.Add(retry) @@ -152,6 +166,7 @@ def __init__(self, interface, title, message, content, retry): quit.Top = 250 quit.Width = 100 quit.Text = "&Quit" + quit.Font = message_font quit.Click += WeakrefCallable(self.winforms_Click_quit) self.native.Controls.Add(quit) @@ -161,10 +176,12 @@ def __init__(self, interface, title, message, content, retry): accept.Top = 250 accept.Width = 100 accept.Text = "&OK" + accept.Font = message_font accept.Click += WeakrefCallable(self.winforms_Click_accept) self.native.Controls.Add(accept) + # Wrap `ShowDialog` in a Python function to preserve a reference to `self`. def show(): self.native.ShowDialog() @@ -179,6 +196,18 @@ def winforms_FormClosing(self, sender, event): self.interface.future.result() except asyncio.InvalidStateError: # pragma: no cover event.Cancel = True + else: + # Reverting the DPI awareness at the end of __init__ would cause the window + # to be DPI-aware, presumably because the window isn't actually "created" + # until we call ShowDialog. + # + # This cleanup doesn't make any difference to the dialogs example, because + # "When the window procedure for a window is called [e.g. when clicking a + # button], the thread is automatically switched to the DPI awareness context + # that was in use when the window was created." However, other apps may do + # things outside of the context of a window event. + if self.prev_dpi_context: + SetThreadDpiAwarenessContext(self.prev_dpi_context) def set_result(self, result): super().set_result(result) diff --git a/winforms/src/toga_winforms/libs/user32.py b/winforms/src/toga_winforms/libs/user32.py new file mode 100644 index 0000000000..afa673be67 --- /dev/null +++ b/winforms/src/toga_winforms/libs/user32.py @@ -0,0 +1,28 @@ +from ctypes import c_void_p, windll, wintypes + +from System import Environment + +user32 = windll.user32 + + +# https://learn.microsoft.com/en-us/windows/win32/hidpi/dpi-awareness-context +DPI_AWARENESS_CONTEXT_UNAWARE = -1 +DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 + +# https://www.lifewire.com/windows-version-numbers-2625171 +win_version = Environment.OSVersion.Version +if (win_version.Major, win_version.Minor, win_version.Build) >= (10, 0, 15063): + SetProcessDpiAwarenessContext = user32.SetProcessDpiAwarenessContext + SetProcessDpiAwarenessContext.restype = wintypes.BOOL + SetProcessDpiAwarenessContext.argtypes = [c_void_p] + + SetThreadDpiAwarenessContext = user32.SetThreadDpiAwarenessContext + SetThreadDpiAwarenessContext.restype = c_void_p + SetThreadDpiAwarenessContext.argtypes = [c_void_p] + +else: # pragma: no cover + print( + "WARNING: Your Windows version doesn't support DPI Awareness setting. " + "We recommend you upgrade to at least Windows 10 version 1703." + ) + SetProcessDpiAwarenessContext = SetThreadDpiAwarenessContext = None From c793d64da1b18c9db4a05338819a98b17f391ecf Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Thu, 4 Apr 2024 22:03:59 +0100 Subject: [PATCH 58/80] Fix various scaling bugs --- winforms/src/toga_winforms/app.py | 25 +++---- winforms/src/toga_winforms/dialogs.py | 4 +- winforms/src/toga_winforms/screens.py | 18 +++++ winforms/src/toga_winforms/widgets/base.py | 83 ++++++---------------- winforms/src/toga_winforms/window.py | 57 +++++++-------- 5 files changed, 76 insertions(+), 111 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index c5ab993231..5611fdcef2 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -7,7 +7,6 @@ from Microsoft.Win32 import SystemEvents from System import Threading from System.ComponentModel import InvalidEnumArgumentException -from System.Drawing import Font as WinFont from System.Media import SystemSounds from System.Net import SecurityProtocolType, ServicePointManager from System.Windows.Threading import Dispatcher @@ -19,21 +18,11 @@ from .keys import toga_to_winforms_key, toga_to_winforms_shortcut from .libs.proactor import WinformsProactorEventLoop from .libs.wrapper import WeakrefCallable -from .screens import Screen as ScreenImpl -from .widgets.base import Scalable +from .screens import Screen from .window import Window class MainWindow(Window): - def update_menubar_font_scale(self): - # Directly using self.native.MainMenuStrip.Font instead of - # original_menubar_font makes the menubar font to not scale down. - self.native.MainMenuStrip.Font = WinFont( - self.original_menubar_font.FontFamily, - self.scale_font(self.original_menubar_font.Size), - self.original_menubar_font.Style, - ) - def winforms_FormClosing(self, sender, event): # Differentiate between the handling that occurs when the user # requests the app to exit, and the actual application exiting. @@ -44,6 +33,10 @@ def winforms_FormClosing(self, sender, event): self.interface.app.on_exit() event.Cancel = True + def update_dpi(self): + super().update_dpi() + self.native.MainMenuStrip.Font = self.scale_font(self.original_menubar_font) + def winforms_thread_exception(sender, winforms_exc): # pragma: no cover # The PythonException returned by Winforms doesn't give us @@ -79,7 +72,7 @@ def print_stack_trace(stack_trace_line): # pragma: no cover print(py_exc.Message) -class App(Scalable): +class App: _MAIN_WINDOW_CLASS = MainWindow def __init__(self, interface): @@ -145,7 +138,7 @@ def create(self): def winforms_DisplaySettingsChanged(self, sender, event): for window in self.interface.windows: - window._impl.update_window_dpi_changed() + window._impl.update_dpi() ###################################################################### # Commands and menus @@ -308,9 +301,9 @@ def set_main_window(self, window): ###################################################################### def get_screens(self): - primary_screen = ScreenImpl(WinForms.Screen.PrimaryScreen) + primary_screen = Screen(WinForms.Screen.PrimaryScreen) screen_list = [primary_screen] + [ - ScreenImpl(native=screen) + Screen(native=screen) for screen in WinForms.Screen.AllScreens if screen != primary_screen.native ] diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 2b26384c33..e0ba6267de 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -104,7 +104,7 @@ def __init__(self, interface, title, message, content, retry): # as the DPI awareness of the calling thread at that time." # (https://learn.microsoft.com/en-us/windows/win32/hidpi/high-dpi-improvements-for-desktop-applications). self.prev_dpi_context = None - if SetThreadDpiAwarenessContext is not None: + if SetThreadDpiAwarenessContext is not None: # pragma: no branch self.prev_dpi_context = SetThreadDpiAwarenessContext( DPI_AWARENESS_CONTEXT_UNAWARE ) @@ -206,7 +206,7 @@ def winforms_FormClosing(self, sender, event): # button], the thread is automatically switched to the DPI awareness context # that was in use when the window was created." However, other apps may do # things outside of the context of a window event. - if self.prev_dpi_context: + if self.prev_dpi_context: # pragma: no branch SetThreadDpiAwarenessContext(self.prev_dpi_context) def set_result(self, result): diff --git a/winforms/src/toga_winforms/screens.py b/winforms/src/toga_winforms/screens.py index 96f33b4441..797cc7cde6 100644 --- a/winforms/src/toga_winforms/screens.py +++ b/winforms/src/toga_winforms/screens.py @@ -1,3 +1,5 @@ +from ctypes import byref, c_void_p, windll, wintypes + from System.Drawing import ( Bitmap, Graphics, @@ -25,6 +27,22 @@ def __new__(cls, native): cls._instances[native] = instance return instance + @property + def dpi_scale(self): + screen_rect = wintypes.RECT( + self.native.Bounds.Left, + self.native.Bounds.Top, + self.native.Bounds.Right, + self.native.Bounds.Bottom, + ) + windll.user32.MonitorFromRect.restype = c_void_p + windll.user32.MonitorFromRect.argtypes = [wintypes.RECT, wintypes.DWORD] + # MONITOR_DEFAULTTONEAREST = 2 + hMonitor = windll.user32.MonitorFromRect(screen_rect, 2) + pScale = wintypes.UINT() + windll.shcore.GetScaleFactorForMonitor(c_void_p(hMonitor), byref(pScale)) + return pScale.value / 100 + def get_name(self): name = self.native.DeviceName # WinForms Display naming convention is "\\.\DISPLAY1". Remove the diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index cf9d4e57e9..191fa8be6b 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -1,66 +1,25 @@ from abc import ABC, abstractmethod -from ctypes import byref, c_void_p, windll, wintypes from decimal import ROUND_HALF_EVEN, Decimal from System.Drawing import ( Color, - Font as WinFont, Point, Size, SystemColors, ) -from System.Windows.Forms import Screen from travertino.size import at_least from toga.colors import TRANSPARENT from toga_winforms.colors import native_color -class Scalable: +class Scalable(ABC): SCALE_DEFAULT_ROUNDING = ROUND_HALF_EVEN - def get_dpi_scale(self, native_screen): - screen_rect = wintypes.RECT( - native_screen.Bounds.Left, - native_screen.Bounds.Top, - native_screen.Bounds.Right, - native_screen.Bounds.Bottom, - ) - windll.user32.MonitorFromRect.restype = c_void_p - windll.user32.MonitorFromRect.argtypes = [wintypes.RECT, wintypes.DWORD] - # MONITOR_DEFAULTTONEAREST = 2 - hMonitor = windll.user32.MonitorFromRect(screen_rect, 2) - pScale = wintypes.UINT() - windll.shcore.GetScaleFactorForMonitor(c_void_p(hMonitor), byref(pScale)) - return pScale.value / 100 - @property + @abstractmethod def dpi_scale(self): - # For Widgets which have an assigned window - if ( - getattr(self, "interface", None) is not None - and getattr(self.interface, "window", None) is not None - ): - self._original_dpi_scale = self.interface.window._impl._original_dpi_scale - return self.interface.window._impl._dpi_scale - # For Container Widgets when not assigned to a window - if hasattr(self, "native_content"): - _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native_content)) - else: - if isinstance(self.native, Screen): - # For Screen - return self.get_dpi_scale(self.native) - else: - # For Windows and others - _dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) - if not hasattr(self, "_original_dpi_scale"): - self._original_dpi_scale = _dpi_scale - return _dpi_scale - - def update_scale(self): - # Should be called only from Window class as Widgets use the dpi scale - # of the Window on which they are present. - self._dpi_scale = self.get_dpi_scale(Screen.FromControl(self.native)) + raise NotImplementedError() # Convert CSS pixels to native pixels def scale_in(self, value, rounding=SCALE_DEFAULT_ROUNDING): @@ -78,13 +37,8 @@ def scale_round(self, value, rounding): return value return int(Decimal(value).to_integral(rounding)) - def scale_font(self, value, rounding=SCALE_DEFAULT_ROUNDING): - return self.scale_round( - value * (self.dpi_scale / self._original_dpi_scale), rounding - ) - -class Widget(ABC, Scalable): +class Widget(Scalable, ABC): # In some widgets, attempting to set a background color with any alpha value other # than 1 raises "System.ArgumentException: Control does not support transparent # background colors". Those widgets should set this attribute to False. @@ -108,8 +62,7 @@ def set_app(self, app): pass def set_window(self, window): - # No special handling required - pass + self.scale_font() @property def container(self): @@ -129,6 +82,14 @@ def container(self, container): self.refresh() + @property + def dpi_scale(self): + window = self.interface.window + if window: + return window._impl.dpi_scale + else: + return 1 + def get_tab_index(self): return self.native.TabIndex @@ -158,9 +119,15 @@ def set_hidden(self, hidden): self.native.Visible = not hidden def set_font(self, font): - self.native.Font = font._impl.native - # Required for font scaling on DPI changes self.original_font = font._impl.native + self.scale_font() + + def scale_font(self): + font = self.original_font + window = self.interface.window + if window: + font = window._impl.scale_font(self.original_font) + self.native.Font = font def set_color(self, color): if color is None: @@ -193,14 +160,6 @@ def remove_child(self, child): child.container = None def refresh(self): - # Update the scaling of the font - if hasattr(self, "original_font"): # pragma: no branch - self.native.Font = WinFont( - self.original_font.FontFamily, - self.scale_font(self.original_font.Size), - self.original_font.Style, - ) - # Default values; may be overwritten by rehint(). self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 7a99ef0a02..b19f343e0b 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -3,12 +3,11 @@ from System.Drawing.Imaging import ImageFormat from System.IO import MemoryStream -from toga import MainWindow from toga.command import Separator from .container import Container from .libs.wrapper import WeakrefCallable -from .screens import Screen as ScreenImpl +from .screens import Screen from .widgets.base import Scalable @@ -27,9 +26,7 @@ def __init__(self, interface, title, position, size): self.native.FormClosing += WeakrefCallable(self.winforms_FormClosing) super().__init__(self.native) - self.update_scale() - # Required for detecting window moving to other screens with different dpi - self.native.LocationChanged += WeakrefCallable(self.winforms_LocationChanged) + self._dpi_scale = self._original_dpi_scale = self.get_current_screen().dpi_scale self.native.MinimizeBox = self.interface.minimizable self.native.MaximizeBox = self.interface.resizable @@ -40,16 +37,30 @@ def __init__(self, interface, title, position, size): self.toolbar_native = None + self.native.LocationChanged += WeakrefCallable(self.winforms_LocationChanged) self.native.Resize += WeakrefCallable(self.winforms_Resize) self.resize_content() # Store initial size self.set_full_screen(self.interface.full_screen) + # We cache the scale to make sure that it only changes inside update_dpi. + @property + def dpi_scale(self): + return self._dpi_scale + + def scale_font(self, native_font): + return WinFont( + native_font.FontFamily, + native_font.Size * (self.dpi_scale / self._original_dpi_scale), + native_font.Style, + ) + ###################################################################### # Native event handlers ###################################################################### def winforms_Resize(self, sender, event): + self.update_dpi() self.resize_content() def winforms_FormClosing(self, sender, event): @@ -64,16 +75,8 @@ def winforms_FormClosing(self, sender, event): self.interface.on_close() event.Cancel = True - def winforms_LocationChanged(self, sender, event): # pragma: no cover - # Check if the window has moved from one screen to another and if the new - # screen has a different dpi scale than the previous screen then rescale - current_screen = WinForms.Screen.FromControl(self.native) - if not hasattr(self, "_previous_screen"): - self._previous_screen = current_screen - if current_screen != self._previous_screen: - if self._dpi_scale != self.get_dpi_scale(current_screen): - self.update_window_dpi_changed() - self._previous_screen = current_screen + def winforms_LocationChanged(self, sender, event): + self.update_dpi() ###################################################################### # Window properties @@ -183,24 +186,16 @@ def resize_content(self): self.native.ClientSize.Height - vertical_shift, ) - def update_toolbar_font_scale(self): - self.toolbar_native.Font = WinFont( - self.original_toolbar_font.FontFamily, - self.scale_font(self.original_toolbar_font.Size), - self.original_toolbar_font.Style, - ) + def update_dpi(self): + new_scale = self.get_current_screen().dpi_scale + if new_scale == self._dpi_scale: + return - # This method is called when the dpi scaling changes - def update_window_dpi_changed(self): - self.update_scale() + self._dpi_scale = new_scale if self.toolbar_native is not None: - self.update_toolbar_font_scale() - if isinstance(self.interface, MainWindow): - self.update_menubar_font_scale() + self.toolbar_native.Font = self.scale_font(self.original_toolbar_font) for widget in self.interface.widgets: - widget.refresh() - self.resize_content() - self.refreshed() + widget._impl.scale_font() ###################################################################### # Window size @@ -225,7 +220,7 @@ def set_size(self, size): ###################################################################### def get_current_screen(self): - return ScreenImpl(WinForms.Screen.FromControl(self.native)) + return Screen(WinForms.Screen.FromControl(self.native)) def get_position(self): location = self.native.Location From 64221ab849bfa45e2f54c59ef41602b60d711c70 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 14 Apr 2024 13:26:03 -0700 Subject: [PATCH 59/80] Fixed reported dpi scaling bugs --- winforms/src/toga_winforms/app.py | 3 ++- winforms/src/toga_winforms/window.py | 24 ++++++++++++++++-------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 5611fdcef2..79f7fb26b4 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -35,7 +35,8 @@ def winforms_FormClosing(self, sender, event): def update_dpi(self): super().update_dpi() - self.native.MainMenuStrip.Font = self.scale_font(self.original_menubar_font) + if getattr(self, "original_menubar_font", None) is not None: + self.native.MainMenuStrip.Font = self.scale_font(self.original_menubar_font) def winforms_thread_exception(sender, winforms_exc): # pragma: no cover diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index b19f343e0b..c1fd01bff9 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -60,7 +60,6 @@ def scale_font(self, native_font): ###################################################################### def winforms_Resize(self, sender, event): - self.update_dpi() self.resize_content() def winforms_FormClosing(self, sender, event): @@ -76,7 +75,15 @@ def winforms_FormClosing(self, sender, event): event.Cancel = True def winforms_LocationChanged(self, sender, event): - self.update_dpi() + # Check if the window has moved from one screen to another and if the new + # screen has a different dpi scale than the previous screen then rescale + current_screen = self.get_current_screen() + if not hasattr(self, "_previous_screen"): + self._previous_screen = current_screen + if current_screen != self._previous_screen: + if self._dpi_scale != current_screen.dpi_scale: + self.update_dpi() + self._previous_screen = current_screen ###################################################################### # Window properties @@ -145,6 +152,7 @@ def show(self): self.interface.content.refresh() if self.interface is not self.interface.app._main_window: self.native.Icon = self.interface.app.icon._impl.native + self.update_dpi() self.native.Show() ###################################################################### @@ -187,15 +195,15 @@ def resize_content(self): ) def update_dpi(self): - new_scale = self.get_current_screen().dpi_scale - if new_scale == self._dpi_scale: - return - - self._dpi_scale = new_scale - if self.toolbar_native is not None: + self._dpi_scale = self.get_current_screen().dpi_scale + if (self.toolbar_native is not None) and ( + getattr(self, "original_toolbar_font", None) is not None + ): self.toolbar_native.Font = self.scale_font(self.original_toolbar_font) for widget in self.interface.widgets: widget._impl.scale_font() + widget.refresh() + self.resize_content() ###################################################################### # Window size From 12a70d1f7f08eb7b49ffebae0f27ec4cb28dff38 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Mon, 15 Apr 2024 04:55:14 -0700 Subject: [PATCH 60/80] Fixed tests --- examples/window/window/app.py | 10 +- testbed/tests/app/test_app.py | 191 ++++++++++------------ winforms/src/toga_winforms/app.py | 4 +- winforms/src/toga_winforms/libs/shcore.py | 5 + winforms/src/toga_winforms/libs/user32.py | 8 + winforms/src/toga_winforms/screens.py | 10 +- winforms/src/toga_winforms/window.py | 19 +-- winforms/tests_backend/app.py | 47 ------ 8 files changed, 121 insertions(+), 173 deletions(-) create mode 100644 winforms/src/toga_winforms/libs/shcore.py diff --git a/examples/window/window/app.py b/examples/window/window/app.py index a1050e5cb8..9d58abb7d9 100644 --- a/examples/window/window/app.py +++ b/examples/window/window/app.py @@ -19,10 +19,16 @@ def do_right(self, widget, **kwargs): self.main_window.position = (2000, 500) def do_left_current_screen(self, widget, **kwargs): - self.main_window.screen_position = (0, 100) + self.main_window.screen_position = ( + self.main_window.screen.origin[0], + self.main_window.screen_position[1], + ) def do_right_current_screen(self, widget, **kwargs): - self.main_window.screen_position = (1080, 100) + self.main_window.screen_position = ( + self.main_window.screen.size[0] - self.main_window.size[0], + self.main_window.screen_position[1], + ) def do_small(self, widget, **kwargs): self.main_window.size = (400, 300) diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 7d5ce860f0..ea60a9c46e 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -7,6 +7,7 @@ from toga.style.pack import Pack from ..test_window import window_probe +from toga_winforms.libs import shcore @pytest.fixture @@ -595,119 +596,95 @@ async def test_screens(app, app_probe): async def test_system_dpi_change( monkeypatch, app, app_probe, main_window, main_window_probe ): - # For restoring original behavior after completion of test. - original_values = dict() - # --------------------------------- Set up for testing --------------------------------- - # For toolbar main_window.toolbar.add(app.cmd1, app.cmd2) - - # ----------------------- Setup Mock values for testing ----------------------- - # For main_window - original_values["main_window_update_scale"] = main_window._impl.update_scale - main_window_update_scale_mock = Mock() - monkeypatch.setattr( - main_window._impl, "update_scale", main_window_update_scale_mock - ) - original_values["main_window_resize_content"] = main_window._impl.resize_content - main_window_resize_content_mock = Mock() - monkeypatch.setattr( - main_window._impl, "resize_content", main_window_resize_content_mock + main_window.content.add( + toga.Button(text="Testing for system DPI change response") ) + GetScaleFactorForMonitor_original = getattr(shcore, "GetScaleFactorForMonitor") + + for dpi_change_event in { + app._impl.winforms_DisplaySettingsChanged, + main_window._impl.winforms_LocationChanged, + main_window._impl.winforms_Resize, + }: + for pScale_value_mock in {125, 150, 175, 200}: + + original_sizes = dict() + original_sizes[main_window._impl.native.MainMenuStrip] = ( + main_window._impl.native.MainMenuStrip.Size + ) + original_sizes[main_window._impl.toolbar_native] = ( + main_window._impl.toolbar_native.Size + ) + for widget in app.widgets: + original_sizes[widget] = widget._impl.native.Size + + def GetScaleFactorForMonitor_mock(hMonitor, pScale): + pScale.value = pScale_value_mock + + monkeypatch.setattr( + "toga_winforms.libs.shcore.GetScaleFactorForMonitor", + GetScaleFactorForMonitor_mock, + ) + assert app.screens[0]._impl.dpi_scale == pScale_value_mock / 100 + + await main_window_probe.redraw( + "Triggering DPI change event for testing property changes" + ) + dpi_change_event(None, None) + + # Check MenuBar Font Scaling + assert ( + main_window._impl.native.MainMenuStrip.Font.Size + == main_window._impl.scale_font( + main_window._impl.original_menubar_font + ).Size + ) + assert ( + main_window._impl.native.MainMenuStrip.Size.Width, + main_window._impl.native.MainMenuStrip.Size.Height, + ) != ( + original_sizes[main_window._impl.native.MainMenuStrip].Width, + original_sizes[main_window._impl.native.MainMenuStrip].Height, + ) + # Check ToolBar Font Scaling and Size + assert ( + main_window._impl.toolbar_native.Font.Size + == main_window._impl.scale_font( + main_window._impl.original_toolbar_font + ).Size + ) + assert ( + main_window._impl.toolbar_native.Size.Width, + main_window._impl.toolbar_native.Size.Height, + ) != ( + original_sizes[main_window._impl.toolbar_native].Width, + original_sizes[main_window._impl.toolbar_native].Height, + ) + + # Check Widget Font Scaling and Size + for widget in app.widgets: + assert ( + widget._impl.native.Font.Size + == widget.window._impl.scale_font( + widget._impl.original_font + ).Size + ) + assert ( + widget._impl.native.Size.Width, + widget._impl.native.Size.Height, + ) != ( + original_sizes[widget].Width, + original_sizes[widget].Height, + ) - window1 = toga.Window("Test Window 1") - window1.content = toga.Box() - window1_probe = window_probe(app, window1) - window1.show() - await window1_probe.wait_for_window("Extra windows added") - - # For window1 - original_values["window1_update_scale"] = window1._impl.update_scale - window1_update_scale_mock = Mock() - monkeypatch.setattr(window1._impl, "update_scale", window1_update_scale_mock) - original_values["window1_resize_content"] = window1._impl.resize_content - window1_resize_content_mock = Mock() - monkeypatch.setattr( - window1._impl, "resize_content", window1_resize_content_mock - ) - original_values["window1_update_toolbar_font_scale"] = ( - window1._impl.update_toolbar_font_scale - ) - window1_update_toolbar_font_scale_mock = Mock() monkeypatch.setattr( - window1._impl, - "update_toolbar_font_scale", - window1_update_toolbar_font_scale_mock, + "toga_winforms.libs.shcore.GetScaleFactorForMonitor", + GetScaleFactorForMonitor_original, ) - # ----------------------------------------------------------------------------- - # Explicitly set the dpi_scale for testing - for window in app.windows: - window._impl._dpi_scale = 1.5 - # -------------------------------------------------------------------------------------- - await main_window_probe.redraw( - "Triggering DPI change event for testing property changes" - ) - app_probe.trigger_dpi_change_event() - - # Test out properties which should change on dpi change - main_window._impl.update_scale.assert_called_once() - window1._impl.update_scale.assert_called_once() - assert main_window_probe.has_toolbar() - app_probe.assert_main_window_toolbar_font_scale_updated() - assert not window1_probe.has_toolbar() - window1._impl.update_toolbar_font_scale.assert_not_called() - app_probe.assert_main_window_menubar_font_scale_updated() - assert not hasattr(window1._impl, "update_menubar_font_scale") - app_probe.assert_main_window_widgets_font_scale_updated() - main_window._impl.resize_content.assert_called_once() - window1._impl.resize_content.assert_called_once() - - # Test if widget.refresh is called once on each widget - for window in app.windows: - for widget in window.widgets: - original_values[id(widget)] = widget.refresh - monkeypatch.setattr(widget, "refresh", Mock()) - - await main_window_probe.redraw( - "Triggering DPI change event for testing widget refresh calls" - ) - app_probe.trigger_dpi_change_event() - - for window in app.windows: - for widget in main_window.widgets: - widget.refresh.assert_called_once() - - # Restore original state - for window in app.windows: - for widget in window.widgets: - monkeypatch.setattr(widget, "refresh", original_values[id(widget)]) - monkeypatch.setattr( - window1._impl, - "update_toolbar_font_scale", - original_values["window1_update_toolbar_font_scale"], - ) - monkeypatch.setattr( - window1._impl, "resize_content", original_values["window1_resize_content"] - ) - monkeypatch.setattr( - window1._impl, "update_scale", original_values["window1_update_scale"] - ) - monkeypatch.setattr( - main_window._impl, - "resize_content", - original_values["main_window_resize_content"], - ) - monkeypatch.setattr( - main_window._impl, - "update_scale", - original_values["main_window_update_scale"], - ) - - # Restore original state - for window in app.windows: - window._impl._dpi_scale = 1.0 await main_window_probe.redraw( "Triggering DPI change event for restoring original state" ) - app_probe.trigger_dpi_change_event() + app._impl.winforms_DisplaySettingsChanged(None, None) + main_window.content.clear() main_window.toolbar.clear() - window1.close() diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 79f7fb26b4..b9ae697c49 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -35,7 +35,9 @@ def winforms_FormClosing(self, sender, event): def update_dpi(self): super().update_dpi() - if getattr(self, "original_menubar_font", None) is not None: + if ( + getattr(self, "original_menubar_font", None) is not None + ): # pragma: no branch self.native.MainMenuStrip.Font = self.scale_font(self.original_menubar_font) diff --git a/winforms/src/toga_winforms/libs/shcore.py b/winforms/src/toga_winforms/libs/shcore.py new file mode 100644 index 0000000000..9a29b41307 --- /dev/null +++ b/winforms/src/toga_winforms/libs/shcore.py @@ -0,0 +1,5 @@ +from ctypes import windll, wintypes, HRESULT, POINTER + +GetScaleFactorForMonitor = windll.shcore.GetScaleFactorForMonitor +GetScaleFactorForMonitor.restype = HRESULT +GetScaleFactorForMonitor.argtypes = [wintypes.HMONITOR, POINTER(wintypes.UINT)] diff --git a/winforms/src/toga_winforms/libs/user32.py b/winforms/src/toga_winforms/libs/user32.py index afa673be67..32d2698df2 100644 --- a/winforms/src/toga_winforms/libs/user32.py +++ b/winforms/src/toga_winforms/libs/user32.py @@ -26,3 +26,11 @@ "We recommend you upgrade to at least Windows 10 version 1703." ) SetProcessDpiAwarenessContext = SetThreadDpiAwarenessContext = None + + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-monitorfromrect +MONITOR_DEFAULTTONEAREST = 2 + +MonitorFromRect = user32.MonitorFromRect +MonitorFromRect.restype = wintypes.HMONITOR +MonitorFromRect.argtypes = [wintypes.LPRECT, wintypes.DWORD] diff --git a/winforms/src/toga_winforms/screens.py b/winforms/src/toga_winforms/screens.py index 797cc7cde6..0d4bcecc60 100644 --- a/winforms/src/toga_winforms/screens.py +++ b/winforms/src/toga_winforms/screens.py @@ -1,4 +1,4 @@ -from ctypes import byref, c_void_p, windll, wintypes +from ctypes import wintypes from System.Drawing import ( Bitmap, @@ -11,6 +11,7 @@ from toga.screens import Screen as ScreenInterface +from .libs import user32, shcore from .widgets.base import Scalable @@ -35,12 +36,9 @@ def dpi_scale(self): self.native.Bounds.Right, self.native.Bounds.Bottom, ) - windll.user32.MonitorFromRect.restype = c_void_p - windll.user32.MonitorFromRect.argtypes = [wintypes.RECT, wintypes.DWORD] - # MONITOR_DEFAULTTONEAREST = 2 - hMonitor = windll.user32.MonitorFromRect(screen_rect, 2) + hMonitor = user32.MonitorFromRect(screen_rect, user32.MONITOR_DEFAULTTONEAREST) pScale = wintypes.UINT() - windll.shcore.GetScaleFactorForMonitor(c_void_p(hMonitor), byref(pScale)) + shcore.GetScaleFactorForMonitor(hMonitor, pScale) return pScale.value / 100 def get_name(self): diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index c1fd01bff9..d1128d4fa0 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -61,6 +61,10 @@ def scale_font(self, native_font): def winforms_Resize(self, sender, event): self.resize_content() + if self.get_current_screen().dpi_scale == self._dpi_scale: + return + else: + self.update_dpi() def winforms_FormClosing(self, sender, event): # If the app is exiting, or a manual close has been requested, don't get @@ -75,15 +79,10 @@ def winforms_FormClosing(self, sender, event): event.Cancel = True def winforms_LocationChanged(self, sender, event): - # Check if the window has moved from one screen to another and if the new - # screen has a different dpi scale than the previous screen then rescale - current_screen = self.get_current_screen() - if not hasattr(self, "_previous_screen"): - self._previous_screen = current_screen - if current_screen != self._previous_screen: - if self._dpi_scale != current_screen.dpi_scale: - self.update_dpi() - self._previous_screen = current_screen + if self.get_current_screen().dpi_scale == self._dpi_scale: + return + else: + self.update_dpi() ###################################################################### # Window properties @@ -196,7 +195,7 @@ def resize_content(self): def update_dpi(self): self._dpi_scale = self.get_current_screen().dpi_scale - if (self.toolbar_native is not None) and ( + if (self.toolbar_native is not None) and ( # pragma: no branch getattr(self, "original_toolbar_font", None) is not None ): self.toolbar_native.Font = self.scale_font(self.original_toolbar_font) diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 676a79284d..202c649aff 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -178,50 +178,3 @@ def activate_menu_minimize(self): def keystroke(self, combination): return winforms_to_toga_key(toga_to_winforms_key(combination)) - - # ------------------- Functions specific to test_system_dpi_change ------------------- - - def trigger_dpi_change_event(self): - self.app._impl.winforms_DisplaySettingsChanged(None, None) - - def assert_main_window_menubar_font_scale_updated(self): - main_window_impl = self.main_window._impl - assert ( - main_window_impl.native.MainMenuStrip.Font.FontFamily.Name - == main_window_impl.original_menubar_font.FontFamily.Name - ) - assert ( - main_window_impl.native.MainMenuStrip.Font.Size - == main_window_impl.scale_font(main_window_impl.original_menubar_font.Size) - ) - assert ( - main_window_impl.native.MainMenuStrip.Font.Style - == main_window_impl.original_menubar_font.Style - ) - - def assert_main_window_toolbar_font_scale_updated(self): - main_window_impl = self.main_window._impl - assert ( - main_window_impl.toolbar_native.Font.FontFamily.Name - == main_window_impl.original_toolbar_font.FontFamily.Name - ) - assert main_window_impl.toolbar_native.Font.Size == main_window_impl.scale_font( - main_window_impl.original_toolbar_font.Size - ) - assert ( - main_window_impl.toolbar_native.Font.Style - == main_window_impl.original_toolbar_font.Style - ) - - def assert_main_window_widgets_font_scale_updated(self): - for widget in self.main_window.widgets: - assert ( - widget._impl.native.Font.FontFamily.Name - == widget._impl.original_font.FontFamily.Name - ) - assert widget._impl.native.Font.Size == widget._impl.scale_font( - widget._impl.original_font.Size - ) - assert widget._impl.native.Font.Style == widget._impl.original_font.Style - - # ------------------------------------------------------------------------------------ From 2cd6213e82ece9de169c914efe438412d3f46000 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Mon, 15 Apr 2024 04:56:18 -0700 Subject: [PATCH 61/80] Empty commit for CI --- testbed/tests/app/test_app.py | 2 +- winforms/src/toga_winforms/libs/shcore.py | 2 +- winforms/src/toga_winforms/screens.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index ea60a9c46e..33b28a3d69 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -5,9 +5,9 @@ import toga from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE from toga.style.pack import Pack +from toga_winforms.libs import shcore from ..test_window import window_probe -from toga_winforms.libs import shcore @pytest.fixture diff --git a/winforms/src/toga_winforms/libs/shcore.py b/winforms/src/toga_winforms/libs/shcore.py index 9a29b41307..6e5dacdeba 100644 --- a/winforms/src/toga_winforms/libs/shcore.py +++ b/winforms/src/toga_winforms/libs/shcore.py @@ -1,4 +1,4 @@ -from ctypes import windll, wintypes, HRESULT, POINTER +from ctypes import HRESULT, POINTER, windll, wintypes GetScaleFactorForMonitor = windll.shcore.GetScaleFactorForMonitor GetScaleFactorForMonitor.restype = HRESULT diff --git a/winforms/src/toga_winforms/screens.py b/winforms/src/toga_winforms/screens.py index 0d4bcecc60..4174beb7df 100644 --- a/winforms/src/toga_winforms/screens.py +++ b/winforms/src/toga_winforms/screens.py @@ -11,7 +11,7 @@ from toga.screens import Screen as ScreenInterface -from .libs import user32, shcore +from .libs import shcore, user32 from .widgets.base import Scalable From 7c8a9b8bf08f5a7e6e1290868c991c837662ed82 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Mon, 15 Apr 2024 05:21:29 -0700 Subject: [PATCH 62/80] Empty commit for CI --- testbed/tests/app/test_app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 33b28a3d69..dcf5a00bea 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -5,7 +5,6 @@ import toga from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE from toga.style.pack import Pack -from toga_winforms.libs import shcore from ..test_window import window_probe @@ -600,6 +599,9 @@ async def test_system_dpi_change( main_window.content.add( toga.Button(text="Testing for system DPI change response") ) + + from toga_winforms.libs import shcore + GetScaleFactorForMonitor_original = getattr(shcore, "GetScaleFactorForMonitor") for dpi_change_event in { From 69054c1892e3ed38ae1f2aaae5a7d4e3f92921af Mon Sep 17 00:00:00 2001 From: proneon267 Date: Fri, 26 Apr 2024 01:52:30 -0700 Subject: [PATCH 63/80] Fixed remaining errors --- winforms/src/toga_winforms/container.py | 8 ++++++++ winforms/src/toga_winforms/widgets/base.py | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/winforms/src/toga_winforms/container.py b/winforms/src/toga_winforms/container.py index 9bebad36fd..0d1a4cd747 100644 --- a/winforms/src/toga_winforms/container.py +++ b/winforms/src/toga_winforms/container.py @@ -13,6 +13,14 @@ def __init__(self, native_parent): self.native_content = WinForms.Panel() native_parent.Controls.Add(self.native_content) + @property + def dpi_scale(self): + window = self.content.interface.window + if window: + return window._impl.dpi_scale + else: + return 1 + @property def width(self): return self.scale_out(self.native_width) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 191fa8be6b..70e919612b 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -52,6 +52,19 @@ def __init__(self, interface): self.native = None self.create() + # Required to prevent Hwnd Related Bugs. + # Obtain a Graphics object and immediately dispose of it. This is + # done to trigger the control's Paint event and force it to redraw. + # Since in toga, Hwnds could be created at inappropriate times. + # As an example, without this fix, running the OptionContainer + # example app should give an error, like: + # ``` + # System.ArgumentOutOfRangeException: InvalidArgument=Value of '0' is not valid for 'index'. + # Parameter name: index + # at System.Windows.Forms.TabControl.GetTabPage(Int32 index) + # ``` + self.native.CreateGraphics().Dispose() + self.interface.style.reapply() @abstractmethod From 5e58f35dd5bafa14c5d869b8b536a1a44f3109b3 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sat, 27 Apr 2024 22:01:35 -0700 Subject: [PATCH 64/80] Fixed test --- testbed/tests/app/test_app.py | 69 ++++++++++++++++--------- winforms/src/toga_winforms/container.py | 2 + 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index dcf5a00bea..ca5523c973 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -599,6 +599,28 @@ async def test_system_dpi_change( main_window.content.add( toga.Button(text="Testing for system DPI change response") ) + await main_window_probe.redraw( + "Main Window is ready for testing system DPI change response" + ) + # Store original values + original_sizes = dict() + original_sizes[main_window._impl.native.MainMenuStrip] = ( + main_window._impl.scale_out( + main_window._impl.native.MainMenuStrip.Size.Width + ), + main_window._impl.scale_out( + main_window._impl.native.MainMenuStrip.Size.Height + ), + ) + original_sizes[main_window._impl.toolbar_native] = ( + main_window._impl.scale_out(main_window._impl.toolbar_native.Size.Width), + main_window._impl.scale_out(main_window._impl.toolbar_native.Size.Height), + ) + for widget in app.widgets: + original_sizes[widget] = ( + widget.window._impl.scale_out(widget._impl.native.Size.Width), + widget.window._impl.scale_out(widget._impl.native.Size.Height), + ) from toga_winforms.libs import shcore @@ -609,31 +631,22 @@ async def test_system_dpi_change( main_window._impl.winforms_LocationChanged, main_window._impl.winforms_Resize, }: - for pScale_value_mock in {125, 150, 175, 200}: - - original_sizes = dict() - original_sizes[main_window._impl.native.MainMenuStrip] = ( - main_window._impl.native.MainMenuStrip.Size - ) - original_sizes[main_window._impl.toolbar_native] = ( - main_window._impl.toolbar_native.Size - ) - for widget in app.widgets: - original_sizes[widget] = widget._impl.native.Size + for pScale_value_mock in [1.0, 1.25, 1.5, 1.75, 2.0]: def GetScaleFactorForMonitor_mock(hMonitor, pScale): - pScale.value = pScale_value_mock + pScale.value = int(pScale_value_mock * 100) monkeypatch.setattr( "toga_winforms.libs.shcore.GetScaleFactorForMonitor", GetScaleFactorForMonitor_mock, ) - assert app.screens[0]._impl.dpi_scale == pScale_value_mock / 100 - + # Trigger DPI change event + dpi_change_event(None, None) await main_window_probe.redraw( "Triggering DPI change event for testing property changes" ) - dpi_change_event(None, None) + # Check that the screen dpi scale returns the mocked value + assert app.screens[0]._impl.dpi_scale == pScale_value_mock # Check MenuBar Font Scaling assert ( @@ -645,9 +658,13 @@ def GetScaleFactorForMonitor_mock(hMonitor, pScale): assert ( main_window._impl.native.MainMenuStrip.Size.Width, main_window._impl.native.MainMenuStrip.Size.Height, - ) != ( - original_sizes[main_window._impl.native.MainMenuStrip].Width, - original_sizes[main_window._impl.native.MainMenuStrip].Height, + ) == ( + main_window._impl.scale_in( + original_sizes[main_window._impl.native.MainMenuStrip][0] + ), + main_window._impl.scale_in( + original_sizes[main_window._impl.native.MainMenuStrip][1] + ), ) # Check ToolBar Font Scaling and Size assert ( @@ -659,9 +676,13 @@ def GetScaleFactorForMonitor_mock(hMonitor, pScale): assert ( main_window._impl.toolbar_native.Size.Width, main_window._impl.toolbar_native.Size.Height, - ) != ( - original_sizes[main_window._impl.toolbar_native].Width, - original_sizes[main_window._impl.toolbar_native].Height, + ) == ( + main_window._impl.scale_in( + original_sizes[main_window._impl.toolbar_native][0] + ), + main_window._impl.scale_in( + original_sizes[main_window._impl.toolbar_native][1] + ), ) # Check Widget Font Scaling and Size @@ -675,9 +696,9 @@ def GetScaleFactorForMonitor_mock(hMonitor, pScale): assert ( widget._impl.native.Size.Width, widget._impl.native.Size.Height, - ) != ( - original_sizes[widget].Width, - original_sizes[widget].Height, + ) == ( + main_window._impl.scale_in(original_sizes[widget][0]), + main_window._impl.scale_in(original_sizes[widget][1]), ) monkeypatch.setattr( diff --git a/winforms/src/toga_winforms/container.py b/winforms/src/toga_winforms/container.py index 0d1a4cd747..e597523a59 100644 --- a/winforms/src/toga_winforms/container.py +++ b/winforms/src/toga_winforms/container.py @@ -13,6 +13,8 @@ def __init__(self, native_parent): self.native_content = WinForms.Panel() native_parent.Controls.Add(self.native_content) + self.native_content.CreateGraphics().Dispose() + @property def dpi_scale(self): window = self.content.interface.window From ff6223c76466ec1d6c99250d94812496dcd9943b Mon Sep 17 00:00:00 2001 From: proneon267 Date: Tue, 14 May 2024 20:07:25 -0700 Subject: [PATCH 65/80] Modified DPI change test --- testbed/tests/app/test_app.py | 188 ++++++++++++++-------------------- 1 file changed, 79 insertions(+), 109 deletions(-) diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 8c9261d1bc..31fe9a1b0a 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -7,6 +7,7 @@ from toga.style.pack import Pack from ..test_window import window_probe +from ..widgets.probe import get_probe @pytest.fixture @@ -605,125 +606,94 @@ async def test_app_icon(app, app_probe): app_probe.assert_app_icon(None) -# This test is windows specific -if toga.platform.current_platform == "windows": +@pytest.mark.skipif( + toga.platform.current_platform != "windows", reason="This test is windows specific." +) +async def test_system_dpi_change( + monkeypatch, app, app_probe, main_window, main_window_probe +): + from toga_winforms.libs import shcore - async def test_system_dpi_change( - monkeypatch, app, app_probe, main_window, main_window_probe - ): - main_window.toolbar.add(app.cmd1, app.cmd2) - main_window.content.add( - toga.Button(text="Testing for system DPI change response") - ) - await main_window_probe.redraw( - "Main Window is ready for testing system DPI change response" + GetScaleFactorForMonitor_original = getattr(shcore, "GetScaleFactorForMonitor") + + def set_mock_dpi_scale(value): + def GetScaleFactorForMonitor_mock(hMonitor, pScale): + pScale.value = int(value * 100) + + monkeypatch.setattr( + "toga_winforms.libs.shcore.GetScaleFactorForMonitor", + GetScaleFactorForMonitor_mock, ) - # Store original values - original_sizes = dict() - original_sizes[main_window._impl.native.MainMenuStrip] = ( - main_window._impl.scale_out( - main_window._impl.native.MainMenuStrip.Size.Width - ), - main_window._impl.scale_out( - main_window._impl.native.MainMenuStrip.Size.Height - ), + + dpi_change_events = [ + app._impl.winforms_DisplaySettingsChanged, + main_window._impl.winforms_LocationChanged, + main_window._impl.winforms_Resize, + ] + for flex_direction in ("row", "column"): + main_window.content = toga.Box( + style=Pack(direction=flex_direction), + children=[ + toga.Box(style=Pack(flex=1)), + toga.Button(text="hello"), + toga.Label(text="toga"), + toga.Button(text="world"), + toga.Box(style=Pack(flex=1)), + ], ) - original_sizes[main_window._impl.toolbar_native] = ( - main_window._impl.scale_out(main_window._impl.toolbar_native.Size.Width), - main_window._impl.scale_out(main_window._impl.toolbar_native.Size.Height), + widget_dimension_to_compare = "width" if flex_direction == "row" else "height" + await main_window_probe.redraw( + "\nMain Window is ready for testing DPI scaling with " + f"window content flex direction set to: {flex_direction}" ) - for widget in app.widgets: - original_sizes[widget] = ( - widget.window._impl.scale_out(widget._impl.native.Size.Width), - widget.window._impl.scale_out(widget._impl.native.Size.Height), + for dpi_change_event in dpi_change_events: + print( + f"\nRunning DPI change event: {dpi_change_event.__func__.__qualname__}" ) - from toga_winforms.libs import shcore - - GetScaleFactorForMonitor_original = getattr(shcore, "GetScaleFactorForMonitor") - - for dpi_change_event in { - app._impl.winforms_DisplaySettingsChanged, - main_window._impl.winforms_LocationChanged, - main_window._impl.winforms_Resize, - }: - for pScale_value_mock in [1.0, 1.25, 1.5, 1.75, 2.0]: + # Set initial DPI scale value + set_mock_dpi_scale(1.0) + dpi_change_events[0](None, None) + await main_window_probe.redraw( + "Setting initial DPI scale value to 1.0 before starting DPI scale testing" + ) - def GetScaleFactorForMonitor_mock(hMonitor, pScale): - pScale.value = int(pScale_value_mock * 100) + for pScale_value_mock in (1.25, 1.5, 1.75, 2.0): + # Store original widget dimension + original_widget_dimension = dict() + for widget in main_window.content.children: + widget_probe = get_probe(widget) + original_widget_dimension[widget] = getattr( + widget_probe, widget_dimension_to_compare + ) - monkeypatch.setattr( - "toga_winforms.libs.shcore.GetScaleFactorForMonitor", - GetScaleFactorForMonitor_mock, - ) + set_mock_dpi_scale(pScale_value_mock) # Trigger DPI change event dpi_change_event(None, None) await main_window_probe.redraw( - "Triggering DPI change event for testing property changes" - ) - # Check that the screen dpi scale returns the mocked value - assert app.screens[0]._impl.dpi_scale == pScale_value_mock - - # Check MenuBar Font Scaling - assert ( - main_window._impl.native.MainMenuStrip.Font.Size - == main_window._impl.scale_font( - main_window._impl.original_menubar_font - ).Size - ) - assert ( - main_window._impl.native.MainMenuStrip.Size.Width, - main_window._impl.native.MainMenuStrip.Size.Height, - ) == ( - main_window._impl.scale_in( - original_sizes[main_window._impl.native.MainMenuStrip][0] - ), - main_window._impl.scale_in( - original_sizes[main_window._impl.native.MainMenuStrip][1] - ), - ) - # Check ToolBar Font Scaling and Size - assert ( - main_window._impl.toolbar_native.Font.Size - == main_window._impl.scale_font( - main_window._impl.original_toolbar_font - ).Size - ) - assert ( - main_window._impl.toolbar_native.Size.Width, - main_window._impl.toolbar_native.Size.Height, - ) == ( - main_window._impl.scale_in( - original_sizes[main_window._impl.toolbar_native][0] - ), - main_window._impl.scale_in( - original_sizes[main_window._impl.toolbar_native][1] - ), + f"Triggering DPI change event for testing scaling at {pScale_value_mock} scale" ) - # Check Widget Font Scaling and Size - for widget in app.widgets: - assert ( - widget._impl.native.Font.Size - == widget.window._impl.scale_font( - widget._impl.original_font - ).Size - ) - assert ( - widget._impl.native.Size.Width, - widget._impl.native.Size.Height, - ) == ( - main_window._impl.scale_in(original_sizes[widget][0]), - main_window._impl.scale_in(original_sizes[widget][1]), - ) - - monkeypatch.setattr( - "toga_winforms.libs.shcore.GetScaleFactorForMonitor", - GetScaleFactorForMonitor_original, - ) - await main_window_probe.redraw( - "Triggering DPI change event for restoring original state" - ) - app._impl.winforms_DisplaySettingsChanged(None, None) - main_window.content.clear() - main_window.toolbar.clear() \ No newline at end of file + # Check Widget size DPI scaling + for widget in main_window.content.children: + if isinstance(widget, toga.Box): + # Dimension of spacer boxes should decrease when dpi scale increases + getattr( + get_probe(widget), widget_dimension_to_compare + ) < original_widget_dimension[widget] + else: + # Dimension of other widgets should increase when dpi scale increases + getattr( + get_probe(widget), widget_dimension_to_compare + ) > original_widget_dimension[widget] + + # Restore original state + monkeypatch.setattr( + "toga_winforms.libs.shcore.GetScaleFactorForMonitor", + GetScaleFactorForMonitor_original, + ) + dpi_change_events[0](None, None) + await main_window_probe.redraw( + "\nTriggering DPI change event for restoring original state" + ) + main_window.content.clear() From 1313d7e71ddbcc88b937d5d9f54941f1b19e414d Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 15 May 2024 10:33:17 -0700 Subject: [PATCH 66/80] Fixed DPI change test --- testbed/tests/app/test_app.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 31fe9a1b0a..7ad4ae1bbe 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -607,11 +607,14 @@ async def test_app_icon(app, app_probe): @pytest.mark.skipif( - toga.platform.current_platform != "windows", reason="This test is windows specific." + toga.platform.current_platform != "windows", reason="This test is Windows specific" ) async def test_system_dpi_change( monkeypatch, app, app_probe, main_window, main_window_probe ): + # Store original window content + main_window_content_original = main_window.content + from toga_winforms.libs import shcore GetScaleFactorForMonitor_original = getattr(shcore, "GetScaleFactorForMonitor") @@ -693,7 +696,7 @@ def GetScaleFactorForMonitor_mock(hMonitor, pScale): GetScaleFactorForMonitor_original, ) dpi_change_events[0](None, None) - await main_window_probe.redraw( - "\nTriggering DPI change event for restoring original state" - ) - main_window.content.clear() + main_window.content.window = None + main_window.content = main_window_content_original + main_window.show() + await main_window_probe.redraw("\nRestoring original state of Main Window") From 41574d4bf7c52ff90aebe4b9efeda62106f290f1 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 10 Jul 2024 08:02:07 -0700 Subject: [PATCH 67/80] Updated to latest main branch --- testbed/tests/app/test_desktop.py | 97 ++++++++++++++++++++++ winforms/src/toga_winforms/__init__.py | 1 - winforms/src/toga_winforms/app.py | 11 ++- winforms/src/toga_winforms/dialogs.py | 30 +++++++ winforms/src/toga_winforms/widgets/base.py | 8 ++ winforms/src/toga_winforms/window.py | 27 +++++- 6 files changed, 169 insertions(+), 5 deletions(-) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 5d8bf9f117..ba8943f128 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -6,6 +6,7 @@ from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE from toga.style.pack import Pack +from ..widgets.probe import get_probe from ..window.test_window import window_probe #################################################################################### @@ -357,6 +358,102 @@ async def test_current_window(app, app_probe, main_window): window3.close() +@pytest.mark.skipif( + toga.platform.current_platform != "windows", reason="This test is Windows specific" +) +async def test_system_dpi_change( + monkeypatch, app, app_probe, main_window, main_window_probe +): + # Store original window content + main_window_content_original = main_window.content + + from toga_winforms.libs import shcore + + GetScaleFactorForMonitor_original = getattr(shcore, "GetScaleFactorForMonitor") + + def set_mock_dpi_scale(value): + def GetScaleFactorForMonitor_mock(hMonitor, pScale): + pScale.value = int(value * 100) + + monkeypatch.setattr( + "toga_winforms.libs.shcore.GetScaleFactorForMonitor", + GetScaleFactorForMonitor_mock, + ) + + dpi_change_events = [ + app._impl.winforms_DisplaySettingsChanged, + main_window._impl.winforms_LocationChanged, + main_window._impl.winforms_Resize, + ] + for flex_direction in ("row", "column"): + main_window.content = toga.Box( + style=Pack(direction=flex_direction), + children=[ + toga.Box(style=Pack(flex=1)), + toga.Button(text="hello"), + toga.Label(text="toga"), + toga.Button(text="world"), + toga.Box(style=Pack(flex=1)), + ], + ) + widget_dimension_to_compare = "width" if flex_direction == "row" else "height" + await main_window_probe.redraw( + "\nMain Window is ready for testing DPI scaling with " + f"window content flex direction set to: {flex_direction}" + ) + for dpi_change_event in dpi_change_events: + print( + f"\nRunning DPI change event: {dpi_change_event.__func__.__qualname__}" + ) + + # Set initial DPI scale value + set_mock_dpi_scale(1.0) + dpi_change_events[0](None, None) + await main_window_probe.redraw( + "Setting initial DPI scale value to 1.0 before starting DPI scale testing" + ) + + for pScale_value_mock in (1.25, 1.5, 1.75, 2.0): + # Store original widget dimension + original_widget_dimension = dict() + for widget in main_window.content.children: + widget_probe = get_probe(widget) + original_widget_dimension[widget] = getattr( + widget_probe, widget_dimension_to_compare + ) + + set_mock_dpi_scale(pScale_value_mock) + # Trigger DPI change event + dpi_change_event(None, None) + await main_window_probe.redraw( + f"Triggering DPI change event for testing scaling at {pScale_value_mock} scale" + ) + + # Check Widget size DPI scaling + for widget in main_window.content.children: + if isinstance(widget, toga.Box): + # Dimension of spacer boxes should decrease when dpi scale increases + getattr( + get_probe(widget), widget_dimension_to_compare + ) < original_widget_dimension[widget] + else: + # Dimension of other widgets should increase when dpi scale increases + getattr( + get_probe(widget), widget_dimension_to_compare + ) > original_widget_dimension[widget] + + # Restore original state + monkeypatch.setattr( + "toga_winforms.libs.shcore.GetScaleFactorForMonitor", + GetScaleFactorForMonitor_original, + ) + dpi_change_events[0](None, None) + main_window.content.window = None + main_window.content = main_window_content_original + main_window.show() + await main_window_probe.redraw("\nRestoring original state of Main Window") + + async def test_session_based_app( monkeypatch, app, diff --git a/winforms/src/toga_winforms/__init__.py b/winforms/src/toga_winforms/__init__.py index 7e47fa5698..eff03c3977 100644 --- a/winforms/src/toga_winforms/__init__.py +++ b/winforms/src/toga_winforms/__init__.py @@ -35,5 +35,4 @@ ): # pragma: no cover print("WARNING: Failed to set the DPI Awareness mode for the app.") - __version__ = toga._package_version(__file__, __name__) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index c31f74496c..75fd015c44 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -4,7 +4,8 @@ import threading import System.Windows.Forms as WinForms -from System import Environment, Threading +from Microsoft.Win32 import SystemEvents +from System import Threading from System.Media import SystemSounds from System.Net import SecurityProtocolType, ServicePointManager from System.Windows.Threading import Dispatcher @@ -113,6 +114,14 @@ def create(self): # Call user code to populate the main window self.interface._startup() + ###################################################################### + # Native event handlers + ###################################################################### + + def winforms_DisplaySettingsChanged(self, sender, event): + for window in self.interface.windows: + window._impl.update_dpi() + ###################################################################### # Commands and menus ###################################################################### diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 20233b0283..0120b39aac 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -97,6 +97,24 @@ class StackTraceDialog(BaseDialog): def __init__(self, title, message, content, retry): super().__init__() + # This dialog uses a fixed layout, so we create it as DPI-unaware so it will be + # scaled by the system. "When a window is created, its DPI awareness is defined + # as the DPI awareness of the calling thread at that time." + # (https://learn.microsoft.com/en-us/windows/win32/hidpi/high-dpi-improvements-for-desktop-applications). + self.prev_dpi_context = None + if SetThreadDpiAwarenessContext is not None: # pragma: no branch + self.prev_dpi_context = SetThreadDpiAwarenessContext( + DPI_AWARENESS_CONTEXT_UNAWARE + ) + if not self.prev_dpi_context: # pragma: no cover + print("WARNING: Failed to set DPI Awareness for StackTraceDialog") + + # Changing the DPI awareness re-scales all pre-existing Font objects, including + # the system fonts. + font_size = 8.25 + message_font = WinFont(FontFamily.GenericSansSerif, font_size) + monospace_font = WinFont(FontFamily.GenericMonospace, font_size) + self.native = WinForms.Form() self.native.MinimizeBox = False self.native.FormBorderStyle = self.native.FormBorderStyle.FixedSingle @@ -171,6 +189,18 @@ def winforms_FormClosing(self, sender, event): # event will be triggered. if not self.future.done(): event.Cancel = True # pragma: no cover + else: + # Reverting the DPI awareness at the end of __init__ would cause the window + # to be DPI-aware, presumably because the window isn't actually "created" + # until we call ShowDialog. + # + # This cleanup doesn't make any difference to the dialogs example, because + # "When the window procedure for a window is called [e.g. when clicking a + # button], the thread is automatically switched to the DPI awareness context + # that was in use when the window was created." However, other apps may do + # things outside of the context of a window event. + if self.prev_dpi_context: # pragma: no branch + SetThreadDpiAwarenessContext(self.prev_dpi_context) def winforms_Click_quit(self, sender, event): self.future.set_result(False) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 70e919612b..72f3f883f1 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -77,6 +77,14 @@ def set_app(self, app): def set_window(self, window): self.scale_font() + @property + def dpi_scale(self): + window = self.interface.window + if window: + return window._impl.dpi_scale + else: + return 1 + @property def container(self): return self._container diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index a5e072c250..3685b923e7 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -4,7 +4,7 @@ import System.Windows.Forms as WinForms from System.ComponentModel import InvalidEnumArgumentException -from System.Drawing import Bitmap, Graphics, Point, Size as WinSize +from System.Drawing import Bitmap, Font as WinFont, Graphics, Point, Size as WinSize from System.Drawing.Imaging import ImageFormat from System.IO import MemoryStream @@ -30,7 +30,6 @@ def __init__(self, interface, title, position, size): self._FormClosing_handler = WeakrefCallable(self.winforms_FormClosing) self.native.FormClosing += self._FormClosing_handler super().__init__(self.native) - self._dpi_scale = self._original_dpi_scale = self.get_current_screen().dpi_scale self.native.MinimizeBox = self.interface.minimizable @@ -43,6 +42,7 @@ def __init__(self, interface, title, position, size): if position: self.set_position(position) + self.native.LocationChanged += WeakrefCallable(self.winforms_LocationChanged) self.native.Resize += WeakrefCallable(self.winforms_Resize) self.resize_content() # Store initial size @@ -51,6 +51,18 @@ def __init__(self, interface, title, position, size): def create(self): self.native = WinForms.Form() + # We cache the scale to make sure that it only changes inside update_dpi. + @property + def dpi_scale(self): + return self._dpi_scale + + def scale_font(self, native_font): + return WinFont( + native_font.FontFamily, + native_font.Size * (self.dpi_scale / self._original_dpi_scale), + native_font.Style, + ) + ###################################################################### # Native event handlers ###################################################################### @@ -240,6 +252,13 @@ def create(self): super().create() self.toolbar_native = None + def update_dpi(self): + super().update_dpi() + if ( + getattr(self, "original_menubar_font", None) is not None + ): # pragma: no branch + self.native.MainMenuStrip.Font = self.scale_font(self.original_menubar_font) + def _top_bars_height(self): vertical_shift = 0 if self.toolbar_native: @@ -313,6 +332,8 @@ def create_menus(self): cmd._impl.native.append(item) submenu.DropDownItems.Add(item) + # Required for font scaling on DPI changes + self.original_menubar_font = menubar.Font self.resize_content() def create_toolbar(self): @@ -348,7 +369,7 @@ def create_toolbar(self): item.Click += WeakrefCallable(cmd._impl.winforms_Click) cmd._impl.native.append(item) self.toolbar_native.Items.Add(item) - + self.original_toolbar_font = self.toolbar_native.Font elif self.toolbar_native: self.native.Controls.Remove(self.toolbar_native) self.toolbar_native = None From 94551969518c2f06c1543a6d8bb7800836810128 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 10 Jul 2024 09:34:37 -0700 Subject: [PATCH 68/80] Updated to latest main branch --- testbed/tests/app/test_app.py | 96 ---------------------- winforms/src/toga_winforms/app.py | 11 +-- winforms/src/toga_winforms/screens.py | 4 +- winforms/src/toga_winforms/widgets/base.py | 8 -- 4 files changed, 4 insertions(+), 115 deletions(-) diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 8080027333..8573cd34af 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -253,99 +253,3 @@ async def test_app_icon(app, app_probe): app.icon = toga.Icon.APP_ICON await app_probe.redraw("Revert app icon to default") app_probe.assert_app_icon(None) - - -@pytest.mark.skipif( - toga.platform.current_platform != "windows", reason="This test is Windows specific" -) -async def test_system_dpi_change( - monkeypatch, app, app_probe, main_window, main_window_probe -): - # Store original window content - main_window_content_original = main_window.content - - from toga_winforms.libs import shcore - - GetScaleFactorForMonitor_original = getattr(shcore, "GetScaleFactorForMonitor") - - def set_mock_dpi_scale(value): - def GetScaleFactorForMonitor_mock(hMonitor, pScale): - pScale.value = int(value * 100) - - monkeypatch.setattr( - "toga_winforms.libs.shcore.GetScaleFactorForMonitor", - GetScaleFactorForMonitor_mock, - ) - - dpi_change_events = [ - app._impl.winforms_DisplaySettingsChanged, - main_window._impl.winforms_LocationChanged, - main_window._impl.winforms_Resize, - ] - for flex_direction in ("row", "column"): - main_window.content = toga.Box( - style=Pack(direction=flex_direction), - children=[ - toga.Box(style=Pack(flex=1)), - toga.Button(text="hello"), - toga.Label(text="toga"), - toga.Button(text="world"), - toga.Box(style=Pack(flex=1)), - ], - ) - widget_dimension_to_compare = "width" if flex_direction == "row" else "height" - await main_window_probe.redraw( - "\nMain Window is ready for testing DPI scaling with " - f"window content flex direction set to: {flex_direction}" - ) - for dpi_change_event in dpi_change_events: - print( - f"\nRunning DPI change event: {dpi_change_event.__func__.__qualname__}" - ) - - # Set initial DPI scale value - set_mock_dpi_scale(1.0) - dpi_change_events[0](None, None) - await main_window_probe.redraw( - "Setting initial DPI scale value to 1.0 before starting DPI scale testing" - ) - - for pScale_value_mock in (1.25, 1.5, 1.75, 2.0): - # Store original widget dimension - original_widget_dimension = dict() - for widget in main_window.content.children: - widget_probe = get_probe(widget) - original_widget_dimension[widget] = getattr( - widget_probe, widget_dimension_to_compare - ) - - set_mock_dpi_scale(pScale_value_mock) - # Trigger DPI change event - dpi_change_event(None, None) - await main_window_probe.redraw( - f"Triggering DPI change event for testing scaling at {pScale_value_mock} scale" - ) - - # Check Widget size DPI scaling - for widget in main_window.content.children: - if isinstance(widget, toga.Box): - # Dimension of spacer boxes should decrease when dpi scale increases - getattr( - get_probe(widget), widget_dimension_to_compare - ) < original_widget_dimension[widget] - else: - # Dimension of other widgets should increase when dpi scale increases - getattr( - get_probe(widget), widget_dimension_to_compare - ) > original_widget_dimension[widget] - - # Restore original state - monkeypatch.setattr( - "toga_winforms.libs.shcore.GetScaleFactorForMonitor", - GetScaleFactorForMonitor_original, - ) - dpi_change_events[0](None, None) - main_window.content.window = None - main_window.content = main_window_content_original - main_window.show() - await main_window_probe.redraw("\nRestoring original state of Main Window") diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 75fd015c44..bbbac23d6b 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -19,13 +19,6 @@ from .libs.wrapper import WeakrefCallable from .screens import Screen as ScreenImpl - def update_dpi(self): - super().update_dpi() - if ( - getattr(self, "original_menubar_font", None) is not None - ): # pragma: no branch - self.native.MainMenuStrip.Font = self.scale_font(self.original_menubar_font) - def winforms_thread_exception(sender, winforms_exc): # pragma: no cover # The PythonException returned by Winforms doesn't give us @@ -232,9 +225,9 @@ def set_main_window(self, window): ###################################################################### def get_screens(self): - primary_screen = Screen(WinForms.Screen.PrimaryScreen) + primary_screen = ScreenImpl(WinForms.Screen.PrimaryScreen) screen_list = [primary_screen] + [ - Screen(native=screen) + ScreenImpl(native=screen) for screen in WinForms.Screen.AllScreens if screen != primary_screen.native ] diff --git a/winforms/src/toga_winforms/screens.py b/winforms/src/toga_winforms/screens.py index 5ad7a2d650..ce1dc31714 100644 --- a/winforms/src/toga_winforms/screens.py +++ b/winforms/src/toga_winforms/screens.py @@ -55,9 +55,9 @@ def get_size(self) -> Size: return Size(self.native.Bounds.Width, self.native.Bounds.Height) def get_image_data(self): - bitmap = Bitmap(*map(self.scale_in, self.get_size())) + bitmap = Bitmap(*self.get_size()) graphics = Graphics.FromImage(bitmap) - source_point = Point(*map(self.scale_in, self.get_origin())) + source_point = Point(*self.get_origin()) destination_point = Point(0, 0) copy_size = WinSize(*self.get_size()) graphics.CopyFromScreen(source_point, destination_point, copy_size) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 72f3f883f1..3470b5f15c 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -103,14 +103,6 @@ def container(self, container): self.refresh() - @property - def dpi_scale(self): - window = self.interface.window - if window: - return window._impl.dpi_scale - else: - return 1 - def get_tab_index(self): return self.native.TabIndex From 767e35e6f59fd2aa408375e3f21a14c0e1edf669 Mon Sep 17 00:00:00 2001 From: proneon267 Date: Wed, 10 Jul 2024 11:23:58 -0700 Subject: [PATCH 69/80] Fixed winforms --- examples/window/window/app.py | 8 ++++---- winforms/src/toga_winforms/window.py | 14 ++++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/examples/window/window/app.py b/examples/window/window/app.py index 7e747ade2a..14ae15dd5a 100644 --- a/examples/window/window/app.py +++ b/examples/window/window/app.py @@ -20,14 +20,14 @@ def do_right(self, widget, **kwargs): def do_left_current_screen(self, widget, **kwargs): self.main_window.screen_position = ( - self.main_window.screen.origin[0], - self.main_window.screen_position[1], + self.main_window.screen.origin.x, + self.main_window.screen_position.y, ) def do_right_current_screen(self, widget, **kwargs): self.main_window.screen_position = ( - self.main_window.screen.size[0] - self.main_window.size[0], - self.main_window.screen_position[1], + self.main_window.screen.size.width - self.main_window.size.width, + self.main_window.screen_position.y, ) def do_small(self, widget, **kwargs): diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 3685b923e7..e96a942933 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -14,7 +14,7 @@ from .container import Container from .keys import toga_to_winforms_key, toga_to_winforms_shortcut from .libs.wrapper import WeakrefCallable -from .screens import Screen +from .screens import Screen as ScreenImpl from .widgets.base import Scalable if TYPE_CHECKING: # pragma: no cover @@ -162,10 +162,7 @@ def resize_content(self): def update_dpi(self): self._dpi_scale = self.get_current_screen().dpi_scale - if (self.toolbar_native is not None) and ( # pragma: no branch - getattr(self, "original_toolbar_font", None) is not None - ): - self.toolbar_native.Font = self.scale_font(self.original_toolbar_font) + for widget in self.interface.widgets: widget._impl.scale_font() widget.refresh() @@ -193,7 +190,7 @@ def set_size(self, size: SizeT): ###################################################################### def get_current_screen(self): - return Screen(WinForms.Screen.FromControl(self.native)) + return ScreenImpl(WinForms.Screen.FromControl(self.native)) def get_position(self) -> Position: location = self.native.Location @@ -259,6 +256,11 @@ def update_dpi(self): ): # pragma: no branch self.native.MainMenuStrip.Font = self.scale_font(self.original_menubar_font) + if (self.toolbar_native is not None) and ( # pragma: no branch + getattr(self, "original_toolbar_font", None) is not None + ): + self.toolbar_native.Font = self.scale_font(self.original_toolbar_font) + def _top_bars_height(self): vertical_shift = 0 if self.toolbar_native: From a30a8e3c7f7d70ff8361b40a7a1dff2efd8ab1df Mon Sep 17 00:00:00 2001 From: proneon267 <45512885+proneon267@users.noreply.github.com> Date: Thu, 11 Jul 2024 07:12:02 -0700 Subject: [PATCH 70/80] Update 2155.bugfix.rst From c4d5c5ee22b8062b0c179cee8c62171d2602554a Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 13 Oct 2024 08:31:48 -0700 Subject: [PATCH 71/80] Parameterized test --- testbed/tests/app/test_desktop.py | 162 +++++++++++++++++------------- 1 file changed, 94 insertions(+), 68 deletions(-) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index e4b8d90ef8..e81bd4b6d6 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -1,3 +1,4 @@ +from functools import reduce from unittest.mock import Mock import pytest @@ -391,15 +392,50 @@ async def test_current_window(app, app_probe, main_window): assert app.current_window == window3 -@pytest.mark.skipif( - toga.platform.current_platform != "windows", reason="This test is Windows specific" +@pytest.mark.parametrize("flex_direction", ["row", "column"]) +@pytest.mark.parametrize( + "dpi_change_event_string", + [ + "app._impl.winforms_DisplaySettingsChanged", + "main_window._impl.winforms_LocationChanged", + "main_window._impl.winforms_Resize", + ], +) +@pytest.mark.parametrize( + "initial_dpi_scale, final_dpi_scale", + [ + (1.0, 1.25), + (1.0, 1.5), + (1.0, 1.75), + (1.0, 2.0), + (1.25, 1.5), + (1.25, 1.75), + (1.25, 2.0), + (1.5, 1.75), + (1.5, 2.0), + (1.75, 2.0), + ], ) async def test_system_dpi_change( - monkeypatch, app, app_probe, main_window, main_window_probe + monkeypatch, + app, + app_probe, + main_window, + main_window_probe, + flex_direction, + dpi_change_event_string, + initial_dpi_scale, + final_dpi_scale, ): - # Store original window content - main_window_content_original = main_window.content + if toga.platform.current_platform != "windows": + pytest.xfail("This test is winforms backend specific") + + # Get the dpi change event from the string + obj_name, *attr_parts = dpi_change_event_string.split(".") + obj = locals()[obj_name] + dpi_change_event = reduce(getattr, attr_parts, obj) + # Patch the internal dpi scale method from toga_winforms.libs import shcore GetScaleFactorForMonitor_original = getattr(shcore, "GetScaleFactorForMonitor") @@ -413,78 +449,68 @@ def GetScaleFactorForMonitor_mock(hMonitor, pScale): GetScaleFactorForMonitor_mock, ) - dpi_change_events = [ - app._impl.winforms_DisplaySettingsChanged, - main_window._impl.winforms_LocationChanged, - main_window._impl.winforms_Resize, - ] - for flex_direction in ("row", "column"): - main_window.content = toga.Box( - style=Pack(direction=flex_direction), - children=[ - toga.Box(style=Pack(flex=1)), - toga.Button(text="hello"), - toga.Label(text="toga"), - toga.Button(text="world"), - toga.Box(style=Pack(flex=1)), - ], - ) - widget_dimension_to_compare = "width" if flex_direction == "row" else "height" - await main_window_probe.redraw( - "\nMain Window is ready for testing DPI scaling with " - f"window content flex direction set to: {flex_direction}" + # Set initial DPI scale value + set_mock_dpi_scale(initial_dpi_scale) + dpi_change_event(None, None) + await main_window_probe.redraw( + f"Initial dpi scale is {initial_dpi_scale} before starting test" + ) + + # Store original window content + main_window_content_original = main_window.content + + # Setup window for testing + main_window.content = toga.Box( + style=Pack(direction=flex_direction), + children=[ + toga.Box(style=Pack(flex=1)), + toga.Button(text="hello"), + toga.Label(text="toga"), + toga.Button(text="world"), + toga.Box(style=Pack(flex=1)), + ], + ) + await main_window_probe.redraw("main_window is ready for testing") + + widget_dimension_to_compare = "width" if flex_direction == "row" else "height" + + # Store original widget dimension + original_widget_dimension = dict() + for widget in main_window.content.children: + widget_probe = get_probe(widget) + original_widget_dimension[widget] = getattr( + widget_probe, widget_dimension_to_compare ) - for dpi_change_event in dpi_change_events: - print( - f"\nRunning DPI change event: {dpi_change_event.__func__.__qualname__}" - ) - - # Set initial DPI scale value - set_mock_dpi_scale(1.0) - dpi_change_events[0](None, None) - await main_window_probe.redraw( - "Setting initial DPI scale value to 1.0 before starting DPI scale testing" - ) - - for pScale_value_mock in (1.25, 1.5, 1.75, 2.0): - # Store original widget dimension - original_widget_dimension = dict() - for widget in main_window.content.children: - widget_probe = get_probe(widget) - original_widget_dimension[widget] = getattr( - widget_probe, widget_dimension_to_compare - ) - - set_mock_dpi_scale(pScale_value_mock) - # Trigger DPI change event - dpi_change_event(None, None) - await main_window_probe.redraw( - f"Triggering DPI change event for testing scaling at {pScale_value_mock} scale" - ) - - # Check Widget size DPI scaling - for widget in main_window.content.children: - if isinstance(widget, toga.Box): - # Dimension of spacer boxes should decrease when dpi scale increases - getattr( - get_probe(widget), widget_dimension_to_compare - ) < original_widget_dimension[widget] - else: - # Dimension of other widgets should increase when dpi scale increases - getattr( - get_probe(widget), widget_dimension_to_compare - ) > original_widget_dimension[widget] + + # Set and Trigger dpi change event with the specified dpi scale + set_mock_dpi_scale(final_dpi_scale) + dpi_change_event(None, None) + await main_window_probe.redraw( + f"Triggered dpi change event with {final_dpi_scale} dpi scale" + ) + + # Check Widget size DPI scaling + for widget in main_window.content.children: + if isinstance(widget, toga.Box): + # Dimension of spacer boxes should decrease when dpi scale increases + getattr( + get_probe(widget), widget_dimension_to_compare + ) < original_widget_dimension[widget] + else: + # Dimension of other widgets should increase when dpi scale increases + getattr( + get_probe(widget), widget_dimension_to_compare + ) > original_widget_dimension[widget] # Restore original state monkeypatch.setattr( "toga_winforms.libs.shcore.GetScaleFactorForMonitor", GetScaleFactorForMonitor_original, ) - dpi_change_events[0](None, None) + dpi_change_event(None, None) main_window.content.window = None main_window.content = main_window_content_original - main_window.show() - await main_window_probe.redraw("\nRestoring original state of Main Window") + await main_window_probe.redraw("Restored original state of main_window") async def test_session_based_app( From 83159fe0b86164bbde816fb7b35308650e4eb70e Mon Sep 17 00:00:00 2001 From: proneon267 Date: Sun, 13 Oct 2024 09:03:41 -0700 Subject: [PATCH 72/80] Parameterized test --- testbed/tests/app/test_desktop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index e81bd4b6d6..26ff48b5f0 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -510,6 +510,7 @@ def GetScaleFactorForMonitor_mock(hMonitor, pScale): dpi_change_event(None, None) main_window.content.window = None main_window.content = main_window_content_original + main_window.show() await main_window_probe.redraw("Restored original state of main_window") From 0800f3fe18f9b3abbefac4810511ad73c50df49d Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 30 Oct 2024 21:15:09 +0000 Subject: [PATCH 73/80] Revert unnecessary change to iOS --- iOS/tests_backend/probe.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iOS/tests_backend/probe.py b/iOS/tests_backend/probe.py index 1ff33a33a0..5865947da4 100644 --- a/iOS/tests_backend/probe.py +++ b/iOS/tests_backend/probe.py @@ -1,7 +1,7 @@ import asyncio import toga -from toga_iOS.libs import NSRunLoop +from toga_iOS.libs import NSRunLoop, UIScreen class BaseProbe: @@ -21,5 +21,5 @@ async def redraw(self, message=None, delay=0): def assert_image_size(self, image_size, size, screen): # Retina displays render images at a higher resolution than their reported size. - scale = int(screen._impl.native.scale) + scale = int(UIScreen.mainScreen.scale) assert image_size == (size[0] * scale, size[1] * scale) From 617c4fe49613e0fad9ff5c77830a46abfffe9612 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 3 Nov 2024 20:35:49 +0000 Subject: [PATCH 74/80] Various cleanups and fixes --- winforms/src/toga_winforms/container.py | 3 ++- winforms/src/toga_winforms/widgets/base.py | 7 +++---- winforms/src/toga_winforms/window.py | 20 ++++++-------------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/winforms/src/toga_winforms/container.py b/winforms/src/toga_winforms/container.py index e597523a59..a18b56e912 100644 --- a/winforms/src/toga_winforms/container.py +++ b/winforms/src/toga_winforms/container.py @@ -13,11 +13,12 @@ def __init__(self, native_parent): self.native_content = WinForms.Panel() native_parent.Controls.Add(self.native_content) + # See comment in Widget.__init__. self.native_content.CreateGraphics().Dispose() @property def dpi_scale(self): - window = self.content.interface.window + window = self.content.interface.window if self.content else None if window: return window._impl.dpi_scale else: diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 3470b5f15c..3f0d2cdf86 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -52,17 +52,16 @@ def __init__(self, interface): self.native = None self.create() - # Required to prevent Hwnd Related Bugs. # Obtain a Graphics object and immediately dispose of it. This is # done to trigger the control's Paint event and force it to redraw. # Since in toga, Hwnds could be created at inappropriate times. # As an example, without this fix, running the OptionContainer # example app should give an error, like: - # ``` - # System.ArgumentOutOfRangeException: InvalidArgument=Value of '0' is not valid for 'index'. + # + # System.ArgumentOutOfRangeException: InvalidArgument=Value of '0' is not valid + # for 'index'. # Parameter name: index # at System.Windows.Forms.TabControl.GetTabPage(Int32 index) - # ``` self.native.CreateGraphics().Dispose() self.interface.style.reapply() diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 3e78be9860..d4adfdf887 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -68,9 +68,7 @@ def scale_font(self, native_font): def winforms_Resize(self, sender, event): if self.native.WindowState != WinForms.FormWindowState.Minimized: self.resize_content() - if self.get_current_screen().dpi_scale == self._dpi_scale: - return - else: + if self.get_current_screen().dpi_scale != self._dpi_scale: self.update_dpi() def winforms_FormClosing(self, sender, event): @@ -92,9 +90,7 @@ def winforms_FormClosing(self, sender, event): event.Cancel = True def winforms_LocationChanged(self, sender, event): - if self.get_current_screen().dpi_scale == self._dpi_scale: - return - else: + if self.get_current_screen().dpi_scale != self._dpi_scale: self.update_dpi() ###################################################################### @@ -250,15 +246,11 @@ def create(self): def update_dpi(self): super().update_dpi() - if ( - getattr(self, "original_menubar_font", None) is not None - ): # pragma: no branch + if self.native.MainMenuStrip: # pragma: no branch self.native.MainMenuStrip.Font = self.scale_font(self.original_menubar_font) - - if (self.toolbar_native is not None) and ( # pragma: no branch - getattr(self, "original_toolbar_font", None) is not None - ): + if self.toolbar_native: self.toolbar_native.Font = self.scale_font(self.original_toolbar_font) + self.resize_content() def _top_bars_height(self): vertical_shift = 0 @@ -309,7 +301,6 @@ def create_menus(self): submenu.DropDownItems.Add(item) - # Required for font scaling on DPI changes self.original_menubar_font = menubar.Font self.resize_content() @@ -347,6 +338,7 @@ def create_toolbar(self): cmd._impl.native.append(item) self.toolbar_native.Items.Add(item) self.original_toolbar_font = self.toolbar_native.Font + elif self.toolbar_native: self.native.Controls.Remove(self.toolbar_native) self.toolbar_native = None From 44f912443132128726d4158ab9d7e67f2714c1cd Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 6 Nov 2024 16:45:31 +0000 Subject: [PATCH 75/80] Rewrite DPI tests --- core/src/toga/types.py | 14 +- core/tests/test_types.py | 23 ++- testbed/tests/app/test_desktop.py | 206 +++++++++++++------------ winforms/src/toga_winforms/window.py | 9 +- winforms/tests_backend/fonts.py | 9 +- winforms/tests_backend/widgets/base.py | 13 +- winforms/tests_backend/window.py | 4 +- 7 files changed, 163 insertions(+), 115 deletions(-) diff --git a/core/src/toga/types.py b/core/src/toga/types.py index b50553cb1f..ca0fca5822 100644 --- a/core/src/toga/types.py +++ b/core/src/toga/types.py @@ -29,7 +29,7 @@ def __str__(self) -> str: class Position(NamedTuple): - """A 2D window position.""" + """A 2D position.""" #: X coordinate, in CSS pixels. x: int @@ -46,15 +46,21 @@ def __add__(self, other): def __sub__(self, other): return Position(self.x - other.x, self.y - other.y) + def __mul__(self, other): + return Position(self.x * other, self.y * other) + class Size(NamedTuple): - """A 2D window size.""" + """A 2D size.""" - #: Width + #: Width, in CSS pixels. width: int - #: Height + #: Height, in CSS pixels. height: int def __str__(self) -> str: return f"({self.width} x {self.height})" + + def __mul__(self, other): + return Size(self.width * other, self.height * other) diff --git a/core/tests/test_types.py b/core/tests/test_types.py index 3b94cbf99a..ec300eac86 100644 --- a/core/tests/test_types.py +++ b/core/tests/test_types.py @@ -7,7 +7,12 @@ def test_position_properties(): assert p.x == 1 assert p.y == 2 assert str(p) == "(1, 2)" - p == (1, 2) # Tuple equivalence for backwards-compatibility + + assert p == Position(1, 2) + assert p != Position(1, 3) + + assert p == (1, 2) # Tuple equivalence for backwards-compatibility + assert p != (1, 3) def test_add_positions(): @@ -20,6 +25,14 @@ def test_sub_positions(): assert Position(1, 2) - Position(3, 4) == Position(-2, -2) +def test_mul_position(): + """Multiplying a Position multiplies its X and Y values""" + assert Position(1, 2) * 2 == Position(2, 4) + assert Position(1, 2) * 0.5 == Position(0.5, 1) + assert Position(1, 2) * 0 == Position(0, 0) + assert Position(1, 2) * -1 == Position(-1, -2) + + def test_size_properties(): """A Size NamedTuple has a width and height.""" s = Size(1, 2) @@ -27,3 +40,11 @@ def test_size_properties(): assert s.height == 2 assert str(s) == "(1 x 2)" s == (1, 2) # Tuple equivalence for backwards-compatibility + + +def test_mul_size(): + """Multiplying a Size multiplies its width and height values""" + assert Size(1, 2) * 2 == Size(2, 4) + assert Size(1, 2) * 0.5 == Size(0.5, 1) + assert Size(1, 2) * 0 == Size(0, 0) + assert Size(1, 2) * -1 == Size(-1, -2) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 26ff48b5f0..e9ad0b4ba1 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -1,9 +1,11 @@ -from functools import reduce +from functools import partial from unittest.mock import Mock import pytest +from System import EventArgs import toga +from toga import Position, Size from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE from toga.style.pack import Pack @@ -392,126 +394,134 @@ async def test_current_window(app, app_probe, main_window): assert app.current_window == window3 -@pytest.mark.parametrize("flex_direction", ["row", "column"]) @pytest.mark.parametrize( - "dpi_change_event_string", + "event_name", [ - "app._impl.winforms_DisplaySettingsChanged", - "main_window._impl.winforms_LocationChanged", - "main_window._impl.winforms_Resize", - ], -) -@pytest.mark.parametrize( - "initial_dpi_scale, final_dpi_scale", - [ - (1.0, 1.25), - (1.0, 1.5), - (1.0, 1.75), - (1.0, 2.0), - (1.25, 1.5), - (1.25, 1.75), - (1.25, 2.0), - (1.5, 1.75), - (1.5, 2.0), - (1.75, 2.0), + # FIXME DpiChangedAfterParent + "LocationChanged", + "Resize", ], ) +@pytest.mark.parametrize("mock_scale", [1.0, 1.25, 1.5, 1.75, 2.0]) async def test_system_dpi_change( - monkeypatch, - app, - app_probe, - main_window, - main_window_probe, - flex_direction, - dpi_change_event_string, - initial_dpi_scale, - final_dpi_scale, + main_window, main_window_probe, event_name, mock_scale ): if toga.platform.current_platform != "windows": pytest.xfail("This test is winforms backend specific") - # Get the dpi change event from the string - obj_name, *attr_parts = dpi_change_event_string.split(".") - obj = locals()[obj_name] - dpi_change_event = reduce(getattr, attr_parts, obj) + real_scale = main_window_probe.scale_factor + if real_scale == mock_scale: + pytest.skip("mock scale and real scale are the same") + scale_change = mock_scale / real_scale + content_size = main_window_probe.content_size - # Patch the internal dpi scale method - from toga_winforms.libs import shcore - - GetScaleFactorForMonitor_original = getattr(shcore, "GetScaleFactorForMonitor") - - def set_mock_dpi_scale(value): - def GetScaleFactorForMonitor_mock(hMonitor, pScale): - pScale.value = int(value * 100) - - monkeypatch.setattr( - "toga_winforms.libs.shcore.GetScaleFactorForMonitor", - GetScaleFactorForMonitor_mock, - ) - - # Set initial DPI scale value - set_mock_dpi_scale(initial_dpi_scale) - dpi_change_event(None, None) - await main_window_probe.redraw( - f"Initial dpi scale is {initial_dpi_scale} before starting test" - ) - - # Store original window content - main_window_content_original = main_window.content + # Get the dpi change event from the string + dpi_change_event = getattr(main_window_probe.native, f"On{event_name}") # Setup window for testing + # Include widgets which are sized in different ways, plus padding and fixed sizes in + # both dimensions. main_window.content = toga.Box( - style=Pack(direction=flex_direction), + style=Pack(direction="row"), children=[ - toga.Box(style=Pack(flex=1)), - toga.Button(text="hello"), - toga.Label(text="toga"), - toga.Button(text="world"), - toga.Box(style=Pack(flex=1)), + toga.Label( + "fixed", + id="fixed", + style=Pack(background_color="yellow", padding_left=20, width=100), + ), + toga.Label( + "minimal", # Shrink to fit content + id="minimal", + style=Pack(background_color="cyan", font_size=16), + ), + toga.Label( + "flex", + id="flex", + style=Pack(background_color="pink", flex=1, padding_top=15, height=50), + ), ], ) await main_window_probe.redraw("main_window is ready for testing") - widget_dimension_to_compare = "width" if flex_direction == "row" else "height" + ids = ["fixed", "minimal", "flex"] + probes = {id: get_probe(main_window.widgets[id]) for id in ids} - # Store original widget dimension - original_widget_dimension = dict() - for widget in main_window.content.children: - widget_probe = get_probe(widget) - original_widget_dimension[widget] = getattr( - widget_probe, widget_dimension_to_compare + def get_metrics(): + return ( + {id: Position(probes[id].x, probes[id].y) for id in ids}, + {id: Size(probes[id].width, probes[id].height) for id in ids}, + {id: probes[id].font_size for id in ids}, ) - # Set and Trigger dpi change event with the specified dpi scale - set_mock_dpi_scale(final_dpi_scale) - dpi_change_event(None, None) - await main_window_probe.redraw( - f"Triggered dpi change event with {final_dpi_scale} dpi scale" - ) + positions, sizes, font_sizes = get_metrics() - # Check Widget size DPI scaling - for widget in main_window.content.children: - if isinstance(widget, toga.Box): - # Dimension of spacer boxes should decrease when dpi scale increases - getattr( - get_probe(widget), widget_dimension_to_compare - ) < original_widget_dimension[widget] - else: - # Dimension of other widgets should increase when dpi scale increases - getattr( - get_probe(widget), widget_dimension_to_compare - ) > original_widget_dimension[widget] + # Because of hinting, font size changes can have non-linear effects on pixel sizes. + approx_fixed = partial(pytest.approx, abs=1) + approx_font = partial(pytest.approx, rel=0.25) - # Restore original state - monkeypatch.setattr( - "toga_winforms.libs.shcore.GetScaleFactorForMonitor", - GetScaleFactorForMonitor_original, - ) - dpi_change_event(None, None) - main_window.content.window = None - main_window.content = main_window_content_original - main_window.show() - await main_window_probe.redraw("Restored original state of main_window") + assert font_sizes["fixed"] == 9 # Default font size on Windows + assert positions["fixed"] == approx_fixed((20, 0)) + assert sizes["fixed"].width == approx_fixed(100) + + assert font_sizes["minimal"] == 16 + assert positions["minimal"] == approx_fixed((120, 0)) + assert sizes["minimal"].height == approx_font(sizes["fixed"].height * 16 / 9) + + assert font_sizes["flex"] == 9 + assert positions["flex"] == approx_fixed((120 + sizes["minimal"].width, 15)) + assert sizes["flex"] == approx_fixed((content_size.width - positions["flex"].x, 50)) + + # Mock the function Toga uses to get the scale factor. + from toga_winforms.libs import shcore + + def GetScaleFactorForMonitor_mock(hMonitor, pScale): + pScale.value = int(mock_scale * 100) + + try: + GetScaleFactorForMonitor_original = shcore.GetScaleFactorForMonitor + shcore.GetScaleFactorForMonitor = GetScaleFactorForMonitor_mock + + # Set and Trigger dpi change event with the specified dpi scale + dpi_change_event(EventArgs.Empty) + await main_window_probe.redraw( + f"Triggered dpi change event with {mock_scale} dpi scale" + ) + + # Check Widget size DPI scaling + positions_scaled, sizes_scaled, font_sizes_scaled = get_metrics() + for id in ids: + assert font_sizes_scaled[id] == approx_fixed(font_sizes[id] * scale_change) + + assert positions_scaled["fixed"] == approx_fixed(Position(20, 0) * scale_change) + assert sizes_scaled["fixed"] == ( + approx_fixed(100 * scale_change), + approx_font(sizes["fixed"].height * scale_change), + ) + + assert positions_scaled["minimal"] == approx_fixed( + Position(120, 0) * scale_change + ) + assert sizes_scaled["minimal"] == approx_font(sizes["minimal"] * scale_change) + + assert positions_scaled["flex"] == approx_fixed( + ( + positions_scaled["minimal"].x + sizes_scaled["minimal"].width, + 15 * scale_change, + ) + ) + assert sizes_scaled["flex"] == approx_fixed( + ( + content_size.width - positions_scaled["flex"].x, + 50 * scale_change, + ) + ) + + finally: + # Restore original state + shcore.GetScaleFactorForMonitor = GetScaleFactorForMonitor_original + dpi_change_event(EventArgs.Empty) + await main_window_probe.redraw("Restored original state of main_window") + assert get_metrics() == (positions, sizes, font_sizes) async def test_session_based_app( diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index d4adfdf887..c47807c6c3 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -11,6 +11,7 @@ from toga.types import Position, Size from .container import Container +from .fonts import DEFAULT_FONT from .libs.wrapper import WeakrefCallable from .screens import Screen as ScreenImpl from .widgets.base import Scalable @@ -28,7 +29,7 @@ def __init__(self, interface, title, position, size): self._FormClosing_handler = WeakrefCallable(self.winforms_FormClosing) self.native.FormClosing += self._FormClosing_handler super().__init__(self.native) - self._dpi_scale = self._original_dpi_scale = self.get_current_screen().dpi_scale + self._dpi_scale = self.get_current_screen().dpi_scale self.native.MinimizeBox = self.interface.minimizable self.native.MaximizeBox = self.interface.resizable @@ -57,7 +58,7 @@ def dpi_scale(self): def scale_font(self, native_font): return WinFont( native_font.FontFamily, - native_font.Size * (self.dpi_scale / self._original_dpi_scale), + native_font.Size * self.dpi_scale, native_font.Style, ) @@ -301,7 +302,7 @@ def create_menus(self): submenu.DropDownItems.Add(item) - self.original_menubar_font = menubar.Font + self.original_menubar_font = DEFAULT_FONT self.resize_content() def create_toolbar(self): @@ -337,7 +338,7 @@ def create_toolbar(self): item.Click += WeakrefCallable(cmd._impl.winforms_Click) cmd._impl.native.append(item) self.toolbar_native.Items.Add(item) - self.original_toolbar_font = self.toolbar_native.Font + self.original_toolbar_font = DEFAULT_FONT elif self.toolbar_native: self.native.Controls.Remove(self.toolbar_native) diff --git a/winforms/tests_backend/fonts.py b/winforms/tests_backend/fonts.py index 9018b846bc..80033ab75a 100644 --- a/winforms/tests_backend/fonts.py +++ b/winforms/tests_backend/fonts.py @@ -35,11 +35,14 @@ def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): else: assert NORMAL == variant + @property + def font_size(self): + return round(self.font.SizeInPoints / self.scale_factor) + def assert_font_size(self, expected): if expected == SYSTEM_DEFAULT_FONT_SIZE: - assert int(self.font.SizeInPoints) == 9 - else: - assert int(self.font.SizeInPoints) == expected + expected = 9 + assert self.font_size == expected def assert_font_family(self, expected): assert str(self.font.Name) == { diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 2d29554a1d..87a3b972a3 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -69,6 +69,14 @@ def font(self): def hidden(self): return not self.native.Visible + @property + def x(self): + return round(self.native.Left / self.scale_factor) + + @property + def y(self): + return round(self.native.Top / self.scale_factor) + @property def width(self): return round(self.native.Width / self.scale_factor) @@ -99,10 +107,7 @@ def assert_layout(self, size, position): # size and position is as expected. assert (self.width, self.height) == approx(size, abs=1) - assert ( - round(self.native.Left / self.scale_factor), - round(self.native.Top / self.scale_factor), - ) == approx(position, abs=1) + assert (self.x, self.y) == approx(position, abs=1) async def press(self): self.native.OnClick(EventArgs.Empty) diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index 7a63d1235b..b5d8268223 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -8,6 +8,8 @@ ToolStripSeparator, ) +from toga import Size + from .dialogs import DialogsMixin from .probe import BaseProbe @@ -39,7 +41,7 @@ def close(self): @property def content_size(self): - return ( + return Size( (self.native.ClientSize.Width) / self.scale_factor, ( (self.native.ClientSize.Height - self.impl._top_bars_height()) From ca540a4d7cc0d2a50abb7164f4b407415e1aeeab Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 6 Nov 2024 19:17:20 +0000 Subject: [PATCH 76/80] Add test of DisplaySettingsChanged event --- testbed/tests/app/test_desktop.py | 50 ++++++++++++++++++++++------ winforms/src/toga_winforms/app.py | 9 ++++- winforms/src/toga_winforms/window.py | 3 ++ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index e9ad0b4ba1..e83a97dfb8 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -2,7 +2,6 @@ from unittest.mock import Mock import pytest -from System import EventArgs import toga from toga import Position, Size @@ -395,16 +394,16 @@ async def test_current_window(app, app_probe, main_window): @pytest.mark.parametrize( - "event_name", + "event_path", [ - # FIXME DpiChangedAfterParent - "LocationChanged", - "Resize", + "SystemEvents.DisplaySettingsChanged", + "Form.LocationChanged", + "Form.Resize", ], ) @pytest.mark.parametrize("mock_scale", [1.0, 1.25, 1.5, 1.75, 2.0]) async def test_system_dpi_change( - main_window, main_window_probe, event_name, mock_scale + main_window, main_window_probe, event_path, mock_scale ): if toga.platform.current_platform != "windows": pytest.xfail("This test is winforms backend specific") @@ -415,9 +414,6 @@ async def test_system_dpi_change( scale_change = mock_scale / real_scale content_size = main_window_probe.content_size - # Get the dpi change event from the string - dpi_change_event = getattr(main_window_probe.native, f"On{event_name}") - # Setup window for testing # Include widgets which are sized in different ways, plus padding and fixed sizes in # both dimensions. @@ -482,7 +478,8 @@ def GetScaleFactorForMonitor_mock(hMonitor, pScale): shcore.GetScaleFactorForMonitor = GetScaleFactorForMonitor_mock # Set and Trigger dpi change event with the specified dpi scale - dpi_change_event(EventArgs.Empty) + dpi_change_event = find_event(event_path, main_window_probe) + dpi_change_event(None) await main_window_probe.redraw( f"Triggered dpi change event with {mock_scale} dpi scale" ) @@ -519,11 +516,42 @@ def GetScaleFactorForMonitor_mock(hMonitor, pScale): finally: # Restore original state shcore.GetScaleFactorForMonitor = GetScaleFactorForMonitor_original - dpi_change_event(EventArgs.Empty) + dpi_change_event(None) await main_window_probe.redraw("Restored original state of main_window") assert get_metrics() == (positions, sizes, font_sizes) +def find_event(event_path, main_window_probe): + from Microsoft.Win32 import SystemEvents + from System import Array, Object + from System.Reflection import BindingFlags + + event_class, event_name = event_path.split(".") + if event_class == "Form": + return getattr(main_window_probe.native, f"On{event_name}") + + elif event_class == "SystemEvents": + # There are no "On" methods in this class, so we need to use reflection. + SystemEvents_type = SystemEvents().GetType() + binding_flags = BindingFlags.Static | BindingFlags.NonPublic + RaiseEvent = [ + method + for method in SystemEvents_type.GetMethods(binding_flags) + if method.Name == "RaiseEvent" and len(method.GetParameters()) == 2 + ][0] + + event_key = SystemEvents_type.GetField( + f"On{event_name}Event", binding_flags + ).GetValue(None) + + return lambda event_args: RaiseEvent.Invoke( + None, [event_key, Array[Object]([None, event_args])] + ) + + else: + raise AssertionError(f"unknown event class {event_class}") + + async def test_session_based_app( monkeypatch, app, diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 083129b18d..1dbfe46c86 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -76,7 +76,14 @@ def create(self): self.app_context = WinForms.ApplicationContext() self.app_dispatcher = Dispatcher.CurrentDispatcher - # Register the DisplaySettingsChanged event handler + # We would prefer to detect DPI changes directly, using the DpiChanged, + # DpiChangedBeforeParent or DpiChangedAfterParent events on the window. But none + # of these events ever fire, possibly because we're missing some app metadata + # (https://github.com/beeware/toga/pull/2155#issuecomment-2460374101). So + # instead we need to listen to all events which could cause a DPI change: + # * DisplaySettingsChanged + # * Form.LocationChanged and Form.Resize, since a window's DPI is determined + # by which screen most of its area is on. SystemEvents.DisplaySettingsChanged += WeakrefCallable( self.winforms_DisplaySettingsChanged ) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index c47807c6c3..74fdff87a6 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -69,6 +69,8 @@ def scale_font(self, native_font): def winforms_Resize(self, sender, event): if self.native.WindowState != WinForms.FormWindowState.Minimized: self.resize_content() + + # See DisplaySettingsChanged in app.py. if self.get_current_screen().dpi_scale != self._dpi_scale: self.update_dpi() @@ -91,6 +93,7 @@ def winforms_FormClosing(self, sender, event): event.Cancel = True def winforms_LocationChanged(self, sender, event): + # See DisplaySettingsChanged in app.py. if self.get_current_screen().dpi_scale != self._dpi_scale: self.update_dpi() From 1188ff9a19aeeb89be0fcb9276057d66f9c1a1d6 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Thu, 7 Nov 2024 19:32:41 +0000 Subject: [PATCH 77/80] Correct font scaling --- testbed/tests/app/test_desktop.py | 112 ++++++++++++++------------ winforms/src/toga_winforms/app.py | 5 ++ winforms/src/toga_winforms/dialogs.py | 4 +- winforms/src/toga_winforms/window.py | 16 ++-- winforms/tests_backend/probe.py | 6 +- 5 files changed, 81 insertions(+), 62 deletions(-) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index e83a97dfb8..8cf68fbd76 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -408,77 +408,82 @@ async def test_system_dpi_change( if toga.platform.current_platform != "windows": pytest.xfail("This test is winforms backend specific") + from toga_winforms.libs import shcore + real_scale = main_window_probe.scale_factor if real_scale == mock_scale: pytest.skip("mock scale and real scale are the same") scale_change = mock_scale / real_scale content_size = main_window_probe.content_size - # Setup window for testing - # Include widgets which are sized in different ways, plus padding and fixed sizes in - # both dimensions. - main_window.content = toga.Box( - style=Pack(direction="row"), - children=[ - toga.Label( - "fixed", - id="fixed", - style=Pack(background_color="yellow", padding_left=20, width=100), - ), - toga.Label( - "minimal", # Shrink to fit content - id="minimal", - style=Pack(background_color="cyan", font_size=16), - ), - toga.Label( - "flex", - id="flex", - style=Pack(background_color="pink", flex=1, padding_top=15, height=50), - ), - ], - ) - await main_window_probe.redraw("main_window is ready for testing") - - ids = ["fixed", "minimal", "flex"] - probes = {id: get_probe(main_window.widgets[id]) for id in ids} + original_content = main_window.content + GetScaleFactorForMonitor_original = shcore.GetScaleFactorForMonitor + dpi_change_event = find_event(event_path, main_window_probe) - def get_metrics(): - return ( - {id: Position(probes[id].x, probes[id].y) for id in ids}, - {id: Size(probes[id].width, probes[id].height) for id in ids}, - {id: probes[id].font_size for id in ids}, + try: + # Include widgets which are sized in different ways, with padding and fixed + # sizes in both dimensions. + main_window.content = toga.Box( + style=Pack(direction="row"), + children=[ + toga.Label( + "fixed", + id="fixed", + style=Pack(background_color="yellow", padding_left=20, width=100), + ), + toga.Label( + "minimal", # Shrink to fit content + id="minimal", + style=Pack(background_color="cyan", font_size=16), + ), + toga.Label( + "flex", + id="flex", + style=Pack( + background_color="pink", flex=1, padding_top=15, height=50 + ), + ), + ], ) + await main_window_probe.redraw("main_window is ready for testing") - positions, sizes, font_sizes = get_metrics() + ids = ["fixed", "minimal", "flex"] + probes = {id: get_probe(main_window.widgets[id]) for id in ids} - # Because of hinting, font size changes can have non-linear effects on pixel sizes. - approx_fixed = partial(pytest.approx, abs=1) - approx_font = partial(pytest.approx, rel=0.25) + def get_metrics(): + return ( + {id: Position(probes[id].x, probes[id].y) for id in ids}, + {id: Size(probes[id].width, probes[id].height) for id in ids}, + {id: probes[id].font_size for id in ids}, + ) - assert font_sizes["fixed"] == 9 # Default font size on Windows - assert positions["fixed"] == approx_fixed((20, 0)) - assert sizes["fixed"].width == approx_fixed(100) + positions, sizes, font_sizes = get_metrics() - assert font_sizes["minimal"] == 16 - assert positions["minimal"] == approx_fixed((120, 0)) - assert sizes["minimal"].height == approx_font(sizes["fixed"].height * 16 / 9) + # Because of hinting, font size changes can have non-linear effects on pixel + # sizes. + approx_fixed = partial(pytest.approx, abs=1) + approx_font = partial(pytest.approx, rel=0.25) - assert font_sizes["flex"] == 9 - assert positions["flex"] == approx_fixed((120 + sizes["minimal"].width, 15)) - assert sizes["flex"] == approx_fixed((content_size.width - positions["flex"].x, 50)) + assert font_sizes["fixed"] == 9 # Default font size on Windows + assert positions["fixed"] == approx_fixed((20, 0)) + assert sizes["fixed"].width == approx_fixed(100) - # Mock the function Toga uses to get the scale factor. - from toga_winforms.libs import shcore + assert font_sizes["minimal"] == 16 + assert positions["minimal"] == approx_fixed((120, 0)) + assert sizes["minimal"].height == approx_font(sizes["fixed"].height * 16 / 9) - def GetScaleFactorForMonitor_mock(hMonitor, pScale): - pScale.value = int(mock_scale * 100) + assert font_sizes["flex"] == 9 + assert positions["flex"] == approx_fixed((120 + sizes["minimal"].width, 15)) + assert sizes["flex"] == approx_fixed( + (content_size.width - positions["flex"].x, 50) + ) - try: - GetScaleFactorForMonitor_original = shcore.GetScaleFactorForMonitor - shcore.GetScaleFactorForMonitor = GetScaleFactorForMonitor_mock + # Mock the function Toga uses to get the scale factor. + def GetScaleFactorForMonitor_mock(hMonitor, pScale): + pScale.value = int(mock_scale * 100) # Set and Trigger dpi change event with the specified dpi scale - dpi_change_event = find_event(event_path, main_window_probe) + shcore.GetScaleFactorForMonitor = GetScaleFactorForMonitor_mock dpi_change_event(None) await main_window_probe.redraw( f"Triggered dpi change event with {mock_scale} dpi scale" @@ -519,6 +524,7 @@ def GetScaleFactorForMonitor_mock(hMonitor, pScale): dpi_change_event(None) await main_window_probe.redraw("Restored original state of main_window") assert get_metrics() == (positions, sizes, font_sizes) + main_window.content = original_content def find_event(event_path, main_window_probe): diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 1dbfe46c86..4cc1ad71e9 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -116,6 +116,11 @@ def create(self): ###################################################################### def winforms_DisplaySettingsChanged(self, sender, event): + # This event is NOT called on the UI thread, so it's not safe for it to access + # the UI directly. + self.interface.loop.call_soon_threadsafe(self.update_dpi) + + def update_dpi(self): for window in self.interface.windows: window._impl.update_dpi() diff --git a/winforms/src/toga_winforms/dialogs.py b/winforms/src/toga_winforms/dialogs.py index 0120b39aac..db575cbe33 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -109,8 +109,8 @@ def __init__(self, title, message, content, retry): if not self.prev_dpi_context: # pragma: no cover print("WARNING: Failed to set DPI Awareness for StackTraceDialog") - # Changing the DPI awareness re-scales all pre-existing Font objects, including - # the system fonts. + # Changing the DPI awareness causes confusion around font sizes, so set them + # all explicitly. font_size = 8.25 message_font = WinFont(FontFamily.GenericSansSerif, font_size) monospace_font = WinFont(FontFamily.GenericMonospace, font_size) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 74fdff87a6..cc8fd2af7f 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -20,6 +20,12 @@ from toga.types import PositionT, SizeT +# It looks like something is caching the initial scale of the primary screen, and +# scaling all font sizes by it. Experiments show that this cache is at the level of the +# app, not the window. +initial_dpi_scale = ScreenImpl(WinForms.Screen.PrimaryScreen).dpi_scale + + class Window(Container, Scalable): def __init__(self, interface, title, position, size): self.interface = interface @@ -58,7 +64,7 @@ def dpi_scale(self): def scale_font(self, native_font): return WinFont( native_font.FontFamily, - native_font.Size * self.dpi_scale, + native_font.Size * (self.dpi_scale / initial_dpi_scale), native_font.Style, ) @@ -251,9 +257,9 @@ def create(self): def update_dpi(self): super().update_dpi() if self.native.MainMenuStrip: # pragma: no branch - self.native.MainMenuStrip.Font = self.scale_font(self.original_menubar_font) + self.native.MainMenuStrip.Font = self.scale_font(DEFAULT_FONT) if self.toolbar_native: - self.toolbar_native.Font = self.scale_font(self.original_toolbar_font) + self.toolbar_native.Font = self.scale_font(DEFAULT_FONT) self.resize_content() def _top_bars_height(self): @@ -291,6 +297,7 @@ def create_menus(self): menubar = WinForms.MenuStrip() self.native.Controls.Add(menubar) self.native.MainMenuStrip = menubar + self.native.MainMenuStrip.Font = self.scale_font(DEFAULT_FONT) menubar.SendToBack() # In a dock, "back" means "top". group_cache = {None: menubar} @@ -305,7 +312,6 @@ def create_menus(self): submenu.DropDownItems.Add(item) - self.original_menubar_font = DEFAULT_FONT self.resize_content() def create_toolbar(self): @@ -317,6 +323,7 @@ def create_toolbar(self): # defaults to `Top`. self.toolbar_native = WinForms.ToolStrip() self.native.Controls.Add(self.toolbar_native) + self.toolbar_native.Font = self.scale_font(DEFAULT_FONT) self.toolbar_native.BringToFront() # In a dock, "front" means "bottom". prev_group = None @@ -341,7 +348,6 @@ def create_toolbar(self): item.Click += WeakrefCallable(cmd._impl.winforms_Click) cmd._impl.native.append(item) self.toolbar_native.Items.Add(item) - self.original_toolbar_font = DEFAULT_FONT elif self.toolbar_native: self.native.Controls.Remove(self.toolbar_native) diff --git a/winforms/tests_backend/probe.py b/winforms/tests_backend/probe.py index ebbfd497b1..04f755d723 100644 --- a/winforms/tests_backend/probe.py +++ b/winforms/tests_backend/probe.py @@ -24,10 +24,12 @@ async def redraw(self, message=None, delay=0): # If we're running slow, wait for a second if toga.App.app.run_slow: delay = max(1, delay) - if delay: print("Waiting for redraw" if message is None else message) - await asyncio.sleep(delay) + + # Sleep even if the delay is zero: this allows any pending callbacks on the + # event loop to run. + await asyncio.sleep(delay) @property def scale_factor(self): From 99d1f76de898226ef07d33e2c25c46aa21d0d1f9 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sat, 9 Nov 2024 19:04:24 +0000 Subject: [PATCH 78/80] Avoid redundant refreshes on DPI changes --- winforms/src/toga_winforms/window.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index cc8fd2af7f..d59af48179 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -168,9 +168,15 @@ def resize_content(self): def update_dpi(self): self._dpi_scale = self.get_current_screen().dpi_scale + # Update all the native fonts and determine the new preferred sizes. for widget in self.interface.widgets: widget._impl.scale_font() - widget.refresh() + widget._impl.refresh() + + # Then do a single layout pass. + if self.interface.content is not None: + self.interface.content.refresh() + self.resize_content() ###################################################################### From fcbae32270a02ee516c778778f450bf79a958f50 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 10 Nov 2024 17:10:20 +0000 Subject: [PATCH 79/80] Add tests of menubar and toolbar scaling --- examples/command/command/app.py | 3 +- testbed/tests/app/test_desktop.py | 66 +++++++++++++++++++++++--- winforms/src/toga_winforms/window.py | 3 +- winforms/tests_backend/fonts.py | 4 ++ winforms/tests_backend/probe.py | 35 +++++++++++++- winforms/tests_backend/widgets/base.py | 39 +-------------- winforms/tests_backend/window.py | 35 +++++++++++--- 7 files changed, 130 insertions(+), 55 deletions(-) diff --git a/examples/command/command/app.py b/examples/command/command/app.py index e802ac08fe..283688ade3 100644 --- a/examples/command/command/app.py +++ b/examples/command/command/app.py @@ -98,10 +98,9 @@ def startup(self): cmd5 = toga.Command( self.action5, text="TB Action 5", - tooltip="Perform toolbar action 5", order=2, group=sub_menu, - ) # there is deliberately no icon to show that a toolbar action also works with text + ) # there is deliberately no icon or tooltip cmd6 = toga.Command( self.action6, text="Action 6", diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 8cf68fbd76..e32083be34 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -414,13 +414,15 @@ async def test_system_dpi_change( if real_scale == mock_scale: pytest.skip("mock scale and real scale are the same") scale_change = mock_scale / real_scale - content_size = main_window_probe.content_size + client_size = main_window_probe.client_size original_content = main_window.content GetScaleFactorForMonitor_original = shcore.GetScaleFactorForMonitor dpi_change_event = find_event(event_path, main_window_probe) try: + main_window.toolbar.add(toga.Command(None, "Test command")) + # Include widgets which are sized in different ways, with padding and fixed # sizes in both dimensions. main_window.content = toga.Box( @@ -447,8 +449,14 @@ async def test_system_dpi_change( ) await main_window_probe.redraw("main_window is ready for testing") - ids = ["fixed", "minimal", "flex"] - probes = {id: get_probe(main_window.widgets[id]) for id in ids} + widget_ids = ["fixed", "minimal", "flex"] + probes = {id: get_probe(main_window.widgets[id]) for id in widget_ids} + + decor_ids = ["menubar", "toolbar", "container"] + probes.update( + {id: getattr(main_window_probe, f"{id}_probe") for id in decor_ids} + ) + ids = widget_ids + decor_ids def get_metrics(): return ( @@ -464,6 +472,25 @@ def get_metrics(): approx_fixed = partial(pytest.approx, abs=1) approx_font = partial(pytest.approx, rel=0.25) + # Positions of the menubar, toolbar and top-level container are relative to the + # window client area. + assert font_sizes["menubar"] == 9 + assert positions["menubar"] == approx_fixed((0, 0)) + assert sizes["menubar"].width == approx_fixed(client_size.width) + + assert font_sizes["toolbar"] == 9 + assert positions["toolbar"] == approx_fixed((0, sizes["menubar"].height)) + assert sizes["toolbar"].width == approx_fixed(client_size.width) + + # Container has no text, so its font doesn't matter. + assert positions["container"] == approx_fixed( + (0, positions["toolbar"].y + sizes["toolbar"].height) + ) + assert sizes["container"] == approx_fixed( + (client_size.width, client_size.height - positions["container"].y) + ) + + # Positions of widgets are relative to the top-level container. assert font_sizes["fixed"] == 9 # Default font size on Windows assert positions["fixed"] == approx_fixed((20, 0)) assert sizes["fixed"].width == approx_fixed(100) @@ -475,7 +502,7 @@ def get_metrics(): assert font_sizes["flex"] == 9 assert positions["flex"] == approx_fixed((120 + sizes["minimal"].width, 15)) assert sizes["flex"] == approx_fixed( - (content_size.width - positions["flex"].x, 50) + (client_size.width - positions["flex"].x, 50) ) # Mock the function Toga uses to get the scale factor. @@ -492,7 +519,31 @@ def GetScaleFactorForMonitor_mock(hMonitor, pScale): # Check Widget size DPI scaling positions_scaled, sizes_scaled, font_sizes_scaled = get_metrics() for id in ids: - assert font_sizes_scaled[id] == approx_fixed(font_sizes[id] * scale_change) + if id != "container": + assert font_sizes_scaled[id] == approx_fixed( + font_sizes[id] * scale_change + ) + + assert positions_scaled["menubar"] == approx_fixed((0, 0)) + assert sizes_scaled["menubar"] == ( + approx_fixed(client_size.width), + approx_font(sizes["menubar"].height * scale_change), + ) + + assert positions_scaled["toolbar"] == approx_fixed( + (0, sizes_scaled["menubar"].height) + ) + assert sizes_scaled["toolbar"] == ( + approx_fixed(client_size.width), + approx_font(sizes["toolbar"].height * scale_change), + ) + + assert positions_scaled["container"] == approx_fixed( + (0, positions_scaled["toolbar"].y + sizes_scaled["toolbar"].height) + ) + assert sizes_scaled["container"] == approx_fixed( + (client_size.width, client_size.height - positions_scaled["container"].y) + ) assert positions_scaled["fixed"] == approx_fixed(Position(20, 0) * scale_change) assert sizes_scaled["fixed"] == ( @@ -513,17 +564,18 @@ def GetScaleFactorForMonitor_mock(hMonitor, pScale): ) assert sizes_scaled["flex"] == approx_fixed( ( - content_size.width - positions_scaled["flex"].x, + client_size.width - positions_scaled["flex"].x, 50 * scale_change, ) ) finally: - # Restore original state shcore.GetScaleFactorForMonitor = GetScaleFactorForMonitor_original dpi_change_event(None) await main_window_probe.redraw("Restored original state of main_window") assert get_metrics() == (positions, sizes, font_sizes) + + main_window.toolbar.clear() main_window.content = original_content diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index d59af48179..903a04a5e9 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -345,7 +345,8 @@ def create_toolbar(self): else: prev_group = cmd.group - item = WinForms.ToolStripMenuItem(cmd.text) + item = WinForms.ToolStripButton(cmd.text) + item.AutoToolTip = False if cmd.tooltip is not None: item.ToolTipText = cmd.tooltip if cmd.icon is not None: diff --git a/winforms/tests_backend/fonts.py b/winforms/tests_backend/fonts.py index 80033ab75a..93bacf364c 100644 --- a/winforms/tests_backend/fonts.py +++ b/winforms/tests_backend/fonts.py @@ -21,6 +21,10 @@ class FontMixin: supports_custom_fonts = True supports_custom_variable_fonts = True + @property + def font(self): + return self.native.Font + def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): assert BOLD if self.font.Bold else NORMAL == weight diff --git a/winforms/tests_backend/probe.py b/winforms/tests_backend/probe.py index 04f755d723..10b9cbf8f0 100644 --- a/winforms/tests_backend/probe.py +++ b/winforms/tests_backend/probe.py @@ -1,10 +1,13 @@ import asyncio from ctypes import byref, c_void_p, windll, wintypes +from pytest import approx from System.Windows.Forms import Screen, SendKeys import toga +from .fonts import FontMixin + KEY_CODES = { f"<{name}>": f"{{{name.upper()}}}" for name in ["esc", "up", "down", "left", "right"] @@ -16,7 +19,12 @@ ) -class BaseProbe: +class BaseProbe(FontMixin): + fixed_height = None + + def __init__(self, native=None): + self.native = native + async def redraw(self, message=None, delay=0): """Request a redraw of the app, waiting until that redraw has completed.""" # Winforms style changes always take effect immediately. @@ -31,6 +39,31 @@ async def redraw(self, message=None, delay=0): # event loop to run. await asyncio.sleep(delay) + @property + def x(self): + return round(self.native.Left / self.scale_factor) + + @property + def y(self): + return round(self.native.Top / self.scale_factor) + + @property + def width(self): + return round(self.native.Width / self.scale_factor) + + @property + def height(self): + return round(self.native.Height / self.scale_factor) + + def assert_width(self, min_width, max_width): + assert min_width <= self.width <= max_width + + def assert_height(self, min_height, max_height): + if self.fixed_height is not None: + assert self.height == approx(self.fixed_height, rel=0.1) + else: + assert min_height <= self.height <= max_height + @property def scale_factor(self): # For ScrollContainer diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 87a3b972a3..8b55536f70 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -7,20 +7,16 @@ from toga.colors import TRANSPARENT from toga.style.pack import JUSTIFY, LEFT -from ..fonts import FontMixin from ..probe import BaseProbe from .properties import toga_color -class SimpleProbe(BaseProbe, FontMixin): - fixed_height = None - +class SimpleProbe(BaseProbe): def __init__(self, widget): - super().__init__() self.app = widget.app self.widget = widget self.impl = widget._impl - self.native = self.impl.native + super().__init__(self.impl.native) assert isinstance(self.native, self.native_class) def assert_container(self, container): @@ -61,41 +57,10 @@ def background_color(self): else: return toga_color(self.native.BackColor) - @property - def font(self): - return self.native.Font - @property def hidden(self): return not self.native.Visible - @property - def x(self): - return round(self.native.Left / self.scale_factor) - - @property - def y(self): - return round(self.native.Top / self.scale_factor) - - @property - def width(self): - return round(self.native.Width / self.scale_factor) - - @property - def height(self): - return round(self.native.Height / self.scale_factor) - - def assert_width(self, min_width, max_width): - assert ( - min_width <= self.width <= max_width - ), f"Width ({self.width}) not in range ({min_width}, {max_width})" - - def assert_height(self, min_height, max_height): - if self.fixed_height is not None: - assert self.height == approx(self.fixed_height, rel=0.1) - else: - assert min_height <= self.height <= max_height - @property def shrink_on_resize(self): return True diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index b5d8268223..3ca2ac2c5f 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -4,6 +4,7 @@ FormBorderStyle, FormWindowState, MenuStrip, + Panel, ToolStrip, ToolStripSeparator, ) @@ -26,11 +27,10 @@ class WindowProbe(BaseProbe, DialogsMixin): supports_placement = True def __init__(self, app, window): - super().__init__() self.app = app self.window = window self.impl = window._impl - self.native = window._impl.native + super().__init__(window._impl.native) assert isinstance(self.native, Form) async def wait_for_window(self, message, minimize=False, full_screen=False): @@ -41,12 +41,17 @@ def close(self): @property def content_size(self): + client_size = self.client_size return Size( - (self.native.ClientSize.Width) / self.scale_factor, - ( - (self.native.ClientSize.Height - self.impl._top_bars_height()) - / self.scale_factor - ), + client_size.width, + client_size.height - (self.impl._top_bars_height() / self.scale_factor), + ) + + @property + def client_size(self): + return Size( + self.native.ClientSize.Width / self.scale_factor, + self.native.ClientSize.Height / self.scale_factor, ) @property @@ -75,6 +80,22 @@ def minimize(self): def unminimize(self): self.native.WindowState = FormWindowState.Normal + @property + def container_probe(self): + panels = [ + control for control in self.native.Controls if isinstance(control, Panel) + ] + assert len(panels) == 1 + return BaseProbe(panels[0]) + + @property + def menubar_probe(self): + return BaseProbe(bar) if (bar := self.native.MainMenuStrip) else None + + @property + def toolbar_probe(self): + return BaseProbe(bar) if (bar := self._native_toolbar()) else None + def _native_toolbar(self): for control in self.native.Controls: if isinstance(control, ToolStrip) and not isinstance(control, MenuStrip): From 066d5b661463a56a333f506271c13d85a5b1443c Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 10 Nov 2024 18:30:00 +0000 Subject: [PATCH 80/80] Fix scaling of Window/Screen position/size --- examples/window/window/app.py | 109 +++++++++++++++++++------- winforms/src/toga_winforms/app.py | 5 +- winforms/src/toga_winforms/screens.py | 14 +++- winforms/src/toga_winforms/window.py | 15 +++- 4 files changed, 109 insertions(+), 34 deletions(-) diff --git a/examples/window/window/app.py b/examples/window/window/app.py index 14ae15dd5a..f5d95e1ac8 100644 --- a/examples/window/window/app.py +++ b/examples/window/window/app.py @@ -3,7 +3,7 @@ from functools import partial import toga -from toga.constants import COLUMN, RIGHT +from toga.constants import COLUMN, RIGHT, ROW from toga.style import Pack @@ -11,30 +11,71 @@ class WindowDemoApp(toga.App): # Button callback functions def do_origin(self, widget, **kwargs): self.main_window.position = (0, 0) + self.do_report() + + def do_up(self, widget, **kwargs): + self.main_window.position = ( + self.main_window.position.x, + self.main_window.position.y - 200, + ) + self.do_report() + + def do_down(self, widget, **kwargs): + self.main_window.position = ( + self.main_window.position.x, + self.main_window.position.y + 200, + ) + self.do_report() def do_left(self, widget, **kwargs): - self.main_window.position = (-1000, 500) + self.main_window.position = ( + self.main_window.position.x - 200, + self.main_window.position.y, + ) + self.do_report() def do_right(self, widget, **kwargs): - self.main_window.position = (2000, 500) + self.main_window.position = ( + self.main_window.position.x + 200, + self.main_window.position.y, + ) + self.do_report() + + def do_screen_top(self, widget, **kwargs): + self.main_window.screen_position = ( + self.main_window.screen_position.x, + 0, + ) + self.do_report() + + def do_screen_bottom(self, widget, **kwargs): + self.main_window.screen_position = ( + self.main_window.screen_position.x, + self.main_window.screen.size.height - self.main_window.size.height, + ) + self.do_report() - def do_left_current_screen(self, widget, **kwargs): + def do_screen_left(self, widget, **kwargs): self.main_window.screen_position = ( - self.main_window.screen.origin.x, + 0, self.main_window.screen_position.y, ) + self.do_report() - def do_right_current_screen(self, widget, **kwargs): + def do_screen_right(self, widget, **kwargs): self.main_window.screen_position = ( self.main_window.screen.size.width - self.main_window.size.width, self.main_window.screen_position.y, ) + self.do_report() def do_small(self, widget, **kwargs): self.main_window.size = (400, 300) + self.do_report() def do_large(self, widget, **kwargs): self.main_window.size = (1500, 1000) + self.do_report() def do_app_full_screen(self, widget, **kwargs): if self.is_full_screen: @@ -91,6 +132,7 @@ def do_new_windows(self, widget, **kwargs): def do_screen_change(self, screen, widget, **kwargs): self.current_window.screen = screen + self.do_report() async def do_save_screenshot(self, screen, window, **kwargs): screenshot = screen.as_image() @@ -118,11 +160,14 @@ async def do_hide_cursor(self, widget, **kwargs): self.show_cursor() self.label.text = "Cursor should be back!" - def do_report(self, widget, **kwargs): + def do_report(self, *args, **kwargs): + window = self.main_window + screen = window.screen self.label.text = ( - f"Window {self.main_window.title!r} " - f"has size {self.main_window.size!r} " - f"at {self.main_window.position!r}" + f"Window: size={tuple(window.size)}, position={tuple(window.position)}, " + f"screen_position={tuple(window.screen_position)}\n" + f"Screen: name={screen.name!r}, size={tuple(screen.size)}, " + f"origin={tuple(screen.origin)}" ) def do_next_content(self, widget): @@ -172,21 +217,30 @@ def startup(self): # Buttons btn_style = Pack(flex=1, padding=5) - btn_do_origin = toga.Button( - "Go to origin", on_press=self.do_origin, style=btn_style - ) - btn_do_left = toga.Button("Go left", on_press=self.do_left, style=btn_style) - btn_do_right = toga.Button("Go right", on_press=self.do_right, style=btn_style) - btn_do_left_current_screen = toga.Button( - "Go left on current screen", - on_press=self.do_left_current_screen, - style=btn_style, + + row_move = toga.Box( + style=Pack(direction=ROW), + children=[ + toga.Label("Move: "), + toga.Button("Origin", on_press=self.do_origin, style=btn_style), + toga.Button("Up", on_press=self.do_up, style=btn_style), + toga.Button("Down", on_press=self.do_down, style=btn_style), + toga.Button("Left", on_press=self.do_left, style=btn_style), + toga.Button("Right", on_press=self.do_right, style=btn_style), + ], ) - btn_do_right_current_screen = toga.Button( - "Go right on current screen", - on_press=self.do_right_current_screen, - style=btn_style, + + row_screen_edge = toga.Box( + style=Pack(direction=ROW), + children=[ + toga.Label("Screen edge: "), + toga.Button("Top", on_press=self.do_screen_top, style=btn_style), + toga.Button("Bottom", on_press=self.do_screen_bottom, style=btn_style), + toga.Button("Left", on_press=self.do_screen_left, style=btn_style), + toga.Button("Right", on_press=self.do_screen_right, style=btn_style), + ], ) + btn_do_small = toga.Button( "Become small", on_press=self.do_small, style=btn_style ) @@ -262,11 +316,9 @@ def startup(self): self.inner_box = toga.Box( children=[ self.label, - btn_do_origin, - btn_do_left, - btn_do_right, - btn_do_left_current_screen, - btn_do_right_current_screen, + row_move, + row_screen_edge, + btn_do_report, btn_do_small, btn_do_large, btn_do_app_full_screen, @@ -275,7 +327,6 @@ def startup(self): btn_do_new_windows, btn_do_current_window_cycling, btn_do_hide_cursor, - btn_do_report, btn_change_content, btn_hide, btn_beep, diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 4cc1ad71e9..f01610667c 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -194,8 +194,11 @@ def set_main_window(self, window): # App resources ###################################################################### + def get_primary_screen(self): + return ScreenImpl(WinForms.Screen.PrimaryScreen) + def get_screens(self): - primary_screen = ScreenImpl(WinForms.Screen.PrimaryScreen) + primary_screen = self.get_primary_screen() screen_list = [primary_screen] + [ ScreenImpl(native=screen) for screen in WinForms.Screen.AllScreens diff --git a/winforms/src/toga_winforms/screens.py b/winforms/src/toga_winforms/screens.py index ce1dc31714..a929605634 100644 --- a/winforms/src/toga_winforms/screens.py +++ b/winforms/src/toga_winforms/screens.py @@ -9,6 +9,7 @@ ) from System.IO import MemoryStream +from toga import App from toga.screens import Screen as ScreenInterface from toga.types import Position, Size @@ -48,11 +49,20 @@ def get_name(self): # non-text part to prevent any errors due to non-escaped characters. return name.split("\\")[-1] + # Screen.origin is scaled according to the DPI of the primary screen, because there + # is no better choice that could cover screens of multiple DPIs. def get_origin(self) -> Position: - return Position(self.native.Bounds.X, self.native.Bounds.Y) + primary_screen = App.app._impl.get_primary_screen() + bounds = self.native.Bounds + return Position( + primary_screen.scale_out(bounds.X), primary_screen.scale_out(bounds.Y) + ) + # Screen.size is scaled according to the screen's own DPI, to be consistent with the + # scaling of Window size and content. def get_size(self) -> Size: - return Size(self.native.Bounds.Width, self.native.Bounds.Height) + bounds = self.native.Bounds + return Size(self.scale_out(bounds.Width), self.scale_out(bounds.Height)) def get_image_data(self): bitmap = Bitmap(*self.get_size()) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 903a04a5e9..a01839538e 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -7,6 +7,7 @@ from System.Drawing.Imaging import ImageFormat from System.IO import MemoryStream +from toga import App from toga.command import Separator from toga.types import Position, Size @@ -183,6 +184,8 @@ def update_dpi(self): # Window size ###################################################################### + # Window.size is scaled according to the DPI of the current screen, to be consistent + # with the scaling of its content. def get_size(self) -> Size: size = self.native.Size return Size( @@ -203,12 +206,20 @@ def set_size(self, size: SizeT): def get_current_screen(self): return ScreenImpl(WinForms.Screen.FromControl(self.native)) + # Window.position is scaled according to the DPI of the primary screen, because the + # interface layer assumes that Screen.origin, Window.position and + # Window.screen_position are all in the same coordinate system. + # + # TODO: remove that assumption, and make Window.position return coordinates relative + # to the current screen's origin and DPI. def get_position(self) -> Position: location = self.native.Location - return Position(*map(self.scale_out, (location.X, location.Y))) + primary_screen = App.app._impl.get_primary_screen() + return Position(*map(primary_screen.scale_out, (location.X, location.Y))) def set_position(self, position: PositionT): - self.native.Location = Point(*map(self.scale_in, position)) + primary_screen = App.app._impl.get_primary_screen() + self.native.Location = Point(*map(primary_screen.scale_in, position)) ###################################################################### # Window visibility