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