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