Editor/Panels/EditPanel.cs

Editor UI panel for the SuperShot editor. Constructs the Edit tab UI including toolbar, tool rail, canvas, sidebar with recent shots, layers list, arrangement controls, and property sheets; hooks property change events to push undo and refresh the canvas.

File Access
using System;
using System.Collections.Generic;
using Sandbox;

namespace Editor.SuperShot;

public sealed class EditPanel : Widget
{
	readonly SuperShotWindow _window;
	RealTimeSince _sinceUndoPush = 100f;

	static readonly HashSet<string> ColorProps = new()
	{
		nameof( EditSettings.Brightness ), nameof( EditSettings.Contrast ), nameof( EditSettings.Saturation ),
		nameof( EditSettings.Exposure ), nameof( EditSettings.Hue )
	};
	static readonly HashSet<string> DetailProps = new() { nameof( EditSettings.Sharpen ), nameof( EditSettings.Blur ) };
	static readonly HashSet<string> EffectProps = new() { nameof( EditSettings.Vignette ), nameof( EditSettings.Grain ) };
	static readonly HashSet<string> TransformProps = new() { nameof( EditSettings.Rotate ), nameof( EditSettings.FlipH ), nameof( EditSettings.FlipV ) };
	static readonly HashSet<string> FrameProps = new() { nameof( EditSettings.Border ), nameof( EditSettings.BorderColor ) };
	static readonly HashSet<string> WatermarkProps = new()
	{
		nameof( EditSettings.Watermark ), nameof( EditSettings.WatermarkText ), nameof( EditSettings.WatermarkAnchor ),
		nameof( EditSettings.WatermarkSize ), nameof( EditSettings.WatermarkColor )
	};

	static readonly HashSet<string> LayerCommonProps = new()
	{
		nameof( SuperShotLayer.Name ), nameof( SuperShotLayer.Visible ),
		nameof( SuperShotLayer.Opacity ), nameof( SuperShotLayer.BlendMode )
	};
	static readonly HashSet<string> LayerTransformProps = new()
	{
		nameof( SuperShotLayer.Position ), nameof( SuperShotLayer.Size )
	};

	DocumentCanvas _canvas;

	public EditPanel( SuperShotWindow window ) : base( null )
	{
		_window = window;
		Name = "Edit";
		WindowTitle = "Edit";
		SetWindowIcon( "tune" );
		Layout = Layout.Column();
		Layout.Margin = 8;
		Layout.Spacing = 8;

		Build();
		_window.Changed += OnWindowChanged;
	}

	void OnWindowChanged() => Rebuild();

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

	protected override void OnVisibilityChanged( bool visible )
	{
		base.OnVisibilityChanged( visible );

		if ( !visible )
			_window.LeaveEditReview();
	}

	void Rebuild()
	{
		Layout.Clear( true );
		Build();
	}

	void Build()
	{
		AddToolbar();

		var workspace = Layout.AddRow();
		workspace.Spacing = 6;

		AddToolRail( workspace );

		_canvas = new DocumentCanvas( this, _window );
		workspace.Add( _canvas, 1 );

		AddSidebar( workspace );
	}

	void AddToolbar()
	{
		var bar = new Widget( this );
		bar.MinimumSize = new Vector2( 0, 40 );
		bar.Layout = Layout.Row();
		bar.Layout.Margin = 4;
		bar.Layout.Spacing = 4;
		Layout.Add( bar );

		var row = bar.Layout;
		row.Add( new Button.Primary( "Save", "save" ) { Enabled = _window.HasCapture, Clicked = () => _window.SaveCurrent() } );
		row.Add( new Button( "Save As", "save_as" ) { Enabled = _window.HasCapture, Clicked = () => _window.SaveCurrentAsNew() } );
		row.AddSpacingCell( 10 );
		row.Add( new Button( "", "undo" ) { ToolTip = "Undo", Clicked = _window.Undo } );
		row.Add( new Button( "", "redo" ) { ToolTip = "Redo", Clicked = _window.Redo } );
		row.Add( new Button( "", "restart_alt" ) { ToolTip = "Reset all edits", Clicked = _window.ResetEdit } );
		row.AddStretchCell();
		row.Add( new Label( _window.HasCapture ? (_window.ActiveDocument is not null ? "Editable document" : "Flat image") : "No image loaded" ) { Color = SuperShotUI.Muted } );
		row.Add( new Button( "Before / After", "compare" )
		{
			Enabled = _window.HasCapture,
			ToolTip = "Hold to compare against the original",
			Clicked = () =>
			{
				if ( _canvas is null )
					return;
				_canvas.ShowOriginal = !_canvas.ShowOriginal;
				_canvas.Refresh();
			}
		} );
	}

	void AddToolRail( Layout workspace )
	{
		var rail = new Widget( this );
		rail.MinimumSize = new Vector2( 44, 0 );
		rail.MaximumSize = new Vector2( 44, 100000 );
		rail.Layout = Layout.Column();
		rail.Layout.Margin = 2;
		rail.Layout.Spacing = 6;
		workspace.Add( rail, 0 );

		AddToolButton( rail.Layout, "Add text layer", "title", _window.AddTextLayer );
		AddToolButton( rail.Layout, "Add image layer", "add_photo_alternate",
			() => new MessagePromptDialog( "Add Image Layer", "Paste an image file path", path => _window.AddImageLayer( path ), "Add" ) );
		AddToolButton( rail.Layout, "Add adjustment layer", "tune", _window.AddAdjustmentLayer );

		rail.Layout.AddSpacingCell( 10 );

		AddToolButton( rail.Layout, "Duplicate selected layer", "content_copy", _window.DuplicateSelectedLayer );
		AddToolButton( rail.Layout, "Delete selected layer", "delete", _window.DeleteSelectedLayer );

		rail.Layout.AddStretchCell();
	}

	static void AddToolButton( Layout layout, string tooltip, string icon, Action clicked )
	{
		layout.Add( new Button( "", icon )
		{
			ToolTip = tooltip,
			FixedSize = new Vector2( 36, 34 ),
			Clicked = clicked
		} );
	}

	void AddSidebar( Layout workspace )
	{
		var right = new ScrollArea( this );
		right.FixedWidth = 440;
		right.MinimumSize = new Vector2( 440, 0 );
		right.MaximumSize = new Vector2( 440, 100000 );
		right.Canvas = new Widget( right );
		right.Canvas.FixedWidth = 420;
		right.Canvas.Layout = Layout.Column();
		right.Canvas.Layout.Margin = 0;
		right.Canvas.Layout.Spacing = 8;
		workspace.Add( right, 0 );

		var body = right.Canvas.Layout;

		if ( !_window.HasCapture || _window.ActiveDocument is null )
		{
			var empty = SuperShotUI.AddCard( body, "Nothing loaded", "image" );
			empty.Body.Add( new Label( "Capture a shot or open one from the Gallery to start editing. Then add text, images, and adjustment layers, and drag them right on the canvas." ) { WordWrap = true } );
			AddRecentShotsUi( body );
			body.AddStretchCell();
			return;
		}

		AddArrangeUi( body );
		AddLayerUi( body );

		if ( _window.SelectedLayer is { } selectedLayer )
			AddSelectedLayerProperties( body, selectedLayer );

		AddBaseAdjustmentUi( body );
		body.AddStretchCell();
	}

	void AddRecentShotsUi( Layout body )
	{
		var card = SuperShotUI.AddCard( body, "Recent Shots", "history" );

		if ( _window.Gallery.Count == 0 )
		{
			card.Body.Add( new Label( "No recent shots yet. Capture something from the Home tab and it will show up here." ) { Color = SuperShotUI.Muted, WordWrap = true } );
			card.Body.Add( new Button( "Open Home", "home" ) { Clicked = () => _window.DockManager.RaiseDock( "Home" ) } );
			return;
		}

		Layout row = null;
		int inRow = 0;
		int shown = 0;

		for ( int i = _window.Gallery.Count - 1; i >= 0 && shown < 6; i--, shown++ )
		{
			if ( inRow == 0 )
			{
				row = card.Body.AddRow();
				row.Spacing = 6;
			}

			row.Add( new RecentShotTile( _window, _window.Gallery[i] ), 1 );
			inRow++;

			if ( inRow == 2 )
			{
				inRow = 0;
				row = null;
			}
		}

		if ( row is not null )
			row.AddStretchCell();

		card.Body.Add( new Button( "Open Full Gallery", "collections" ) { Clicked = () => _window.DockManager.RaiseDock( "Gallery" ) } );
	}

	void AddArrangeUi( Layout body )
	{
		var layer = _window.SelectedLayer;
		bool enabled = layer is not null && layer.HasVisualBox;

		var card = SuperShotUI.AddCard( body, "Arrange", "align_horizontal_center" );
		card.Body.Add( new Label( enabled ? "Align the selected layer to the canvas." : "Select a text or image layer to align it." ) { Color = SuperShotUI.Muted, WordWrap = true } );

		var horizontal = card.Body.AddRow();
		horizontal.Spacing = 4;
		AddAlignButton( horizontal, "align_horizontal_left", "Align left", enabled, () => _window.AlignSelectedLayer( -1, null ) );
		AddAlignButton( horizontal, "align_horizontal_center", "Center horizontally", enabled, () => _window.AlignSelectedLayer( 0, null ) );
		AddAlignButton( horizontal, "align_horizontal_right", "Align right", enabled, () => _window.AlignSelectedLayer( 1, null ) );
		horizontal.AddStretchCell();

		var vertical = card.Body.AddRow();
		vertical.Spacing = 4;
		AddAlignButton( vertical, "align_vertical_top", "Align top", enabled, () => _window.AlignSelectedLayer( null, -1 ) );
		AddAlignButton( vertical, "align_vertical_center", "Center vertically", enabled, () => _window.AlignSelectedLayer( null, 0 ) );
		AddAlignButton( vertical, "align_vertical_bottom", "Align bottom", enabled, () => _window.AlignSelectedLayer( null, 1 ) );
		vertical.AddStretchCell();

		var quick = card.Body.AddRow();
		quick.Spacing = 4;
		quick.Add( new Button( "Center", "filter_center_focus" ) { Enabled = enabled, Clicked = () => _window.AlignSelectedLayer( 0, 0 ) }, 1 );
		quick.Add( new Button( "Fill Canvas", "fit_screen" ) { Enabled = enabled, Clicked = _window.FillCanvasWithSelectedLayer }, 1 );
	}

	static void AddAlignButton( Layout row, string icon, string tooltip, bool enabled, Action clicked )
	{
		row.Add( new Button( "", icon )
		{
			ToolTip = tooltip,
			Enabled = enabled,
			FixedSize = new Vector2( 32, 28 ),
			Clicked = clicked
		} );
	}

	void AddLayerUi( Layout body )
	{
		var document = _window.ActiveDocument;
		var card = SuperShotUI.AddCard( body, "Layers", "layers" );

		var actions = card.Body.AddRow();
		actions.Spacing = 4;
		actions.Add( new Button( "Text", "title" ) { Clicked = _window.AddTextLayer }, 1 );
		actions.Add( new Button( "Image", "image" )
		{
			Clicked = () => new MessagePromptDialog( "Add Image Layer", "Paste an image file path", path => _window.AddImageLayer( path ), "Add" )
		}, 1 );
		actions.Add( new Button( "Adj", "tune" ) { ToolTip = "Add adjustment layer", Clicked = _window.AddAdjustmentLayer }, 1 );

		if ( document.Layers.Count == 0 )
		{
			card.Body.Add( new Label( "No layers yet. Add text, image, or adjustment layers above." ) { Color = SuperShotUI.Muted, WordWrap = true } );
			return;
		}

		for ( int i = document.Layers.Count - 1; i >= 0; i-- )
		{
			var layer = document.Layers[i];
			if ( layer is null )
				continue;

			bool selected = document.SelectedLayerId == layer.Id;

			var row = card.Body.AddRow();
			row.Spacing = 4;

			row.Add( new Button( "", layer.Visible ? "visibility" : "visibility_off" )
			{
				ToolTip = layer.Visible ? "Hide layer" : "Show layer",
				FixedSize = new Vector2( 28, 26 ),
				Clicked = () => _window.ToggleLayerVisibility( layer )
			} );

			row.Add( new Button( "", LayerIcon( layer.Kind ) )
			{
				FixedSize = new Vector2( 28, 26 ),
				Clicked = () => _window.SelectLayer( layer )
			} );

			row.Add( new Button( selected ? $"●  {layer.Name}" : layer.Name )
			{
				Clicked = () => _window.SelectLayer( layer )
			}, 1 );
		}

		var order = card.Body.AddRow();
		order.Spacing = 4;
		order.Add( new Button( "", "arrow_upward" ) { ToolTip = "Move up", FixedSize = new Vector2( 30, 26 ), Clicked = () => _window.MoveSelectedLayer( 1 ) } );
		order.Add( new Button( "", "arrow_downward" ) { ToolTip = "Move down", FixedSize = new Vector2( 30, 26 ), Clicked = () => _window.MoveSelectedLayer( -1 ) } );
		order.AddStretchCell();
		order.Add( new Button( "", "content_copy" ) { ToolTip = "Duplicate layer", FixedSize = new Vector2( 30, 26 ), Clicked = _window.DuplicateSelectedLayer } );
		order.Add( new Button( "", "delete" ) { ToolTip = "Delete layer", FixedSize = new Vector2( 30, 26 ), Clicked = _window.DeleteSelectedLayer } );
	}

	static string LayerIcon( SuperShotLayerKind kind )
	{
		return kind switch
		{
			SuperShotLayerKind.Text => "title",
			SuperShotLayerKind.Image => "image",
			SuperShotLayerKind.Adjustment => "tune",
			_ => "layers"
		};
	}

	void AddSelectedLayerProperties( Layout body, SuperShotLayer layer )
	{
		var layerSo = layer.GetSerialized();
		layerSo.OnPropertyChanged += _ => OnEditChanged();

		var card = SuperShotUI.AddCard( body, $"Layer · {layer.Name}", "edit" );
		card.Body.Add( SuperShotUI.SheetWidget( layerSo, p => LayerCommonProps.Contains( p.Name ) ) );

		if ( layer.HasVisualBox )
			SuperShotUI.AddSection( body, "Transform", "open_with", SuperShotUI.SheetWidget( layerSo, p => LayerTransformProps.Contains( p.Name ) ), "supershot.layer.transform", defaultOpen: false );

		SerializedObject detail = layer.Kind switch
		{
			SuperShotLayerKind.Text => layer.Text.GetSerialized(),
			SuperShotLayerKind.Image => layer.Image.GetSerialized(),
			SuperShotLayerKind.Adjustment => layer.Adjustment.Edit.GetSerialized(),
			_ => null
		};

		if ( detail is null )
			return;

		detail.OnPropertyChanged += _ => OnEditChanged();

		SuperShotUI.AddSection( body, $"{layer.Kind} Settings", "tune", SuperShotUI.SheetWidget( detail ), defaultOpen: true );
	}

	void AddBaseAdjustmentUi( Layout body )
	{
		var so = _window.ActiveDocument.BaseEdit.GetSerialized();
		so.OnPropertyChanged += _ => OnEditChanged();

		var color = SuperShotUI.AddCard( body, "Image Color", "palette" );
		color.Body.Add( SuperShotUI.SheetWidget( so, p => ColorProps.Contains( p.Name ) ) );

		SuperShotUI.AddSection( body, "Detail", "deblur", SuperShotUI.SheetWidget( so, p => DetailProps.Contains( p.Name ) ), "supershot.edit.detail" );
		SuperShotUI.AddSection( body, "Effects", "auto_awesome", SuperShotUI.SheetWidget( so, p => EffectProps.Contains( p.Name ) ), "supershot.edit.effects" );
		SuperShotUI.AddSection( body, "Transform", "crop_rotate", SuperShotUI.SheetWidget( so, p => TransformProps.Contains( p.Name ) ), "supershot.edit.transform" );
		SuperShotUI.AddSection( body, "Frame", "crop_din", SuperShotUI.SheetWidget( so, p => FrameProps.Contains( p.Name ) ), "supershot.edit.frame" );
		SuperShotUI.AddSection( body, "Watermark", "branding_watermark", SuperShotUI.SheetWidget( so, p => WatermarkProps.Contains( p.Name ) ), "supershot.edit.watermark" );
	}

	void OnEditChanged()
	{
		if ( _sinceUndoPush > 0.75f )
		{
			_window.PushEditUndo();
			_sinceUndoPush = 0f;
		}

		_canvas?.Refresh();
	}

	sealed class RecentShotTile : Widget
	{
		readonly SuperShotWindow _window;
		readonly CapturedShot _shot;

		public RecentShotTile( SuperShotWindow window, CapturedShot shot ) : base( null )
		{
			_window = window;
			_shot = shot;
			MinimumSize = new Vector2( 0, 112 );
			Cursor = CursorShape.Finger;
			ToolTip = $"{shot.Size.x}x{shot.Size.y}  {shot.Time:g}";
		}

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

			if ( e.LeftMouseButton )
				_window.OpenInEditor( _shot );
		}

		protected override void OnPaint()
		{
			var bg = IsUnderMouse ? SuperShotUI.CardBorder.WithAlpha( 0.65f ) : SuperShotUI.CardBackground;

			Paint.ClearPen();
			Paint.SetBrush( bg );
			Paint.DrawRect( LocalRect, 6f );

			Paint.ClearBrush();
			Paint.SetPen( IsUnderMouse ? SuperShotUI.Accent : SuperShotUI.CardBorder, 1f );
			Paint.DrawRect( LocalRect.Shrink( 0.5f ), 6f );

			var imageRect = new Rect( 6, 6, Width - 12, Height - 30 );
			Paint.ClearPen();
			Paint.SetBrush( Color.Black );
			Paint.DrawRect( imageRect, 4f );

			if ( _shot.Thumbnail is not null )
			{
				var rect = FitRect( _shot.Thumbnail.Size, imageRect.Size );
				rect.Left += imageRect.Left;
				rect.Top += imageRect.Top;
				Paint.Draw( rect, _shot.Thumbnail );
			}
			else
			{
				Paint.SetPen( SuperShotUI.Muted );
				Paint.DrawIcon( imageRect, "image", 22, TextFlag.Center );
			}

			Paint.SetDefaultFont( 7, 500 );
			Paint.SetPen( Theme.Text.WithAlpha( 0.9f ) );
			var label = _shot.SavedPath is not null ? "Saved shot" : "Recent shot";
			Paint.DrawText( new Rect( 8, Height - 22, Width - 16, 10 ), label, TextFlag.LeftTop );

			Paint.SetPen( SuperShotUI.Muted );
			Paint.DrawText( new Rect( 8, Height - 12, Width - 16, 10 ), $"{_shot.Size.x}x{_shot.Size.y}", TextFlag.LeftTop );
		}

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