Skip to content

WPF DPI Scaling Fix#721

Merged
swharden merged 8 commits intoScottPlot:masterfrom
bclehmann:wpf-dpi-fix
Feb 7, 2021
Merged

WPF DPI Scaling Fix#721
swharden merged 8 commits intoScottPlot:masterfrom
bclehmann:wpf-dpi-fix

Conversation

@bclehmann
Copy link
Member

New Contributors:
please review CONTRIBUTING.md

Purpose:
#720

This works by creating a new DPICorrectedInputState class, which implements a new IInputState interface. One can get an instance of this new class like this new DPICorrectedInputState(input, dpiScaling). This conveniently means that the only method that needs changing is the GetInputState method, and only if it's a platform which has DPI scaling and requires ScottPlot to handle the DPI scaling (i.e. WPF). The modification is trivial even in that case.

New Functionality:
Fixes #720

@bclehmann
Copy link
Member Author

bclehmann commented Jan 27, 2021

The DPI factor is only computed once but for some reason it seems to work fine when moving to a different screen? Even when I set windows display scaling to a different value on the other screen.

The only other thing I'm wondering about is on WinForms, it now uses DPI correction, which doesn't matter now as the DPI factor will always be 1. But if they add DPI support, it's possible they'll go the Avalonia route (handling DPI correction internally), which means this change will break the control whereas if they go the WPF route not making this change will break the control. I think Microsoft would probably mimic their existing product in this regard, but I don't know which is more likely. The Avalonia control uses a "normal" InputState with no DPI correction.

@swharden
Copy link
Member

swharden commented Jan 28, 2021

Hey @bclehmann! I'm going to put this down for tonight so I don't burn out, but I'll make one note that might be helpful

The only other thing I'm wondering about is on WinForms, it now uses DPI correction, which doesn't matter now as the DPI factor will always be 1

I think .NET Framework and .NET Core WinForms behave differently. There's a demo of each in the sandbox folder, but I recall WinForms in .NET 5 supporting display scaling out of the box (one of the big advantages of upgrading). It looks like you can even define this in Program.cs with Application.SetHighDpiMode(HighDpiMode)

Maybe these preprocessor directives (copy/pasted from the internet) would be useful in determining how to detect DPI scaling from inside a user control?

#if NETFRAMEWORK
    FrameworkSpecific();
#else
    CoreSpecific();
#endif

@bclehmann
Copy link
Member Author

I think .NET Framework and .NET Core WinForms behave differently. There's a demo of each in the sandbox folder, but I recall WinForms in .NET 5 supporting display scaling out of the box (one of the big advantages of upgrading).

I tried running on Net Framework 4.8, Net Core 3.1, and Net 5 (yeesh Microsoft has to sort out their naming...) and the DPI factor was always set to 1. I may be doing something wrong.

And as for the preprocessor directives, how does it work when you publish to NuGet? I thought the packages were framework-agnostic, and assuming that the C# preprocessor is a traditional preprocessor, those directives would make a choice at compile time on your machine, not at runtime on the users'. However, if the nupkg has an assembly for each version then the preprocessor directives would work.

@swharden
Copy link
Member

swharden commented Feb 5, 2021

Preprocessor directives and NuGet

If you check out https://www.nuget.org/packages/ScottPlot.WinForms you can see the platforms a package was targeted for in the dependencies drop-down.

If you download the .nupkg file and rename it to .zip, you can extract the folder and see how it works. It looks like if it targets 4 platforms, the package contains 4 DLLs.

I think this means we can use framework/core preprocessor directives freely!

DPICorrectedInputState and IInputState

I noticed that the only time DPIFactor is used is when the mouse coordinates are multiplied by it before interacting with the control back-end. The use of an interface here seems like a bit much considering the limited scope of what's going on here, and how much code change is required to switch to an interface here. I'm interested in considering some other options to see if there might be something simpler.

I like what you did with GetDPIScale(), and it makes me wonder if putting that code in a little class to couple DPI detection with storing scale factor might make sense here.

I'm thinking out loud here and typing in the browser, but what would you think about something like this stored at the top-level of a class? We could use events to call DisplayScale.Measure() if we suspect the resolution changed.

class DisplayScale
{
    public double ScaleFactor {get; private set;}
    const int DEFAULT_DPI = 96;

    public DisplayScale() => Measure();

    public Measure(){
        using Graphics gfx = Drawing.GDI.Graphics(new Bitmap(1, 1));
        ScaleFactor = gfx.DpiX / DEFAULT_DPI; // is this definitely a floating point result?
    }
}
// control code
DisplayScale DPI = new DisplayScale();
// control GetInputState()
X = e.X * DPI.ScaleFactor,
Y = e.Y * DPI.ScaleFactor,

What do you think?

@bclehmann
Copy link
Member Author

bclehmann commented Feb 5, 2021

Preprocessor

I think this means we can use framework/core preprocessor directives freely!

You are correct, I tried this myself with a decompiler and the preprocessor directives work as intended:

C#:

protected override void Dispose(bool disposing)
{
#if NET5_0
	throw new System.Exception("Waaaoow");
#endif
	if (disposing && (components != null))
	{
		components.Dispose();
	}
	base.Dispose(disposing);
}

Output NET 5.0:

.method family hidebysig virtual instance void Dispose(bool disposing) cil managed
{
    // Method Start RVA 0x28b0
    // Code Size 12 (0xc)
    .maxstack 2
    .locals init
    (
        [0] bool #flag1
    )
    L_0000: nop 
    L_0001: ldstr "Waaaoow"
    L_0006: newobj instance void [System.Runtime]System.Exception::.ctor(string)
    L_000b: throw 
}

Output NET 4.72

.method family hidebysig virtual instance void Dispose(bool disposing) cil managed
{
    // Method Start RVA 0x28b0
    // Code Size 43 (0x2b)
    .maxstack 2
    .locals init
    (
        [0] bool #flag1
    )
    L_0000: nop 
    L_0001: ldarg.1 
    L_0002: brfalse.s L_000f
    L_0004: ldarg.0 
    L_0005: ldfld [ScottPlot.WinForms]ScottPlot.FormsPlot::components
    L_000a: ldnull 
    L_000b: cgt.un 
    L_000d: br.s L_0010
    L_000f: ldc.i4.0 
    L_0010: stloc.0 
    L_0011: ldloc.0 
    L_0012: brfalse.s L_0022
    L_0014: nop 
    L_0015: ldarg.0 
    L_0016: ldfld [ScottPlot.WinForms]ScottPlot.FormsPlot::components
    L_001b: callvirt instance void [mscorlib]System.IDisposable::Dispose()
    L_0020: nop 
    L_0021: nop 
    L_0022: ldarg.0 
    L_0023: ldarg.1 
    L_0024: call instance void [System.Windows.Forms]System.Windows.Forms.ContainerControl::Dispose(bool)
    L_0029: nop 
    L_002a: ret 
}

My apologies, the decompiler I used can normally turn the MSIL back into C# (or even VBScript), but it doesn't seem to like the class that I put my test code in. C'est la vie.

IInputState

I chose to use an interface so that the two classes (regular InputState and DPICorrectedInputState) could be used interchangeably, although I concede that having a separate class was perhaps unnecessary. It doesn't really save the user much time over multiplying by the DPI factor themselves. Although perhaps it would make it more convenient if DPICorrectedInputState handled the detection of the DPI scaling factor itself?

DisplayScale.Measure()

I like this idea, I think it would fit in DPICorrectedInputState if you're not moving to scrap that class? Otherwise, I believe it works well as a standalone class. The only thing I would say is that it could probably be a static class.

@swharden
Copy link
Member

swharden commented Feb 5, 2021

Preprocessor

Awesome, thanks for checking that out! I suspected that was the case but it helps to see it to know for sure.

FYI I edited your comment to add asm syntax highlighting (mostly because I was curious what would happen)

Where to put scaling logic

It doesn't really save the user much time over multiplying by the DPI factor themselves

Yeah, that's my feeling too. I kind of like seeing that those discrete multiplication steps too - it makes it really obvious exactly what's happening.

I believe it works well as a standalone class. The only thing I would say is that it could probably be a static class.

That sounds good to me! Probably in the ScottPlot.Control namespace?

@bclehmann
Copy link
Member Author

FYI I edited your comment to add asm syntax highlighting (mostly because I was curious what would happen)

Yeah, it sorta works, it got the opcodes that both have in common like nop and it highlighted the string correctly. Maybe github will add syntax highlighting for MSIL, but I imagine it's a low priority.

That sounds good to me! Probably in the ScottPlot.Control namespace?

Yeah, that sounds good to me.

@bclehmann
Copy link
Member Author

I ended up doing pretty much exactly what you suggested, I moved against using a static class because one could have two controls on different monitors (although the DPI doesn't seem to change when moving between monitors). And I kept GetDPIScale in ScottPlot.Drawing.GDI because I figured we might as well keep all our GDI+ dependencies in one place, in case one were to move to a different graphics library.

And it's possible that we may move to depend on the platforms to detect the DPI scaling rather than the graphics library, I'm not sure if Skia or other graphics libraries offer facilities to get the DPI scaling information.

One issue I don't have an answer to is when is it necessary to call DisplayScaling.Measure(), I think unfortunately this might depend on individual platform's DPI scaling implementation.

Finally, with regards to WinForms DPI scaling, I got it working, I don't know what the issue was because it seemed to just be as simple as calling SetHighDpiMode and it works. There are no issues with mouse information being incorrect. However there are issues in the WinForms demo's VisualTree when it's set to PerMonitor or PerMonitorV2, but no issues with the actual ScottPlot control. I set it to SystemAware and it looks fine.

PerMonitorV2 (same issue with PerMonitor):
image

SystemAware:
image

I set it to SystemAware for now. There was no issue with the ScottPlot control with either.

The back-end is intended for managing plots in a UI-agnostic manner. The front-end/controls is intended to manage platform-specific state.

I also renamed some things to use the word "ratio" everywhere "factor" was written.
@swharden
Copy link
Member

swharden commented Feb 7, 2021

Thanks @bclehmann!

Interestingly, controls that use display scale correction for mouse tracking also need it for resizing 0de7edd

I made a note in https://github.com/ScottPlot/Website/issues/1 to put some of what we discussed here on the new FAQ section when that gets off the ground.

@swharden swharden merged commit c1d4b7c into ScottPlot:master Feb 7, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WPF is no longer DPI Aware

2 participants