From 44f912443132128726d4158ab9d7e67f2714c1cd Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 6 Nov 2024 16:45:31 +0000 Subject: [PATCH] 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())