Stelios Adamantidis
Stelios Adamantidis

Reputation: 2041

Move window without a mouse drag gesture

I have a window (subclass of System.Windows.Forms.Form) without a border in order to apply a custom style to it. Moving the window when the mouse button is down seems to be easy. Either by sending a WM_NCLBUTTONDOWN HT_CAPTION or a WM_SYSCOMMAND 0xF102 message the window can be dragged to a new location. As soon as the mouse button is up though, it seems to be impossible to move the window.

One could send WM_SYSCOMMAND SC_MOVE message but then the cursor moves at the top center of the window and awaits for the user to press any arrow key in order to hook the window for move -which is awkward at the least. I tried to fake a key press/release sequence but that of course didn't work as I called SendMessage with the current form Handle as argument but I guess the message should not be sent to the current form.

The desired behavior is: click a button (ie the mouse button is released) move the form where cursor goes, click again to release the form. Is that possible with winapi? Unfortunately I am not familiar with it.

Addendum

Sending a key input: I tried to use SendInput as SendMessage is supposed to be bad practice. Still it didn't hook the window. I tried to read and print the winapi error code with Marshal.GetLastWin32Error() and I got a 5 which is access denied. The curious thing was that I received the messages after the move sequence ended (ie I manually pressed a key or mouse button). No idea how to work around this.

Using the IMessageFilter (IVSoftware's answer): this is what I ended up doing but that has 2 issues: moving the window with its Location property has lag compared to the native way (no big deal for now) and also it doesn't receive mouse messages that occur outside the main form. That means it won't work a. for multiscreen environments b. if the cursor moves outside the forms of the application. I could create full screen transparent forms for every monitor that will serve as an "message canvas" but still... why not give the OS way a chance.

Upvotes: 2

Views: 329

Answers (2)

Stelios Adamantidis
Stelios Adamantidis

Reputation: 2041

Here is a possible solution that I will go with after all. It's not that IVSoftware's answer doesn't work, it does, I tried it. It's that my solution has some set of advantages relevant to what I am trying to do. The main points are:

  • Utilizing the IMessageFilter (thanks to SwDevMan81's answer) which reminded me that the correct way to process messages "globally" is not to override WndProc)
  • Laying out a set of transparent windows on all screens in order to receive mouse move messages everywhere.

Pros

  • It works without having to make any P/Invokes
  • It allows more tricks to be done like for example leverage the transparent forms to implement a "move border instead of form" functionality (though I didn't test it, paint might be tricky)
  • Can be easily applied for resize as well.
  • Can work with mouse buttons other than the left/primary.

Cons

  • It has too many "moving parts". At least for my taste. Laying out transparent windows all over the place? Hm.
  • It has some corner cases. Pressing Alt+F4 while moving the form will close the "canvas form". That can be easily mitigated but there might be others as well.
  • There must be an OS way to do this...

The code (basic parts; full code on github)

public enum WindowMessage
{
    WM_MOUSEMOVE = 0x200,
    WM_LBUTTONDOWN = 0x201,
    WM_LBUTTONUP = 0x202,
    WM_RBUTTONDOWN = 0x204,
    //etc. omitted for brevity
}

public class MouseMessageFilter : IMessageFilter
{
    public event EventHandler MouseMoved;
    public event EventHandler<MouseButtons> MouseDown;
    public event EventHandler<MouseButtons> MouseUp;

    public bool PreFilterMessage(ref Message m)
    {
        switch (m.Msg)
        {
            case (int)WindowMessage.WM_MOUSEMOVE:
                MouseMoved?.Invoke(this, EventArgs.Empty);
                break;
            case (int)WindowMessage.WM_LBUTTONDOWN:
                MouseDown?.Invoke(this, MouseButtons.Left);
                break;
            //etc. omitted for brevity
        }

        return false;
    }
}

public partial class CustomForm : Form
{
    private MouseMessageFilter windowMoveHandler = new();
    private Point originalLocation;
    private Point offset;

    private static List<Form> canvases = new(SystemInformation.MonitorCount);

    public CustomForm()
    {
        InitializeComponent();
        
        windowMoveHandler.MouseMoved += (_, _) =>
        {
            Point position = Cursor.Position;
            position.Offset(offset);
            Location = position;
        };
        windowMoveHandler.MouseDown += (_, button) =>
        {
            switch (button)
            {
                case MouseButtons.Left:
                    EndMove();
                    break;
                case MouseButtons.Middle:
                    CancelMove();
                    break;
            }
        };
        moveButton.MouseClick += (_, _) =>
        {
            BeginMove();
        };
    }

    private void BeginMove()
    {
        Application.AddMessageFilter(windowMoveHandler);
        originalLocation = Location;
        offset = Invert(PointToClient(Cursor.Position));
        ShowCanvases();
    }
    
    //Normally an extension method in another library of mine but I didn't want to
    //add a dependency just for that
    private static Point Invert(Point p) => new Point(-p.X, -p.Y);

    private void ShowCanvases()
    {
        for (int i = 0; i < Screen.AllScreens.Length; i++)
        {
            Screen screen = Screen.AllScreens[i];
            Form form = new TransparentForm
            {
                Bounds = screen.Bounds,
                Owner = Owner
            };
            canvases.Add(form);
            form.Show();
        }
    }

    private void EndMove()
    {
        DisposeCanvases();
    }

    private void DisposeCanvases()
    {
        Application.RemoveMessageFilter(windowMoveHandler);
        for (var i = 0; i < canvases.Count; i++)
        {
            canvases[i].Close();
        }
        canvases.Clear();
    }

    private void CancelMove()
    {
        EndMove();
        Location = originalLocation;
    }

    //The form used as a "message canvas" for moving the form outside the client area.
    //It practically helps extend the client area. Without it we won't be able to get
    //the events from everywhere
    private class TransparentForm : Form
    {
        public TransparentForm()
        {
            StartPosition = FormStartPosition.Manual;
            FormBorderStyle = FormBorderStyle.None;
            ShowInTaskbar = false;
        }

        protected override void OnPaintBackground(PaintEventArgs e)
        {
            //Draws a white border mostly useful for debugging. For example that's
            //how I realised I needed Screen.Bounds instead of WorkingArea.
            ControlPaint.DrawBorder(e.Graphics, new Rectangle(Point.Empty, Size),
                Color.White, 2, ButtonBorderStyle.Solid,
                Color.White, 2, ButtonBorderStyle.Solid,
                Color.White, 2, ButtonBorderStyle.Solid,
                Color.White, 2, ButtonBorderStyle.Solid);
        }
    }
}

Upvotes: 0

IV.
IV.

Reputation: 9511

As I understand it, the desired behavior is to enable the "Click to Move" (one way or another) and then click anywhere on a multiscreen surface and have the borderless form follow the mouse to the new position. One solution that seems to work in my brief testing is to pinvoke the WinApi SetWindowsHookEx to install a global low level hook for WH_MOUSE_LL in order to intercept WM_LBUTTONDOWN.

*This answer has been modified in order to track updates to the question.


Low-level global mouse hook

    public MainForm()
    {
        InitializeComponent();
        using (var process = Process.GetCurrentProcess())
        {
            using (var module = process.MainModule!)
            {
                var mname = module.ModuleName!;
                var handle = GetModuleHandle(mname);
                _hook = SetWindowsHookEx(
                    HookType.WH_MOUSE_LL,
                    lpfn: callback,
                    GetModuleHandle(mname),
                    0);
            }
        }

        // Unhook when this `Form` disposes.
        Disposed += (sender, e) => UnhookWindowsHookEx(_hook);

        // A little hack to keep window on top while Click-to-Move is enabled.
        checkBoxEnableCTM.CheckedChanged += (sender, e) =>
        {
            TopMost = checkBoxEnableCTM.Checked;
        };

        // Compensate move offset with/without the title NC area.
        var offset = RectangleToScreen(ClientRectangle);
        CLIENT_RECT_OFFSET = offset.Y - Location.Y;
    }
    readonly int CLIENT_RECT_OFFSET;
    IntPtr _hook;
    private IntPtr callback(int code, IntPtr wParam, IntPtr lParam)
    {
        var next = IntPtr.Zero;
        if (code >= 0)
        {
            switch ((int)wParam)
            {
                case WM_LBUTTONDOWN:
                    if (checkBoxEnableCTM.Checked)
                    {
                        _ = onClickToMove(MousePosition);
                        // This is a very narrow condition and the window is topmost anyway.
                        // So probably swallow this mouse click and skip other hooks in the chain.
                        return (IntPtr)1;
                    }
                    break;
            }
        }
        return CallNextHookEx(IntPtr.Zero, code, wParam, lParam);
    }
}

Perform the move

private async Task onClickToMove(Point mousePosition)
{
    // Exempt clicks that occur on the 'Enable Click to Move` button itself.
    if (!checkBoxEnableCTM.ClientRectangle.Contains(checkBoxEnableCTM.PointToClient(mousePosition)))
    {
        // Try this. Offset the new `mousePosition` so that the cursor lands
        // in the middle of the button when the move is over. This feels
        // like a semi-intuitive motion perhaps. This means we have to
        // subtract the relative position of the button from the new loc.
        var clientNew = PointToClient(mousePosition);

        var centerButton =
            new Point(
                checkBoxEnableCTM.Location.X + checkBoxEnableCTM.Width / 2,
                checkBoxEnableCTM.Location.Y + checkBoxEnableCTM.Height / 2);

        var offsetToNow = new Point(
            mousePosition.X - centerButton.X,
            mousePosition.Y - centerButton.Y - CLIENT_RECT_OFFSET);

        // Allow the pending mouse messages to pump. 
        await Task.Delay(TimeSpan.FromMilliseconds(1));
        WindowState = FormWindowState.Normal; // JIC window happens to be maximized.
        Location = offsetToNow;            
    }
    checkBoxEnableCTM.Checked = false; // Turn off after each move.
}

In the code I used to test this answer, it seemed intuitive to center the button where the click takes place (this offset is easy to change if it doesn't suit you). Here's the result of the multiscreen test:

borderless form

multiscreen

WinApi

#region P I N V O K E
public enum HookType : int { WH_MOUSE = 7, WH_MOUSE_LL = 14 }
const int WM_LBUTTONDOWN = 0x0201;

delegate IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam);

[DllImport("user32.dll", SetLastError = true)]
static extern IntPtr SetWindowsHookEx(HookType hookType, HookProc lpfn, IntPtr hMod, int dwThreadId);

[DllImport("user32.dll")]
static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam,
    IntPtr lParam);

[DllImport("user32.dll", SetLastError = true)]
static extern bool UnhookWindowsHookEx(IntPtr hhk);

[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern IntPtr GetModuleHandle(string lpModuleName);
#endregion P I N V O K E

Upvotes: 3

Related Questions