Editor/SuperShotWindow.cs

Editor window for the Supershot Studio tool. Manages capturing the scene view to bitmaps, editing settings, an in-memory gallery, saving to disk, posting to Discord webhooks, undo/redo for edits, and constructing the editor UI layout and menus.

File AccessNetworkingHttp Calls
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Sandbox;

namespace Editor.SuperShot;

public sealed class CapturedShot
{
	public Bitmap Raw { get; set; }
	public EditSettings Edit { get; set; }
	public Pixmap Thumbnail { get; set; }
	public string SavedPath { get; set; }
	public Vector2Int Size { get; set; }
	public DateTime Time { get; set; } = DateTime.Now;
}

[EditorApp( "Supershot Studio", "photo_camera", "Take and edit screenshots from the editor camera" )]
public sealed class SuperShotWindow : DockWindow
{
	public SuperShotSettings Settings => SuperShotSettings.Current;

	public Bitmap RawCapture { get; private set; }
	public bool HasCapture => RawCapture is not null && RawCapture.IsValid;

	public List<CapturedShot> Gallery { get; } = new();

	public event Action Changed;

	readonly List<EditSettings> _undo = new();
	readonly List<EditSettings> _redo = new();

	EditSettings _editBaseline;
	bool _reviewingGalleryShot;

	CapturedShot _editingShot;

	static SuperShotWindow _instance;
	bool _duplicate;

	public static SuperShotWindow Open()
	{
		if ( _instance is not null && _instance.IsValid )
		{
			_instance.Show();
			_instance.Focus();
			return _instance;
		}

		return new SuperShotWindow();
	}

	public SuperShotWindow()
	{
		// The editor "Apps" launcher news this up directly, bypassing Open(); a duplicate focuses the real
		// window and discards itself in Show().
		if ( _instance is not null && _instance.IsValid )
		{
			_duplicate = true;
			return;
		}

		_instance = this;

		DeleteOnClose = true;
		Title = "Supershot Studio";
		Size = new Vector2( 1280, 820 );
		SetWindowIcon( "photo_camera" );

		LoadSavedShotsIntoGallery();
		RestoreDefaultDockLayout();
		Show();
	}

	public override void Show()
	{
		if ( _duplicate )
		{
			if ( _instance is not null && _instance.IsValid )
			{
				_instance.Show();
				_instance.Focus();
			}

			Destroy();
			return;
		}

		base.Show();
	}

	public void NotifyChanged() => Changed?.Invoke();

	public void CaptureNow()
	{
		var raw = SuperShotService.CaptureRaw( Settings.Capture );
		if ( raw is null )
		{
			Log.Warning( "[Supershot] Capture produced no image. Move the scene view (activate the Supershot framing tool) or pick a scene camera." );
			return;
		}

		RawCapture?.Dispose();
		RawCapture = raw;
		_reviewingGalleryShot = false;
		_editingShot = null;
		_editBaseline = Settings.Edit.Clone();

		AddToGallery( raw, Settings.Edit );
		NotifyChanged();
	}

	public string Capture()
	{
		CaptureNow();
		if ( !HasCapture )
			return null;

		var path = SaveCurrent();
		if ( path is not null && Gallery.Count > 0 )
			_editingShot = Gallery[^1];

		return path;
	}

	public void QuickCapture( ShotResolution resolution )
	{
		var previous = Settings.Capture.Resolution;
		Settings.Capture.Resolution = resolution;
		CaptureNow();
		SaveCurrent();
		Settings.Capture.Resolution = previous;
		NotifyChanged();
	}

	public void CaptureAt( ShotResolution resolution )
	{
		var previous = Settings.Capture.Resolution;
		Settings.Capture.Resolution = resolution;
		CaptureNow();
		Settings.Capture.Resolution = previous;
		NotifyChanged();
	}

	public Bitmap BuildFinished()
	{
		if ( !HasCapture )
			return null;

		return SuperShotEdit.Apply( RawCapture, Settings.Edit );
	}

	public void PushEditUndo()
	{
		_undo.Add( Settings.Edit.Clone() );
		if ( _undo.Count > 64 ) _undo.RemoveAt( 0 );
		_redo.Clear();
	}

	public void Undo()
	{
		if ( _undo.Count == 0 ) return;
		_redo.Add( Settings.Edit.Clone() );
		var prev = _undo[^1];
		_undo.RemoveAt( _undo.Count - 1 );
		CopyEdit( prev, Settings.Edit );
		Settings.Save();
		NotifyChanged();
	}

	public void Redo()
	{
		if ( _redo.Count == 0 ) return;
		_undo.Add( Settings.Edit.Clone() );
		var next = _redo[^1];
		_redo.RemoveAt( _redo.Count - 1 );
		CopyEdit( next, Settings.Edit );
		Settings.Save();
		NotifyChanged();
	}

	public void ResetEdit()
	{
		PushEditUndo();
		CopyEdit( new EditSettings(), Settings.Edit );
		Settings.Save();
		NotifyChanged();
	}

	static void CopyEdit( EditSettings from, EditSettings to )
	{
		to.Brightness = from.Brightness; to.Contrast = from.Contrast; to.Saturation = from.Saturation;
		to.Exposure = from.Exposure; to.Hue = from.Hue; to.Filter = from.Filter;
		to.Sharpen = from.Sharpen; to.Blur = from.Blur; to.Vignette = from.Vignette; to.Grain = from.Grain;
		to.Rotate = from.Rotate; to.FlipH = from.FlipH; to.FlipV = from.FlipV;
		to.Border = from.Border; to.BorderColor = from.BorderColor;
		to.Watermark = from.Watermark; to.WatermarkText = from.WatermarkText;
		to.WatermarkAnchor = from.WatermarkAnchor; to.WatermarkSize = from.WatermarkSize; to.WatermarkColor = from.WatermarkColor;
	}

	public string SaveCurrent()
	{
		using var finished = BuildFinished();
		if ( finished is null )
		{
			Log.Warning( "[Supershot] Nothing captured to save." );
			return null;
		}

		// Shots opened from the library overwrite their original file in place; fresh captures write new files.
		if ( _editingShot is not null && !string.IsNullOrEmpty( _editingShot.SavedPath ) )
		{
			var saved = SuperShotService.SaveOver( finished, _editingShot.SavedPath, Settings.Output.Quality );
			if ( saved is not null )
			{
				_editingShot.Edit = Settings.Edit.Clone();
				_editingShot.Size = finished.Size;
				_editingShot.Thumbnail = MakeThumbnail( finished );
				_editBaseline = Settings.Edit.Clone();
			}

			NotifyChanged();
			return saved;
		}

		var path = SuperShotService.Save( finished, Settings.Output, Settings.Capture );
		if ( path is not null && Gallery.Count > 0 )
			Gallery[^1].SavedPath = path;

		NotifyChanged();
		return path;
	}

	public void UploadToArtPage()
	{
		var path = SaveCurrent();
		SuperShotService.UploadToArtPage( path );
	}

	public async Task PostCurrentToAll()
	{
		var targets = SuperShotService.EnabledWebhooks( Settings.Share );
		if ( targets.Count == 0 )
		{
			Log.Warning( "[Supershot] No enabled Discord channels configured (Discord tab)." );
			return;
		}

		if ( !TryEncodeCurrent( out var bytes, out var name ) )
			return;

		SaveCurrent();

		int sent = await DiscordWebhook.PostImageToMany( targets, bytes, name, Settings.Share.Message, SuperShotService.MimeType( Settings.Output.Format ) );
		Log.Info( $"[Supershot] Posted to {sent}/{targets.Count} Discord channel(s)." );
	}

	public async Task<bool> PostCurrentToWebhook( DiscordWebhookConfig webhook )
	{
		if ( webhook is null || !webhook.IsConfigured )
		{
			Log.Warning( "[Supershot] That channel has no webhook URL set." );
			return false;
		}

		if ( !TryEncodeCurrent( out var bytes, out var name ) )
			return false;

		SaveCurrent();

		return await DiscordWebhook.PostImage( webhook.Url, bytes, name, webhook.ResolveMessage( Settings.Share.Message ), SuperShotService.MimeType( Settings.Output.Format ) );
	}

	bool TryEncodeCurrent( out byte[] bytes, out string filename )
	{
		bytes = null;
		filename = null;

		using var finished = BuildFinished();
		if ( finished is null )
		{
			Log.Warning( "[Supershot] Nothing captured to post." );
			return false;
		}

		bytes = SuperShotService.Encode( finished, Settings.Output.Format, Settings.Output.Quality );
		filename = $"supershot.{SuperShotService.Extension( Settings.Output.Format )}";
		return true;
	}

	void AddToGallery( Bitmap raw, EditSettings edit )
	{
		var shot = new CapturedShot
		{
			Raw = raw.Clone(),
			Edit = edit.Clone(),
			Size = raw.Size,
			Thumbnail = MakeThumbnail( raw )
		};
		Gallery.Add( shot );
		if ( Gallery.Count > 50 )
		{
			Gallery[0].Raw?.Dispose();
			Gallery.RemoveAt( 0 );
		}
	}

	static Pixmap MakeThumbnail( Bitmap source )
	{
		try
		{
			int w = 256;
			int h = Math.Max( 1, (int)(w * (source.Height / (float)source.Width)) );
			using var small = source.Resize( w, h );
			var pm = new Pixmap( w, h );
			pm.UpdateFromPixels( small );
			return pm;
		}
		catch
		{
			return null;
		}
	}

	public void RefreshGalleryFromDisk()
	{
		LoadSavedShotsIntoGallery();
		NotifyChanged();
	}

	void LoadSavedShotsIntoGallery()
	{
		try
		{
			var folder = Settings.Output.ResolveFolder();
			if ( string.IsNullOrEmpty( folder ) || !Directory.Exists( folder ) )
				return;

			var matches = new List<string>();
			foreach ( var file in Directory.GetFiles( folder ) )
			{
				var ext = Path.GetExtension( file ).ToLowerInvariant();
				if ( ext is ".png" or ".jpg" or ".jpeg" or ".webp" )
					matches.Add( file );
			}

			matches.Sort( ( a, b ) => File.GetLastWriteTime( a ).CompareTo( File.GetLastWriteTime( b ) ) );

			const int max = 30;
			int start = Math.Max( 0, matches.Count - max );

			for ( int i = start; i < matches.Count; i++ )
			{
				var file = matches[i];
				if ( GalleryContainsPath( file ) )
					continue;

				try
				{
					using var bmp = Bitmap.CreateFromBytes( File.ReadAllBytes( file ) );
					if ( bmp is null || !bmp.IsValid )
						continue;

					Gallery.Add( new CapturedShot
					{
						Raw = null,
						Edit = new EditSettings(),
						Size = bmp.Size,
						Thumbnail = MakeThumbnail( bmp ),
						SavedPath = file,
						Time = File.GetLastWriteTime( file )
					} );
				}
				catch
				{
				}
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Supershot] Couldn't scan the gallery folder: {e.Message}" );
		}
	}

	bool GalleryContainsPath( string path )
	{
		foreach ( var shot in Gallery )
		{
			if ( !string.IsNullOrEmpty( shot.SavedPath ) && string.Equals( shot.SavedPath, path, StringComparison.OrdinalIgnoreCase ) )
				return true;
		}
		return false;
	}

	public void LoadFromGallery( CapturedShot shot )
	{
		if ( shot is null )
			return;

		Bitmap raw = null;
		if ( shot.Raw is not null && shot.Raw.IsValid )
		{
			raw = shot.Raw.Clone();
		}
		else if ( !string.IsNullOrEmpty( shot.SavedPath ) && File.Exists( shot.SavedPath ) )
		{
			try { raw = Bitmap.CreateFromBytes( File.ReadAllBytes( shot.SavedPath ) ); }
			catch ( Exception e ) { Log.Warning( $"[Supershot] Couldn't open '{shot.SavedPath}': {e.Message}" ); }

			// Cache the decoded original so re-editing applies to these pixels, not a file we just baked edits into.
			if ( raw is not null && raw.IsValid && shot.Raw is null )
				shot.Raw = raw.Clone();
		}

		if ( raw is null || !raw.IsValid )
			return;

		RawCapture?.Dispose();
		RawCapture = raw;
		_editingShot = shot;
		CopyEdit( shot.Edit, Settings.Edit );
		_editBaseline = Settings.Edit.Clone();
		Settings.Save();
		NotifyChanged();
	}

	public void OpenInEditor( CapturedShot shot )
	{
		LoadFromGallery( shot );
		if ( !HasCapture )
			return;

		_reviewingGalleryShot = true;
		_editBaseline = Settings.Edit.Clone();

		DockManager.RaiseDock( "Edit" );
		DockManager.Update();
	}

	public void ReturnToLivePreview()
	{
		_reviewingGalleryShot = false;
		_editingShot = null;
		RawCapture?.Dispose();
		RawCapture = null;
		NotifyChanged();
	}

	public void LeaveEditReview()
	{
		if ( !_reviewingGalleryShot )
			return;

		_reviewingGalleryShot = false;

		if ( HasCapture && HasUnsavedEditChanges() )
		{
			Dialog.AskConfirm(
				() => { SaveCurrent(); ReturnToLivePreview(); },
				() => ReturnToLivePreview(),
				"Save your edits to this shot before returning to the live preview?",
				"Unsaved changes", "Save", "Discard" );
		}
		else
		{
			ReturnToLivePreview();
		}
	}

	public bool HasUnsavedEditChanges()
	{
		return _editBaseline is not null && !EditEquals( _editBaseline, Settings.Edit );
	}

	static bool EditEquals( EditSettings a, EditSettings b )
	{
		return a.Brightness == b.Brightness && a.Contrast == b.Contrast && a.Saturation == b.Saturation
			&& a.Exposure == b.Exposure && a.Hue == b.Hue && a.Filter == b.Filter
			&& a.Sharpen == b.Sharpen && a.Blur == b.Blur && a.Vignette == b.Vignette && a.Grain == b.Grain
			&& a.Rotate == b.Rotate && a.FlipH == b.FlipH && a.FlipV == b.FlipV
			&& a.Border == b.Border && a.BorderColor == b.BorderColor
			&& a.Watermark == b.Watermark && a.WatermarkText == b.WatermarkText
			&& a.WatermarkAnchor == b.WatermarkAnchor && a.WatermarkSize == b.WatermarkSize && a.WatermarkColor == b.WatermarkColor;
	}

	public void RemoveFromGallery( CapturedShot shot )
	{
		if ( shot is null ) return;
		shot.Raw?.Dispose();

		// Gallery is backed by the output folder, so deleting also removes the file (else it returns on reopen).
		if ( !string.IsNullOrEmpty( shot.SavedPath ) )
		{
			try { if ( File.Exists( shot.SavedPath ) ) File.Delete( shot.SavedPath ); }
			catch ( Exception e ) { Log.Warning( $"[Supershot] Couldn't delete '{shot.SavedPath}': {e.Message}" ); }
		}

		Gallery.Remove( shot );
		NotifyChanged();
	}

	protected override void RestoreDefaultDockLayout()
	{
		var capture = new CapturePanel( this );
		var preview = new PreviewPanel( this );
		var edit = new EditPanel( this );
		var gallery = new GalleryPanel( this );
		var discord = new SharePanel( this );
		var settings = new SettingsPanel( this );

		DockManager.Clear();
		DockManager.RegisterDockType( "Capture", "photo_camera", () => new CapturePanel( this ) );
		DockManager.RegisterDockType( "Preview", "image", () => new PreviewPanel( this ) );
		DockManager.RegisterDockType( "Edit", "tune", () => new EditPanel( this ) );
		DockManager.RegisterDockType( "Gallery", "collections", () => new GalleryPanel( this ) );
		DockManager.RegisterDockType( "Discord", "forum", () => new SharePanel( this ) );
		DockManager.RegisterDockType( "Settings", "settings", () => new SettingsPanel( this ) );

		DockManager.AddDock( null, preview, DockArea.Right, DockManager.DockProperty.HideOnClose, 0.62f );
		DockManager.AddDock( null, capture, DockArea.Left, DockManager.DockProperty.HideOnClose, 0.38f );
		DockManager.AddDock( capture, edit, DockArea.Inside, DockManager.DockProperty.HideOnClose );
		DockManager.AddDock( capture, discord, DockArea.Inside, DockManager.DockProperty.HideOnClose );
		DockManager.AddDock( capture, settings, DockArea.Inside, DockManager.DockProperty.HideOnClose );
		DockManager.AddDock( preview, gallery, DockArea.Bottom, DockManager.DockProperty.HideOnClose, 0.25f );

		DockManager.RaiseDock( "Capture" );
		DockManager.Update();

		RebuildMenuBar();
	}

	void RebuildMenuBar()
	{
		MenuBar.Clear();

		var file = MenuBar.AddMenu( "File" );
		file.AddOption( "Capture", "photo_camera", () => Capture() );
		file.AddOption( "Capture All Package Thumbnails", "burst_mode", () => SuperShotService.CaptureAllPackageThumbnails() );
		file.AddSeparator();
		file.AddOption( "Save", "save", () => SaveCurrent() );
		file.AddOption( "Copy Path", "content_copy", () => SuperShotService.CopyPathToClipboard( SaveCurrent() ) );
		file.AddOption( "Open Output Folder", "folder", () => SuperShotService.RevealInExplorer( Settings.Output.ResolveFolder() ) );
		file.AddSeparator();
		file.AddOption( new Option( "Close" ) { Triggered = Close } );

		var editMenu = MenuBar.AddMenu( "Edit" );
		editMenu.AddOption( "Undo", "undo", Undo );
		editMenu.AddOption( "Redo", "redo", Redo );
		editMenu.AddSeparator();
		editMenu.AddOption( "Reset Edits", "restart_alt", ResetEdit );

		var discord = MenuBar.AddMenu( "Discord" );
		discord.AboutToShow += () => OnDiscordMenu( discord );

		var view = MenuBar.AddMenu( "View" );
		view.AboutToShow += () => OnViewMenu( view );

		var help = MenuBar.AddMenu( "Help" );
		help.AddOption( "Upload to s&box Art Page", "image", UploadToArtPage );
		help.AddOption( "About Supershot", "info", () => EditorUtility.DisplayDialog( "Supershot",
			"Editor-only screenshot studio.\n\nCapture the scene-view freecam, edit, then save or share to Discord. Includes presets for YouTube, Steam and s&box package thumbnails (Square 512x512, Wide 910x512, Tall 512x910)." ) );
	}

	void OnDiscordMenu( Menu menu )
	{
		menu.Clear();

		var webhooks = Settings.Share.Webhooks;
		var configured = webhooks.FindAll( w => w is not null && w.IsConfigured );

		if ( configured.Count == 0 )
		{
			var none = menu.AddOption( "No channels configured", "link_off" );
			none.Enabled = false;
			menu.AddSeparator();
			menu.AddOption( "Open Discord Tab", "forum", () => DockManager.RaiseDock( "Discord" ) );
			return;
		}

		menu.AddOption( "Post current shot to all Discord Channels", "share", () => _ = PostCurrentToAll() );
		menu.AddOption( "Capture + Post to all Discord Channels", "burst_mode", () => _ = SuperShotService.CaptureAndPostAll() );

		var favorites = configured.FindAll( w => w.Favorite );
		if ( favorites.Count > 0 )
		{
			menu.AddSeparator();
			var heading = menu.AddOption( "Favorites", "star" );
			heading.Enabled = false;

			foreach ( var wh in favorites )
			{
				var target = wh;
				menu.AddOption( $"Post to {wh.Name}", "send", () => _ = PostCurrentToWebhook( target ) );
			}
		}

		menu.AddSeparator();

		foreach ( var wh in configured )
		{
			var sub = menu.AddMenu( wh.Name );
			var target = wh;
			sub.AddOption( "Post Current", "send", () => _ = PostCurrentToWebhook( target ) );
			sub.AddOption( "Capture + Post", "photo_camera", () => _ = SuperShotService.CaptureAndPostTo( target ) );
			sub.AddSeparator();

			var favOpt = sub.AddOption( "Favorite" );
			favOpt.Checkable = true;
			favOpt.Checked = target.Favorite;
			favOpt.Toggled += ( b ) => { target.Favorite = b; Settings.Save(); };
		}
	}

	void OnViewMenu( Menu view )
	{
		view.Clear();
		view.AddOption( "Restore To Default", "settings_backup_restore", RestoreDefaultDockLayout );
		view.AddSeparator();

		var live = view.AddOption( "Live Preview" );
		live.Checkable = true;
		live.Checked = Settings.LivePreview;
		live.Toggled += ( b ) =>
		{
			Settings.LivePreview = b;
			Settings.Save();
			NotifyChanged();
		};
		view.AddSeparator();

		foreach ( var dock in DockManager.DockTypes )
		{
			var o = view.AddOption( dock.Title, dock.Icon );
			o.Checkable = true;
			o.Checked = DockManager.IsDockOpen( dock.Title );
			o.Toggled += ( b ) => DockManager.SetDockState( dock.Title, b );
		}
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();

		if ( _duplicate )
			return;

		if ( _instance == this )
			_instance = null;

		Settings.Save();
		RawCapture?.Dispose();
		foreach ( var s in Gallery )
			s.Raw?.Dispose();
	}
}