diff --git a/changes/2155.bugfix.rst b/changes/2155.bugfix.rst new file mode 100644 index 0000000000..4c12d4e5aa --- /dev/null +++ b/changes/2155.bugfix.rst @@ -0,0 +1 @@ +DPI scaling on Windows is now improved and related bugs are fixed. 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/examples/window/window/app.py b/examples/window/window/app.py index e4d6fd032d..14ae15dd5a 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.x, + self.main_window.screen_position.y, + ) 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.width - self.main_window.size.width, + self.main_window.screen_position.y, + ) def do_small(self, widget, **kwargs): self.main_window.size = (400, 300) diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 5e36ee03a0..8cf68fbd76 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -1,8 +1,10 @@ +from functools import partial from unittest.mock import Mock import pytest import toga +from toga import Position, Size from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE from toga.style.pack import Pack @@ -391,6 +393,171 @@ async def test_current_window(app, app_probe, main_window): assert app.current_window == window3 +@pytest.mark.parametrize( + "event_path", + [ + "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_path, mock_scale +): + 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 + + original_content = main_window.content + GetScaleFactorForMonitor_original = shcore.GetScaleFactorForMonitor + dpi_change_event = find_event(event_path, main_window_probe) + + 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") + + ids = ["fixed", "minimal", "flex"] + probes = {id: get_probe(main_window.widgets[id]) for id in ids} + + 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}, + ) + + positions, sizes, font_sizes = get_metrics() + + # 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["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. + def GetScaleFactorForMonitor_mock(hMonitor, pScale): + pScale.value = int(mock_scale * 100) + + # Set and Trigger dpi change event with the specified dpi scale + shcore.GetScaleFactorForMonitor = GetScaleFactorForMonitor_mock + dpi_change_event(None) + 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(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): + 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/__init__.py b/winforms/src/toga_winforms/__init__.py index 4cf94ccde4..eff03c3977 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,18 @@ "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 54194a20a5..4cc1ad71e9 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -2,10 +2,10 @@ import re import sys import threading -from ctypes import windll 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 @@ -76,32 +76,17 @@ 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 - ): # pragma: no cover - 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(-2) - # Any other version of windows should use SetProcessDPIAware() - else: # pragma: no cover - windll.user32.SetProcessDPIAware() - - self.native.EnableVisualStyles() - self.native.SetCompatibleTextRenderingDefault(False) + # 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 + ) # Ensure that TLS1.2 and TLS1.3 are enabled for HTTPS connections. # For some reason, some Windows installs have these protocols @@ -126,6 +111,19 @@ def create(self): # Populate the main window as soon as the event loop is running. self.loop.call_soon_threadsafe(self.interface._startup) + ###################################################################### + # Native event handlers + ###################################################################### + + 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() + ###################################################################### # Commands and menus ###################################################################### diff --git a/winforms/src/toga_winforms/container.py b/winforms/src/toga_winforms/container.py index dcddf391f6..a18b56e912 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 @@ -14,6 +13,17 @@ 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 if self.content else None + 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/dialogs.py b/winforms/src/toga_winforms/dialogs.py index a6a9fc3a14..db575cbe33 100644 --- a/winforms/src/toga_winforms/dialogs.py +++ b/winforms/src/toga_winforms/dialogs.py @@ -6,11 +6,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 @@ -98,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 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) + self.native = WinForms.Form() self.native.MinimizeBox = False self.native.FormBorderStyle = self.native.FormBorderStyle.FixedSingle @@ -114,22 +131,18 @@ def __init__(self, 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) @@ -141,6 +154,7 @@ def __init__(self, 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) @@ -150,6 +164,7 @@ def __init__(self, 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) @@ -159,6 +174,7 @@ def __init__(self, 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) @@ -173,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/libs/shcore.py b/winforms/src/toga_winforms/libs/shcore.py new file mode 100644 index 0000000000..6e5dacdeba --- /dev/null +++ b/winforms/src/toga_winforms/libs/shcore.py @@ -0,0 +1,5 @@ +from ctypes import HRESULT, POINTER, windll, wintypes + +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 new file mode 100644 index 0000000000..32d2698df2 --- /dev/null +++ b/winforms/src/toga_winforms/libs/user32.py @@ -0,0 +1,36 @@ +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 + + +# 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 a05295b86d..ce1dc31714 100644 --- a/winforms/src/toga_winforms/screens.py +++ b/winforms/src/toga_winforms/screens.py @@ -1,3 +1,5 @@ +from ctypes import wintypes + from System.Drawing import ( Bitmap, Graphics, @@ -10,8 +12,11 @@ from toga.screens import Screen as ScreenInterface from toga.types import Position, Size +from .libs import shcore, user32 +from .widgets.base import Scalable + -class Screen: +class Screen(Scalable): _instances = {} def __new__(cls, native): @@ -24,6 +29,19 @@ 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, + ) + hMonitor = user32.MonitorFromRect(screen_rect, user32.MONITOR_DEFAULTTONEAREST) + pScale = wintypes.UINT() + shcore.GetScaleFactorForMonitor(hMonitor, 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 123c98f257..3f0d2cdf86 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -13,11 +13,13 @@ from toga_winforms.colors import native_color -class Scalable: +class Scalable(ABC): SCALE_DEFAULT_ROUNDING = ROUND_HALF_EVEN - def init_scale(self, native): - self.dpi_scale = native.CreateGraphics().DpiX / 96 + @property + @abstractmethod + def dpi_scale(self): + raise NotImplementedError() # Convert CSS pixels to native pixels def scale_in(self, value, rounding=SCALE_DEFAULT_ROUNDING): @@ -36,7 +38,7 @@ def scale_round(self, value, rounding): return int(Decimal(value).to_integral(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. @@ -49,7 +51,19 @@ def __init__(self, interface): self._container = None self.native = None self.create() - self.init_scale(self.native) + + # 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 @@ -60,8 +74,15 @@ def set_app(self, app): pass def set_window(self, window): - # No special handling required - pass + 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): @@ -110,7 +131,15 @@ def set_hidden(self, hidden): self.native.Visible = not hidden def set_font(self, font): - self.native.Font = font._impl.native + 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: 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 96d990fc1a..cc8fd2af7f 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING import System.Windows.Forms as WinForms -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 @@ -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 @@ -19,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 @@ -28,7 +35,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.init_scale(self.native) + self._dpi_scale = self.get_current_screen().dpi_scale self.native.MinimizeBox = self.interface.minimizable self.native.MaximizeBox = self.interface.resizable @@ -40,6 +47,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 @@ -48,6 +56,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 / initial_dpi_scale), + native_font.Style, + ) + ###################################################################### # Native event handlers ###################################################################### @@ -56,6 +76,10 @@ 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() + def winforms_FormClosing(self, sender, event): # If the app is exiting, do nothing; we've already approved the exit # (and thus the window close). This branch can't be triggered in test @@ -74,6 +98,11 @@ def winforms_FormClosing(self, sender, event): self.interface.on_close() 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() + ###################################################################### # Window properties ###################################################################### @@ -99,6 +128,7 @@ def set_app(self, app): def show(self): if self.interface.content is not None: self.interface.content.refresh() + self.update_dpi() self.native.Show() ###################################################################### @@ -135,6 +165,14 @@ def resize_content(self): self.native.ClientSize.Height - vertical_shift, ) + def update_dpi(self): + self._dpi_scale = self.get_current_screen().dpi_scale + + for widget in self.interface.widgets: + widget._impl.scale_font() + widget.refresh() + self.resize_content() + ###################################################################### # Window size ###################################################################### @@ -216,6 +254,14 @@ def create(self): super().create() self.toolbar_native = None + def update_dpi(self): + super().update_dpi() + if self.native.MainMenuStrip: # pragma: no branch + self.native.MainMenuStrip.Font = self.scale_font(DEFAULT_FONT) + if self.toolbar_native: + self.toolbar_native.Font = self.scale_font(DEFAULT_FONT) + self.resize_content() + def _top_bars_height(self): vertical_shift = 0 if self.toolbar_native: @@ -251,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} @@ -276,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 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/probe.py b/winforms/tests_backend/probe.py index bc4b8b089c..04f755d723 100644 --- a/winforms/tests_backend/probe.py +++ b/winforms/tests_backend/probe.py @@ -1,8 +1,7 @@ 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 +from System.Windows.Forms import Screen, SendKeys import toga @@ -25,15 +24,38 @@ 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): - # Does the same thing as `return self.native.CreateGraphics().DpiX / 96` - return Graphics.FromHdc(Graphics.FromHwnd(IntPtr.Zero).GetHdc()).DpiX / 96 + # 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, + 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 +77,8 @@ 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 == ( + 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..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 ( - self.native.Left / self.scale_factor, - 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/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 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())