Editor/Panels/DocumentCanvas.cs

Editor UI widget for the SuperShot document canvas. It displays the captured image (pixmap), draws overlays like rule-of-thirds and layer outlines, and handles mouse and keyboard interactions for selecting, moving, resizing and deleting layers with undo support.

File AccessNative Interop
using System;
using Sandbox;

namespace Editor.SuperShot;

public sealed class DocumentCanvas : Widget
{
	enum DragMode { None, Move, Resize }

	readonly SuperShotWindow _window;
	Pixmap _pixmap;
	bool _dirty = true;

	Rect _imageRect;

	bool _armed;
	DragMode _mode = DragMode.None;
	Vector2 _grab;
	Vector2 _startMouseN;
	Vector2 _startPos;
	Vector2 _startSize;
	bool _movedUndo;
	bool _selectionChanged;
	RealTimeSince _sinceKeyUndo = 100f;

	static readonly (int x, int y)[] HandleDirs =
	{
		(-1, -1), (0, -1), (1, -1),
		(-1, 0), (1, 0),
		(-1, 1), (0, 1), (1, 1)
	};

	const float HandleHitPx = 8f;
	const float HandleDrawPx = 8f;

	public bool ShowOriginal { get; set; }
	public bool ShowThirds { get; set; } = true;

	public DocumentCanvas( Widget parent, SuperShotWindow window ) : base( parent )
	{
		_window = window;
		MinimumSize = new Vector2( 180, 140 );
		MouseTracking = true;
		FocusMode = FocusMode.Click;
		_window.Changed += Refresh;
	}

	public void Refresh()
	{
		_dirty = true;
		Update();
	}

	[EditorEvent.Frame]
	void OnFrame()
	{
		if ( _dirty )
		{
			_dirty = false;
			BuildPixmap();
		}

		if ( _pixmap is not null )
			_imageRect = FitRect( _pixmap.Size, Size );

		Update();
	}

	void BuildPixmap()
	{
		Bitmap source = null;
		bool ownsSource = false;

		if ( _window.HasCapture )
		{
			if ( ShowOriginal )
			{
				source = _window.RawCapture;
			}
			else
			{
				source = _window.BuildFinished();
				ownsSource = true;
			}
		}

		if ( source is null || !source.IsValid )
		{
			_pixmap = null;
			return;
		}

		if ( _pixmap is null || _pixmap.Width != source.Width || _pixmap.Height != source.Height )
			_pixmap = new Pixmap( source.Width, source.Height );

		_pixmap.UpdateFromPixels( source );

		if ( ownsSource )
			source.Dispose();
	}

	Vector2 ToNormalized( Vector2 screen )
	{
		if ( _imageRect.Width <= 0f || _imageRect.Height <= 0f )
			return new Vector2( 0.5f, 0.5f );

		return new Vector2(
			(screen.x - _imageRect.Left) / _imageRect.Width,
			(screen.y - _imageRect.Top) / _imageRect.Height );
	}

	Rect LayerScreenRect( SuperShotLayer layer )
	{
		float w = layer.Size.x * _imageRect.Width;
		float h = layer.Size.y * _imageRect.Height;
		float cx = _imageRect.Left + layer.Position.x * _imageRect.Width;
		float cy = _imageRect.Top + layer.Position.y * _imageRect.Height;
		return new Rect( cx - w * 0.5f, cy - h * 0.5f, w, h );
	}

	static Vector2 HandlePos( Rect r, int hx, int hy )
	{
		return new Vector2(
			r.Left + (hx + 1) * 0.5f * r.Width,
			r.Top + (hy + 1) * 0.5f * r.Height );
	}

	Vector2 HitHandle( SuperShotLayer layer, Vector2 screen )
	{
		var r = LayerScreenRect( layer );
		foreach ( var (hx, hy) in HandleDirs )
		{
			if ( (HandlePos( r, hx, hy ) - screen).Length <= HandleHitPx )
				return new Vector2( hx, hy );
		}
		return Vector2.Zero;
	}

	static bool Contains( Rect r, Vector2 p )
	{
		return p.x >= r.Left && p.x <= r.Right && p.y >= r.Top && p.y <= r.Bottom;
	}

	SuperShotLayer TopLayerAt( Vector2 screen )
	{
		var layers = _window.ActiveDocument?.Layers;
		if ( layers is null )
			return null;

		for ( int i = layers.Count - 1; i >= 0; i-- )
		{
			var layer = layers[i];
			if ( layer is null || !layer.Visible || !layer.HasVisualBox )
				continue;

			if ( Contains( LayerScreenRect( layer ), screen ) )
				return layer;
		}
		return null;
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		if ( e.Button != MouseButtons.Left )
			return;

		if ( _window.ActiveDocument is null || !_window.HasCapture || _pixmap is null )
			return;

		Focus();

		var screen = e.LocalPosition;
		_movedUndo = false;
		_selectionChanged = false;
		_mode = DragMode.None;
		_grab = Vector2.Zero;

		var sel = _window.SelectedLayer;
		if ( sel is not null && sel.HasVisualBox )
		{
			var g = HitHandle( sel, screen );
			if ( g != Vector2.Zero )
			{
				_mode = DragMode.Resize;
				_grab = g;
				BeginDrag( sel, screen );
				return;
			}
		}

		var hit = TopLayerAt( screen );
		if ( hit is not null )
		{
			if ( sel != hit )
			{
				_window.SetSelectedLayerSilent( hit );
				_selectionChanged = true;
			}
			_mode = DragMode.Move;
			BeginDrag( hit, screen );
			Refresh();
			return;
		}

		if ( sel is not null )
		{
			_window.SetSelectedLayerSilent( null );
			_selectionChanged = true;
			Refresh();
		}

		_armed = false;
	}

	void BeginDrag( SuperShotLayer layer, Vector2 screen )
	{
		_armed = true;
		_startMouseN = ToNormalized( screen );
		_startPos = layer.Position;
		_startSize = layer.Size;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );

		if ( _armed && _mode != DragMode.None )
		{
			var layer = _window.SelectedLayer;
			if ( layer is null )
			{
				_armed = false;
				return;
			}

			var n = ToNormalized( e.LocalPosition );

			if ( !_movedUndo )
			{
				var d = n - _startMouseN;
				if ( MathF.Abs( d.x ) + MathF.Abs( d.y ) > 0.0015f )
				{
					_window.PushEditUndo();
					_movedUndo = true;
				}
			}

			if ( _mode == DragMode.Move )
				ApplyMove( layer, n );
			else
				ApplyResize( layer, n );

			Refresh();
			return;
		}

		UpdateHoverCursor( e.LocalPosition );
	}

	void ApplyMove( SuperShotLayer layer, Vector2 n )
	{
		var p = _startPos + (n - _startMouseN);
		p.x = MathX.Clamp( p.x, 0f, 1f );
		p.y = MathX.Clamp( p.y, 0f, 1f );
		layer.Position = p;
	}

	void ApplyResize( SuperShotLayer layer, Vector2 n )
	{
		const float min = 0.02f;

		float l = _startPos.x - _startSize.x * 0.5f;
		float r = _startPos.x + _startSize.x * 0.5f;
		float t = _startPos.y - _startSize.y * 0.5f;
		float b = _startPos.y + _startSize.y * 0.5f;

		if ( _grab.x < 0 ) l = MathF.Min( n.x, r - min );
		else if ( _grab.x > 0 ) r = MathF.Max( n.x, l + min );

		if ( _grab.y < 0 ) t = MathF.Min( n.y, b - min );
		else if ( _grab.y > 0 ) b = MathF.Max( n.y, t + min );

		layer.Position = new Vector2( (l + r) * 0.5f, (t + b) * 0.5f );
		layer.Size = new Vector2( r - l, b - t );
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		base.OnMouseReleased( e );

		if ( e.Button != MouseButtons.Left )
			return;

		_armed = false;
		_mode = DragMode.None;

		if ( _selectionChanged )
			_window.NotifyChanged();

		_selectionChanged = false;
		_movedUndo = false;
	}

	protected override void OnDoubleClick( MouseEvent e )
	{
		base.OnDoubleClick( e );

		if ( e.Button != MouseButtons.Left || _pixmap is null )
			return;

		var hit = TopLayerAt( e.LocalPosition );
		if ( hit is not null )
			_window.SelectLayer( hit );
	}

	protected override void OnKeyPress( KeyEvent e )
	{
		base.OnKeyPress( e );

		if ( e.Accepted )
			return;

		var layer = _window.SelectedLayer;
		if ( layer is null )
			return;

		if ( e.Key == KeyCode.Delete )
		{
			_window.DeleteSelectedLayer();
			e.Accepted = true;
			return;
		}

		if ( !layer.HasVisualBox )
			return;

		var dir = Vector2.Zero;
		if ( e.Key == KeyCode.Left ) dir = new Vector2( -1, 0 );
		else if ( e.Key == KeyCode.Right ) dir = new Vector2( 1, 0 );
		else if ( e.Key == KeyCode.Up ) dir = new Vector2( 0, -1 );
		else if ( e.Key == KeyCode.Down ) dir = new Vector2( 0, 1 );

		if ( dir == Vector2.Zero )
			return;

		bool fast = Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Shift );
		var step = dir * (fast ? 0.02f : 0.004f);

		if ( _sinceKeyUndo > 0.6f )
		{
			_window.PushEditUndo();
			_sinceKeyUndo = 0f;
		}

		var p = layer.Position + step;
		p.x = MathX.Clamp( p.x, 0f, 1f );
		p.y = MathX.Clamp( p.y, 0f, 1f );
		layer.Position = p;

		Refresh();
		e.Accepted = true;
	}

	void UpdateHoverCursor( Vector2 screen )
	{
		var sel = _window.SelectedLayer;
		if ( sel is not null && sel.HasVisualBox )
		{
			var g = HitHandle( sel, screen );
			if ( g != Vector2.Zero )
			{
				Cursor = CursorForHandle( (int)g.x, (int)g.y );
				return;
			}
		}

		Cursor = TopLayerAt( screen ) is not null ? CursorShape.SizeAll : CursorShape.Arrow;
	}

	static CursorShape CursorForHandle( int x, int y )
	{
		return (x, y) switch
		{
			(-1, -1) => CursorShape.SizeFDiag,
			(1, 1) => CursorShape.SizeFDiag,
			(1, -1) => CursorShape.SizeBDiag,
			(-1, 1) => CursorShape.SizeBDiag,
			(0, -1) => CursorShape.SizeV,
			(0, 1) => CursorShape.SizeV,
			(-1, 0) => CursorShape.SizeH,
			(1, 0) => CursorShape.SizeH,
			_ => CursorShape.Arrow
		};
	}

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground.Darken( 0.35f ) );
		Paint.DrawRect( LocalRect );

		if ( _pixmap is null )
		{
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.65f ) );
			Paint.DrawText( LocalRect.Shrink( 24 ), "Capture a shot or open one from the Gallery to start editing.", TextFlag.Center | TextFlag.WordWrap );
			return;
		}

		var rect = _imageRect;
		DrawCheckerboard( rect );
		Paint.Draw( rect, _pixmap );

		if ( ShowThirds && !ShowOriginal )
			DrawThirds( rect );

		if ( !ShowOriginal )
			DrawLayerOverlays();
	}

	void DrawLayerOverlays()
	{
		var doc = _window.ActiveDocument;
		if ( doc?.Layers is null )
			return;

		var selected = _window.SelectedLayer;

		foreach ( var layer in doc.Layers )
		{
			if ( layer is null || !layer.HasVisualBox || !layer.Visible || layer == selected )
				continue;

			var r = LayerScreenRect( layer );
			Paint.ClearBrush();
			Paint.SetPen( SuperShotUI.Accent.WithAlpha( 0.25f ), 1f );
			Paint.DrawRect( r );
		}

		if ( selected is null || !selected.HasVisualBox )
			return;

		var sr = LayerScreenRect( selected );

		Paint.ClearBrush();
		Paint.SetPen( SuperShotUI.Accent, 1.5f );
		Paint.DrawRect( sr );

		foreach ( var (hx, hy) in HandleDirs )
		{
			var p = HandlePos( sr, hx, hy );
			var hr = new Rect( p.x - HandleDrawPx * 0.5f, p.y - HandleDrawPx * 0.5f, HandleDrawPx, HandleDrawPx );

			Paint.ClearPen();
			Paint.SetBrush( Color.White );
			Paint.DrawRect( hr, 1f );
			Paint.ClearBrush();
			Paint.SetPen( SuperShotUI.Accent, 1f );
			Paint.DrawRect( hr, 1f );
		}
	}

	void DrawCheckerboard( Rect rect )
	{
		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground.Darken( 0.2f ) );
		Paint.DrawRect( rect );
	}

	void DrawThirds( Rect rect )
	{
		Paint.SetPen( Color.White.WithAlpha( 0.18f ), 1f );

		float x1 = rect.Left + rect.Width / 3f;
		float x2 = rect.Left + rect.Width * 2f / 3f;
		float y1 = rect.Top + rect.Height / 3f;
		float y2 = rect.Top + rect.Height * 2f / 3f;

		Paint.DrawLine( new Vector2( x1, rect.Top ), new Vector2( x1, rect.Bottom ) );
		Paint.DrawLine( new Vector2( x2, rect.Top ), new Vector2( x2, rect.Bottom ) );
		Paint.DrawLine( new Vector2( rect.Left, y1 ), new Vector2( rect.Right, y1 ) );
		Paint.DrawLine( new Vector2( rect.Left, y2 ), new Vector2( rect.Right, y2 ) );
	}

	static Rect FitRect( Vector2 content, Vector2 area )
	{
		if ( content.x <= 0f || content.y <= 0f )
			return new Rect( 0, 0, area.x, area.y );

		float scale = Math.Min( area.x / content.x, area.y / content.y );
		var size = content * scale;
		return new Rect( (area.x - size.x) / 2f, (area.y - size.y) / 2f, size.x, size.y );
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();
		_window.Changed -= Refresh;
	}
}