A UI panel subclass that implements a draggable title-grip. It captures mouse down, computes an initial grab offset, and on mouse move while active it moves the nearest ancestor with CSS class "panel" (or an explicitly set Target) by updating its Style.Left/Top in panel units, clamping position so the title remains reachable.
using Sandbox;
using Sandbox.UI;
namespace NZombies;
/// <summary>
/// A title bar you can drag a dev panel around by. `<DragHandle class="head">`.
///
/// ⛔ A PANEL SUBCLASS RATHER THAN RAZOR EVENT HANDLERS, AND THE REASON IS MOUSE CAPTURE. A
/// `onmousedown` in the markup gives you the press but nothing that survives the cursor leaving
/// the bar — and a window drag is precisely the gesture where the cursor outruns the thing it is
/// dragging. `Panel.HasActive` stays true from press to release no matter where the pointer goes,
/// which is what makes the drag feel like a window instead of like a slider that keeps slipping.
///
/// ⚠️ IT MOVES THE NEAREST ANCESTOR WITH CLASS `panel`, not its own parent. The grip sits
/// inside the title bar, which sits inside the window, so "my parent" is the bar and dragging it
/// would slide the bar around inside a window that never moved. Walking up to a named class
/// survives someone adding another wrapper later, which a hard-coded `Parent.Parent` would not.
///
/// ⚠️ SCREEN PIXELS AND PANEL UNITS ARE NOT THE SAME NUMBER. `Box.Rect` and `Mouse.Position` are
/// real pixels; `Style.Left` is in the panel's own scaled units. `ScaleFromScreen` is the
/// conversion, the same one SniperScope and DamageNumbersHud already use — without it the window
/// runs away from the cursor on any display that is not at 100%.
/// </summary>
public class DragHandle : Panel
{
/// <summary>What to move. Null walks up to the nearest ancestor with class `panel`.</summary>
public Panel Target { get; set; }
Panel Moving
{
get
{
if ( Target is not null ) return Target;
for ( var p = Parent; p is not null; p = p.Parent )
if ( p.HasClass( "panel" ) ) return p;
return Parent;
}
}
// The cursor's offset from the moved panel's top-left, in screen pixels, captured on press.
// ⚠️ WITHOUT THIS THE WINDOW SNAPS ITS CORNER TO THE POINTER on the first frame of every drag,
// which reads as the panel jumping out from under you before it starts following.
Vector2 _grab;
protected override void OnMouseDown( MousePanelEvent e )
{
base.OnMouseDown( e );
// ⚠️ THE CLOSE BUTTON IS NOT COVERED BY THE GRIP, which is handled in the stylesheet by
// stopping this element short of the bar's right edge rather than by testing the target
// here. A geometric answer cannot be defeated by an event arriving from an unexpected
// panel, and it also keeps the button's own hover state working.
var t = Moving;
if ( t is null ) return;
_grab = Mouse.Position - new Vector2( t.Box.Rect.Left, t.Box.Rect.Top );
e.StopPropagation();
}
protected override void OnMouseMove( MousePanelEvent e )
{
base.OnMouseMove( e );
if ( !HasActive ) return;
var t = Moving;
if ( t is null ) return;
var s = ScaleFromScreen;
var pos = (Mouse.Position - _grab) * s;
// ⛔ CLAMPED SO THE BAR CANNOT LEAVE THE SCREEN. A panel dragged off the top or past an
// edge takes its own title bar with it, and a window you cannot grab is a window you
// cannot get back without a console command. The limits keep the bar reachable rather
// than keeping the whole panel visible, which would stop you tucking one into a corner.
var w = Screen.Width * s;
var h = Screen.Height * s;
var keep = 80f;
pos.x = pos.x.Clamp( keep - t.Box.Rect.Width * s, w - keep );
pos.y = pos.y.Clamp( 0f, h - keep );
t.Style.Left = Length.Pixels( pos.x );
t.Style.Top = Length.Pixels( pos.y );
e.StopPropagation();
}
}