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 106 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
winforms/src/toga_winforms/widgets/base.py Outdated Show resolved Hide resolved
winforms/src/toga_winforms/widgets/base.py Outdated Show resolved Hide resolved
@mhsmith
Copy link
Member

mhsmith commented Apr 3, 2024

I've fixed the StackTraceDialog scaling by making its window DPI-unaware, so it'll be scaled as a bitmap like everything else was before. Ideally we could avoid this by using Toga's own layout system rather than hard-coded coordinates, but since this isn't a dialog that will ever appear in normal use of an app, there's no need to deal with that now.

In the process, I've moved the DPI initialization code to toga_winforms/__init__.py, to make absolutely sure that it's run before any other UI-related code. In its previous position in app.py this was not guaranteed. For example, at one point while debugging I caused fonts to be imported before app, which caused a font to be accessed before the process became DPI-aware, which changed all the font sizes in the app.

@mhsmith
Copy link
Member

mhsmith commented Apr 3, 2024

@proneon267: You can answer the questions I've posted above, but please don't push any changes, as I'm currently working on some myself.

@proneon267
Copy link
Contributor Author

proneon267 commented Apr 3, 2024

It's never doing that for me, maybe because I'm using WIndows 10 and you're using Windows 11. I guess this isn't critical to fix, but did you add any code to resize the window, or did it just happen automatically when you enabled DPI awareness?

No, I don't think, I had added any code. It might be Windows 10 vs 11 discrepancy, but I am not sure.

@proneon267
Copy link
Contributor Author

I've fixed the StackTraceDialog scaling by making its window DPI-unaware, so it'll be scaled as a bitmap like everything else was before. Ideally we could avoid this by using Toga's own layout system rather than hard-coded coordinates, but since this isn't a dialog that will ever appear in normal use of an app, there's no need to deal with that now.

In the process, I've moved the DPI initialization code to toga_winforms/__init__.py, to make absolutely sure that it's run before any other UI-related code. In its previous position in app.py this was not guaranteed. For example, at one point while debugging I caused fonts to be imported before app, which caused a font to be accessed before the process became DPI-aware, which changed all the font sizes in the app.

Thank you for helping :)

@proneon267: You can answer the questions I've posted above, but please don't push any changes, as I'm currently working on some myself.

Sure, let me know when you are done.

@mhsmith
Copy link
Member

mhsmith commented Apr 4, 2024

I've just pushed the following changes:

  • Replace the confusing chain of isinstance and hasattr checks in dpi_scale with separate implementations for Screen, Window and Widget, which ensure that DPI is always determined based on the extent of a window rather than a widget.
  • Move _original_dpi_scale and scale_font to the Window class, since that's the only context in which they make sense.
  • Avoid excessive refreshes by adding a scale_font method to Widget.
  • Check for a DPI change in the Resize event handler, since this can change which screen the majority of a window is on.

That fixes all the bugs I found in my previous review, except for the window not resizing when the DPI changes, which I think we can accept for this PR.

Please give it a try and see if it works for you.

I haven't updated the tests, because I think they're written at completely the wrong level. In their current state, they're all about internal details of the implementation, so even a minor refactoring will invalidate them.

I think a better approach would be to do the following:

  • Make sure GetScaleFactorForMonitor is being called in a way that can be monkey-patched. I'm not sure whether windll.user32 can be monkey-patched directly. If not, you can do it by moving the definition of GetScaleFactorForMonitor into libs.shcore, like I did earlier with SetProcessDpiAwarenessContext, and then call it through a module attribute, i.e. shcore.GetScaleFactorForMonitor(...).
  • Then in the tests, for each of the events that could indicate a DPI change:
    • Monkey-patch GetScaleFactorForMonitor to return a different value.
    • Fire the event.
    • Check that the layout and font sizes have updated correctly.

@proneon267
Copy link
Contributor Author

Thanks for helping. I have tested with two commits - c793d64(current) and 31e54c9(previous). Currently, I have found the following bugs in the current commit c793d64:

  • Increased button height:
Previous(31e54c9)
Current(c793d64)
  • When I start the app at 175%, the fonts are not scaled properly and button height remains increased:
Previous(31e54c9)
Current(c793d64)
  • When I start the app at 175% and then change dpi scale to 100%, the button height are not scaled:
Previous(31e54c9)
Current(c793d64)

@mhsmith
Copy link
Member

mhsmith commented Apr 5, 2024

OK, I will be busy for the next week, so please fix these bugs if you can.

@proneon267
Copy link
Contributor Author

No problem. I'll fix them and report back to you :)

@proneon267
Copy link
Contributor Author

Also noticed a bug on Current(c793d64)
image
I am working on fixing the bugs and will report back as soon as I am done.

@proneon267
Copy link
Contributor Author

At 125% scale
Previous(c793d64)
Current(64221ab)
At 175%
Previous(c793d64)
Current(64221ab)

I have also resolved the other reported bugs. Now all that is left is to rewrite the test. I'll report back as soon as I'm done.

@mhsmith
Copy link
Member

mhsmith commented Apr 15, 2024

Why are you pushing empty commits for CI? Have you found that the CI results are inconsistent?

If you're having trouble running the testbed locally, there's some documentation here.

@proneon267
Copy link
Contributor Author

Sometimes, CI results are being inconsistent and running it locally also fails most of the time due to the window dialogs losing focus. Apologies for any troubles. I was thinking of squashing the commits later on.

@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
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