editor/AdvancedMappingTools/UI/PresetPanel.cs

Editor-side presets UI for the Advanced Mapping Tools tools. Lists saved presets for the current tool, applies, renames, duplicates, deletes, searches, saves, exports/imports JSON and resets to defaults, plus a static clipboard for copying settings between tools.

namespace AdvancedMappingTools;

/// <summary>
/// Settings copied from one tool, to paste into the same tool or another one. Only values whose
/// name and type the other tool has go across; a part tool's generator settings only into a tool
/// of the same kind.
/// </summary>
public static class ToolSettingsClipboard
{
	public static string Tool { get; private set; }
	public static string ToolTitle { get; private set; }
	static Dictionary<string, string> _values;
	static Dictionary<string, string> _types;

	public static bool Has => _values is not null;

	public static void Copy( BuilderEditor editor )
	{
		Tool = editor.GetType().Name;
		ToolTitle = editor.Title;
		_values = editor.CaptureSettings();
		_types = editor.SettingTypes();
	}

	/// <summary>
	/// Puts what fits into the editor. Returns how many values went across and how many were copied.
	/// </summary>
	public static (int Pasted, int Copied) PasteInto( BuilderEditor editor )
	{
		if ( !Has ) return (0, 0);

		var sameTool = editor.GetType().Name == Tool;
		var targetTypes = editor.SettingTypes();
		var fitting = _values
			.Where( x => sameTool || (_types.TryGetValue( x.Key, out var type ) && targetTypes.TryGetValue( x.Key, out var target ) && type == target) )
			.ToDictionary( x => x.Key, x => x.Value );

		editor.ApplySettings( fitting );
		return (fitting.Count, _values.Count);
	}
}

/// <summary>
/// The presets of a tool as a list: a click applies one, the buttons on a row rename, copy or
/// delete it. The preset the current settings match is marked. Below: save, copy and paste of
/// settings, export and import, and reset.
/// </summary>
public sealed class PresetPanel : Widget
{
	readonly BuilderEditor _editor;
	readonly Action _applied;
	readonly Widget _list;
	readonly LineEdit _search;

	string TypeName => _editor.GetType().Name;

	public PresetPanel( Widget parent, BuilderEditor editor, Action applied ) : base( parent )
	{
		_editor = editor;
		_applied = applied;

		Layout = Layout.Column();
		Layout.Spacing = 4;
		Layout.Margin = new Sandbox.UI.Margin( 4, 0, 4, 0 );

		_search = Layout.Add( new LineEdit( this ) { PlaceholderText = "Find a preset…", ClearButtonEnabled = true } );
		_search.TextEdited += _ => Refresh();

		_list = Layout.Add( new Widget( this ) { Layout = Layout.Column() } );
		_list.Layout.Spacing = 1;

		var save = Layout.AddRow();
		save.Spacing = 4;
		save.Add( new ListButton( "Save As Preset", "bookmark_add" ) { Clicked = Save, ToolTip = "Keep the current settings under a name." }, 1 );

		var clip = Layout.AddRow();
		clip.Spacing = 4;
		clip.Add( new ListButton( "Copy Settings", "content_copy" ) { Clicked = Copy, ToolTip = "Copy every setting of this tool, to paste here later or into another tool." }, 1 );
		clip.Add( new ListButton( "Paste", "content_paste" ) { Clicked = Paste, ToolTip = "Take over the copied settings. From another tool only the settings this tool has as well." }, 1 );

		var files = Layout.AddRow();
		files.Spacing = 4;
		files.Add( new ListButton( "Export", "upload_file" ) { Clicked = Export, ToolTip = "Save the presets of this tool to a file, to hand to someone else." }, 1 );
		files.Add( new ListButton( "Import", "file_open" ) { Clicked = Import, ToolTip = "Add the presets from a file. Names that exist already get a number." }, 1 );

		Layout.Add( new ListButton( "Reset To Defaults", "restart_alt" ) { Clicked = Reset, ToolTip = "Every setting of this tool back to how it came. Saved presets stay." } );

		Refresh();
	}

	Dictionary<string, Dictionary<string, string>> Presets => BuilderStore.LoadPresets( TypeName );

	/// <summary>
	/// True when every value the preset holds is what the tool has now.
	/// </summary>
	bool InUse( Dictionary<string, string> preset )
	{
		var current = _editor.CaptureSettings();
		return preset.All( x => current.TryGetValue( x.Key, out var value ) && value == x.Value );
	}

	public void Refresh()
	{
		_list.Layout.Clear( true );

		var presets = Presets;
		_search.Visible = presets.Count >= 6;
		var filter = _search.Text?.Trim() ?? "";
		var shown = presets.Where( x => filter == "" || x.Key.Contains( filter, StringComparison.OrdinalIgnoreCase ) ).OrderBy( x => x.Key ).ToList();

		if ( presets.Count == 0 )
			_list.Layout.Add( new Label( "No presets yet. Save the current settings to come back to them.", _list ) { WordWrap = true, Color = Theme.TextLight } );
		else if ( shown.Count == 0 )
			_list.Layout.Add( new Label( $"No preset is called “{filter}”.", _list ) { Color = Theme.TextLight } );

		foreach ( var (name, settings) in shown )
		{
			var preset = name;
			_list.Layout.Add( new PresetRow( _list, name, InUse( settings ) )
			{
				Applied = () => Apply( preset ),
				Renamed = () => Rename( preset ),
				Duplicated = () => Duplicate( preset ),
				Deleted = () => Delete( preset )
			} );
		}
	}

	void Apply( string name )
	{
		if ( !Presets.TryGetValue( name, out var settings ) ) return;
		_editor.ApplySettings( settings );
		_applied?.Invoke();
	}

	void Save()
	{
		Dialog.AskString( name =>
		{
			name = name?.Trim();
			if ( string.IsNullOrEmpty( name ) ) return;

			var presets = Presets;
			if ( presets.ContainsKey( name ) )
			{
				Dialog.AskConfirm( () => Store( name ), $"A preset called “{name}” exists already. Replace it with the current settings?", "Replace preset", "Replace" );
				return;
			}

			Store( name );
		}, "Name of this preset", "Save", initialName: $"{_editor.Title} {Presets.Count + 1}", title: "Save preset", minLength: 1 );
	}

	void Store( string name )
	{
		var presets = Presets;
		presets[name] = _editor.CaptureSettings();
		BuilderStore.SavePresets( TypeName, presets );
		Refresh();
	}

	void Rename( string name )
	{
		Dialog.AskString( newName =>
		{
			newName = newName?.Trim();
			var presets = Presets;
			if ( string.IsNullOrEmpty( newName ) || newName == name || !presets.TryGetValue( name, out var settings ) ) return;
			if ( presets.ContainsKey( newName ) )
			{
				Log.Warning( $"{_editor.Title}: a preset called {newName} exists already." );
				return;
			}

			presets.Remove( name );
			presets[newName] = settings;
			BuilderStore.SavePresets( TypeName, presets );
			Refresh();
		}, "New name", "Rename", initialName: name, title: "Rename preset", minLength: 1 );
	}

	void Duplicate( string name )
	{
		var presets = Presets;
		if ( !presets.TryGetValue( name, out var settings ) ) return;

		var copy = $"{name} copy";
		for ( var i = 2; presets.ContainsKey( copy ); i++ ) copy = $"{name} copy {i}";

		presets[copy] = new Dictionary<string, string>( settings );
		BuilderStore.SavePresets( TypeName, presets );
		Refresh();
	}

	void Delete( string name )
	{
		Dialog.AskConfirm( () =>
		{
			var presets = Presets;
			if ( !presets.Remove( name ) ) return;
			BuilderStore.SavePresets( TypeName, presets );
			Refresh();
		}, $"Delete the preset “{name}”? This cannot be undone.", "Delete preset", "Delete" );
	}

	void Copy()
	{
		ToolSettingsClipboard.Copy( _editor );
		Log.Info( $"{_editor.Title}: settings copied." );
	}

	void Paste()
	{
		if ( !ToolSettingsClipboard.Has )
		{
			Log.Info( "No settings copied yet. Use Copy Settings in a tool first." );
			return;
		}

		var (pasted, copied) = ToolSettingsClipboard.PasteInto( _editor );
		Log.Info( ToolSettingsClipboard.Tool == TypeName
			? $"{_editor.Title}: settings pasted."
			: $"{_editor.Title}: {pasted} of {copied} settings from {ToolSettingsClipboard.ToolTitle} fit and were pasted." );
		_applied?.Invoke();
	}

	void Export()
	{
		var dialog = new FileDialog( null );
		dialog.Title = $"Export {_editor.Title} Presets";
		dialog.Directory = Environment.GetFolderPath( Environment.SpecialFolder.MyDocuments );
		dialog.DefaultSuffix = ".json";
		dialog.SelectFile( $"{_editor.Title} Presets.json" );
		dialog.SetFindFile();
		dialog.SetModeSave();
		dialog.SetNameFilter( "Presets (*.json)" );

		if ( !dialog.Execute() ) return;

		var count = BuilderStore.ExportPresets( TypeName, dialog.SelectedFile, _editor.CaptureSettings() );
		Log.Info( $"{count} {_editor.Title} preset(s) saved to {dialog.SelectedFile}" );
	}

	void Import()
	{
		var dialog = new FileDialog( null );
		dialog.Title = $"Import {_editor.Title} Presets";
		dialog.Directory = Environment.GetFolderPath( Environment.SpecialFolder.MyDocuments );
		dialog.SetFindFile();
		dialog.SetModeOpen();
		dialog.SetNameFilter( "Presets (*.json)" );

		if ( !dialog.Execute() ) return;

		var (added, problem) = BuilderStore.ImportPresets( TypeName, dialog.SelectedFile );

		if ( problem is not null )
		{
			Log.Warning( $"{_editor.Title}: {problem}" );
			return;
		}

		Log.Info( $"{added} {_editor.Title} preset(s) added" );
		Refresh();
	}

	void Reset()
	{
		Dialog.AskConfirm( () =>
		{
			_editor.ApplySettings( BuilderEditor.Defaults( _editor.GetType() ) );
			_applied?.Invoke();
		}, $"Set every setting of {_editor.Title} back to its default? Saved presets stay as they are." );
	}

	/// <summary>
	/// One preset: its name, a mark when the settings match it, and on hover rename, duplicate and delete.
	/// </summary>
	sealed class PresetRow : Widget
	{
		const float Button = 24.0f;
		static readonly string[] Icons = ["edit", "content_copy", "delete"];
		static readonly string[] Tips = ["Rename", "Duplicate", "Delete"];

		readonly string _name;
		readonly bool _inUse;
		Vector2 _mouse;

		public Action Applied { get; set; }
		public Action Renamed { get; set; }
		public Action Duplicated { get; set; }
		public Action Deleted { get; set; }

		public PresetRow( Widget parent, string name, bool inUse ) : base( parent )
		{
			_name = name;
			_inUse = inUse;
			FixedHeight = 28;
		}

		int HoveredButton => IsUnderMouse && _mouse.x > Width - Button * Icons.Length ? (int)((_mouse.x - (Width - Button * Icons.Length)) / Button) : -1;

		protected override void OnPaint()
		{
			var rect = LocalRect;
			Paint.ClearPen();
			if ( _inUse )
			{
				Paint.SetBrush( Theme.Primary.WithAlpha( 0.22f ) );
				Paint.DrawRect( rect, 4 );
				Paint.SetBrush( Theme.Primary );
				Paint.DrawRect( new Rect( 0, 4, 3, rect.Height - 8 ), 1.5f );
			}
			else if ( IsUnderMouse )
			{
				Paint.SetBrush( Theme.SurfaceBackground.Lighten( 0.15f ) );
				Paint.DrawRect( rect, 4 );
			}

			Paint.SetPen( _inUse ? Theme.Primary.Lighten( 0.4f ) : Theme.TextLight );
			Paint.DrawIcon( new Rect( 8, 0, 18, rect.Height ), _inUse ? "bookmark" : "bookmark_border", 15, TextFlag.Center );

			var right = IsUnderMouse ? rect.Width - Button * Icons.Length - 4 : rect.Width - 8;
			if ( _inUse && !IsUnderMouse )
			{
				Paint.SetDefaultFont( 7, 400, false, false );
				Paint.SetPen( Theme.Primary.Lighten( 0.35f ) );
				Paint.DrawText( new Rect( rect.Width - 60, 0, 52, rect.Height ), "in use", TextFlag.RightCenter | TextFlag.SingleLine );
				right -= 56;
			}

			Paint.SetPen( Theme.Text );
			Paint.SetDefaultFont( 8, _inUse ? 600 : 400, false, false );
			var text = new Rect( 32, 0, MathF.Max( right - 32, 10 ), rect.Height );
			Paint.DrawText( text, Paint.GetElidedText( _name, text.Width, ElideMode.Right, TextFlag.SingleLine ), TextFlag.LeftCenter | TextFlag.SingleLine );

			if ( !IsUnderMouse ) return;
			var hovered = HoveredButton;
			for ( var i = 0; i < Icons.Length; i++ )
			{
				var box = new Rect( rect.Width - Button * (Icons.Length - i), 2, Button, rect.Height - 4 );
				if ( i == hovered )
				{
					Paint.ClearPen();
					Paint.SetBrush( Theme.SurfaceBackground.Lighten( 0.3f ) );
					Paint.DrawRect( box, 4 );
				}
				Paint.SetPen( i == hovered ? (i == 2 ? Theme.Red : Theme.Text) : Theme.TextLight );
				Paint.DrawIcon( box, Icons[i], 14, TextFlag.Center );
			}
		}

		protected override void OnMouseEnter() => Update();
		protected override void OnMouseLeave() => Update();

		protected override void OnMouseMove( MouseEvent e )
		{
			_mouse = e.LocalPosition;
			var b = HoveredButton;
			ToolTip = b >= 0 ? Tips[b] : $"Apply {_name}";
			Update();
		}

		protected override void OnMousePress( MouseEvent e )
		{
			if ( !e.LeftMouseButton ) return;
			_mouse = e.LocalPosition;

			switch ( HoveredButton )
			{
				case 0: Renamed?.Invoke(); break;
				case 1: Duplicated?.Invoke(); break;
				case 2: Deleted?.Invoke(); break;
				default: Applied?.Invoke(); break;
			}
		}
	}
}