diff --git a/RadialActions.Tests/PieSelectionControllerTests.cs b/RadialActions.Tests/PieSelectionControllerTests.cs new file mode 100644 index 0000000..160d44d --- /dev/null +++ b/RadialActions.Tests/PieSelectionControllerTests.cs @@ -0,0 +1,88 @@ +using System.Windows.Input; + +namespace RadialActions.Tests; + +public class PieSelectionControllerTests +{ + private static readonly PieSelectionController.Item[] DirectionalItems = + [ + new(0, -60), + new(1, 20), + new(2, 100), + new(3, 170), + ]; + + [Theory] + [InlineData(Key.Up, 0)] + [InlineData(Key.Right, 1)] + [InlineData(Key.Down, 2)] + [InlineData(Key.Left, 3)] + public void HandleArrowKey_FirstSelectionChoosesClosestSlice(Key key, int expectedIndex) + { + var controller = new PieSelectionController(); + + controller.HandleArrowKey(key, DirectionalItems); + + Assert.Equal(expectedIndex, controller.SelectedIndex); + } + + [Theory] + [InlineData(Key.Left)] + [InlineData(Key.Up)] + public void HandleArrowKey_LeftAndUpWrapBackward(Key key) + { + var controller = new PieSelectionController(); + PieSelectionController.Item[] items = + [ + new(0, -10), + new(1, 100), + new(2, 170), + ]; + + controller.HandleArrowKey(Key.Right, items); + controller.HandleArrowKey(key, items); + + Assert.Equal(2, controller.SelectedIndex); + } + + [Theory] + [InlineData(Key.Right)] + [InlineData(Key.Down)] + public void HandleArrowKey_RightAndDownWrapForward(Key key) + { + var controller = new PieSelectionController(); + PieSelectionController.Item[] items = + [ + new(0, -10), + new(1, 100), + new(2, 170), + ]; + + controller.HandleArrowKey(Key.Left, items); + controller.HandleArrowKey(key, items); + + Assert.Equal(0, controller.SelectedIndex); + } + + [Fact] + public void EnsureSelectionIsValid_ResetsWhenSelectedIndexIsMissing() + { + var controller = new PieSelectionController(); + PieSelectionController.Item[] originalItems = + [ + new(0, -10), + new(1, 100), + new(2, 170), + ]; + PieSelectionController.Item[] updatedItems = + [ + new(0, -10), + new(2, 170), + ]; + + controller.HandleArrowKey(Key.Down, originalItems); + controller.EnsureSelectionIsValid(updatedItems); + + Assert.Equal(PieSelectionController.NoSelection, controller.SelectedIndex); + } +} diff --git a/RadialActions/Pie/PieAnimationService.cs b/RadialActions/Pie/PieAnimationService.cs new file mode 100644 index 0000000..7abc059 --- /dev/null +++ b/RadialActions/Pie/PieAnimationService.cs @@ -0,0 +1,140 @@ +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Animation; + +namespace RadialActions; + +internal sealed class PieAnimationService +{ + public void ApplyBrushColor( + SolidColorBrush brush, + Color color, + bool animate, + Duration duration, + IEasingFunction easingFunction) + { + if (animate) + { + AnimateBrushColor(brush, color, duration, easingFunction); + return; + } + + brush.BeginAnimation(SolidColorBrush.ColorProperty, null); + brush.Color = color; + } + + public void AnimateBrushColor( + SolidColorBrush brush, + Color toColor, + Duration duration, + IEasingFunction easingFunction) + { + if (IsReducedMotionEnabled()) + { + brush.BeginAnimation(SolidColorBrush.ColorProperty, null); + brush.Color = toColor; + return; + } + + var colorAnimation = new ColorAnimation + { + To = toColor, + Duration = duration, + EasingFunction = easingFunction, + }; + + brush.BeginAnimation(SolidColorBrush.ColorProperty, colorAnimation, HandoffBehavior.SnapshotAndReplace); + } + + public void AnimateOpacity( + UIElement element, + double toOpacity, + Duration duration, + IEasingFunction easingFunction, + Action onCompleted = null) + { + if (IsReducedMotionEnabled()) + { + element.BeginAnimation(UIElement.OpacityProperty, null); + element.Opacity = toOpacity; + onCompleted?.Invoke(); + return; + } + + var opacityAnimation = new DoubleAnimation + { + To = toOpacity, + Duration = duration, + EasingFunction = easingFunction, + }; + + if (onCompleted != null) + { + opacityAnimation.Completed += (_, _) => onCompleted(); + } + + element.BeginAnimation(UIElement.OpacityProperty, opacityAnimation, HandoffBehavior.SnapshotAndReplace); + } + + public void AnimateClickDown(UIElement target, Duration duration, IEasingFunction easingFunction) + { + if (target.RenderTransform is not ScaleTransform scaleTransform) + { + scaleTransform = new ScaleTransform(1, 1); + target.RenderTransform = scaleTransform; + target.RenderTransformOrigin = new Point(0.5, 0.5); + } + + if (IsReducedMotionEnabled()) + { + scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, null); + scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, null); + scaleTransform.ScaleX = 0.95; + scaleTransform.ScaleY = 0.95; + return; + } + + var scaleAnimation = new DoubleAnimation + { + To = 0.95, + Duration = duration, + EasingFunction = easingFunction, + FillBehavior = FillBehavior.HoldEnd, + }; + + scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); + scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); + } + + public void AnimateClickUp(UIElement target, Duration duration, IEasingFunction easingFunction) + { + if (target.RenderTransform is not ScaleTransform scaleTransform) + { + return; + } + + if (IsReducedMotionEnabled()) + { + scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, null); + scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, null); + scaleTransform.ScaleX = 1; + scaleTransform.ScaleY = 1; + return; + } + + var scaleAnimation = new DoubleAnimation + { + To = 1, + Duration = duration, + EasingFunction = easingFunction, + }; + + scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); + scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); + } + + public static bool IsReducedMotionEnabled() + { + return !SystemParameters.ClientAreaAnimation; + } +} diff --git a/RadialActions/Pie/PieCenterVisual.cs b/RadialActions/Pie/PieCenterVisual.cs new file mode 100644 index 0000000..5586b36 --- /dev/null +++ b/RadialActions/Pie/PieCenterVisual.cs @@ -0,0 +1,12 @@ +using System.Windows.Controls; +using System.Windows.Media; + +namespace RadialActions; + +internal sealed class PieCenterVisual +{ + public required Grid Target { get; init; } + public required SolidColorBrush FillBrush { get; init; } + public required SolidColorBrush StrokeBrush { get; init; } + public required TextBlock Icon { get; init; } +} diff --git a/RadialActions/Pie/PieControl.xaml.cs b/RadialActions/Pie/PieControl.xaml.cs index a108d64..0690c98 100644 --- a/RadialActions/Pie/PieControl.xaml.cs +++ b/RadialActions/Pie/PieControl.xaml.cs @@ -6,7 +6,6 @@ using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; -using System.Windows.Media.Animation; using System.Windows.Shapes; using System.Windows.Threading; using Microsoft.Win32; @@ -19,7 +18,6 @@ namespace RadialActions; public partial class PieControl : UserControl { private const double DefaultCenterHoleRatio = 0.25; - private const int NoSelectedSliceIndex = -1; private const int SliceZIndex = 10; private const int HoveredSliceZIndex = 14; private const double MouseMoveThreshold = 0.25; @@ -30,32 +28,14 @@ private enum InteractionMode Keyboard, } - private sealed class SliceVisual - { - public required int Index { get; init; } - public required double MidAngle { get; init; } - public required PieAction Action { get; init; } - public required Path Path { get; init; } - public required SolidColorBrush FillBrush { get; init; } - public required SolidColorBrush StrokeBrush { get; init; } - public required ContextMenu ContextMenu { get; init; } - } - - private sealed class CenterVisual - { - public required Grid Target { get; init; } - public required SolidColorBrush FillBrush { get; init; } - public required SolidColorBrush StrokeBrush { get; init; } - public required TextBlock Icon { get; init; } - } - private bool _renderRefreshPending; private bool _renderRefreshQueued; - private readonly List _sliceVisuals = []; - private CenterVisual _centerVisual; + private readonly List _sliceVisuals = []; + private PieCenterVisual _centerVisual; + private readonly PieAnimationService _animationService = new(); + private readonly PieSelectionController _selectionController = new(); private readonly PieRenderState _renderState = new(); private InteractionMode _interactionMode = InteractionMode.Mouse; - private int _selectedSliceIndex = NoSelectedSliceIndex; private Point _keyboardModeMousePosition; private bool _hasKeyboardModeMousePosition; @@ -156,31 +136,12 @@ private void OnPieCanvasMouseMove(object sender, MouseEventArgs e) private void HandleArrowKey(Key key) { - if (_sliceVisuals.Count == 0) + _selectionController.HandleArrowKey(key, GetSelectionItems()); + if (_selectionController.SelectedIndex == PieSelectionController.NoSelection) { return; } - if (_selectedSliceIndex == NoSelectedSliceIndex) - { - _selectedSliceIndex = key switch - { - Key.Up => GetSliceIndexClosestToAngle(-90), - Key.Right => GetSliceIndexClosestToAngle(0), - Key.Down => GetSliceIndexClosestToAngle(90), - Key.Left => GetSliceIndexClosestToAngle(180), - _ => NoSelectedSliceIndex - }; - } - else if (key is Key.Right or Key.Down) - { - _selectedSliceIndex = (_selectedSliceIndex + 1) % _sliceVisuals.Count; - } - else if (key is Key.Left or Key.Up) - { - _selectedSliceIndex = (_selectedSliceIndex - 1 + _sliceVisuals.Count) % _sliceVisuals.Count; - } - _interactionMode = InteractionMode.Keyboard; _keyboardModeMousePosition = Mouse.GetPosition(PieCanvas); _hasKeyboardModeMousePosition = true; @@ -210,37 +171,9 @@ private bool OpenSelectedSliceContextMenu() return true; } - private SliceVisual GetSelectedSliceVisual() - { - if (_selectedSliceIndex == NoSelectedSliceIndex) - { - return null; - } - - return _sliceVisuals.FirstOrDefault(slice => slice.Index == _selectedSliceIndex); - } - - private int GetSliceIndexClosestToAngle(double targetAngle) + private PieSliceVisual GetSelectedSliceVisual() { - if (_sliceVisuals.Count == 0) - { - return 0; - } - - var bestIndex = 0; - var bestDistance = double.MaxValue; - - foreach (var sliceVisual in _sliceVisuals) - { - var distance = Math.Abs(PieLayoutCalculator.NormalizeSignedAngle(sliceVisual.MidAngle - targetAngle)); - if (distance < bestDistance) - { - bestDistance = distance; - bestIndex = sliceVisual.Index; - } - } - - return bestIndex; + return _sliceVisuals.FirstOrDefault(slice => slice.Index == _selectionController.SelectedIndex); } public static readonly DependencyProperty SlicesProperty = @@ -332,7 +265,7 @@ private void CreatePieMenu() Slices?.Count ?? 0, ActualWidth, ActualHeight); - _selectedSliceIndex = NoSelectedSliceIndex; + _selectionController.Reset(); return; } @@ -393,7 +326,7 @@ private void CreatePieMenu() theme.HubContainerStyle, theme.IconTextStyle); - var centerVisual = new CenterVisual + var centerVisual = new PieCenterVisual { Target = centerElements.Target, FillBrush = centerElements.FillBrush, @@ -427,14 +360,14 @@ private void CreatePieMenu() } isCenterMouseDown = false; - AnimateSliceClickUp(centerElements.Target, pressDuration, _renderState.StandardEasing); + _animationService.AnimateClickUp(centerElements.Target, pressDuration, _renderState.StandardEasing); }; centerElements.Target.MouseLeftButtonDown += (_, e) => { EnterMouseInteractionMode(refreshVisualState: false, animate: false); isCenterMouseDown = true; - AnimateSliceClickDown(centerElements.Target, pressDuration, _renderState.StandardEasing); + _animationService.AnimateClickDown(centerElements.Target, pressDuration, _renderState.StandardEasing); e.Handled = true; }; @@ -446,7 +379,7 @@ private void CreatePieMenu() } isCenterMouseDown = false; - AnimateSliceClickUp(centerElements.Target, pressDuration, _renderState.StandardEasing); + _animationService.AnimateClickUp(centerElements.Target, pressDuration, _renderState.StandardEasing); if (_interactionMode == InteractionMode.Mouse) { if (centerElements.Target.IsMouseOver) @@ -506,7 +439,7 @@ private void CreatePieMenu() var contextMenu = CreateSliceContextMenu(sliceAction); slice.ContextMenu = contextMenu; - var sliceVisual = new SliceVisual + var sliceVisual = new PieSliceVisual { Index = i, MidAngle = (startAngle + endAngle) / 2, @@ -524,9 +457,9 @@ private void CreatePieMenu() { EnterMouseInteractionMode(refreshVisualState: false, animate: false); isMouseDown = true; - AnimateSliceColor(fillBrush, theme.PressedColor, pressDuration, _renderState.StandardEasing); - AnimateSliceColor(strokeBrush, _renderState.BorderHoverColor, pressDuration, _renderState.StandardEasing); - AnimateSliceClickDown(slice, pressDuration, _renderState.StandardEasing); + _animationService.AnimateBrushColor(fillBrush, theme.PressedColor, pressDuration, _renderState.StandardEasing); + _animationService.AnimateBrushColor(strokeBrush, _renderState.BorderHoverColor, pressDuration, _renderState.StandardEasing); + _animationService.AnimateClickDown(slice, pressDuration, _renderState.StandardEasing); e.Handled = true; }; @@ -538,7 +471,7 @@ private void CreatePieMenu() } isMouseDown = false; - AnimateSliceClickUp(slice, pressDuration, _renderState.StandardEasing); + _animationService.AnimateClickUp(slice, pressDuration, _renderState.StandardEasing); if (_interactionMode == InteractionMode.Mouse) { if (slice.IsMouseOver) @@ -582,7 +515,7 @@ private void CreatePieMenu() } isMouseDown = false; - AnimateSliceClickUp(slice, pressDuration, _renderState.StandardEasing); + _animationService.AnimateClickUp(slice, pressDuration, _renderState.StandardEasing); }; Panel.SetZIndex(slice, SliceZIndex); PieCanvas.Children.Add(slice); @@ -615,11 +548,7 @@ private void CreatePieMenu() PieCanvas.Children.Add(contentPanel); } - if (_selectedSliceIndex != NoSelectedSliceIndex - && _sliceVisuals.All(sliceVisual => sliceVisual.Index != _selectedSliceIndex)) - { - _selectedSliceIndex = NoSelectedSliceIndex; - } + _selectionController.EnsureSelectionIsValid(GetSelectionItems()); Log.Debug( "Pie menu rendered with {SliceCount} slices at {CanvasSize}px", @@ -631,7 +560,7 @@ private void CreatePieMenu() private void EnterMouseInteractionMode(bool refreshVisualState, bool animate) { _interactionMode = InteractionMode.Mouse; - _selectedSliceIndex = NoSelectedSliceIndex; + _selectionController.Reset(); _hasKeyboardModeMousePosition = false; if (refreshVisualState) @@ -654,7 +583,7 @@ private void RefreshVisualState(bool animate) foreach (var sliceVisual in _sliceVisuals) { var isSelectedOrHovered = _interactionMode == InteractionMode.Keyboard - ? _selectedSliceIndex == sliceVisual.Index + ? _selectionController.SelectedIndex == sliceVisual.Index : sliceVisual.Path.IsMouseOver; if (isSelectedOrHovered) @@ -683,18 +612,18 @@ private void RefreshVisualState(bool animate) } } - private void ApplySliceNormalVisual(SliceVisual sliceVisual, bool animate) + private void ApplySliceNormalVisual(PieSliceVisual sliceVisual, bool animate) { Panel.SetZIndex(sliceVisual.Path, SliceZIndex); - ApplyBrushColor(sliceVisual.FillBrush, _renderState.SliceColor, animate); - ApplyBrushColor(sliceVisual.StrokeBrush, _renderState.BorderColor, animate); + _animationService.ApplyBrushColor(sliceVisual.FillBrush, _renderState.SliceColor, animate, _renderState.HoverDuration, _renderState.StandardEasing); + _animationService.ApplyBrushColor(sliceVisual.StrokeBrush, _renderState.BorderColor, animate, _renderState.HoverDuration, _renderState.StandardEasing); } - private void ApplySliceHoverVisual(SliceVisual sliceVisual, bool animate) + private void ApplySliceHoverVisual(PieSliceVisual sliceVisual, bool animate) { Panel.SetZIndex(sliceVisual.Path, HoveredSliceZIndex); - ApplyBrushColor(sliceVisual.FillBrush, _renderState.HoverColor, animate); - ApplyBrushColor(sliceVisual.StrokeBrush, _renderState.BorderHoverColor, animate); + _animationService.ApplyBrushColor(sliceVisual.FillBrush, _renderState.HoverColor, animate, _renderState.HoverDuration, _renderState.StandardEasing); + _animationService.ApplyBrushColor(sliceVisual.StrokeBrush, _renderState.BorderHoverColor, animate, _renderState.HoverDuration, _renderState.StandardEasing); } private void ApplyCenterNormalVisual(bool animate) @@ -710,7 +639,7 @@ private void ApplyCenterNormalVisual(bool animate) if (animate) { - AnimateOpacity(centerVisual.Icon, 0, _renderState.HoverDuration, _renderState.StandardEasing, () => + _animationService.AnimateOpacity(centerVisual.Icon, 0, _renderState.HoverDuration, _renderState.StandardEasing, () => { if (!centerVisual.Target.IsMouseOver || _interactionMode == InteractionMode.Keyboard) { @@ -740,7 +669,7 @@ private void ApplyCenterHoverVisual(bool animate) if (animate) { - AnimateOpacity(centerVisual.Icon, 1, _renderState.HoverDuration, _renderState.StandardEasing); + _animationService.AnimateOpacity(centerVisual.Icon, 1, _renderState.HoverDuration, _renderState.StandardEasing); } else { @@ -751,14 +680,7 @@ private void ApplyCenterHoverVisual(bool animate) private void ApplyBrushColor(SolidColorBrush brush, Color color, bool animate) { - if (animate) - { - AnimateSliceColor(brush, color, _renderState.HoverDuration, _renderState.StandardEasing); - return; - } - - brush.BeginAnimation(SolidColorBrush.ColorProperty, null); - brush.Color = color; + _animationService.ApplyBrushColor(brush, color, animate, _renderState.HoverDuration, _renderState.StandardEasing); } private void OnSystemParametersChanged(object sender, PropertyChangedEventArgs e) @@ -804,119 +726,9 @@ private void RequestRenderRefresh() }, DispatcherPriority.Background); } - private void AnimateSliceColor( - SolidColorBrush brush, - Color toColor, - Duration duration, - IEasingFunction easingFunction) - { - if (IsReducedMotionEnabled()) - { - brush.BeginAnimation(SolidColorBrush.ColorProperty, null); - brush.Color = toColor; - return; - } - - var colorAnimation = new ColorAnimation - { - To = toColor, - Duration = duration, - EasingFunction = easingFunction, - }; - - brush.BeginAnimation(SolidColorBrush.ColorProperty, colorAnimation, HandoffBehavior.SnapshotAndReplace); - } - - private void AnimateOpacity( - UIElement element, - double toOpacity, - Duration duration, - IEasingFunction easingFunction, - Action onCompleted = null) - { - if (IsReducedMotionEnabled()) - { - element.BeginAnimation(UIElement.OpacityProperty, null); - element.Opacity = toOpacity; - onCompleted?.Invoke(); - return; - } - - var opacityAnimation = new DoubleAnimation - { - To = toOpacity, - Duration = duration, - EasingFunction = easingFunction, - }; - - if (onCompleted != null) - { - opacityAnimation.Completed += (_, _) => onCompleted(); - } - - element.BeginAnimation(UIElement.OpacityProperty, opacityAnimation, HandoffBehavior.SnapshotAndReplace); - } - - private void AnimateSliceClickDown(UIElement target, Duration duration, IEasingFunction easingFunction) - { - if (target.RenderTransform is not ScaleTransform scaleTransform) - { - scaleTransform = new ScaleTransform(1, 1); - target.RenderTransform = scaleTransform; - target.RenderTransformOrigin = new Point(0.5, 0.5); - } - - if (IsReducedMotionEnabled()) - { - scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, null); - scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, null); - scaleTransform.ScaleX = 0.95; - scaleTransform.ScaleY = 0.95; - return; - } - - var scaleAnimation = new DoubleAnimation - { - To = 0.95, - Duration = duration, - EasingFunction = easingFunction, - FillBehavior = FillBehavior.HoldEnd, - }; - - scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); - scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); - } - - private void AnimateSliceClickUp(UIElement target, Duration duration, IEasingFunction easingFunction) - { - if (target.RenderTransform is not ScaleTransform scaleTransform) - { - return; - } - - if (IsReducedMotionEnabled()) - { - scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, null); - scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, null); - scaleTransform.ScaleX = 1; - scaleTransform.ScaleY = 1; - return; - } - - var scaleAnimation = new DoubleAnimation - { - To = 1, - Duration = duration, - EasingFunction = easingFunction, - }; - - scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); - scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); - } - - private static bool IsReducedMotionEnabled() + private PieSelectionController.Item[] GetSelectionItems() { - return !SystemParameters.ClientAreaAnimation; + return _sliceVisuals.Select(static slice => slice.ToSelectionItem()).ToArray(); } private Point SnapPoint(Point point) diff --git a/RadialActions/Pie/PieSelectionController.cs b/RadialActions/Pie/PieSelectionController.cs new file mode 100644 index 0000000..709af8f --- /dev/null +++ b/RadialActions/Pie/PieSelectionController.cs @@ -0,0 +1,98 @@ +using System.Windows.Input; + +namespace RadialActions; + +internal sealed class PieSelectionController +{ + internal readonly record struct Item(int Index, double MidAngle); + + public const int NoSelection = -1; + + public int SelectedIndex { get; private set; } = NoSelection; + + public void Reset() + { + SelectedIndex = NoSelection; + } + + public void EnsureSelectionIsValid(IReadOnlyList items) + { + if (SelectedIndex == NoSelection) + { + return; + } + + if (items.All(item => item.Index != SelectedIndex)) + { + SelectedIndex = NoSelection; + } + } + + public void HandleArrowKey(Key key, IReadOnlyList items) + { + if (items.Count == 0) + { + return; + } + + if (SelectedIndex == NoSelection) + { + SelectedIndex = key switch + { + Key.Up => GetIndexClosestToAngle(items, -90), + Key.Right => GetIndexClosestToAngle(items, 0), + Key.Down => GetIndexClosestToAngle(items, 90), + Key.Left => GetIndexClosestToAngle(items, 180), + _ => NoSelection, + }; + return; + } + + var selectedPosition = GetSelectedPosition(items); + if (selectedPosition < 0) + { + SelectedIndex = NoSelection; + return; + } + + if (key is Key.Right or Key.Down) + { + SelectedIndex = items[(selectedPosition + 1) % items.Count].Index; + } + else if (key is Key.Left or Key.Up) + { + SelectedIndex = items[(selectedPosition - 1 + items.Count) % items.Count].Index; + } + } + + private int GetSelectedPosition(IReadOnlyList items) + { + for (var i = 0; i < items.Count; i++) + { + if (items[i].Index == SelectedIndex) + { + return i; + } + } + + return -1; + } + + private static int GetIndexClosestToAngle(IReadOnlyList items, double targetAngle) + { + var bestIndex = items[0].Index; + var bestDistance = double.MaxValue; + + foreach (var item in items) + { + var distance = Math.Abs(PieLayoutCalculator.NormalizeSignedAngle(item.MidAngle - targetAngle)); + if (distance < bestDistance) + { + bestDistance = distance; + bestIndex = item.Index; + } + } + + return bestIndex; + } +} diff --git a/RadialActions/Pie/PieSliceVisual.cs b/RadialActions/Pie/PieSliceVisual.cs new file mode 100644 index 0000000..346a481 --- /dev/null +++ b/RadialActions/Pie/PieSliceVisual.cs @@ -0,0 +1,21 @@ +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Shapes; + +namespace RadialActions; + +internal sealed class PieSliceVisual +{ + public required int Index { get; init; } + public required double MidAngle { get; init; } + public required PieAction Action { get; init; } + public required Path Path { get; init; } + public required SolidColorBrush FillBrush { get; init; } + public required SolidColorBrush StrokeBrush { get; init; } + public required ContextMenu ContextMenu { get; init; } + + public PieSelectionController.Item ToSelectionItem() + { + return new PieSelectionController.Item(Index, MidAngle); + } +}