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.
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;
}
}