using System; using System.Linq; using Unity.CodeEditor.Document; using Unity.CodeEditor.Editing; using Unity.CodeEditor.Rendering; using Unity.PlasticSCM.Editor.UI.UIElements; using UnityEditor; using UnityEngine; using UnityEngine.UIElements; using Selection = Unity.CodeEditor.Editing.Selection; using TextAnchor = UnityEngine.TextAnchor; namespace Unity.CodeEditor.Search { internal class SearchPanel : VisualElement { readonly TextEditor _textEditor; readonly TextArea _textArea; readonly SearchResultBackgroundRenderer _renderer; TextField _searchTextField; TextField _replaceTextField; Label _searchCounterLabel; Button _matchCaseButton; Button _wholeWordButton; Button _regexButton; Button _previousButton; Button _nextButton; Button _closeButton; Button _replaceNextButton; Button _replaceAllButton; VisualElement _replaceRow; VisualElement _messageRow; Button _expandReplaceButton; ISearchStrategy _strategy; int _currentSearchResultIndex = -1; static readonly EventCallback _onButtonMouseEnter = e => ((VisualElement)e.currentTarget).style.backgroundColor = StyleKeyword.Null; static readonly EventCallback _onButtonMouseLeave = e => ((VisualElement)e.currentTarget).style.backgroundColor = Color.clear; bool _matchCase; bool _wholeWords; bool _useRegex; bool _isReplaceMode; const int SEARCH_FIELD_FONT_SIZE = 11; const int BUTTON_FONT_SIZE = 11; const int SEARCH_FIELD_WIDTH = 215; internal bool IsClosed { get; private set; } = true; internal bool IsOpened => !IsClosed; internal string SearchPattern { get => _searchTextField?.value ?? string.Empty; set { if (_searchTextField != null) _searchTextField.value = value ?? string.Empty; } } internal string ReplacePattern { get => _replaceTextField?.value ?? string.Empty; } internal bool IsReplaceMode { get => _isReplaceMode; set { if (_textEditor.IsReadOnly) value = false; _isReplaceMode = value; UpdateReplaceModeVisibility(); } } internal event EventHandler SearchOptionsChanged; internal SearchPanel(TextEditor textEditor) { _textEditor = textEditor ?? throw new ArgumentNullException(nameof(textEditor)); _textArea = textEditor.TextArea; _renderer = new SearchResultBackgroundRenderer(TextEditorColors.SearchResultColor); BuildUI(); RegisterCallback(OnKeyDown, TrickleDown.TrickleDown); IsClosed = true; style.display = DisplayStyle.None; } internal void Dispose() { UnregisterCallback(OnKeyDown, TrickleDown.TrickleDown); _searchTextField.UnregisterValueChangedCallback(OnSearchTextChanged); _textArea.DocumentChanged -= OnDocumentChanged; if (_textArea.Document != null) _textArea.Document.TextChanged -= OnDocumentTextChanged; _textArea.TextView.BackgroundRenderers.Remove(_renderer); } internal void Open() { if (!IsClosed) { Reactivate(); return; } IsClosed = false; style.display = DisplayStyle.Flex; BringToFront(); _expandReplaceButton.style.display = _textEditor.IsReadOnly ? DisplayStyle.None : DisplayStyle.Flex; if (!_textArea.TextView.BackgroundRenderers.Contains(_renderer)) _textArea.TextView.BackgroundRenderers.Add(_renderer); if (!(_textArea.Selection.IsEmpty || _textArea.Selection.IsMultiline)) SearchPattern = _textArea.Selection.GetText(); DoSearch(true); Reactivate(); } internal void Close() { if (IsClosed) return; IsClosed = true; style.display = DisplayStyle.None; if (_messageRow != null) _messageRow.style.display = DisplayStyle.None; _textArea.TextView.BackgroundRenderers.Remove(_renderer); CleanSearchResults(); _textArea.TextView.InvalidateLayer(KnownLayer.Selection); schedule.Execute(() => _textArea.Focus()); } internal void Reactivate() { if (_searchTextField == null) return; _searchTextField.Focus(); _searchTextField.SelectAll(); } internal void FindNext(int startOffset = -1) { var result = _renderer.CurrentResults.FindFirstSegmentWithStartAfter( startOffset == -1 ? _textArea.Caret.Offset : startOffset) ?? _renderer.CurrentResults.FirstSegment; if (result != null) SetCurrentSearchResult(result); } internal void FindPrevious() { int offset = Math.Max( _textArea.Caret.Offset - _textArea.Selection.Length, 0); var result = _renderer.CurrentResults.FindFirstSegmentWithStartAfter(offset); if (result != null) result = _renderer.CurrentResults.GetPreviousSegment(result); if (result == null) result = _renderer.CurrentResults.LastSegment; if (result != null) SetCurrentSearchResult(result); } internal void ReplaceNext() { if (!IsReplaceMode) return; FindNext(Math.Max(_textArea.Caret.Offset - _textArea.Selection.Length, 0)); if (!_textArea.Selection.IsEmpty) _textArea.Selection.ReplaceSelectionWithText(ReplacePattern ?? string.Empty); UpdateSearch(); } internal void ReplaceAll() { if (!IsReplaceMode) return; var replacement = ReplacePattern ?? string.Empty; var document = _textArea.Document; using (document.RunUpdate()) { var segments = _renderer.CurrentResults .OrderByDescending(x => x.EndOffset).ToArray(); foreach (var segment in segments) { document.Replace(segment.StartOffset, segment.Length, new StringTextSource(replacement)); } } } #region UI Construction void BuildUI() { style.position = Position.Absolute; style.top = 0; style.right = 0; style.flexDirection = FlexDirection.Column; style.paddingLeft = 10; style.paddingRight = 5; style.paddingTop = 5; style.paddingBottom = 5; style.borderBottomWidth = 1; style.borderLeftWidth = 1; style.borderRightWidth = 1; style.borderBottomColor = TextEditorColors.Border; style.borderLeftColor = TextEditorColors.Border; style.borderRightColor = TextEditorColors.Border; style.borderBottomLeftRadius = 4; style.borderBottomRightRadius = 4; style.backgroundColor = TextEditorColors.Background; Add(BuildSearchRow()); _replaceRow = BuildReplaceRow(); _replaceRow.style.display = DisplayStyle.None; Add(_replaceRow); _messageRow = BuildMessageRow(); _messageRow.style.display = DisplayStyle.None; Add(_messageRow); _textArea.DocumentChanged += OnDocumentChanged; if (_textArea.Document != null) _textArea.Document.TextChanged += OnDocumentTextChanged; } VisualElement BuildSearchRow() { var row = new VisualElement(); row.style.flexDirection = FlexDirection.Row; row.style.alignItems = Align.Center; row.style.height = 24; _expandReplaceButton = CreateIconButton( "\u25B6", SearchPanelLocalization.ToggleReplaceTooltip, OnExpandReplaceClicked); _expandReplaceButton.style.width = 16; _expandReplaceButton.style.height = 16; _expandReplaceButton.style.fontSize = 8; _expandReplaceButton.style.marginRight = 5; row.Add(_expandReplaceButton); var searchFieldWrapper = new VisualElement(); searchFieldWrapper.style.width = SEARCH_FIELD_WIDTH; _searchTextField = new TextField(); _searchTextField.style.fontSize = SEARCH_FIELD_FONT_SIZE; _searchTextField.style.marginLeft = 0; _searchTextField.style.marginRight = 0; _searchTextField.style.marginTop = 0; _searchTextField.style.marginBottom = 0; var textInput = _searchTextField.Q("unity-text-input"); if (textInput != null) { textInput.style.paddingRight = 72; textInput.style.overflow = Overflow.Visible; } _searchTextField.SetTextCursor(); _searchTextField.RegisterValueChangedCallback(OnSearchTextChanged); SetupPlaceholder(_searchTextField, SearchPanelLocalization.FindPlaceholder); searchFieldWrapper.Add(_searchTextField); var toggleOverlay = new VisualElement(); toggleOverlay.style.position = Position.Absolute; toggleOverlay.style.right = 2; toggleOverlay.style.top = 0; toggleOverlay.style.bottom = 0; toggleOverlay.style.flexDirection = FlexDirection.Row; toggleOverlay.style.alignItems = Align.Center; toggleOverlay.pickingMode = PickingMode.Ignore; _matchCaseButton = CreateToggleButton( "Aa", SearchPanelLocalization.MatchCaseTooltip, () => { _matchCase = !_matchCase; UpdateSearch(); }, () => _matchCase); toggleOverlay.Add(_matchCaseButton); _wholeWordButton = CreateToggleButton( "W", SearchPanelLocalization.MatchWholeWordTooltip, () => { _wholeWords = !_wholeWords; UpdateSearch(); }, () => _wholeWords); toggleOverlay.Add(_wholeWordButton); _regexButton = CreateToggleButton( ".*", SearchPanelLocalization.UseRegularExpressionsTooltip, () => { _useRegex = !_useRegex; UpdateSearch(); }, () => _useRegex); toggleOverlay.Add(_regexButton); if (textInput != null) textInput.Add(toggleOverlay); else searchFieldWrapper.Add(toggleOverlay); row.Add(searchFieldWrapper); _previousButton = CreateIconButton( "\u2191", SearchPanelLocalization.PreviousMatchTooltip, () => FindPrevious()); _previousButton.style.marginLeft = 5; row.Add(_previousButton); _nextButton = CreateIconButton( "\u2193", SearchPanelLocalization.NextMatchTooltip, () => FindNext()); row.Add(_nextButton); _closeButton = CreateIconButton( "\u2715", SearchPanelLocalization.CloseTooltip, () => Close()); row.Add(_closeButton); return row; } VisualElement BuildReplaceRow() { var row = new VisualElement(); row.style.flexDirection = FlexDirection.Row; row.style.alignItems = Align.Center; row.style.height = 24; var spacer = new VisualElement(); spacer.style.width = 21; row.Add(spacer); _replaceTextField = new TextField(); _replaceTextField.style.fontSize = SEARCH_FIELD_FONT_SIZE; _replaceTextField.style.width = SEARCH_FIELD_WIDTH; _replaceTextField.style.marginLeft = 0; _replaceTextField.style.marginRight = 0; _replaceTextField.style.marginTop = 0; _replaceTextField.style.marginBottom = 0; _replaceTextField.SetTextCursor(); SetupPlaceholder(_replaceTextField, SearchPanelLocalization.ReplacePlaceholder); row.Add(_replaceTextField); _replaceNextButton = CreateIconButton( "R", SearchPanelLocalization.ReplaceNextTooltip, () => ReplaceNext()); _replaceNextButton.style.marginLeft = 5; row.Add(_replaceNextButton); _replaceAllButton = CreateIconButton( "RA", SearchPanelLocalization.ReplaceAllTooltip, () => ReplaceAll()); row.Add(_replaceAllButton); return row; } VisualElement BuildMessageRow() { var row = new VisualElement(); row.style.flexDirection = FlexDirection.Row; row.style.marginTop = 2; if (!_textEditor.IsReadOnly) { var spacer = new VisualElement(); spacer.style.width = 21; row.Add(spacer); } _searchCounterLabel = new Label(); _searchCounterLabel.style.fontSize = 11; row.Add(_searchCounterLabel); return row; } static void SetupPlaceholder(TextField textField, string placeholderText) { var placeholder = new Label(placeholderText); placeholder.style.position = Position.Absolute; placeholder.style.left = 2; placeholder.style.top = 0; placeholder.style.bottom = 0; placeholder.style.unityTextAlign = TextAnchor.MiddleLeft; placeholder.style.color = TextEditorColors.SecondaryText; placeholder.style.fontSize = 11; placeholder.pickingMode = PickingMode.Ignore; var textInput = textField.Q("unity-text-input"); textInput?.Add(placeholder); placeholder.style.display = string.IsNullOrEmpty(textField.value) ? DisplayStyle.Flex : DisplayStyle.None; textField.RegisterValueChangedCallback(e => placeholder.style.display = string.IsNullOrEmpty(e.newValue) ? DisplayStyle.Flex : DisplayStyle.None); } Button CreateIconButton(string text, string tooltip, Action onClick) { var button = CreateBaseButton(text, tooltip, onClick); button.style.width = 20; button.style.height = 16; button.RegisterCallback(_onButtonMouseEnter); button.RegisterCallback(_onButtonMouseLeave); return button; } Button CreateToggleButton( string text, string tooltip, Action onClick, Func isActive) { var button = CreateBaseButton(text, tooltip, null); button.style.width = 20; button.style.height = 13; button.style.flexShrink = 0; button.style.borderTopWidth = 1; button.style.borderBottomWidth = 1; button.style.borderLeftWidth = 1; button.style.borderRightWidth = 1; button.style.borderTopColor = Color.clear; button.style.borderBottomColor = Color.clear; button.style.borderLeftColor = Color.clear; button.style.borderRightColor = Color.clear; button.SetMouseCursor(MouseCursor.Arrow); button.clickable = new Clickable(() => { onClick(); UpdateToggleButtonAppearance(button, isActive()); }); return button; } static Button CreateBaseButton(string text, string tooltip, Action onClick) { var button = new Button(onClick); button.text = text; button.tooltip = tooltip; button.style.marginLeft = 1; button.style.marginRight = 1; button.style.paddingLeft = 0; button.style.paddingRight = 0; button.style.paddingTop = 0; button.style.paddingBottom = 0; button.style.fontSize = BUTTON_FONT_SIZE; button.style.unityTextAlign = TextAnchor.MiddleCenter; button.style.borderLeftWidth = 0; button.style.borderRightWidth = 0; button.style.borderTopWidth = 0; button.style.borderBottomWidth = 0; button.style.color = TextEditorColors.IconColor; button.style.backgroundColor = Color.clear; return button; } static void UpdateToggleButtonAppearance(Button button, bool active) { var color = active ? TextEditorColors.ToggleActive : Color.clear; button.style.backgroundColor = color; button.style.borderTopColor = color; button.style.borderBottomColor = color; button.style.borderLeftColor = color; button.style.borderRightColor = color; } void UpdateReplaceModeVisibility() { if (_replaceRow == null) return; _replaceRow.style.display = _isReplaceMode ? DisplayStyle.Flex : DisplayStyle.None; _expandReplaceButton.text = _isReplaceMode ? "\u25BC" : "\u25B6"; } #endregion #region Search Logic void OnSearchTextChanged(ChangeEvent evt) { UpdateSearch(); } void OnDocumentChanged(object sender, DocumentChangedEventArgs e) { if (e.OldDocument != null) e.OldDocument.TextChanged -= OnDocumentTextChanged; if (e.NewDocument != null) { e.NewDocument.TextChanged += OnDocumentTextChanged; DoSearch(false); } } void OnDocumentTextChanged(object sender, EventArgs e) { DoSearch(false); } void UpdateSearch() { try { // Hide message while results exist to prevent "no matches found" flickering if (_renderer.CurrentResults.Count > 0 && _messageRow != null) _messageRow.style.display = DisplayStyle.None; _strategy = SearchStrategyFactory.Create( SearchPattern ?? "", !_matchCase, _wholeWords, _useRegex ? SearchMode.RegEx : SearchMode.Normal); SearchOptionsChanged?.Invoke(this, new SearchOptionsChangedEventArgs( SearchPattern, _matchCase, _useRegex, _wholeWords)); DoSearch(true); } catch (SearchPatternException) { CleanSearchResults(); UpdateSearchLabel(); } } void DoSearch(bool changeSelection) { if (IsClosed) return; CleanSearchResults(); int offset = Math.Max( _textArea.Caret.Offset - _textArea.Selection.Length, 0); if (changeSelection) _textArea.ClearSelection(); if (!string.IsNullOrEmpty(SearchPattern) && _strategy != null) { foreach (var result in _strategy .FindAll(_textArea.Document, 0, _textArea.Document.TextLength) .Cast()) { _renderer.CurrentResults.Add(result); } if (changeSelection) { var result = _renderer.CurrentResults .FindFirstSegmentWithStartAfter(offset) ?? _renderer.CurrentResults.FirstSegment; if (result != null) SelectResult(result); _currentSearchResultIndex = _renderer.CurrentResults.Count > 0 ? GetSearchResultIndex(_renderer.CurrentResults, result) : -1; } } UpdateSearchLabel(); _textArea.TextView.InvalidateLayer(KnownLayer.Selection); } void CleanSearchResults() { _renderer.CurrentResults.Clear(); _currentSearchResultIndex = -1; } void SetCurrentSearchResult(SearchResult result) { _currentSearchResultIndex = GetSearchResultIndex(_renderer.CurrentResults, result); SelectResult(result); UpdateSearchLabel(); } void SelectResult(TextSegment result) { _textArea.Caret.Offset = result.EndOffset; _textArea.Selection = Selection.Create( _textArea, result.StartOffset, result.EndOffset); _textArea.Caret.BringCaretToView(); _textArea.Caret.Show(); } void UpdateSearchLabel() { if (_messageRow == null || _searchCounterLabel == null) return; if (string.IsNullOrEmpty(SearchPattern)) { _messageRow.style.display = DisplayStyle.None; return; } _messageRow.style.display = DisplayStyle.Flex; int count = _renderer.CurrentResults.Count; if (count == 0) { _searchCounterLabel.text = SearchPanelLocalization.NoMatchesFound; } else if (_currentSearchResultIndex == -1) { _searchCounterLabel.text = count == 1 ? SearchPanelLocalization.OneMatch : string.Format(SearchPanelLocalization.MultipleMatchesFormat, count); } else { _searchCounterLabel.text = string.Format( SearchPanelLocalization.MatchIndexFormat, _currentSearchResultIndex + 1, count); } } static int GetSearchResultIndex( TextSegmentCollection searchResults, SearchResult match) { if (match == null) return -1; int index = 0; foreach (var searchResult in searchResults) { if (searchResult.Equals(match)) return index; index++; } return -1; } #endregion #region Keyboard Handling void OnKeyDown(KeyDownEvent e) { if (e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter) { e.StopPropagation(); if (e.shiftKey) FindPrevious(); else FindNext(); } else if (e.keyCode == KeyCode.Escape) { e.StopPropagation(); Close(); } } void OnExpandReplaceClicked() { IsReplaceMode = !IsReplaceMode; } #endregion } internal class SearchOptionsChangedEventArgs : EventArgs { internal string SearchPattern { get; } internal bool MatchCase { get; } internal bool UseRegex { get; } internal bool WholeWords { get; } internal SearchOptionsChangedEventArgs( string searchPattern, bool matchCase, bool useRegex, bool wholeWords) { SearchPattern = searchPattern; MatchCase = matchCase; UseRegex = useRegex; WholeWords = wholeWords; } } }