Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved DPI Scaling on Windows and Fixed related Bugs #2155

Open
wants to merge 115 commits into
base: main
Choose a base branch
from

Conversation

proneon267
Copy link
Contributor

@proneon267 proneon267 commented Oct 15, 2023

While investigating the scaling problems encountered in #1930, I found that the call to SetProcessDpiAwarenessContext is erroneous. Hence, all the toga apps are always running in the DPI Unaware mode.

This PR fixes the call and also checks if it was successful or not.

Note that this PR only has changes that fix the call to SetProcessDpiAwarenessContext, so that the call works. It doesn't fix any of the scaling issues currently present in toga. In particular, the test script from #1930, still shows the scaling bugs:

"""
My first application
"""
import toga
from toga.style import Pack
from toga.style.pack import COLUMN, ROW


class HelloWorld(toga.App):
    def startup(self):
        """
        Construct and show the Toga application.

        Usually, you would add your application to a main content box.
        We then create a main window (with a name matching the app), and
        show the main window.
        """
        main_box = toga.Box(style=Pack(direction="column"))

        self.main_window = toga.MainWindow(title=self.formal_name)
        self.main_window.content = main_box
        self.main_window.show()
        self.main_window.position = (0, 0)

        main_box.add(
            toga.Label(text=f"Window Size: {self.main_window.size}"),
            toga.Label(text=f"Window Position: {self.main_window.position}"),
            toga.Label(text=f"dpi_scale: {self.main_window._impl.dpi_scale}"),
            toga.Label(
                text=f"DpiX: {self.main_window._impl.native.CreateGraphics().DpiX}"
            ),
        )

def main():
    return HelloWorld()

At 125% scaling:
image

As you can see, the DpiX and dpi_scale will always be 96 and 1.0 respectively. Furthermore, in the DPI Aware mode, the app menu has a disproportionately larger size compared to the rest of the window elements.

Both of these bugs, indicate that there are problems in the scaling on windows. But those are for separate PRs.

Also, if required, I can add the code to correctly detect the actual system dpi.

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

@proneon267
Copy link
Contributor Author

Though not my original intention, but I have fixed all dpi scaling bugs that I had encountered. Earlier, the scaling operations on windows were essentially no-ops. But the latest commit fixes the bugs, and the dpi based scaling operations are now being done properly.

At 125% scaling:
image
This correctly detects the dpi for scaling.

Now, all that remains is to add an event handler to detect dpi change while the app is running and scale correctly.

@proneon267 proneon267 changed the title Fixed bug in calling of SetProcessDpiAwarenessContext Fixed bugs related to dpi scaling on windows Oct 16, 2023
@proneon267
Copy link
Contributor Author

proneon267 commented Oct 18, 2023

I have added an event handler to detect DPI changes while the app is running. I have also modified Scalable class to:

  • Correctly get the latest DPI scale factor in real time
  • Remove dependency on a Control object
  • Allow other APIs to use the scale_in() and scale_out() methods
  • Reduce the number of calls to get the latest DPI value

The latest commit detects the DPI changes of the Primary Screen only. But this will be fixed in #1930, where the DPI scale factor of each screen will be detected individually and will be used to do the scaling.

I have tested the latest commit and it correctly detects new DPI change and scales the elements accordingly.

However, the fonts' don't seem to be using the latest DPI value and as such they are not being scaled when a new DPI change is detected while the app is running. This needs to be fixed.

Copy link
Member

@mhsmith mhsmith left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for looking into this, I think you're on the right track.

The latest commit detects the DPI changes of the Primary Screen only. But this will be fixed in #1930, where the DPI scale factor of each screen will be detected individually and will be used to do the scaling.

WinForms has some per-window events for detecting DPI changes, which I think would allow this to be fixed without requiring Toga to be aware of multiple screens. That would allow us to fix all the DPI issues together in this PR, so I've added a "fixes" link to the top comment.

A few more comments:

winforms/src/toga_winforms/app.py Outdated Show resolved Hide resolved
winforms/src/toga_winforms/app.py Outdated Show resolved Hide resolved
winforms/src/toga_winforms/app.py Outdated Show resolved Hide resolved
winforms/src/toga_winforms/app.py Outdated Show resolved Hide resolved
@proneon267
Copy link
Contributor Author

WinForms has some per-window events for detecting DPI changes, which I think would allow this to be fixed without requiring Toga to be aware of multiple screens.

I have tested them previously and was thinking about using DpiChanged event & DeviceDpiNew: https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.dpichangedeventargs.devicedpinew?view=windowsdesktop-7.0#system-windows-forms-dpichangedeventargs-devicedpinew

But, none of these events trigger when the system DPI changes. Only the SystemEvents.DisplaySettingsChanged event is triggered consistently when system DPI changes.

I am searching for a proper way to address this and will let you know as soon as I find a viable solution.

@proneon267
Copy link
Contributor Author

I have added support so that the font scales when the DPI changes while the app is running. I have also added Screen as an optional dependency to Scalable class, so that the DPI can be found for the current screen, without the need for the API in #1930, while still allowing other APIs to use scale_in and scale_out methods.

The following is the same run of the test script with the latest commit:
At 125% scaling:
image

At 175% scaling:
image

The fonts scale correctly and detect the DPI changes while the app is running.

@proneon267
Copy link
Contributor Author

When the app is scaled from 125% -> 100% -> 175%, then the menu bar appears to be clipped, due to incorrect calculation of main_window.context box:
Screenshot (20)
This needs to be fixed.

@proneon267
Copy link
Contributor Author

proneon267 commented Oct 22, 2023

Nevermind. The bug was caused due to the faulty implementation of WeakRef introduced in #2066 and reported in #2163.

self.native.Resize += WeakrefCallable(self.winforms_Resize)

After removing the WeakRef wrapping, the resizing event handler appears to fire consistently when the DPI scale is changed. Here is the app after fixing the bug:
image
So, this bug will be resolved automatically when #2163 is addressed. Looks like the weakref calls in different places are causing problems. I wonder if this is also leading to failing of the tests on windows testbed.

@freakboy3742
Copy link
Member

So, this bug will be resolved automatically when #2163 is addressed. Looks like the weakref calls in different places are causing problems. I wonder if this is also leading to failing of the tests on windows testbed.

To be clear - the tests are not failing on main at present. If they're failing in this PR, it's either an unintended side effect of something in this PR, or an error that isn't 100% reproducible.

The latter does happen sometimes - it's the nature of running a GUI test that sometimes, the GUI doesn't respond quite quickly enough, which results in a test failure. Re-running the test will (usually) fix these problems; however, I've just re-run the tests, and the same problem is occurring, which suggests it likely isn't a transient problem - it's an unintended side effect.

From a quick inspection, I can't see any obvious connection between this PR's changes and OptionContainer. However, I can confirm that when I run the optioncontainer tests, I see the problem locally, and if I remove the Weakref usage from OptionContainer, the problem remains.

@proneon267
Copy link
Contributor Author

In that case, I'll search further and report back what is causing the tests to fail.

@proneon267
Copy link
Contributor Author

proneon267 commented Oct 23, 2023

Turns out the bugs were related to Hwnds being created at inappropriate times.

As discussed in #2155 (comment), the Hwnds are being created even before the Application instance is created.

I have fixed the current bug by initializing and disposing a graphics context at the time of widget Hwnd creation.

But, I recon more bugs related to Hwnd will be encountered in the future due to the way toga app execution flow works. But that's for the future.

@proneon267 proneon267 changed the title Fixed bugs related to dpi scaling on windows Fixed Bugs related to DPI Scaling on Windows Oct 23, 2023
@proneon267
Copy link
Contributor Author

proneon267 commented Apr 28, 2024

@mhsmith I need some guidance on how to test the behavior. Currently, I have the following test:

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"
)
# 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
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]:
def GetScaleFactorForMonitor_mock(hMonitor, pScale):
pScale.value = int(pScale_value_mock * 100)
monkeypatch.setattr(
"toga_winforms.libs.shcore.GetScaleFactorForMonitor",
GetScaleFactorForMonitor_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]
),
)
# 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()

When the dpi scale is manually changed from the settings app then Windows automatically scales and resizes the window and does some other related stuff. Due to this reason, we are not manually resizing the window size on dpi change events as doing so would lead to wrong calculation of layout, glitchy behavior and the window will not restore to the correct size after change in dpi scale(i.e. the window size will be incorrect when we scale down from higher DPI to lower DPI). So, when Windows automatically resizes the window then only the calculated/expected size of widgets in the test are correct.

But when we manually trigger any dpi change event like winforms_DisplaySettingsChanged then the automatic resizing by Windows is not done and the calculated/expected sizes of widgets are incorrect. As evident from the error in the testbed:

___________________________ test_system_dpi_change ____________________________
Traceback (most recent call last):
  File "D:\a\toga\toga\testbed\build\testbed\windows\app\src\app\tests\app\test_app.py", line 658, in test_system_dpi_change
    assert (
AssertionError: assert (640, 28) == (800, 30)
  At index 0 diff: 640 != 800
  Full diff:
  - (800, 30)
  + (640, 28)

The widget sizes are not changing and are stuck at the initial size. But visually the widgets are resized.

I had also tried to manually resizing the window on DPI change event but the calculations were still wrong.

___________________________ test_system_dpi_change ____________________________
Traceback (most recent call last):
  File "E:\patch-20\testbed\tests\app\test_app.py", line 658, in test_system_dpi_change
    assert (
AssertionError: assert (804, 28) == (800, 30)
  At index 0 diff: 804 != 800
  Full diff:
  - (800, 30)
  + (804, 28)

I am not sure on how to test the behavior correctly without actually changing the system DPI setting manually.

Copy link
Member

@mhsmith mhsmith left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see what this has to do with resizing the window. We already found above that my machine and yours were behaving differently here, but I don't think this is critical as long as the content of the window is scaled correctly.

You should be able to test this by setting up a situation where some widgets are set to their minimum size, which is smaller than the window, and then checking that they scale correctly. For example:

toga.Box(
    style=Pack(direction="row"),
    children=[
        toga.Box(style=Pack(flex=1)),
        toga.Button(text="hello"),
        toga.Button(text="world"),
        toga.Box(style=Pack(flex=1)),
    ]
)

When the scale factor increases, the buttons should get bigger, and the spacer boxes should get smaller.

The monkey patch won't have any effect on the size of the toolbar and menu bar, because they're not implemented in Python. So there's probably no point in including them in the test.

FInally, this test still depends too much on implementation details. Notice that out of the 45 occurences of ._ in the file, 44 of them are from this new test. Where at all possible, tests should depend only on public Toga and WinForms APIs. Look at the existing tests that use the assert_layout method.

Comment on lines 608 to 609
# This test is windows specific
if toga.platform.current_platform == "windows":
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytest.mark.skipif is a cleaner way of doing this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Thanks!

@proneon267
Copy link
Contributor Author

The core test fails on python 3.13 on macOS and ubuntu due to the failure to build Pillow. The testbed failure on macOS-x86_64 is due to the unreliable test of map widget, and most likely should resolve on rerun of CI. Both of the failures are unrelated to this PR.

@proneon267 proneon267 requested a review from mhsmith May 15, 2024 10:49
@proneon267
Copy link
Contributor Author

@mhsmith just a friendly reminder to review this whenever you are free.

@mhsmith
Copy link
Member

mhsmith commented Jun 19, 2024

Sorry for the slow reply; I'll look at this as soon as I can.

@proneon267 proneon267 reopened this Jul 10, 2024
@proneon267
Copy link
Contributor Author

proneon267 commented Jul 10, 2024

I have updated to latest main branch. The dpi scaling is working properly. But between the last time I had updated to the latest main branch and now, something has changed in the main branch due to which it reports wrong screen_position, when the dpi scale is not 1. Also, somehow screen.get_image_data() now automatically takes correct screenshot of correct size without any scale_in applied to the screen's origin and size.

I suspect these changes have happened after the new named tuples - Position and Size were introduced. I took a quick look at the changes but couldn't find anything that would cause this problem. I might need to do deeper investigation to find the source of the bugs.
Only Partially true since that pull request corrected screen.size, which earlier was reporting scaled out size, and so these bugs remained hidden.

P.S. If you suspect anything that might be causing these bugs, then please let me know.

@proneon267
Copy link
Contributor Author

On doing a deeper inspection, I have found that when dpi awareness is enabled, the following things happen:

Object Operates in Scaling Required
Form.Position dpi in-dependent coordinates No
Form.Size dpi dependent coordinates Yes
Screen.Bounds dpi in-dependent coordinates No

This complicates things, since earlier, to move a window to the right side of the screen, we could do:

self.main_window.screen_position = (
            (self.main_window.screen.size.width - self.main_window.size.width),
            self.main_window.position.y,
        )

But now, we need to do:

self.main_window.screen_position = (
            (self.main_window.screen.size.width - self.main_window.size.width*(dpi_scale)),
            self.main_window.position.y,
        )

@proneon267
Copy link
Contributor Author

@mhsmith I would greatly appreciate your feedback regarding this new issue.

@proneon267
Copy link
Contributor Author

@mhsmith friendly reminder to take a look look at this, as soon as you are free.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants