Control.PointToScreen misalignment with multiple monitors

Hey Developers,

There seems to be an inconsistency when using Eto’s Control.PointToScreen() method. I understand that it returns values in logical coordinate space, but things break apart when working with multiple monitors of varying scales.

Below values were captured with a dual monitor setup (1920 x 1080 + 5120 x 2160) and 125% and 150% scaling respectively. The Mouse position is correctly translated to logical screen coordinates, but the one from Control is off.

Eto.Forms.Mouse.Position → X = 3177, Y = 682
Control.PointToScreen(new Point(0,0)) → X = 3431, Y = 676

Having dug deeper into this, I found a workaround which explains where the discrepancy happens. This only works when the secondary screen is to the right of the primary. It doesn’t include multiple monitors, nor their potential configurations.

This issue seems to have been fixed in Rhino 8 and can only be reproduced in R7. Is there a way to get this fixed in R7 as well?

@curtisw, @CallumSykes maybe you can help?

            var screenPoint = Owner.PointToScreen(new Point());
            var screen = Screen.FromPoint(screenPoint);

            if (!screen.IsPrimary)
            {
                var screenWidth = Screen.PrimaryScreen.WorkingArea.Width;
                var scalingFactor = screen.LogicalPixelSize / Screen.PrimaryScreen.LogicalPixelSize;
                screenPoint.X = screenWidth + (screenPoint.X - screenWidth) * scalingFactor;
                screenPoint.Y = (int)(screenPoint.Y * scalingFactor);
            }
1 Like

This fix in Eto 2.7.2 seems to address this issue. I believe that Rhino 7 uses a slightly modified version of Eto 2.7.1 which would explain why it’s still happening, but R8 works correctly.

2 Likes

To push this fix to Rhino 7 we’d have to update the Eto version of Rhino 7, which is something we’re quite unlikely to do unless a major OS bug crops up.

Does this workaround work in 7? If so I think that’s your best option along with some conditional compilation attributes #IF RHINO_VERSION_7 etc.

Is there a way of telling my plugin to use a newer version of Eto/Eto.Wpf while letting Rhino use the legacy 2.7.1?

I tried a few approaches but without success. Rhino’s version is strongly-typed and loads early thus overriding anything which I try to load later.

For what it’s worth, I ported this exact fix to my code by creating an extension method with a custom version of PointToScreen() but there are other instances where it would be beneficial to have access to the 2.8.x versions.

In dotnet framework, assemblies are loaded once with a first past the post system, so Rhino loads (with Eto) and the process that has Eto loaded (Rhino) will mean that Eto version is set and if any other version tries to load any version of Eto will use the existing Eto assembly.

Do you mean inside of Eto? Or elsewhere?

I learned this the hard way :slight_smile:

Functionality from newer versions of Eto which shipped after 2.7.1. One example below. Each time I encounter one of these issues, I end up backporting the newer functionality via extension methods. But what on the surface looks like a concise method, quite often has a long list of internal dependencies which need to be copied over as well.

As I’m typing it, it occurred to me that maybe it’d be worth compiling a custom version of Eto with a different namespace, and use it as an external library to handle only the missing parts while keeping 2.7.1 for the default interactions.

You could re-compile eto (to a different namespace) and ship it with your plugin. The only thing is I have absolutely no idea how well this will work… It WILL be a new assembly so you’ll be able to use any version of Eto you want… But … (I think) it WILL also be a new assembly so (I think) a normal Eto.Forms.Form WILL be a different class to your Meto.Morms.Morm or whatever you choose to call it. And that might become more of a pain than some Rhino 7 bugs.

You were right. I tested it out quickly and these forms are not compatible and can’t be cast to each other in a straightforward way. It’s a dead end.

If anyone is interested in the fix to the original question, here it is:

/// <summary>
/// Converts a point relative to a control to screen coordinates, handling DPI scaling
/// </summary>
/// <param name="owner">The control that owns the point</param>
/// <param name="point">The point to convert, relative to the control</param>
/// <returns>The converted screen coordinate point</returns>
public static Point PointToScreen(Control owner, PointF point)
{
    var wpfElement = owner.ControlObject as System.Windows.FrameworkElement;
    PointF pt;

    if (Win32Helpers.IsSystemDpiAware)
    {
        var logicalPixelSize = owner.ParentWindow.Screen.LogicalPixelSize;
        var systemDpi = Win32Helpers.SystemDpi;

        point = point / systemDpi * logicalPixelSize;

        // Convert to screen coordinates in DPI-aware context
        pt = Win32Helpers.ExecuteInDpiAwarenessContext(() => wpfElement.PointToScreen(point.ToWpf())).ToEto();

        // WPF does not take into account the location of the element in the form...
        var rootVisual = wpfElement.GetVisualParents()
            .OfType<System.Windows.UIElement>()
            .Last();
        var location = wpfElement.TranslatePoint(new System.Windows.Point(0, 0), rootVisual).ToEto();
        pt += (location * logicalPixelSize) - (location * systemDpi);
    }
    else
    {
        // Simple conversion for non-DPI-aware scenario
        pt = Win32Helpers.ExecuteInDpiAwarenessContext(() => wpfElement.PointToScreen(point.ToWpf())).ToEto();
    }

    return new Point(EtoHelpers.ScreenToLogical(Point.Truncate(pt)));
}

And the Win32Helpers class

  // This backports a PointToScreen fix introduced in Eto 2.7.2
  // https://github.com/picoe/Eto/pull/2336

  #region DPI Awareness

  /// <summary>
  /// Specifies the DPI awareness context for a thread
  /// </summary>
  public enum DPI_AWARENESS_CONTEXT
  {
      NONE = 0,
      UNAWARE = -1,
      SYSTEM_AWARE = -2,
      PER_MONITOR_AWARE = -3,
      PER_MONITOR_AWARE_v2 = -4,
      UNAWARE_GDISCALED = -5
  }

  /// <summary>
  /// Specifies the DPI awareness level of a process
  /// </summary>
  public enum PROCESS_DPI_AWARENESS : uint
  {
      UNAWARE = 0,
      SYSTEM_DPI_AWARE = 1,
      PER_MONITOR_DPI_AWARE = 2
  }

  [DllImport("User32.dll")]
  private static extern DPI_AWARENESS_CONTEXT SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT dpiContext);

  [DllImport("User32.dll", SetLastError = true)]
  [return: MarshalAs(UnmanagedType.Bool)]
  public static extern bool IsProcessDPIAware();

  [DllImport("user32.dll")]
  public static extern uint GetDpiForSystem();

  [DllImport("shcore.dll")]
  public static extern uint GetProcessDpiAwareness(IntPtr handle, out PROCESS_DPI_AWARENESS awareness);

  [DllImport("gdi32.dll")]
  public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);

  [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
  private static extern IntPtr LoadLibrary(string library);

  [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
  private static extern bool FreeLibrary(IntPtr moduleHandle);

  [DllImport("kernel32.dll", CharSet = CharSet.Ansi, BestFitMapping = false, SetLastError = true, ExactSpelling = true)]
  private static extern IntPtr GetProcAddress(IntPtr moduleHandle, string method);

  /// <summary>
  /// Gets whether per-monitor thread DPI awareness is supported
  /// </summary>
  public static bool PerMonitorThreadDpiSupported => perMonitorThreadDpiSupported.Value;

  /// <summary>
  /// Gets whether per-monitor DPI awareness is supported
  /// </summary>
  public static bool PerMonitorDpiSupported => perMonitorDpiSupported.Value;

  /// <summary>
  /// Gets the system DPI scaling factor relative to the default 96 DPI
  /// </summary>
  /// <remarks>
  /// For systems with per-monitor DPI support, uses GetDpiForSystem.
  /// For older systems, falls back to GetDeviceCaps with LOGPIXELSX.
  /// The result is normalized to a scaling factor where 1.0 represents 96 DPI.
  /// </remarks>
  public static float SystemDpi => (PerMonitorThreadDpiSupported ? GetDpiForSystem() : (uint)GetDeviceCaps(IntPtr.Zero, 88 /*LOGPIXELSX*/)) / 96f;

  /// <summary>
  /// Gets whether the current process is system DPI aware
  /// </summary>
  public static bool IsSystemDpiAware => PerMonitorDpiSupported ?
      (GetProcessDpiAwareness(IntPtr.Zero, out var awareness) == 0 && awareness == PROCESS_DPI_AWARENESS.SYSTEM_DPI_AWARE) :
      IsProcessDPIAware();

  private static readonly Lazy<bool> perMonitorThreadDpiSupported = new Lazy<bool>(() => MethodExists("User32.dll", "SetThreadDpiAwarenessContext"));
  private static readonly Lazy<bool> perMonitorDpiSupported = new Lazy<bool>(() => MethodExists("shcore.dll", "SetProcessDpiAwareness"));

  /// <summary>
  /// Checks if a specific method exists in a Windows DLL
  /// </summary>
  /// <param name="module">The DLL module name</param>
  /// <param name="method">The method name to check</param>
  /// <returns>True if the method exists, false otherwise</returns>
  public static bool MethodExists(string module, string method)
  {
      var moduleHandle = LoadLibrary(module);
      if (moduleHandle == IntPtr.Zero)
          return false;
      try
      {
          return GetProcAddress(moduleHandle, method) != IntPtr.Zero;
      }
      finally
      {
          FreeLibrary(moduleHandle);
      }
  }

  /// <summary>
  /// Executes a function within a specific DPI awareness context
  /// </summary>
  /// <typeparam name="T">The return type of the function</typeparam>
  /// <param name="func">The function to execute</param>
  /// <returns>The result of the function execution</returns>
  public static T ExecuteInDpiAwarenessContext<T>(Func<T> func)
  {
      var oldDpiAwareness = SetThreadDpiAwarenessContextSafe(DPI_AWARENESS_CONTEXT.PER_MONITOR_AWARE_v2);
      try
      {
          return func();
      }
      finally
      {
          if (oldDpiAwareness != DPI_AWARENESS_CONTEXT.NONE)
              SetThreadDpiAwarenessContextSafe(oldDpiAwareness);
      }
  }

  /// <summary>
  /// Safely sets the thread DPI awareness context
  /// </summary>
  /// <param name="dpiContext">The desired DPI awareness context</param>
  /// <returns>The previous DPI awareness context</returns>
  public static DPI_AWARENESS_CONTEXT SetThreadDpiAwarenessContextSafe(DPI_AWARENESS_CONTEXT dpiContext)
  {
      if (!PerMonitorThreadDpiSupported)
          return DPI_AWARENESS_CONTEXT.NONE;
      return SetThreadDpiAwarenessContext(dpiContext);
  }

  /// <summary>
  /// Gets the visual parent elements of a WPF dependency object
  /// </summary>
  /// <param name="element">The dependency object to get parents for</param>
  /// <returns>An enumerable of parent dependency objects</returns>
  public static IEnumerable<System.Windows.DependencyObject> GetVisualParents(this System.Windows.DependencyObject element)
  {
      var parent = System.Windows.Media.VisualTreeHelper.GetParent(element);
      while (parent != null)
      {
          yield return parent;
          parent = System.Windows.Media.VisualTreeHelper.GetParent(parent);
      }
  }
  #endregion
3 Likes

I yield the floor to the Eto Magician, @curtisw incase he has any ideas :slight_smile:

1 Like

Sorry, there’s no real way to use a newer custom version of Eto with Rhino 7, unless all of your UI does not need to interact with Rhino’s UI.

With .NET 4.8, multiple copies of Eto can be loaded as long as they have different strong names (it is tricky though, Rhino tries to prevent you from doing so), but with .NET Core there is no way to make that happen as it goes by the name alone.

Regardless, it is a massive pain and I would suggest against trying to do any of that. If you need to support Rhino 7, I would recommend having some conditional code executed in that case, or compile separate versions of your plugin.

Hope this helps!
Curtis.

Excellent. Sorry I’m just now seeing this conversation. We do something very similar to allow for multiple DPI across monitors.