// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team // // Permission is hereby granted, free of charge, to any person obtaining a copy of this // software and associated documentation files (the "Software"), to deal in the Software // without restriction, including without limitation the rights to use, copy, modify, merge, // publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons // to whom the Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all copies or // substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR // PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE // FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.Threading; using Avalonia.VisualTree; using AvaloniaEdit; using AvaloniaEdit.Document; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; /// /// Base class for completion windows. Handles positioning the window at the caret. /// [SuppressMessage("ReSharper", "MemberCanBeProtected.Global")] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] public class CompletionWindowBase : Popup { protected override Type StyleKeyOverride => typeof(PopupRoot); /// /// Gets the parent TextArea. /// public TextArea TextArea { get; } private readonly Window? _parentWindow; private TextDocument? _document; /// /// Gets/Sets the start of the text range in which the completion window stays open. /// This text portion is used to determine the text used to select an entry in the completion list by typing. /// public int StartOffset { get; set; } /// /// Gets/Sets the end of the text range in which the completion window stays open. /// This text portion is used to determine the text used to select an entry in the completion list by typing. /// public int EndOffset { get; set; } /// /// Gets whether the window was opened above the current line. /// protected bool IsUp { get; private set; } /// /// Creates a new CompletionWindowBase. /// public CompletionWindowBase(TextArea textArea) { TextArea = textArea ?? throw new ArgumentNullException(nameof(textArea)); _parentWindow = textArea.GetVisualRoot() as Window ?? throw new InvalidOperationException("CompletionWindow requires a visual root."); AddHandler(PointerReleasedEvent, OnMouseUp, handledEventsToo: true); StartOffset = EndOffset = TextArea.Caret.Offset; PlacementTarget = TextArea.TextView; Placement = PlacementMode.AnchorAndGravity; PlacementAnchor = global::Avalonia.Controls.Primitives.PopupPositioning.PopupAnchor.TopLeft; PlacementGravity = global::Avalonia.Controls.Primitives.PopupPositioning.PopupGravity.BottomRight; //Deactivated += OnDeactivated; //Not needed? Closed += (_, _) => DetachEvents(); AttachEvents(); Initialize(); } protected virtual void OnClosed() { DetachEvents(); } private void Initialize() { if (_document != null && StartOffset != TextArea.Caret.Offset) { SetPosition(new TextViewPosition(_document.GetLocation(StartOffset))); } else { SetPosition(TextArea.Caret.Position); } } public void Show() { UpdatePosition(); Open(); Height = double.NaN; MinHeight = 0; } public void Hide() { Close(); OnClosed(); } #region Event Handlers private void AttachEvents() { ((ISetLogicalParent)this).SetParent(TextArea.GetVisualRoot() as ILogical); _document = TextArea.Document; if (_document != null) { _document.Changing += TextArea_Document_Changing; } // LostKeyboardFocus seems to be more reliable than PreviewLostKeyboardFocus - see SD-1729 TextArea.LostFocus += TextAreaLostFocus; TextArea.TextView.ScrollOffsetChanged += TextViewScrollOffsetChanged; TextArea.DocumentChanged += TextAreaDocumentChanged; if (_parentWindow != null) { _parentWindow.PositionChanged += ParentWindow_LocationChanged; _parentWindow.Deactivated += ParentWindow_Deactivated; } // close previous completion windows of same type foreach (var x in TextArea.StackedInputHandlers.OfType()) { if (x.Window.GetType() == GetType()) TextArea.PopStackedInputHandler(x); } _myInputHandler = new InputHandler(this); TextArea.PushStackedInputHandler(_myInputHandler); } /// /// Detaches events from the text area. /// protected virtual void DetachEvents() { ((ISetLogicalParent)this).SetParent(null); if (_document != null) { _document.Changing -= TextArea_Document_Changing; } TextArea.LostFocus -= TextAreaLostFocus; TextArea.TextView.ScrollOffsetChanged -= TextViewScrollOffsetChanged; TextArea.DocumentChanged -= TextAreaDocumentChanged; if (_parentWindow != null) { _parentWindow.PositionChanged -= ParentWindow_LocationChanged; _parentWindow.Deactivated -= ParentWindow_Deactivated; } TextArea.PopStackedInputHandler(_myInputHandler); } #region InputHandler private InputHandler? _myInputHandler; /// /// A dummy input handler (that justs invokes the default input handler). /// This is used to ensure the completion window closes when any other input handler /// becomes active. /// private sealed class InputHandler : TextAreaStackedInputHandler { internal readonly CompletionWindowBase Window; public InputHandler(CompletionWindowBase window) : base(window.TextArea) { Debug.Assert(window != null); Window = window; } public override void Detach() { base.Detach(); Window.Hide(); } public override void OnPreviewKeyDown(KeyEventArgs e) { // prevents crash when typing deadchar while CC window is open if (e.Key == Key.DeadCharProcessed) return; e.Handled = RaiseEventPair(Window, null, KeyDownEvent, new KeyEventArgs { Key = e.Key }); } public override void OnPreviewKeyUp(KeyEventArgs e) { if (e.Key == Key.DeadCharProcessed) return; e.Handled = RaiseEventPair(Window, null, KeyUpEvent, new KeyEventArgs { Key = e.Key }); } } #endregion private void TextViewScrollOffsetChanged(object? sender, EventArgs e) { ILogicalScrollable textView = TextArea; var visibleRect = new Rect(textView.Offset.X, textView.Offset.Y, textView.Viewport.Width, textView.Viewport.Height); //close completion window when the user scrolls so far that the anchor position is leaving the visible area if (visibleRect.Contains(_visualLocation) || visibleRect.Contains(_visualLocationTop)) { UpdatePosition(); } else { Hide(); } } private void TextAreaDocumentChanged(object? sender, EventArgs e) { Hide(); } private void TextAreaLostFocus(object? sender, RoutedEventArgs e) { Dispatcher.UIThread.Post(CloseIfFocusLost, DispatcherPriority.Background); } private void ParentWindow_Deactivated(object? sender, EventArgs e) { Hide(); } private void ParentWindow_LocationChanged(object? sender, EventArgs e) { UpdatePosition(); } /* private void OnDeactivated(object sender, EventArgs e) { Dispatcher.UIThread.Post(CloseIfFocusLost, DispatcherPriority.Background); }*/ #endregion /// /// Raises a tunnel/bubble event pair for a control. /// /// The control for which the event should be raised. /// The tunneling event. /// The bubbling event. /// The event args to use. /// The value of the event args. protected static bool RaiseEventPair(Control target, RoutedEvent? previewEvent, RoutedEvent @event, RoutedEventArgs args) { if (target == null) throw new ArgumentNullException(nameof(target)); if (args == null) throw new ArgumentNullException(nameof(args)); if (previewEvent != null) { args.RoutedEvent = previewEvent; target.RaiseEvent(args); } args.RoutedEvent = @event ?? throw new ArgumentNullException(nameof(@event)); target.RaiseEvent(args); return args.Handled; } // Special handler: handledEventsToo private void OnMouseUp(object? sender, PointerReleasedEventArgs e) { ActivateParentWindow(); } /// /// Activates the parent window. /// protected virtual void ActivateParentWindow() { _parentWindow?.Activate(); } private void CloseIfFocusLost() { if (CloseOnFocusLost) { Debug.WriteLine("CloseIfFocusLost: this.IsFocues=" + IsFocused + " IsTextAreaFocused=" + IsTextAreaFocused); if (!IsFocused && !IsTextAreaFocused) { Hide(); } } } /// /// Gets whether the completion window should automatically close when the text editor looses focus. /// protected virtual bool CloseOnFocusLost => true; private bool IsTextAreaFocused { get { if (_parentWindow != null && !_parentWindow.IsActive) return false; return TextArea.IsFocused; } } /// protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (!e.Handled && e.Key == Key.Escape) { e.Handled = true; Hide(); } } private Point _visualLocation; private Point _visualLocationTop; /// /// Positions the completion window at the specified position. /// protected void SetPosition(TextViewPosition position) { var textView = TextArea.TextView; _visualLocation = textView.GetVisualPosition(position, VisualYPosition.LineBottom); _visualLocationTop = textView.GetVisualPosition(position, VisualYPosition.LineTop); UpdatePosition(); } /// /// Updates the position of the CompletionWindow based on the parent TextView position and the screen working area. /// It ensures that the CompletionWindow is completely visible on the screen. /// protected void UpdatePosition() { var textView = TextArea.TextView; var position = _visualLocation - textView.ScrollOffset; this.HorizontalOffset = position.X; this.VerticalOffset = position.Y; } // TODO: check if needed //protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo) //{ // base.OnRenderSizeChanged(sizeInfo); // if (sizeInfo.HeightChanged && IsUp) // { // this.Top += sizeInfo.PreviousSize.Height - sizeInfo.NewSize.Height; // } //} /// /// Gets/sets whether the completion window should expect text insertion at the start offset, /// which not go into the completion region, but before it. /// /// This property allows only a single insertion, it is reset to false /// when that insertion has occurred. public bool ExpectInsertionBeforeStart { get; set; } protected virtual void TextArea_Document_Changing(object? sender, DocumentChangeEventArgs e) { if (e.Offset + e.RemovalLength == StartOffset && e.RemovalLength > 0) { Hide(); // removal immediately in front of completion segment: close the window // this is necessary when pressing backspace after dot-completion } if (e.Offset == StartOffset && e.RemovalLength == 0 && ExpectInsertionBeforeStart) { StartOffset = e.GetNewOffset(StartOffset, AnchorMovementType.AfterInsertion); ExpectInsertionBeforeStart = false; } else { StartOffset = e.GetNewOffset(StartOffset, AnchorMovementType.BeforeInsertion); } EndOffset = e.GetNewOffset(EndOffset, AnchorMovementType.AfterInsertion); } }