UI/CustomPatternGrid.razor

A Razor UI Panel that renders an editable rectangular grid of cells and handles mouse input for painting cells. It displays Rows as text rows, tracks hover and dragging, computes cell hit tests including snapping to nearest cell, draws continuous strokes by rasterizing a line between sampled cells, and invokes a callback OnCellPaint when a cell changes.

File Access
@using System
@using System.Collections.Generic
@using Sandbox
@using Sandbox.UI
@inherits Panel
@namespace BlockParty
@attribute [StyleSheet( "CustomPatternGrid.razor.scss" )]

<root class="custom-pattern-grid">
	@for ( int displayRow = 0; displayRow < GridHeight; displayRow++ )
	{
		int row = GridHeight - 1 - displayRow;
		<div class="pattern-grid-row">
			@for ( int column = 0; column < GridWidth; column++ )
			{
				<div class="pattern-cell @(CellFilled( row, column ) ? "filled" : "") @(row == _hoverRow && column == _hoverColumn ? "hovered" : "")"></div>
			}
		</div>
	}
</root>

@code
{
	[Parameter] public IReadOnlyList<string> Rows { get; set; }

	// Dimensions come from the owner rather than being re-derived from Rows: the paint callback resizes
	// and indexes the pattern using ITS notion of width/height, so a second copy of that formula here
	// would only be waiting to disagree (Rows also lags the live level by a frame after every edit).
	[Parameter] public int GridWidth { get; set; } = 1;
	[Parameter] public int GridHeight { get; set; } = 1;

	[Parameter] public Action<int, int, bool> OnCellPaint { get; set; }

	private bool _painting;
	private bool _paintValue;
	private int _lastRow = -1;
	private int _lastColumn = -1;
	private int _hoverRow = -1;
	private int _hoverColumn = -1;

	private bool CellFilled( int row, int column )
		=> row >= 0 && column >= 0 && Rows is not null && row < Rows.Count
			&& column < (Rows[row]?.Length ?? 0) && Rows[row][column] == '1';

	protected override void OnMouseDown( MousePanelEvent e )
	{
		base.OnMouseDown( e );
		if ( e.MouseButton != MouseButtons.Left ) return;

		// Claim the press before hit-testing: the engine gates its drag machinery on the mousedown still
		// propagating, so a press we can't resolve to a cell (zero-sized boxes on the first frame) must
		// still be swallowed rather than handed to an ancestor drag target.
		e.StopPropagation();

		// Snap to the nearest cell: the grid's padding and inter-cell gaps are dead pixels, and a press
		// landing in one would otherwise abort the whole stroke, not just that cell.
		if ( !TryGetCellAtMouse( out int row, out int column, snapToNearest: true ) ) return;

		_painting = true;
		_paintValue = !CellFilled( row, column );
		_lastRow = -1;
		_lastColumn = -1;
		Audio.PlaySfx( SfxType.MenuBlip, 0.7f );
		PaintStroke( row, column );
	}

	protected override void OnMouseMove( MousePanelEvent e )
	{
		base.OnMouseMove( e );
		bool hit = TryGetCellAtMouse( out int row, out int column );
		SetHoverCell( hit ? row : -1, hit ? column : -1 );

		if ( !_painting || !HasActive ) return;
		if ( hit ) PaintStroke( row, column );
		e.StopPropagation();
	}

	protected override void OnMouseUp( MousePanelEvent e )
	{
		base.OnMouseUp( e );
		if ( e.MouseButton != MouseButtons.Left || !_painting ) return;
		EndPaint();
		e.StopPropagation();
	}

	protected override void OnMouseOut( MousePanelEvent e )
	{
		base.OnMouseOut( e );
		SetHoverCell( -1, -1 );
	}

	public override void Tick()
	{
		base.Tick();
		if ( _painting && !HasActive )
			EndPaint();
	}

	// Cells ignore pointer events so this stable root keeps HasActive while their filled classes rebuild.
	// snapToNearest picks the closest cell centre for a press that lands in the padding/gap, but only
	// inside our own box — a drag that wanders off the grid must stop painting, not smear the edge.
	private bool TryGetCellAtMouse( out int row, out int column, bool snapToNearest = false )
	{
		row = -1;
		column = -1;

		Vector2 mouse = Mouse.Position;
		snapToNearest &= mouse.x >= Box.Left && mouse.x <= Box.Right && mouse.y >= Box.Top && mouse.y <= Box.Bottom;
		float bestDistance = float.MaxValue;

		int displayRow = 0;
		foreach ( Panel rowPanel in Children )
		{
			// A parameter change lands a frame before the rebuilt cells, so ignore rows the current
			// dimensions can't address rather than mapping them to a negative index.
			int cellRow = GridHeight - 1 - displayRow;
			if ( cellRow < 0 ) break;

			int cellColumn = 0;
			foreach ( Panel cell in rowPanel.Children )
			{
				if ( cellColumn >= GridWidth ) break;

				var box = cell.Box;
				if ( mouse.x >= box.Left && mouse.x <= box.Right && mouse.y >= box.Top && mouse.y <= box.Bottom )
				{
					row = cellRow;
					column = cellColumn;
					return true;
				}

				if ( snapToNearest )
				{
					var centre = new Vector2( (box.Left + box.Right) * 0.5f, (box.Top + box.Bottom) * 0.5f );
					float distance = (mouse - centre).LengthSquared;
					if ( distance < bestDistance )
					{
						bestDistance = distance;
						row = cellRow;
						column = cellColumn;
					}
				}

				cellColumn++;
			}
			displayRow++;
		}

		if ( snapToNearest && row >= 0 ) return true;

		row = -1;
		column = -1;
		return false;
	}

	// The engine delivers at most one mouse-move per frame, so a quick swipe jumps several cells at
	// once — walk the line from the last sampled cell or the stroke comes out dotted.
	private void PaintStroke( int row, int column )
	{
		if ( row == _lastRow && column == _lastColumn ) return;

		if ( _lastRow < 0 || _lastColumn < 0 )
		{
			PaintCell( row, column );
		}
		else
		{
			int x = _lastColumn, y = _lastRow;
			int dx = Math.Abs( column - x ), stepX = x < column ? 1 : -1;
			int dy = -Math.Abs( row - y ), stepY = y < row ? 1 : -1;
			int error = dx + dy;

			while ( x != column || y != row )
			{
				int doubled = error * 2;
				if ( doubled >= dy ) { error += dy; x += stepX; }
				if ( doubled <= dx ) { error += dx; y += stepY; }
				PaintCell( y, x );
			}
		}

		_lastRow = row;
		_lastColumn = column;
	}

	private void PaintCell( int row, int column )
	{
		if ( CellFilled( row, column ) != _paintValue )
			OnCellPaint?.Invoke( row, column, _paintValue );
	}

	private void SetHoverCell( int row, int column )
	{
		if ( row == _hoverRow && column == _hoverColumn ) return;
		_hoverRow = row;
		_hoverColumn = column;
	}

	private void EndPaint()
	{
		_painting = false;
		_lastRow = -1;
		_lastColumn = -1;
	}

	protected override int BuildHash()
	{
		var hash = new HashCode();
		hash.Add( GridWidth );
		hash.Add( GridHeight );
		hash.Add( _hoverRow );
		hash.Add( _hoverColumn );
		if ( Rows is not null )
			foreach ( string row in Rows ) hash.Add( row );
		return hash.ToHashCode();
	}
}