Browse Source

Merge remote-tracking branch 'origin/master' into windowing-prototype-drag-and-drop

windowing-prototype-drag-and-drop
Dan Walmsley 8 years ago
parent
commit
558c8f56db
  1. 2
      build/Microsoft.CSharp.props
  2. 2
      build/Microsoft.Reactive.Testing.props
  3. 2
      build/ReactiveUI.props
  4. 10
      build/Rx.props
  5. 2
      packages.cake
  6. 3
      readme.md
  7. 13
      samples/ControlCatalog/Pages/RadioButtonPage.xaml
  8. 9
      scripts/ReplaceNugetCache.ps1
  9. 13
      src/Avalonia.Base/Threading/Dispatcher.cs
  10. 11
      src/Avalonia.Base/Threading/IDispatcher.cs
  11. 168
      src/Avalonia.Base/Threading/JobRunner.cs
  12. 3
      src/Avalonia.Controls/Canvas.cs
  13. 148
      src/Avalonia.Controls/ContextMenu.cs
  14. 21
      src/Avalonia.Controls/IMenu.cs
  15. 40
      src/Avalonia.Controls/IMenuElement.cs
  16. 41
      src/Avalonia.Controls/IMenuItem.cs
  17. 87
      src/Avalonia.Controls/ItemsControl.cs
  18. 250
      src/Avalonia.Controls/Menu.cs
  19. 301
      src/Avalonia.Controls/MenuItem.cs
  20. 459
      src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs
  21. 22
      src/Avalonia.Controls/Platform/IMenuInteractionHandler.cs
  22. 9
      src/Avalonia.Controls/Presenters/ItemsPresenter.cs
  23. 36
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  24. 161
      src/Avalonia.Controls/Primitives/UniformGrid.cs
  25. 176
      src/Avalonia.Controls/RadioButton.cs
  26. 58
      src/Avalonia.Controls/StackPanel.cs
  27. 86
      src/Avalonia.Controls/TreeView.cs
  28. 7
      src/Avalonia.Controls/TreeViewItem.cs
  29. 5
      src/Avalonia.Controls/WrapPanel.cs
  30. 37
      src/Avalonia.Input/AccessKeyHandler.cs
  31. 2
      src/Avalonia.Input/IInputElement.cs
  32. 7
      src/Avalonia.Input/IMainMenu.cs
  33. 3
      src/Avalonia.Input/INavigableContainer.cs
  34. 2
      src/Avalonia.Input/InputElement.cs
  35. 33
      src/Avalonia.Input/KeyboardNavigation.cs
  36. 47
      src/Avalonia.Input/KeyboardNavigationHandler.cs
  37. 242
      src/Avalonia.Input/Navigation/DirectionalNavigation.cs
  38. 2
      src/Avalonia.Input/Navigation/TabNavigation.cs
  39. 70
      src/Avalonia.Input/NavigationDirection.cs
  40. 7
      src/Avalonia.Themes.Default/MenuItem.xaml
  41. 3
      src/Avalonia.Themes.Default/Separator.xaml
  42. 4
      src/Markup/Avalonia.Markup/Data/Binding.cs
  43. 7
      src/Windows/Avalonia.Win32/Win32Platform.cs
  44. 72
      tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs
  45. 507
      tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs
  46. 144
      tests/Avalonia.Controls.UnitTests/Primitives/UniformGridTests.cs
  47. 38
      tests/Avalonia.Controls.UnitTests/RadioButtonTests.cs
  48. 2
      tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
  49. 799
      tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs
  50. 31
      tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs
  51. 6
      tests/Avalonia.UnitTests/ImmediateDispatcher.cs

2
build/Microsoft.CSharp.props

@ -1,5 +1,5 @@
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.CSharp" Version="4.3.0" /> <PackageReference Include="Microsoft.CSharp" Version="4.5.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

2
build/Microsoft.Reactive.Testing.props

@ -1,5 +1,5 @@
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Reactive.Testing" Version="3.0.0" /> <PackageReference Include="Microsoft.Reactive.Testing" Version="4.0.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

2
build/ReactiveUI.props

@ -1,5 +1,5 @@
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup> <ItemGroup>
<PackageReference Include="reactiveui" Version="8.0.0" /> <PackageReference Include="reactiveui" Version="8.7.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

10
build/Rx.props

@ -1,9 +1,9 @@
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup> <ItemGroup>
<PackageReference Include="System.Reactive" Version="3.1.1" /> <PackageReference Include="System.Reactive" Version="4.0.0" />
<PackageReference Include="System.Reactive.Core" Version="3.1.1" /> <PackageReference Include="System.Reactive.Core" Version="4.0.0" />
<PackageReference Include="System.Reactive.Interfaces" Version="3.1.1" /> <PackageReference Include="System.Reactive.Interfaces" Version="4.0.0" />
<PackageReference Include="System.Reactive.Linq" Version="3.1.1" /> <PackageReference Include="System.Reactive.Linq" Version="4.0.0" />
<PackageReference Include="System.Reactive.PlatformServices" Version="3.1.1" /> <PackageReference Include="System.Reactive.PlatformServices" Version="4.0.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

2
packages.cake

@ -303,7 +303,7 @@ public class Packages
{ {
new NuSpecContent { Source = "Avalonia.Android.dll", Target = "lib/MonoAndroid10" } new NuSpecContent { Source = "Avalonia.Android.dll", Target = "lib/MonoAndroid10" }
}, },
BasePath = context.Directory("./src/Android/Avalonia.Android/bin/" + parameters.DirSuffix + "/monoandroid44/"), BasePath = context.Directory("./src/Android/Avalonia.Android/bin/" + parameters.DirSuffix + "/monoandroid44/MonoAndroid44/"),
OutputDirectory = parameters.NugetRoot OutputDirectory = parameters.NugetRoot
}, },
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////

3
readme.md

@ -35,6 +35,9 @@ Install-Package Avalonia.Desktop
Try out the latest build of Avalonia available for download here: Try out the latest build of Avalonia available for download here:
https://ci.appveyor.com/project/AvaloniaUI/Avalonia/branch/master/artifacts https://ci.appveyor.com/project/AvaloniaUI/Avalonia/branch/master/artifacts
or use nightly build feeds as described here:
https://github.com/AvaloniaUI/Avalonia/wiki/Using-nightly-build-feed
## Documentation ## Documentation
As mentioned above, Avalonia is still in beta and as such there's not much documentation yet. You can take a look at the [getting started page](http://avaloniaui.net/docs/quickstart/) for an overview of how to get started but probably the best thing to do for now is to already know a little bit about WPF/Silverlight/UWP/XAML and ask questions in our [Gitter room](https://gitter.im/AvaloniaUI/Avalonia). As mentioned above, Avalonia is still in beta and as such there's not much documentation yet. You can take a look at the [getting started page](http://avaloniaui.net/docs/quickstart/) for an overview of how to get started but probably the best thing to do for now is to already know a little bit about WPF/Silverlight/UWP/XAML and ask questions in our [Gitter room](https://gitter.im/AvaloniaUI/Avalonia).

13
samples/ControlCatalog/Pages/RadioButtonPage.xaml

@ -22,6 +22,19 @@
<RadioButton IsChecked="{x:Null}" IsThreeState="True">Three States: Option 3</RadioButton> <RadioButton IsChecked="{x:Null}" IsThreeState="True">Three States: Option 3</RadioButton>
<RadioButton IsChecked="{x:Null}" IsThreeState="True" IsEnabled="False">Disabled</RadioButton> <RadioButton IsChecked="{x:Null}" IsThreeState="True" IsEnabled="False">Disabled</RadioButton>
</StackPanel> </StackPanel>
<StackPanel Orientation="Vertical"
Spacing="16">
<RadioButton GroupName="A" IsChecked="True">Group A: Option 1</RadioButton>
<RadioButton GroupName="A" IsEnabled="False">Group A: Disabled</RadioButton>
<RadioButton GroupName="B">Group B: Option 1</RadioButton>
<RadioButton GroupName="B" IsChecked="{x:Null}">Group B: Option 3</RadioButton>
</StackPanel>
<StackPanel Orientation="Vertical"
Spacing="16">
<RadioButton GroupName="A" IsChecked="True">Group A: Option 2</RadioButton>
<RadioButton GroupName="B">Group B: Option 2</RadioButton>
<RadioButton GroupName="B" IsChecked="{x:Null}">Group B: Option 4</RadioButton>
</StackPanel>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

9
scripts/ReplaceNugetCache.ps1

@ -1,5 +1,6 @@
copy ..\samples\ControlCatalog.Desktop\bin\Debug\net461\Avalonia**.dll ~\.nuget\packages\avalonia\$args\lib\net461\
copy ..\samples\ControlCatalog.NetCore\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia\$args\lib\netcoreapp2.0\ copy ..\samples\ControlCatalog.NetCore\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia\$args\lib\netcoreapp2.0\
copy ..\samples\ControlCatalog.NetCore.\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia\$args\lib\netstandard2.0\ copy ..\samples\ControlCatalog.NetCore\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia\$args\lib\netstandard2.0\
copy ..\samples\ControlCatalog.NetCore.\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia.gtk3\$args\lib\netstandard2.0\ copy ..\samples\ControlCatalog.NetCore\bin\Debug\netcoreapp2.0\Avalonia.Gtk3.dll ~\.nuget\packages\avalonia.gtk3\$args\lib\netstandard2.0\
copy ..\samples\ControlCatalog.NetCore.\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia.win32\$args\lib\netstandard2.0\ copy ..\samples\ControlCatalog.NetCore\bin\Debug\netcoreapp2.0\Avalonia.Win32.dll ~\.nuget\packages\avalonia.win32\$args\lib\netstandard2.0\
copy ..\samples\ControlCatalog.NetCore.\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia.skia\$args\lib\netstandard2.0\ copy ..\samples\ControlCatalog.NetCore\bin\Debug\netcoreapp2.0\Avalonia.Skia.dll ~\.nuget\packages\avalonia.skia\$args\lib\netstandard2.0\

13
src/Avalonia.Base/Threading/Dispatcher.cs

@ -69,7 +69,7 @@ namespace Avalonia.Threading
/// </summary> /// </summary>
public void RunJobs() public void RunJobs()
{ {
_jobRunner?.RunJobs(null); _jobRunner.RunJobs(null);
} }
/// <summary> /// <summary>
@ -82,14 +82,21 @@ namespace Avalonia.Threading
public Task InvokeAsync(Action action, DispatcherPriority priority = DispatcherPriority.Normal) public Task InvokeAsync(Action action, DispatcherPriority priority = DispatcherPriority.Normal)
{ {
Contract.Requires<ArgumentNullException>(action != null); Contract.Requires<ArgumentNullException>(action != null);
return _jobRunner?.InvokeAsync(action, priority); return _jobRunner.InvokeAsync(action, priority);
}
/// <inheritdoc/>
public Task<TResult> InvokeAsync<TResult>(Func<TResult> function, DispatcherPriority priority = DispatcherPriority.Normal)
{
Contract.Requires<ArgumentNullException>(function != null);
return _jobRunner.InvokeAsync(function, priority);
} }
/// <inheritdoc/> /// <inheritdoc/>
public void Post(Action action, DispatcherPriority priority = DispatcherPriority.Normal) public void Post(Action action, DispatcherPriority priority = DispatcherPriority.Normal)
{ {
Contract.Requires<ArgumentNullException>(action != null); Contract.Requires<ArgumentNullException>(action != null);
_jobRunner?.Post(action, priority); _jobRunner.Post(action, priority);
} }
/// <summary> /// <summary>

11
src/Avalonia.Base/Threading/IDispatcher.cs

@ -28,12 +28,17 @@ namespace Avalonia.Threading
void Post(Action action, DispatcherPriority priority = DispatcherPriority.Normal); void Post(Action action, DispatcherPriority priority = DispatcherPriority.Normal);
/// <summary> /// <summary>
/// Post action that will be invoked on main thread /// Posts an action that will be invoked on the dispatcher thread.
/// </summary> /// </summary>
/// <param name="action">The method.</param> /// <param name="action">The method.</param>
/// <param name="priority">The priority with which to invoke the method.</param> /// <param name="priority">The priority with which to invoke the method.</param>
// TODO: The naming of this method is confusing: the Async suffix usually means return a task.
// Remove this and rename InvokeTaskAsync as InvokeAsync. See #816.
Task InvokeAsync(Action action, DispatcherPriority priority = DispatcherPriority.Normal); Task InvokeAsync(Action action, DispatcherPriority priority = DispatcherPriority.Normal);
/// <summary>
/// Posts a function that will be invoked on the dispatcher thread.
/// </summary>
/// <param name="function">The method.</param>
/// <param name="priority">The priority with which to invoke the method.</param>
Task<TResult> InvokeAsync<TResult>(Func<TResult> function, DispatcherPriority priority = DispatcherPriority.Normal);
} }
} }

168
src/Avalonia.Base/Threading/JobRunner.cs

@ -14,32 +14,16 @@ namespace Avalonia.Threading
/// </summary> /// </summary>
internal class JobRunner internal class JobRunner
{ {
private IPlatformThreadingInterface _platform; private IPlatformThreadingInterface _platform;
private Queue<Job>[] _queues = Enumerable.Range(0, (int) DispatcherPriority.MaxValue + 1) private readonly Queue<IJob>[] _queues = Enumerable.Range(0, (int) DispatcherPriority.MaxValue + 1)
.Select(_ => new Queue<Job>()).ToArray(); .Select(_ => new Queue<IJob>()).ToArray();
public JobRunner(IPlatformThreadingInterface platform) public JobRunner(IPlatformThreadingInterface platform)
{ {
_platform = platform; _platform = platform;
} }
Job GetNextJob(DispatcherPriority minimumPriority)
{
for (int c = (int) DispatcherPriority.MaxValue; c >= (int) minimumPriority; c--)
{
var q = _queues[c];
lock (q)
{
if (q.Count > 0)
return q.Dequeue();
}
}
return null;
}
/// <summary> /// <summary>
/// Runs continuations pushed on the loop. /// Runs continuations pushed on the loop.
/// </summary> /// </summary>
@ -52,24 +36,8 @@ namespace Avalonia.Threading
var job = GetNextJob(minimumPriority); var job = GetNextJob(minimumPriority);
if (job == null) if (job == null)
return; return;
if (job.TaskCompletionSource == null) job.Run();
{
job.Action();
}
else
{
try
{
job.Action();
job.TaskCompletionSource.SetResult(null);
}
catch (Exception e)
{
job.TaskCompletionSource.SetException(e);
}
}
} }
} }
@ -83,7 +51,20 @@ namespace Avalonia.Threading
{ {
var job = new Job(action, priority, false); var job = new Job(action, priority, false);
AddJob(job); AddJob(job);
return job.TaskCompletionSource.Task; return job.Task;
}
/// <summary>
/// Invokes a method on the main loop.
/// </summary>
/// <param name="function">The method.</param>
/// <param name="priority">The priority with which to invoke the method.</param>
/// <returns>A task that can be used to track the method's execution.</returns>
public Task<TResult> InvokeAsync<TResult>(Func<TResult> function, DispatcherPriority priority)
{
var job = new Job<TResult>(function, priority);
AddJob(job);
return job.Task;
} }
/// <summary> /// <summary>
@ -105,9 +86,9 @@ namespace Avalonia.Threading
_platform = AvaloniaLocator.Current.GetService<IPlatformThreadingInterface>(); _platform = AvaloniaLocator.Current.GetService<IPlatformThreadingInterface>();
} }
private void AddJob(Job job) private void AddJob(IJob job)
{ {
var needWake = false; bool needWake;
var queue = _queues[(int) job.Priority]; var queue = _queues[(int) job.Priority];
lock (queue) lock (queue)
{ {
@ -118,38 +99,129 @@ namespace Avalonia.Threading
_platform?.Signal(job.Priority); _platform?.Signal(job.Priority);
} }
private IJob GetNextJob(DispatcherPriority minimumPriority)
{
for (int c = (int) DispatcherPriority.MaxValue; c >= (int) minimumPriority; c--)
{
var q = _queues[c];
lock (q)
{
if (q.Count > 0)
return q.Dequeue();
}
}
return null;
}
private interface IJob
{
/// <summary>
/// Gets the job priority.
/// </summary>
DispatcherPriority Priority { get; }
/// <summary>
/// Runs the job.
/// </summary>
void Run();
}
/// <summary> /// <summary>
/// A job to run. /// A job to run.
/// </summary> /// </summary>
private class Job private sealed class Job : IJob
{ {
/// <summary>
/// The method to call.
/// </summary>
private readonly Action _action;
/// <summary>
/// The task completion source.
/// </summary>
private readonly TaskCompletionSource<object> _taskCompletionSource;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Job"/> class. /// Initializes a new instance of the <see cref="Job"/> class.
/// </summary> /// </summary>
/// <param name="action">The method to call.</param> /// <param name="action">The method to call.</param>
/// <param name="priority">The job priority.</param> /// <param name="priority">The job priority.</param>
/// <param name="throwOnUiThread">Do not wrap excepption in TaskCompletionSource</param> /// <param name="throwOnUiThread">Do not wrap exception in TaskCompletionSource</param>
public Job(Action action, DispatcherPriority priority, bool throwOnUiThread) public Job(Action action, DispatcherPriority priority, bool throwOnUiThread)
{ {
Action = action; _action = action;
Priority = priority; Priority = priority;
TaskCompletionSource = throwOnUiThread ? null : new TaskCompletionSource<object>(); _taskCompletionSource = throwOnUiThread ? null : new TaskCompletionSource<object>();
} }
/// <inheritdoc/>
public DispatcherPriority Priority { get; }
/// <summary> /// <summary>
/// Gets the method to call. /// The task.
/// </summary> /// </summary>
public Action Action { get; } public Task Task => _taskCompletionSource?.Task;
/// <inheritdoc/>
void IJob.Run()
{
if (_taskCompletionSource == null)
{
_action();
return;
}
try
{
_action();
_taskCompletionSource.SetResult(null);
}
catch (Exception e)
{
_taskCompletionSource.SetException(e);
}
}
}
/// <summary>
/// A job to run.
/// </summary>
private sealed class Job<TResult> : IJob
{
private readonly Func<TResult> _function;
private readonly TaskCompletionSource<TResult> _taskCompletionSource;
/// <summary> /// <summary>
/// Gets the job priority. /// Initializes a new instance of the <see cref="Job"/> class.
/// </summary> /// </summary>
public DispatcherPriority Priority { get; } /// <param name="function">The method to call.</param>
/// <param name="priority">The job priority.</param>
public Job(Func<TResult> function, DispatcherPriority priority)
{
_function = function;
Priority = priority;
_taskCompletionSource = new TaskCompletionSource<TResult>();
}
/// <inheritdoc/>
public DispatcherPriority Priority { get; }
/// <summary> /// <summary>
/// Gets the task completion source. /// The task.
/// </summary> /// </summary>
public TaskCompletionSource<object> TaskCompletionSource { get; } public Task<TResult> Task => _taskCompletionSource.Task;
/// <inheritdoc/>
void IJob.Run()
{
try
{
var result = _function();
_taskCompletionSource.SetResult(result);
}
catch (Exception e)
{
_taskCompletionSource.SetException(e);
}
}
} }
} }
} }

3
src/Avalonia.Controls/Canvas.cs

@ -136,8 +136,9 @@ namespace Avalonia.Controls
/// </summary> /// </summary>
/// <param name="direction">The movement direction.</param> /// <param name="direction">The movement direction.</param>
/// <param name="from">The control from which movement begins.</param> /// <param name="from">The control from which movement begins.</param>
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
/// <returns>The control.</returns> /// <returns>The control.</returns>
IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from) IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap)
{ {
// TODO: Implement this // TODO: Implement this
return null; return null;

148
src/Avalonia.Controls/ContextMenu.cs

@ -1,16 +1,18 @@
using System;
using System.Reactive.Linq;
using System.Linq;
using System.ComponentModel;
using Avalonia.Controls.Platform;
using System.Collections.Generic;
using Avalonia.Input;
using Avalonia.LogicalTree;
using Avalonia.Controls.Primitives;
namespace Avalonia.Controls namespace Avalonia.Controls
{ {
using Input; public class ContextMenu : SelectingItemsControl, IMenu
using Interactivity;
using LogicalTree;
using Primitives;
using System;
using System.Reactive.Linq;
using System.Linq;
using System.ComponentModel;
public class ContextMenu : SelectingItemsControl
{ {
private readonly IMenuInteractionHandler _interaction;
private bool _isOpen; private bool _isOpen;
private Popup _popup; private Popup _popup;
@ -20,6 +22,25 @@ namespace Avalonia.Controls
public static readonly DirectProperty<ContextMenu, bool> IsOpenProperty = public static readonly DirectProperty<ContextMenu, bool> IsOpenProperty =
AvaloniaProperty.RegisterDirect<ContextMenu, bool>(nameof(IsOpen), o => o.IsOpen); AvaloniaProperty.RegisterDirect<ContextMenu, bool>(nameof(IsOpen), o => o.IsOpen);
/// <summary>
/// Initializes a new instance of the <see cref="ContextMenu"/> class.
/// </summary>
public ContextMenu()
{
_interaction = AvaloniaLocator.Current.GetService<IMenuInteractionHandler>() ??
new DefaultMenuInteractionHandler();
}
/// <summary>
/// Initializes a new instance of the <see cref="ContextMenu"/> class.
/// </summary>
/// <param name="interactionHandler">The menu iteraction handler.</param>
public ContextMenu(IMenuInteractionHandler interactionHandler)
{
Contract.Requires<ArgumentNullException>(interactionHandler != null);
_interaction = interactionHandler;
}
/// <summary> /// <summary>
/// Initializes static members of the <see cref="ContextMenu"/> class. /// Initializes static members of the <see cref="ContextMenu"/> class.
@ -27,8 +48,6 @@ namespace Avalonia.Controls
static ContextMenu() static ContextMenu()
{ {
ContextMenuProperty.Changed.Subscribe(ContextMenuChanged); ContextMenuProperty.Changed.Subscribe(ContextMenuChanged);
MenuItem.ClickEvent.AddClassHandler<ContextMenu>(x => x.OnContextMenuClick, handledEventsToo: true);
} }
/// <summary> /// <summary>
@ -36,6 +55,36 @@ namespace Avalonia.Controls
/// </summary> /// </summary>
public bool IsOpen => _isOpen; public bool IsOpen => _isOpen;
/// <inheritdoc/>
IMenuInteractionHandler IMenu.InteractionHandler => _interaction;
/// <inheritdoc/>
IMenuItem IMenuElement.SelectedItem
{
get
{
var index = SelectedIndex;
return (index != -1) ?
(IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
null;
}
set
{
SelectedIndex = ItemContainerGenerator.IndexFromContainer(value);
}
}
/// <inheritdoc/>
IEnumerable<IMenuItem> IMenuElement.SubItems
{
get
{
return ItemContainerGenerator.Containers
.Select(x => x.ContainerControl)
.OfType<IMenuItem>();
}
}
/// <summary> /// <summary>
/// Occurs when the value of the /// Occurs when the value of the
/// <see cref="P:Avalonia.Controls.ContextMenu.IsOpen" /> /// <see cref="P:Avalonia.Controls.ContextMenu.IsOpen" />
@ -50,7 +99,6 @@ namespace Avalonia.Controls
/// </summary> /// </summary>
public event CancelEventHandler ContextMenuClosing; public event CancelEventHandler ContextMenuClosing;
/// <summary> /// <summary>
/// Called when the <see cref="Control.ContextMenu"/> property changes on a control. /// Called when the <see cref="Control.ContextMenu"/> property changes on a control.
/// </summary> /// </summary>
@ -71,62 +119,53 @@ namespace Avalonia.Controls
} }
/// <summary> /// <summary>
/// Called when a submenu is clicked somewhere in the menu. /// Opens the menu.
/// </summary> /// </summary>
/// <param name="e">The event args.</param> public void Open() => Open(null);
private void OnContextMenuClick(RoutedEventArgs e)
{
Hide();
FocusManager.Instance.Focus(null);
e.Handled = true;
}
/// <summary> /// <summary>
/// Closes the menu. /// Opens a context menu on the specified control.
/// </summary> /// </summary>
public void Hide() /// <param name="control">The control.</param>
public void Open(Control control)
{ {
if (_popup != null && _popup.IsVisible) if (_popup == null)
{ {
_popup.IsOpen = false; _popup = new Popup()
{
PlacementMode = PlacementMode.Pointer,
PlacementTarget = control,
StaysOpen = false,
ObeyScreenEdges = true
};
_popup.Closed += PopupClosed;
_interaction.Attach(this);
} }
SelectedIndex = -1; ((ISetLogicalParent)_popup).SetParent(control);
_popup.Child = this;
_popup.IsOpen = true;
SetAndRaise(IsOpenProperty, ref _isOpen, false); SetAndRaise(IsOpenProperty, ref _isOpen, true);
} }
/// <summary> /// <summary>
/// Shows a context menu for the specified control. /// Closes the menu.
/// </summary> /// </summary>
/// <param name="control">The control.</param> public void Close()
private void Show(Control control)
{ {
if (control != null) if (_popup != null && _popup.IsVisible)
{ {
if (_popup == null) _popup.IsOpen = false;
{ }
_popup = new Popup()
{
PlacementMode = PlacementMode.Pointer,
PlacementTarget = control,
StaysOpen = false,
ObeyScreenEdges = true
};
_popup.Closed += PopupClosed;
}
((ISetLogicalParent)_popup).SetParent(control);
_popup.Child = this;
_popup.IsOpen = true; SelectedIndex = -1;
SetAndRaise(IsOpenProperty, ref _isOpen, true); SetAndRaise(IsOpenProperty, ref _isOpen, false);
}
} }
private static void PopupClosed(object sender, EventArgs e) private void PopupClosed(object sender, EventArgs e)
{ {
var contextMenu = (sender as Popup)?.Child as ContextMenu; var contextMenu = (sender as Popup)?.Child as ContextMenu;
@ -152,7 +191,7 @@ namespace Avalonia.Controls
if (contextMenu.CancelClosing()) if (contextMenu.CancelClosing())
return; return;
control.ContextMenu.Hide(); control.ContextMenu.Close();
e.Handled = true; e.Handled = true;
} }
@ -161,7 +200,7 @@ namespace Avalonia.Controls
if (contextMenu.CancelOpening()) if (contextMenu.CancelOpening())
return; return;
contextMenu.Show(control); contextMenu.Open(control);
e.Handled = true; e.Handled = true;
} }
} }
@ -179,5 +218,10 @@ namespace Avalonia.Controls
ContextMenuOpening?.Invoke(this, eventArgs); ContextMenuOpening?.Invoke(this, eventArgs);
return eventArgs.Cancel; return eventArgs.Cancel;
} }
bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap)
{
throw new NotImplementedException();
}
} }
} }

21
src/Avalonia.Controls/IMenu.cs

@ -0,0 +1,21 @@
using System;
using Avalonia.Controls.Platform;
namespace Avalonia.Controls
{
/// <summary>
/// Represents a <see cref="Menu"/> or <see cref="ContextMenu"/>.
/// </summary>
public interface IMenu : IMenuElement
{
/// <summary>
/// Gets the menu interaction handler.
/// </summary>
IMenuInteractionHandler InteractionHandler { get; }
/// <summary>
/// Gets a value indicating whether the menu is open.
/// </summary>
bool IsOpen { get; }
}
}

40
src/Avalonia.Controls/IMenuElement.cs

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using Avalonia.Input;
namespace Avalonia.Controls
{
/// <summary>
/// Represents an <see cref="IMenu"/> or <see cref="IMenuItem"/>.
/// </summary>
public interface IMenuElement : IControl
{
/// <summary>
/// Gets or sets the currently selected submenu item.
/// </summary>
IMenuItem SelectedItem { get; set; }
/// <summary>
/// Gets the submenu items.
/// </summary>
IEnumerable<IMenuItem> SubItems { get; }
/// <summary>
/// Opens the menu or menu item.
/// </summary>
void Open();
/// <summary>
/// Closes the menu or menu item.
/// </summary>
void Close();
/// <summary>
/// Moves the submenu selection in the specified direction.
/// </summary>
/// <param name="direction">The direction.</param>
/// <param name="wrap">Whether to wrap after the first or last item.</param>
/// <returns>True if the selection was moved; otherwise false.</returns>
bool MoveSelection(NavigationDirection direction, bool wrap);
}
}

41
src/Avalonia.Controls/IMenuItem.cs

@ -0,0 +1,41 @@
using System;
namespace Avalonia.Controls
{
/// <summary>
/// Represents a <see cref="MenuItem"/>.
/// </summary>
public interface IMenuItem : IMenuElement
{
/// <summary>
/// Gets or sets a value that indicates whether the item has a submenu.
/// </summary>
bool HasSubMenu { get; }
/// <summary>
/// Gets a value indicating whether the mouse is currently over the menu item's submenu.
/// </summary>
bool IsPointerOverSubMenu { get; }
/// <summary>
/// Gets or sets a value that indicates whether the submenu of the <see cref="MenuItem"/> is
/// open.
/// </summary>
bool IsSubMenuOpen { get; set; }
/// <summary>
/// Gets a value that indicates whether the <see cref="MenuItem"/> is a top-level main menu item.
/// </summary>
bool IsTopLevel { get; }
/// <summary>
/// Gets the parent <see cref="IMenuElement"/>.
/// </summary>
new IMenuElement Parent { get; }
/// <summary>
/// Raises a click event on the menu item.
/// </summary>
void RaiseClick();
}
}

87
src/Avalonia.Controls/ItemsControl.cs

@ -15,6 +15,7 @@ using Avalonia.Controls.Utils;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.LogicalTree; using Avalonia.LogicalTree;
using Avalonia.Metadata; using Avalonia.Metadata;
using Avalonia.VisualTree;
namespace Avalonia.Controls namespace Avalonia.Controls
{ {
@ -323,6 +324,46 @@ namespace Avalonia.Controls
LogicalChildren.RemoveAll(toRemove); LogicalChildren.RemoveAll(toRemove);
} }
/// <summary>
/// Handles directional navigation within the <see cref="ItemsControl"/>.
/// </summary>
/// <param name="e">The key events.</param>
protected override void OnKeyDown(KeyEventArgs e)
{
if (!e.Handled)
{
var focus = FocusManager.Instance;
var direction = e.Key.ToNavigationDirection();
var container = Presenter?.Panel as INavigableContainer;
if (container == null ||
focus.Current == null ||
direction == null ||
direction.Value.IsTab())
{
return;
}
var current = focus.Current
.GetSelfAndVisualAncestors()
.OfType<IInputElement>()
.FirstOrDefault(x => x.VisualParent == container);
if (current != null)
{
var next = GetNextControl(container, direction.Value, current, false);
if (next != null)
{
focus.Focus(next, NavigationMethod.Directional);
e.Handled = true;
}
}
}
base.OnKeyDown(e);
}
/// <summary> /// <summary>
/// Caled when the <see cref="Items"/> property changes. /// Caled when the <see cref="Items"/> property changes.
/// </summary> /// </summary>
@ -335,6 +376,7 @@ namespace Avalonia.Controls
var oldValue = e.OldValue as IEnumerable; var oldValue = e.OldValue as IEnumerable;
var newValue = e.NewValue as IEnumerable; var newValue = e.NewValue as IEnumerable;
UpdateItemCount();
RemoveControlItemsFromLogicalChildren(oldValue); RemoveControlItemsFromLogicalChildren(oldValue);
AddControlItemsToLogicalChildren(newValue); AddControlItemsToLogicalChildren(newValue);
SubscribeToItems(newValue); SubscribeToItems(newValue);
@ -358,10 +400,8 @@ namespace Avalonia.Controls
RemoveControlItemsFromLogicalChildren(e.OldItems); RemoveControlItemsFromLogicalChildren(e.OldItems);
break; break;
} }
int? count = (Items as IList)?.Count; UpdateItemCount();
if (count != null)
ItemCount = (int)count;
var collection = sender as ICollection; var collection = sender as ICollection;
PseudoClasses.Set(":empty", collection == null || collection.Count == 0); PseudoClasses.Set(":empty", collection == null || collection.Count == 0);
@ -445,5 +485,44 @@ namespace Avalonia.Controls
// TODO: Rebuild the item containers. // TODO: Rebuild the item containers.
} }
} }
private void UpdateItemCount()
{
if (Items == null)
{
ItemCount = 0;
}
else if (Items is IList list)
{
ItemCount = list.Count;
}
else
{
ItemCount = Items.Count();
}
}
protected static IInputElement GetNextControl(
INavigableContainer container,
NavigationDirection direction,
IInputElement from,
bool wrap)
{
IInputElement result;
do
{
result = container.GetControl(direction, from, wrap);
if (result?.Focusable == true)
{
return result;
}
from = result;
} while (from != null);
return null;
}
} }
} }

250
src/Avalonia.Controls/Menu.cs

@ -2,30 +2,23 @@
// Licensed under the MIT license. See licence.md file in the project root for full license information. // Licensed under the MIT license. See licence.md file in the project root for full license information.
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reactive.Disposables;
using Avalonia.Controls.Generators; using Avalonia.Controls.Generators;
using Avalonia.Controls.Platform;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.LogicalTree; using Avalonia.LogicalTree;
using Avalonia.Rendering;
namespace Avalonia.Controls namespace Avalonia.Controls
{ {
/// <summary> /// <summary>
/// A top-level menu control. /// A top-level menu control.
/// </summary> /// </summary>
public class Menu : SelectingItemsControl, IFocusScope, IMainMenu public class Menu : SelectingItemsControl, IFocusScope, IMainMenu, IMenu
{ {
/// <summary>
/// Defines the default items panel used by a <see cref="Menu"/>.
/// </summary>
private static readonly ITemplate<IPanel> DefaultPanel =
new FuncTemplate<IPanel>(() => new StackPanel { Orientation = Orientation.Horizontal });
/// <summary> /// <summary>
/// Defines the <see cref="IsOpen"/> property. /// Defines the <see cref="IsOpen"/> property.
/// </summary> /// </summary>
@ -34,12 +27,42 @@ namespace Avalonia.Controls
nameof(IsOpen), nameof(IsOpen),
o => o.IsOpen); o => o.IsOpen);
/// <summary>
/// Defines the <see cref="MenuOpened"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> MenuOpenedEvent =
RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(MenuOpened), RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="MenuClosed"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> MenuClosedEvent =
RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(MenuClosed), RoutingStrategies.Bubble);
private static readonly ITemplate<IPanel> DefaultPanel =
new FuncTemplate<IPanel>(() => new StackPanel { Orientation = Orientation.Horizontal });
private readonly IMenuInteractionHandler _interaction;
private bool _isOpen; private bool _isOpen;
/// <summary> /// <summary>
/// Tracks event handlers added to the root of the visual tree. /// Initializes a new instance of the <see cref="Menu"/> class.
/// </summary>
public Menu()
{
_interaction = AvaloniaLocator.Current.GetService<IMenuInteractionHandler>() ??
new DefaultMenuInteractionHandler();
}
/// <summary>
/// Initializes a new instance of the <see cref="Menu"/> class.
/// </summary> /// </summary>
private IDisposable _subscription; /// <param name="interactionHandler">The menu iteraction handler.</param>
public Menu(IMenuInteractionHandler interactionHandler)
{
Contract.Requires<ArgumentNullException>(interactionHandler != null);
_interaction = interactionHandler;
}
/// <summary> /// <summary>
/// Initializes static members of the <see cref="Menu"/> class. /// Initializes static members of the <see cref="Menu"/> class.
@ -47,7 +70,6 @@ namespace Avalonia.Controls
static Menu() static Menu()
{ {
ItemsPanelProperty.OverrideDefaultValue(typeof(Menu), DefaultPanel); ItemsPanelProperty.OverrideDefaultValue(typeof(Menu), DefaultPanel);
MenuItem.ClickEvent.AddClassHandler<Menu>(x => x.OnMenuClick, handledEventsToo: true);
MenuItem.SubmenuOpenedEvent.AddClassHandler<Menu>(x => x.OnSubmenuOpened); MenuItem.SubmenuOpenedEvent.AddClassHandler<Menu>(x => x.OnSubmenuOpened);
} }
@ -60,18 +82,52 @@ namespace Avalonia.Controls
private set { SetAndRaise(IsOpenProperty, ref _isOpen, value); } private set { SetAndRaise(IsOpenProperty, ref _isOpen, value); }
} }
/// <summary> /// <inheritdoc/>
/// Gets the selected <see cref="MenuItem"/> container. IMenuInteractionHandler IMenu.InteractionHandler => _interaction;
/// </summary>
private MenuItem SelectedMenuItem /// <inheritdoc/>
IMenuItem IMenuElement.SelectedItem
{ {
get get
{ {
var index = SelectedIndex; var index = SelectedIndex;
return (index != -1) ? return (index != -1) ?
(MenuItem)ItemContainerGenerator.ContainerFromIndex(index) : (IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
null; null;
} }
set
{
SelectedIndex = ItemContainerGenerator.IndexFromContainer(value);
}
}
/// <inheritdoc/>
IEnumerable<IMenuItem> IMenuElement.SubItems
{
get
{
return ItemContainerGenerator.Containers
.Select(x => x.ContainerControl)
.OfType<IMenuItem>();
}
}
/// <summary>
/// Occurs when a <see cref="Menu"/> is opened.
/// </summary>
public event EventHandler<RoutedEventArgs> MenuOpened
{
add { AddHandler(MenuOpenedEvent, value); }
remove { RemoveHandler(MenuOpenedEvent, value); }
}
/// <summary>
/// Occurs when a <see cref="Menu"/> is closed.
/// </summary>
public event EventHandler<RoutedEventArgs> MenuClosed
{
add { AddHandler(MenuClosedEvent, value); }
remove { RemoveHandler(MenuClosedEvent, value); }
} }
/// <summary> /// <summary>
@ -79,13 +135,22 @@ namespace Avalonia.Controls
/// </summary> /// </summary>
public void Close() public void Close()
{ {
foreach (MenuItem i in this.GetLogicalChildren()) if (IsOpen)
{ {
i.IsSubMenuOpen = false; foreach (var i in ((IMenu)this).SubItems)
} {
i.Close();
}
IsOpen = false;
SelectedIndex = -1;
IsOpen = false; RaiseEvent(new RoutedEventArgs
SelectedIndex = -1; {
RoutedEvent = MenuClosedEvent,
Source = this,
});
}
} }
/// <summary> /// <summary>
@ -93,9 +158,25 @@ namespace Avalonia.Controls
/// </summary> /// </summary>
public void Open() public void Open()
{ {
SelectedIndex = 0; if (!IsOpen)
SelectedMenuItem.Focus(); {
IsOpen = true; IsOpen = true;
RaiseEvent(new RoutedEventArgs
{
RoutedEvent = MenuOpenedEvent,
Source = this,
});
}
}
/// <inheritdoc/>
bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap);
/// <inheritdoc/>
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new ItemContainerGenerator<MenuItem>(this, MenuItem.HeaderProperty, null);
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -103,79 +184,27 @@ namespace Avalonia.Controls
{ {
base.OnAttachedToVisualTree(e); base.OnAttachedToVisualTree(e);
var topLevel = (TopLevel)e.Root;
var window = e.Root as Window;
if (window != null)
window.Deactivated += Deactivated;
var pointerPress = topLevel.AddHandler(
PointerPressedEvent,
TopLevelPreviewPointerPress,
RoutingStrategies.Tunnel);
_subscription = new CompositeDisposable(
pointerPress,
Disposable.Create(() =>
{
if (window != null)
window.Deactivated -= Deactivated;
}),
InputManager.Instance.Process.Subscribe(ListenForNonClientClick));
var inputRoot = e.Root as IInputRoot; var inputRoot = e.Root as IInputRoot;
if (inputRoot?.AccessKeyHandler != null) if (inputRoot?.AccessKeyHandler != null)
{ {
inputRoot.AccessKeyHandler.MainMenu = this; inputRoot.AccessKeyHandler.MainMenu = this;
} }
_interaction.Attach(this);
} }
/// <inheritdoc/> /// <inheritdoc/>
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{ {
base.OnDetachedFromVisualTree(e); base.OnDetachedFromVisualTree(e);
_subscription.Dispose(); _interaction.Detach(this);
} }
/// <inheritdoc/> /// <inheritdoc/>
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new ItemContainerGenerator<MenuItem>(this, MenuItem.HeaderProperty, null);
}
/// <summary>
/// Called when a key is pressed within the menu.
/// </summary>
/// <param name="e">The event args.</param>
protected override void OnKeyDown(KeyEventArgs e) protected override void OnKeyDown(KeyEventArgs e)
{ {
bool menuWasOpen = SelectedMenuItem?.IsSubMenuOpen ?? false; // Don't handle here: let the interaction handler handle it.
base.OnKeyDown(e);
if (menuWasOpen)
{
// If a menu item was open and we navigate to a new one with the arrow keys, open
// that menu and select the first item.
var selection = SelectedMenuItem;
if (selection != null && !selection.IsSubMenuOpen)
{
selection.IsSubMenuOpen = true;
selection.SelectedIndex = 0;
}
}
}
/// <summary>
/// Called when the menu loses focus.
/// </summary>
/// <param name="e">The event args.</param>
protected override void OnLostFocus(RoutedEventArgs e)
{
base.OnLostFocus(e);
SelectedItem = null;
} }
/// <summary> /// <summary>
@ -184,9 +213,7 @@ namespace Avalonia.Controls
/// <param name="e">The event args.</param> /// <param name="e">The event args.</param>
protected virtual void OnSubmenuOpened(RoutedEventArgs e) protected virtual void OnSubmenuOpened(RoutedEventArgs e)
{ {
var menuItem = e.Source as MenuItem; if (e.Source is MenuItem menuItem && menuItem.Parent == this)
if (menuItem != null && menuItem.Parent == this)
{ {
foreach (var child in this.GetLogicalChildren().OfType<MenuItem>()) foreach (var child in this.GetLogicalChildren().OfType<MenuItem>())
{ {
@ -199,58 +226,5 @@ namespace Avalonia.Controls
IsOpen = true; IsOpen = true;
} }
/// <summary>
/// Called when the top-level window is deactivated.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The event args.</param>
private void Deactivated(object sender, EventArgs e)
{
Close();
}
/// <summary>
/// Listens for non-client clicks and closes the menu when one is detected.
/// </summary>
/// <param name="e">The raw event.</param>
private void ListenForNonClientClick(RawInputEventArgs e)
{
var mouse = e as RawMouseEventArgs;
if (mouse?.Type == RawMouseEventType.NonClientLeftButtonDown)
{
Close();
}
}
/// <summary>
/// Called when a submenu is clicked somewhere in the menu.
/// </summary>
/// <param name="e">The event args.</param>
private void OnMenuClick(RoutedEventArgs e)
{
Close();
FocusManager.Instance.Focus(null);
e.Handled = true;
}
/// <summary>
/// Called when the pointer is pressed anywhere on the window.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The event args.</param>
private void TopLevelPreviewPointerPress(object sender, PointerPressedEventArgs e)
{
if (IsOpen)
{
var control = e.Source as ILogical;
if (!this.IsLogicalParentOf(control))
{
Close();
}
}
}
} }
} }

301
src/Avalonia.Controls/MenuItem.cs

@ -2,6 +2,7 @@
// Licensed under the MIT license. See licence.md file in the project root for full license information. // Licensed under the MIT license. See licence.md file in the project root for full license information.
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Windows.Input; using System.Windows.Input;
using Avalonia.Controls.Generators; using Avalonia.Controls.Generators;
@ -11,14 +12,13 @@ using Avalonia.Controls.Templates;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.LogicalTree; using Avalonia.LogicalTree;
using Avalonia.Threading;
namespace Avalonia.Controls namespace Avalonia.Controls
{ {
/// <summary> /// <summary>
/// A menu item control. /// A menu item control.
/// </summary> /// </summary>
public class MenuItem : HeaderedSelectingItemsControl, ISelectable public class MenuItem : HeaderedSelectingItemsControl, IMenuItem, ISelectable
{ {
/// <summary> /// <summary>
/// Defines the <see cref="Command"/> property. /// Defines the <see cref="Command"/> property.
@ -62,6 +62,18 @@ namespace Avalonia.Controls
public static readonly RoutedEvent<RoutedEventArgs> ClickEvent = public static readonly RoutedEvent<RoutedEventArgs> ClickEvent =
RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(Click), RoutingStrategies.Bubble); RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(Click), RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="PointerEnterItem"/> event.
/// </summary>
public static readonly RoutedEvent<PointerEventArgs> PointerEnterItemEvent =
RoutedEvent.Register<InputElement, PointerEventArgs>(nameof(PointerEnterItem), RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="PointerLeaveItem"/> event.
/// </summary>
public static readonly RoutedEvent<PointerEventArgs> PointerLeaveItemEvent =
RoutedEvent.Register<InputElement, PointerEventArgs>(nameof(PointerLeaveItem), RoutingStrategies.Bubble);
/// <summary> /// <summary>
/// Defines the <see cref="SubmenuOpened"/> event. /// Defines the <see cref="SubmenuOpened"/> event.
/// </summary> /// </summary>
@ -72,15 +84,7 @@ namespace Avalonia.Controls
/// The default value for the <see cref="ItemsControl.ItemsPanel"/> property. /// The default value for the <see cref="ItemsControl.ItemsPanel"/> property.
/// </summary> /// </summary>
private static readonly ITemplate<IPanel> DefaultPanel = private static readonly ITemplate<IPanel> DefaultPanel =
new FuncTemplate<IPanel>(() => new StackPanel new FuncTemplate<IPanel>(() => new StackPanel());
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
});
/// <summary>
/// The timer used to display submenus.
/// </summary>
private IDisposable _submenuTimer;
/// <summary> /// <summary>
/// The submenu popup. /// The submenu popup.
@ -96,16 +100,15 @@ namespace Avalonia.Controls
CommandProperty.Changed.Subscribe(CommandChanged); CommandProperty.Changed.Subscribe(CommandChanged);
FocusableProperty.OverrideDefaultValue<MenuItem>(true); FocusableProperty.OverrideDefaultValue<MenuItem>(true);
IconProperty.Changed.AddClassHandler<MenuItem>(x => x.IconChanged); IconProperty.Changed.AddClassHandler<MenuItem>(x => x.IconChanged);
IsSelectedProperty.Changed.AddClassHandler<MenuItem>(x => x.IsSelectedChanged);
ItemsPanelProperty.OverrideDefaultValue<MenuItem>(DefaultPanel); ItemsPanelProperty.OverrideDefaultValue<MenuItem>(DefaultPanel);
ClickEvent.AddClassHandler<MenuItem>(x => x.OnClick); ClickEvent.AddClassHandler<MenuItem>(x => x.OnClick);
SubmenuOpenedEvent.AddClassHandler<MenuItem>(x => x.OnSubmenuOpened); SubmenuOpenedEvent.AddClassHandler<MenuItem>(x => x.OnSubmenuOpened);
IsSubMenuOpenProperty.Changed.AddClassHandler<MenuItem>(x => x.SubMenuOpenChanged); IsSubMenuOpenProperty.Changed.AddClassHandler<MenuItem>(x => x.SubMenuOpenChanged);
AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<MenuItem>(x => x.AccessKeyPressed);
} }
public MenuItem() public MenuItem()
{ {
} }
/// <summary> /// <summary>
@ -117,6 +120,30 @@ namespace Avalonia.Controls
remove { RemoveHandler(ClickEvent, value); } remove { RemoveHandler(ClickEvent, value); }
} }
/// <summary>
/// Occurs when the pointer enters a menu item.
/// </summary>
/// <remarks>
/// A bubbling version of the <see cref="InputElement.PointerEnter"/> event for menu items.
/// </remarks>
public event EventHandler<PointerEventArgs> PointerEnterItem
{
add { AddHandler(PointerEnterItemEvent, value); }
remove { RemoveHandler(PointerEnterItemEvent, value); }
}
/// <summary>
/// Raised when the pointer leaves a menu item.
/// </summary>
/// <remarks>
/// A bubbling version of the <see cref="InputElement.PointerLeave"/> event for menu items.
/// </remarks>
public event EventHandler<PointerEventArgs> PointerLeaveItem
{
add { AddHandler(PointerLeaveItemEvent, value); }
remove { RemoveHandler(PointerLeaveItemEvent, value); }
}
/// <summary> /// <summary>
/// Occurs when a <see cref="MenuItem"/>'s submenu is opened. /// Occurs when a <see cref="MenuItem"/>'s submenu is opened.
/// </summary> /// </summary>
@ -188,10 +215,71 @@ namespace Avalonia.Controls
public bool HasSubMenu => !Classes.Contains(":empty"); public bool HasSubMenu => !Classes.Contains(":empty");
/// <summary> /// <summary>
/// Gets a value that indicates whether the <see cref="MenuItem"/> is a top-level menu item. /// Gets a value that indicates whether the <see cref="MenuItem"/> is a top-level main menu item.
/// </summary> /// </summary>
public bool IsTopLevel => Parent is Menu; public bool IsTopLevel => Parent is Menu;
/// <inheritdoc/>
bool IMenuItem.IsPointerOverSubMenu => _popup.PopupRoot?.IsPointerOver ?? false;
/// <inheritdoc/>
IMenuElement IMenuItem.Parent => Parent as IMenuElement;
/// <inheritdoc/>
bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap);
/// <inheritdoc/>
IMenuItem IMenuElement.SelectedItem
{
get
{
var index = SelectedIndex;
return (index != -1) ?
(IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
null;
}
set
{
SelectedIndex = ItemContainerGenerator.IndexFromContainer(value);
}
}
/// <inheritdoc/>
IEnumerable<IMenuItem> IMenuElement.SubItems
{
get
{
return ItemContainerGenerator.Containers
.Select(x => x.ContainerControl)
.OfType<IMenuItem>();
}
}
/// <summary>
/// Opens the submenu.
/// </summary>
/// <remarks>
/// This has the same effect as setting <see cref="IsSubMenuOpen"/> to true.
/// </remarks>
public void Open() => IsSubMenuOpen = true;
/// <summary>
/// Closes the submenu.
/// </summary>
/// <remarks>
/// This has the same effect as setting <see cref="IsSubMenuOpen"/> to false.
/// </remarks>
public void Close() => IsSubMenuOpen = false;
/// <inheritdoc/>
void IMenuItem.RaiseClick() => RaiseEvent(new RoutedEventArgs(ClickEvent));
/// <inheritdoc/>
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new MenuItemContainerGenerator(this);
}
/// <summary> /// <summary>
/// Called when the <see cref="MenuItem"/> is clicked. /// Called when the <see cref="MenuItem"/> is clicked.
/// </summary> /// </summary>
@ -205,163 +293,43 @@ namespace Avalonia.Controls
} }
} }
/// <summary> /// <inheritdoc/>
/// Called when the <see cref="MenuItem"/> recieves focus.
/// </summary>
/// <param name="e">The event args.</param>
protected override void OnGotFocus(GotFocusEventArgs e) protected override void OnGotFocus(GotFocusEventArgs e)
{ {
base.OnGotFocus(e); base.OnGotFocus(e);
IsSelected = true; e.Handled = UpdateSelectionFromEventSource(e.Source, true);
} }
/// <inheritdoc/> /// <inheritdoc/>
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new MenuItemContainerGenerator(this);
}
/// <summary>
/// Called when a key is pressed in the <see cref="MenuItem"/>.
/// </summary>
/// <param name="e">The event args.</param>
protected override void OnKeyDown(KeyEventArgs e) protected override void OnKeyDown(KeyEventArgs e)
{ {
// Some keypresses we want to pass straight to the parent MenuItem/Menu without giving // Don't handle here: let event bubble up to menu.
// this MenuItem the chance to handle them. This is usually e.g. when the submenu is
// closed so passing them to the base would try to move the selection in a hidden
// submenu.
var passStraightToParent = true;
switch (e.Key)
{
case Key.Left:
if (!IsTopLevel && IsSubMenuOpen)
{
IsSubMenuOpen = false;
e.Handled = true;
}
passStraightToParent = IsTopLevel || !IsSubMenuOpen;
break;
case Key.Right:
if (!IsTopLevel && HasSubMenu && !IsSubMenuOpen)
{
SelectedIndex = 0;
IsSubMenuOpen = true;
e.Handled = true;
}
passStraightToParent = IsTopLevel || !IsSubMenuOpen;
break;
case Key.Enter:
if (HasSubMenu)
{
goto case Key.Right;
}
else
{
RaiseEvent(new RoutedEventArgs(ClickEvent));
e.Handled = true;
}
break;
case Key.Escape:
if (IsSubMenuOpen)
{
IsSubMenuOpen = false;
e.Handled = true;
}
break;
}
if (!passStraightToParent)
{
base.OnKeyDown(e);
}
} }
/// <summary> /// <inheritdoc/>
/// Called when the pointer enters the <see cref="MenuItem"/>.
/// </summary>
/// <param name="e">The event args.</param>
protected override void OnPointerEnter(PointerEventArgs e) protected override void OnPointerEnter(PointerEventArgs e)
{ {
base.OnPointerEnter(e); base.OnPointerEnter(e);
var menu = Parent as Menu; RaiseEvent(new PointerEventArgs
if (menu != null)
{
if (menu.IsOpen)
{
IsSubMenuOpen = true;
}
}
else if (HasSubMenu && !IsSubMenuOpen)
{ {
_submenuTimer = DispatcherTimer.Run( Device = e.Device,
() => IsSubMenuOpen = true, RoutedEvent = PointerEnterItemEvent,
TimeSpan.FromMilliseconds(400)); Source = this,
} });
else
{
var parentItem = Parent as MenuItem;
if (parentItem != null)
{
foreach (var sibling in parentItem.Items
.OfType<MenuItem>()
.Where(x => x != this && x.IsSubMenuOpen))
{
sibling.CloseSubmenus();
sibling.IsSubMenuOpen = false;
sibling.IsSelected = false;
}
}
}
} }
/// <summary> /// <inheritdoc/>
/// Called when the pointer leaves the <see cref="MenuItem"/>.
/// </summary>
/// <param name="e">The event args.</param>
protected override void OnPointerLeave(PointerEventArgs e) protected override void OnPointerLeave(PointerEventArgs e)
{ {
base.OnPointerLeave(e); base.OnPointerLeave(e);
if (_submenuTimer != null) RaiseEvent(new PointerEventArgs
{ {
_submenuTimer.Dispose(); Device = e.Device,
_submenuTimer = null; RoutedEvent = PointerLeaveItemEvent,
} Source = this,
} });
/// <summary>
/// Called when the pointer is pressed over the <see cref="MenuItem"/>.
/// </summary>
/// <param name="e">The event args.</param>
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
if (!HasSubMenu)
{
RaiseEvent(new RoutedEventArgs(ClickEvent));
}
else if (IsTopLevel)
{
IsSubMenuOpen = !IsSubMenuOpen;
}
else
{
IsSubMenuOpen = true;
}
e.Handled = true;
} }
/// <summary> /// <summary>
@ -374,7 +342,7 @@ namespace Avalonia.Controls
if (menuItem != null && menuItem.Parent == this) if (menuItem != null && menuItem.Parent == this)
{ {
foreach (var child in Items.OfType<MenuItem>()) foreach (var child in ((IMenuItem)this).SubItems)
{ {
if (child != menuItem && child.IsSubMenuOpen) if (child != menuItem && child.IsSubMenuOpen)
{ {
@ -395,31 +363,12 @@ namespace Avalonia.Controls
_popup.Closed += PopupClosed; _popup.Closed += PopupClosed;
} }
/// <summary>
/// Called when the menu item's access key is pressed.
/// </summary>
/// <param name="e">The event args.</param>
private void AccessKeyPressed(RoutedEventArgs e)
{
if (HasSubMenu)
{
SelectedIndex = 0;
IsSubMenuOpen = true;
}
else
{
RaiseEvent(new RoutedEventArgs(ClickEvent));
}
e.Handled = true;
}
/// <summary> /// <summary>
/// Closes all submenus of the menu item. /// Closes all submenus of the menu item.
/// </summary> /// </summary>
private void CloseSubmenus() private void CloseSubmenus()
{ {
foreach (var child in Items.OfType<MenuItem>()) foreach (var child in ((IMenuItem)this).SubItems)
{ {
child.IsSubMenuOpen = false; child.IsSubMenuOpen = false;
} }
@ -479,6 +428,18 @@ namespace Avalonia.Controls
} }
} }
/// <summary>
/// Called when the <see cref="IsSelected"/> property changes.
/// </summary>
/// <param name="e">The property change event.</param>
private void IsSelectedChanged(AvaloniaPropertyChangedEventArgs e)
{
if ((bool)e.NewValue)
{
Focus();
}
}
/// <summary> /// <summary>
/// Called when the <see cref="IsSubMenuOpen"/> property changes. /// Called when the <see cref="IsSubMenuOpen"/> property changes.
/// </summary> /// </summary>

459
src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs

@ -0,0 +1,459 @@
using System;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
using Avalonia.Rendering;
using Avalonia.Threading;
namespace Avalonia.Controls.Platform
{
/// <summary>
/// Provides the default keyboard and pointer interaction for menus.
/// </summary>
public class DefaultMenuInteractionHandler : IMenuInteractionHandler
{
private IDisposable _inputManagerSubscription;
private IRenderRoot _root;
public DefaultMenuInteractionHandler()
: this(Input.InputManager.Instance, DefaultDelayRun)
{
}
public DefaultMenuInteractionHandler(
IInputManager inputManager,
Action<Action, TimeSpan> delayRun)
{
InputManager = inputManager;
DelayRun = delayRun;
}
public virtual void Attach(IMenu menu)
{
if (Menu != null)
{
throw new NotSupportedException("DefaultMenuInteractionHandler is already attached.");
}
Menu = menu;
Menu.GotFocus += GotFocus;
Menu.LostFocus += LostFocus;
Menu.KeyDown += KeyDown;
Menu.PointerPressed += PointerPressed;
Menu.PointerReleased += PointerReleased;
Menu.AddHandler(AccessKeyHandler.AccessKeyPressedEvent, AccessKeyPressed);
Menu.AddHandler(Avalonia.Controls.Menu.MenuOpenedEvent, this.MenuOpened);
Menu.AddHandler(MenuItem.PointerEnterItemEvent, PointerEnter);
Menu.AddHandler(MenuItem.PointerLeaveItemEvent, PointerLeave);
_root = Menu.VisualRoot;
if (_root is InputElement inputRoot)
{
inputRoot.AddHandler(InputElement.PointerPressedEvent, RootPointerPressed, RoutingStrategies.Tunnel);
}
if (_root is WindowBase window)
{
window.Deactivated += WindowDeactivated;
}
_inputManagerSubscription = InputManager.Process.Subscribe(RawInput);
}
public virtual void Detach(IMenu menu)
{
if (Menu != menu)
{
throw new NotSupportedException("DefaultMenuInteractionHandler is not attached to the menu.");
}
Menu.GotFocus -= GotFocus;
Menu.LostFocus -= LostFocus;
Menu.KeyDown -= KeyDown;
Menu.PointerPressed -= PointerPressed;
Menu.PointerReleased -= PointerReleased;
Menu.RemoveHandler(AccessKeyHandler.AccessKeyPressedEvent, AccessKeyPressed);
Menu.RemoveHandler(Avalonia.Controls.Menu.MenuOpenedEvent, this.MenuOpened);
Menu.RemoveHandler(MenuItem.PointerEnterItemEvent, PointerEnter);
Menu.RemoveHandler(MenuItem.PointerLeaveItemEvent, PointerLeave);
if (_root is InputElement inputRoot)
{
inputRoot.RemoveHandler(InputElement.PointerPressedEvent, RootPointerPressed);
}
if (_root is WindowBase root)
{
root.Deactivated -= WindowDeactivated;
}
_inputManagerSubscription.Dispose();
Menu = null;
_root = null;
}
protected Action<Action, TimeSpan> DelayRun { get; }
protected IInputManager InputManager { get; }
protected IMenu Menu { get; private set; }
protected static TimeSpan MenuShowDelay { get; } = TimeSpan.FromMilliseconds(400);
protected internal virtual void GotFocus(object sender, GotFocusEventArgs e)
{
var item = GetMenuItem(e.Source as IControl);
if (item?.Parent != null)
{
item.SelectedItem = item;
}
}
protected internal virtual void LostFocus(object sender, RoutedEventArgs e)
{
var item = GetMenuItem(e.Source as IControl);
if (item != null)
{
item.SelectedItem = null;
}
}
protected internal virtual void KeyDown(object sender, KeyEventArgs e)
{
var item = GetMenuItem(e.Source as IControl);
if (item != null)
{
KeyDown(item, e);
}
}
protected internal virtual void KeyDown(IMenuItem item, KeyEventArgs e)
{
Contract.Requires<ArgumentNullException>(item != null);
switch (e.Key)
{
case Key.Up:
case Key.Down:
if (item.IsTopLevel)
{
if (item.HasSubMenu && !item.IsSubMenuOpen)
{
Open(item, true);
e.Handled = true;
}
}
else
{
goto default;
}
break;
case Key.Left:
if (item.Parent is IMenuItem parent && !parent.IsTopLevel && parent.IsSubMenuOpen)
{
parent.Close();
parent.Focus();
e.Handled = true;
}
else
{
goto default;
}
break;
case Key.Right:
if (!item.IsTopLevel && item.HasSubMenu)
{
Open(item, true);
e.Handled = true;
}
else
{
goto default;
}
break;
case Key.Enter:
if (!item.HasSubMenu)
{
Click(item);
}
else
{
Open(item, true);
}
e.Handled = true;
break;
case Key.Escape:
if (item.Parent != null)
{
item.Parent.Close();
item.Parent.Focus();
e.Handled = true;
}
break;
default:
var direction = e.Key.ToNavigationDirection();
if (direction.HasValue && item.Parent?.MoveSelection(direction.Value, true) == true)
{
// If the the parent is an IMenu which successfully moved its selection,
// and the current menu is open then close the current menu and open the
// new menu.
if (item.IsSubMenuOpen && item.Parent is IMenu)
{
item.Close();
Open(item.Parent.SelectedItem, true);
}
e.Handled = true;
}
break;
}
if (!e.Handled && item.Parent is IMenuItem parentItem)
{
KeyDown(parentItem, e);
}
}
protected internal virtual void AccessKeyPressed(object sender, RoutedEventArgs e)
{
var item = GetMenuItem(e.Source as IControl);
if (item == null)
{
return;
}
if (item.HasSubMenu)
{
Open(item, true);
}
else
{
Click(item);
}
e.Handled = true;
}
protected internal virtual void PointerEnter(object sender, PointerEventArgs e)
{
var item = GetMenuItem(e.Source as IControl);
if (item?.Parent == null)
{
return;
}
if (item.IsTopLevel)
{
if (item.Parent.SelectedItem?.IsSubMenuOpen == true)
{
item.Parent.SelectedItem.Close();
SelectItemAndAncestors(item);
Open(item, false);
}
else
{
SelectItemAndAncestors(item);
}
}
else
{
SelectItemAndAncestors(item);
if (item.HasSubMenu)
{
OpenWithDelay(item);
}
else if (item.Parent != null)
{
foreach (var sibling in item.Parent.SubItems)
{
if (sibling.IsSubMenuOpen)
{
CloseWithDelay(sibling);
}
}
}
}
}
protected internal virtual void PointerLeave(object sender, PointerEventArgs e)
{
var item = GetMenuItem(e.Source as IControl);
if (item?.Parent == null)
{
return;
}
if (item.Parent.SelectedItem == item)
{
if (item.IsTopLevel)
{
if (!((IMenu)item.Parent).IsOpen)
{
item.Parent.SelectedItem = null;
}
}
else if (!item.HasSubMenu)
{
item.Parent.SelectedItem = null;
}
}
}
protected internal virtual void PointerPressed(object sender, PointerPressedEventArgs e)
{
var item = GetMenuItem(e.Source as IControl);
if (e.MouseButton == MouseButton.Left && item?.HasSubMenu == true)
{
Open(item, false);
e.Handled = true;
}
}
protected internal virtual void PointerReleased(object sender, PointerReleasedEventArgs e)
{
var item = GetMenuItem(e.Source as IControl);
if (e.MouseButton == MouseButton.Left && item.HasSubMenu == false)
{
Click(item);
e.Handled = true;
}
}
protected internal virtual void MenuOpened(object sender, RoutedEventArgs e)
{
if (e.Source == Menu)
{
Menu.MoveSelection(NavigationDirection.First, true);
}
}
protected internal virtual void RawInput(RawInputEventArgs e)
{
var mouse = e as RawMouseEventArgs;
if (mouse?.Type == RawMouseEventType.NonClientLeftButtonDown)
{
Menu.Close();
}
}
protected internal virtual void RootPointerPressed(object sender, PointerPressedEventArgs e)
{
if (Menu?.IsOpen == true)
{
var control = e.Source as ILogical;
if (!Menu.IsLogicalParentOf(control))
{
Menu.Close();
}
}
}
protected internal virtual void WindowDeactivated(object sender, EventArgs e)
{
Menu.Close();
}
protected void Click(IMenuItem item)
{
item.RaiseClick();
CloseMenu(item);
}
protected void CloseMenu(IMenuItem item)
{
var current = (IMenuElement)item;
while (current != null && !(current is IMenu))
{
current = (current as IMenuItem)?.Parent;
}
current?.Close();
}
protected void CloseWithDelay(IMenuItem item)
{
void Execute()
{
if (item.Parent?.SelectedItem != item)
{
item.Close();
}
}
DelayRun(Execute, MenuShowDelay);
}
protected void Open(IMenuItem item, bool selectFirst)
{
item.Open();
if (selectFirst)
{
item.MoveSelection(NavigationDirection.First, true);
}
}
protected void OpenWithDelay(IMenuItem item)
{
void Execute()
{
if (item.Parent?.SelectedItem == item)
{
Open(item, false);
}
}
DelayRun(Execute, MenuShowDelay);
}
protected void SelectItemAndAncestors(IMenuItem item)
{
var current = item;
while (current?.Parent != null)
{
current.Parent.SelectedItem = current;
current = current.Parent as IMenuItem;
}
}
protected static IMenuItem GetMenuItem(IControl item)
{
while (true)
{
if (item == null)
return null;
if (item is IMenuItem menuItem)
return menuItem;
item = item.Parent;
}
}
private static void DefaultDelayRun(Action action, TimeSpan timeSpan)
{
DispatcherTimer.RunOnce(action, timeSpan);
}
}
}

22
src/Avalonia.Controls/Platform/IMenuInteractionHandler.cs

@ -0,0 +1,22 @@
using System;
using Avalonia.Input;
namespace Avalonia.Controls.Platform
{
/// <summary>
/// Handles user interaction for menus.
/// </summary>
public interface IMenuInteractionHandler
{
/// <summary>
/// Attaches the interaction handler to a menu.
/// </summary>
/// <param name="menu">The menu.</param>
void Attach(IMenu menu);
/// <summary>
/// Detaches the interaction handler from the attached menu.
/// </summary>
void Detach(IMenu menu);
}
}

9
src/Avalonia.Controls/Presenters/ItemsPresenter.cs

@ -143,13 +143,6 @@ namespace Avalonia.Controls.Presenters
Virtualizer = ItemVirtualizer.Create(this); Virtualizer = ItemVirtualizer.Create(this);
((ILogicalScrollable)this).InvalidateScroll?.Invoke(); ((ILogicalScrollable)this).InvalidateScroll?.Invoke();
if (!Panel.IsSet(KeyboardNavigation.DirectionalNavigationProperty))
{
KeyboardNavigation.SetDirectionalNavigation(
(InputElement)Panel,
KeyboardNavigationMode.Contained);
}
KeyboardNavigation.SetTabNavigation( KeyboardNavigation.SetTabNavigation(
(InputElement)Panel, (InputElement)Panel,
KeyboardNavigation.GetTabNavigation(this)); KeyboardNavigation.GetTabNavigation(this));
@ -175,4 +168,4 @@ namespace Avalonia.Controls.Presenters
((ILogicalScrollable)this).InvalidateScroll?.Invoke(); ((ILogicalScrollable)this).InvalidateScroll?.Invoke();
} }
} }
} }

36
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@ -457,6 +457,42 @@ namespace Avalonia.Controls.Primitives
} }
} }
/// <summary>
/// Moves the selection in the specified direction relative to the current selection.
/// </summary>
/// <param name="direction">The direction to move.</param>
/// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
/// <returns>True if the selection was moved; otherwise false.</returns>
protected bool MoveSelection(NavigationDirection direction, bool wrap)
{
var from = SelectedIndex != -1 ? ItemContainerGenerator.ContainerFromIndex(SelectedIndex) : null;
return MoveSelection(from, direction, wrap);
}
/// <summary>
/// Moves the selection in the specified direction relative to the specified container.
/// </summary>
/// <param name="from">The container which serves as a starting point for the movement.</param>
/// <param name="direction">The direction to move.</param>
/// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
/// <returns>True if the selection was moved; otherwise false.</returns>
protected bool MoveSelection(IControl from, NavigationDirection direction, bool wrap)
{
if (Presenter?.Panel is INavigableContainer container &&
GetNextControl(container, direction, from, wrap) is IControl next)
{
var index = ItemContainerGenerator.IndexFromContainer(next);
if (index != -1)
{
SelectedIndex = index;
return true;
}
}
return false;
}
/// <summary> /// <summary>
/// Updates the selection for an item based on user interaction. /// Updates the selection for an item based on user interaction.
/// </summary> /// </summary>

161
src/Avalonia.Controls/Primitives/UniformGrid.cs

@ -0,0 +1,161 @@
using System;
namespace Avalonia.Controls.Primitives
{
/// <summary>
/// A <see cref="Panel"/> with uniform column and row sizes.
/// </summary>
public class UniformGrid : Panel
{
/// <summary>
/// Defines the <see cref="Rows"/> property.
/// </summary>
public static readonly StyledProperty<int> RowsProperty =
AvaloniaProperty.Register<UniformGrid, int>(nameof(Rows));
/// <summary>
/// Defines the <see cref="Columns"/> property.
/// </summary>
public static readonly StyledProperty<int> ColumnsProperty =
AvaloniaProperty.Register<UniformGrid, int>(nameof(Columns));
/// <summary>
/// Defines the <see cref="FirstColumn"/> property.
/// </summary>
public static readonly StyledProperty<int> FirstColumnProperty =
AvaloniaProperty.Register<UniformGrid, int>(nameof(FirstColumn));
private int _rows;
private int _columns;
/// <summary>
/// Specifies the row count. If set to 0, row count will be calculated automatically.
/// </summary>
public int Rows
{
get => GetValue(RowsProperty);
set => SetValue(RowsProperty, value);
}
/// <summary>
/// Specifies the column count. If set to 0, column count will be calculated automatically.
/// </summary>
public int Columns
{
get => GetValue(ColumnsProperty);
set => SetValue(ColumnsProperty, value);
}
/// <summary>
/// Specifies, for the first row, the column where the items should start.
/// </summary>
public int FirstColumn
{
get => GetValue(FirstColumnProperty);
set => SetValue(FirstColumnProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
UpdateRowsAndColumns();
var maxWidth = 0d;
var maxHeight = 0d;
var childAvailableSize = new Size(availableSize.Width / _columns, availableSize.Height / _rows);
foreach (var child in Children)
{
child.Measure(childAvailableSize);
if (child.DesiredSize.Width > maxWidth)
{
maxWidth = child.DesiredSize.Width;
}
if (child.DesiredSize.Height > maxHeight)
{
maxHeight = child.DesiredSize.Height;
}
}
return new Size(maxWidth * _columns, maxHeight * _rows);
}
protected override Size ArrangeOverride(Size finalSize)
{
var x = FirstColumn;
var y = 0;
var width = finalSize.Width / _columns;
var height = finalSize.Height / _rows;
foreach (var child in Children)
{
if (!child.IsVisible)
{
continue;
}
child.Arrange(new Rect(x * width, y * height, width, height));
x++;
if (x >= _columns)
{
x = 0;
y++;
}
}
return finalSize;
}
private void UpdateRowsAndColumns()
{
_rows = Rows;
_columns = Columns;
if (FirstColumn >= Columns)
{
FirstColumn = 0;
}
var itemCount = FirstColumn;
foreach (var child in Children)
{
if (child.IsVisible)
{
itemCount++;
}
}
if (_rows == 0)
{
if (_columns == 0)
{
_rows = _columns = (int)Math.Ceiling(Math.Sqrt(itemCount));
}
else
{
_rows = Math.DivRem(itemCount, _columns, out int rem);
if (rem != 0)
{
_rows++;
}
}
}
else if (_columns == 0)
{
_columns = Math.DivRem(itemCount, _rows, out int rem);
if (rem != 0)
{
_columns++;
}
}
}
}
}

176
src/Avalonia.Controls/RadioButton.cs

@ -2,19 +2,119 @@
// Licensed under the MIT license. See licence.md file in the project root for full license information. // Licensed under the MIT license. See licence.md file in the project root for full license information.
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Rendering;
using Avalonia.VisualTree; using Avalonia.VisualTree;
namespace Avalonia.Controls namespace Avalonia.Controls
{ {
public class RadioButton : ToggleButton public class RadioButton : ToggleButton
{ {
private class RadioButtonGroupManager
{
public static readonly RadioButtonGroupManager Default = new RadioButtonGroupManager();
static readonly ConditionalWeakTable<IRenderRoot, RadioButtonGroupManager> s_registeredVisualRoots
= new ConditionalWeakTable<IRenderRoot, RadioButtonGroupManager>();
readonly Dictionary<string, List<WeakReference<RadioButton>>> s_registeredGroups
= new Dictionary<string, List<WeakReference<RadioButton>>>();
public static RadioButtonGroupManager GetOrCreateForRoot(IRenderRoot root)
{
if (root == null)
return Default;
return s_registeredVisualRoots.GetValue(root, key => new RadioButtonGroupManager());
}
public void Add(RadioButton radioButton)
{
lock (s_registeredGroups)
{
string groupName = radioButton.GroupName;
if (!s_registeredGroups.TryGetValue(groupName, out var group))
{
group = new List<WeakReference<RadioButton>>();
s_registeredGroups.Add(groupName, group);
}
group.Add(new WeakReference<RadioButton>(radioButton));
}
}
public void Remove(RadioButton radioButton, string oldGroupName)
{
lock (s_registeredGroups)
{
if (!string.IsNullOrEmpty(oldGroupName) && s_registeredGroups.TryGetValue(oldGroupName, out var group))
{
int i = 0;
while (i < group.Count)
{
if (!group[i].TryGetTarget(out var button) || button == radioButton)
{
group.RemoveAt(i);
continue;
}
i++;
}
if (group.Count == 0)
{
s_registeredGroups.Remove(oldGroupName);
}
}
}
}
public void SetChecked(RadioButton radioButton)
{
lock (s_registeredGroups)
{
string groupName = radioButton.GroupName;
if (s_registeredGroups.TryGetValue(groupName, out var group))
{
int i = 0;
while (i < group.Count)
{
if (!group[i].TryGetTarget(out var current))
{
group.RemoveAt(i);
continue;
}
if (current != radioButton && current.IsChecked.GetValueOrDefault())
current.IsChecked = false;
i++;
}
if (group.Count == 0)
{
s_registeredGroups.Remove(groupName);
}
}
}
}
}
public static readonly DirectProperty<RadioButton, string> GroupNameProperty =
AvaloniaProperty.RegisterDirect<RadioButton, string>(
nameof(GroupName),
o => o.GroupName,
(o, v) => o.GroupName = v);
private string _groupName;
private RadioButtonGroupManager _groupManager;
public RadioButton() public RadioButton()
{ {
this.GetObservable(IsCheckedProperty).Subscribe(IsCheckedChanged); this.GetObservable(IsCheckedProperty).Subscribe(IsCheckedChanged);
} }
public string GroupName
{
get { return _groupName; }
set { SetGroupName(value); }
}
protected override void Toggle() protected override void Toggle()
{ {
if (!IsChecked.GetValueOrDefault()) if (!IsChecked.GetValueOrDefault())
@ -23,21 +123,77 @@ namespace Avalonia.Controls
} }
} }
private void IsCheckedChanged(bool? value) protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
if (!string.IsNullOrEmpty(GroupName))
{
var manager = RadioButtonGroupManager.GetOrCreateForRoot(e.Root);
if (manager != _groupManager)
{
_groupManager.Remove(this, _groupName);
_groupManager = manager;
manager.Add(this);
}
}
base.OnAttachedToVisualTree(e);
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
if (!string.IsNullOrEmpty(GroupName) && _groupManager != null)
{
_groupManager.Remove(this, _groupName);
}
}
private void SetGroupName(string newGroupName)
{ {
var parent = this.GetVisualParent(); string oldGroupName = GroupName;
if (newGroupName != oldGroupName)
{
if (!string.IsNullOrEmpty(oldGroupName) && _groupManager != null)
{
_groupManager.Remove(this, oldGroupName);
}
_groupName = newGroupName;
if (!string.IsNullOrEmpty(newGroupName))
{
if (_groupManager == null)
{
_groupManager = RadioButtonGroupManager.GetOrCreateForRoot(this.GetVisualRoot());
}
_groupManager.Add(this);
}
}
}
if (value.GetValueOrDefault() && parent != null) private void IsCheckedChanged(bool? value)
{
string groupName = GroupName;
if (string.IsNullOrEmpty(groupName))
{ {
var siblings = parent var parent = this.GetVisualParent();
.GetVisualChildren()
.OfType<RadioButton>()
.Where(x => x != this);
foreach (var sibling in siblings) if (value.GetValueOrDefault() && parent != null)
{
var siblings = parent
.GetVisualChildren()
.OfType<RadioButton>()
.Where(x => x != this);
foreach (var sibling in siblings)
{
if (sibling.IsChecked.GetValueOrDefault())
sibling.IsChecked = false;
}
}
}
else
{
if (value.GetValueOrDefault() && _groupManager != null)
{ {
if (sibling.IsChecked.GetValueOrDefault()) _groupManager.SetChecked(this);
sibling.IsChecked = false;
} }
} }
} }

58
src/Avalonia.Controls/StackPanel.cs

@ -56,11 +56,49 @@ namespace Avalonia.Controls
/// </summary> /// </summary>
/// <param name="direction">The movement direction.</param> /// <param name="direction">The movement direction.</param>
/// <param name="from">The control from which movement begins.</param> /// <param name="from">The control from which movement begins.</param>
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
/// <returns>The control.</returns> /// <returns>The control.</returns>
IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from) IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap)
{ {
var fromControl = from as IControl; var result = GetControlInDirection(direction, from as IControl);
return (fromControl != null) ? GetControlInDirection(direction, fromControl) : null;
if (result == null && wrap)
{
if (Orientation == Orientation.Vertical)
{
switch (direction)
{
case NavigationDirection.Up:
case NavigationDirection.Previous:
case NavigationDirection.PageUp:
result = GetControlInDirection(NavigationDirection.Last, null);
break;
case NavigationDirection.Down:
case NavigationDirection.Next:
case NavigationDirection.PageDown:
result = GetControlInDirection(NavigationDirection.First, null);
break;
}
}
else
{
switch (direction)
{
case NavigationDirection.Left:
case NavigationDirection.Previous:
case NavigationDirection.PageUp:
result = GetControlInDirection(NavigationDirection.Last, null);
break;
case NavigationDirection.Right:
case NavigationDirection.Next:
case NavigationDirection.PageDown:
result = GetControlInDirection(NavigationDirection.First, null);
break;
}
}
}
return result;
} }
/// <summary> /// <summary>
@ -72,7 +110,7 @@ namespace Avalonia.Controls
protected virtual IInputElement GetControlInDirection(NavigationDirection direction, IControl from) protected virtual IInputElement GetControlInDirection(NavigationDirection direction, IControl from)
{ {
var horiz = Orientation == Orientation.Horizontal; var horiz = Orientation == Orientation.Horizontal;
int index = Children.IndexOf((IControl)from); int index = from != null ? Children.IndexOf(from) : -1;
switch (direction) switch (direction)
{ {
@ -83,22 +121,22 @@ namespace Avalonia.Controls
index = Children.Count - 1; index = Children.Count - 1;
break; break;
case NavigationDirection.Next: case NavigationDirection.Next:
++index; if (index != -1) ++index;
break; break;
case NavigationDirection.Previous: case NavigationDirection.Previous:
--index; if (index != -1) --index;
break; break;
case NavigationDirection.Left: case NavigationDirection.Left:
index = horiz ? index - 1 : -1; if (index != -1) index = horiz ? index - 1 : -1;
break; break;
case NavigationDirection.Right: case NavigationDirection.Right:
index = horiz ? index + 1 : -1; if (index != -1) index = horiz ? index + 1 : -1;
break; break;
case NavigationDirection.Up: case NavigationDirection.Up:
index = horiz ? -1 : index - 1; if (index != -1) index = horiz ? -1 : index - 1;
break; break;
case NavigationDirection.Down: case NavigationDirection.Down:
index = horiz ? -1 : index + 1; if (index != -1) index = horiz ? -1 : index + 1;
break; break;
default: default:
index = -1; index = -1;

86
src/Avalonia.Controls/TreeView.cs

@ -136,6 +136,92 @@ namespace Avalonia.Controls
} }
} }
protected override void OnKeyDown(KeyEventArgs e)
{
var direction = e.Key.ToNavigationDirection();
if (direction?.IsDirectional() == true && !e.Handled)
{
if (SelectedItem != null)
{
var next = GetContainerInDirection(
GetContainerFromEventSource(e.Source) as TreeViewItem,
direction.Value,
true);
if (next != null)
{
FocusManager.Instance.Focus(next, NavigationMethod.Directional);
e.Handled = true;
}
}
else
{
SelectedItem = ElementAt(Items, 0);
}
}
}
private TreeViewItem GetContainerInDirection(
TreeViewItem from,
NavigationDirection direction,
bool intoChildren)
{
IItemContainerGenerator parentGenerator;
if (from?.Parent is TreeView treeView)
{
parentGenerator = treeView.ItemContainerGenerator;
}
else if (from?.Parent is TreeViewItem item)
{
parentGenerator = item.ItemContainerGenerator;
}
else
{
return null;
}
var index = parentGenerator.IndexFromContainer(from);
var parent = from.Parent as ItemsControl;
TreeViewItem result = null;
switch (direction)
{
case NavigationDirection.Up:
if (index > 0)
{
var previous = (TreeViewItem)parentGenerator.ContainerFromIndex(index - 1);
result = previous.IsExpanded ?
(TreeViewItem)previous.ItemContainerGenerator.ContainerFromIndex(previous.ItemCount - 1) :
previous;
}
else
{
result = from.Parent as TreeViewItem;
}
break;
case NavigationDirection.Down:
if (from.IsExpanded && intoChildren)
{
result = (TreeViewItem)from.ItemContainerGenerator.ContainerFromIndex(0);
}
else if (index < parent?.ItemCount - 1)
{
result = (TreeViewItem)parentGenerator.ContainerFromIndex(index + 1);
}
else if (parent is TreeViewItem parentItem)
{
return GetContainerInDirection(parentItem, direction, false);
}
break;
}
return result;
}
/// <inheritdoc/> /// <inheritdoc/>
protected override void OnPointerPressed(PointerPressedEventArgs e) protected override void OnPointerPressed(PointerPressedEventArgs e)
{ {

7
src/Avalonia.Controls/TreeViewItem.cs

@ -32,10 +32,7 @@ namespace Avalonia.Controls
ListBoxItem.IsSelectedProperty.AddOwner<TreeViewItem>(); ListBoxItem.IsSelectedProperty.AddOwner<TreeViewItem>();
private static readonly ITemplate<IPanel> DefaultPanel = private static readonly ITemplate<IPanel> DefaultPanel =
new FuncTemplate<IPanel>(() => new StackPanel new FuncTemplate<IPanel>(() => new StackPanel());
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
});
private TreeView _treeView; private TreeView _treeView;
private bool _isExpanded; private bool _isExpanded;
@ -127,7 +124,7 @@ namespace Avalonia.Controls
} }
} }
base.OnKeyDown(e); // Don't call base.OnKeyDown - let events bubble up to containing TreeView.
} }
} }
} }

5
src/Avalonia.Controls/WrapPanel.cs

@ -47,8 +47,9 @@ namespace Avalonia.Controls
/// </summary> /// </summary>
/// <param name="direction">The movement direction.</param> /// <param name="direction">The movement direction.</param>
/// <param name="from">The control from which movement begins.</param> /// <param name="from">The control from which movement begins.</param>
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
/// <returns>The control.</returns> /// <returns>The control.</returns>
IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from) IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap)
{ {
var horiz = Orientation == Orientation.Horizontal; var horiz = Orientation == Orientation.Horizontal;
int index = Children.IndexOf((IControl)from); int index = Children.IndexOf((IControl)from);
@ -250,4 +251,4 @@ namespace Avalonia.Controls
} }
} }
} }
} }

37
src/Avalonia.Input/AccessKeyHandler.cs

@ -53,10 +53,32 @@ namespace Avalonia.Input
/// </summary> /// </summary>
private IInputElement _restoreFocusElement; private IInputElement _restoreFocusElement;
/// <summary>
/// The window's main menu.
/// </summary>
private IMainMenu _mainMenu;
/// <summary> /// <summary>
/// Gets or sets the window's main menu. /// Gets or sets the window's main menu.
/// </summary> /// </summary>
public IMainMenu MainMenu { get; set; } public IMainMenu MainMenu
{
get => _mainMenu;
set
{
if (_mainMenu != null)
{
_mainMenu.MenuClosed -= MainMenuClosed;
}
_mainMenu = value;
if (_mainMenu != null)
{
_mainMenu.MenuClosed += MainMenuClosed;
}
}
}
/// <summary> /// <summary>
/// Sets the owner of the access key handler. /// Sets the owner of the access key handler.
@ -160,13 +182,7 @@ namespace Avalonia.Input
{ {
bool menuIsOpen = MainMenu?.IsOpen == true; bool menuIsOpen = MainMenu?.IsOpen == true;
if (e.Key == Key.Escape && menuIsOpen) if ((e.Modifiers & InputModifiers.Alt) != 0 || menuIsOpen)
{
// When the Escape key is pressed with the main menu open, close it.
CloseMenu();
e.Handled = true;
}
else if ((e.Modifiers & InputModifiers.Alt) != 0 || menuIsOpen)
{ {
// If any other key is pressed with the Alt key held down, or the main menu is open, // If any other key is pressed with the Alt key held down, or the main menu is open,
// find all controls who have registered that access key. // find all controls who have registered that access key.
@ -245,5 +261,10 @@ namespace Avalonia.Input
MainMenu.Close(); MainMenu.Close();
_owner.ShowAccessKeys = _showingAccessKeys = false; _owner.ShowAccessKeys = _showingAccessKeys = false;
} }
private void MainMenuClosed(object sender, EventArgs e)
{
_owner.ShowAccessKeys = false;
}
} }
} }

2
src/Avalonia.Input/IInputElement.cs

@ -15,7 +15,7 @@ namespace Avalonia.Input
/// <summary> /// <summary>
/// Occurs when the control receives focus. /// Occurs when the control receives focus.
/// </summary> /// </summary>
event EventHandler<RoutedEventArgs> GotFocus; event EventHandler<GotFocusEventArgs> GotFocus;
/// <summary> /// <summary>
/// Occurs when the control loses focus. /// Occurs when the control loses focus.

7
src/Avalonia.Input/IMainMenu.cs

@ -1,6 +1,8 @@
// Copyright (c) The Avalonia Project. All rights reserved. // Copyright (c) The Avalonia Project. All rights reserved.
// Licensed under the MIT license. See licence.md file in the project root for full license information. // Licensed under the MIT license. See licence.md file in the project root for full license information.
using System;
using Avalonia.Interactivity;
using Avalonia.VisualTree; using Avalonia.VisualTree;
namespace Avalonia.Input namespace Avalonia.Input
@ -24,5 +26,10 @@ namespace Avalonia.Input
/// Opens the menu in response to the Alt/F10 key. /// Opens the menu in response to the Alt/F10 key.
/// </summary> /// </summary>
void Open(); void Open();
/// <summary>
/// Occurs when the main menu closes.
/// </summary>
event EventHandler<RoutedEventArgs> MenuClosed;
} }
} }

3
src/Avalonia.Input/INavigableContainer.cs

@ -13,7 +13,8 @@ namespace Avalonia.Input
/// </summary> /// </summary>
/// <param name="direction">The movement direction.</param> /// <param name="direction">The movement direction.</param>
/// <param name="from">The control from which movement begins.</param> /// <param name="from">The control from which movement begins.</param>
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
/// <returns>The control.</returns> /// <returns>The control.</returns>
IInputElement GetControl(NavigationDirection direction, IInputElement from); IInputElement GetControl(NavigationDirection direction, IInputElement from, bool wrap);
} }
} }

2
src/Avalonia.Input/InputElement.cs

@ -177,7 +177,7 @@ namespace Avalonia.Input
/// <summary> /// <summary>
/// Occurs when the control receives focus. /// Occurs when the control receives focus.
/// </summary> /// </summary>
public event EventHandler<RoutedEventArgs> GotFocus public event EventHandler<GotFocusEventArgs> GotFocus
{ {
add { AddHandler(GotFocusEvent, value); } add { AddHandler(GotFocusEvent, value); }
remove { RemoveHandler(GotFocusEvent, value); } remove { RemoveHandler(GotFocusEvent, value); }

33
src/Avalonia.Input/KeyboardNavigation.cs

@ -8,19 +8,6 @@ namespace Avalonia.Input
/// </summary> /// </summary>
public static class KeyboardNavigation public static class KeyboardNavigation
{ {
/// <summary>
/// Defines the DirectionalNavigation attached property.
/// </summary>
/// <remarks>
/// The DirectionalNavigation attached property defines how pressing arrow keys causes
/// focus to be navigated between the children of the container.
/// </remarks>
public static readonly AttachedProperty<KeyboardNavigationMode> DirectionalNavigationProperty =
AvaloniaProperty.RegisterAttached<InputElement, KeyboardNavigationMode>(
"DirectionalNavigation",
typeof(KeyboardNavigation),
KeyboardNavigationMode.None);
/// <summary> /// <summary>
/// Defines the TabNavigation attached property. /// Defines the TabNavigation attached property.
/// </summary> /// </summary>
@ -46,26 +33,6 @@ namespace Avalonia.Input
"TabOnceActiveElement", "TabOnceActiveElement",
typeof(KeyboardNavigation)); typeof(KeyboardNavigation));
/// <summary>
/// Gets the <see cref="DirectionalNavigationProperty"/> for a container.
/// </summary>
/// <param name="element">The container.</param>
/// <returns>The <see cref="KeyboardNavigationMode"/> for the container.</returns>
public static KeyboardNavigationMode GetDirectionalNavigation(InputElement element)
{
return element.GetValue(DirectionalNavigationProperty);
}
/// <summary>
/// Sets the <see cref="DirectionalNavigationProperty"/> for a container.
/// </summary>
/// <param name="element">The container.</param>
/// <param name="value">The <see cref="KeyboardNavigationMode"/> for the container.</param>
public static void SetDirectionalNavigation(InputElement element, KeyboardNavigationMode value)
{
element.SetValue(DirectionalNavigationProperty, value);
}
/// <summary> /// <summary>
/// Gets the <see cref="TabNavigationProperty"/> for a container. /// Gets the <see cref="TabNavigationProperty"/> for a container.
/// </summary> /// </summary>

47
src/Avalonia.Input/KeyboardNavigationHandler.cs

@ -85,7 +85,7 @@ namespace Avalonia.Input
} }
else else
{ {
return DirectionalNavigation.GetNext(element, direction); throw new NotSupportedException();
} }
} }
@ -122,47 +122,12 @@ namespace Avalonia.Input
{ {
var current = FocusManager.Instance.Current; var current = FocusManager.Instance.Current;
if (current != null) if (current != null && e.Key == Key.Tab)
{ {
NavigationDirection? direction = null; var direction = (e.Modifiers & InputModifiers.Shift) == 0 ?
NavigationDirection.Next : NavigationDirection.Previous;
switch (e.Key) Move(current, direction, e.Modifiers);
{ e.Handled = true;
case Key.Tab:
direction = (e.Modifiers & InputModifiers.Shift) == 0 ?
NavigationDirection.Next : NavigationDirection.Previous;
break;
case Key.Up:
direction = NavigationDirection.Up;
break;
case Key.Down:
direction = NavigationDirection.Down;
break;
case Key.Left:
direction = NavigationDirection.Left;
break;
case Key.Right:
direction = NavigationDirection.Right;
break;
case Key.PageUp:
direction = NavigationDirection.PageUp;
break;
case Key.PageDown:
direction = NavigationDirection.PageDown;
break;
case Key.Home:
direction = NavigationDirection.First;
break;
case Key.End:
direction = NavigationDirection.Last;
break;
}
if (direction.HasValue)
{
Move(current, direction.Value, e.Modifiers);
e.Handled = true;
}
} }
} }
} }

242
src/Avalonia.Input/Navigation/DirectionalNavigation.cs

@ -1,242 +0,0 @@
// Copyright (c) The Avalonia Project. All rights reserved.
// Licensed under the MIT license. See licence.md file in the project root for full license information.
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.VisualTree;
namespace Avalonia.Input.Navigation
{
/// <summary>
/// The implementation for default directional navigation.
/// </summary>
public static class DirectionalNavigation
{
/// <summary>
/// Gets the next control in the specified navigation direction.
/// </summary>
/// <param name="element">The element.</param>
/// <param name="direction">The navigation direction.</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 GetNext(
IInputElement element,
NavigationDirection direction)
{
Contract.Requires<ArgumentNullException>(element != null);
Contract.Requires<ArgumentException>(
direction != NavigationDirection.Next &&
direction != NavigationDirection.Previous);
var container = element.GetVisualParent<IInputElement>();
if (container != null)
{
var mode = KeyboardNavigation.GetDirectionalNavigation((InputElement)container);
switch (mode)
{
case KeyboardNavigationMode.Continue:
return GetNextInContainer(element, container, direction) ??
GetFirstInNextContainer(element, element, direction);
case KeyboardNavigationMode.Cycle:
return GetNextInContainer(element, container, direction) ??
GetFocusableDescendant(container, direction);
case KeyboardNavigationMode.Contained:
return GetNextInContainer(element, container, direction);
default:
return null;
}
}
else
{
return GetFocusableDescendants(element).FirstOrDefault();
}
}
/// <summary>
/// Returns a value indicting whether the specified direction is forward.
/// </summary>
/// <param name="direction">The direction.</param>
/// <returns>True if the direction is forward.</returns>
private static bool IsForward(NavigationDirection direction)
{
return direction == NavigationDirection.Next ||
direction == NavigationDirection.Last ||
direction == NavigationDirection.Right ||
direction == NavigationDirection.Down;
}
/// <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)
{
return IsForward(direction) ?
GetFocusableDescendants(container).FirstOrDefault() :
GetFocusableDescendants(container).LastOrDefault();
}
/// <summary>
/// Gets the focusable descendants of the specified element.
/// </summary>
/// <param name="element">The element.</param>
/// <returns>The element's focusable descendants.</returns>
private static IEnumerable<IInputElement> GetFocusableDescendants(IInputElement element)
{
var children = element.GetVisualChildren().OfType<IInputElement>();
foreach (var child in children)
{
if (child.CanFocus())
{
yield return child;
}
if (child.CanFocusDescendants())
{
foreach (var descendant in GetFocusableDescendants(child))
{
yield return descendant;
}
}
}
}
/// <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>
/// <returns>The next element, or null if the element is the last.</returns>
private static IInputElement GetNextInContainer(
IInputElement element,
IInputElement container,
NavigationDirection direction)
{
if (direction == NavigationDirection.Down)
{
var descendant = GetFocusableDescendants(element).FirstOrDefault();
if (descendant != null)
{
return descendant;
}
}
if (container != null)
{
var navigable = container as INavigableContainer;
if (navigable != null)
{
while (element != null)
{
element = navigable.GetControl(direction, element);
if (element != null && element.CanFocus())
{
break;
}
}
}
else
{
// TODO: Do a spatial search here if the container doesn't implement
// INavigableContainer.
element = null;
}
if (element != null && direction == NavigationDirection.Up)
{
var descendant = GetFocusableDescendants(element).LastOrDefault();
if (descendant != null)
{
return descendant;
}
}
return element;
}
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)
{
var parent = container.GetVisualParent<IInputElement>();
var isForward = IsForward(direction);
IInputElement next = null;
if (parent != null)
{
if (!isForward && parent.CanFocus())
{
return parent;
}
var siblings = parent.GetVisualChildren()
.OfType<IInputElement>()
.Where(FocusExtensions.CanFocusDescendants);
var sibling = isForward ?
siblings.SkipWhile(x => x != container).Skip(1).FirstOrDefault() :
siblings.TakeWhile(x => x != container).LastOrDefault();
if (sibling != null)
{
if (sibling is ICustomKeyboardNavigation custom)
{
var (handled, customNext) = custom.GetNext(element, direction);
if (handled)
{
return customNext;
}
}
if (sibling.CanFocus())
{
next = sibling;
}
else
{
next = isForward ?
GetFocusableDescendants(sibling).FirstOrDefault() :
GetFocusableDescendants(sibling).LastOrDefault();
}
}
if (next == null)
{
next = GetFirstInNextContainer(element, parent, direction);
}
}
else
{
next = isForward ?
GetFocusableDescendants(container).FirstOrDefault() :
GetFocusableDescendants(container).LastOrDefault();
}
return next;
}
}
}

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

@ -168,7 +168,7 @@ namespace Avalonia.Input.Navigation
{ {
while (element != null) while (element != null)
{ {
element = navigable.GetControl(direction, element); element = navigable.GetControl(direction, element, false);
if (element != null && element.CanFocus()) if (element != null && element.CanFocus())
{ {

70
src/Avalonia.Input/NavigationDirection.cs

@ -58,4 +58,74 @@ namespace Avalonia.Input
/// </summary> /// </summary>
PageDown, PageDown,
} }
public static class NavigationDirectionExtensions
{
/// <summary>
/// Checks whether a <see cref="NavigationDirection"/> represents a tab movement.
/// </summary>
/// <param name="direction">The direction.</param>
/// <returns>
/// True if the direction represents a tab movement (<see cref="NavigationDirection.Next"/>
/// or <see cref="NavigationDirection.Previous"/>); otherwise false.
/// </returns>
public static bool IsTab(this NavigationDirection direction)
{
return direction == NavigationDirection.Next ||
direction == NavigationDirection.Previous;
}
/// <summary>
/// Checks whether a <see cref="NavigationDirection"/> represents a directional movement.
/// </summary>
/// <param name="direction">The direction.</param>
/// <returns>
/// True if the direction represents a directional movement (any value except
/// <see cref="NavigationDirection.Next"/> and <see cref="NavigationDirection.Previous"/>);
/// otherwise false.
/// </returns>
public static bool IsDirectional(this NavigationDirection direction)
{
return direction > NavigationDirection.Previous ||
direction <= NavigationDirection.PageDown;
}
/// <summary>
/// Converts a keypress into a <see cref="NavigationDirection"/>.
/// </summary>
/// <param name="key">The key.</param>
/// <param name="modifiers">The keyboard modifiers.</param>
/// <returns>
/// A <see cref="NavigationDirection"/> if the keypress represents a navigation keypress.
/// </returns>
public static NavigationDirection? ToNavigationDirection(
this Key key,
InputModifiers modifiers = InputModifiers.None)
{
switch (key)
{
case Key.Tab:
return (modifiers & InputModifiers.Shift) != 0 ?
NavigationDirection.Next : NavigationDirection.Previous;
case Key.Up:
return NavigationDirection.Up;
case Key.Down:
return NavigationDirection.Down;
case Key.Left:
return NavigationDirection.Left;
case Key.Right:
return NavigationDirection.Right;
case Key.Home:
return NavigationDirection.First;
case Key.End:
return NavigationDirection.Last;
case Key.PageUp:
return NavigationDirection.PageUp;
case Key.PageDown:
return NavigationDirection.PageDown;
default:
return null;
}
}
}
} }

7
src/Avalonia.Themes.Default/MenuItem.xaml

@ -127,11 +127,6 @@
<Setter Property="BorderBrush" Value="{DynamicResource ThemeAccentBrush}"/> <Setter Property="BorderBrush" Value="{DynamicResource ThemeAccentBrush}"/>
</Style> </Style>
<Style Selector="MenuItem:pointerover /template/ Border#root">
<Setter Property="Background" Value="{DynamicResource ThemeAccentBrush4}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeAccentBrush}"/>
</Style>
<Style Selector="MenuItem:empty /template/ Path#rightArrow"> <Style Selector="MenuItem:empty /template/ Path#rightArrow">
<Setter Property="IsVisible" Value="False"/> <Setter Property="IsVisible" Value="False"/>
</Style> </Style>
@ -139,4 +134,4 @@
<Style Selector="MenuItem:disabled"> <Style Selector="MenuItem:disabled">
<Setter Property="Opacity" Value="{DynamicResource ThemeDisabledOpacity}"/> <Setter Property="Opacity" Value="{DynamicResource ThemeDisabledOpacity}"/>
</Style> </Style>
</Styles> </Styles>

3
src/Avalonia.Themes.Default/Separator.xaml

@ -1,6 +1,7 @@
<Styles xmlns="https://github.com/avaloniaui"> <Styles xmlns="https://github.com/avaloniaui">
<Style Selector="Separator"> <Style Selector="Separator">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template"> <Setter Property="Template">
<ControlTemplate> <ControlTemplate>
<Border BorderBrush="{TemplateBinding BorderBrush}" <Border BorderBrush="{TemplateBinding BorderBrush}"
@ -22,4 +23,4 @@
<Setter Property="Height" Value="1"/> <Setter Property="Height" Value="1"/>
</Style> </Style>
</Styles> </Styles>

4
src/Markup/Avalonia.Markup/Data/Binding.cs

@ -150,7 +150,9 @@ namespace Avalonia.Data
} }
else if (RelativeSource.Mode == RelativeSourceMode.Self) else if (RelativeSource.Mode == RelativeSourceMode.Self)
{ {
observer = CreateSourceObserver(target, node); observer = CreateSourceObserver(
(target as IStyledElement) ?? (anchor as IStyledElement),
node);
} }
else if (RelativeSource.Mode == RelativeSourceMode.TemplatedParent) else if (RelativeSource.Mode == RelativeSourceMode.TemplatedParent)
{ {

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

@ -50,7 +50,12 @@ namespace Avalonia.Win32
// Declare that this process is aware of per monitor DPI // Declare that this process is aware of per monitor DPI
if (UnmanagedMethods.ShCoreAvailable) if (UnmanagedMethods.ShCoreAvailable)
{ {
UnmanagedMethods.SetProcessDpiAwareness(UnmanagedMethods.PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE); var osVersion = Environment.OSVersion.Version;
if (osVersion.Major > 6 || (osVersion.Major == 6 && osVersion.Minor > 2))
{
UnmanagedMethods.SetProcessDpiAwareness(UnmanagedMethods.PROCESS_DPI_AWARENESS
.PROCESS_PER_MONITOR_DPI_AWARE);
}
} }
CreateMessageWindow(); CreateMessageWindow();

72
tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs

@ -11,6 +11,7 @@ using Avalonia.VisualTree;
using Xunit; using Xunit;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using Avalonia.UnitTests; using Avalonia.UnitTests;
using Avalonia.Input;
namespace Avalonia.Controls.UnitTests namespace Avalonia.Controls.UnitTests
{ {
@ -494,6 +495,77 @@ namespace Avalonia.Controls.UnitTests
Assert.NotNull(NameScope.GetNameScope((TextBlock)container.Child)); Assert.NotNull(NameScope.GetNameScope((TextBlock)container.Child));
} }
[Fact]
public void Focuses_Next_Item_On_Key_Down()
{
using (UnitTestApplication.Start(TestServices.RealFocus))
{
var items = new object[]
{
new Button(),
new Button(),
};
var target = new ItemsControl
{
Template = GetTemplate(),
Items = items,
};
var root = new TestRoot { Child = target };
target.ApplyTemplate();
target.Presenter.ApplyTemplate();
target.Presenter.Panel.Children[0].Focus();
target.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Down,
});
Assert.Equal(
target.Presenter.Panel.Children[1],
FocusManager.Instance.Current);
}
}
[Fact]
public void Does_Not_Focus_Non_Focusable_Item_On_Key_Down()
{
using (UnitTestApplication.Start(TestServices.RealFocus))
{
var items = new object[]
{
new Button(),
new Button { Focusable = false },
new Button(),
};
var target = new ItemsControl
{
Template = GetTemplate(),
Items = items,
};
var root = new TestRoot { Child = target };
target.ApplyTemplate();
target.Presenter.ApplyTemplate();
target.Presenter.Panel.Children[0].Focus();
target.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Down,
});
Assert.Equal(
target.Presenter.Panel.Children[2],
FocusManager.Instance.Current);
}
}
private class Item private class Item
{ {
public Item(string value) public Item(string value)

507
tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs

@ -0,0 +1,507 @@
using System;
using Avalonia.Controls.Platform;
using Avalonia.Input;
using Moq;
using Xunit;
namespace Avalonia.Controls.UnitTests.Platform
{
public class DefaultMenuInteractionHandlerTests
{
public class TopLevel
{
[Fact]
public void Up_Opens_MenuItem_With_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var e = new KeyEventArgs { Key = Key.Up, Source = item };
target.KeyDown(item, e);
Mock.Get(item).Verify(x => x.Open());
Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true));
Assert.True(e.Handled);
}
[Fact]
public void Down_Opens_MenuItem_With_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var e = new KeyEventArgs { Key = Key.Down, Source = item };
target.KeyDown(item, e);
Mock.Get(item).Verify(x => x.Open());
Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true));
Assert.True(e.Handled);
}
[Fact]
public void Right_Selects_Next_MenuItem()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>(x => x.MoveSelection(NavigationDirection.Right, true) == true);
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
var e = new KeyEventArgs { Key = Key.Right, Source = item };
target.KeyDown(item, e);
Mock.Get(menu).Verify(x => x.MoveSelection(NavigationDirection.Right, true));
Assert.True(e.Handled);
}
[Fact]
public void Left_Selects_Previous_MenuItem()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>(x => x.MoveSelection(NavigationDirection.Left, true) == true);
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
var e = new KeyEventArgs { Key = Key.Left, Source = item };
target.KeyDown(item, e);
Mock.Get(menu).Verify(x => x.MoveSelection(NavigationDirection.Left, true));
Assert.True(e.Handled);
}
[Fact]
public void Enter_On_Item_With_No_SubMenu_Causes_Click()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
var e = new KeyEventArgs { Key = Key.Enter, Source = item };
target.KeyDown(item, e);
Mock.Get(item).Verify(x => x.RaiseClick());
Mock.Get(menu).Verify(x => x.Close());
Assert.True(e.Handled);
}
[Fact]
public void Enter_On_Item_With_SubMenu_Opens_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var e = new KeyEventArgs { Key = Key.Enter, Source = item };
target.KeyDown(item, e);
Mock.Get(item).Verify(x => x.Open());
Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true));
Assert.True(e.Handled);
}
[Fact]
public void Escape_Closes_Parent_Menu()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
var e = new KeyEventArgs { Key = Key.Escape, Source = item };
target.KeyDown(item, e);
Mock.Get(menu).Verify(x => x.Close());
Assert.True(e.Handled);
}
[Fact]
public void PointerEnter_Opens_Item_When_Old_Item_Is_Open()
{
var target = new DefaultMenuInteractionHandler();
var menu = new Mock<IMenu>();
var item = Mock.Of<IMenuItem>(x =>
x.IsSubMenuOpen == true &&
x.IsTopLevel == true &&
x.HasSubMenu == true &&
x.Parent == menu.Object);
var nextItem = Mock.Of<IMenuItem>(x =>
x.IsTopLevel == true &&
x.HasSubMenu == true &&
x.Parent == menu.Object);
var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = nextItem };
menu.SetupGet(x => x.SelectedItem).Returns(item);
target.PointerEnter(nextItem, e);
Mock.Get(item).Verify(x => x.Close());
menu.VerifySet(x => x.SelectedItem = nextItem);
Mock.Get(nextItem).Verify(x => x.Open());
Mock.Get(nextItem).Verify(x => x.MoveSelection(NavigationDirection.First, true), Times.Never);
Assert.False(e.Handled);
}
[Fact]
public void PointerLeave_Deselects_Item_When_Menu_Not_Open()
{
var target = new DefaultMenuInteractionHandler();
var menu = new Mock<IMenu>();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu.Object);
var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
menu.SetupGet(x => x.SelectedItem).Returns(item);
target.PointerLeave(item, e);
menu.VerifySet(x => x.SelectedItem = null);
Assert.False(e.Handled);
}
[Fact]
public void PointerLeave_Doesnt_Deselect_Item_When_Menu_Open()
{
var target = new DefaultMenuInteractionHandler();
var menu = new Mock<IMenu>();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu.Object);
var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
menu.SetupGet(x => x.IsOpen).Returns(true);
menu.SetupGet(x => x.SelectedItem).Returns(item);
target.PointerLeave(item, e);
menu.VerifySet(x => x.SelectedItem = null, Times.Never);
Assert.False(e.Handled);
}
}
public class NonTopLevel
{
[Fact]
public void Up_Selects_Previous_MenuItem()
{
var target = new DefaultMenuInteractionHandler();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new KeyEventArgs { Key = Key.Up, Source = item };
target.KeyDown(item, e);
Mock.Get(parentItem).Verify(x => x.MoveSelection(NavigationDirection.Up, true));
Assert.True(e.Handled);
}
[Fact]
public void Down_Selects_Next_MenuItem()
{
var target = new DefaultMenuInteractionHandler();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new KeyEventArgs { Key = Key.Down, Source = item };
target.KeyDown(item, e);
Mock.Get(parentItem).Verify(x => x.MoveSelection(NavigationDirection.Down, true));
Assert.True(e.Handled);
}
[Fact]
public void Left_Closes_Parent_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var parentItem = Mock.Of<IMenuItem>(x => x.HasSubMenu == true && x.IsSubMenuOpen == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new KeyEventArgs { Key = Key.Left, Source = item };
target.KeyDown(item, e);
Mock.Get(parentItem).Verify(x => x.Close());
Mock.Get(parentItem).Verify(x => x.Focus());
Assert.True(e.Handled);
}
[Fact]
public void Right_With_SubMenu_Items_Opens_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
var e = new KeyEventArgs { Key = Key.Right, Source = item };
target.KeyDown(item, e);
Mock.Get(item).Verify(x => x.Open());
Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true));
Assert.True(e.Handled);
}
[Fact]
public void Right_On_TopLevel_Child_Navigates_TopLevel_Selection()
{
var target = new DefaultMenuInteractionHandler();
var menu = new Mock<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x =>
x.IsSubMenuOpen == true &&
x.IsTopLevel == true &&
x.HasSubMenu == true &&
x.Parent == menu.Object);
var nextItem = Mock.Of<IMenuItem>(x =>
x.IsTopLevel == true &&
x.HasSubMenu == true &&
x.Parent == menu.Object);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new KeyEventArgs { Key = Key.Right, Source = item };
menu.Setup(x => x.MoveSelection(NavigationDirection.Right, true))
.Callback(() => menu.SetupGet(x => x.SelectedItem).Returns(nextItem))
.Returns(true);
target.KeyDown(item, e);
menu.Verify(x => x.MoveSelection(NavigationDirection.Right, true));
Mock.Get(parentItem).Verify(x => x.Close());
Mock.Get(nextItem).Verify(x => x.Open());
Mock.Get(nextItem).Verify(x => x.MoveSelection(NavigationDirection.First, true));
Assert.True(e.Handled);
}
[Fact]
public void Enter_On_Item_With_No_SubMenu_Causes_Click()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new KeyEventArgs { Key = Key.Enter, Source = item };
target.KeyDown(item, e);
Mock.Get(item).Verify(x => x.RaiseClick());
Mock.Get(menu).Verify(x => x.Close());
Assert.True(e.Handled);
}
[Fact]
public void Enter_On_Item_With_SubMenu_Opens_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
var e = new KeyEventArgs { Key = Key.Enter, Source = item };
target.KeyDown(item, e);
Mock.Get(item).Verify(x => x.Open());
Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true));
Assert.True(e.Handled);
}
[Fact]
public void Escape_Closes_Parent_MenuItem()
{
var target = new DefaultMenuInteractionHandler();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new KeyEventArgs { Key = Key.Escape, Source = item };
target.KeyDown(item, e);
Mock.Get(parentItem).Verify(x => x.Close());
Mock.Get(parentItem).Verify(x => x.Focus());
Assert.True(e.Handled);
}
[Fact]
public void PointerEnter_Selects_Item()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item };
target.PointerEnter(item, e);
Mock.Get(parentItem).VerifySet(x => x.SelectedItem = item);
Assert.False(e.Handled);
}
[Fact]
public void PointerEnter_Opens_Submenu_After_Delay()
{
var timer = new TestTimer();
var target = new DefaultMenuInteractionHandler(null, timer.RunOnce);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item };
target.PointerEnter(item, e);
Mock.Get(item).Verify(x => x.Open(), Times.Never);
timer.Pulse();
Mock.Get(item).Verify(x => x.Open());
Assert.False(e.Handled);
}
[Fact]
public void PointerEnter_Closes_Sibling_Submenu_After_Delay()
{
var timer = new TestTimer();
var target = new DefaultMenuInteractionHandler(null, timer.RunOnce);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var sibling = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true && x.IsSubMenuOpen == true);
var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item };
Mock.Get(parentItem).SetupGet(x => x.SubItems).Returns(new[] { item, sibling });
target.PointerEnter(item, e);
Mock.Get(sibling).Verify(x => x.Close(), Times.Never);
timer.Pulse();
Mock.Get(sibling).Verify(x => x.Close());
Assert.False(e.Handled);
}
[Fact]
public void PointerLeave_Deselects_Item()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
Mock.Get(parentItem).SetupGet(x => x.SelectedItem).Returns(item);
target.PointerLeave(item, e);
Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null);
Assert.False(e.Handled);
}
[Fact]
public void PointerLeave_Doesnt_Deselect_Sibling()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var sibling = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
Mock.Get(parentItem).SetupGet(x => x.SelectedItem).Returns(sibling);
target.PointerLeave(item, e);
Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null, Times.Never);
Assert.False(e.Handled);
}
[Fact]
public void PointerLeave_Doesnt_Deselect_Item_If_Pointer_Over_Submenu()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true && x.IsPointerOverSubMenu == true);
var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
target.PointerLeave(item, e);
Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null, Times.Never);
Assert.False(e.Handled);
}
[Fact]
public void PointerReleased_On_Item_With_No_SubMenu_Causes_Click()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new PointerReleasedEventArgs { MouseButton = MouseButton.Left, Source = item };
target.PointerReleased(item, e);
Mock.Get(item).Verify(x => x.RaiseClick());
Mock.Get(menu).Verify(x => x.Close());
Assert.True(e.Handled);
}
[Fact]
public void Selection_Is_Correct_When_Pointer_Temporarily_Exits_Item_To_Select_SubItem()
{
var timer = new TestTimer();
var target = new DefaultMenuInteractionHandler(null, timer.RunOnce);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
var childItem = Mock.Of<IMenuItem>(x => x.Parent == item);
var enter = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item };
var leave = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
// Pointer enters item; item is selected.
target.PointerEnter(item, enter);
Assert.True(timer.ActionIsQueued);
Mock.Get(parentItem).VerifySet(x => x.SelectedItem = item);
Mock.Get(parentItem).ResetCalls();
// SubMenu shown after a delay.
timer.Pulse();
Mock.Get(item).Verify(x => x.Open());
Mock.Get(item).SetupGet(x => x.IsSubMenuOpen).Returns(true);
Mock.Get(item).ResetCalls();
// Pointer briefly exits item, but submenu remains open.
target.PointerLeave(item, leave);
Mock.Get(item).Verify(x => x.Close(), Times.Never);
Mock.Get(item).ResetCalls();
// Pointer enters child item; is selected.
enter.Source = childItem;
target.PointerEnter(childItem, enter);
Mock.Get(item).VerifySet(x => x.SelectedItem = childItem);
Mock.Get(parentItem).VerifySet(x => x.SelectedItem = item);
Mock.Get(item).ResetCalls();
Mock.Get(parentItem).ResetCalls();
}
[Fact]
public void PointerPressed_On_Item_With_SubMenu_Causes_Opens_Submenu()
{
var target = new DefaultMenuInteractionHandler();
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
var e = new PointerPressedEventArgs { MouseButton = MouseButton.Left, Source = item };
target.PointerPressed(item, e);
Mock.Get(item).Verify(x => x.Open());
Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true), Times.Never);
Assert.True(e.Handled);
}
}
private class TestTimer
{
private Action _action;
public bool ActionIsQueued => _action != null;
public void Pulse()
{
_action();
_action = null;
}
public void RunOnce(Action action, TimeSpan timeSpan)
{
if (_action != null)
{
throw new NotSupportedException("Action already set.");
}
_action = action;
}
}
}
}

144
tests/Avalonia.Controls.UnitTests/Primitives/UniformGridTests.cs

@ -0,0 +1,144 @@
using Avalonia.Controls.Primitives;
using Xunit;
namespace Avalonia.Controls.UnitTests.Primitives
{
public class UniformGridTests
{
[Fact]
public void Grid_Columns_Equals_Rows_For_Auto_Columns_And_Rows()
{
var target = new UniformGrid()
{
Children =
{
new Border { Width = 50, Height = 70 },
new Border { Width = 30, Height = 50 },
new Border { Width = 80, Height = 90 }
}
};
target.Measure(Size.Infinity);
target.Arrange(new Rect(target.DesiredSize));
// 2 * 2 grid
Assert.Equal(new Size(2 * 80, 2 * 90), target.Bounds.Size);
}
[Fact]
public void Grid_Expands_Vertically_For_Columns_With_Auto_Rows()
{
var target = new UniformGrid()
{
Columns = 2,
Children =
{
new Border { Width = 50, Height = 70 },
new Border { Width = 30, Height = 50 },
new Border { Width = 80, Height = 90 },
new Border { Width = 20, Height = 30 },
new Border { Width = 40, Height = 60 }
}
};
target.Measure(Size.Infinity);
target.Arrange(new Rect(target.DesiredSize));
// 2 * 3 grid
Assert.Equal(new Size(2 * 80, 3 * 90), target.Bounds.Size);
}
[Fact]
public void Grid_Extends_For_Columns_And_First_Column_With_Auto_Rows()
{
var target = new UniformGrid()
{
Columns = 3,
FirstColumn = 2,
Children =
{
new Border { Width = 50, Height = 70 },
new Border { Width = 30, Height = 50 },
new Border { Width = 80, Height = 90 },
new Border { Width = 20, Height = 30 },
new Border { Width = 40, Height = 60 }
}
};
target.Measure(Size.Infinity);
target.Arrange(new Rect(target.DesiredSize));
// 3 * 3 grid
Assert.Equal(new Size(3 * 80, 3 * 90), target.Bounds.Size);
}
[Fact]
public void Grid_Expands_Horizontally_For_Rows_With_Auto_Columns()
{
var target = new UniformGrid()
{
Rows = 2,
Children =
{
new Border { Width = 50, Height = 70 },
new Border { Width = 30, Height = 50 },
new Border { Width = 80, Height = 90 },
new Border { Width = 20, Height = 30 },
new Border { Width = 40, Height = 60 }
}
};
target.Measure(Size.Infinity);
target.Arrange(new Rect(target.DesiredSize));
// 3 * 2 grid
Assert.Equal(new Size(3 * 80, 2 * 90), target.Bounds.Size);
}
[Fact]
public void Grid_Size_Is_Limited_By_Rows_And_Columns()
{
var target = new UniformGrid()
{
Columns = 2,
Rows = 2,
Children =
{
new Border { Width = 50, Height = 70 },
new Border { Width = 30, Height = 50 },
new Border { Width = 80, Height = 90 },
new Border { Width = 20, Height = 30 },
new Border { Width = 40, Height = 60 }
}
};
target.Measure(Size.Infinity);
target.Arrange(new Rect(target.DesiredSize));
// 2 * 2 grid
Assert.Equal(new Size(2 * 80, 2 * 90), target.Bounds.Size);
}
[Fact]
public void Not_Visible_Children_Are_Ignored()
{
var target = new UniformGrid()
{
Children =
{
new Border { Width = 50, Height = 70 },
new Border { Width = 30, Height = 50 },
new Border { Width = 80, Height = 90, IsVisible = false },
new Border { Width = 20, Height = 30 },
new Border { Width = 40, Height = 60 }
}
};
target.Measure(Size.Infinity);
target.Arrange(new Rect(target.DesiredSize));
// 2 * 2 grid
Assert.Equal(new Size(2 * 50, 2 * 70), target.Bounds.Size);
}
}
}

38
tests/Avalonia.Controls.UnitTests/RadioButtonTests.cs

@ -32,5 +32,43 @@ namespace Avalonia.Controls.UnitTests
Assert.True(radioButton1.IsChecked); Assert.True(radioButton1.IsChecked);
Assert.Null(radioButton2.IsChecked); Assert.Null(radioButton2.IsChecked);
} }
[Fact]
public void RadioButton_In_Same_Group_Is_Unchecked()
{
var parent = new Panel();
var panel1 = new Panel();
var panel2 = new Panel();
parent.Children.Add(panel1);
parent.Children.Add(panel2);
var radioButton1 = new RadioButton();
radioButton1.GroupName = "A";
radioButton1.IsChecked = false;
var radioButton2 = new RadioButton();
radioButton2.GroupName = "A";
radioButton2.IsChecked = true;
var radioButton3 = new RadioButton();
radioButton3.GroupName = "A";
radioButton3.IsChecked = false;
panel1.Children.Add(radioButton1);
panel1.Children.Add(radioButton2);
panel2.Children.Add(radioButton3);
Assert.False(radioButton1.IsChecked);
Assert.True(radioButton2.IsChecked);
Assert.False(radioButton3.IsChecked);
radioButton3.IsChecked = true;
Assert.False(radioButton1.IsChecked);
Assert.False(radioButton2.IsChecked);
Assert.True(radioButton3.IsChecked);
}
} }
} }

2
tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

@ -219,7 +219,7 @@ namespace Avalonia.Controls.UnitTests
scrollable.Setup(x => x.IsLogicalScrollEnabled).Returns(true); scrollable.Setup(x => x.IsLogicalScrollEnabled).Returns(true);
((ISetLogicalParent)target).SetParent(presenter.Object); ((ISetLogicalParent)target).SetParent(presenter.Object);
((INavigableContainer)target).GetControl(NavigationDirection.Next, from); ((INavigableContainer)target).GetControl(NavigationDirection.Next, from, false);
scrollable.Verify(x => x.GetControlInDirection(NavigationDirection.Next, from)); scrollable.Verify(x => x.GetControlInDirection(NavigationDirection.Next, from));
} }

799
tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs

@ -1,799 +0,0 @@
// Copyright (c) The Avalonia Project. All rights reserved.
// Licensed under the MIT license. See licence.md file in the project root for full license information.
using Avalonia.Controls;
using Xunit;
namespace Avalonia.Input.UnitTests
{
public class KeyboardNavigationTests_Arrows
{
[Fact]
public void Down_Continue_Returns_Down_Control_In_Container()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new Button { Name = "Button1" },
(current = new Button { Name = "Button2" }),
(next = new Button { Name = "Button3" }),
}
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
Assert.Equal(next, result);
}
[Fact]
public void Down_Continue_Returns_First_Control_In_Down_Sibling_Container()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new Button { Name = "Button1" },
new Button { Name = "Button2" },
(current = new Button { Name = "Button3" }),
}
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
(next = new Button { Name = "Button4" }),
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
Assert.Equal(next, result);
}
[Fact]
public void Down_Continue_Returns_Down_Sibling()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new Button { Name = "Button1" },
new Button { Name = "Button2" },
(current = new Button { Name = "Button3" }),
}
},
(next = new Button { Name = "Button4" }),
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
Assert.Equal(next, result);
}
[Fact]
public void Down_Continue_Returns_First_Control_In_Down_Uncle_Container()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new Button { Name = "Button1" },
new Button { Name = "Button2" },
(current = new Button { Name = "Button3" }),
}
},
},
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
(next = new Button { Name = "Button4" }),
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
Assert.Equal(next, result);
}
[Fact]
public void Down_Continue_Returns_Child_Of_Top_Level()
{
Button next;
var top = new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
(next = new Button { Name = "Button1" }),
}
};
var result = KeyboardNavigationHandler.GetNext(top, NavigationDirection.Down);
Assert.Equal(next, result);
}
[Fact]
public void Down_Continue_Wraps()
{
Button current;
Button next;
var top = new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
(next = new Button { Name = "Button1" }),
new Button { Name = "Button2" },
new Button { Name = "Button3" },
}
},
},
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
(current = new Button { Name = "Button6" }),
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
Assert.Equal(next, result);
}
[Fact]
public void Down_Cycle_Returns_Down_Control_In_Container()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new Button { Name = "Button1" },
(current = new Button { Name = "Button2" }),
(next = new Button { Name = "Button3" }),
}
},
new StackPanel
{
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
Assert.Equal(next, result);
}
[Fact]
public void Down_Cycle_Wraps_To_First()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(next = new Button { Name = "Button1" }),
new Button { Name = "Button2" },
(current = new Button { Name = "Button3" }),
}
},
new StackPanel
{
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
Assert.Equal(next, result);
}
[Fact]
public void Down_Contained_Returns_Down_Control_In_Container()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
Children =
{
new Button { Name = "Button1" },
(current = new Button { Name = "Button2" }),
(next = new Button { Name = "Button3" }),
}
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
Assert.Equal(next, result);
}
[Fact]
public void Down_Contained_Stops_At_End()
{
Button current;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
Children =
{
new Button { Name = "Button1" },
new Button { Name = "Button2" },
(current = new Button { Name = "Button3" }),
}
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
Assert.Null(result);
}
[Fact]
public void Down_None_Does_Nothing()
{
Button current;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.None,
Children =
{
new Button { Name = "Button1" },
(current = new Button { Name = "Button2" }),
new Button { Name = "Button3" },
}
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
Assert.Null(result);
}
[Fact]
public void Up_Continue_Returns_Up_Control_In_Container()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new Button { Name = "Button1" },
(next = new Button { Name = "Button2" }),
(current = new Button { Name = "Button3" }),
}
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
Assert.Equal(next, result);
}
[Fact]
public void Up_Continue_Returns_Last_Control_In_Up_Sibling_Container()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new Button { Name = "Button1" },
new Button { Name = "Button2" },
(next = new Button { Name = "Button3" }),
}
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
(current = new Button { Name = "Button4" }),
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
Assert.Equal(next, result);
}
[Fact]
public void Up_Continue_Returns_Last_Child_Of_Sibling()
{
Button current;
Button next;
var top = new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new Button { Name = "Button1" },
new Button { Name = "Button2" },
(next = new Button { Name = "Button3" }),
}
},
(current = new Button { Name = "Button4" }),
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
Assert.Equal(next, result);
}
[Fact]
public void Up_Continue_Returns_Last_Control_In_Up_Nephew_Container()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new Button { Name = "Button1" },
new Button { Name = "Button2" },
(next = new Button { Name = "Button3" }),
}
},
},
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
(current = new Button { Name = "Button4" }),
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
Assert.Equal(next, result);
}
[Fact]
public void Up_Continue_Wraps()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
(current = new Button { Name = "Button1" }),
new Button { Name = "Button2" },
new Button { Name = "Button3" },
}
},
},
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
(next = new Button { Name = "Button6" }),
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
Assert.Equal(next, result);
}
[Fact]
public void Up_Continue_Returns_Parent()
{
Button current;
var top = new Decorator
{
Focusable = true,
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
Child = current = new Button
{
Name = "Button",
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
Assert.Equal(top, result);
}
[Fact]
public void Up_Cycle_Returns_Up_Control_In_Container()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(next = new Button { Name = "Button1" }),
(current = new Button { Name = "Button2" }),
new Button { Name = "Button3" },
}
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
Assert.Equal(next, result);
}
[Fact]
public void Up_Cycle_Wraps_To_Last()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
(current = new Button { Name = "Button1" }),
new Button { Name = "Button2" },
(next = new Button { Name = "Button3" }),
}
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
Assert.Equal(next, result);
}
[Fact]
public void Up_Contained_Returns_Up_Control_In_Container()
{
Button current;
Button next;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
Children =
{
(next = new Button { Name = "Button1" }),
(current = new Button { Name = "Button2" }),
new Button { Name = "Button3" },
}
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
Assert.Equal(next, result);
}
[Fact]
public void Up_Contained_Stops_At_Beginning()
{
Button current;
var top = new StackPanel
{
Children =
{
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
Children =
{
(current = new Button { Name = "Button1" }),
new Button { Name = "Button2" },
new Button { Name = "Button3" },
}
},
new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
Children =
{
new Button { Name = "Button4" },
new Button { Name = "Button5" },
new Button { Name = "Button6" },
}
},
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
Assert.Null(result);
}
[Fact]
public void Up_Contained_Doesnt_Return_Child_Control()
{
Decorator current;
var top = new StackPanel
{
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
Children =
{
(current = new Decorator
{
Focusable = true,
Child = new Button(),
})
}
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
Assert.Null(result);
}
}
}

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

@ -140,37 +140,6 @@ namespace Avalonia.Input.UnitTests
Assert.Same(next, result); Assert.Same(next, result);
} }
[Fact]
public void Right_Should_Custom_Navigate_From_Outside()
{
Button current;
Button next;
var target = new CustomNavigatingStackPanel
{
Children =
{
new Button { Content = "Button 1" },
new Button { Content = "Button 2" },
(next = new Button { Content = "Button 3" }),
},
NextControl = next,
};
var root = new StackPanel
{
Children =
{
(current = new Button { Content = "Outside" }),
target,
},
[KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
};
var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right);
Assert.Same(next, result);
}
[Fact] [Fact]
public void Tab_Should_Navigate_Outside_When_Null_Returned_As_Next() public void Tab_Should_Navigate_Outside_When_Null_Returned_As_Next()
{ {

6
tests/Avalonia.UnitTests/ImmediateDispatcher.cs

@ -25,6 +25,12 @@ namespace Avalonia.UnitTests
return Task.FromResult<object>(null); return Task.FromResult<object>(null);
} }
public Task<TResult> InvokeAsync<TResult>(Func<TResult> function, DispatcherPriority priority = DispatcherPriority.Normal)
{
var result = function();
return Task.FromResult(result);
}
public void VerifyAccess() public void VerifyAccess()
{ {
} }

Loading…
Cancel
Save