Editor/Prism/Text/FindReplaceBar.cs

A UI widget providing a find-and-replace bar for the code editor. It handles searching (literal and regex), match highlighting, navigation between matches, single replace and replace-all (in one undo step), options for case, whole-word and search-in-selection, and updates the editor with match ranges.

Reflection
using Editor.Prism.Core;
using Editor.Prism.Ui;
using Margin = Sandbox.UI.Margin;
using System.Text.RegularExpressions;

namespace Editor.Prism.Text;

/// <summary>
/// The find and replace strip that sits above a <see cref="CodeEditorWidget"/>. Supports literal and
/// regular-expression search, case and whole-word constraints, searching inside the current
/// selection, live match highlighting and replace-all in a single undo step.
/// </summary>
public sealed class FindReplaceBar : Widget
{
	/// <summary>One search hit and the text that would replace it.</summary>
	readonly record struct Found( TextRange Range, string Replacement );

	sealed class SearchField : LineEdit
	{
		readonly FindReplaceBar _bar;
		readonly bool _isReplace;

		public SearchField( FindReplaceBar bar, bool isReplace ) : base( bar )
		{
			_bar = bar;
			_isReplace = isReplace;
		}

		protected override void OnKeyPress( KeyEvent e )
		{
			if ( e.Key == KeyCode.Escape )
			{
				_bar.Dismiss();
				e.Accepted = true;
				return;
			}

			if ( e.Key is KeyCode.Return or KeyCode.Enter )
			{
				if ( _isReplace && !e.HasCtrl )
					_bar.ReplaceCurrent();
				else if ( _isReplace )
					_bar.ReplaceAll();
				else
					_bar.FindNext( !e.HasShift );

				e.Accepted = true;
				return;
			}

			if ( e.Key == KeyCode.F3 )
			{
				_bar.FindNext( !e.HasShift );
				e.Accepted = true;
				return;
			}

			base.OnKeyPress( e );
		}
	}

	readonly List<Found> _matches = new();

	readonly Widget _findRow;
	readonly Widget _replaceRow;
	readonly SearchField _findField;
	readonly SearchField _replaceField;
	readonly Label _status;
	readonly IconButton _caseButton;
	readonly IconButton _wordButton;
	readonly IconButton _regexButton;
	readonly IconButton _scopeButton;

	TextRange _scope = TextRange.Empty;
	bool _dirty = true;
	bool _patternValid = true;

	/// <summary>Creates a find bar. It starts hidden.</summary>
	public FindReplaceBar( Widget parent = null ) : base( parent )
	{
		Layout = Layout.Column();
		Layout.Margin = new Margin( 6, 4, 6, 4 );
		Layout.Spacing = 4;

		_findRow = new Widget( this );
		_findRow.Layout = Layout.Row();
		_findRow.Layout.Spacing = 4;
		Layout.Add( _findRow );

		_findField = new SearchField( this, false ) { PlaceholderText = "Find" };
		_findField.MinimumWidth = 160;
		_findField.TextEdited += _ => { _dirty = true; Refresh(); };
		_findRow.Layout.Add( _findField, 1 );

		_status = new Label( "", _findRow ) { Color = PrismTheme.TextMuted };
		_status.MinimumWidth = 84;
		_findRow.Layout.Add( _status );

		_caseButton = MakeToggle( _findRow, "match_case", "Match case", value => { MatchCase = value; } );
		_wordButton = MakeToggle( _findRow, "abc", "Whole word", value => { WholeWord = value; } );
		_regexButton = MakeToggle( _findRow, "regular_expression", "Regular expression", value => { UseRegex = value; } );
		_scopeButton = MakeToggle( _findRow, "select_all", "Find in selection", value => { InSelection = value; } );

		_findRow.Layout.Add( new IconButton( "keyboard_arrow_up", () => FindNext( false ), _findRow )
		{
			ToolTip = "Previous match",
			Background = PrismTheme.PanelAlt,
			Foreground = PrismTheme.TextSecondary
		} );

		_findRow.Layout.Add( new IconButton( "keyboard_arrow_down", () => FindNext(), _findRow )
		{
			ToolTip = "Next match",
			Background = PrismTheme.PanelAlt,
			Foreground = PrismTheme.TextSecondary
		} );

		_findRow.Layout.Add( new IconButton( "close", Dismiss, _findRow )
		{
			ToolTip = "Close",
			Background = PrismTheme.PanelAlt,
			Foreground = PrismTheme.TextSecondary
		} );

		_replaceRow = new Widget( this );
		_replaceRow.Layout = Layout.Row();
		_replaceRow.Layout.Spacing = 4;
		Layout.Add( _replaceRow );

		_replaceField = new SearchField( this, true ) { PlaceholderText = "Replace with" };
		_replaceField.MinimumWidth = 160;
		_replaceField.TextEdited += _ => { _dirty = true; Refresh(); };
		_replaceRow.Layout.Add( _replaceField, 1 );

		var replaceOne = new Button( "Replace", _replaceRow );
		replaceOne.Clicked = () => ReplaceCurrent();
		_replaceRow.Layout.Add( replaceOne );

		var replaceAll = new Button( "Replace All", _replaceRow );
		replaceAll.Clicked = () => ReplaceAll();
		_replaceRow.Layout.Add( replaceAll );

		_replaceRow.Visible = false;
		Visible = false;
		FixedHeight = 34;
	}

	/// <summary>Raised when the bar closes, so the window can return focus to the editor.</summary>
	public event Action Closed;

	/// <summary>The editor this bar searches.</summary>
	public CodeEditorWidget Editor { get; set; }

	/// <summary>Whether the replace row is showing.</summary>
	public bool ReplaceVisible
	{
		get => _replaceRow.Visible;
		set
		{
			_replaceRow.Visible = value;
			FixedHeight = value ? 64 : 34;
		}
	}

	/// <summary>Whether the search is case sensitive.</summary>
	public bool MatchCase
	{
		get => _caseButton.IsActive;
		set { _caseButton.IsActive = value; _dirty = true; Refresh(); }
	}

	/// <summary>Whether only whole words match.</summary>
	public bool WholeWord
	{
		get => _wordButton.IsActive;
		set { _wordButton.IsActive = value; _dirty = true; Refresh(); }
	}

	/// <summary>Whether the search text is a .NET regular expression.</summary>
	public bool UseRegex
	{
		get => _regexButton.IsActive;
		set { _regexButton.IsActive = value; _dirty = true; Refresh(); }
	}

	/// <summary>Whether the search is limited to the selection captured when the bar opened.</summary>
	public bool InSelection
	{
		get => _scopeButton.IsActive;
		set { _scopeButton.IsActive = value; _dirty = true; Refresh(); }
	}

	/// <summary>The search pattern.</summary>
	public string SearchText
	{
		get => _findField.Text ?? string.Empty;
		set { _findField.Text = value ?? string.Empty; _dirty = true; Refresh(); }
	}

	/// <summary>The replacement text, or a regex substitution when <see cref="UseRegex"/> is set.</summary>
	public string ReplaceText
	{
		get => _replaceField.Text ?? string.Empty;
		set { _replaceField.Text = value ?? string.Empty; _dirty = true; Refresh(); }
	}

	/// <summary>Number of matches currently found.</summary>
	public int MatchCount => _matches.Count;

	/// <summary>Index of the match nearest the caret, or -1.</summary>
	public int CurrentIndex { get; private set; } = -1;

	/// <summary>Shows the bar, seeding it from the editor's selection when there is one.</summary>
	public void Open( bool replace, string initialText = null )
	{
		ReplaceVisible = replace;
		Visible = true;

		var seed = initialText;

		if ( string.IsNullOrEmpty( seed ) && Editor is not null )
		{
			var primary = Editor.Selection.Primary;

			if ( primary.HasSelection && primary.Selection.IsSingleLine )
				seed = Editor.Document.GetText( primary.Selection );
			else
				seed = Editor.Controller.WordAt( primary.Position );
		}

		if ( !string.IsNullOrEmpty( seed ) )
			_findField.Text = seed;

		CaptureScope();

		_dirty = true;
		Refresh();

		_findField.Focus();
		_findField.SelectAll();
	}

	/// <summary>Hides the bar and clears the editor's match highlights.</summary>
	public void Dismiss()
	{
		Visible = false;
		_matches.Clear();
		Editor?.SetSearchMatches( null );
		Editor?.Focus();
		Closed?.Invoke();
	}

	/// <summary>Captures the current selection as the in-selection search scope.</summary>
	public void CaptureScope()
	{
		if ( Editor is null )
		{
			_scope = TextRange.Empty;
			return;
		}

		var primary = Editor.Selection.Primary;
		_scope = primary.HasSelection ? primary.Selection : TextRange.Empty;

		if ( _scope.IsEmpty || _scope.IsSingleLine )
			_scopeButton.IsActive = false;
	}

	/// <summary>Recomputes the match set and pushes it to the editor.</summary>
	public void Refresh()
	{
		if ( Editor is null || !Visible )
			return;

		if ( _dirty )
		{
			_dirty = false;
			Rebuild();
		}

		var ranges = new List<TextRange>( _matches.Count );

		for ( var i = 0; i < _matches.Count; i++ )
			ranges.Add( _matches[i].Range );

		CurrentIndex = IndexNearCaret();
		Editor.SetSearchMatches( ranges, CurrentIndex );

		if ( !_patternValid )
		{
			_status.Text = "bad pattern";
			_status.Color = PrismTheme.Error;
			return;
		}

		_status.Color = PrismTheme.TextMuted;
		_status.Text = _matches.Count == 0
			? "no results"
			: $"{Math.Max( 1, CurrentIndex + 1 )} of {_matches.Count}";
	}

	/// <summary>Selects the next (or previous) match, wrapping around the document.</summary>
	public bool FindNext( bool forward = true )
	{
		if ( Editor is null )
			return false;

		if ( _dirty )
		{
			_dirty = false;
			Rebuild();
		}

		if ( _matches.Count == 0 )
		{
			Refresh();
			return false;
		}

		var caret = Editor.Selection.Primary;
		var from = forward ? caret.Selection.End : caret.Selection.Start;

		var index = -1;

		if ( forward )
		{
			for ( var i = 0; i < _matches.Count; i++ )
			{
				if ( _matches[i].Range.Start >= from )
				{
					index = i;
					break;
				}
			}

			if ( index < 0 )
				index = 0;
		}
		else
		{
			for ( var i = _matches.Count - 1; i >= 0; i-- )
			{
				if ( _matches[i].Range.End <= from )
				{
					index = i;
					break;
				}
			}

			if ( index < 0 )
				index = _matches.Count - 1;
		}

		CurrentIndex = index;
		Editor.Reveal( _matches[index].Range, true, 4 );
		Refresh();
		return true;
	}

	/// <summary>Replaces the current match and moves to the next one.</summary>
	public bool ReplaceCurrent()
	{
		if ( Editor is null || Editor.ReadOnly )
			return false;

		if ( _dirty )
		{
			_dirty = false;
			Rebuild();
		}

		if ( _matches.Count == 0 )
			return false;

		var caret = Editor.Selection.Primary;
		var index = -1;

		for ( var i = 0; i < _matches.Count; i++ )
		{
			if ( _matches[i].Range.Normalized == caret.Selection.Normalized )
			{
				index = i;
				break;
			}
		}

		if ( index < 0 )
			return FindNext();

		var found = _matches[index];

		if ( !Editor.Controller.ReplaceRange( found.Range, found.Replacement, "Replace" ) )
			return false;

		_dirty = true;
		Refresh();
		FindNext();
		return true;
	}

	/// <summary>Replaces every match in one undo step. Returns how many were replaced.</summary>
	public int ReplaceAll()
	{
		if ( Editor is null || Editor.ReadOnly )
			return 0;

		_dirty = false;
		Rebuild();

		if ( _matches.Count == 0 )
			return 0;

		var ranges = new List<TextRange>( _matches.Count );
		var replacements = new Dictionary<TextRange, string>();

		for ( var i = 0; i < _matches.Count; i++ )
		{
			ranges.Add( _matches[i].Range );
			replacements[_matches[i].Range] = _matches[i].Replacement;
		}

		var count = ranges.Count;

		if ( !Editor.Controller.ReplaceRanges( ranges, range => replacements.TryGetValue( range, out var value ) ? value : string.Empty, "Replace All" ) )
			return 0;

		_dirty = true;
		Refresh();
		return count;
	}

	// ---- internals --------------------------------------------------------

	IconButton MakeToggle( Widget row, string icon, string tooltip, Action<bool> onToggled )
	{
		var button = new IconButton( icon, null, row )
		{
			IsToggle = true,
			ToolTip = tooltip,
			Background = PrismTheme.PanelAlt,
			BackgroundActive = PrismTheme.AccentSoft,
			Foreground = PrismTheme.TextMuted,
			ForegroundActive = PrismTheme.Accent
		};

		button.OnToggled = _ =>
		{
			onToggled?.Invoke( button.IsActive );
			_dirty = true;
			Refresh();
		};

		row.Layout.Add( button );
		return button;
	}

	int IndexNearCaret()
	{
		if ( Editor is null || _matches.Count == 0 )
			return -1;

		var caret = Editor.Selection.Primary.Position;

		for ( var i = 0; i < _matches.Count; i++ )
		{
			if ( _matches[i].Range.ContainsInclusive( caret ) )
				return i;
		}

		for ( var i = 0; i < _matches.Count; i++ )
		{
			if ( _matches[i].Range.Start >= caret )
				return i;
		}

		return _matches.Count - 1;
	}

	void Rebuild()
	{
		_matches.Clear();
		_patternValid = true;

		if ( Editor is null )
			return;

		var pattern = SearchText;

		if ( string.IsNullOrEmpty( pattern ) )
			return;

		var document = Editor.Document;

		var firstLine = 0;
		var lastLine = document.LineCount - 1;
		var scope = _scope.Normalized;
		var scoped = InSelection && !scope.IsEmpty;

		if ( scoped )
		{
			firstLine = Math.Clamp( scope.Start.Line, 0, lastLine );
			lastLine = Math.Clamp( scope.End.Line, firstLine, lastLine );
		}

		Regex regex = null;

		if ( UseRegex || WholeWord )
		{
			var expression = UseRegex ? pattern : Regex.Escape( pattern );

			if ( WholeWord )
				expression = $@"\b(?:{expression})\b";

			var options = RegexOptions.CultureInvariant;

			if ( !MatchCase )
				options |= RegexOptions.IgnoreCase;

			try
			{
				regex = new Regex( expression, options, TimeSpan.FromMilliseconds( 250 ) );
			}
			catch ( Exception )
			{
				_patternValid = false;
				return;
			}
		}

		var comparison = MatchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
		var replacement = ReplaceText;

		for ( var line = firstLine; line <= lastLine; line++ )
		{
			var text = document.GetLine( line );

			if ( text.Length == 0 )
				continue;

			if ( regex is not null )
			{
				try
				{
					foreach ( Match match in regex.Matches( text ) )
					{
						if ( match.Length == 0 )
							continue;

						var range = new TextRange(
							new TextPosition( line, match.Index ),
							new TextPosition( line, match.Index + match.Length ) );

						if ( !InScope( range, scope, scoped ) )
							continue;

						var value = UseRegex
							? SafeResult( match, replacement )
							: replacement;

						_matches.Add( new Found( range, value ) );
					}
				}
				catch ( RegexMatchTimeoutException )
				{
					_patternValid = false;
					PrismLog.Warn( "Prism.Text: search pattern timed out" );
					return;
				}

				continue;
			}

			var index = 0;

			while ( index <= text.Length - pattern.Length )
			{
				var found = text.IndexOf( pattern, index, comparison );

				if ( found < 0 )
					break;

				var range = new TextRange(
					new TextPosition( line, found ),
					new TextPosition( line, found + pattern.Length ) );

				if ( InScope( range, scope, scoped ) )
					_matches.Add( new Found( range, replacement ) );

				index = found + Math.Max( 1, pattern.Length );
			}
		}
	}

	static bool InScope( TextRange range, TextRange scope, bool scoped )
	{
		if ( !scoped )
			return true;

		return range.Start >= scope.Start && range.End <= scope.End;
	}

	static string SafeResult( Match match, string replacement )
	{
		if ( string.IsNullOrEmpty( replacement ) )
			return string.Empty;

		try
		{
			return match.Result( replacement );
		}
		catch ( Exception )
		{
			return replacement;
		}
	}

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( PrismTheme.PanelAlt );
		Paint.DrawRect( LocalRect );

		Paint.SetPen( PrismTheme.BorderSubtle, 1f );
		Paint.DrawLine( new Vector2( 0f, LocalRect.Bottom - 0.5f ), new Vector2( LocalRect.Right, LocalRect.Bottom - 0.5f ) );
	}
}