using System; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Linq; using Unity.CodeEditor.Document; using Unity.CodeEditor.Indentation; using Unity.CodeEditor.Platform; using Unity.CodeEditor.Rendering; using Unity.CodeEditor.Utils; using Unity.PlasticSCM.Editor.UI; using UnityEditor; using UnityEngine; using UnityEngine.UIElements; namespace Unity.CodeEditor.Editing { internal class TextArea : VisualElement { private bool _isFocused; private TextEditorOptions _options; private TextDocument _document; private IReadOnlySectionProvider _readOnlySectionProvider = NoReadOnlySections.Instance; private bool _rigthClickMovesCaret; private SelectionMouseHandler _selectionMouseHandler; private EditingCommandHandler _editingCommandHandler; private CaretNavigationCommandHandler _caretNavigationCommandHandler; private VisualElement _leftMarginsContainer; internal bool IsFocused => _isFocused; #region Caret handling on document changes private void OnDocumentChanging(object sender, DocumentChangeEventArgs e) { Caret.OnDocumentChanging(); } private void OnDocumentChanged(object sender, DocumentChangeEventArgs e) { Caret.OnDocumentChanged(e); Selection = _selection.UpdateOnDocumentChange(e); } private void OnUpdateStarted(object sender, EventArgs e) { Document.UndoStack.PushOptional(new RestoreCaretAndSelectionUndoAction(this)); } private void OnUpdateFinished(object sender, EventArgs e) { Caret.OnDocumentUpdateFinished(); } private sealed class RestoreCaretAndSelectionUndoAction : IUndoableOperation { // keep textarea in weak reference because the IUndoableOperation is stored with the document private readonly WeakReference _textAreaReference; private readonly TextViewPosition _caretPosition; private readonly Selection _selection; internal RestoreCaretAndSelectionUndoAction(TextArea textArea) { _textAreaReference = new WeakReference(textArea); // Just save the old caret position, no need to validate here. // If we restore it, we'll validate it anyways. _caretPosition = textArea.Caret.NonValidatedPosition; _selection = textArea.Selection; } public void Undo() { var textArea = (TextArea)_textAreaReference.Target; if (textArea != null) { textArea.Caret.Position = _caretPosition; textArea.Selection = _selection; } } public void Redo() { // redo=undo: we just restore the caret/selection state Undo(); } } #endregion #region TextView property /// /// Gets the text view used to display text in this text area. /// internal TextView TextView { get; } #endregion #region Document property /// /// Gets/Sets the document displayed by the text editor. /// internal TextDocument Document { get { return _document; } set { TextDocument oldValue = _document; _document = value; OnDocumentChanged(oldValue, value); } } /// internal event EventHandler DocumentChanged; /// /// Gets if the the document displayed by the text editor is readonly /// internal bool IsReadOnly { get => ReadOnlySectionProvider == ReadOnlySectionDocument.Instance; } private void OnDocumentChanged(TextDocument oldValue, TextDocument newValue) { if (oldValue != null) { TextDocumentWeakEventManager.Changing.RemoveHandler(oldValue, OnDocumentChanging); TextDocumentWeakEventManager.Changed.RemoveHandler(oldValue, OnDocumentChanged); TextDocumentWeakEventManager.UpdateStarted.RemoveHandler(oldValue, OnUpdateStarted); TextDocumentWeakEventManager.UpdateFinished.RemoveHandler(oldValue, OnUpdateFinished); } TextView.Document = newValue; if (newValue != null) { TextDocumentWeakEventManager.Changing.AddHandler(newValue, OnDocumentChanging); TextDocumentWeakEventManager.Changed.AddHandler(newValue, OnDocumentChanged); TextDocumentWeakEventManager.UpdateStarted.AddHandler(newValue, OnUpdateStarted); TextDocumentWeakEventManager.UpdateFinished.AddHandler(newValue, OnUpdateFinished); MarkDirtyRepaint(); } // Reset caret location and selection: this is necessary because the caret/selection might be invalid // in the new document (e.g. if new document is shorter than the old document). Caret.Location = new TextLocation(1, 1); ClearSelection(); DocumentChanged?.Invoke(this, new DocumentChangedEventArgs(oldValue, newValue)); //CommandManager.InvalidateRequerySuggested(); } #endregion #region Options property /// /// Options property. /// internal TextEditorOptions Options { get { return _options; } set { TextEditorOptions oldValue = _options; _options = value; OnOptionsChanged(oldValue, value); } } /// /// Occurs when a text editor option has changed. /// internal event PropertyChangedEventHandler OptionChanged; private void OnOptionChanged(object sender, PropertyChangedEventArgs e) { OnOptionChanged(e); } /// /// Raises the event. /// protected virtual void OnOptionChanged(PropertyChangedEventArgs e) { OptionChanged?.Invoke(this, e); } private void OnOptionsChanged(TextEditorOptions oldValue, TextEditorOptions newValue) { if (oldValue != null) { PropertyChangedWeakEventManager.RemoveHandler(oldValue, OnOptionChanged); } TextView.Options = newValue; if (newValue != null) { PropertyChangedWeakEventManager.AddHandler(newValue, OnOptionChanged); } OnOptionChanged(new PropertyChangedEventArgs(null)); } #endregion /// /// Creates a new TextArea instance. /// internal TextArea() : this(new TextView()) { } /// /// Creates a new TextArea instance. /// protected TextArea(TextView textView) { focusable = true; style.flexDirection = FlexDirection.Row; style.flexGrow = 1; style.backgroundColor = TextEditorColors.Background; _selectionMouseHandler = new SelectionMouseHandler(this); _editingCommandHandler = new EditingCommandHandler(this); _caretNavigationCommandHandler = new CaretNavigationCommandHandler(this); TextView = textView ?? throw new ArgumentNullException(nameof(textView)); Options = textView.Options; _selection = EmptySelection = new EmptySelection(this); textView.Services.AddService(this); _selectionLayer = new SelectionLayer(this); textView.InsertLayer(_selectionLayer, KnownLayer.Selection, LayerInsertionPosition.Replace); Caret = new Caret(this); Caret.PositionChanged += (sender, e) => RequestSelectionValidation(); Caret.PositionChanged += CaretPositionChanged; LeftMargins.CollectionChanged += LeftMargins_CollectionChanged; _leftMarginsContainer = new VisualElement(); _leftMarginsContainer.style.flexDirection = FlexDirection.Row; _leftMarginsContainer.style.flexShrink = 0; Add(_leftMarginsContainer); Add(textView); RegisterCallback(OnKeyDownEvent); RegisterCallback(OnNavigationMoveEvent); RegisterCallback(OnFocusIn); RegisterCallback(OnFocusOut); RegisterCallback(OnPointerDownEvent); RegisterCallback(OnPointerUpEvent); RegisterCallback(OnPointerMoveEvent); } internal void Dispose() { UnregisterCallback(OnKeyDownEvent); UnregisterCallback(OnNavigationMoveEvent); UnregisterCallback(OnFocusIn); UnregisterCallback(OnFocusOut); UnregisterCallback(OnPointerDownEvent); UnregisterCallback(OnPointerUpEvent); UnregisterCallback(OnPointerMoveEvent); } private void OnKeyDownEvent(KeyDownEvent e) { _caretNavigationCommandHandler.OnKeyDown(e); _editingCommandHandler.OnKeyDown(e); ProcessTextInput(e); } private void OnNavigationMoveEvent(NavigationMoveEvent e) { #if UNITY_2022_1_OR_NEWER if (IsReadOnly) return; // Tab and Shift+Tab generate a NavigationMoveEvent with direction // Next/Previous. Consuming it prevents the focus controller from // moving focus away from the text editor. if (e.direction == NavigationMoveEvent.Direction.Next || e.direction == NavigationMoveEvent.Direction.Previous) { e.StopPropagation(); #if UNITY_6000_0_OR_NEWER focusController.IgnoreEvent(e); #else e.PreventDefault(); #endif } #endif } private void OnFocusIn(FocusInEvent e) { OnFocus(); } private void OnFocusOut(FocusOutEvent e) { OnLostFocus(); } private void OnPointerDownEvent(PointerDownEvent e) { var localPos = e.localPosition; var textViewPos = TextView.WorldToLocal(e.position); TextView.OnPointerPressed(textViewPos); _selectionMouseHandler.OnPointerPressed(e); OnFocus(); this.CapturePointer(e.pointerId); } private void OnPointerUpEvent(PointerUpEvent e) { var textViewPos = TextView.WorldToLocal(e.position); TextView.OnPointerReleased(textViewPos); _selectionMouseHandler.OnPointerReleased(e); this.ReleasePointer(e.pointerId); } private void OnPointerMoveEvent(PointerMoveEvent e) { if (this.HasPointerCapture(e.pointerId)) { TextView.OnPointerMoved(); _selectionMouseHandler.OnPointerMoved(e); } } #region Force caret to stay inside selection private bool _ensureSelectionValidRequested; private int _allowCaretOutsideSelection; private void RequestSelectionValidation() { if (!_ensureSelectionValidRequested && _allowCaretOutsideSelection == 0) { _ensureSelectionValidRequested = true; EditorDispatcher.Dispatch(EnsureSelectionValid); } } /// /// Code that updates only the caret but not the selection can cause confusion when /// keys like 'Delete' delete the (possibly invisible) selected text and not the /// text around the caret. /// /// So we'll ensure that the caret is inside the selection. /// (when the caret is not in the selection, we'll clear the selection) /// /// This method is invoked using the Dispatcher so that code may temporarily violate this rule /// (e.g. most 'extend selection' methods work by first setting the caret, then the selection), /// it's sufficient to fix it after any event handlers have run. /// private void EnsureSelectionValid() { _ensureSelectionValidRequested = false; if (_allowCaretOutsideSelection == 0) { if (!_selection.IsEmpty && !_selection.Contains(Caret.Offset)) { ClearSelection(); } } } /// /// Temporarily allows positioning the caret outside the selection. /// Dispose the returned IDisposable to revert the allowance. /// /// /// The text area only forces the caret to be inside the selection when other events /// have finished running (using the dispatcher), so you don't have to use this method /// for temporarily positioning the caret in event handlers. /// This method is only necessary if you want to run the dispatcher, e.g. if you /// perform a drag'n'drop operation. /// internal IDisposable AllowCaretOutsideSelection() { EditorDispatcher.VerifyMainThreadAccess(); _allowCaretOutsideSelection++; return new CallbackOnDispose( delegate { EditorDispatcher.VerifyMainThreadAccess(); _allowCaretOutsideSelection--; RequestSelectionValidation(); }); } #endregion #region Properties /// /// Gets the Caret used for this text area. /// internal Caret Caret { get; } /// /// Scrolls the text view so that the requested line is in the middle. /// If the textview can be scrolled. /// /// The line to scroll to. internal void ScrollToLine(int line) { var viewPortLines = (int)TextView.ScrollViewport.y; if (viewPortLines < Document.LineCount) { ScrollToLine(line, 2, viewPortLines / 2); } } /// /// Scrolls the textview to a position with n lines above and below it. /// /// the requested line number. /// The number of lines above and below. internal void ScrollToLine(int line, int linesEitherSide) { ScrollToLine(line, linesEitherSide, linesEitherSide); } /// /// Scrolls the textview to a position with n lines above and below it. /// /// the requested line number. /// The number of lines above. /// The number of lines below. internal void ScrollToLine(int line, int linesAbove, int linesBelow) { var offset = line - linesAbove; if (offset < 0) { offset = 0; } this.TextView.MakeVisible(new Rect(1, offset, 0, 1)); offset = line + linesBelow; if (offset >= 0) { this.TextView.MakeVisible(new Rect(1, offset, 0, 1)); } } private void CaretPositionChanged(object sender, EventArgs e) { // TODO: review!! ScrollToLine is not correct /*if (TextView == null) return; TextView.HighlightedLine = Caret.Line; ScrollToLine(Caret.Line, 2);*/ } #endregion /// /// Gets the collection of margins displayed to the left of the text view. /// internal ObservableCollection LeftMargins { get; } = new ObservableCollection(); private void LeftMargins_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { if (e.OldItems != null) { foreach (var item in e.OldItems) { if (item is ITextViewConnect c) c.RemoveFromTextView(TextView); if (item is VisualElement ve) ve.RemoveFromHierarchy(); } } if (e.NewItems != null) { foreach (var item in e.NewItems) { if (item is ITextViewConnect c) c.AddToTextView(TextView); if (item is VisualElement ve && _leftMarginsContainer != null) _leftMarginsContainer.Add(ve); } } } /// /// Gets/Sets an object that provides read-only sections for the text area. /// internal IReadOnlySectionProvider ReadOnlySectionProvider { get => _readOnlySectionProvider; set => _readOnlySectionProvider = value ?? throw new ArgumentNullException(nameof(value)); } /// /// Determines whether caret position should be changed to the mouse position when you right click or not. /// internal bool RightClickMovesCaret { get => _rigthClickMovesCaret; set => _rigthClickMovesCaret = value; } internal readonly Selection EmptySelection; private Selection _selection; private SelectionLayer _selectionLayer; /// /// Occurs when the selection has changed. /// internal event EventHandler SelectionChanged; /// /// Gets/Sets the selection in this text area. /// internal Selection Selection { get => _selection; set { if (value == null) throw new ArgumentNullException(nameof(value)); if (value.TextArea != this) throw new ArgumentException("Cannot use a Selection instance that belongs to another text area."); if (!Equals(_selection, value)) { if (TextView != null) { var oldSegment = _selection.SurroundingSegment; var newSegment = value.SurroundingSegment; if (!Selection.EnableVirtualSpace && (_selection is SimpleSelection && value is SimpleSelection && oldSegment != null && newSegment != null)) { // perf optimization: // When a simple selection changes, don't redraw the whole selection, but only the changed parts. var oldSegmentOffset = oldSegment.Offset; var newSegmentOffset = newSegment.Offset; if (oldSegmentOffset != newSegmentOffset) { TextView.Redraw(Math.Min(oldSegmentOffset, newSegmentOffset), Math.Abs(oldSegmentOffset - newSegmentOffset)); } var oldSegmentEndOffset = oldSegment.EndOffset; var newSegmentEndOffset = newSegment.EndOffset; if (oldSegmentEndOffset != newSegmentEndOffset) { TextView.Redraw(Math.Min(oldSegmentEndOffset, newSegmentEndOffset), Math.Abs(oldSegmentEndOffset - newSegmentEndOffset)); } } else { TextView.Redraw(oldSegment); TextView.Redraw(newSegment); } } _selection = value; _selectionLayer.InvalidateSelection(); SelectionChanged?.Invoke(this, EventArgs.Empty); // a selection change causes commands like copy/paste/etc. to change status //CommandManager.InvalidateRequerySuggested(); } } } /// /// Clears the current selection. /// internal void ClearSelection() { Selection = EmptySelection; } Color mSelectionForeground; /// /// Gets/Sets the foreground brush used for selected text. /// internal Color SelectionForeground { get => mSelectionForeground; set { mSelectionForeground = value; TextView.Redraw(); } } #region Focus Handling (Show/Hide Caret) internal void OnFocus() { _isFocused = true; Caret.Show(); } internal void OnLostFocus() { _isFocused = false; Caret.Hide(); } void ProcessTextInput(KeyDownEvent e) { if (e.character != 0 && TextView.Font != null && TextView.Font.HasCharacter(e.character)) { TextInputEventArgs inputEventArgs = new TextInputEventArgs() { Text = e.character.ToString(), }; e.StopPropagation(); PerformTextInput(inputEventArgs); } } #endregion #region OnTextInput / RemoveSelectedText / ReplaceSelectionWithText /// /// Occurs when the TextArea receives text input. /// but occurs immediately before the TextArea handles the TextInput event. /// internal event EventHandler TextEntering; /// /// Occurs when the TextArea receives text input. /// but occurs immediately after the TextArea handles the TextInput event. /// internal event EventHandler TextEntered; /// /// Raises the TextEntering event. /// protected virtual void OnTextEntering(TextInputEventArgs e) { TextEntering?.Invoke(this, e); } /// /// Raises the TextEntered event. /// protected virtual void OnTextEntered(TextInputEventArgs e) { TextEntered?.Invoke(this, e); } /*protected override void OnTextInput(TextInputEventArgs e) { base.OnTextInput(e); if (!e.Handled && Document != null) { if (string.IsNullOrEmpty(e.Text) || e.Text == "\x1b" || e.Text == "\b" || e.Text == "\u007f") { // TODO: check this // ASCII 0x1b = ESC. // produces a TextInput event with that old ASCII control char // when Escape is pressed. We'll just ignore it. // A deadkey followed by backspace causes a textinput event for the BS character. // Similarly, some shortcuts like Alt+Space produce an empty TextInput event. // We have to ignore those (not handle them) to keep the shortcut working. return; } HideMouseCursor(); PerformTextInput(e); e.Handled = true; } }*/ /// /// Performs text input. /// This raises the event, replaces the selection with the text, /// and then raises the event. /// internal void PerformTextInput(string text) { var e = new TextInputEventArgs { Text = text, }; PerformTextInput(e); } /// /// Performs text input. /// This raises the event, replaces the selection with the text, /// and then raises the event. /// internal void PerformTextInput(TextInputEventArgs e) { if (e == null) throw new ArgumentNullException(nameof(e)); if (Document == null) throw ThrowUtil.NoDocumentAssigned(); OnTextEntering(e); if (!e.Handled) { if (e.Text == "\n" || e.Text == "\r" || e.Text == "\r\n") ReplaceSelectionWithNewLine(); else { // TODO //if (OverstrikeMode && Selection.IsEmpty && Document.GetLineByNumber(Caret.Line).EndOffset > Caret.Offset) // EditingCommands.SelectRightByCharacter.Execute(null, this); ReplaceSelectionWithText(e.Text); } OnTextEntered(e); Caret.BringCaretToView(); } } private void ReplaceSelectionWithNewLine() { var newLine = TextUtilities.GetNewLineFromDocument(Document, Caret.Line); using (Document.RunUpdate()) { ReplaceSelectionWithText(newLine); if (IndentationStrategy != null) { var line = Document.GetLineByNumber(Caret.Line); var deletable = GetDeletableSegments(line); if (deletable.Length == 1 && deletable[0].Offset == line.Offset && deletable[0].Length == line.Length) { // use indentation strategy only if the line is not read-only IndentationStrategy.IndentLine(Document, line); } } } } internal void ReplaceSelectionWithText(string newText) { if (newText == null) throw new ArgumentNullException(nameof(newText)); if (Document == null) throw ThrowUtil.NoDocumentAssigned(); _selection.ReplaceSelectionWithText(newText); } internal ISegment[] GetDeletableSegments(ISegment segment) { var deletableSegments = ReadOnlySectionProvider.GetDeletableSegments(segment); if (deletableSegments == null) throw new InvalidOperationException("ReadOnlySectionProvider.GetDeletableSegments returned null"); var array = deletableSegments.ToArray(); var lastIndex = segment.Offset; foreach (var t in array) { if (t.Offset < lastIndex) throw new InvalidOperationException("ReadOnlySectionProvider returned incorrect segments (outside of input segment / wrong order)"); lastIndex = t.EndOffset; } if (lastIndex > segment.EndOffset) throw new InvalidOperationException("ReadOnlySectionProvider returned incorrect segments (outside of input segment / wrong order)"); return array; } #endregion #region IndentationStrategy property /// /// Gets/Sets the indentation strategy used when inserting new lines. /// internal IIndentationStrategy IndentationStrategy { get; set; } #endregion internal void RemoveSelectedText() { if (Document == null) throw ThrowUtil.NoDocumentAssigned(); _selection.ReplaceSelectionWithText(string.Empty); #if DEBUG if (!_selection.IsEmpty) { foreach (var s in _selection.Segments) { Debug.Assert(!ReadOnlySectionProvider.GetDeletableSegments(s).Any()); } } #endif } /// /// Occurs when text inside the TextArea was copied. /// internal event EventHandler TextCopied; internal void OnTextCopied(TextEventArgs e) { TextCopied?.Invoke(this, e); } /// /// EventArgs with text. /// internal class TextEventArgs : EventArgs { /// /// Gets the text. /// internal string Text { get; } /// /// Creates a new TextEventArgs instance. /// internal TextEventArgs(string text) { Text = text ?? throw new ArgumentNullException(nameof(text)); } } } }