モノトーンの伝説日記

Apex Legends, Splatoon, Programming, and so on...

『WPFでカスタムウィンドウを作るのに人類は奮闘してきた』最終回(簡易コード付き)

 前回の記事を読んでない人はまず読んできてください。

mntone.hateblo.jp

 実はこの方法,タスク バーの移動ができない(ウィンドウサイズ調整に追従できない)んですよね……

1. WindowChrome (WindowChromeWorker) で実現していること。

 以下のコードを参考に紐解きました。

wpf/WindowChromeWorker.cs at ac9d1b7a6b0ee7c44fd2875a1174b820b3940619 · dotnet/wpf · GitHub

 まずは最大化時のイメージ図から。

f:id:mntone:20200802011406p:plain:w400

  • 黒いブロック部分: クライアント領域
  • 黒と赤の間の部分: 非クライアント領域

 Windows API の仕様上 (要出典; もしかしたら非公開APIにあるかもしれません) 非クライアント領域をなくすことはできません

 でも我々は非クライアント領域を削除したいわけですよね? 実はそれをする API があります。

2. Windows 95 からある GDI の関数。

 SetWindowRgn

docs.microsoft.com

 これで表示するエリアのリージョンを指定してやると,DWM 時代でも問題なく指定エリアのみが表示されるという………

 ところで,GDI に詳しい人はこんなことを思うかもしれません。

 角丸のリージョン指定もできるのでは?

 もちろんできます。実際に WindowChrome ではそれで角丸のくり抜き指定までコードが書いてありました。

8/3 追記: 角丸ウィンドウできました

mstdn.jp

3. WindowChrome の一体何が問題だったの?

 不具合の理由は

  • 最大化時のクライアントエリアの調整が不十分 (event WM_NCCALCSIZE)

たったこれだけ。

 MetroRadiance 向けコードで申し訳ないが,WndProc 内で以下のように「wParam」 (BOOL) が TRUE のときのみ捉えます(飛んでくるデータが多少違います。旧 OS だと FALSE で RECT のみが飛んでくるんでしょう。いつからこの API のバージョンが新しくなったのかはわかりませんでした……)

if (msg == (int)WindowsMessages.WM_NCCALCSIZE)
{
  if (wParam != IntPtr.Zero)
  {
    var result = this.CalcNonClientSize(hwnd, lParam, ref handled);
    if (handled) return result;
  }
}

 実際のクライアント領域の計算です(戻り値フラグを enum 化した方がいいですね,結構ここが大事)。

private IntPtr CalcNonClientSize(IntPtr hWnd, IntPtr lParam, ref bool handled)
{
  if (!User32.IsZoomed(hWnd))
  {
    handled = true;
    return (IntPtr)0x300;
  }

  var rcsize = Marshal.PtrToStructure<NCCALCSIZE_PARAMS>(lParam);
  System.Diagnostics.Debug.WriteLine("CalcNonClientSize {0}", rcsize.lppos.flags);
  if (rcsize.lppos.flags.HasFlag(SetWindowPosFlags.SWP_NOSIZE)) return IntPtr.Zero;

  var hMonitor = User32.MonitorFromWindow(hWnd, MonitorDefaultTo.MONITOR_DEFAULTTONEAREST);
  if (hMonitor == IntPtr.Zero) return IntPtr.Zero;

  var monitorInfo = new MONITORINFO()
  {
    cbSize = (uint)Marshal.SizeOf(typeof(MONITORINFO))
  };
  if (User32.GetMonitorInfo(hMonitor, ref monitorInfo))
  {
    var workArea = monitorInfo.rcWork;
    AppBar.ApplyAppbarSpace(monitorInfo.rcMonitor, ref workArea);
    System.Diagnostics.Debug.WriteLine("CalcNonClientSize {0} {1} {2} {3}", workArea.Left, workArea.Top, workArea.Right, workArea.Bottom);

    rcsize.rgrc[0] = workArea;
    rcsize.rgrc[1] = workArea;
    rcsize.rgrc[2] = workArea;
    Marshal.StructureToPtr(rcsize, lParam, true);
  }

  handled = true;
  return (IntPtr)0x7F0;
}

 P/Invoke 群です。

// The MIT License
// Copyright 2020 mntone

public enum MONITORINFOF : uint
{
  MONITORINFOF_PRIMARY = 1
}

[StructLayout(LayoutKind.Sequential)]
public struct MONITORINFO
{
  public uint cbSize;
  public RECT rcMonitor;
  public RECT rcWork;
  public MONITORINFOF dwFlags;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct MONITORINFOEX
{
  public uint cbSize;
  public RECT rcMonitor;
  public RECT rcWork;
  public MONITORINFOF dwFlags;

  [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
  public string szDevice;
}

[DllImport("user32", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);

[DllImport("user32", CharSet = CharSet.Unicode, EntryPoint = "GetMonitorInfo", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetMonitorInfoEx(IntPtr hMonitor, ref MONITORINFOEX lpmi);

[StructLayout(LayoutKind.Sequential)]
public struct WINDOWPOS
{
  public IntPtr hwnd;
  public IntPtr hwndInsertAfter;
  public int x;
  public int y;
  public int cx;
  public int cy;
  public SetWindowPosFlags flags;
}

[StructLayout(LayoutKind.Sequential)]
public struct NCCALCSIZE_PARAMS
{
  [MarshalAs(UnmanagedType.ByValArray, SizeConst = 3)]
  public RECT[] rgrc;
  public WINDOWPOS lppos;
}

public enum AppBarEdges : uint
{
  /// <summary>
  /// Left edge.
  /// </summary>
  ABE_LEFT = 0,
  
  /// <summary>
  /// Top edge.
  /// </summary>
  ABE_TOP = 1,
  
  /// <summary>
  /// Right edge.
  /// </summary>
  ABE_RIGHT = 2,
  
  /// <summary>
  /// Bottom edge.
  /// </summary>
  ABE_BOTTOM = 3
}

[StructLayout(LayoutKind.Sequential)]
public struct APPBARDATA
{
  public int cbSize;
  public IntPtr hWnd;
  public uint uCallbackMessage;
  public AppBarEdges uEdge;
  public RECT rc;
  public IntPtr lParam;
}

[Flags]
public enum AppBarState : uint
{
  /// <summary>
  /// The taskbar is in the autohide state.
  /// <summary>
  ABS_AUTOHIDE = 0x1,

  /// <summary>
  /// he taskbar is in the always-on-top state.
  /// <summary>
  ABS_ALWAYSONTOP = 0x2
}

internal enum AppBarMessages : uint
{
  /// <summary>
  /// Registers a new appbar and specifies the message identifier that the system should use to send notification messages to the appbar.
  /// </summary>
  ABM_NEW = 0x0,
  
  /// <summary>
  /// Unregisters an appbar, removing the bar from the system's internal list.
  /// </summary>
  ABM_REMOVE = 0x1,
  
  /// <summary>
  /// Requests a size and screen position for an appbar.
  /// </summary>
  ABM_QUERYPOS = 0x2,
  
  /// <summary>
  /// Sets the size and screen position of an appbar.
  /// </summary>
  ABM_SETPOS = 0x3,
  
  /// <summary>
  /// Retrieves the autohide and always-on-top states of the Windows taskbar.
  /// </summary>
  ABM_GETSTATE = 0x4,
  
  /// <summary>
  /// Retrieves the bounding rectangle of the Windows taskbar. Note that this applies only to the system taskbar.
  /// Other objects, particularly toolbars supplied with third-party software, also can be present.
  /// As a result, some of the screen area not covered by the Windows taskbar might not be visible to the user.
  /// To retrieve the area of the screen not covered by both the taskbar and other app bars—the working area available
  /// to your application—, use the GetMonitorInfo function.
  /// </summary>
  ABM_GETTASKBARPOS = 0x5,
  
  /// <summary>
  /// Notifies the system to activate or deactivate an appbar.
  /// The lParam member of the APPBARDATA pointed to by pData is set to TRUE to activate or FALSE to deactivate.
  /// </summary>
  ABM_ACTIVATE = 0x6,
  
  /// <summary>
  /// Retrieves the handle to the autohide appbar associated with a particular edge of the screen.
  /// </summary>
  ABM_GETAUTOHIDEBAR = 0x7,
  
  /// <summary>
  /// Registers or unregisters an autohide appbar for an edge of the screen.
  /// </summary>
  ABM_SETAUTOHIDEBAR = 0x8,
  
  /// <summary>
  /// Notifies the system when an appbar's position has changed.
  /// </summary>
  ABM_WINDOWPOSCHANGED = 0x9,
  
  /// <summary>
  /// Sets the state of the appbar's autohide and always-on-top attributes.
  /// </summary>
  ABM_SETSTATE = 0xA,
  
  /// <summary>
  /// Retrieves the handle to the autohide appbar associated with a particular edge of a particular monitor.
  /// </summary>
  ABM_GETAUTOHIDEBAREX = 0xB,
  
  /// <summary>
  /// Registers or unregisters an autohide appbar for an edge of a particular monitor.
  /// </summary>
  ABM_SETAUTOHIDEBAREX = 0xC
}

public static class Shell32
{
  [DllImport("shell32")]
  internal static extern IntPtr SHAppBarMessage(AppBarMessages dwMessage, ref APPBARDATA pData);

  public static IntPtr SHAppBarGetAutoHideBar(AppBarEdges uEdge)
  {
    var data = new APPBARDATA()
    {
      cbSize = Marshal.SizeOf(typeof(APPBARDATA)),
      uEdge = uEdge,
    };
    return SHAppBarMessage(AppBarMessages.ABM_GETAUTOHIDEBAR, ref data);
  }

  public static IntPtr SHAppBarGetAutoHideBarEx(AppBarEdges uEdge, RECT rc)
  {
    var data = new APPBARDATA()
    {
      cbSize = Marshal.SizeOf(typeof(APPBARDATA)),
      uEdge = uEdge,
      rc = rc,
    };
    return SHAppBarMessage(AppBarMessages.ABM_GETAUTOHIDEBAREX, ref data);
  }
}

public static class AppBar
{
  private const string _appbarClass = "Shell_TrayWnd";

  // Note: This is constant in every DPI.
  private const int _hideAppbarSpace = 2;

  public static bool HasAutoHideAppBar(RECT area, AppBarEdges targetEdge)
  {
    var appbar = Shell32.SHAppBarGetAutoHideBarEx(targetEdge, area);
    return User32.IsWindow(appbar);
  }

  public static void ApplyAppbarSpace(RECT monitorArea, ref RECT workArea)
  {
    if (HasAutoHideAppBar(monitorArea, AppBarEdges.ABE_TOP)) workArea.Top += _hideAppbarSpace;
    if (HasAutoHideAppBar(monitorArea, AppBarEdges.ABE_LEFT)) workArea.Left += _hideAppbarSpace;
    if (HasAutoHideAppBar(monitorArea, AppBarEdges.ABE_RIGHT)) workArea.Right -= _hideAppbarSpace;
    if (HasAutoHideAppBar(monitorArea, AppBarEdges.ABE_BOTTOM)) workArea.Bottom -= _hideAppbarSpace;
  }
}

4. タスク バー (Appbar) の考慮

 Windows 10 からはマルチ ディスプレイにおける複数タスク バーをサポートしているので,任意の画面の範囲内にあるタスク バーを探すことで実現しています。しかも AutoHide 属性がついているのに限定しているのは,作業領域の調整は AutoHide ついている場合しか考慮する必要がないので。

 本番コードでは取れないこともあるかもしれないし,もう少し判定を工夫した方がいいのかもしれません。ちなみに DPI 関係なく Auto Hide なタスクバーのための空間 2px 固定っぽいです。あくまで最大化時のサイズを調整するだけで,切り取るクライアントエリアは作業領域に一致しているもので問題ないです。

まとめ

 実は,WindowChrome にあたる自作実装を軽く進めていて,MetroRadiance の PR にはこのコードは投げない予定 (これを使わなくても解決できた,ただどう言う実装で動いてるか知れた)ですが,技術デモとしてやりたいことができて,SylphyHorn 関連の PR 投げ終わったらそっちを作りたいと思います。

 まだ書いたままのひどい状態で,MetroRadiance で使わない部分も大事な資産なので分離作業しないといけない……

 結局,WPF 最大化問題は P/Invoke のコードが大量に増えるのでした。

16:23 追記: PR の形になったのでより実践的なコードはこちらを参照してください。

github.com