2

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.

Stelios Adamantidis
  • 1,866
  • 21
  • 36
  • I used to use WinAPI for this years ago, but if I am understanding your question, I don't think you need it. MouseDown and MouseMove events. On Mousedown, set clicked, moving, and startlocation variables. On MouseMove if moving is true, move form (set Form location) using MouseMove event e.Location.X and Y from startlocation. You'll probably need PointToScreen() for coordinates. On Mousedown if clicked was true, set moving to false. I'm not next to my old code atm, but if no one has answered or you don't have this by tonight when I get home, I'll post some code. – OldDog Dec 16 '22 at 22:01
  • @OldDog thanks but that won't work. The form will stop receiving mouse move events as soon as the cursor moves outside the form bounds. Which will happen quite often as moving the form from its `Location` property has a significant lag compared to the native way of moving it. – Stelios Adamantidis Dec 17 '22 at 09:52
  • 1
    Search for posts on this topic referring to the WM_NCHITTEST message – David Heffernan Dec 17 '22 at 10:24
  • Thx for the "addendum" I have improved the answer using `SetWindowsHookEx` to install a global low-level mouse hook for `WH_MOUSE_LL` to intercept `WM_LBUTTONDOWN`. In my brief testing this seems to provide the multiscreen support for the functionality. The `onClickToMove` is unchanged, just tweaked the manner in which to obtain the Win32 message. – IVSoftware Dec 17 '22 at 16:06
  • @DavidHeffernan true that works, **if** the mouse button is the left/primary and it's down ie _drag_. What I want to do is "trick" (even better "tell") the OS to move a window when the mouse button is up. If you have an answer by all means I am interested in reading it. – Stelios Adamantidis Dec 18 '22 at 15:49

2 Answers2

3

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
IVSoftware
  • 5,732
  • 2
  • 12
  • 23
  • Thank you for the detailed answer. This is what I ended up doing as a temporary solution. There is a significant obstacle though: it can't support multi screen setups. Actually it doesn't work outside the "main" form. That's why I want to leverage the OS way of moving the windows. – Stelios Adamantidis Dec 17 '22 at 09:38
  • Got it! Thx for letting me know. I modified my answer to use a global low-level mouse hook to provide the multiscreen functionality. [Clone](https://github.com/IVSoftware/move-with-mouse-click-global.git) – IVSoftware Dec 17 '22 at 16:11
  • I saw your modifications, thanks for the effort (I _didn't_ downvote BTW). I will have a look and respond later on. – Stelios Adamantidis Dec 17 '22 at 17:09
  • 1
    Oh thx! I never suspected you on that one. Something like this is a process and having a few iterations seems pretty routine - your addenda just seems to reflect that we're trying to iron out a few details. Every now and then, a post presents (dare I say it?) a really "fun" challenge and for me this is one of those. – IVSoftware Dec 17 '22 at 18:02
  • I tried your solution and it works. Thanks for the time, effort and devotion to make your solution workable. You have a +1 from me. Though I have posted an answer of my own doing the same thing differently -eg not using P/Invoke. I hope you don't mind as I would like to wait for the voting system to work before I chose an answer. – Stelios Adamantidis Dec 18 '22 at 16:31
  • Thx! And of course the main thing is that you find the solution that is optimal for you. I had a blast playing around with this and your question gets a +1 from me as well. – IVSoftware Dec 18 '22 at 16:43
  • Curious - are you making a distinction between "P/Invoke" and when you refer to doing it with the OS or WinAPI? I guess I've always thought of these as one and the same, so if there's a difference that I'm not aware of please do share that with me so I can adjust my own thinking. – IVSoftware Dec 18 '22 at 17:12
  • 1
    Oh no actually my thought was "if I can't take advantage of the OS then I might as well avoid p/invoke completely". :) I can't think of a way to tell the OS to do the move without p/invoking `SendMessage` -or any other method for that matter. – Stelios Adamantidis Dec 18 '22 at 17:21
  • Got it (I think...)! Ok, consider that in terms of _moving_ the form, well, the form has the ability to move _itself_ to anywhere on the desktop, yes? So, no OS call needed for that IMO, like ever. The pesky thing seems to be this mouse click which "might" fall _outside_ the reach of `WndProc` or `IMessageFilter`. _We just need to know that it happened_ because static `Form.MousePosition` also works anywhere on the screen. So the OS might only be for the simple detection that `WM_LBUTTONDOWN` (or `WM_RBUTTONDOWN`) has occurred from 'outside' of the app views. `Form` does the rest ?? :) – IVSoftware Dec 18 '22 at 18:08
0

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);
        }
    }
}
Stelios Adamantidis
  • 1,866
  • 21
  • 36