From f16ca692fcbbbd32554ee27916f4a6c4aa9e8ca8 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 20 Jul 2026 12:26:49 -0500 Subject: [PATCH 1/3] Redesign the Actions editor as a live pie menu The Actions tab now edits the menu on the pie itself: every action renders as a slice (hidden ones dimmed), clicking a slice selects it in the inspector, dragging reorders using the same drag logic as the live menu, and a dashed ghost "+" slice appends a new action. The inspector pane gains Test, Duplicate, and Remove buttons and an icon+name header, replacing the old list with Add/Remove/Up/Down buttons. PieControl gains an IsEditMode dependency property (all-slices rendering, ghost slice, accent selection highlight via a new SelectedSlice property, inert hub, no digit hints) and now detaches its collection handlers on unload so short-lived hosts like the settings window are not kept alive by the app-lifetime actions collection. Drag reorder wraps over the full slot ring including the ghost slot, and an empty editor renders the ghost as a clickable full ring. PieAction gains Clone() for duplication. Fixes #16 --- .../Actions/ActionsSettingsViewModelTests.cs | 80 ++- .../Pie/PieReorderCalculatorTests.cs | 16 +- RadialActions/Actions/Action.cs | 16 + RadialActions/Pie/PieControl.xaml.cs | 472 ++++++++++++++---- RadialActions/Pie/PieRenderState.cs | 6 + RadialActions/Pie/PieThemeSnapshot.cs | 6 + RadialActions/Settings/ActionEditorView.xaml | 53 +- .../Settings/ActionsSettingsView.xaml | 132 ++--- .../Settings/ActionsSettingsView.xaml.cs | 9 +- .../Settings/ActionsSettingsViewModel.cs | 70 ++- 10 files changed, 582 insertions(+), 278 deletions(-) diff --git a/RadialActions.Tests/Actions/ActionsSettingsViewModelTests.cs b/RadialActions.Tests/Actions/ActionsSettingsViewModelTests.cs index 8307ac3..23d2559 100644 --- a/RadialActions.Tests/Actions/ActionsSettingsViewModelTests.cs +++ b/RadialActions.Tests/Actions/ActionsSettingsViewModelTests.cs @@ -18,7 +18,6 @@ public void SettingsWindowViewModel_SelectAction_SelectsActionTabAndForwardsSele Assert.Equal(1, settings.SettingsTabIndex); Assert.Same(second, viewModel.Actions.SelectedAction); - Assert.Equal(1, viewModel.Actions.SelectedActionIndex); } [Fact] @@ -29,7 +28,6 @@ public void Constructor_SelectsFirstAction() var viewModel = CreateViewModel(first, second); Assert.Same(first, viewModel.SelectedAction); - Assert.Equal(0, viewModel.SelectedActionIndex); Assert.Same(first, viewModel.Editor.SelectedAction); } @@ -43,30 +41,55 @@ public void SelectAction_SelectsMatchingAction() viewModel.SelectAction(second); Assert.Same(second, viewModel.SelectedAction); - Assert.Equal(1, viewModel.SelectedActionIndex); Assert.Same(second, viewModel.Editor.SelectedAction); } [Fact] - public void AddAction_InsertsBlankActionAfterSelectedActionAndSelectsIt() + public void AddAction_AppendsNewActionAtEndAndSelectsIt() { var first = PieAction.CreateKeyAction("Mute"); var second = PieAction.CreateKeyAction("VolumeUp"); - var third = PieAction.CreateKeyAction("VolumeDown"); - var viewModel = CreateViewModel(first, second, third); - viewModel.SelectAction(second); + var viewModel = CreateViewModel(first, second); + viewModel.SelectAction(first); viewModel.AddActionCommand.Execute(null); var added = viewModel.Actions[2]; - Assert.Equal([first, second, added, third], viewModel.Actions); - Assert.Equal("Blank action", added.Name); + Assert.Equal([first, second, added], viewModel.Actions); + Assert.Equal(PieAction.DefaultName, added.Name); Assert.Equal(ActionType.None, added.Type); Assert.Same(added, viewModel.SelectedAction); - Assert.Equal(2, viewModel.SelectedActionIndex); Assert.Same(added, viewModel.Editor.SelectedAction); } + [Fact] + public void DuplicateAction_InsertsCopyAfterSourceAndSelectsIt() + { + var first = PieAction.CreateScriptAction("Backup", "Get-Date", icon: "🧹", interpreter: "pwsh.exe", workingDirectory: @"C:\", runHidden: true); + first.IsEnabled = false; + first.Arguments = "/select"; + var second = PieAction.CreateKeyAction("Mute"); + var viewModel = CreateViewModel(first, second); + viewModel.SelectAction(first); + + viewModel.DuplicateActionCommand.Execute(null); + + var copy = viewModel.Actions[1]; + Assert.Equal([first, copy, second], viewModel.Actions); + Assert.NotSame(first, copy); + Assert.Equal(first.Name, copy.Name); + Assert.Equal(first.Icon, copy.Icon); + Assert.Equal(first.Type, copy.Type); + Assert.Equal(first.IsEnabled, copy.IsEnabled); + Assert.Equal(first.Parameter, copy.Parameter); + Assert.Equal(first.Arguments, copy.Arguments); + Assert.Equal(first.WorkingDirectory, copy.WorkingDirectory); + Assert.Equal(first.Script, copy.Script); + Assert.Equal(first.RunHidden, copy.RunHidden); + Assert.Same(copy, viewModel.SelectedAction); + Assert.Same(copy, viewModel.Editor.SelectedAction); + } + [Fact] public void AddDroppedTargets_InsertsAfterSelectionAndSelectsLast() { @@ -85,7 +108,6 @@ public void AddDroppedTargets_InsertsAfterSelectionAndSelectsLast() Assert.Equal("https://a.com", addedA.Parameter); Assert.Equal("https://b.com", addedB.Parameter); Assert.Same(addedB, viewModel.SelectedAction); - Assert.Equal(3, viewModel.SelectedActionIndex); Assert.Same(addedB, viewModel.Editor.SelectedAction); } @@ -99,7 +121,6 @@ public void AddDroppedTargets_WithoutSelection_AppendsAtEnd() var added = Assert.Single(viewModel.Actions); Assert.Equal("https://a.com", added.Parameter); Assert.Same(added, viewModel.SelectedAction); - Assert.Equal(0, viewModel.SelectedActionIndex); } [Fact] @@ -113,7 +134,6 @@ public void AddDroppedTargets_EmptyOrNull_DoesNothing() Assert.Equal([first], viewModel.Actions); Assert.Same(first, viewModel.SelectedAction); - Assert.Equal(0, viewModel.SelectedActionIndex); } [Fact] @@ -170,7 +190,6 @@ public void RemoveAction_SelectsNextAvailableAction() Assert.Equal([first, third], viewModel.Actions); Assert.Same(third, viewModel.SelectedAction); - Assert.Equal(1, viewModel.SelectedActionIndex); Assert.Same(third, viewModel.Editor.SelectedAction); } @@ -184,42 +203,9 @@ public void RemoveAction_ClearsSelectionWhenListBecomesEmpty() Assert.Empty(viewModel.Actions); Assert.Null(viewModel.SelectedAction); - Assert.Equal(-1, viewModel.SelectedActionIndex); Assert.Null(viewModel.Editor.SelectedAction); } - [Fact] - public void MoveUp_MovesSelectedActionAndKeepsSelection() - { - var first = PieAction.CreateKeyAction("Mute"); - var second = PieAction.CreateKeyAction("VolumeUp"); - var third = PieAction.CreateKeyAction("VolumeDown"); - var viewModel = CreateViewModel(first, second, third); - viewModel.SelectAction(second); - - viewModel.MoveUpCommand.Execute(null); - - Assert.Equal([second, first, third], viewModel.Actions); - Assert.Same(second, viewModel.SelectedAction); - Assert.Equal(0, viewModel.SelectedActionIndex); - } - - [Fact] - public void MoveDown_MovesSelectedActionAndKeepsSelection() - { - var first = PieAction.CreateKeyAction("Mute"); - var second = PieAction.CreateKeyAction("VolumeUp"); - var third = PieAction.CreateKeyAction("VolumeDown"); - var viewModel = CreateViewModel(first, second, third); - viewModel.SelectAction(second); - - viewModel.MoveDownCommand.Execute(null); - - Assert.Equal([first, third, second], viewModel.Actions); - Assert.Same(second, viewModel.SelectedAction); - Assert.Equal(2, viewModel.SelectedActionIndex); - } - private static ActionsSettingsViewModel CreateViewModel(params PieAction[] actions) { return new ActionsSettingsViewModel(CreateSettings(actions)); diff --git a/RadialActions.Tests/Pie/PieReorderCalculatorTests.cs b/RadialActions.Tests/Pie/PieReorderCalculatorTests.cs index f371dc2..baa652d 100644 --- a/RadialActions.Tests/Pie/PieReorderCalculatorTests.cs +++ b/RadialActions.Tests/Pie/PieReorderCalculatorTests.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Windows; namespace RadialActions.Tests; @@ -37,6 +37,20 @@ public void GetTargetSlot_MapsRotationToSlot(int originalIndex, double rotationO Assert.Equal(expectedSlot, slot); } + [Theory] + [InlineData(0, 360, 0)] // a full lap over the six-slot editor ring (five slices plus the ghost) lands home + [InlineData(4, 60, 5)] // the last slice dragged one slot clockwise lands in the ghost slot, which the control clamps to the last real slot + [InlineData(0, -60, 5)] // the first slice dragged counterclockwise wraps into the ghost slot the same way + public void GetTargetSlot_WithEditModeGhostSlot_WrapsOverTheFullSlotRing(int originalIndex, double rotationOffset, int expectedSlot) + { + const double angleStep = 60; + const int slotCount = 6; + + var slot = PieReorderCalculator.GetTargetSlot(originalIndex, rotationOffset, angleStep, slotCount); + + Assert.Equal(expectedSlot, slot); + } + [Theory] [InlineData(0, 288, -72)] // short way is backward [InlineData(300, 0, 360)] // short way keeps going forward diff --git a/RadialActions/Actions/Action.cs b/RadialActions/Actions/Action.cs index 18186f0..0b6bb95 100644 --- a/RadialActions/Actions/Action.cs +++ b/RadialActions/Actions/Action.cs @@ -200,6 +200,22 @@ public static PieAction CreateScriptAction(string name, string script, string ic RunHidden = runHidden }; + /// + /// Creates a copy of this action with the same configuration. + /// + public PieAction Clone() => new() + { + Name = Name, + Icon = Icon, + Type = Type, + IsEnabled = IsEnabled, + Parameter = Parameter, + Arguments = Arguments, + WorkingDirectory = WorkingDirectory, + Script = Script, + RunHidden = RunHidden, + }; + /// /// Executes the action. /// diff --git a/RadialActions/Pie/PieControl.xaml.cs b/RadialActions/Pie/PieControl.xaml.cs index c893a8f..02aa8ba 100644 --- a/RadialActions/Pie/PieControl.xaml.cs +++ b/RadialActions/Pie/PieControl.xaml.cs @@ -19,6 +19,7 @@ public partial class PieControl : UserControl { private const double DefaultCenterHoleRatio = 0.25; private const int SliceZIndex = 10; + private const int SelectedSliceZIndex = 12; private const int HoveredSliceZIndex = 14; private const int SliceContentZIndex = 15; private const int DraggedSliceZIndex = 16; @@ -63,6 +64,8 @@ private enum InteractionMode private bool _hasKeyboardModeMousePosition; private Point _layoutCenter; private double _layoutAngleStep; + private int _layoutSlotCount; + private bool _slicesHandlersAttached; private PieSliceVisual _dragCandidate; private Point _dragPressPosition; private DragReorderState _drag; @@ -95,6 +98,7 @@ private void OnLoaded(object sender, RoutedEventArgs e) { SystemParameters.StaticPropertyChanged += OnSystemParametersChanged; SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged; + AttachSlicesHandlers(); RequestRenderRefresh(); } @@ -102,6 +106,9 @@ private void OnUnloaded(object sender, RoutedEventArgs e) { SystemParameters.StaticPropertyChanged -= OnSystemParametersChanged; SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; + + // Detach so a closed or hidden host (like the settings window) doesn't stay alive through handlers on the app-lifetime actions collection. + DetachSlicesHandlers(Slices); } private void OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) @@ -238,7 +245,7 @@ private void ActivateSelectedSlice() private bool OpenSelectedSliceContextMenu() { var selectedSlice = GetSelectedSliceVisual(); - if (selectedSlice == null) + if (selectedSlice?.ContextMenu == null) { return false; } @@ -267,6 +274,54 @@ public ObservableCollection Slices set => SetValue(SlicesProperty, value); } + public static readonly DependencyProperty IsEditModeProperty = + DependencyProperty.Register( + nameof(IsEditMode), + typeof(bool), + typeof(PieControl), + new PropertyMetadata(false, OnIsEditModePropertyChanged)); + + /// + /// When true the pie renders as a live editor surface: every action is shown (disabled ones dimmed), a ghost slice at the end adds new actions, clicking selects instead of executing, and the selected slice is highlighted with the accent color. + /// + public bool IsEditMode + { + get => (bool)GetValue(IsEditModeProperty); + set => SetValue(IsEditModeProperty, value); + } + + private static void OnIsEditModePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PieControl control) + { + control.RequestRenderRefresh(); + } + } + + public static readonly DependencyProperty SelectedSliceProperty = + DependencyProperty.Register( + nameof(SelectedSlice), + typeof(PieAction), + typeof(PieControl), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedSlicePropertyChanged)); + + /// + /// The action highlighted as selected while is on. + /// + public PieAction SelectedSlice + { + get => (PieAction)GetValue(SelectedSliceProperty); + set => SetValue(SelectedSliceProperty, value); + } + + private static void OnSelectedSlicePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PieControl control) + { + control.RefreshVisualState(animate: true); + } + } + private static void OnSlicesPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is not PieControl control) @@ -274,25 +329,47 @@ private static void OnSlicesPropertyChanged(DependencyObject d, DependencyProper return; } - if (e.OldValue is ObservableCollection oldCollection) + control.DetachSlicesHandlers(e.OldValue as ObservableCollection); + + // Wait for Loaded when the binding resolves before the control enters the tree, so unloaded controls never hold handlers on a long-lived collection. + if (control.IsLoaded) { - oldCollection.CollectionChanged -= control.OnSlicesCollectionChanged; - foreach (var item in oldCollection) - { - item.PropertyChanged -= control.OnSlicePropertyChanged; - } + control.AttachSlicesHandlers(); } - if (e.NewValue is ObservableCollection newCollection) + control.RequestRenderRefresh(); + } + + private void AttachSlicesHandlers() + { + if (_slicesHandlersAttached || Slices is not ObservableCollection collection) { - newCollection.CollectionChanged += control.OnSlicesCollectionChanged; - foreach (var item in newCollection) - { - item.PropertyChanged += control.OnSlicePropertyChanged; - } + return; } - control.RequestRenderRefresh(); + collection.CollectionChanged += OnSlicesCollectionChanged; + foreach (var item in collection) + { + item.PropertyChanged += OnSlicePropertyChanged; + } + + _slicesHandlersAttached = true; + } + + private void DetachSlicesHandlers(ObservableCollection collection) + { + if (!_slicesHandlersAttached || collection == null) + { + return; + } + + collection.CollectionChanged -= OnSlicesCollectionChanged; + foreach (var item in collection) + { + item.PropertyChanged -= OnSlicePropertyChanged; + } + + _slicesHandlersAttached = false; } private void OnSlicesCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) @@ -332,15 +409,19 @@ private void CreatePieMenu() _drag = null; _dragCandidate = null; - var enabledSlices = Slices? - .Where(slice => slice?.IsEnabled == true) + // Edit mode shows every action so hidden ones can still be selected and edited; the live menu only shows enabled ones. + var visibleSlices = Slices? + .Where(slice => slice != null && (IsEditMode || slice.IsEnabled)) .ToList(); - if (enabledSlices == null || enabledSlices.Count == 0 || ActualWidth <= 0 || ActualHeight <= 0) + // The ghost add slice takes up one extra slot in edit mode, so an empty editor still renders a full-circle ghost. + var slotCount = (visibleSlices?.Count ?? 0) + (IsEditMode ? 1 : 0); + + if (visibleSlices == null || slotCount == 0 || ActualWidth <= 0 || ActualHeight <= 0) { Log.Debug( - "Skipping pie render (EnabledSlices={EnabledSliceCount}, TotalSlices={TotalSliceCount}, Width={Width}, Height={Height})", - enabledSlices?.Count ?? 0, + "Skipping pie render (VisibleSlices={VisibleSliceCount}, TotalSlices={TotalSliceCount}, Width={Width}, Height={Height})", + visibleSlices?.Count ?? 0, Slices?.Count ?? 0, ActualWidth, ActualHeight); @@ -356,15 +437,15 @@ private void CreatePieMenu() if (!PieLayoutCalculator.TryCreateLayout( ActualWidth, ActualHeight, - enabledSlices.Count, + slotCount, DefaultCenterHoleRatio, theme.SliceStrokeThickness, SnapToDevicePixel, out var layout)) { Log.Warning( - "Failed to create pie layout (EnabledSlices={EnabledSliceCount}, TotalSlices={TotalSliceCount}, Width={Width}, Height={Height})", - enabledSlices.Count, + "Failed to create pie layout (VisibleSlices={VisibleSliceCount}, TotalSlices={TotalSliceCount}, Width={Width}, Height={Height})", + visibleSlices.Count, Slices?.Count ?? 0, ActualWidth, ActualHeight); @@ -394,6 +475,7 @@ private void CreatePieMenu() var angleStep = layout.AngleStep; _layoutCenter = center; _layoutAngleStep = angleStep; + _layoutSlotCount = slotCount; if (innerRadius > 0) { @@ -416,73 +498,22 @@ private void CreatePieMenu() }; _centerVisual = centerVisual; - var isCenterMouseDown = false; - - centerElements.Target.MouseEnter += (_, _) => - { - if (_interactionMode != InteractionMode.Mouse) - { - return; - } - - ApplyCenterHoverVisual(animate: true); - }; - - centerElements.Target.MouseLeave += (_, _) => - { - if (_interactionMode == InteractionMode.Mouse) - { - ApplyCenterNormalVisual(animate: true); - } - - if (!isCenterMouseDown) - { - return; - } - - isCenterMouseDown = false; - _animationService.AnimateClickUp(centerElements.Target, pressDuration, _renderState.StandardEasing); - }; - - centerElements.Target.MouseLeftButtonDown += (_, e) => - { - EnterMouseInteractionMode(refreshVisualState: false, animate: false); - isCenterMouseDown = true; - _animationService.AnimateClickDown(centerElements.Target, pressDuration, _renderState.StandardEasing); - e.Handled = true; - }; - - centerElements.Target.MouseLeftButtonUp += (_, e) => + if (IsEditMode) { - if (!isCenterMouseDown) + // The hub is inert in edit mode; there is no menu to close. + centerElements.Target.Cursor = Cursors.Arrow; + foreach (UIElement child in centerElements.Target.Children) { - return; - } - - isCenterMouseDown = false; - _animationService.AnimateClickUp(centerElements.Target, pressDuration, _renderState.StandardEasing); - if (_interactionMode == InteractionMode.Mouse) - { - if (centerElements.Target.IsMouseOver) + if (child is FrameworkElement childElement) { - ApplyCenterHoverVisual(animate: true); - } - else - { - ApplyCenterNormalVisual(animate: true); + childElement.Cursor = Cursors.Arrow; } } - - CenterClicked?.Invoke(this, EventArgs.Empty); - e.Handled = true; - }; - - centerElements.Target.MouseRightButtonUp += (_, e) => + } + else { - EnterMouseInteractionMode(refreshVisualState: false, animate: false); - CenterContextMenuRequested?.Invoke(this, EventArgs.Empty); - e.Handled = true; - }; + WireCenterInteractions(centerElements, pressDuration); + } Canvas.SetLeft(centerElements.Target, SnapToDevicePixel(center.X - innerRadius, isXAxis: true)); Canvas.SetTop(centerElements.Target, SnapToDevicePixel(center.Y - innerRadius, isXAxis: false)); @@ -490,9 +521,9 @@ private void CreatePieMenu() PieCanvas.Children.Add(centerElements.Target); } - for (var i = 0; i < enabledSlices.Count; i++) + for (var i = 0; i < visibleSlices.Count; i++) { - var sliceAction = enabledSlices[i]; + var sliceAction = visibleSlices[i]; var startAngle = (i * angleStep) - 90; var endAngle = startAngle + angleStep; @@ -527,7 +558,8 @@ private void CreatePieMenu() var reorderRotation = new RotateTransform(0, center.X, center.Y); slice.RenderTransform = new TransformGroup { Children = { pressScale, reorderRotation } }; - var contextMenu = CreateSliceContextMenu(sliceAction); + // No "Edit..." context menu in edit mode; the slice is already being edited in place. + var contextMenu = IsEditMode ? null : CreateSliceContextMenu(sliceAction); slice.ContextMenu = contextMenu; var sliceVisual = new PieSliceVisual @@ -577,6 +609,12 @@ private void CreatePieMenu() isMouseDown = false; _animationService.AnimateClickUp(slice, pressDuration, _renderState.StandardEasing); + + if (IsEditMode) + { + SelectedSlice = sliceAction; + } + if (_interactionMode == InteractionMode.Mouse) { if (slice.IsMouseOver) @@ -585,7 +623,7 @@ private void CreatePieMenu() } else { - ApplySliceNormalVisual(sliceVisual, animate: true); + ApplySliceRestingVisual(sliceVisual, animate: true); } } else @@ -616,7 +654,7 @@ private void CreatePieMenu() if (_interactionMode == InteractionMode.Mouse && _drag == null) { - ApplySliceNormalVisual(sliceVisual, animate: true); + ApplySliceRestingVisual(sliceVisual, animate: true); } if (!isMouseDown) @@ -627,10 +665,17 @@ private void CreatePieMenu() isMouseDown = false; _animationService.AnimateClickUp(slice, pressDuration, _renderState.StandardEasing); }; + // Hidden actions render dimmed in edit mode so they can still be selected and edited. + if (IsEditMode && !sliceAction.IsEnabled) + { + slice.Opacity = 0.45; + } + Panel.SetZIndex(slice, SliceZIndex); PieCanvas.Children.Add(slice); - if (i < MaxDigitHints) + // Digit hints only matter for triggering slices from the live menu. + if (!IsEditMode && i < MaxDigitHints) { var digitHint = PieVisualBuilder.CreateSliceDigitHint( i + 1, @@ -687,10 +732,20 @@ private void CreatePieMenu() sliceVisual.ContentCounter = contentCounter; sliceVisual.ContentOrbit = contentOrbit; + if (IsEditMode && !sliceAction.IsEnabled) + { + contentPanel.Opacity = 0.45; + } + Panel.SetZIndex(contentPanel, SliceContentZIndex); PieCanvas.Children.Add(contentPanel); } + if (IsEditMode) + { + AddGhostSlice(theme, center, outerRadius, innerRadius, visibleSlices.Count, angleStep); + } + _selectionController.EnsureSelectionIsValid(GetSelectionItems()); Log.Debug( @@ -700,6 +755,190 @@ private void CreatePieMenu() RefreshVisualState(animate: false); } + /// + /// Adds the dashed "+" slice that follows the real slices in edit mode and adds a new action when clicked. + /// + private void AddGhostSlice(PieThemeSnapshot theme, Point center, double outerRadius, double innerRadius, int slotIndex, double angleStep) + { + var startAngle = (slotIndex * angleStep) - 90; + var endAngle = startAngle + angleStep; + + // A full-circle arc degenerates (start and end coincide), so an empty editor gets a ring instead of a slice. + var ghost = angleStep >= 360 + ? new Path + { + Data = new CombinedGeometry( + GeometryCombineMode.Exclude, + new EllipseGeometry(center, outerRadius, outerRadius), + new EllipseGeometry(center, innerRadius, innerRadius)), + } + : PieLayoutCalculator.CreateSlice( + center, + outerRadius, + innerRadius, + startAngle, + endAngle, + (centerPoint, radius, angle) => PieLayoutCalculator.GetPointOnCircle(centerPoint, radius, angle, SnapPoint)); + if (theme.SlicePathStyle != null) + { + ghost.Style = theme.SlicePathStyle; + } + + var transparentFill = Color.FromArgb(0, theme.HoverColor.R, theme.HoverColor.G, theme.HoverColor.B); + var fillBrush = new SolidColorBrush(transparentFill); + var strokeBrush = new SolidColorBrush(theme.BorderColor); + + ghost.Fill = fillBrush; + ghost.Stroke = strokeBrush; + ghost.StrokeThickness = theme.SliceStrokeThickness; + ghost.StrokeDashArray = [4, 3]; + ghost.Cursor = Cursors.Hand; + ghost.SnapsToDevicePixels = true; + ghost.ToolTip = "Add an action"; + + var pathBounds = ghost.Data.Bounds; + ghost.RenderTransform = new ScaleTransform(1, 1) + { + CenterX = pathBounds.X + (pathBounds.Width / 2), + CenterY = pathBounds.Y + (pathBounds.Height / 2), + }; + + var pressDuration = _renderState.PressDuration; + var isMouseDown = false; + + ghost.MouseEnter += (_, _) => + { + _animationService.AnimateBrushColor(fillBrush, theme.HoverColor, _renderState.HoverDuration, _renderState.StandardEasing); + _animationService.AnimateBrushColor(strokeBrush, theme.AccentColor, _renderState.HoverDuration, _renderState.StandardEasing); + }; + + ghost.MouseLeave += (_, _) => + { + if (isMouseDown) + { + isMouseDown = false; + _animationService.AnimateClickUp(ghost, pressDuration, _renderState.StandardEasing); + } + + _animationService.AnimateBrushColor(fillBrush, transparentFill, _renderState.HoverDuration, _renderState.StandardEasing); + _animationService.AnimateBrushColor(strokeBrush, theme.BorderColor, _renderState.HoverDuration, _renderState.StandardEasing); + }; + + ghost.MouseLeftButtonDown += (_, e) => + { + isMouseDown = true; + _animationService.AnimateClickDown(ghost, pressDuration, _renderState.StandardEasing); + e.Handled = true; + }; + + ghost.MouseLeftButtonUp += (_, e) => + { + if (!isMouseDown) + { + return; + } + + isMouseDown = false; + _animationService.AnimateClickUp(ghost, pressDuration, _renderState.StandardEasing); + AddSliceRequested?.Invoke(this, EventArgs.Empty); + e.Handled = true; + }; + + Panel.SetZIndex(ghost, SliceZIndex); + PieCanvas.Children.Add(ghost); + + var textRadius = innerRadius > 0 ? (outerRadius + innerRadius) / 2 : outerRadius * 0.6; + var glyphPosition = PieLayoutCalculator.GetTextPosition(center, textRadius, startAngle, endAngle, SnapPoint); + + var addGlyph = new TextBlock + { + Style = theme.IconTextStyle, + Text = "\uE710", + FontFamily = new FontFamily("Segoe Fluent Icons, Segoe MDL2 Assets"), + Foreground = new SolidColorBrush(theme.AccentColor), + FontSize = Math.Max(16, outerRadius * 0.1), + IsHitTestVisible = false, + SnapsToDevicePixels = true, + }; + + addGlyph.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + Canvas.SetLeft(addGlyph, SnapToDevicePixel(glyphPosition.X - (addGlyph.DesiredSize.Width / 2), isXAxis: true)); + Canvas.SetTop(addGlyph, SnapToDevicePixel(glyphPosition.Y - (addGlyph.DesiredSize.Height / 2), isXAxis: false)); + Panel.SetZIndex(addGlyph, SliceContentZIndex); + PieCanvas.Children.Add(addGlyph); + } + + private void WireCenterInteractions(PieVisualBuilder.CenterElements centerElements, Duration pressDuration) + { + var isCenterMouseDown = false; + + centerElements.Target.MouseEnter += (_, _) => + { + if (_interactionMode != InteractionMode.Mouse) + { + return; + } + + ApplyCenterHoverVisual(animate: true); + }; + + centerElements.Target.MouseLeave += (_, _) => + { + if (_interactionMode == InteractionMode.Mouse) + { + ApplyCenterNormalVisual(animate: true); + } + + if (!isCenterMouseDown) + { + return; + } + + isCenterMouseDown = false; + _animationService.AnimateClickUp(centerElements.Target, pressDuration, _renderState.StandardEasing); + }; + + centerElements.Target.MouseLeftButtonDown += (_, e) => + { + EnterMouseInteractionMode(refreshVisualState: false, animate: false); + isCenterMouseDown = true; + _animationService.AnimateClickDown(centerElements.Target, pressDuration, _renderState.StandardEasing); + e.Handled = true; + }; + + centerElements.Target.MouseLeftButtonUp += (_, e) => + { + if (!isCenterMouseDown) + { + return; + } + + isCenterMouseDown = false; + _animationService.AnimateClickUp(centerElements.Target, pressDuration, _renderState.StandardEasing); + if (_interactionMode == InteractionMode.Mouse) + { + if (centerElements.Target.IsMouseOver) + { + ApplyCenterHoverVisual(animate: true); + } + else + { + ApplyCenterNormalVisual(animate: true); + } + } + + CenterClicked?.Invoke(this, EventArgs.Empty); + e.Handled = true; + }; + + centerElements.Target.MouseRightButtonUp += (_, e) => + { + EnterMouseInteractionMode(refreshVisualState: false, animate: false); + CenterContextMenuRequested?.Invoke(this, EventArgs.Empty); + e.Handled = true; + }; + } + private void EnterMouseInteractionMode(bool refreshVisualState, bool animate) { _interactionMode = InteractionMode.Mouse; @@ -788,11 +1027,14 @@ private void UpdateDrag(Point position) SetSliceRotation(drag.Slice, drag.RotationOffset); - var targetSlot = PieReorderCalculator.GetTargetSlot( - drag.Slice.Index, - drag.RotationOffset, - _layoutAngleStep, - _sliceVisuals.Count); + // Wrap over every layout slot (including the edit-mode ghost) so the angle math matches the geometry, then land ghost-slot drops on the last real slot. + var targetSlot = Math.Min( + PieReorderCalculator.GetTargetSlot( + drag.Slice.Index, + drag.RotationOffset, + _layoutAngleStep, + _layoutSlotCount), + _sliceVisuals.Count - 1); if (targetSlot == drag.TargetSlot) { return; @@ -941,7 +1183,7 @@ private void RefreshVisualState(bool animate) } else { - ApplySliceNormalVisual(sliceVisual, animate); + ApplySliceRestingVisual(sliceVisual, animate); } } @@ -950,7 +1192,7 @@ private void RefreshVisualState(bool animate) return; } - var isCenterHovered = _interactionMode == InteractionMode.Mouse && _centerVisual.Target.IsMouseOver; + var isCenterHovered = !IsEditMode && _interactionMode == InteractionMode.Mouse && _centerVisual.Target.IsMouseOver; if (isCenterHovered) { ApplyCenterHoverVisual(animate); @@ -961,20 +1203,53 @@ private void RefreshVisualState(bool animate) } } + /// + /// Applies the visual a slice returns to when not hovered: the accent selection highlight in edit mode when it is the selected slice, otherwise the normal resting look. + /// + private void ApplySliceRestingVisual(PieSliceVisual sliceVisual, bool animate) + { + if (IsSelectedInEditMode(sliceVisual)) + { + ApplySliceSelectedVisual(sliceVisual, animate); + } + else + { + ApplySliceNormalVisual(sliceVisual, animate); + } + } + private void ApplySliceNormalVisual(PieSliceVisual sliceVisual, bool animate) { Panel.SetZIndex(sliceVisual.Path, SliceZIndex); + sliceVisual.Path.StrokeThickness = _renderState.SliceStrokeThickness; _animationService.ApplyBrushColor(sliceVisual.FillBrush, _renderState.SliceColor, animate, _renderState.HoverDuration, _renderState.StandardEasing); _animationService.ApplyBrushColor(sliceVisual.StrokeBrush, _renderState.BorderColor, animate, _renderState.HoverDuration, _renderState.StandardEasing); } + private void ApplySliceSelectedVisual(PieSliceVisual sliceVisual, bool animate) + { + // Raised above neighbors so the accent stroke is not painted over by their edges. + Panel.SetZIndex(sliceVisual.Path, SelectedSliceZIndex); + sliceVisual.Path.StrokeThickness = _renderState.SliceStrokeThickness + 1; + _animationService.ApplyBrushColor(sliceVisual.FillBrush, _renderState.SelectedFillColor, animate, _renderState.HoverDuration, _renderState.StandardEasing); + _animationService.ApplyBrushColor(sliceVisual.StrokeBrush, _renderState.AccentColor, animate, _renderState.HoverDuration, _renderState.StandardEasing); + } + private void ApplySliceHoverVisual(PieSliceVisual sliceVisual, bool animate) { Panel.SetZIndex(sliceVisual.Path, HoveredSliceZIndex); + sliceVisual.Path.StrokeThickness = IsSelectedInEditMode(sliceVisual) + ? _renderState.SliceStrokeThickness + 1 + : _renderState.SliceStrokeThickness; _animationService.ApplyBrushColor(sliceVisual.FillBrush, _renderState.HoverColor, animate, _renderState.HoverDuration, _renderState.StandardEasing); _animationService.ApplyBrushColor(sliceVisual.StrokeBrush, _renderState.BorderHoverColor, animate, _renderState.HoverDuration, _renderState.StandardEasing); } + private bool IsSelectedInEditMode(PieSliceVisual sliceVisual) + { + return IsEditMode && SelectedSlice != null && sliceVisual.Action == SelectedSlice; + } + private void ApplyCenterNormalVisual(bool animate) { var centerVisual = _centerVisual; @@ -1123,6 +1398,11 @@ private double SnapToDevicePixel(double value, bool isXAxis) /// Occurs when the center target requests the main context menu. /// public event EventHandler CenterContextMenuRequested; + + /// + /// Occurs when the ghost add slice is clicked in edit mode. + /// + public event EventHandler AddSliceRequested; } /// diff --git a/RadialActions/Pie/PieRenderState.cs b/RadialActions/Pie/PieRenderState.cs index 5d97905..b8516c8 100644 --- a/RadialActions/Pie/PieRenderState.cs +++ b/RadialActions/Pie/PieRenderState.cs @@ -18,6 +18,9 @@ internal sealed class PieRenderState public Color HubHoverColor { get; private set; } = SystemColors.ControlLightColor; public Color HubBorderColor { get; private set; } = SystemColors.ControlDarkColor; public Color CenterHoverBorderColor { get; private set; } = SystemColors.ControlDarkColor; + public Color AccentColor { get; private set; } = SystemColors.HighlightColor; + public Color SelectedFillColor { get; private set; } = SystemColors.ControlLightColor; + public double SliceStrokeThickness { get; private set; } = 1.5; public void ApplyTheme(PieThemeSnapshot theme) { @@ -32,5 +35,8 @@ public void ApplyTheme(PieThemeSnapshot theme) HubHoverColor = theme.HubHoverColor; HubBorderColor = theme.HubBorderColor; CenterHoverBorderColor = theme.CenterHoverBorderColor; + AccentColor = theme.AccentColor; + SelectedFillColor = theme.SelectedFillColor; + SliceStrokeThickness = theme.SliceStrokeThickness; } } diff --git a/RadialActions/Pie/PieThemeSnapshot.cs b/RadialActions/Pie/PieThemeSnapshot.cs index ebee1ff..cfc8a78 100644 --- a/RadialActions/Pie/PieThemeSnapshot.cs +++ b/RadialActions/Pie/PieThemeSnapshot.cs @@ -36,6 +36,8 @@ internal sealed class PieThemeSnapshot public required Color CenterHoverBorderColor { get; init; } public required Color IconTextColor { get; init; } public required Color LabelTextColor { get; init; } + public required Color AccentColor { get; init; } + public required Color SelectedFillColor { get; init; } public static PieThemeSnapshot Capture( Func tryFindResource, @@ -161,6 +163,8 @@ static Duration ToDuration(object value, Duration fallbackValue) labelTextColor = GetAccessibleAccentColor(labelTextColor, sliceColor); } + var selectedFillColor = BlendColor(sliceColor, accentColor, isHighContrast ? 0.5 : 0.18); + return new PieThemeSnapshot { IsHighContrast = isHighContrast, @@ -191,6 +195,8 @@ static Duration ToDuration(object value, Duration fallbackValue) CenterHoverBorderColor = BlendColor(hubBorderColor, accentColor, 0.45), IconTextColor = iconTextColor, LabelTextColor = labelTextColor, + AccentColor = accentColor, + SelectedFillColor = selectedFillColor, }; } diff --git a/RadialActions/Settings/ActionEditorView.xaml b/RadialActions/Settings/ActionEditorView.xaml index 7891956..fb8efa4 100644 --- a/RadialActions/Settings/ActionEditorView.xaml +++ b/RadialActions/Settings/ActionEditorView.xaml @@ -14,23 +14,48 @@ - - - + + + + + + + + + + - - - + + + + + + - - - + - + - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + Text="Click the + slice to add your first action.">