a resizable panel

drag a corner handle to resize a box live

by the end of this guide you will have a panel whose width and height update in real time as you drag a grip handle in its bottom-right corner. you will see how PointerDrag tracks an in-progress drag without relying on mouse-move events, how the Tick method gates rebuilds to frames where something actually changed, and how OnMouseDown and OnMouseUp start and stop a drag.

hold the size in a field

create Code/Demo/ResizeBoxUI.cs. the only state the panel needs is a Vector2 for the current size. start with a fixed box so you can see the shape before wiring any interaction.

using System;
using Goo;
using Sandbox;
using Sandbox.UI;

namespace Goo.Demo;

public class ResizeBoxUI : GooPanel<Container>
{
    static readonly Vector2 MinSize = new( 120f, 90f );

    Vector2 _size = new( 320f, 220f );

    protected override Container Build()
    {
        var box = new Container
        {
            Position        = PositionMode.Absolute,
            Left            = Px.Of( 200 ), Top = Px.Of( 160 ),
            Width           = Px.Of( _size.x ), Height = Px.Of( _size.y ),
            BorderRadius    = Px.Of( 10 ),
            BackgroundColor = new Color( 0.16f, 0.18f, 0.24f, 0.92f ),
            AlignItems      = Align.Center, JustifyContent = Justify.Center,
            PointerEvents   = PointerEvents.All,
            Children        =
            {
                new Text( $"{_size.x:0} x {_size.y:0}" )
                {
                    FontFamily = "Roboto", FontSize = Px.Of( 26 ), FontWeight = 600,
                    FontColor = Color.White,
                },
            },
        };

        return new Container
        {
            Width         = Length.Percent( 100 ),
            Height        = Length.Percent( 100 ),
            PointerEvents = PointerEvents.None,
            Children      = { box },
        };
    }
}

_size drives both Width and Height on the box and the label inside it. when you change _size and call Rebuild(), goo reruns Build() and the box snaps to the new dimensions.

add a drag handle

a PointerDrag tracks an ongoing drag. call Begin with the starting value when the mouse goes down, poll Current each tick to read the dragged value updated by how far the pointer has moved since, and call End when the mouse comes up. the key property is Active - it is true while a drag is in progress.

add the PointerDrag field and a grip panel inside the box:

readonly PointerDrag _resize = new();

protected override Container Build()
{
    var grip = new Container
    {
        Position        = PositionMode.Absolute,
        Right           = Px.Of( 0 ), Bottom = Px.Of( 0 ),
        Width           = Px.Of( 18 ), Height = Px.Of( 18 ),
        BorderRadius    = Px.Of( 4 ),
        BackgroundColor = new Color( 0.18f, 0.62f, 0.95f ),
        PointerEvents   = PointerEvents.All,
        OnMouseDown     = _ => _resize.Begin( _size ),
        OnMouseUp       = _ => _resize.End(),
    };

    var box = new Container
    {
        Position        = PositionMode.Absolute,
        Left            = Px.Of( 200 ), Top = Px.Of( 160 ),
        Width           = Px.Of( _size.x ), Height = Px.Of( _size.y ),
        BorderRadius    = Px.Of( 10 ),
        BackgroundColor = new Color( 0.16f, 0.18f, 0.24f, 0.92f ),
        AlignItems      = Align.Center, JustifyContent = Justify.Center,
        PointerEvents   = PointerEvents.All,
        Children        =
        {
            new Text( $"{_size.x:0} x {_size.y:0}" )
            {
                FontFamily = "Roboto", FontSize = Px.Of( 26 ), FontWeight = 600,
                FontColor = Color.White,
            },
            grip,
        },
    };

    return new Container
    {
        Width         = Length.Percent( 100 ),
        Height        = Length.Percent( 100 ),
        PointerEvents = PointerEvents.None,
        Children      = { box },
    };
}

OnMouseDown calls _resize.Begin( _size ) - that records _size as the drag origin. OnMouseUp calls _resize.End() to clear the active state.

poll the drag in tick

mouse-move events can drop frames when the cursor moves fast. PointerDrag avoids that: instead of reacting to individual events it stores where the pointer is now and you read it each frame. override Tick to do that poll:

protected override bool Tick( float dt )
{
    if ( _resize.Active )
    {
        Vector2 cur = _resize.Current( Panel );
        _size = new Vector2( MathF.Max( cur.x, MinSize.x ), MathF.Max( cur.y, MinSize.y ) );
        return true;
    }
    return false;
}

_resize.Current( Panel ) returns the start size passed to Begin, offset by how far the cursor has moved since, scaled by Panel.ScaleFromScreen so the delta lands in the same unit as _size (CSS pixels) regardless of render scale. MathF.Max( cur.x, MinSize.x ) clamps so the box cannot shrink below MinSize. returning true from Tick signals goo to call Rebuild() automatically; false skips the rebuild on frames where nothing changed.

the complete class

using System;
using Goo;
using Sandbox;
using Sandbox.UI;

namespace Goo.Demo;

public class ResizeBoxUI : GooPanel<Container>
{
    static readonly Vector2 MinSize = new( 120f, 90f );

    Vector2 _size = new( 320f, 220f );
    readonly PointerDrag _resize = new();

    protected override bool Tick( float dt )
    {
        if ( _resize.Active )
        {
            Vector2 cur = _resize.Current( Panel );
            _size = new Vector2( MathF.Max( cur.x, MinSize.x ), MathF.Max( cur.y, MinSize.y ) );
            return true;
        }
        return false;
    }

    protected override Container Build()
    {
        var grip = new Container
        {
            Position        = PositionMode.Absolute,
            Right           = Px.Of( 0 ), Bottom = Px.Of( 0 ),
            Width           = Px.Of( 18 ), Height = Px.Of( 18 ),
            BorderRadius    = Px.Of( 4 ),
            BackgroundColor = new Color( 0.18f, 0.62f, 0.95f ),
            PointerEvents   = PointerEvents.All,
            OnMouseDown     = _ => _resize.Begin( _size ),
            OnMouseUp       = _ => _resize.End(),
        };

        var box = new Container
        {
            Position        = PositionMode.Absolute,
            Left            = Px.Of( 200 ), Top = Px.Of( 160 ),
            Width           = Px.Of( _size.x ), Height = Px.Of( _size.y ),
            BorderRadius    = Px.Of( 10 ),
            BackgroundColor = new Color( 0.16f, 0.18f, 0.24f, 0.92f ),
            AlignItems      = Align.Center, JustifyContent = Justify.Center,
            PointerEvents   = PointerEvents.All,
            Children        =
            {
                new Text( $"{_size.x:0} x {_size.y:0}" )
                {
                    FontFamily = "Roboto", FontSize = Px.Of( 26 ), FontWeight = 600,
                    FontColor = Color.White,
                },
                grip,
            },
        };

        return new Container
        {
            Width         = Length.Percent( 100 ),
            Height        = Length.Percent( 100 ),
            PointerEvents = PointerEvents.None,
            Children      = { box },
        };
    }
}

press play, drop ResizeBoxUI on a screen panel, and drag the blue corner. the box and its label update every frame while the drag is active.

what just happened

three ideas worked together:

the root container sets PointerEvents = PointerEvents.None so the full-screen backing panel does not block clicks beneath it; the box and grip opt back in with PointerEvents.All.

resize several boxes with Each.Of

the same drag-and-poll pattern generalizes past one box. replace the single _size field and _resize handle with a list, one position, size, and PointerDrag per box, and let Each.Of build one panel per item since the loop is the only dynamic part of the root's children:

using System.Collections.Generic;

class ResizableBox
{
    public Vector2 Position;
    public Vector2 Size;
    public readonly PointerDrag Resize = new();
}

readonly List<ResizableBox> _boxes = new()
{
    new ResizableBox { Position = new( 120, 120 ), Size = new( 260, 180 ) },
    new ResizableBox { Position = new( 460, 260 ), Size = new( 220, 160 ) },
};

protected override bool Tick( float dt )
{
    bool dirty = false;
    foreach ( var b in _boxes )
    {
        if ( !b.Resize.Active ) continue;
        Vector2 cur = b.Resize.Current( Panel );
        b.Size = new Vector2( MathF.Max( cur.x, MinSize.x ), MathF.Max( cur.y, MinSize.y ) );
        dirty = true;
    }
    return dirty;
}

protected override Container Build()
{
    return new Container
    {
        Width         = Length.Percent( 100 ),
        Height        = Length.Percent( 100 ),
        PointerEvents = PointerEvents.None,
        Children      = { Each.Of( _boxes, ( i, b ) => BoxPanel( b ) ) },
    };
}

Container BoxPanel( ResizableBox b ) => new Container
{
    Position        = PositionMode.Absolute,
    Left            = Px.Of( b.Position.x ), Top = Px.Of( b.Position.y ),
    Width           = Px.Of( b.Size.x ), Height = Px.Of( b.Size.y ),
    BorderRadius    = Px.Of( 10 ),
    BackgroundColor = new Color( 0.16f, 0.18f, 0.24f, 0.92f ),
    PointerEvents   = PointerEvents.All,
    Children =
    {
        new Container
        {
            Position        = PositionMode.Absolute,
            Right           = Px.Of( 0 ), Bottom = Px.Of( 0 ),
            Width           = Px.Of( 18 ), Height = Px.Of( 18 ),
            BorderRadius    = Px.Of( 4 ),
            BackgroundColor = new Color( 0.18f, 0.62f, 0.95f ),
            PointerEvents   = PointerEvents.All,
            OnMouseDown     = _ => b.Resize.Begin( b.Size ),
            OnMouseUp       = _ => b.Resize.End(),
        },
    },
};

Tick now loops every box instead of checking one flag, and returns true for the frame if any box is mid-drag, so two boxes dragged in the same frame still cost a single Rebuild(). Each.Of( _boxes, ... ) is the only dynamic part of the root's children list, so it drops straight into the initializer the same way a literal child would. _boxes here is append-only, so the default index key Each.Of assigns is enough; give it a keyOf selector instead once boxes can be added or removed from the middle of the list, so a box's drag state follows it rather than its slot.

measure the rebuild cost while dragging

Tick returns true on every frame a box is mid-drag, so Build(), the diff, and the apply pass all rerun every single frame the grip is held. Perf measures exactly what that costs: reset the counters when a drag starts, read the report when it ends. this replaces the single-box grip's OnMouseDown/OnMouseUp from the complete class above:

OnMouseDown = _ =>
{
    Perf.Enabled = true;
    Perf.Reset();
    _resize.Begin( _size );
},
OnMouseUp = _ =>
{
    _resize.End();
    Log.Info( Perf.Report() );
    Perf.Enabled = false;
},

Perf.Report() prints per-phase timing (build, diff, apply) and a per-op-kind breakdown, both totalled since the last Reset(), so the numbers cover exactly the drag you just did. leave Perf.Enabled off outside a debug session: it costs a branch per rebuild and per op even when nobody reads the report.

see also