Browse Source

Merge branch 'master' into feature/templatedcontrol-cornerradius

pull/6347/head
Max Katz 5 years ago
committed by GitHub
parent
commit
6e691da378
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 17
      src/Avalonia.Controls/ContextMenu.cs
  2. 23
      src/Avalonia.Controls/Diagnostics/IPopupHostProvider.cs
  3. 15
      src/Avalonia.Controls/Diagnostics/ToolTipDiagnostics.cs
  4. 18
      src/Avalonia.Controls/Flyouts/FlyoutBase.cs
  5. 6
      src/Avalonia.Controls/Platform/InternalPlatformThreadingInterface.cs
  6. 5
      src/Avalonia.Controls/Primitives/OverlayPopupHost.cs
  7. 16
      src/Avalonia.Controls/Primitives/Popup.cs
  8. 34
      src/Avalonia.Controls/Primitives/ScrollBar.cs
  9. 65
      src/Avalonia.Controls/TextBox.cs
  10. 40
      src/Avalonia.Controls/ToolTip.cs
  11. 1
      src/Avalonia.Controls/TopLevel.cs
  12. 11
      src/Avalonia.Controls/Utils/UndoRedoHelper.cs
  13. 8
      src/Avalonia.Diagnostics/Diagnostics/ViewModels/MainViewModel.cs
  14. 33
      src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreeNode.cs
  15. 123
      src/Avalonia.Diagnostics/Diagnostics/ViewModels/VisualTreeNode.cs
  16. 56
      src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml
  17. 165
      src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs
  18. 2
      src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml
  19. 3
      src/Avalonia.Input/Avalonia.Input.csproj
  20. 13
      src/Avalonia.Input/FocusManager.cs
  21. 17
      src/Avalonia.Input/ICustomKeyboardNavigation.cs
  22. 31
      src/Avalonia.Input/InputElement.cs
  23. 33
      src/Avalonia.Input/KeyboardNavigation.cs
  24. 111
      src/Avalonia.Input/KeyboardNavigationHandler.cs
  25. 7
      src/Avalonia.Input/KeyboardNavigationMode.cs
  26. 734
      src/Avalonia.Input/Navigation/TabNavigation.cs
  27. 8
      src/Avalonia.Native/AvaloniaNativePlatform.cs
  28. 35
      src/Avalonia.Native/AvaloniaNativePlatformExtensions.cs
  29. 3
      src/Avalonia.Visuals/VisualTree/IVisualTreeHost.cs
  30. 46
      src/Avalonia.X11/X11Platform.cs
  31. 37
      src/Windows/Avalonia.Win32/Win32Platform.cs
  32. 33
      tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs
  33. 122
      tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs
  34. 1
      tests/Avalonia.UnitTests/TestRoot.cs

17
src/Avalonia.Controls/ContextMenu.cs

@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using Avalonia.Controls.Diagnostics;
using Avalonia.Controls.Generators;
using Avalonia.Controls.Platform;
using Avalonia.Controls.Primitives;
@ -21,7 +21,7 @@ namespace Avalonia.Controls
/// <summary>
/// A control context menu.
/// </summary>
public class ContextMenu : MenuBase, ISetterValue
public class ContextMenu : MenuBase, ISetterValue, IPopupHostProvider
{
/// <summary>
/// Defines the <see cref="HorizontalOffset"/> property.
@ -82,6 +82,7 @@ namespace Avalonia.Controls
private Popup? _popup;
private List<Control>? _attachedControls;
private IInputElement? _previousFocus;
private Action<IPopupHost?>? _popupHostChangedHandler;
/// <summary>
/// Initializes a new instance of the <see cref="ContextMenu"/> class.
@ -304,6 +305,14 @@ namespace Avalonia.Controls
}
}
IPopupHost? IPopupHostProvider.PopupHost => _popup?.Host;
event Action<IPopupHost?>? IPopupHostProvider.PopupHostChanged
{
add => _popupHostChangedHandler += value;
remove => _popupHostChangedHandler -= value;
}
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new MenuItemContainerGenerator(this);
@ -364,6 +373,8 @@ namespace Avalonia.Controls
{
_previousFocus = FocusManager.Instance?.Current;
Focus();
_popupHostChangedHandler?.Invoke(_popup!.Host);
}
private void PopupClosing(object sender, CancelEventArgs e)
@ -397,6 +408,8 @@ namespace Avalonia.Controls
RoutedEvent = MenuClosedEvent,
Source = this,
});
_popupHostChangedHandler?.Invoke(null);
}
private void PopupKeyUp(object sender, KeyEventArgs e)

23
src/Avalonia.Controls/Diagnostics/IPopupHostProvider.cs

@ -0,0 +1,23 @@
using System;
using Avalonia.Controls.Primitives;
#nullable enable
namespace Avalonia.Controls.Diagnostics
{
/// <summary>
/// Diagnostics interface to retrieve an associated <see cref="IPopupHost"/>.
/// </summary>
public interface IPopupHostProvider
{
/// <summary>
/// The popup host.
/// </summary>
IPopupHost? PopupHost { get; }
/// <summary>
/// Raised when the popup host changes.
/// </summary>
event Action<IPopupHost?>? PopupHostChanged;
}
}

15
src/Avalonia.Controls/Diagnostics/ToolTipDiagnostics.cs

@ -0,0 +1,15 @@
#nullable enable
namespace Avalonia.Controls.Diagnostics
{
/// <summary>
/// Helper class to provide diagnostics information for <see cref="ToolTip"/>.
/// </summary>
public static class ToolTipDiagnostics
{
/// <summary>
/// Provides access to the internal <see cref="ToolTip.ToolTipProperty"/> for use in DevTools.
/// </summary>
public static AvaloniaProperty<ToolTip?> ToolTipProperty = ToolTip.ToolTipProperty;
}
}

18
src/Avalonia.Controls/Flyouts/FlyoutBase.cs

@ -1,19 +1,18 @@
using System;
using System.ComponentModel;
using Avalonia.Controls.Diagnostics;
using System.Linq;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Input.Raw;
using Avalonia.Layout;
using Avalonia.Logging;
using Avalonia.Rendering;
#nullable enable
namespace Avalonia.Controls.Primitives
{
public abstract class FlyoutBase : AvaloniaObject
public abstract class FlyoutBase : AvaloniaObject, IPopupHostProvider
{
static FlyoutBase()
{
@ -59,6 +58,7 @@ namespace Avalonia.Controls.Primitives
private Rect? _enlargedPopupRect;
private PixelRect? _enlargePopupRectScreenPixelRect;
private IDisposable? _transientDisposable;
private Action<IPopupHost?>? _popupHostChangedHandler;
public FlyoutBase()
{
@ -103,6 +103,14 @@ namespace Avalonia.Controls.Primitives
private set => SetAndRaise(TargetProperty, ref _target, value);
}
IPopupHost? IPopupHostProvider.PopupHost => Popup?.Host;
event Action<IPopupHost?>? IPopupHostProvider.PopupHostChanged
{
add => _popupHostChangedHandler += value;
remove => _popupHostChangedHandler -= value;
}
public event EventHandler? Closed;
public event EventHandler<CancelEventArgs>? Closing;
public event EventHandler? Opened;
@ -363,6 +371,8 @@ namespace Avalonia.Controls.Primitives
private void OnPopupOpened(object sender, EventArgs e)
{
IsOpen = true;
_popupHostChangedHandler?.Invoke(Popup!.Host);
}
private void OnPopupClosing(object sender, CancelEventArgs e)
@ -376,6 +386,8 @@ namespace Avalonia.Controls.Primitives
private void OnPopupClosed(object sender, EventArgs e)
{
HideCore(false);
_popupHostChangedHandler?.Invoke(null);
}
// This method is handling both popup logical tree and target logical tree.

6
src/Avalonia.Controls/Platform/InternalPlatformThreadingInterface.cs

@ -21,10 +21,12 @@ namespace Avalonia.Controls.Platform
public void RunLoop(CancellationToken cancellationToken)
{
while (true)
var handles = new[] { _signaled, cancellationToken.WaitHandle };
while (!cancellationToken.IsCancellationRequested)
{
Signaled?.Invoke(null);
_signaled.WaitOne();
WaitHandle.WaitAny(handles);
}
}

5
src/Avalonia.Controls/Primitives/OverlayPopupHost.cs

@ -140,10 +140,5 @@ namespace Avalonia.Controls.Primitives
return new OverlayPopupHost(overlayLayer);
}
public override void Render(DrawingContext context)
{
context.FillRectangle(Brushes.White, new Rect(default, Bounds.Size));
}
}
}

16
src/Avalonia.Controls/Primitives/Popup.cs

@ -2,6 +2,7 @@ using System;
using System.ComponentModel;
using System.Linq;
using System.Reactive.Disposables;
using Avalonia.Controls.Diagnostics;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Input;
@ -18,7 +19,7 @@ namespace Avalonia.Controls.Primitives
/// <summary>
/// Displays a popup window.
/// </summary>
public class Popup : Control, IVisualTreeHost
public class Popup : Control, IVisualTreeHost, IPopupHostProvider
{
public static readonly StyledProperty<bool> WindowManagerAddShadowHintProperty =
AvaloniaProperty.Register<PopupRoot, bool>(nameof(WindowManagerAddShadowHint), true);
@ -134,6 +135,7 @@ namespace Avalonia.Controls.Primitives
private bool _ignoreIsOpenChanged;
private PopupOpenState? _openState;
private IInputElement _overlayInputPassThroughElement;
private Action<IPopupHost?>? _popupHostChangedHandler;
/// <summary>
/// Initializes static members of the <see cref="Popup"/> class.
@ -351,6 +353,14 @@ namespace Avalonia.Controls.Primitives
/// </summary>
IVisual? IVisualTreeHost.Root => _openState?.PopupHost.HostedVisualTreeRoot;
IPopupHost? IPopupHostProvider.PopupHost => Host;
event Action<IPopupHost?>? IPopupHostProvider.PopupHostChanged
{
add => _popupHostChangedHandler += value;
remove => _popupHostChangedHandler -= value;
}
/// <summary>
/// Opens the popup.
/// </summary>
@ -482,6 +492,8 @@ namespace Avalonia.Controls.Primitives
}
Opened?.Invoke(this, EventArgs.Empty);
_popupHostChangedHandler?.Invoke(Host);
}
/// <summary>
@ -591,6 +603,8 @@ namespace Avalonia.Controls.Primitives
_openState.Dispose();
_openState = null;
_popupHostChangedHandler?.Invoke(null);
using (BeginIgnoringIsOpen())
{
IsOpen = false;

34
src/Avalonia.Controls/Primitives/ScrollBar.cs

@ -57,6 +57,18 @@ namespace Avalonia.Controls.Primitives
public static readonly StyledProperty<bool> AllowAutoHideProperty =
AvaloniaProperty.Register<ScrollBar, bool>(nameof(AllowAutoHide), true);
/// <summary>
/// Defines the <see cref="HideDelay"/> property.
/// </summary>
public static readonly StyledProperty<TimeSpan> HideDelayProperty =
AvaloniaProperty.Register<ScrollBar, TimeSpan>(nameof(HideDelay), TimeSpan.FromSeconds(2));
/// <summary>
/// Defines the <see cref="ShowDelay"/> property.
/// </summary>
public static readonly StyledProperty<TimeSpan> ShowDelayProperty =
AvaloniaProperty.Register<ScrollBar, TimeSpan>(nameof(ShowDelay), TimeSpan.FromSeconds(0.5));
private Button _lineUpButton;
private Button _lineDownButton;
private Button _pageUpButton;
@ -126,6 +138,24 @@ namespace Avalonia.Controls.Primitives
get => GetValue(AllowAutoHideProperty);
set => SetValue(AllowAutoHideProperty, value);
}
/// <summary>
/// Gets a value that determines how long will be the hide delay after user stops interacting with the scrollbar.
/// </summary>
public TimeSpan HideDelay
{
get => GetValue(HideDelayProperty);
set => SetValue(HideDelayProperty, value);
}
/// <summary>
/// Gets a value that determines how long will be the show delay when user starts interacting with the scrollbar.
/// </summary>
public TimeSpan ShowDelay
{
get => GetValue(ShowDelayProperty);
set => SetValue(ShowDelayProperty, value);
}
public event EventHandler<ScrollEventArgs> Scroll;
@ -296,12 +326,12 @@ namespace Avalonia.Controls.Primitives
private void CollapseAfterDelay()
{
InvokeAfterDelay(Collapse, TimeSpan.FromSeconds(2));
InvokeAfterDelay(Collapse, HideDelay);
}
private void ExpandAfterDelay()
{
InvokeAfterDelay(Expand, TimeSpan.FromMilliseconds(400));
InvokeAfterDelay(Expand, ShowDelay);
}
private void Collapse()

65
src/Avalonia.Controls/TextBox.cs

@ -31,7 +31,7 @@ namespace Avalonia.Controls
public static KeyGesture PasteGesture { get; } = AvaloniaLocator.Current
.GetService<PlatformHotkeyConfiguration>()?.Paste.FirstOrDefault();
public static readonly StyledProperty<bool> AcceptsReturnProperty =
AvaloniaProperty.Register<TextBox, bool>(nameof(AcceptsReturn));
@ -117,7 +117,7 @@ namespace Avalonia.Controls
public static readonly StyledProperty<bool> RevealPasswordProperty =
AvaloniaProperty.Register<TextBox, bool>(nameof(RevealPassword));
public static readonly DirectProperty<TextBox, bool> CanCutProperty =
AvaloniaProperty.RegisterDirect<TextBox, bool>(
nameof(CanCut),
@ -135,7 +135,7 @@ namespace Avalonia.Controls
public static readonly StyledProperty<bool> IsUndoEnabledProperty =
AvaloniaProperty.Register<TextBox, bool>(
nameof(IsUndoEnabled),
nameof(IsUndoEnabled),
defaultValue: true);
public static readonly DirectProperty<TextBox, int> UndoLimitProperty =
@ -157,6 +157,10 @@ namespace Avalonia.Controls
}
public bool Equals(UndoRedoState other) => ReferenceEquals(Text, other.Text) || Equals(Text, other.Text);
public override bool Equals(object obj) => obj is UndoRedoState other && Equals(other);
public override int GetHashCode() => Text.GetHashCode();
}
private string _text;
@ -174,6 +178,10 @@ namespace Avalonia.Controls
private string _newLine = Environment.NewLine;
private static readonly string[] invalidCharacters = new String[1] { "\u007f" };
private int _selectedTextChangesMadeSinceLastUndoSnapshot;
private bool _hasDoneSnapshotOnce;
private const int _maxCharsBeforeUndoSnapshot = 7;
static TextBox()
{
FocusableProperty.OverrideDefaultValue(typeof(TextBox), true);
@ -202,7 +210,8 @@ namespace Avalonia.Controls
horizontalScrollBarVisibility,
BindingPriority.Style);
_undoRedoHelper = new UndoRedoHelper<UndoRedoState>(this);
_selectedTextChangesMadeSinceLastUndoSnapshot = 0;
_hasDoneSnapshotOnce = false;
UpdatePseudoclasses();
}
@ -331,6 +340,7 @@ namespace Avalonia.Controls
if (SetAndRaise(TextProperty, ref _text, value) && IsUndoEnabled && !_isUndoingRedoing)
{
_undoRedoHelper.Clear();
SnapshotUndoRedo(); // so we always have an initial state
}
}
}
@ -341,16 +351,16 @@ namespace Avalonia.Controls
get { return GetSelection(); }
set
{
SnapshotUndoRedo();
if (string.IsNullOrEmpty(value))
{
_selectedTextChangesMadeSinceLastUndoSnapshot++;
SnapshotUndoRedo(ignoreChangeCount: false);
DeleteSelection();
}
else
{
HandleTextInput(value);
}
SnapshotUndoRedo();
}
}
@ -422,7 +432,7 @@ namespace Avalonia.Controls
get { return _newLine; }
set { SetAndRaise(NewLineProperty, ref _newLine, value); }
}
/// <summary>
/// Clears the current selection, maintaining the <see cref="CaretIndex"/>
/// </summary>
@ -480,11 +490,13 @@ namespace Avalonia.Controls
var oldValue = _undoRedoHelper.Limit;
_undoRedoHelper.Limit = value;
RaisePropertyChanged(UndoLimitProperty, oldValue, value);
}
}
// from docs at
// https://docs.microsoft.com/en-us/dotnet/api/system.windows.controls.primitives.textboxbase.isundoenabled:
// "Setting UndoLimit clears the undo queue."
_undoRedoHelper.Clear();
_selectedTextChangesMadeSinceLastUndoSnapshot = 0;
_hasDoneSnapshotOnce = false;
}
}
@ -515,6 +527,8 @@ namespace Avalonia.Controls
// Therefore, if you disable undo and then re-enable it, undo commands still do not work
// because the undo stack was emptied when you disabled undo."
_undoRedoHelper.Clear();
_selectedTextChangesMadeSinceLastUndoSnapshot = 0;
_hasDoneSnapshotOnce = false;
}
}
@ -577,23 +591,25 @@ namespace Avalonia.Controls
{
return;
}
input = RemoveInvalidCharacters(input);
if (string.IsNullOrEmpty(input))
{
return;
}
_selectedTextChangesMadeSinceLastUndoSnapshot++;
SnapshotUndoRedo(ignoreChangeCount: false);
string text = Text ?? string.Empty;
int caretIndex = CaretIndex;
int newLength = input.Length + text.Length - Math.Abs(SelectionStart - SelectionEnd);
if (MaxLength > 0 && newLength > MaxLength)
{
input = input.Remove(Math.Max(0, input.Length - (newLength - MaxLength)));
}
if (!string.IsNullOrEmpty(input))
{
DeleteSelection();
@ -627,7 +643,6 @@ namespace Avalonia.Controls
SnapshotUndoRedo();
Copy();
DeleteSelection();
SnapshotUndoRedo();
}
public async void Copy()
@ -647,7 +662,6 @@ namespace Avalonia.Controls
SnapshotUndoRedo();
HandleTextInput(text);
SnapshotUndoRedo();
}
protected override void OnKeyDown(KeyEventArgs e)
@ -696,6 +710,7 @@ namespace Avalonia.Controls
{
try
{
SnapshotUndoRedo();
_isUndoingRedoing = true;
_undoRedoHelper.Undo();
}
@ -830,7 +845,6 @@ namespace Avalonia.Controls
CaretIndex -= removedCharacters;
ClearSelection();
}
SnapshotUndoRedo();
handled = true;
break;
@ -858,7 +872,6 @@ namespace Avalonia.Controls
SetTextInternal(text.Substring(0, caretIndex) +
text.Substring(caretIndex + removedCharacters));
}
SnapshotUndoRedo();
handled = true;
break;
@ -868,7 +881,6 @@ namespace Avalonia.Controls
{
SnapshotUndoRedo();
HandleTextInput(NewLine);
SnapshotUndoRedo();
handled = true;
}
@ -879,7 +891,6 @@ namespace Avalonia.Controls
{
SnapshotUndoRedo();
HandleTextInput("\t");
SnapshotUndoRedo();
handled = true;
}
else
@ -889,6 +900,10 @@ namespace Avalonia.Controls
break;
case Key.Space:
SnapshotUndoRedo(); // always snapshot in between words
break;
default:
handled = false;
break;
@ -1319,11 +1334,19 @@ namespace Avalonia.Controls
}
}
private void SnapshotUndoRedo()
private void SnapshotUndoRedo(bool ignoreChangeCount = true)
{
if (IsUndoEnabled)
{
_undoRedoHelper.Snapshot();
if (ignoreChangeCount ||
!_hasDoneSnapshotOnce ||
(!ignoreChangeCount &&
_selectedTextChangesMadeSinceLastUndoSnapshot >= _maxCharsBeforeUndoSnapshot))
{
_undoRedoHelper.Snapshot();
_selectedTextChangesMadeSinceLastUndoSnapshot = 0;
_hasDoneSnapshotOnce = true;
}
}
}
}

40
src/Avalonia.Controls/ToolTip.cs

@ -1,9 +1,8 @@
#nullable enable
using System;
using System.Reactive.Linq;
using Avalonia.Controls.Diagnostics;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.VisualTree;
namespace Avalonia.Controls
{
@ -17,7 +16,7 @@ namespace Avalonia.Controls
/// assigning the content that you want displayed.
/// </remarks>
[PseudoClasses(":open")]
public class ToolTip : ContentControl
public class ToolTip : ContentControl, IPopupHostProvider
{
/// <summary>
/// Defines the ToolTip.Tip attached property.
@ -61,7 +60,8 @@ namespace Avalonia.Controls
internal static readonly AttachedProperty<ToolTip?> ToolTipProperty =
AvaloniaProperty.RegisterAttached<ToolTip, Control, ToolTip?>("ToolTip");
private IPopupHost? _popup;
private IPopupHost? _popupHost;
private Action<IPopupHost?>? _popupHostChangedHandler;
/// <summary>
/// Initializes static members of the <see cref="ToolTip"/> class.
@ -251,35 +251,45 @@ namespace Avalonia.Controls
tooltip.RecalculatePosition(control);
}
IPopupHost? IPopupHostProvider.PopupHost => _popupHost;
event Action<IPopupHost?>? IPopupHostProvider.PopupHostChanged
{
add => _popupHostChangedHandler += value;
remove => _popupHostChangedHandler -= value;
}
internal void RecalculatePosition(Control control)
{
_popup?.ConfigurePosition(control, GetPlacement(control), new Point(GetHorizontalOffset(control), GetVerticalOffset(control)));
_popupHost?.ConfigurePosition(control, GetPlacement(control), new Point(GetHorizontalOffset(control), GetVerticalOffset(control)));
}
private void Open(Control control)
{
Close();
_popup = OverlayPopupHost.CreatePopupHost(control, null);
_popup.SetChild(this);
((ISetLogicalParent)_popup).SetParent(control);
_popupHost = OverlayPopupHost.CreatePopupHost(control, null);
_popupHost.SetChild(this);
((ISetLogicalParent)_popupHost).SetParent(control);
_popup.ConfigurePosition(control, GetPlacement(control),
_popupHost.ConfigurePosition(control, GetPlacement(control),
new Point(GetHorizontalOffset(control), GetVerticalOffset(control)));
WindowManagerAddShadowHintChanged(_popup, false);
WindowManagerAddShadowHintChanged(_popupHost, false);
_popup.Show();
_popupHost.Show();
_popupHostChangedHandler?.Invoke(_popupHost);
}
private void Close()
{
if (_popup != null)
if (_popupHost != null)
{
_popup.SetChild(null);
_popup.Dispose();
_popup = null;
_popupHost.SetChild(null);
_popupHost.Dispose();
_popupHost = null;
_popupHostChangedHandler?.Invoke(null);
}
}

1
src/Avalonia.Controls/TopLevel.cs

@ -90,6 +90,7 @@ namespace Avalonia.Controls
/// </summary>
static TopLevel()
{
KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue<TopLevel>(KeyboardNavigationMode.Cycle);
AffectsMeasure<TopLevel>(ClientSizeProperty);
TransparencyLevelHintProperty.Changed.AddClassHandler<TopLevel>(

11
src/Avalonia.Controls/Utils/UndoRedoHelper.cs

@ -7,7 +7,7 @@ using Avalonia.Utilities;
namespace Avalonia.Controls.Utils
{
class UndoRedoHelper<TState> : WeakTimer.IWeakTimerSubscriber where TState : struct, IEquatable<TState>
class UndoRedoHelper<TState>
{
private readonly IUndoRedoHost _host;
@ -31,7 +31,6 @@ namespace Avalonia.Controls.Utils
public UndoRedoHelper(IUndoRedoHost host)
{
_host = host;
WeakTimer.StartWeakTimer(this, TimeSpan.FromSeconds(1));
}
public void Undo()
@ -61,7 +60,7 @@ namespace Avalonia.Controls.Utils
if (_states.Last != null)
{
_states.Last.Value = state;
}
}
}
public void UpdateLastState()
@ -103,11 +102,5 @@ namespace Avalonia.Controls.Utils
_states.Clear();
_currentNode = null;
}
bool WeakTimer.IWeakTimerSubscriber.Tick()
{
Snapshot();
return true;
}
}
}

8
src/Avalonia.Diagnostics/Diagnostics/ViewModels/MainViewModel.cs

@ -1,6 +1,5 @@
using System;
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Diagnostics.Models;
using Avalonia.Input;
@ -22,6 +21,7 @@ namespace Avalonia.Diagnostics.ViewModels
private bool _shouldVisualizeMarginPadding = true;
private bool _shouldVisualizeDirtyRects;
private bool _showFpsOverlay;
private bool _freezePopups;
#nullable disable
// Remove "nullable disable" after MemberNotNull will work on our CI.
@ -41,6 +41,12 @@ namespace Avalonia.Diagnostics.ViewModels
Console = new ConsoleViewModel(UpdateConsoleContext);
}
public bool FreezePopups
{
get => _freezePopups;
set => RaiseAndSetIfChanged(ref _freezePopups, value);
}
public bool ShouldVisualizeMarginPadding
{
get => _shouldVisualizeMarginPadding;

33
src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreeNode.cs

@ -1,26 +1,28 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Reactive;
using System.Reactive.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.LogicalTree;
using Avalonia.Media;
using Avalonia.VisualTree;
namespace Avalonia.Diagnostics.ViewModels
{
internal abstract class TreeNode : ViewModelBase, IDisposable
{
private IDisposable? _classesSubscription;
private readonly IDisposable? _classesSubscription;
private string _classes;
private bool _isExpanded;
public TreeNode(IVisual visual, TreeNode? parent)
protected TreeNode(IVisual visual, TreeNode? parent, string? customName = null)
{
_classes = string.Empty;
Parent = parent;
Type = visual.GetType().Name;
Type = customName ?? visual.GetType().Name;
Visual = visual;
_classes = string.Empty;
FontWeight = IsRoot ? FontWeight.Bold : FontWeight.Normal;
if (visual is IControl control)
{
@ -52,6 +54,12 @@ namespace Avalonia.Diagnostics.ViewModels
}
}
private bool IsRoot => Visual is TopLevel ||
Visual is ContextMenu ||
Visual is IPopupHost;
public FontWeight FontWeight { get; }
public abstract TreeNodeCollection Children
{
get;
@ -95,20 +103,5 @@ namespace Avalonia.Diagnostics.ViewModels
_classesSubscription?.Dispose();
Children.Dispose();
}
private static int IndexOf(IReadOnlyList<TreeNode> collection, TreeNode item)
{
var count = collection.Count;
for (var i = 0; i < count; ++i)
{
if (collection[i] == item)
{
return i;
}
}
throw new AvaloniaInternalException("TreeNode was not present in parent Children collection.");
}
}
}

123
src/Avalonia.Diagnostics/Diagnostics/ViewModels/VisualTreeNode.cs

@ -1,5 +1,10 @@
using System;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Controls.Diagnostics;
using Avalonia.Controls.Primitives;
using Avalonia.Styling;
using Avalonia.VisualTree;
@ -7,31 +12,30 @@ namespace Avalonia.Diagnostics.ViewModels
{
internal class VisualTreeNode : TreeNode
{
public VisualTreeNode(IVisual visual, TreeNode? parent)
: base(visual, parent)
public VisualTreeNode(IVisual visual, TreeNode? parent, string? customName = null)
: base(visual, parent, customName)
{
Children = new VisualTreeNodeCollection(this, visual);
if ((Visual is IStyleable styleable))
{
if (Visual is IStyleable styleable)
IsInTemplate = styleable.TemplatedParent != null;
}
}
public bool IsInTemplate { get; private set; }
public bool IsInTemplate { get; }
public override TreeNodeCollection Children { get; }
public static VisualTreeNode[] Create(object control)
{
var visual = control as IVisual;
return visual != null ? new[] { new VisualTreeNode(visual, null) } : Array.Empty<VisualTreeNode>();
return control is IVisual visual ?
new[] { new VisualTreeNode(visual, null) } :
Array.Empty<VisualTreeNode>();
}
internal class VisualTreeNodeCollection : TreeNodeCollection
{
private readonly IVisual _control;
private IDisposable? _subscription;
private readonly CompositeDisposable _subscriptions = new CompositeDisposable(2);
public VisualTreeNodeCollection(TreeNode owner, IVisual control)
: base(owner)
@ -41,15 +45,106 @@ namespace Avalonia.Diagnostics.ViewModels
public override void Dispose()
{
_subscription?.Dispose();
_subscriptions.Dispose();
}
private static IObservable<PopupRoot?>? GetHostedPopupRootObservable(IVisual visual)
{
static IObservable<PopupRoot?> GetPopupHostObservable(
IPopupHostProvider popupHostProvider,
string? providerName = null)
{
return Observable.FromEvent<IPopupHost?>(
x => popupHostProvider.PopupHostChanged += x,
x => popupHostProvider.PopupHostChanged -= x)
.StartWith(popupHostProvider.PopupHost)
.Select(popupHost =>
{
if (popupHost is IControl control)
return new PopupRoot(
control,
providerName != null ? $"{providerName} ({control.GetType().Name})" : null);
return (PopupRoot?)null;
});
}
return visual switch
{
Popup p => GetPopupHostObservable(p),
Control c => Observable.CombineLatest(
c.GetObservable(Control.ContextFlyoutProperty),
c.GetObservable(Control.ContextMenuProperty),
c.GetObservable(FlyoutBase.AttachedFlyoutProperty),
c.GetObservable(ToolTipDiagnostics.ToolTipProperty),
(ContextFlyout, ContextMenu, AttachedFlyout, ToolTip) =>
{
if (ContextMenu != null)
//Note: ContextMenus are special since all the items are added as visual children.
//So we don't need to go via Popup
return Observable.Return<PopupRoot?>(new PopupRoot(ContextMenu));
if (ContextFlyout != null)
return GetPopupHostObservable(ContextFlyout, "ContextFlyout");
if (AttachedFlyout != null)
return GetPopupHostObservable(AttachedFlyout, "AttachedFlyout");
if (ToolTip != null)
return GetPopupHostObservable(ToolTip, "ToolTip");
return Observable.Return<PopupRoot?>(null);
})
.Switch(),
_ => null
};
}
protected override void Initialize(AvaloniaList<TreeNode> nodes)
{
_subscription = _control.VisualChildren.ForEachItem(
(i, item) => nodes.Insert(i, new VisualTreeNode(item, Owner)),
(i, item) => nodes.RemoveAt(i),
() => nodes.Clear());
_subscriptions.Clear();
if (GetHostedPopupRootObservable(_control) is { } popupRootObservable)
{
VisualTreeNode? childNode = null;
_subscriptions.Add(
popupRootObservable
.Subscribe(popupRoot =>
{
if (popupRoot != null)
{
childNode = new VisualTreeNode(
popupRoot.Value.Root,
Owner,
popupRoot.Value.CustomName);
nodes.Add(childNode);
}
else if (childNode != null)
{
nodes.Remove(childNode);
}
}));
}
_subscriptions.Add(
_control.VisualChildren.ForEachItem(
(i, item) => nodes.Insert(i, new VisualTreeNode(item, Owner)),
(i, item) => nodes.RemoveAt(i),
() => nodes.Clear()));
}
private struct PopupRoot
{
public PopupRoot(IControl root, string? customName = null)
{
Root = root;
CustomName = customName;
}
public IControl Root { get; }
public string? CustomName { get; }
}
}
}

56
src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml

@ -5,14 +5,14 @@
<Grid Name="rootGrid" RowDefinitions="Auto,Auto,*,Auto,0,Auto">
<Menu>
<MenuItem Header="_File">
<MenuItem Header="E_xit" Command="{Binding $parent[Window].Close}"/>
<MenuItem Header="E_xit" Command="{Binding $parent[Window].Close}" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="_Console" Command="{Binding $parent[UserControl].ToggleConsole}">
<MenuItem.Icon>
<CheckBox BorderThickness="0"
IsChecked="{Binding Console.IsVisible}"
IsEnabled="False"/>
IsEnabled="False" />
</MenuItem.Icon>
</MenuItem>
</MenuItem>
@ -21,58 +21,68 @@
<MenuItem.Icon>
<CheckBox BorderThickness="0"
IsChecked="{Binding ShouldVisualizeMarginPadding}"
IsEnabled="False"/>
IsEnabled="False" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Visualize dirty rects" Command="{Binding ToggleVisualizeDirtyRects}">
<MenuItem.Icon>
<CheckBox BorderThickness="0"
IsChecked="{Binding ShouldVisualizeDirtyRects}"
IsEnabled="False"/>
IsEnabled="False" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Show fps overlay" Command="{Binding ToggleFpsOverlay}">
<MenuItem Header="Show fps overlay" Command="{Binding ToggleFpsOverlay}">
<MenuItem.Icon>
<CheckBox BorderThickness="0"
IsChecked="{Binding ShowFpsOverlay}"
IsEnabled="False"/>
IsEnabled="False" />
</MenuItem.Icon>
</MenuItem>
</MenuItem>
</Menu>
<TabStrip Grid.Row="1" SelectedIndex="{Binding SelectedTab, Mode=TwoWay}">
<TabStripItem Content="Logical Tree"/>
<TabStripItem Content="Visual Tree"/>
<TabStripItem Content="Events"/>
<TabStripItem Content="Logical Tree" />
<TabStripItem Content="Visual Tree" />
<TabStripItem Content="Events" />
</TabStrip>
<ContentControl Grid.Row="2"
BorderBrush="{DynamicResource ThemeControlMidBrush}"
BorderThickness="0,1,0,0"
Content="{Binding Content}"/>
Content="{Binding Content}" />
<GridSplitter Name="consoleSplitter" Grid.Row="3" Height="1"
Background="{DynamicResource ThemeControlMidBrush}"
IsVisible="False"/>
IsVisible="False" />
<views:ConsoleView Name="console"
Grid.Row="4"
DataContext="{Binding Console}"
IsVisible="{Binding IsVisible}"/>
IsVisible="{Binding IsVisible}" />
<Border Grid.Row="5"
BorderBrush="{DynamicResource ThemeControlMidBrush}"
BorderThickness="0,1,0,0">
<StackPanel Spacing="4" Orientation="Horizontal">
<TextBlock>Hold Ctrl+Shift over a control to inspect.</TextBlock>
<Separator Width="8"/>
<TextBlock>Focused:</TextBlock>
<TextBlock Text="{Binding FocusedControl}"/>
<Separator Width="8"/>
<TextBlock>Pointer Over:</TextBlock>
<TextBlock Text="{Binding PointerOverElement}"/>
</StackPanel>
<Grid ColumnDefinitions="*, Auto">
<StackPanel Grid.Column="0" Spacing="4" Orientation="Horizontal">
<TextBlock>Hold Ctrl+Shift over a control to inspect.</TextBlock>
<Separator Width="8" />
<TextBlock>Focused:</TextBlock>
<TextBlock Text="{Binding FocusedControl}" />
<Separator Width="8" />
<TextBlock>Pointer Over:</TextBlock>
<TextBlock Text="{Binding PointerOverElement}" />
</StackPanel>
<TextBlock Grid.Column="1"
Foreground="Gray"
Margin="2 0"
Text="Popups frozen"
IsVisible="{Binding FreezePopups}" />
</Grid>
</Border>
</Grid>
</UserControl>

165
src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs

@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Diagnostics;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Diagnostics.ViewModels;
using Avalonia.Input;
using Avalonia.Input.Raw;
@ -15,6 +18,7 @@ namespace Avalonia.Diagnostics.Views
internal class MainWindow : Window, IStyleHost
{
private readonly IDisposable _keySubscription;
private readonly Dictionary<Popup, IDisposable> _frozenPopupStates;
private TopLevel? _root;
public MainWindow()
@ -23,23 +27,26 @@ namespace Avalonia.Diagnostics.Views
_keySubscription = InputManager.Instance.Process
.OfType<RawKeyEventArgs>()
.Where(x => x.Type == RawKeyEventType.KeyDown)
.Subscribe(RawKeyDown);
_frozenPopupStates = new Dictionary<Popup, IDisposable>();
EventHandler? lh = default;
lh = (s, e) =>
{
this.Opened -= lh;
if ((DataContext as MainViewModel)?.StartupScreenIndex is int index)
{
var screens = this.Screens;
if (index > -1 && index < screens.ScreenCount)
{
var screen = screens.All[index];
this.Position = screen.Bounds.TopLeft;
this.WindowState = WindowState.Maximized;
}
}
};
{
this.Opened -= lh;
if ((DataContext as MainViewModel)?.StartupScreenIndex is { } index)
{
var screens = this.Screens;
if (index > -1 && index < screens.ScreenCount)
{
var screen = screens.All[index];
this.Position = screen.Bounds.TopLeft;
this.WindowState = WindowState.Maximized;
}
}
};
this.Opened += lh;
}
@ -77,6 +84,13 @@ namespace Avalonia.Diagnostics.Views
base.OnClosed(e);
_keySubscription.Dispose();
foreach (var state in _frozenPopupStates)
{
state.Value.Dispose();
}
_frozenPopupStates.Clear();
if (_root != null)
{
_root.Closed -= RootClosed;
@ -91,6 +105,53 @@ namespace Avalonia.Diagnostics.Views
AvaloniaXamlLoader.Load(this);
}
private IControl? GetHoveredControl(TopLevel topLevel)
{
#pragma warning disable CS0618 // Type or member is obsolete
var point = (topLevel as IInputRoot)?.MouseDevice?.GetPosition(topLevel) ?? default;
#pragma warning restore CS0618 // Type or member is obsolete
return (IControl?)topLevel.GetVisualsAt(point, x =>
{
if (x is AdornerLayer || !x.IsVisible)
{
return false;
}
return !(x is IInputElement ie) || ie.IsHitTestVisible;
})
.FirstOrDefault();
}
private static List<PopupRoot> GetPopupRoots(IVisual root)
{
var popupRoots = new List<PopupRoot>();
void ProcessProperty<T>(IControl control, AvaloniaProperty<T> property)
{
if (control.GetValue(property) is IPopupHostProvider popupProvider
&& popupProvider.PopupHost is PopupRoot popupRoot)
{
popupRoots.Add(popupRoot);
}
}
foreach (var control in root.GetVisualDescendants().OfType<IControl>())
{
if (control is Popup p && p.Host is PopupRoot popupRoot)
{
popupRoots.Add(popupRoot);
}
ProcessProperty(control, ContextFlyoutProperty);
ProcessProperty(control, ContextMenuProperty);
ProcessProperty(control, FlyoutBase.AttachedFlyoutProperty);
ProcessProperty(control, ToolTipDiagnostics.ToolTipProperty);
}
return popupRoots;
}
private void RawKeyDown(RawKeyEventArgs e)
{
var vm = (MainViewModel?)DataContext;
@ -99,34 +160,72 @@ namespace Avalonia.Diagnostics.Views
return;
}
const RawInputModifiers modifiers = RawInputModifiers.Control | RawInputModifiers.Shift;
if (e.Modifiers == modifiers)
switch (e.Modifiers)
{
#pragma warning disable CS0618 // Type or member is obsolete
var point = (Root as IInputRoot)?.MouseDevice?.GetPosition(Root) ?? default;
#pragma warning restore CS0618 // Type or member is obsolete
case RawInputModifiers.Control | RawInputModifiers.Shift:
{
IControl? control = null;
foreach (var popupRoot in GetPopupRoots(Root))
{
control = GetHoveredControl(popupRoot);
if (control != null)
{
break;
}
}
control ??= GetHoveredControl(Root);
var control = Root.GetVisualsAt(point, x =>
if (control != null)
{
if (x is AdornerLayer || !x.IsVisible) return false;
if (!(x is IInputElement ie)) return true;
return ie.IsHitTestVisible;
})
.FirstOrDefault();
vm.SelectControl(control);
}
break;
}
if (control != null)
case RawInputModifiers.Control | RawInputModifiers.Alt when e.Key == Key.F:
{
vm.SelectControl((IControl)control);
vm.FreezePopups = !vm.FreezePopups;
foreach (var popupRoot in GetPopupRoots(Root))
{
if (popupRoot.Parent is Popup popup)
{
if (vm.FreezePopups)
{
var lightDismissEnabledState = popup.SetValue(
Popup.IsLightDismissEnabledProperty,
!vm.FreezePopups,
BindingPriority.Animation);
if (lightDismissEnabledState != null)
{
_frozenPopupStates[popup] = lightDismissEnabledState;
}
}
else
{
//TODO Use Dictionary.Remove(Key, out Value) in netstandard 2.1
if (_frozenPopupStates.ContainsKey(popup))
{
_frozenPopupStates[popup].Dispose();
_frozenPopupStates.Remove(popup);
}
}
}
}
break;
}
}
else if (e.Modifiers == RawInputModifiers.Alt)
{
if (e.Key == Key.S || e.Key == Key.D)
case RawInputModifiers.Alt when e.Key == Key.S || e.Key == Key.D:
{
var enable = e.Key == Key.S;
vm.EnableSnapshotStyles(e.Key == Key.S);
vm.EnableSnapshotStyles(enable);
break;
}
}
}

2
src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml

@ -11,7 +11,7 @@
<TreeDataTemplate DataType="vm:TreeNode"
ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Type}"/>
<TextBlock Text="{Binding Type}" FontWeight="{Binding FontWeight}"/>
<TextBlock Text="{Binding Classes}"/>
<TextBlock Foreground="Gray" Text="{Binding ElementName}"/>
</StackPanel>

3
src/Avalonia.Input/Avalonia.Input.csproj

@ -4,6 +4,9 @@
<Nullable>Enable</Nullable>
<WarningsAsErrors>CS8600;CS8602;CS8603</WarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Avalonia.Base\Metadata\NullableAttributes.cs" Link="NullableAttributes.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Avalonia.Animation\Avalonia.Animation.csproj" />
<ProjectReference Include="..\Avalonia.Base\Avalonia.Base.csproj" />

13
src/Avalonia.Input/FocusManager.cs

@ -92,6 +92,17 @@ namespace Avalonia.Input
}
}
public IInputElement? GetFocusedElement(IInputElement e)
{
if (e is IFocusScope scope)
{
_focusScopes.TryGetValue(scope, out var result);
return result;
}
return null;
}
/// <summary>
/// Sets the currently focused element in the specified scope.
/// </summary>
@ -151,6 +162,8 @@ namespace Avalonia.Input
Focus(e);
}
public static bool GetIsFocusScope(IInputElement e) => e is IFocusScope;
/// <summary>
/// Checks if the specified element can be focused.
/// </summary>

17
src/Avalonia.Input/ICustomKeyboardNavigation.cs

@ -1,4 +1,5 @@

#nullable enable
namespace Avalonia.Input
{
/// <summary>
@ -6,6 +7,18 @@ namespace Avalonia.Input
/// </summary>
public interface ICustomKeyboardNavigation
{
(bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction);
/// <summary>
/// Gets the next element in the specified navigation direction.
/// </summary>
/// <param name="element">The element being navigated from.</param>
/// <param name="direction">The navigation direction.</param>
/// <returns>
/// A tuple consisting of:
/// - A boolean indicating whether the request was handled. If false is returned then
/// custom navigation will be ignored and default navigation will take place.
/// - If handled is true: the next element in the navigation direction, or null if default
/// navigation should continue outside the element.
/// </returns>
(bool handled, IInputElement? next) GetNext(IInputElement element, NavigationDirection direction);
}
}

31
src/Avalonia.Input/InputElement.cs

@ -71,6 +71,12 @@ namespace Avalonia.Input
public static readonly DirectProperty<InputElement, bool> IsPointerOverProperty =
AvaloniaProperty.RegisterDirect<InputElement, bool>(nameof(IsPointerOver), o => o.IsPointerOver);
/// <summary>
/// Defines the <see cref="IsTabStop"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsTabStopProperty =
KeyboardNavigation.IsTabStopProperty.AddOwner<InputElement>();
/// <summary>
/// Defines the <see cref="GotFocus"/> event.
/// </summary>
@ -99,6 +105,12 @@ namespace Avalonia.Input
"KeyUp",
RoutingStrategies.Tunnel | RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="TabIndex"/> property.
/// </summary>
public static readonly StyledProperty<int> TabIndexProperty =
KeyboardNavigation.TabIndexProperty.AddOwner<InputElement>();
/// <summary>
/// Defines the <see cref="TextInput"/> event.
/// </summary>
@ -426,6 +438,15 @@ namespace Avalonia.Input
internal set { SetAndRaise(IsPointerOverProperty, ref _isPointerOver, value); }
}
/// <summary>
/// Gets or sets a value that indicates whether the control is included in tab navigation.
/// </summary>
public bool IsTabStop
{
get => GetValue(IsTabStopProperty);
set => SetValue(IsTabStopProperty, value);
}
/// <inheritdoc/>
public bool IsEffectivelyEnabled
{
@ -437,6 +458,16 @@ namespace Avalonia.Input
}
}
/// <summary>
/// Gets or sets a value that determines the order in which elements receive focus when the
/// user navigates through controls by pressing the Tab key.
/// </summary>
public int TabIndex
{
get => GetValue(TabIndexProperty);
set => SetValue(TabIndexProperty, value);
}
public List<KeyBinding> KeyBindings { get; } = new List<KeyBinding>();
/// <summary>

33
src/Avalonia.Input/KeyboardNavigation.cs

@ -5,6 +5,15 @@ namespace Avalonia.Input
/// </summary>
public static class KeyboardNavigation
{
/// <summary>
/// Defines the TabIndex attached property.
/// </summary>
public static readonly AttachedProperty<int> TabIndexProperty =
AvaloniaProperty.RegisterAttached<StyledElement, int>(
"TabIndex",
typeof(KeyboardNavigation),
int.MaxValue);
/// <summary>
/// Defines the TabNavigation attached property.
/// </summary>
@ -42,6 +51,26 @@ namespace Avalonia.Input
typeof(KeyboardNavigation),
true);
/// <summary>
/// Gets the <see cref="TabIndexProperty"/> for an element.
/// </summary>
/// <param name="element">The container.</param>
/// <returns>The <see cref="KeyboardNavigationMode"/> for the container.</returns>
public static int GetTabIndex(IInputElement element)
{
return ((IAvaloniaObject)element).GetValue(TabIndexProperty);
}
/// <summary>
/// Sets the <see cref="TabIndexProperty"/> for an element.
/// </summary>
/// <param name="element">The element.</param>
/// <param name="value">The tab index.</param>
public static void SetTabIndex(IInputElement element, int value)
{
((IAvaloniaObject)element).SetValue(TabIndexProperty, value);
}
/// <summary>
/// Gets the <see cref="TabNavigationProperty"/> for a container.
/// </summary>
@ -83,7 +112,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Sets the <see cref="IsTabStopProperty"/> for a container.
/// Sets the <see cref="IsTabStopProperty"/> for an element.
/// </summary>
/// <param name="element">The container.</param>
/// <param name="value">Value indicating whether the container is a tab stop.</param>
@ -93,7 +122,7 @@ namespace Avalonia.Input
}
/// <summary>
/// Gets the <see cref="IsTabStopProperty"/> for a container.
/// Gets the <see cref="IsTabStopProperty"/> for an element.
/// </summary>
/// <param name="element">The container.</param>
/// <returns>Whether the container is a tab stop.</returns>

111
src/Avalonia.Input/KeyboardNavigationHandler.cs

@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Avalonia.Input.Navigation;
using Avalonia.VisualTree;
@ -48,39 +49,24 @@ namespace Avalonia.Input
{
element = element ?? throw new ArgumentNullException(nameof(element));
var customHandler = element.GetSelfAndVisualAncestors()
.OfType<ICustomKeyboardNavigation>()
.FirstOrDefault();
// If there's a custom keyboard navigation handler as an ancestor, use that.
var custom = element.FindAncestorOfType<ICustomKeyboardNavigation>(true);
if (custom is object && HandlePreCustomNavigation(custom, element, direction, out var ce))
return ce;
if (customHandler != null)
var result = direction switch
{
var (handled, next) = customHandler.GetNext(element, direction);
NavigationDirection.Next => TabNavigation.GetNextTab(element, false),
NavigationDirection.Previous => TabNavigation.GetPrevTab(element, null, false),
_ => throw new NotSupportedException(),
};
if (handled)
{
if (next != null)
{
return next;
}
else if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
{
return TabNavigation.GetNextInTabOrder((IInputElement)customHandler, direction, true);
}
else
{
return null;
}
}
}
// If there wasn't a custom navigation handler as an ancestor of the current element,
// but there is one as an ancestor of the new element, use that.
if (custom is null && HandlePostCustomNavigation(element, result, direction, out ce))
return ce;
if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
{
return TabNavigation.GetNextInTabOrder(element, direction);
}
else
{
throw new NotSupportedException();
}
return result;
}
/// <summary>
@ -90,7 +76,7 @@ namespace Avalonia.Input
/// <param name="direction">The direction to move.</param>
/// <param name="keyModifiers">Any key modifiers active at the time of focus.</param>
public void Move(
IInputElement element,
IInputElement element,
NavigationDirection direction,
KeyModifiers keyModifiers = KeyModifiers.None)
{
@ -124,5 +110,70 @@ namespace Avalonia.Input
e.Handled = true;
}
}
private static bool HandlePreCustomNavigation(
ICustomKeyboardNavigation customHandler,
IInputElement element,
NavigationDirection direction,
[NotNullWhen(true)] out IInputElement? result)
{
if (customHandler != null)
{
var (handled, next) = customHandler.GetNext(element, direction);
if (handled)
{
if (next != null)
{
result = next;
return true;
}
else if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
{
var r = direction switch
{
NavigationDirection.Next => TabNavigation.GetNextTabOutside(customHandler),
NavigationDirection.Previous => TabNavigation.GetPrevTabOutside(customHandler),
_ => throw new NotSupportedException(),
};
if (r is object)
{
result = r;
return true;
}
}
}
}
result = null;
return false;
}
private static bool HandlePostCustomNavigation(
IInputElement element,
IInputElement? newElement,
NavigationDirection direction,
[NotNullWhen(true)] out IInputElement? result)
{
if (newElement is object)
{
var customHandler = newElement.FindAncestorOfType<ICustomKeyboardNavigation>(true);
if (customHandler is object)
{
var (handled, next) = customHandler.GetNext(element, direction);
if (handled && next is object)
{
result = next;
return true;
}
}
}
result = null;
return false;
}
}
}

7
src/Avalonia.Input/KeyboardNavigationMode.cs

@ -36,5 +36,10 @@ namespace Avalonia.Input
/// The container's children will not be focused when using the tab key.
/// </summary>
None,
/// <summary>
/// TabIndexes are considered on local subtree only inside this container
/// </summary>
Local,
}
}
}

734
src/Avalonia.Input/Navigation/TabNavigation.cs

@ -10,277 +10,663 @@ namespace Avalonia.Input.Navigation
/// </summary>
internal static class TabNavigation
{
/// <summary>
/// Gets the next control in the specified tab direction.
/// </summary>
/// <param name="element">The element.</param>
/// <param name="direction">The tab direction. Must be Next or Previous.</param>
/// <param name="outsideElement">
/// If true will not descend into <paramref name="element"/> to find next control.
/// </param>
/// <returns>
/// The next element in the specified direction, or null if <paramref name="element"/>
/// was the last in the requested direction.
/// </returns>
public static IInputElement? GetNextInTabOrder(
IInputElement element,
NavigationDirection direction,
bool outsideElement = false)
public static IInputElement? GetNextTab(IInputElement e, bool goDownOnly)
{
element = element ?? throw new ArgumentNullException(nameof(element));
if (direction != NavigationDirection.Next && direction != NavigationDirection.Previous)
{
throw new ArgumentException("Invalid direction: must be Next or Previous.");
}
return GetNextTab(e, GetGroupParent(e), goDownOnly);
}
var container = element.GetVisualParent<IInputElement>();
public static IInputElement? GetNextTab(IInputElement? e, IInputElement container, bool goDownOnly)
{
var tabbingType = GetKeyNavigationMode(container);
if (container != null)
if (e == null)
{
var mode = KeyboardNavigation.GetTabNavigation((InputElement)container);
if (IsTabStop(container))
return container;
switch (mode)
// Using ActiveElement if set
var activeElement = GetActiveElement(container);
if (activeElement != null)
return GetNextTab(null, activeElement, true);
}
else
{
if (tabbingType == KeyboardNavigationMode.Once || tabbingType == KeyboardNavigationMode.None)
{
case KeyboardNavigationMode.Continue:
return GetNextInContainer(element, container, direction, outsideElement) ??
GetFirstInNextContainer(element, element, direction);
case KeyboardNavigationMode.Cycle:
return GetNextInContainer(element, container, direction, outsideElement) ??
GetFocusableDescendant(container, direction);
case KeyboardNavigationMode.Contained:
return GetNextInContainer(element, container, direction, outsideElement);
default:
return GetFirstInNextContainer(element, container, direction);
if (container != e)
{
if (goDownOnly)
return null;
var parentContainer = GetGroupParent(container);
return GetNextTab(container, parentContainer, goDownOnly);
}
}
}
else
// All groups
IInputElement? loopStartElement = null;
var nextTabElement = e;
var currentTabbingType = tabbingType;
// Search down inside the container
while ((nextTabElement = GetNextTabInGroup(nextTabElement, container, currentTabbingType)) != null)
{
return GetFocusableDescendants(element, direction).FirstOrDefault();
// Avoid the endless loop here for Cycle groups
if (loopStartElement == nextTabElement)
break;
if (loopStartElement == null)
loopStartElement = nextTabElement;
var firstTabElementInside = GetNextTab(null, nextTabElement, true);
if (firstTabElementInside != null)
return firstTabElementInside;
// If we want to continue searching inside the Once groups, we should change the navigation mode
if (currentTabbingType == KeyboardNavigationMode.Once)
currentTabbingType = KeyboardNavigationMode.Contained;
}
// If there is no next element in the group (nextTabElement == null)
// Search up in the tree if allowed
// consider: Use original tabbingType instead of currentTabbingType
if (!goDownOnly && currentTabbingType != KeyboardNavigationMode.Contained && GetParent(container) != null)
{
return GetNextTab(container, GetGroupParent(container), false);
}
return null;
}
/// <summary>
/// Gets the first or last focusable descendant of the specified element.
/// </summary>
/// <param name="container">The element.</param>
/// <param name="direction">The direction to search.</param>
/// <returns>The element or null if not found.##</returns>
private static IInputElement GetFocusableDescendant(IInputElement container, NavigationDirection direction)
public static IInputElement? GetNextTabOutside(ICustomKeyboardNavigation e)
{
return direction == NavigationDirection.Next ?
GetFocusableDescendants(container, direction).FirstOrDefault() :
GetFocusableDescendants(container, direction).LastOrDefault();
if (e is IInputElement container)
{
var last = GetLastInTree(container);
if (last is object)
return GetNextTab(last, false);
}
return null;
}
/// <summary>
/// Gets the focusable descendants of the specified element.
/// </summary>
/// <param name="element">The element.</param>
/// <param name="direction">The tab direction. Must be Next or Previous.</param>
/// <returns>The element's focusable descendants.</returns>
private static IEnumerable<IInputElement> GetFocusableDescendants(IInputElement element,
NavigationDirection direction)
public static IInputElement? GetPrevTab(IInputElement? e, IInputElement? container, bool goDownOnly)
{
var mode = KeyboardNavigation.GetTabNavigation((InputElement)element);
if (e is null && container is null)
throw new InvalidOperationException("Either 'e' or 'container' must be non-null.");
if (mode == KeyboardNavigationMode.None)
{
yield break;
}
if (container is null)
container = GetGroupParent(e!);
var children = element.GetVisualChildren().OfType<IInputElement>();
KeyboardNavigationMode tabbingType = GetKeyNavigationMode(container);
if (mode == KeyboardNavigationMode.Once)
if (e == null)
{
var active = KeyboardNavigation.GetTabOnceActiveElement((InputElement)element);
if (active != null)
// Using ActiveElement if set
var activeElement = GetActiveElement(container);
if (activeElement != null)
return GetPrevTab(null, activeElement, true);
else
{
yield return active;
yield break;
// If we Shift+Tab on a container with KeyboardNavigationMode=Once, and ActiveElement is null
// then we want to go to the first item (not last) within the container
if (tabbingType == KeyboardNavigationMode.Once)
{
var firstTabElement = GetNextTabInGroup(null, container, tabbingType);
if (firstTabElement == null)
{
if (IsTabStop(container))
return container;
if (goDownOnly)
return null;
return GetPrevTab(container, null, false);
}
else
{
return GetPrevTab(null, firstTabElement, true);
}
}
}
else
}
else
{
if (tabbingType == KeyboardNavigationMode.Once || tabbingType == KeyboardNavigationMode.None)
{
children = children.Take(1);
if (goDownOnly || container == e)
return null;
// FocusedElement should not be e otherwise we will delegate focus to the same element
if (IsTabStop(container))
return container;
return GetPrevTab(container, null, false);
}
}
foreach (var child in children)
// All groups (except Once) - continue
IInputElement? loopStartElement = null;
IInputElement? nextTabElement = e;
// Look for element with the same TabIndex before the current element
while ((nextTabElement = GetPrevTabInGroup(nextTabElement, container, tabbingType)) != null)
{
var customNext = GetCustomNext(child, direction);
if (nextTabElement == container && tabbingType == KeyboardNavigationMode.Local)
break;
// At this point nextTabElement is TabStop or TabGroup
// In case it is a TabStop only return the element
if (IsTabStop(nextTabElement) && !IsGroup(nextTabElement))
return nextTabElement;
// Avoid the endless loop here
if (loopStartElement == nextTabElement)
break;
if (loopStartElement == null)
loopStartElement = nextTabElement;
// At this point nextTabElement is TabGroup
var lastTabElementInside = GetPrevTab(null, nextTabElement, true);
if (lastTabElementInside != null)
return lastTabElementInside;
}
if (customNext.handled)
{
yield return customNext.next!;
}
else
if (tabbingType == KeyboardNavigationMode.Contained)
return null;
if (e != container && IsTabStop(container))
return container;
// If end of the subtree is reached or there no other elements above
if (!goDownOnly && GetParent(container) != null)
{
return GetPrevTab(container, null, false);
}
return null;
}
public static IInputElement? GetPrevTabOutside(ICustomKeyboardNavigation e)
{
if (e is IInputElement container)
{
var first = GetFirstChild(container);
if (first is object)
return GetPrevTab(first, null, false);
}
return null;
}
private static IInputElement? FocusedElement(IInputElement e)
{
var iie = e;
// Focus delegation is enabled only if keyboard focus is outside the container
if (iie != null && !iie.IsKeyboardFocusWithin)
{
var focusedElement = (FocusManager.Instance as FocusManager)?.GetFocusedElement(e);
if (focusedElement != null)
{
if (child.CanFocus() && KeyboardNavigation.GetIsTabStop((InputElement)child))
if (!IsFocusScope(e))
{
yield return child;
// Verify if focusedElement is a visual descendant of e
if (focusedElement is IVisual visualFocusedElement &&
visualFocusedElement != e &&
e.IsVisualAncestorOf(visualFocusedElement))
{
return focusedElement;
}
}
}
}
return null;
}
private static IInputElement? GetFirstChild(IInputElement e)
{
// If the element has a FocusedElement it should be its first child
if (FocusedElement(e) is IInputElement focusedElement)
return focusedElement;
if (child.CanFocusDescendants())
// Return the first visible element.
var uiElement = e as InputElement;
if (uiElement is null || uiElement.IsVisible)
{
if (e is IVisual elementAsVisual)
{
var children = elementAsVisual.VisualChildren;
var count = children.Count;
for (int i = 0; i < count; i++)
{
foreach (var descendant in GetFocusableDescendants(child, direction))
if (children[i] is InputElement ie)
{
if (KeyboardNavigation.GetIsTabStop((InputElement)descendant))
if (ie.IsVisible)
return ie;
else
{
yield return descendant;
var firstChild = GetFirstChild(ie);
if (firstChild != null)
return firstChild;
}
}
}
}
}
return null;
}
/// <summary>
/// Gets the next item that should be focused in the specified container.
/// </summary>
/// <param name="element">The starting element/</param>
/// <param name="container">The container.</param>
/// <param name="direction">The direction.</param>
/// <param name="outsideElement">
/// If true will not descend into <paramref name="element"/> to find next control.
/// </param>
/// <returns>The next element, or null if the element is the last.</returns>
private static IInputElement? GetNextInContainer(
IInputElement element,
IInputElement container,
NavigationDirection direction,
bool outsideElement)
private static IInputElement? GetLastChild(IInputElement e)
{
IInputElement? e = element;
// If the element has a FocusedElement it should be its last child
if (FocusedElement(e) is IInputElement focusedElement)
return focusedElement;
if (direction == NavigationDirection.Next && !outsideElement)
{
var descendant = GetFocusableDescendants(element, direction).FirstOrDefault();
// Return the last visible element.
var uiElement = e as InputElement;
if (descendant != null)
{
return descendant;
}
}
if (container != null)
if (uiElement == null || uiElement.IsVisible)
{
var navigable = container as INavigableContainer;
var elementAsVisual = e as IVisual;
// TODO: Do a spatial search here if the container doesn't implement
// INavigableContainer.
if (navigable != null)
if (elementAsVisual != null)
{
while (e != null)
{
e = navigable.GetControl(direction, e, false);
var children = elementAsVisual.VisualChildren;
var count = children.Count;
if (e != null &&
e.CanFocus() &&
KeyboardNavigation.GetIsTabStop((InputElement)e))
for (int i = count - 1; i >= 0; i--)
{
if (children[i] is InputElement ie)
{
break;
if (ie.IsVisible)
return ie;
else
{
var lastChild = GetLastChild(ie);
if (lastChild != null)
return lastChild;
}
}
}
}
else
}
return null;
}
private static IInputElement? GetFirstTabInGroup(IInputElement container)
{
IInputElement? firstTabElement = null;
int minIndexFirstTab = int.MinValue;
var currElement = container;
while ((currElement = GetNextInTree(currElement, container)) != null)
{
if (IsTabStopOrGroup(currElement))
{
// TODO: Do a spatial search here if the container doesn't implement
// INavigableContainer.
e = null;
int currPriority = KeyboardNavigation.GetTabIndex(currElement);
if (currPriority < minIndexFirstTab || firstTabElement == null)
{
minIndexFirstTab = currPriority;
firstTabElement = currElement;
}
}
}
return firstTabElement;
}
private static IInputElement? GetLastInTree(IInputElement container)
{
IInputElement? result;
IInputElement? c = container;
do
{
result = c;
c = GetLastChild(c);
} while (c != null && !IsGroup(c));
if (c != null)
return c;
return result;
}
if (e != null && direction == NavigationDirection.Previous)
private static IInputElement? GetLastTabInGroup(IInputElement container)
{
IInputElement? lastTabElement = null;
int maxIndexFirstTab = int.MaxValue;
var currElement = GetLastInTree(container);
while (currElement != null && currElement != container)
{
if (IsTabStopOrGroup(currElement))
{
var descendant = GetFocusableDescendants(e, direction).LastOrDefault();
int currPriority = KeyboardNavigation.GetTabIndex(currElement);
if (descendant != null)
if (currPriority > maxIndexFirstTab || lastTabElement == null)
{
return descendant;
maxIndexFirstTab = currPriority;
lastTabElement = currElement;
}
}
currElement = GetPreviousInTree(currElement, container);
}
return lastTabElement;
}
private static IInputElement? GetNextInTree(IInputElement e, IInputElement container)
{
IInputElement? result = null;
if (e == container || !IsGroup(e))
result = GetFirstChild(e);
if (result != null || e == container)
return result;
IInputElement? parent = e;
do
{
var sibling = GetNextSibling(parent);
if (sibling != null)
return sibling;
return e;
parent = GetParent(parent);
} while (parent != null && parent != container);
return null;
}
private static IInputElement? GetNextSibling(IInputElement e)
{
if (GetParent(e) is IVisual parentAsVisual && e is IVisual elementAsVisual)
{
var children = parentAsVisual.VisualChildren;
var count = children.Count;
var i = 0;
//go till itself
for (; i < count; i++)
{
var vchild = children[i];
if (vchild == elementAsVisual)
break;
}
i++;
//search ahead
for (; i < count; i++)
{
var visual = children[i];
if (visual is IInputElement ie)
return ie;
}
}
return null;
}
/// <summary>
/// Gets the first item that should be focused in the next container.
/// </summary>
/// <param name="element">The element being navigated away from.</param>
/// <param name="container">The container.</param>
/// <param name="direction">The direction of the search.</param>
/// <returns>The first element, or null if there are no more elements.</returns>
private static IInputElement? GetFirstInNextContainer(
IInputElement element,
IInputElement container,
NavigationDirection direction)
private static IInputElement? GetNextTabInGroup(IInputElement? e, IInputElement container, KeyboardNavigationMode tabbingType)
{
var parent = container.GetVisualParent<IInputElement>();
IInputElement? next = null;
// None groups: Tab navigation is not supported
if (tabbingType == KeyboardNavigationMode.None)
return null;
if (parent != null)
// e == null or e == container -> return the first TabStopOrGroup
if (e == null || e == container)
{
if (direction == NavigationDirection.Previous &&
parent.CanFocus() &&
KeyboardNavigation.GetIsTabStop((InputElement) parent))
return GetFirstTabInGroup(container);
}
if (tabbingType == KeyboardNavigationMode.Once)
return null;
var nextTabElement = GetNextTabWithSameIndex(e, container);
if (nextTabElement != null)
return nextTabElement;
return GetNextTabWithNextIndex(e, container, tabbingType);
}
private static IInputElement? GetNextTabWithSameIndex(IInputElement e, IInputElement container)
{
var elementTabPriority = KeyboardNavigation.GetTabIndex(e);
var currElement = e;
while ((currElement = GetNextInTree(currElement, container)) != null)
{
if (IsTabStopOrGroup(currElement) && KeyboardNavigation.GetTabIndex(currElement) == elementTabPriority)
{
return parent;
return currElement;
}
}
var allSiblings = parent.GetVisualChildren()
.OfType<IInputElement>()
.Where(FocusExtensions.CanFocusDescendants);
var siblings = direction == NavigationDirection.Next ?
allSiblings.SkipWhile(x => x != container).Skip(1) :
allSiblings.TakeWhile(x => x != container).Reverse();
return null;
}
foreach (var sibling in siblings)
private static IInputElement? GetNextTabWithNextIndex(IInputElement e, IInputElement container, KeyboardNavigationMode tabbingType)
{
// Find the next min index in the tree
// min (index>currentTabIndex)
IInputElement? nextTabElement = null;
IInputElement? firstTabElement = null;
int minIndexFirstTab = int.MinValue;
int minIndex = int.MinValue;
int elementTabPriority = KeyboardNavigation.GetTabIndex(e);
IInputElement? currElement = container;
while ((currElement = GetNextInTree(currElement, container)) != null)
{
if (IsTabStopOrGroup(currElement))
{
var customNext = GetCustomNext(sibling, direction);
if (customNext.handled)
int currPriority = KeyboardNavigation.GetTabIndex(currElement);
if (currPriority > elementTabPriority)
{
return customNext.next;
if (currPriority < minIndex || nextTabElement == null)
{
minIndex = currPriority;
nextTabElement = currElement;
}
}
if (sibling.CanFocus() && KeyboardNavigation.GetIsTabStop((InputElement) sibling))
if (currPriority < minIndexFirstTab || firstTabElement == null)
{
return sibling;
minIndexFirstTab = currPriority;
firstTabElement = currElement;
}
}
}
next = direction == NavigationDirection.Next ?
GetFocusableDescendants(sibling, direction).FirstOrDefault() :
GetFocusableDescendants(sibling, direction).LastOrDefault();
// Cycle groups: if not found - return first element
if (tabbingType == KeyboardNavigationMode.Cycle && nextTabElement == null)
nextTabElement = firstTabElement;
return nextTabElement;
}
if (next != null)
private static IInputElement? GetPrevTabInGroup(IInputElement? e, IInputElement container, KeyboardNavigationMode tabbingType)
{
// None groups: Tab navigation is not supported
if (tabbingType == KeyboardNavigationMode.None)
return null;
// Search the last index inside the group
if (e == null)
{
return GetLastTabInGroup(container);
}
if (tabbingType == KeyboardNavigationMode.Once)
return null;
if (e == container)
return null;
var nextTabElement = GetPrevTabWithSameIndex(e, container);
if (nextTabElement != null)
return nextTabElement;
return GetPrevTabWithPrevIndex(e, container, tabbingType);
}
private static IInputElement? GetPrevTabWithSameIndex(IInputElement e, IInputElement container)
{
int elementTabPriority = KeyboardNavigation.GetTabIndex(e);
var currElement = GetPreviousInTree(e, container);
while (currElement != null)
{
if (IsTabStopOrGroup(currElement) && KeyboardNavigation.GetTabIndex(currElement) == elementTabPriority && currElement != container)
{
return currElement;
}
currElement = GetPreviousInTree(currElement, container);
}
return null;
}
private static IInputElement? GetPrevTabWithPrevIndex(IInputElement e, IInputElement container, KeyboardNavigationMode tabbingType)
{
// Find the next max index in the tree
// max (index<currentTabIndex)
IInputElement? lastTabElement = null;
IInputElement? nextTabElement = null;
int elementTabPriority = KeyboardNavigation.GetTabIndex(e);
int maxIndexFirstTab = Int32.MaxValue;
int maxIndex = Int32.MaxValue;
var currElement = GetLastInTree(container);
while (currElement != null)
{
if (IsTabStopOrGroup(currElement) && currElement != container)
{
int currPriority = KeyboardNavigation.GetTabIndex(currElement);
if (currPriority < elementTabPriority)
{
if (currPriority > maxIndex || nextTabElement == null)
{
maxIndex = currPriority;
nextTabElement = currElement;
}
}
if (currPriority > maxIndexFirstTab || lastTabElement == null)
{
return next;
maxIndexFirstTab = currPriority;
lastTabElement = currElement;
}
}
next = GetFirstInNextContainer(element, parent, direction);
currElement = GetPreviousInTree(currElement, container);
}
// Cycle groups: if not found - return first element
if (tabbingType == KeyboardNavigationMode.Cycle && nextTabElement == null)
nextTabElement = lastTabElement;
return nextTabElement;
}
private static IInputElement? GetPreviousInTree(IInputElement e, IInputElement container)
{
if (e == container)
return null;
var result = GetPreviousSibling(e);
if (result != null)
{
if (IsGroup(result))
return result;
else
return GetLastInTree(result);
}
else
return GetParent(e);
}
private static IInputElement? GetPreviousSibling(IInputElement e)
{
if (GetParent(e) is IVisual parentAsVisual && e is IVisual elementAsVisual)
{
next = direction == NavigationDirection.Next ?
GetFocusableDescendants(container, direction).FirstOrDefault() :
GetFocusableDescendants(container, direction).LastOrDefault();
var children = parentAsVisual.VisualChildren;
var count = children.Count;
IInputElement? prev = null;
for (int i = 0; i < count; i++)
{
var vchild = children[i];
if (vchild == elementAsVisual)
break;
if (vchild.IsVisible == true && vchild is IInputElement ie)
prev = ie;
}
return prev;
}
return null;
}
return next;
private static IInputElement? GetActiveElement(IInputElement e)
{
return ((IAvaloniaObject)e).GetValue(KeyboardNavigation.TabOnceActiveElementProperty);
}
private static (bool handled, IInputElement? next) GetCustomNext(IInputElement element,
NavigationDirection direction)
private static IInputElement GetGroupParent(IInputElement e) => GetGroupParent(e, false);
private static IInputElement GetGroupParent(IInputElement element, bool includeCurrent)
{
if (element is ICustomKeyboardNavigation custom)
var result = element; // Keep the last non null element
var e = element;
// If we don't want to include the current element,
// start at the parent of the element. If the element
// is the root, then just return it as the group parent.
if (!includeCurrent)
{
result = e;
e = GetParent(e);
if (e == null)
return result;
}
while (e != null)
{
return custom.GetNext(element, direction);
if (IsGroup(e))
return e;
result = e;
e = GetParent(e);
}
return (false, null);
return result;
}
private static IInputElement? GetParent(IInputElement e)
{
// For Visual - go up the visual parent chain until we find Visual.
if (e is IVisual v)
return v.FindAncestorOfType<IInputElement>();
// This will need to be implemented when we have non-visual input elements.
throw new NotSupportedException();
}
private static KeyboardNavigationMode GetKeyNavigationMode(IInputElement e)
{
return ((IAvaloniaObject)e).GetValue(KeyboardNavigation.TabNavigationProperty);
}
private static bool IsFocusScope(IInputElement e) => FocusManager.GetIsFocusScope(e) || GetParent(e) == null;
private static bool IsGroup(IInputElement e) => GetKeyNavigationMode(e) != KeyboardNavigationMode.Continue;
private static bool IsTabStop(IInputElement e)
{
if (e is InputElement ie)
return ie.Focusable && KeyboardNavigation.GetIsTabStop(ie) && ie.IsVisible && ie.IsEnabled;
return false;
}
private static bool IsTabStopOrGroup(IInputElement e) => IsTabStop(e) || IsGroup(e);
}
}

8
src/Avalonia.Native/AvaloniaNativePlatform.cs

@ -109,11 +109,17 @@ namespace Avalonia.Native
.Bind<IRenderLoop>().ToConstant(new RenderLoop())
.Bind<IRenderTimer>().ToConstant(new DefaultRenderTimer(60))
.Bind<ISystemDialogImpl>().ToConstant(new SystemDialogs(_factory.CreateSystemDialogs()))
.Bind<PlatformHotkeyConfiguration>().ToConstant(new PlatformHotkeyConfiguration(KeyModifiers.Meta))
.Bind<PlatformHotkeyConfiguration>().ToConstant(new PlatformHotkeyConfiguration(KeyModifiers.Meta, wholeWordTextActionModifiers: KeyModifiers.Alt))
.Bind<IMountedVolumeInfoProvider>().ToConstant(new MacOSMountedVolumeInfoProvider())
.Bind<IPlatformDragSource>().ToConstant(new AvaloniaNativeDragSource(_factory))
.Bind<IPlatformLifetimeEventsImpl>().ToConstant(applicationPlatform);
var hotkeys = AvaloniaLocator.Current.GetService<PlatformHotkeyConfiguration>();
hotkeys.MoveCursorToTheStartOfLine.Add(new KeyGesture(Key.Left, hotkeys.CommandModifiers));
hotkeys.MoveCursorToTheStartOfLineWithSelection.Add(new KeyGesture(Key.Left, hotkeys.CommandModifiers | hotkeys.SelectionModifiers));
hotkeys.MoveCursorToTheEndOfLine.Add(new KeyGesture(Key.Right, hotkeys.CommandModifiers));
hotkeys.MoveCursorToTheEndOfLineWithSelection.Add(new KeyGesture(Key.Right, hotkeys.CommandModifiers | hotkeys.SelectionModifiers));
if (_options.UseGpu)
{
try

35
src/Avalonia.Native/AvaloniaNativePlatformExtensions.cs

@ -26,19 +26,52 @@ namespace Avalonia
}
}
/// <summary>
/// OSX backend options.
/// </summary>
public class AvaloniaNativePlatformOptions
{
/// <summary>
/// Deferred renderer would be used when set to true. Immediate renderer when set to false. The default value is true.
/// </summary>
/// <remarks>
/// Avalonia has two rendering modes: Immediate and Deferred rendering.
/// Immediate re-renders the whole scene when some element is changed on the scene. Deferred re-renders only changed elements.
/// </remarks>
public bool UseDeferredRendering { get; set; } = true;
/// <summary>
/// Determines whether to use GPU for rendering in your project. The default value is true.
/// </summary>
public bool UseGpu { get; set; } = true;
/// <summary>
/// Embeds popups to the window when set to true. The default value is false.
/// </summary>
public bool OverlayPopups { get; set; }
/// <summary>
/// This property should be used in case you want to build Avalonia OSX native part by yourself
/// and make your Avalonia app run with it. The default value is null.
/// </summary>
public string AvaloniaNativeLibraryPath { get; set; }
}
// ReSharper disable once InconsistentNaming
/// <summary>
/// OSX front-end options.
/// </summary>
public class MacOSPlatformOptions
{
/// <summary>
/// Determines whether to show your application in the dock when it runs. The default value is true.
/// </summary>
public bool ShowInDock { get; set; } = true;
/// <summary>
/// By default, Avalonia adds items like Quit, Hide to the OSX Application Menu.
/// You can prevent Avalonia from adding those items to the OSX Application Menu with this property. The default value is false.
/// </summary>
public bool DisableDefaultApplicationMenuItems { get; set; }
}
}

3
src/Avalonia.Visuals/VisualTree/IVisualTreeHost.cs

@ -1,8 +1,11 @@
using System;
namespace Avalonia.VisualTree
{
/// <summary>
/// Interface for controls that host their own separate visual tree, such as popups.
/// </summary>
[Obsolete]
public interface IVisualTreeHost
{
/// <summary>

46
src/Avalonia.X11/X11Platform.cs

@ -162,14 +162,49 @@ namespace Avalonia.X11
namespace Avalonia
{
/// <summary>
/// Platform-specific options which apply to Linux.
/// </summary>
public class X11PlatformOptions
{
/// <summary>
/// Enables native Linux EGL when set to true. The default value is false.
/// </summary>
public bool UseEGL { get; set; }
/// <summary>
/// Determines whether to use GPU for rendering in your project. The default value is true.
/// </summary>
public bool UseGpu { get; set; } = true;
/// <summary>
/// Embeds popups to the window when set to true. The default value is false.
/// </summary>
public bool OverlayPopups { get; set; }
/// <summary>
/// Enables global menu support on Linux desktop environments where it's supported (e. g. XFCE and MATE with plugin, KDE, etc).
/// The default value is false.
/// </summary>
public bool UseDBusMenu { get; set; }
/// <summary>
/// Deferred renderer would be used when set to true. Immediate renderer when set to false. The default value is true.
/// </summary>
/// <remarks>
/// Avalonia has two rendering modes: Immediate and Deferred rendering.
/// Immediate re-renders the whole scene when some element is changed on the scene. Deferred re-renders only changed elements.
/// </remarks>
public bool UseDeferredRendering { get; set; } = true;
/// <summary>
/// Determines whether to use IME.
/// IME would be enabled by default if the current user input language is one of the following: Mandarin, Japanese, Vietnamese or Korean.
/// </summary>
/// <remarks>
/// Input method editor is a component that enables users to generate characters not natively available
/// on their input devices by using sequences of characters or mouse operations that are natively available on their input devices.
/// </remarks>
public bool? EnableIme { get; set; }
public IList<GlVersion> GlProfiles { get; set; } = new List<GlVersion>
@ -190,7 +225,14 @@ namespace Avalonia
"llvmpipe"
};
public string WmClass { get; set; } = Assembly.GetEntryAssembly()?.GetName()?.Name ?? "AvaloniaApplication";
public bool? EnableMultiTouch { get; set; }
/// <summary>
/// Enables multitouch support. The default value is true.
/// </summary>
/// <remarks>
/// Multitouch allows a surface (a touchpad or touchscreen) to recognize the presence of more than one point of contact with the surface at the same time.
/// </remarks>
public bool? EnableMultiTouch { get; set; } = true;
}
public static class AvaloniaX11PlatformExtensions
{

37
src/Windows/Avalonia.Win32/Win32Platform.cs

@ -35,15 +35,46 @@ namespace Avalonia
}
}
/// <summary>
/// Platform-specific options which apply to Windows.
/// </summary>
public class Win32PlatformOptions
{
/// <summary>
/// Deferred renderer would be used when set to true. Immediate renderer when set to false. The default value is true.
/// </summary>
/// <remarks>
/// Avalonia has two rendering modes: Immediate and Deferred rendering.
/// Immediate re-renders the whole scene when some element is changed on the scene. Deferred re-renders only changed elements.
/// </remarks>
public bool UseDeferredRendering { get; set; } = true;
/// <summary>
/// Enables ANGLE for Windows. For every Windows version that is above Windows 7, the default is true otherwise it's false.
/// </summary>
/// <remarks>
/// GPU rendering will not be enabled if this is set to false.
/// </remarks>
public bool? AllowEglInitialization { get; set; }
public bool? EnableMultitouch { get; set; }
/// <summary>
/// Enables multitouch support. The default value is true.
/// </summary>
/// <remarks>
/// Multitouch allows a surface (a touchpad or touchscreen) to recognize the presence of more than one point of contact with the surface at the same time.
/// </remarks>
public bool? EnableMultitouch { get; set; } = true;
/// <summary>
/// Embeds popups to the window when set to true. The default value is false.
/// </summary>
public bool OverlayPopups { get; set; }
/// <summary>
/// Avalonia would try to use native Widows OpenGL when set to true. The default value is false.
/// </summary>
public bool UseWgl { get; set; }
public IList<GlVersion> WglProfiles { get; set; } = new List<GlVersion>
{
new GlVersion(GlProfileType.OpenGL, 4, 0),

33
tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs

@ -95,6 +95,7 @@ namespace Avalonia.Input.UnitTests
var root = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
target,
@ -125,6 +126,7 @@ namespace Avalonia.Input.UnitTests
var root = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(current = new Button { Content = "Outside" }),
@ -137,6 +139,36 @@ namespace Avalonia.Input.UnitTests
Assert.Same(next, result);
}
[Fact]
public void ShiftTab_Should_Navigate_Outside_When_Null_Returned_As_Next()
{
Button current;
Button next;
var target = new CustomNavigatingStackPanel
{
Children =
{
new Button { Content = "Button 1" },
(current = new Button { Content = "Button 2" }),
new Button { Content = "Button 3" },
},
};
var root = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
target,
(next = new Button { Content = "Outside" }),
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous);
Assert.Same(next, result);
}
[Fact]
public void Tab_Should_Navigate_Outside_When_Null_Returned_As_Next()
{
@ -154,6 +186,7 @@ namespace Avalonia.Input.UnitTests
var root = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
target,

122
tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs

@ -1,3 +1,4 @@
using System.Collections.Generic;
using Avalonia.Controls;
using Xunit;
@ -13,6 +14,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -49,6 +51,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -85,6 +88,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -122,6 +126,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -165,6 +170,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -193,6 +199,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -222,6 +229,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -263,6 +271,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(next = new Button { Name = "Button1" }),
@ -282,6 +291,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -324,6 +334,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -361,6 +372,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -398,6 +410,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -434,6 +447,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -471,6 +485,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -509,6 +524,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(container = new StackPanel
@ -548,6 +564,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -586,6 +603,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(container = new StackPanel
@ -625,6 +643,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -661,6 +680,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -697,6 +717,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -725,6 +746,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -767,6 +789,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -828,6 +851,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -865,6 +889,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -902,6 +927,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -938,6 +964,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -975,6 +1002,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -1013,6 +1041,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(container = new StackPanel
@ -1052,6 +1081,7 @@ namespace Avalonia.Input.UnitTests
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
@ -1103,5 +1133,97 @@ namespace Avalonia.Input.UnitTests
Assert.Null(result);
}
[Fact]
public void Respects_TabIndex_Moving_Forwards()
{
Button start;
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
{
Children =
{
new Button { Name = "Button1", TabIndex = 5 },
(start = new Button { Name = "Button2", TabIndex = 2 }),
new Button { Name = "Button3", TabIndex = 1 },
}
},
new StackPanel
{
Children =
{
new Button { Name = "Button4", TabIndex = 3 },
new Button { Name = "Button5", TabIndex = 6 },
new Button { Name = "Button6", TabIndex = 4 },
}
},
}
};
var result = new List<string>();
var current = (IInputElement)start;
do
{
result.Add(((IControl)current).Name);
current = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
} while (current is object && current != start);
Assert.Equal(new[]
{
"Button2", "Button4", "Button6", "Button1", "Button5", "Button3"
}, result);
}
[Fact]
public void Respects_TabIndex_Moving_Backwards()
{
Button start;
var top = new StackPanel
{
[KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new StackPanel
{
Children =
{
new Button { Name = "Button1", TabIndex = 5 },
(start = new Button { Name = "Button2", TabIndex = 2 }),
new Button { Name = "Button3", TabIndex = 1 },
}
},
new StackPanel
{
Children =
{
new Button { Name = "Button4", TabIndex = 3 },
new Button { Name = "Button5", TabIndex = 6 },
new Button { Name = "Button6", TabIndex = 4 },
}
},
}
};
var result = new List<string>();
var current = (IInputElement)start;
do
{
result.Add(((IControl)current).Name);
current = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous);
} while (current is object && current != start);
Assert.Equal(new[]
{
"Button2", "Button3", "Button5", "Button1", "Button6", "Button4"
}, result);
}
}
}

1
tests/Avalonia.UnitTests/TestRoot.cs

@ -21,6 +21,7 @@ namespace Avalonia.UnitTests
Renderer = Mock.Of<IRenderer>();
LayoutManager = new LayoutManager(this);
IsVisible = true;
KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Cycle);
}
public TestRoot(IControl child)

Loading…
Cancel
Save