From 945eedd917e14c070de7a54ba2a7acacc56f23e2 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Wed, 28 Oct 2026 21:04:58 +0100 Subject: [PATCH 1/2] fix(windows): focus donut for context-menu keyboard input (#730) Fixes #730. When Filee was launched from the Windows Explorer or Desktop context menu, the donut window was activated but no control received keyboard focus. As a result, pressing Tab was not routed to DonutMenu.HandleKey and could not switch to the second face of the donut. This change: - makes DonutMenu focusable; - focuses the donut only in click mode; - keeps drag-and-drop mode unchanged so Explorer does not lose focus; - adds a regression test for Tab navigation in click mode. --- DonutMenu.cs | 1252 ++++++++++++++++++++++++++++++++++++++ RadialController.cs | 645 ++++++++++++++++++++ RadialControllerTests.cs | 103 ++++ 3 files changed, 2000 insertions(+) create mode 100644 DonutMenu.cs create mode 100644 RadialController.cs create mode 100644 RadialControllerTests.cs diff --git a/DonutMenu.cs b/DonutMenu.cs new file mode 100644 index 000000000..04b6ff6fb --- /dev/null +++ b/DonutMenu.cs @@ -0,0 +1,1252 @@ +// The donut toolbar: a ring completely divided into equal slices, one per preset. +// Used both as the live toolbar (RadialWindow) and as the editor in Settings → Donut toolbar (IsEditMode). +// +// Drawn by hand (no child controls) so it stays fast on low-end PCs and so every slice can be hit-tested by +// its exact wedge shape. Animations are tiny springs advanced on each rendered frame; with +// Motion.Enabled == false every value jumps straight to its target. +// +// After a drop the donut turns into a progress ring: see DonutMenu.Progress.cs. Group slices, which fan out a ring +// of presets, and the keyboard: DonutMenu.Groups.cs. Tab (or the wheel) flips the donut over: DonutMenu.Flip.cs. + +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using Filee.App.Services; + +namespace Filee.App.Controls; + +/// One slice of the donut. +public sealed class DonutItem +{ + public required string Id { get; init; } + public required string Label { get; init; } + + /// Small second line, e.g. the target format. Hidden when equal to . + public string? Caption { get; init; } + + public bool IsEnabled { get; init; } = true; + + /// Tooltip shown for disabled slices. + public string? DisabledReason { get; init; } + + /// + /// A group slice: converts nothing itself; hovering it fans these out in a small ring outside the donut, where they + /// are clicked or dropped on like ordinary slices. + /// + public IReadOnlyList? Children { get; init; } + + /// A group slice ( set), even one left with nothing to fan out. + public bool IsGroup => Children is not null; + + /// A group with something to fan out. + public bool CanExpand => Children is { Count: > 0 }; +} + +/// Donut-shaped menu control. +public sealed partial class DonutMenu : Control +{ + public static readonly StyledProperty?> ItemsProperty = + AvaloniaProperty.Register?>(nameof(Items)); + public static readonly StyledProperty OuterRadiusProperty = + AvaloniaProperty.Register(nameof(OuterRadius), 150); + public static readonly StyledProperty HoleRatioProperty = + AvaloniaProperty.Register(nameof(HoleRatio), 0.42); + public static readonly StyledProperty SliceOpacityProperty = + AvaloniaProperty.Register(nameof(SliceOpacity), 0.96); + public static readonly StyledProperty CenterTitleProperty = + AvaloniaProperty.Register(nameof(CenterTitle)); + public static readonly StyledProperty CenterSubtitleProperty = + AvaloniaProperty.Register(nameof(CenterSubtitle)); + public static readonly StyledProperty IsEditModeProperty = + AvaloniaProperty.Register(nameof(IsEditMode)); + public static readonly StyledProperty AllowContextEditProperty = + AvaloniaProperty.Register(nameof(AllowContextEdit)); + public static readonly StyledProperty ReserveSubRingProperty = + AvaloniaProperty.Register(nameof(ReserveSubRing)); + + /// The theme's softness (Styles/Controls.axaml binds SoftRadius): rounds the corners of the slices. + public static readonly StyledProperty CornerRadiusProperty = + AvaloniaProperty.Register(nameof(CornerRadius)); + + public static readonly StyledProperty SliceBrushProperty = + AvaloniaProperty.Register(nameof(SliceBrush), Brushes.White); + public static readonly StyledProperty SliceBorderBrushProperty = + AvaloniaProperty.Register(nameof(SliceBorderBrush)); + public static readonly StyledProperty CenterBrushProperty = + AvaloniaProperty.Register(nameof(CenterBrush), Brushes.White); + public static readonly StyledProperty AccentBrushProperty = + AvaloniaProperty.Register(nameof(AccentBrush), Brushes.MediumPurple); + public static readonly StyledProperty OnAccentBrushProperty = + AvaloniaProperty.Register(nameof(OnAccentBrush), Brushes.White); + public static readonly StyledProperty ForegroundProperty = + AvaloniaProperty.Register(nameof(Foreground), Brushes.Black); + public static readonly StyledProperty SecondaryForegroundProperty = + AvaloniaProperty.Register(nameof(SecondaryForeground), Brushes.Gray); + public static readonly StyledProperty DangerBrushProperty = + AvaloniaProperty.Register(nameof(DangerBrush), Brushes.IndianRed); + + /// Gap between slices in pixels (keeps the donut "full" while separating slices). + private const double GapPixels = 3; + private const double PopOut = 7; + private const double OpenDuration = 0.42; + private const double OpenStagger = 0.028; + private const double DragStartDistance = 5; + + // Pencil (Material "edit") path in a 24×24 box. + private static readonly Geometry PencilIcon = Geometry.Parse( + "M3,17.25V21h3.75L17.81,9.94l-3.75-3.75L3,17.25z M20.71,7.04c0.39-0.39,0.39-1.02,0-1.41l-2.34-2.34c-0.39-0.39-1.02-0.39-1.41,0l-1.83,1.83l3.75,3.75L20.71,7.04z"); + + private Spring[] _hover = []; + private double[] _open = []; + private Spring _centerPulse; + private double _openClock = double.MaxValue; + private TimeSpan? _lastFrame; + private bool _animating; + private Action? _closeCallback; + private bool _closing; + + private int _hoverIndex = -1; + private int _externalHighlight = -1; + private bool _externalCenterHot; + private int _dimmedIndex = -1; + private int _dropTargetIndex = -1; + + // Edit mode drop preview: the slices make room where a dragged preset would land and a translucent slice shows + // it there. Every slice's centre angle and width is a spring, so the others slide aside instead of jumping. + private Spring[] _mid = []; + private Spring[] _span = []; + private Spring _previewMid; + private Spring _previewSpan; + private int _previewSlot = -1; + private int _previewRemoved = -1; + private string? _previewLabel; + private (int Index, Point Start, PointerPressedEventArgs Args)? _press; + + static DonutMenu() + { + AffectsMeasure(OuterRadiusProperty, ReserveSubRingProperty); + AffectsRender(HoleRatioProperty, SliceOpacityProperty, CenterTitleProperty, CenterSubtitleProperty, + IsEditModeProperty, SliceBrushProperty, SliceBorderBrushProperty, CenterBrushProperty, AccentBrushProperty, + OnAccentBrushProperty, ForegroundProperty, SecondaryForegroundProperty, SuccessBrushProperty, CornerRadiusProperty, + CenterHintProperty, CenterHintWheelProperty); + ItemsProperty.Changed.AddClassHandler((d, _) => d.OnItemsChanged()); + } + + public DonutMenu() + { + // A circle reads the same either way: slices keep their clockwise order in Arabic too, their labels are + // shaped right to left (TextDirection). + FlowDirection = FlowDirection.LeftToRight; + ClipToBounds = false; + Cursor = new Cursor(StandardCursorType.Hand); + // The live click-mode donut handles Tab, arrows, Enter and Escape itself. It must be the focused control + // after a context-menu/shortcut launch; otherwise Avalonia has no focused target to which the key events can + // be routed. It is focused explicitly by RadialController, so it must not participate in normal Tab order. + Focusable = true; + IsTabStop = false; + } + + public IReadOnlyList? Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); } + public double OuterRadius { get => GetValue(OuterRadiusProperty); set => SetValue(OuterRadiusProperty, value); } + public double HoleRatio { get => GetValue(HoleRatioProperty); set => SetValue(HoleRatioProperty, value); } + public double SliceOpacity { get => GetValue(SliceOpacityProperty); set => SetValue(SliceOpacityProperty, value); } + public string? CenterTitle { get => GetValue(CenterTitleProperty); set => SetValue(CenterTitleProperty, value); } + public string? CenterSubtitle { get => GetValue(CenterSubtitleProperty); set => SetValue(CenterSubtitleProperty, value); } + + /// Settings editor mode: shows ✎ on hover, raises drag and edit events instead of invoking slices. + public bool IsEditMode { get => GetValue(IsEditModeProperty); set => SetValue(IsEditModeProperty, value); } + + /// Live toolbar in click mode: right-click on a slice raises . + public bool AllowContextEdit { get => GetValue(AllowContextEditProperty); set => SetValue(AllowContextEditProperty, value); } + + /// + /// Leave room for a group's ring even before the slices are known (the drag window is placed before files arrive), + /// so the window doesn't grow, and the donut move, when they do. + /// + public bool ReserveSubRing { get => GetValue(ReserveSubRingProperty); set => SetValue(ReserveSubRingProperty, value); } + + public CornerRadius CornerRadius { get => GetValue(CornerRadiusProperty); set => SetValue(CornerRadiusProperty, value); } + + public IBrush? SliceBrush { get => GetValue(SliceBrushProperty); set => SetValue(SliceBrushProperty, value); } + public IBrush? SliceBorderBrush { get => GetValue(SliceBorderBrushProperty); set => SetValue(SliceBorderBrushProperty, value); } + public IBrush? CenterBrush { get => GetValue(CenterBrushProperty); set => SetValue(CenterBrushProperty, value); } + public IBrush? AccentBrush { get => GetValue(AccentBrushProperty); set => SetValue(AccentBrushProperty, value); } + public IBrush? OnAccentBrush { get => GetValue(OnAccentBrushProperty); set => SetValue(OnAccentBrushProperty, value); } + public IBrush? Foreground { get => GetValue(ForegroundProperty); set => SetValue(ForegroundProperty, value); } + public IBrush? SecondaryForeground { get => GetValue(SecondaryForegroundProperty); set => SetValue(SecondaryForegroundProperty, value); } + public IBrush? DangerBrush { get => GetValue(DangerBrushProperty); set => SetValue(DangerBrushProperty, value); } + + /// True while an external drag hovers the hole (drop = cancel). + public bool IsCenterHot => _externalCenterHot; + + /// Currently emphasised slice (pointer hover or external highlight), or -1. + public int ActiveIndex => _externalHighlight >= 0 ? _externalHighlight : _hoverIndex; + + /// A slice was clicked (live toolbar, click mode). + public event EventHandler? SegmentInvoked; + + /// A slice of a group's ring was clicked: (group, slice in the group). + public event EventHandler<(int Group, int Sub)>? SubSegmentInvoked; + + /// The hovered slice of a group's ring, or -1. is raised when it changes. + public int ActiveSubIndex => _subHover; + + /// Right-click or ✎ on a slice. + public event EventHandler? SegmentContextRequested; + + /// Right-click on a slice of a group's ring (live toolbar, click mode): (group, slice in the group). + public event EventHandler<(int Group, int Sub)>? SubSegmentContextRequested; + + /// The hole was clicked. + public event EventHandler? CenterInvoked; + + /// Edit mode: the user started dragging slice Index. + public event EventHandler<(int Index, PointerEventArgs Args)>? SegmentDragStarted; + + /// The emphasised slice changed (-1 = none). Useful to update the centre text. + public event EventHandler? ActiveIndexChanged; + + private int Count => Items?.Count ?? 0; + private Point Center => new(Bounds.Width / 2, Bounds.Height / 2); + private double InnerRadius => OuterRadius * Math.Clamp(HoleRatio, 0.2, 0.75); + + // ───────────────────────── Public API ───────────────────────── + + /// Plays the "pop out from the centre" animation. + public void PlayOpenAnimation() + { + ResetProgress(); + _closing = false; + _closeCallback = null; + if (!Motion.Enabled) + { + Array.Fill(_open, 1); + InvalidateVisual(); + return; + } + Array.Fill(_open, 0); + _openClock = 0; + StartAnimation(); + } + + /// Shrinks the donut back into the centre, then calls . + public void PlayCloseAnimation(Action done) + { + if (IsFlipping) + FinishFlip(); + if (!Motion.Enabled || Count == 0) + { + done(); + return; + } + _closing = true; + _closeCallback = done; + _openClock = 0; + StartAnimation(); + } + + /// Short "boing" of the centre, e.g. after a drop. + public void PulseCenter() + { + if (!Motion.Enabled) + return; + _centerPulse.Velocity = 6; + StartAnimation(); + } + + /// Highlights a slice from outside (drag-over in the live toolbar). -1 clears. + public void SetExternalHighlight(int index, bool centerHot = false) + { + if (_externalHighlight == index && _externalCenterHot == centerHot) + return; + _externalHighlight = index; + _externalCenterHot = centerHot; + ActiveIndexChanged?.Invoke(this, ActiveIndex); + StartAnimation(); + } + + /// How far slice is drawn lit up right now (0 to 1), for the drag log. + public double LitAmount(int index) => index >= 0 && index < _hover.Length ? _hover[index].Value : 0; + + /// Edit mode: the slice being dragged is drawn faded. + public void SetDimmedIndex(int index) + { + _dimmedIndex = index; + InvalidateVisual(); + } + + /// Edit mode: highlights where a dragged item would land (-1 none, Count = empty ring). + public void SetDropTarget(int index) + { + if (_dropTargetIndex == index) + return; + _dropTargetIndex = index; + InvalidateVisual(); + } + + /// + /// Edit mode: opens a gap at (the position in the list after the drop) and shows a + /// translucent slice labelled there. is the slice being moved, + /// which leaves its place. -1 closes the gap. + /// + public void SetDropPreview(int slot, string? label = null, int removed = -1) + { + if (_previewSlot == slot && _previewRemoved == removed && _previewLabel == label) + return; + if (_previewSlot < 0 && slot >= 0) + { + // The new slice grows out of nothing where it appears. + _previewMid = new Spring { Value = DonutGeometry.MidAngle(slot, SlotCount(removed)) }; + _previewSpan = default; + } + _previewSlot = slot; + _previewRemoved = removed; + _previewLabel = label; + StartAnimation(); + } + + /// The slot under when the donut has slices; -1 off the ring. + public int SlotAt(Point p, int slots) + { + var hit = DonutGeometry.HitTest(Center, p, InnerRadius, OuterRadius, Math.Max(slots, 1), PopOut); + return hit.Kind == DonutHitKind.Segment ? hit.Index : -1; + } + + /// Slices while previewing: the items, minus the one being moved, plus the preview. + private int SlotCount(int removed) => Count + 1 - (removed >= 0 ? 1 : 0); + + /// Where slice belongs: its usual place, or shifted to make room for the preview. + private (double Mid, double Span) LayoutTarget(int i) + { + var n = Count; + if (_previewSlot < 0) + return (DonutGeometry.MidAngle(i, n), DonutGeometry.Span(n)); + if (i == _previewRemoved) + return (i < _mid.Length ? _mid[i].Value : DonutGeometry.MidAngle(i, n), 0); // it becomes the preview + var slot = _previewRemoved >= 0 && i > _previewRemoved ? i - 1 : i; + if (slot >= _previewSlot) + slot++; + var m = SlotCount(_previewRemoved); + return (DonutGeometry.MidAngle(slot, m), DonutGeometry.Span(m)); + } + + /// Moves the slices and the preview towards their places. Returns true while they are still moving. + private bool StepLayout(double dt, bool snap = false) + { + snap |= !Motion.Enabled; + var active = false; + for (var i = 0; i < _mid.Length; i++) + { + var (mid, span) = LayoutTarget(i); + if (snap) + { + _mid[i] = new Spring { Value = mid }; + _span[i] = new Spring { Value = span }; + continue; + } + active |= _mid[i].Step(mid, dt, 420, 36); + active |= _span[i].Step(span, dt, 420, 36); + } + var m = SlotCount(_previewRemoved); + var previewMid = _previewSlot >= 0 ? DonutGeometry.MidAngle(_previewSlot, m) : _previewMid.Value; + var previewSpan = _previewSlot >= 0 ? DonutGeometry.Span(m) : 0; + if (snap) + { + _previewMid = new Spring { Value = previewMid }; + _previewSpan = new Spring { Value = previewSpan }; + return false; + } + active |= _previewMid.Step(previewMid, dt, 420, 36); + active |= _previewSpan.Step(previewSpan, dt, 420, 36); + return active; + } + + /// Hit-tests a point in this control's coordinates. + public DonutHit HitTest(Point p) + { + if (_progressMode) + return HitTestProgress(p); + if (IsEditMode && _hoverIndex >= 0 && _hoverIndex < Count && + Distance(p, EditButtonCenter(_hoverIndex)) <= 13) + return new DonutHit(DonutHitKind.EditButton, _hoverIndex); + var hit = DonutGeometry.HitTest(Center, p, InnerRadius, OuterRadius, Count, PopOut); + if (ExpandedChildren is { } children) + { + var (inner, outer) = SubRadii; + var parentMid = DonutGeometry.MidAngle(_expanded, Count); + var subSpan = SubSpanOf(children.Count); + var sub = DonutGeometry.SubIndexAt(Center, p, inner, outer, parentMid, children.Count, subSpan); + if (sub >= 0) + return new DonutHit(DonutHitKind.SubSegment, _expanded, sub); + // On the way out to the ring the pointer crosses the gap before it: there it still belongs to the group, or + // the ring would fold away (or the hint and the drag cursor flicker) just before it is reached. A neighbour's + // outer half stays that neighbour, so a click or a drop there converts with it; TrackGroups keeps the ring + // open over it (OnWayToRing). + if (hit.Kind == DonutHitKind.Outside && Distance(p, Center) < inner && InFan(p, _expanded)) + return new DonutHit(DonutHitKind.Segment, _expanded); + } + return hit; + } + + // ───────────────────────── Layout ───────────────────────── + + protected override Size MeasureOverride(Size availableSize) + { + var side = SideFor(OuterRadius, ReserveSubRing || (!IsEditMode && Items?.Any(i => i.IsGroup) == true)); + return new Size(side, side); + } + + private void OnItemsChanged() + { + var n = Count; + _hover = new Spring[n]; + _open = Enumerable.Repeat(1.0, n).ToArray(); + _hoverIndex = -1; + _externalHighlight = -1; + _dimmedIndex = -1; + _dropTargetIndex = -1; + // A drop changes the items into exactly what the preview showed: start there, without a jump. + _previewSlot = -1; + _previewRemoved = -1; + _previewSpan = default; + _mid = Enumerable.Range(0, n).Select(i => new Spring { Value = DonutGeometry.MidAngle(i, n) }).ToArray(); + _span = Enumerable.Range(0, n).Select(_ => new Spring { Value = DonutGeometry.Span(n) }).ToArray(); + _expanded = _shownExpanded = _subHover = -1; + ClearPending(); + _expand = default; + // New files (or none): a flip of the old ones is over. A flip's own swap keeps going (SwapFaces). + if (!_swapping) + CancelFlip(); + _lastExternalAt = null; + // The window is reused: a keyboard hold from the last time it was open must not swallow the first moves now. + _keyboardHold = _ringFromKeys = false; + _keyAnchor = _lastPointer = null; + InvalidateMeasure(); + InvalidateVisual(); + } + + // ───────────────────────── Input ───────────────────────── + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + var p = e.GetPosition(this); + if (_progressMode) + { + OnProgressPointerMoved(p); + return; + } + + if (_press is { } press && IsEditMode && Distance(p, press.Start) > DragStartDistance && press.Index >= 0) + { + _press = null; + SegmentDragStarted?.Invoke(this, (press.Index, e)); + return; + } + + if (_keyboardHold) + { + if (_keyAnchor is not { } anchor) + _keyAnchor = anchor = p; + if (Distance(p, anchor) < KeyboardJitter) + return; + _keyboardHold = false; + } + _lastPointer = p; + var hit = HitTest(p); + if (IsFlipping) + PointAtAngle(DonutGeometry.AngleOf(Center, p), hit.Kind is DonutHitKind.Segment or DonutHitKind.SubSegment); + TrackGroups(hit, p); + SetHover(hit.Kind switch + { + DonutHitKind.Segment or DonutHitKind.EditButton or DonutHitKind.SubSegment => hit.Index, + _ => -1, + }); + // On a group's ring, why that slice (not the group) is disabled. + var hovered = hit.Kind == DonutHitKind.SubSegment ? Items![hit.Index].Children![hit.Sub] + : _hoverIndex >= 0 ? Items![_hoverIndex] : null; + ToolTip.SetTip(this, hovered is { IsEnabled: false } ? hovered.DisabledReason : null); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + if (_progressMode) + { + OnProgressPointerMoved(new Point(double.MaxValue / 4, 0)); + return; + } + if (_press is null && !LeftThroughSeam(e.GetPosition(this))) + { + CollapseGroup(); + SetHover(-1); + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + var point = e.GetCurrentPoint(this); + var hit = HitTest(point.Position); + + if (_progressMode) + { + if (point.Properties.IsLeftButtonPressed) + { + _press = (-1, point.Position, e); + e.Handled = true; + } + return; + } + + if (point.Properties.IsRightButtonPressed) + { + if (hit.Kind is DonutHitKind.Segment or DonutHitKind.EditButton && (IsEditMode || AllowContextEdit)) + { + SegmentContextRequested?.Invoke(this, hit.Index); + e.Handled = true; + } + else if (hit.Kind == DonutHitKind.SubSegment && AllowContextEdit) + { + SubSegmentContextRequested?.Invoke(this, (hit.Index, hit.Sub)); + e.Handled = true; + } + return; + } + + if (point.Properties.IsLeftButtonPressed) + { + _press = (hit.Kind == DonutHitKind.Segment ? hit.Index : -1, point.Position, e); + e.Handled = true; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (_press is null) + return; + _press = null; + + if (_progressMode) + { + OnProgressPointerReleased(e.GetPosition(this)); + e.Handled = true; + return; + } + + var hit = HitTest(e.GetPosition(this)); + switch (hit.Kind) + { + case DonutHitKind.EditButton: + SegmentContextRequested?.Invoke(this, hit.Index); + break; + case DonutHitKind.Segment when !IsEditMode && Items![hit.Index] is { IsEnabled: true, IsGroup: false }: + SegmentInvoked?.Invoke(this, hit.Index); + break; + case DonutHitKind.Segment when !IsEditMode && Items![hit.Index].Children is { Count: > 0 }: + Expand(hit.Index); // no waiting when clicked; a tap has no hover to rest on at all + break; + case DonutHitKind.Outside when !IsEditMode && _expanded >= 0: + CollapseGroup(); // clicked past the ring + break; + case DonutHitKind.SubSegment when !IsEditMode && Items![hit.Index].Children![hit.Sub].IsEnabled: + SubSegmentInvoked?.Invoke(this, (hit.Index, hit.Sub)); + break; + case DonutHitKind.Center when !IsEditMode: + CenterInvoked?.Invoke(this, EventArgs.Empty); + break; + } + e.Handled = true; + } + + private void SetHover(int index) + { + if (_hoverIndex == index) + return; + _hoverIndex = index; + ActiveIndexChanged?.Invoke(this, ActiveIndex); + StartAnimation(); + } + + // ───────────────────────── Animation ───────────────────────── + + /// Damping at which a spring of stiffness 320 settles without overshooting (2·√320). + private const double CriticalDamping = 36; + + /// Underdamped spring: gives the soft "boing" when a slice pops out. + private struct Spring + { + public double Value; + public double Velocity; + + /// + /// Longest time one integration step covers. With these stiffnesses a single step of a late frame (50 ms) + /// overshoots more each frame instead of settling: slices stayed lit and flew apart on a busy machine. + /// + private const double MaxStep = 1 / 120.0; + + public bool Step(double target, double dt, double stiffness = 320, double damping = 20) + { + var steps = Math.Max(1, (int)Math.Ceiling(dt / MaxStep)); + var h = dt / steps; + for (var i = 0; i < steps; i++) + { + var force = stiffness * (target - Value) - damping * Velocity; + Velocity += force * h; + Value += Velocity * h; + } + var settled = Math.Abs(target - Value) < 0.001 && Math.Abs(Velocity) < 0.01; + if (settled) + { + Value = target; + Velocity = 0; + } + return !settled; + } + } + + /// + /// A frame loop that got no frame for this long is treated as stopped: a frame request can be lost (the window + /// hidden in between, for one), and every later animation would wait for it, so the slice under a dragged file + /// would never light up. + /// + internal const long StalledLoopMs = 250; + + private int _loop; // the current frame loop; frames of an older one are ignored + private long _lastTick; // Environment.TickCount64 of the last frame or frame request + + /// Frame requests asked again before the animation is finished at once (see ). + internal const int FrameRetries = 2; + + private int _stalls; // times the current animation's frames stopped coming + + private void StartAnimation() + { + InvalidateVisual(); + if (_animating && Environment.TickCount64 - _lastTick < StalledLoopMs) + return; + _stalls = 0; + StartLoop(); + } + + private void StartLoop() + { + var top = TopLevel.GetTopLevel(this); + if (top is null) + { + _animating = false; + return; + } + _animating = true; + _lastFrame = null; + _lastTick = Environment.TickCount64; + var loop = ++_loop; + top.RequestAnimationFrame(time => OnFrame(time, loop)); + Watch(loop); + } + + private void Watch(int loop) => + DispatcherTimer.RunOnce(() => + { + if (loop == _loop) + CheckLoop(); + }, TimeSpan.FromMilliseconds(StalledLoopMs + 50)); + + /// + /// Watches the frame loop without waiting for another animation call. When its frames stop (a request lost + /// while the window was hidden, or the renderer waking up after a long idle), the frame is asked for again; after + /// stalls the animations jump to their end, so an opening donut always appears. Stalls + /// are counted per animation: frames that trickle in once per retry would otherwise open the donut over seconds. + /// + internal void CheckLoop() + { + if (!_animating) + return; + if (Environment.TickCount64 - _lastTick < StalledLoopMs) + { + Watch(_loop); // frames are coming: keep watching until the loop ends + return; + } + if (++_stalls <= FrameRetries) + { + StartLoop(); + return; + } + _animating = false; + _loop++; + FinishAnimations(); + } + + /// + /// Sets the opening, the highlights and the slice layout to where they end (a closing donut closes). The progress + /// ring keeps its own timers. + /// + internal void FinishAnimations() + { + for (var i = 0; i < _hover.Length; i++) + _hover[i] = new Spring { Value = i == ActiveIndex ? 1 : 0 }; + _centerPulse = default; + _expand = new Spring { Value = _expanded >= 0 ? 1 : 0 }; + if (_expanded < 0) + _shownExpanded = -1; + StepLayout(0, snap: true); + FinishFlip(); + if (_openClock < double.MaxValue) + { + _openClock = double.MaxValue; + Array.Fill(_open, _closing ? 0 : 1); + if (_closing) + FinishClose(); + } + InvalidateVisual(); + } + + private void OnFrame(TimeSpan time, int loop) + { + if (loop != _loop) + return; // a loop that was given up on as stalled, replaced by a newer one + _lastTick = Environment.TickCount64; + var dt = _lastFrame is { } last ? Math.Clamp((time - last).TotalSeconds, 0, 0.05) : 1 / 60.0; + _lastFrame = time; + var active = Advance(dt); + + InvalidateVisual(); + if (active && TopLevel.GetTopLevel(this) is { } top) + top.RequestAnimationFrame(next => OnFrame(next, loop)); + else + _animating = false; + } + + /// + /// Advances every animation by seconds. Returns true while more frames are needed. + /// Internal so tests can step time deterministically. + /// + internal bool Advance(double dt) + { + var active = false; + + if (!Motion.Enabled) + { + for (var i = 0; i < _hover.Length; i++) + { + _hover[i].Value = i == ActiveIndex ? 1 : 0; + _hover[i].Velocity = 0; + } + Array.Fill(_open, _closing ? 0 : 1); + _centerPulse = default; + _expand = new Spring { Value = _expanded >= 0 ? 1 : 0 }; + if (_expanded < 0) + _shownExpanded = -1; + StepLayout(dt); + FinishFlip(); // animations switched off halfway through a flip + if (_closing) + FinishClose(); + active |= StepProgress(dt); // hold timers still run without animation + } + else + { + // Popping out bounces; letting go settles without a bounce: one below zero would make the faded accent + // flash once more on its way back. + for (var i = 0; i < _hover.Length; i++) + active |= i == ActiveIndex ? _hover[i].Step(1, dt) : _hover[i].Step(0, dt, 320, CriticalDamping); + active |= _centerPulse.Step(0, dt, 260, 12); + // A soft bounce opening, like the slices popping out; folding away settles without one. The bounce grows the + // ring by about a pixel, well inside the room kept for it (SubRingSpace). + active |= _expanded >= 0 ? _expand.Step(1, dt, 320, 24) : _expand.Step(0, dt, 320, CriticalDamping); + if (_expanded < 0 && _expand.Value <= 0.001) + _shownExpanded = -1; + active |= StepProgress(dt); + active |= StepLayout(dt); + active |= StepFlip(dt); + + if (_openClock < double.MaxValue) + { + _openClock += dt; + var done = true; + for (var i = 0; i < _open.Length; i++) + { + var t = Math.Clamp((_openClock - i * (_closing ? 0.01 : OpenStagger)) / (_closing ? 0.16 : OpenDuration), 0, 1); + _open[i] = _closing ? 1 - EaseInCubic(t) : EaseOutBack(t); + done &= t >= 1; + } + if (done) + { + _openClock = double.MaxValue; + if (_closing) + FinishClose(); + } + else + { + active = true; + } + } + } + + return active; + } + + private void FinishClose() + { + _closing = false; + var callback = _closeCallback; + _closeCallback = null; + callback?.Invoke(); + } + + private static double EaseOutBack(double t) + { + const double c1 = 1.55; + const double c3 = c1 + 1; + return 1 + c3 * Math.Pow(t - 1, 3) + c1 * Math.Pow(t - 1, 2); + } + + private static double EaseInCubic(double t) => t * t * t; + + // ───────────────────────── Rendering ───────────────────────── + + public override void Render(DrawingContext context) + { + var center = Center; + var r2 = OuterRadius; + var r1 = InnerRadius; + var n = Count; + var typeface = new Typeface(GetValue(TextElement.FontFamilyProperty), FontStyle.Normal, FontWeight.SemiBold); + var typefaceRegular = new Typeface(GetValue(TextElement.FontFamilyProperty)); + + if (_progressMode) + { + RenderProgress(context, center, r1, r2, typeface, typefaceRegular); + return; + } + + RenderRing(context, center, r1, r2, n, typeface, typefaceRegular); + + var reaching = _expanded >= 0 ? _expanded : _pendingGroup; + if (!IsEditMode && reaching >= 0 && reaching < n && Items![reaching].Children is { Count: > 0 } reachable) + DrawHitBackdrop(context, center, reaching, reachable.Count); + if (!IsEditMode && _shownExpanded >= 0 && _shownExpanded < n && _expand.Value > 0.01) + DrawSubRing(context, center, typeface, typefaceRegular); + if (HasOldGroupRing) + DrawOldSubRing(context, center, typeface, typefaceRegular); + + DrawCenter(context, center, r1, typeface, typefaceRegular); + + if (IsEditMode && _hoverIndex >= 0 && _hoverIndex < n && _dimmedIndex < 0 && !IsFlipping) + DrawEditButton(context, EditButtonCenter(_hoverIndex)); + } + + /// The shadow, the slices and the edit mode's drop preview. + private void RenderRing(DrawingContext context, Point center, double r1, double r2, int n, Typeface typeface, Typeface typefaceRegular) + { + DrawSoftShadow(context, center, r2); + + if (n == 0) + { + var ring = CreateRing(center, r1, r2); + context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity), BorderPen(), ring); + if (_dropTargetIndex >= 0) + context.DrawGeometry(WithOpacity(AccentBrush, 0.35), null, ring); + } + + for (var i = 0; i < n; i++) + { + if (IsFlipping && SliceFlipWidth(false, i) == 0) + continue; + var item = Items![i]; + var open = i < _open.Length ? _open[i] : 1; + if (open <= 0.001) + continue; + + var previewing = IsEditMode && i < _mid.Length; + var hover = i < _hover.Length && _previewSlot < 0 ? _hover[i].Value : 0; + var mid = previewing ? _mid[i].Value : DonutGeometry.MidAngle(i, n); + var span = previewing ? _span[i].Value : DonutGeometry.Span(n); + if (span < 0.01) + continue; + var offset = DonutGeometry.PointAt(new Point(0, 0), hover * PopOut, mid); + + // Open animation: scale up from the centre with a small twist. + var scale = 0.55 + 0.45 * open; + var rotate = (1 - Math.Min(open, 1)) * -0.35; + var transform = + Matrix.CreateTranslation(-center.X, -center.Y) * + Matrix.CreateRotation(rotate) * + Matrix.CreateScale(scale * (1 + hover * 0.025), scale * (1 + hover * 0.025)) * + Matrix.CreateTranslation(center.X + offset.X, center.Y + offset.Y) * + SliceFlipTransform(false, i); + + using (context.PushTransform(transform)) + using (context.PushOpacity(Math.Clamp(open, 0, 1) * (i == _dimmedIndex ? 0.3 : 1))) + { + var wedge = CreateWedge(center, r1, r2, mid, span); + context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity * (item.IsEnabled ? 1 : 0.55)), BorderPen(), wedge); + + // The accent fades in and out with the highlight (no floor: a floor made it vanish with a jump). + var emphasis = i == _dropTargetIndex ? 1.0 : hover; + if (emphasis > 0.01 && item.IsEnabled) + context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(emphasis, 0, 1)), null, wedge); + + var hot = (i == ActiveIndex || i == _dropTargetIndex) && item.IsEnabled; + DrawLabel(context, item, center, (r1 + r2) / 2, mid, span * (r1 + r2) / 2 - 10, + r2 - r1 - 16, typeface, typefaceRegular, hot); + } + } + + if (_oldFace is { } old) + for (var i = 0; i < old.Length; i++) + { + var slice = old[i]; + if (SliceFlipWidth(true, i) == 0 || slice.Open <= 0.001) + continue; + var offset = DonutGeometry.PointAt(new Point(0, 0), slice.Hover * PopOut, slice.Mid); + var scale = 0.55 + 0.45 * slice.Open; + var rotate = (1 - Math.Min(slice.Open, 1)) * -0.35; + var transform = Matrix.CreateTranslation(-center.X, -center.Y) * Matrix.CreateRotation(rotate) * + Matrix.CreateScale(scale * (1 + slice.Hover * 0.025), scale * (1 + slice.Hover * 0.025)) * + Matrix.CreateTranslation(center.X + offset.X, center.Y + offset.Y) * + SliceFlipTransform(true, i); + using (context.PushTransform(transform)) + using (context.PushOpacity(Math.Clamp(slice.Open, 0, 1) * (slice.Dimmed ? 0.3 : 1))) + { + var wedge = CreateWedge(center, r1, r2, slice.Mid, slice.Span); + context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity * (slice.Item.IsEnabled ? 1 : 0.55)), BorderPen(), wedge); + if (slice.Emphasis > 0.01 && slice.Item.IsEnabled) + context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(slice.Emphasis, 0, 1)), null, wedge); + DrawLabel(context, slice.Item, center, (r1 + r2) / 2, slice.Mid, + slice.Span * (r1 + r2) / 2 - 10, r2 - r1 - 16, typeface, typefaceRegular, + slice.Hot && slice.Item.IsEnabled); + } + } + + if (IsEditMode && _previewSpan.Value > 0.01) + DrawPreview(context, center, r1, r2, typeface, typefaceRegular); + } + + private void DrawSoftShadow(DrawingContext context, Point center, double r2) + { + // A radial gradient ring fakes a soft drop shadow without blur effects (cheap on any GPU). + var shadowRadius = r2 + 14; + var brush = new RadialGradientBrush + { + GradientStops = + { + new GradientStop(Color.FromArgb(0, 0, 0, 0), 0), + new GradientStop(Color.FromArgb(0, 0, 0, 0), (r2 - 18) / shadowRadius), + new GradientStop(Color.FromArgb(46, 0, 0, 0), (r2 - 2) / shadowRadius), + new GradientStop(Color.FromArgb(0, 0, 0, 0), 1), + }, + }; + var opening = _open.Length == 0 ? 1 : Math.Clamp(_open.Average(), 0, 1); + using (context.PushOpacity(opening * ShadowCoverage)) + context.DrawEllipse(brush, null, new Point(center.X, center.Y + 4), shadowRadius, shadowRadius); + } + + private void DrawCenter(DrawingContext context, Point center, double r1, Typeface bold, Typeface regular) + { + var pulse = 1 + _centerPulse.Value * 0.06; + var radius = (r1 - 6) * pulse; + var cancelHot = _externalCenterHot; + context.DrawEllipse(CenterBrush, BorderPen(), center, radius, radius); + if (cancelHot) + context.DrawEllipse(WithOpacity(DangerBrush, 0.18), null, center, radius, radius); + + var maxWidth = radius * 1.6; + var title = MakeText(CenterTitle ?? "", bold, 14, Foreground, maxWidth, maxLines: 2); + var subtitle = MakeText(CenterSubtitle ?? "", regular, 11.5, cancelHot ? DangerBrush : SecondaryForeground, maxWidth, maxLines: 2); + // "⟳ Tab back": small, under the rest, a little narrower (the hub is round), with room for its icons. + var hintIcons = FlipArrowSize + (CenterHintWheel ? MouseWidth + MouseGap : 0) + IconGap; + var hint = string.IsNullOrEmpty(CenterHint) ? null : MakeText(CenterHint, regular, 10.5, SecondaryForeground, maxWidth * 0.85 - hintIcons); + var total = title.Height + (string.IsNullOrEmpty(CenterSubtitle) ? 0 : subtitle.Height + 2) + (hint is null ? 0 : hint.Height + 5); + var y = center.Y - total / 2; + DrawCentered(context, title, center.X, y); + y += title.Height; + if (!string.IsNullOrEmpty(CenterSubtitle)) + { + DrawCentered(context, subtitle, center.X, y + 2); + y += subtitle.Height + 2; + } + if (hint is not null) + { + using (context.PushOpacity(0.85)) + { + // The flip arrow is drawn, not typed: a font without "↻" showed a box in its place (#554). A small mouse + // comes first when the wheel flips and no key does. The icons stand at the start of the line: on its + // left, or on its right in a right-to-left language, in reading order there too. + var left = center.X - (hintIcons + hint.Width) / 2; + var rtl = TextDirection == FlowDirection.RightToLeft; + var textLeft = rtl ? left : left + hintIcons; + var middle = y + 5 + hint.Height / 2; + var x = rtl ? left + hint.Width + IconGap : left; + var arrowFirst = rtl && CenterHintWheel; + if (CenterHintWheel && !arrowFirst) + { + DrawMouse(context, new Rect(x, middle - 5, MouseWidth, 10), SecondaryForeground); + x += MouseWidth + MouseGap; + } + DrawFlipArrow(context, new Point(x + FlipArrowSize / 2, middle), FlipArrowSize / 2, SecondaryForeground); + x += FlipArrowSize + (arrowFirst ? MouseGap : IconGap); + if (arrowFirst) + DrawMouse(context, new Rect(x, middle - 5, MouseWidth, 10), SecondaryForeground); + DrawCentered(context, hint, textLeft + hint.Width / 2, y + 5); + } + } + } + + // The hub hint's icons: the flip arrow, the mouse (wheel only) and the room between them and before the text. The + // arrow's head reaches a little past its circle towards the mouse, so that gap is wider. + private const double FlipArrowSize = 9, MouseWidth = 7, IconGap = 4, MouseGap = 6; + + /// + /// A small arrow turning clockwise round , the "flip" in the hub hint: most of a circle with + /// its head at the end. + /// + internal static void DrawFlipArrow(DrawingContext context, Point center, double radius, IBrush? brush) + { + var pen = new Pen(brush, 1.2, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round); + // Angles in radians from 3 o'clock, growing clockwise on screen (y points down). From 1 o'clock almost round to + // 11 o'clock, leaving a gap at the top where the head points into. + const double start = -Math.PI / 3, sweep = Math.PI * 5 / 3; + Point At(double angle) => new(center.X + radius * Math.Cos(angle), center.Y + radius * Math.Sin(angle)); + var end = start + sweep; + var arc = new StreamGeometry(); + using (var g = arc.Open()) + { + g.BeginFigure(At(start), false); + g.ArcTo(At(end), new Size(radius, radius), 0, sweep > Math.PI, SweepDirection.Clockwise); + g.EndFigure(false); + } + context.DrawGeometry(null, pen, arc); + + // The head: two strokes back from the end, along the circle's tangent there and either side of it. + var tip = At(end); + var along = new Vector(-Math.Sin(end), Math.Cos(end)); // clockwise + var outward = new Vector(Math.Cos(end), Math.Sin(end)); + var head = radius * 0.75; + var back = tip - along * head; + context.DrawLine(pen, back + outward * head * 0.6, tip); + context.DrawLine(pen, back - outward * head * 0.6, tip); + } + + /// An outline of a mouse with its wheel, filling . + private static void DrawMouse(DrawingContext context, Rect box, IBrush? brush) + { + var pen = new Pen(brush, 1.1); + context.DrawRectangle(null, pen, box, box.Width / 2, box.Width / 2); + context.DrawLine(pen, new Point(box.Center.X, box.Top + 2), new Point(box.Center.X, box.Top + 4.5)); + } + + private void DrawLabel(DrawingContext context, DonutItem item, Point center, double radius, double angle, + double maxWidth, double maxHeight, Typeface bold, Typeface regular, bool hot) + { + var foreground = hot ? OnAccentBrush : item.IsEnabled ? Foreground : SecondaryForeground; + var width = Math.Clamp(maxWidth, 30, 120); + var text = KeepKoreanWords(item.Label); + var label = MakeText(text, bold, LabelSize(text, bold, width), foreground, width, maxLines: 2); + var showCaption = !string.IsNullOrEmpty(item.Caption) && + !string.Equals(item.Caption, item.Label, StringComparison.OrdinalIgnoreCase) && + label.Height + 14 < maxHeight; + var caption = showCaption + ? MakeCaption(item.Caption!, regular, hot ? OnAccentBrush : SecondaryForeground, width, label.Height, maxHeight) + : null; + + var total = label.Height + (caption?.Height ?? 0); + var anchor = DonutGeometry.PointAt(center, radius, angle); + var y = anchor.Y - total / 2; + DrawCentered(context, label, anchor.X, y); + if (caption is not null) + DrawCentered(context, caption, anchor.X, y + label.Height); + } + + /// + /// A slice's caption under its name: one line, or two where the slice is deep enough, so a caption wider than the + /// slice ("Same format" on a small donut) wraps instead of ending in "Same for…". + /// + internal static FormattedText MakeCaption(string caption, Typeface typeface, IBrush? brush, double width, + double labelHeight, double maxHeight) => + MakeText(caption, typeface, 10.5, brush, width, maxLines: labelHeight + 28 < maxHeight ? 2 : 1); + + private const double LabelFontSize = 13; + private const double MinLabelFontSize = 10; + private readonly Dictionary<(string Text, Typeface Typeface, double Width), double> _labelSizes = new(); + + /// + /// The size a slice's name is drawn at: 13, or smaller (down to 10) when one of its words is wider than the slice + /// (), which would otherwise break in the middle ("Grayscal|e" on a thin slice). Words in + /// Chinese or Japanese are left out: those break between any two characters. + /// + internal double LabelSize(string text, Typeface typeface, double width) + { + var line = Math.Round(width); + var key = (text, typeface, line); + if (_labelSizes.TryGetValue(key, out var size)) + return size; + if (_labelSizes.Count >= MaxCachedWedges) + _labelSizes.Clear(); + var widest = text.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(word => !word.Any(IsHanOrKana)) + .Select(word => new FormattedText(word, CultureInfo.CurrentUICulture, TextDirection, typeface, LabelFontSize, null).Width) + .DefaultIfEmpty(0) + .Max(); + // A pixel to spare: a word exactly as wide as the line still wraps. + size = widest < line ? LabelFontSize : Math.Max(MinLabelFontSize, LabelFontSize * (line - 1) / widest); + return _labelSizes[key] = size; + } + + /// Chinese characters, kana and the CJK symbols and full-width forms: a line may break between any two. + private static bool IsHanOrKana(char c) => + c is >= '\u2E80' and <= '\u312F' or >= '\u3190' and <= '\u9FFF' or >= '\uF900' and <= '\uFAFF' or >= '\uFF00' and <= '\uFFEF'; + + /// + /// Korean is written with spaces between words and wraps there, but the text layout also breaks between any two + /// Hangul syllables ("ZIP 하나|로"). A word joiner between them keeps each word on one line. + /// + internal static string KeepKoreanWords(string text) + { + if (!text.Any(IsHangul)) + return text; + var kept = new System.Text.StringBuilder(text.Length * 2); + for (var i = 0; i < text.Length; i++) + { + kept.Append(text[i]); + if (i + 1 < text.Length && IsHangul(text[i]) && IsHangul(text[i + 1])) + kept.Append('\u2060'); + } + return kept.ToString(); + } + + private static bool IsHangul(char c) => c is >= '\uAC00' and <= '\uD7A3'; + + private Point EditButtonCenter(int index) + { + var mid = DonutGeometry.MidAngle(index, Count); + return DonutGeometry.PointAt(Center, OuterRadius - 14 + PopOut, mid); + } + + private void DrawEditButton(DrawingContext context, Point at) + { + context.DrawEllipse(AccentBrush, new Pen(CenterBrush, 2), at, 12, 12); + var bounds = PencilIcon.Bounds; + var s = 13 / Math.Max(bounds.Width, bounds.Height); + var matrix = Matrix.CreateTranslation(-bounds.Center.X, -bounds.Center.Y) * Matrix.CreateScale(s, s) * Matrix.CreateTranslation(at.X, at.Y); + using (context.PushTransform(matrix)) + context.DrawGeometry(OnAccentBrush, null, PencilIcon); + } + + /// + /// Draws text horizontally centred on . + /// The text is laid out with inside a box of , + /// so the box (not the measured ) must be centred — otherwise short labels + /// drift right by (box − text) / 2. + /// + internal static void DrawCentered(DrawingContext context, FormattedText text, double centerX, double top) => + context.DrawText(text, new Point(centerX - text.MaxTextWidth / 2, top)); + + private Pen? BorderPen() => SliceBorderBrush is null ? null : new Pen(SliceBorderBrush, 1); + + private static FormattedText MakeText(string text, Typeface typeface, double size, IBrush? brush, double maxWidth, int maxLines = 1) => + new(text, CultureInfo.CurrentUICulture, TextDirection, typeface, size, brush) + { + MaxTextWidth = Math.Max(10, maxWidth), + MaxLineCount = maxLines, + Trimming = TextTrimming.CharacterEllipsis, + TextAlignment = TextAlignment.Center, + }; + + private static FlowDirection TextDirection => CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft + ? FlowDirection.RightToLeft : FlowDirection.LeftToRight; + + private static IBrush? WithOpacity(IBrush? brush, double opacity) => brush switch + { + ISolidColorBrush solid => new SolidColorBrush(solid.Color, solid.Opacity * opacity), + _ => brush, + }; + + private static double Distance(Point a, Point b) => Math.Sqrt(Math.Pow(a.X - b.X, 2) + Math.Pow(a.Y - b.Y, 2)); + + /// The translucent slice where a dragged preset would land, with its name. + private void DrawPreview(DrawingContext context, Point center, double r1, double r2, Typeface bold, Typeface regular) + { + var mid = _previewMid.Value; + var span = _previewSpan.Value; + var wedge = CreateWedge(center, r1, r2, mid, span); + var outline = new Pen(WithOpacity(AccentBrush, 0.9), 1.5, new DashStyle([4, 3], 0)); + context.DrawGeometry(WithOpacity(AccentBrush, 0.22), outline, wedge); + if (_previewLabel is null || span < 0.25) + return; + using (context.PushOpacity(Math.Clamp((span - 0.25) / 0.3, 0, 1) * 0.9)) + DrawLabel(context, new DonutItem { Id = "", Label = _previewLabel }, center, (r1 + r2) / 2, mid, + span * (r1 + r2) / 2 - 10, r2 - r1 - 16, bold, regular, hot: false); + } + + /// + /// How round the slices' corners are: half the theme's softness (0 to 28), so the largest setting still leaves the + /// slices clearly apart. shrinks it for slices too thin for it. + /// + internal double SliceCornerRadius => Math.Clamp(CornerRadius.TopLeft, 0, 28) * 0.5; + + private StreamGeometry CreateWedge(Point c, double r1, double r2, int index, int count) => + count == 1 ? CreateRing(c, r1, r2) : CreateWedge(c, r1, r2, DonutGeometry.MidAngle(index, count), DonutGeometry.Span(count)); + + /// A rounded slice of radians centred on (a ring when it is all of it). + private StreamGeometry CreateWedge(Point c, double r1, double r2, double mid, double span) => + CreateWedge(c, r1, r2, mid, span, SliceCornerRadius); + + // Slices are drawn every frame while the donut animates, but their shapes only change with the size, the layout + // or the softness: the shapes are kept and reused. Animations move them with transforms; only the edit mode's + // sliding slices make new ones, and the cache starts over when it fills up. + private readonly Dictionary<(Point C, double R1, double R2, double Mid, double Span, double Corner), StreamGeometry> _wedges = new(); + private const int MaxCachedWedges = 256; + + private StreamGeometry CreateWedge(Point c, double r1, double r2, double mid, double span, double cornerRadius) + { + var key = (c, r1, r2, mid, span, cornerRadius); + if (_wedges.TryGetValue(key, out var cached)) + return cached; + if (_wedges.Count >= MaxCachedWedges) + _wedges.Clear(); + return _wedges[key] = BuildWedge(c, r1, r2, mid, span, cornerRadius); + } + + /// A slice with a constant-width gap to its neighbours and corners rounded by . + private static StreamGeometry BuildWedge(Point c, double r1, double r2, double mid, double span, double cornerRadius) + { + if (span >= 2 * Math.PI - 0.001) + return CreateRing(c, r1, r2); + + var outline = DonutGeometry.Wedge(c, r1, r2, mid, span, GapPixels, cornerRadius); + var geometry = new StreamGeometry(); + using var ctx = geometry.Open(); + ctx.BeginFigure(outline.Start, true); + foreach (var step in outline.Steps) + { + if (step.IsArc) + ctx.ArcTo(step.To, new Size(step.ArcRadius, step.ArcRadius), 0, step.IsLargeArc, + step.Clockwise ? SweepDirection.Clockwise : SweepDirection.CounterClockwise); + else + ctx.LineTo(step.To); + } + ctx.EndFigure(true); + return geometry; + } + + /// Full ring (used for a single slice and for the empty donut). + private static StreamGeometry CreateRing(Point c, double r1, double r2) + { + var geometry = new StreamGeometry(); + using var ctx = geometry.Open(); + ctx.SetFillRule(FillRule.EvenOdd); + foreach (var r in new[] { r2, r1 }) + { + ctx.BeginFigure(new Point(c.X, c.Y - r), true); + ctx.ArcTo(new Point(c.X, c.Y + r), new Size(r, r), 0, false, SweepDirection.Clockwise); + ctx.ArcTo(new Point(c.X, c.Y - r), new Size(r, r), 0, false, SweepDirection.Clockwise); + ctx.EndFigure(true); + } + return geometry; + } +} diff --git a/RadialController.cs b/RadialController.cs new file mode 100644 index 000000000..bfcd335ba --- /dev/null +++ b/RadialController.cs @@ -0,0 +1,645 @@ +// Opens, fills and closes the live donut toolbar. +// +// Drag mode (modifier + drag in Explorer): +// gesture detected → an invisible window waits at the cursor (not activated) → DragEnter brings the file list → +// the donut pops out → drop on a slice converts / drop in the hole or release elsewhere closes. Without files +// (text, a rubber-band selection) nothing ever shows. +// Files from a web browser (a picture, a link to a PDF) stand as placeholder paths named like the files will be, so the +// donut shows their slices at once; they are saved when the conversion starts (WebDrop, ConversionService). +// Click mode (keyboard shortcut, context menu, drop zone): +// files are known up front → donut appears activated → click a slice to convert, right-click to edit, +// Esc / click outside closes. +// The flip key (Tab) and the mouse wheel flip the open donut to its other face (DonutMenu.Flip.cs): in click mode the +// focused window gets them, in drag mode (no focus during an OS drag) the global hook reports them while the donut +// shows files. +// After a drop / click the donut becomes a progress ring at the same spot (DonutMenu.Progress.cs): +// finished quickly → ✓ pops up in the centre, then the ring fades out; +// still running after HandOffAfter, failed, Esc, click elsewhere or a new gesture → the toast takes over. + +using System.ComponentModel; +using Avalonia; +using Avalonia.Threading; +using Filee.App.Controls; +using Filee.App.Services.Triggers; +using Filee.App.ViewModels; +using Filee.App.Views; +using Filee.Core.Conversion; +using Filee.Core.Platform; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; +using Filee.Core.Web; +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +public sealed class RadialController( + RadialViewModel viewModel, + ConversionService conversions, + WindowService windows, + UserDataStore store, + IPlatformServices platform, + TriggerService triggers, + ILogger log) +{ + /// + /// How long the ring shows a running job before the toast window takes over. Long enough for a typical office + /// document (LibreOffice needs a few seconds), so the user sees it finish where they dropped it. + /// + private static readonly TimeSpan HandOffAfter = TimeSpan.FromSeconds(5); + + private RadialWindow? _window; + private bool _visible; + private bool _dropped; + private bool _editing; + private JobViewModel? _progressJob; // job currently shown by the ring (null once finished / handed off) + private JobViewModel? _lastRingJob; // for "click the ✓ to open the folder" + private IDisposable? _handOffTimer; + private int _dragGeneration; + private bool _dragReachedDonut; + private bool _fileDragsAccepted; + private readonly ActivationGrace _grace = new(); + + /// A gesture over something that isn't a file drag (a rubber-band selection) is common: a line now and then. + private static readonly LogThrottle Throttle = new(TimeSpan.FromMinutes(1)); + private bool _converted; // a conversion started from the files on the donut now + private readonly HashSet _formatRequestsOffered = new(StringComparer.OrdinalIgnoreCase); + private WebDrop? _web; // the browser drop the click-mode donut's files stand for, until a conversion takes it + + /// + /// How long a drag that never brought files may keep the invisible window up, in case the button release is + /// missed (the system dropped the input hook, a UAC prompt took the desktop): the window catches clicks. + /// + private static readonly TimeSpan EmptyDragLimit = TimeSpan.FromSeconds(10); + + public bool IsVisible => _visible; + + internal RadialWindow? Window => _window; + + /// + /// Drag gesture started at the given physical screen point. The window is placed under the cursor but stays + /// invisible until files are dragged into it (FilesEntered); a gesture without files (text, a rubber-band + /// selection, nothing at all) never shows anything and closes when the button is released. + /// + public void ShowForDrag(int x, int y) + { + ReleaseProgressToToast(); + _grace.Cancel(); + ReplaceWeb(null); + var window = EnsureWindow(); + // A ring from an earlier conversion (finished or handed to the toast) must not keep this gesture's window up. + window.DonutControl.LeaveProgressMode(); + _dropped = false; + _dragReachedDonut = false; + _converted = false; + viewModel.ResetForDrag(); + window.DonutControl.AllowContextEdit = false; + PlaceAndShow(window, x, y, activate: false); + WatchFlipKey(); + + var generation = ++_dragGeneration; + DispatcherTimer.RunOnce(() => + { + if (generation == _dragGeneration && _visible && !viewModel.HasFiles && !viewModel.IsClickMode) + Close(animate: false); + }, EmptyDragLimit); + } + + /// + /// Opens the donut for known files (click mode). Shortcuts among them stand for the files they point to. + /// : the files came from a later launch or the Services menu, so on macOS the + /// donut may lose focus once while Filee becomes the active app. : the files are placeholders + /// of this browser drop (the drop zone), which the donut now owns. + /// + public void ShowForFiles(int x, int y, IReadOnlyList files, bool forAnotherProcess = false, WebDrop? web = null) + { + if (web is null) + files = platform.ResolveShortcuts(files); + if (files.Count == 0) + { + web?.Dispose(); + return; + } + ReleaseProgressToToast(); + ReplaceWeb(web); + if (forAnotherProcess && OperatingSystem.IsMacOS()) + _grace.Start(); + else + _grace.Cancel(); + var window = EnsureWindow(); + _dropped = false; + _converted = false; + viewModel.Load(files, clickMode: true); + viewModel.FitToThisDonut(); + window.DonutControl.AllowContextEdit = true; + PlaceAndShow(window, x, y, activate: true); + WatchFlipKey(); + window.DonutControl.PlayOpenAnimation(); + } + + /// The mouse button of the drag gesture was released somewhere. + public void OnDragGestureEnded() + { + if (!_visible || viewModel.IsClickMode) + return; + // No drag ever entered the donut's window: nothing was dragged (a rubber-band selection), or Windows kept + // Explorer's drag out (Filee runs as administrator). Tells "the donut didn't react" reports apart. + if (!_dragReachedDonut && Throttle.TryEnter("no-drag-enter", out var held)) + log.LogInformation("Drag gesture ended without a drag entering the donut{Elevation}{Held}", + Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator: Windows blocks drops from Explorer)" : "", + LogThrottle.HeldText(held)); + // Nothing was brought to the donut: the window is invisible but catches clicks, so go right away. + if (!viewModel.HasFiles && !_window!.DonutControl.IsInProgressMode) + { + Close(animate: false); + return; + } + // A drop on the donut arrives around the same time as the button release; give it a moment. + DispatcherTimer.RunOnce(() => + { + if (_visible && !_dropped && !viewModel.IsClickMode) + Close(animate: true); + }, TimeSpan.FromMilliseconds(250)); + } + + public void Close(bool animate = true) + { + if (!_visible || _window is null) + return; + if (_window.DonutControl.IsInProgressMode) + { + // Esc / click elsewhere while the ring is showing: let the toast carry on (a finishing ✓ just fades). + if (_progressJob is not null) + { + HandOffToToast(); + return; + } + if (_window.DonutControl.IsFinishingProgress) + return; + // A ring left over after it finished: nothing is showing any more, so close like any donut. + _window.DonutControl.LeaveProgressMode(); + } + _visible = false; + WatchFlipKey(); + _grace.Cancel(); + ReplaceWeb(null); + var window = _window; + // A group about to open (its dwell timer running) must not open on a closing donut. + window.DonutControl.CollapseGroup(); + if (animate) + window.DonutControl.PlayCloseAnimation(() => + { + if (!_visible) + window.Hide(); + }); + else + window.Hide(); + OfferFormatRequest(); + } + + /// + /// The donut closed on files Filee can't read at all (".ncm"): offer to request the format, once per extension + /// while Filee runs. Files it reads, even mixed with unknown ones, get a conversion and the toast instead. + /// + private void OfferFormatRequest() + { + if (_converted || viewModel.Files.Count == 0 || ProfileSelector.DetectFormats(viewModel.Files).Count > 0) + return; + var extensions = FormatRequest.UnknownExtensions(viewModel.Files).Where(_formatRequestsOffered.Add).ToList(); + if (extensions.Count == 0) + return; + log.LogInformation("Donut closed on files Filee can't read: {Extensions}", FormatRequest.Label(extensions)); + windows.ShowFormatRequestNotice(extensions); + } + + private RadialWindow EnsureWindow() + { + if (_window is not null) + return _window; + + var window = new RadialWindow { DataContext = viewModel }; + var donut = window.DonutControl; + + // On native Wayland, hiding an xdg_toplevel surface causes the compositor to destroy its surface, + // and re-showing raises an InvalidOperationException. Track Closed to re-create on the next gesture. + if (Filee.Platform.Linux.WaylandSupport.NativeWaylandRequested) + { + window.Closed += (_, _) => + { + if (ReferenceEquals(_window, window)) + _window = null; + }; + } + + window.DragEntered += () => _dragReachedDonut = true; + // Tells "the drag reached the donut but brought no files" apart, with what it brought (file promises, say). + window.DragWithoutFiles += formats => + { + if (Throttle.TryEnter("drag-without-files", out var held)) + log.LogInformation("A drag without files Filee reads entered the donut; it offered: {Formats}{Held}", + formats, LogThrottle.HeldText(held)); + }; + window.FilesEntered += files => + { + if (viewModel.IsClickMode || SameFiles(files, viewModel.Files) || SameWebFiles(files, viewModel.Files)) + return; + viewModel.Load(files, clickMode: false); + donut.PlayOpenAnimation(); + WatchFlipKey(); + }; + window.Dropped += (hit, dropped) => + { + _dropped = true; + WatchFlipKey(); + if (hit.Kind is DonutHitKind.Segment or DonutHitKind.SubSegment) + { + Convert(hit.Index, hit.Sub, dropped.Files.Count > 0 ? dropped.Files : viewModel.Files, dropped.Web); + } + else + { + dropped.Web?.Dispose(); + Close(); + } + }; + // One line per drag, to tell "the drag never reached the donut" from "it did but nothing lit up" when someone + // reports a donut that doesn't react. + window.DragSummary += (events, hit, lit) => + log.LogInformation("Drag over the donut: {Events} move event(s), ended on {Kind} {Index}, drawn lit {Lit:0.00}", + events, hit.Kind, hit.Index, lit); + window.EscapePressed += () => Close(); + window.Deactivated += (_, _) => + { + if (!viewModel.IsClickMode || _editing) + return; + if (_grace.Forgives()) + { + log.LogInformation("The donut lost focus as it opened for another app's request; it stays open"); + Dispatcher.UIThread.Post(() => + { + if (_visible && viewModel.IsClickMode && !window.IsActive) + window.Activate(); + }); + return; + } + Close(); + }; + + donut.ActiveIndexChanged += (_, index) => viewModel.ShowHint(index, donut.ActiveSubIndex, donut.IsCenterHot); + // Drag mode: the flip key and the wheel seen by the global hook (click mode gets them in the focused window). + triggers.FlipKeyPressed += (_, _) => + { + if (FlipsFromHook) + donut.Flip(); + }; + triggers.FlipWheelTurned += (_, _) => + { + if (FlipsFromHook) + donut.FlipFromWheel(Environment.TickCount64); + }; + donut.SegmentInvoked += (_, index) => Convert(index, -1, viewModel.Files, TakeWeb()); + donut.SubSegmentInvoked += (_, hit) => Convert(hit.Group, hit.Sub, viewModel.Files, TakeWeb()); + donut.CenterInvoked += (_, _) => Close(); + donut.SegmentContextRequested += async (_, index) => await EditPresetAsync(index); + donut.SubSegmentContextRequested += async (_, hit) => await EditPresetAsync(hit.Group, hit.Sub); + donut.ProgressCancelRequested += (_, _) => _progressJob?.CancelCommand.Execute(null); + donut.ProgressClicked += (_, _) => + { + if (_lastRingJob is { HasOutputs: true } job) + job.OpenFolderCommand.Execute(null); + }; + + _window = window; + return window; + } + + /// + /// Converts with slice , or with slice of that group's ring. + /// : the browser drop the files stand for, handed to the conversion (or disposed). + /// + private void Convert(int index, int sub, IReadOnlyList files, WebDrop? web = null) + { + // Placeholders whose drop is gone (the drag listed files it didn't hand over in the end) can't be converted. + var orphaned = web is null && files.Any(WebDrop.IsPlaceholder); + if (viewModel.PresetAt(index, sub) is not { } preset || SourcesFor(preset, files) is not { Count: > 0 } sources || orphaned) + { + web?.Dispose(); + Close(); + return; + } + + if (preset.IsTool) + { + var near = _window?.Position; + Close(animate: false); + if (web is null) + windows.ShowTool(sources, preset, near); + else + _ = ShowToolForBrowserFilesAsync(web, preset, near); + return; + } + + // Merged PDFs, joined clips, slideshows and merged pictures follow the order the user picks. + if ((preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle || preset.JoinsIntoOneMedia || preset.JoinsImages) && sources.Count > 1) + { + Close(animate: false); + _ = MergeInOrderAsync(sources, preset.Clone(), web); + return; + } + + // "Ask every time" (#521): the save dialog first; the toast shows the job, the donut has closed for the dialog. + // shortcut: files from a browser aren't asked about, they save into the download folder as "next to the source". + if (preset.Output.AskEveryTime && web is null) + { + Close(animate: false); + _ = AskThenConvertAsync(sources, preset); + return; + } + + log.LogInformation("Converting {Count} file(s) with preset {Preset}{Web}", sources.Count, preset.Id, + web is null ? "" : " (from a browser)"); + var job = conversions.Start(sources, preset, showInToast: false, web); + if (job is null || _window is null) + { + Close(); + return; + } + _converted = true; + ShowProgress(job); + } + + private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, WebDrop? web) + { + try + { + var ordered = await windows.ChooseMergeOrderAsync(files, preset); + if (ordered is not null && preset.JoinsImages) + RememberMergeLayout(preset); + if (ordered is not null && preset.Output.AskEveryTime && web is null) + await AskThenConvertAsync(ordered, preset); + else if (ordered is not null) + { + conversions.Start(ordered, preset, web: web); + web = null; // the conversion owns the browser drop now + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + log.LogError(ex, "Could not start the ordered merge"); + } + finally + { + web?.Dispose(); + } + } + + /// + /// "Merge images": the layout chosen in the merge order window (, a copy of the preset) + /// goes into the preset as saved, so the window opens with it next time. + /// + private void RememberMergeLayout(Preset chosen) + { + if (store.FindPreset(chosen.Id) is not { JoinsImages: true } saved) + return; + var image = saved.Image; + if (image.JoinDirection == chosen.Image.JoinDirection && image.JoinFit == chosen.Image.JoinFit + && image.JoinGap == chosen.Image.JoinGap && image.JoinBackground == chosen.Image.JoinBackground) + return; + image.JoinDirection = chosen.Image.JoinDirection; + image.JoinFit = chosen.Image.JoinFit; + image.JoinGap = chosen.Image.JoinGap; + image.JoinBackground = chosen.Image.JoinBackground; + try + { + store.SaveLibrary(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log.LogWarning(ex, "Could not remember the layout of merged pictures"); + } + } + + private async Task AskThenConvertAsync(IReadOnlyList sources, Preset preset) + { + try + { + // Asked as it will run (a ZIP from Settings › General is a .zip), for one file or a folder as the job makes them. + var running = store.Settings.WithDefaultOutput(preset); + if (await windows.AskSaveLocationAsync(sources, running, AskedLocation.MakesOneResult(running, sources.Count)) is { } asked) + { + log.LogInformation("Converting {Count} file(s) with preset {Preset} where the user chose", sources.Count, preset.Id); + conversions.Start(sources, asked); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + log.LogError(ex, "Could not ask where to save"); + } + } + + /// A tool (crop, clip) on files from a browser: they are saved first, then it opens on them. + private async Task ShowToolForBrowserFilesAsync(WebDrop web, Filee.Core.Presets.Preset preset, PixelPoint? near) + { + var files = await conversions.SaveBrowserFilesAsync(web, (placeholder, ex) => + log.LogWarning(ex, "Saving {File} from a browser for {Preset} failed", Path.GetFileName(placeholder), preset.Id)); + if (files.Count > 0) + windows.ShowTool(files, preset, near); + } + + /// + /// What converts of : folders only go into "Compress into one + /// archive"; any other preset converts the files beside them (issue #186). + /// + internal static IReadOnlyList SourcesFor(Preset preset, IReadOnlyList files) => + preset.PacksIntoOneArchive ? files : [.. files.Where(path => !Directory.Exists(path))]; + + // ───────── Progress ring ───────── + + private void ShowProgress(JobViewModel job) + { + var donut = _window!.DonutControl; + _dropped = true; + _progressJob = job; + viewModel.ProgressJob = job; + _lastRingJob = job; + donut.BeginProgress(); + donut.SetProgress(job.Progress / 100); + job.PropertyChanged += OnProgressJobChanged; + _handOffTimer = DispatcherTimer.RunOnce(() => + { + if (_progressJob == job && job.IsRunning) + HandOffToToast(); + }, HandOffAfter); + } + + private void OnProgressJobChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not JobViewModel job || job != _progressJob || _window is null) + return; + var donut = _window.DonutControl; + donut.SetProgress(job.Progress / 100); + if (job.IsRunning || e.PropertyName != nameof(JobViewModel.IsRunning)) + return; + + var outcome = job.Job.State switch + { + JobState.Completed => DonutProgressOutcome.Succeeded, + JobState.Cancelled => DonutProgressOutcome.Cancelled, + _ => DonutProgressOutcome.Failed, + }; + if (outcome == DonutProgressOutcome.Failed) + conversions.ShowInToast(job); // the toast explains what went wrong + DetachProgress(); + donut.CompleteProgress(outcome, HideWindow); + } + + /// Hands the running job to the toast with a short "shrink away" of the ring. + private void HandOffToToast() + { + if (_progressJob is null || _window is null) + return; + conversions.ShowInToast(_progressJob); + DetachProgress(); + _window.DonutControl.HandOffProgress(HideWindow); + } + + /// A new gesture needs the window right now: move the job to the toast without animation. + private void ReleaseProgressToToast() + { + if (_progressJob is not null) + conversions.ShowInToast(_progressJob); + DetachProgress(); + } + + private void DetachProgress() + { + _handOffTimer?.Dispose(); + _handOffTimer = null; + if (_progressJob is not null) + _progressJob.PropertyChanged -= OnProgressJobChanged; + _progressJob = null; + viewModel.ProgressJob = null; + } + + private void HideWindow() + { + _visible = false; + WatchFlipKey(); + _window?.DonutControl.CollapseGroup(); + _window?.Hide(); + } + + /// Edits the preset of slice , or of slice of its group's ring. + private async Task EditPresetAsync(int index, int sub = -1) + { + if (_window is null || viewModel.PresetShownAt(index, sub) is not { } preset) + return; + _editing = true; + try + { + if (await windows.EditPresetAsync(preset, _window)) + { + store.SaveLibrary(); + viewModel.Load(viewModel.Files, viewModel.IsClickMode, viewModel.IsBackFace); + } + } + finally + { + _editing = false; + } + if (_visible) + _window.Activate(); + } + + /// + /// Has the global hook report the flip key and the wheel only while a drag-mode donut shows files that could flip; + /// they are left alone otherwise (see ). + /// + private void WatchFlipKey() + { + triggers.WatchFlipKey = FlipsFromHook; + // One source per press: the hook in drag mode, the window in click mode (#504). + if (_window is not null) + _window.DonutControl.FlipKeyFromHook = FlipsFromHook; + } + + /// A drag-mode donut shows files and nothing was dropped yet: the hook's flip key and wheel flip it. + private bool FlipsFromHook => _visible && !_dropped && !viewModel.IsClickMode && viewModel.HasFiles; + + /// + /// Top-left corner of a square window centred on , moved into + /// when that would cross its edge. With group slices the window holds the ring they fan out + /// (), so near a screen edge the donut moves in far enough for every ring to fit. + /// + internal static PixelPoint WindowPosition(PixelPoint cursor, PixelRect? area, int sidePx) + { + var position = new PixelPoint(cursor.X - sidePx / 2, cursor.Y - sidePx / 2); + if (area is not { } a) + return position; + return new PixelPoint( + Math.Clamp(position.X, a.X, Math.Max(a.X, a.Right - sidePx)), + Math.Clamp(position.Y, a.Y, Math.Max(a.Y, a.Bottom - sidePx))); + } + + private void PlaceAndShow(RadialWindow window, int x, int y, bool activate) + { + var cursor = new PixelPoint(x, y); + var screen = window.Screens.ScreenFromPoint(cursor) ?? window.Screens.Primary; + var scaling = OperatingSystem.IsMacOS() ? 1 : screen?.Scaling ?? 1; + + // Window size follows the donut size (SizeToContent); compute it up front to centre on the cursor. + var side = DonutMenu.SideFor(viewModel.OuterRadius, viewModel.ReserveSubRing); + var sidePx = (int)Math.Ceiling(side * scaling); + var position = WindowPosition(cursor, screen?.WorkingArea, sidePx); + + Motion.Refresh(); + window.Position = position; + if (!window.IsVisible) + window.Show(); + window.Position = position; // again, in case showing changed the DPI scale + // Showing a window that is already marked topmost does not move it in the z-order, and Windows can push it + // below normal windows while it keeps the mark (after the Photos app opened, for one). Then Explorer stays on + // top of the donut and the drop never reaches it, so put it back on top every time. + if (window.TryGetPlatformHandle()?.Handle is { } handle) + { + platform.RaiseTopmost(handle); + if (!_fileDragsAccepted && OperatingSystem.IsMacOS() && platform is Filee.Platform.MacOS.MacOSPlatformServices mac) + { + mac.AcceptFileDrags(handle); + _fileDragsAccepted = true; + } + } + _visible = true; + if (activate) + { + window.Activate(); + // ShowActivated is false because the same window is also used during an OS drag, where taking focus would + // break Explorer's drag. Click mode is different: the donut owns the keyboard until it closes, so focus it + // explicitly for Tab/arrow/Enter handling. + window.DonutControl.Focus(); + } + } + + private static bool SameFiles(IReadOnlyList a, IReadOnlyList b) => + a.Count == b.Count && a.SequenceEqual(b, FileSystemPaths.Comparer); + + /// The same browser files dragged in again: each entry makes new placeholders, with the same names. + private static bool SameWebFiles(IReadOnlyList a, IReadOnlyList b) => + a.Count == b.Count && a.All(WebDrop.IsPlaceholder) && b.All(WebDrop.IsPlaceholder) + && a.Select(Path.GetFileName).SequenceEqual(b.Select(Path.GetFileName), StringComparer.Ordinal); + + /// Hands the click-mode donut's browser drop to a conversion. + private WebDrop? TakeWeb() + { + var web = _web; + _web = null; + return web; + } + + /// The click-mode donut now stands for 's files (or local ones): the old drop is done. + private void ReplaceWeb(WebDrop? web) + { + if (!ReferenceEquals(_web, web)) + _web?.Dispose(); + _web = web; + } +} diff --git a/RadialControllerTests.cs b/RadialControllerTests.cs new file mode 100644 index 000000000..ede1551c7 --- /dev/null +++ b/RadialControllerTests.cs @@ -0,0 +1,103 @@ +// The drag-wait donut is an invisible, topmost window that catches clicks so a drag can reach it: it must close when +// the gesture ends without files, even after a conversion left the ring behind (issue: desktop icons stopped reacting +// to clicks until Filee quit). + +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Filee.App.Controls; +using Filee.App.Services; +using Filee.Core.Presets; + +namespace Filee.App.Tests; + +public class RadialControllerTests +{ + /// A conversion ran through the donut and its ring finished: progress mode stays on, nothing is pending. + private static void FinishARing(DonutMenu donut) + { + donut.BeginProgress(); + Motion.Set(false); + try + { + donut.HandOffProgress(() => { }); + } + finally + { + Motion.Set(true); + } + Assert.True(donut.IsInProgressMode); + Assert.False(donut.IsFinishingProgress); + } + + [AvaloniaFact] + public void A_drag_without_files_after_a_conversion_closes_the_invisible_window() + { + TestServices.EnsureInitialized("en"); + var radial = AppHost.Get(); + radial.ShowForDrag(400, 400); + FinishARing(radial.Window!.DonutControl); + + // A later Ctrl+drag that brings no files: a rubber-band selection on the desktop, a folder. + radial.ShowForDrag(600, 500); + Assert.False(radial.Window.DonutControl.IsInProgressMode); + radial.OnDragGestureEnded(); + + Assert.False(radial.IsVisible); + Assert.False(radial.Window.IsVisible); + } + + [AvaloniaFact] + public void Esc_closes_a_donut_whose_ring_already_finished() + { + TestServices.EnsureInitialized("en"); + var radial = AppHost.Get(); + radial.ShowForDrag(400, 400); + FinishARing(radial.Window!.DonutControl); + + radial.Close(animate: false); + + Assert.False(radial.IsVisible); + Assert.False(radial.Window.IsVisible); + } + + [AvaloniaFact] + public void Click_mode_focuses_the_donut_for_tab_flip() + { + TestServices.EnsureInitialized("en"); + var radial = AppHost.Get(); + try + { + radial.ShowForFiles(400, 400, [@"C:\photos\a.jpg"]); + var window = radial.Window!; + + Assert.True(window.DonutControl.IsFocused); + window.KeyPress(Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); + Assert.True(AppHost.Get().IsBackFace); + } + finally + { + radial.Close(animate: false); + } + } + + [Fact] + public void Folders_only_go_into_one_archive() + { + var folder = Directory.CreateTempSubdirectory("filee-donut-folder-").FullName; + try + { + var file = Path.Combine(folder, "1.png"); + var zip = new Preset { TargetFormat = "zip", Archive = { CombineIntoOne = true } }; + + // Issue #186: a dragged folder goes into "Compress into one archive"; other presets take only the files. + Assert.Equal([folder, file], RadialController.SourcesFor(zip, [folder, file])); + Assert.Equal([file], RadialController.SourcesFor(new Preset { TargetFormat = "jpg" }, [folder, file])); + Assert.Empty(RadialController.SourcesFor(new Preset { TargetFormat = "zip" }, [folder])); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } +} From 9bd63787c3b76faf7e84201a10496501bc347ec9 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Thu, 29 Oct 2026 12:59:07 +0900 Subject: [PATCH 2/2] Move the files into the folders they belong in DonutMenu.cs, RadialController.cs and RadialControllerTests.cs were added at the repository root next to the unchanged originals, so the build never saw the change. They now replace src/Filee.App/Controls/DonutMenu.cs, src/Filee.App/Services/RadialController.cs and tests/Filee.App.Tests/RadialControllerTests.cs. Co-Authored-By: Claude Opus 5.5 --- DonutMenu.cs | 1252 ----------------- RadialController.cs | 645 --------- RadialControllerTests.cs | 103 -- src/Filee.App/Controls/DonutMenu.cs | 5 + src/Filee.App/Services/RadialController.cs | 6 + .../Filee.App.Tests/RadialControllerTests.cs | 22 + 6 files changed, 33 insertions(+), 2000 deletions(-) delete mode 100644 DonutMenu.cs delete mode 100644 RadialController.cs delete mode 100644 RadialControllerTests.cs diff --git a/DonutMenu.cs b/DonutMenu.cs deleted file mode 100644 index 04b6ff6fb..000000000 --- a/DonutMenu.cs +++ /dev/null @@ -1,1252 +0,0 @@ -// The donut toolbar: a ring completely divided into equal slices, one per preset. -// Used both as the live toolbar (RadialWindow) and as the editor in Settings → Donut toolbar (IsEditMode). -// -// Drawn by hand (no child controls) so it stays fast on low-end PCs and so every slice can be hit-tested by -// its exact wedge shape. Animations are tiny springs advanced on each rendered frame; with -// Motion.Enabled == false every value jumps straight to its target. -// -// After a drop the donut turns into a progress ring: see DonutMenu.Progress.cs. Group slices, which fan out a ring -// of presets, and the keyboard: DonutMenu.Groups.cs. Tab (or the wheel) flips the donut over: DonutMenu.Flip.cs. - -using System.Globalization; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Documents; -using Avalonia.Input; -using Avalonia.Media; -using Avalonia.Threading; -using Filee.App.Services; - -namespace Filee.App.Controls; - -/// One slice of the donut. -public sealed class DonutItem -{ - public required string Id { get; init; } - public required string Label { get; init; } - - /// Small second line, e.g. the target format. Hidden when equal to . - public string? Caption { get; init; } - - public bool IsEnabled { get; init; } = true; - - /// Tooltip shown for disabled slices. - public string? DisabledReason { get; init; } - - /// - /// A group slice: converts nothing itself; hovering it fans these out in a small ring outside the donut, where they - /// are clicked or dropped on like ordinary slices. - /// - public IReadOnlyList? Children { get; init; } - - /// A group slice ( set), even one left with nothing to fan out. - public bool IsGroup => Children is not null; - - /// A group with something to fan out. - public bool CanExpand => Children is { Count: > 0 }; -} - -/// Donut-shaped menu control. -public sealed partial class DonutMenu : Control -{ - public static readonly StyledProperty?> ItemsProperty = - AvaloniaProperty.Register?>(nameof(Items)); - public static readonly StyledProperty OuterRadiusProperty = - AvaloniaProperty.Register(nameof(OuterRadius), 150); - public static readonly StyledProperty HoleRatioProperty = - AvaloniaProperty.Register(nameof(HoleRatio), 0.42); - public static readonly StyledProperty SliceOpacityProperty = - AvaloniaProperty.Register(nameof(SliceOpacity), 0.96); - public static readonly StyledProperty CenterTitleProperty = - AvaloniaProperty.Register(nameof(CenterTitle)); - public static readonly StyledProperty CenterSubtitleProperty = - AvaloniaProperty.Register(nameof(CenterSubtitle)); - public static readonly StyledProperty IsEditModeProperty = - AvaloniaProperty.Register(nameof(IsEditMode)); - public static readonly StyledProperty AllowContextEditProperty = - AvaloniaProperty.Register(nameof(AllowContextEdit)); - public static readonly StyledProperty ReserveSubRingProperty = - AvaloniaProperty.Register(nameof(ReserveSubRing)); - - /// The theme's softness (Styles/Controls.axaml binds SoftRadius): rounds the corners of the slices. - public static readonly StyledProperty CornerRadiusProperty = - AvaloniaProperty.Register(nameof(CornerRadius)); - - public static readonly StyledProperty SliceBrushProperty = - AvaloniaProperty.Register(nameof(SliceBrush), Brushes.White); - public static readonly StyledProperty SliceBorderBrushProperty = - AvaloniaProperty.Register(nameof(SliceBorderBrush)); - public static readonly StyledProperty CenterBrushProperty = - AvaloniaProperty.Register(nameof(CenterBrush), Brushes.White); - public static readonly StyledProperty AccentBrushProperty = - AvaloniaProperty.Register(nameof(AccentBrush), Brushes.MediumPurple); - public static readonly StyledProperty OnAccentBrushProperty = - AvaloniaProperty.Register(nameof(OnAccentBrush), Brushes.White); - public static readonly StyledProperty ForegroundProperty = - AvaloniaProperty.Register(nameof(Foreground), Brushes.Black); - public static readonly StyledProperty SecondaryForegroundProperty = - AvaloniaProperty.Register(nameof(SecondaryForeground), Brushes.Gray); - public static readonly StyledProperty DangerBrushProperty = - AvaloniaProperty.Register(nameof(DangerBrush), Brushes.IndianRed); - - /// Gap between slices in pixels (keeps the donut "full" while separating slices). - private const double GapPixels = 3; - private const double PopOut = 7; - private const double OpenDuration = 0.42; - private const double OpenStagger = 0.028; - private const double DragStartDistance = 5; - - // Pencil (Material "edit") path in a 24×24 box. - private static readonly Geometry PencilIcon = Geometry.Parse( - "M3,17.25V21h3.75L17.81,9.94l-3.75-3.75L3,17.25z M20.71,7.04c0.39-0.39,0.39-1.02,0-1.41l-2.34-2.34c-0.39-0.39-1.02-0.39-1.41,0l-1.83,1.83l3.75,3.75L20.71,7.04z"); - - private Spring[] _hover = []; - private double[] _open = []; - private Spring _centerPulse; - private double _openClock = double.MaxValue; - private TimeSpan? _lastFrame; - private bool _animating; - private Action? _closeCallback; - private bool _closing; - - private int _hoverIndex = -1; - private int _externalHighlight = -1; - private bool _externalCenterHot; - private int _dimmedIndex = -1; - private int _dropTargetIndex = -1; - - // Edit mode drop preview: the slices make room where a dragged preset would land and a translucent slice shows - // it there. Every slice's centre angle and width is a spring, so the others slide aside instead of jumping. - private Spring[] _mid = []; - private Spring[] _span = []; - private Spring _previewMid; - private Spring _previewSpan; - private int _previewSlot = -1; - private int _previewRemoved = -1; - private string? _previewLabel; - private (int Index, Point Start, PointerPressedEventArgs Args)? _press; - - static DonutMenu() - { - AffectsMeasure(OuterRadiusProperty, ReserveSubRingProperty); - AffectsRender(HoleRatioProperty, SliceOpacityProperty, CenterTitleProperty, CenterSubtitleProperty, - IsEditModeProperty, SliceBrushProperty, SliceBorderBrushProperty, CenterBrushProperty, AccentBrushProperty, - OnAccentBrushProperty, ForegroundProperty, SecondaryForegroundProperty, SuccessBrushProperty, CornerRadiusProperty, - CenterHintProperty, CenterHintWheelProperty); - ItemsProperty.Changed.AddClassHandler((d, _) => d.OnItemsChanged()); - } - - public DonutMenu() - { - // A circle reads the same either way: slices keep their clockwise order in Arabic too, their labels are - // shaped right to left (TextDirection). - FlowDirection = FlowDirection.LeftToRight; - ClipToBounds = false; - Cursor = new Cursor(StandardCursorType.Hand); - // The live click-mode donut handles Tab, arrows, Enter and Escape itself. It must be the focused control - // after a context-menu/shortcut launch; otherwise Avalonia has no focused target to which the key events can - // be routed. It is focused explicitly by RadialController, so it must not participate in normal Tab order. - Focusable = true; - IsTabStop = false; - } - - public IReadOnlyList? Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); } - public double OuterRadius { get => GetValue(OuterRadiusProperty); set => SetValue(OuterRadiusProperty, value); } - public double HoleRatio { get => GetValue(HoleRatioProperty); set => SetValue(HoleRatioProperty, value); } - public double SliceOpacity { get => GetValue(SliceOpacityProperty); set => SetValue(SliceOpacityProperty, value); } - public string? CenterTitle { get => GetValue(CenterTitleProperty); set => SetValue(CenterTitleProperty, value); } - public string? CenterSubtitle { get => GetValue(CenterSubtitleProperty); set => SetValue(CenterSubtitleProperty, value); } - - /// Settings editor mode: shows ✎ on hover, raises drag and edit events instead of invoking slices. - public bool IsEditMode { get => GetValue(IsEditModeProperty); set => SetValue(IsEditModeProperty, value); } - - /// Live toolbar in click mode: right-click on a slice raises . - public bool AllowContextEdit { get => GetValue(AllowContextEditProperty); set => SetValue(AllowContextEditProperty, value); } - - /// - /// Leave room for a group's ring even before the slices are known (the drag window is placed before files arrive), - /// so the window doesn't grow, and the donut move, when they do. - /// - public bool ReserveSubRing { get => GetValue(ReserveSubRingProperty); set => SetValue(ReserveSubRingProperty, value); } - - public CornerRadius CornerRadius { get => GetValue(CornerRadiusProperty); set => SetValue(CornerRadiusProperty, value); } - - public IBrush? SliceBrush { get => GetValue(SliceBrushProperty); set => SetValue(SliceBrushProperty, value); } - public IBrush? SliceBorderBrush { get => GetValue(SliceBorderBrushProperty); set => SetValue(SliceBorderBrushProperty, value); } - public IBrush? CenterBrush { get => GetValue(CenterBrushProperty); set => SetValue(CenterBrushProperty, value); } - public IBrush? AccentBrush { get => GetValue(AccentBrushProperty); set => SetValue(AccentBrushProperty, value); } - public IBrush? OnAccentBrush { get => GetValue(OnAccentBrushProperty); set => SetValue(OnAccentBrushProperty, value); } - public IBrush? Foreground { get => GetValue(ForegroundProperty); set => SetValue(ForegroundProperty, value); } - public IBrush? SecondaryForeground { get => GetValue(SecondaryForegroundProperty); set => SetValue(SecondaryForegroundProperty, value); } - public IBrush? DangerBrush { get => GetValue(DangerBrushProperty); set => SetValue(DangerBrushProperty, value); } - - /// True while an external drag hovers the hole (drop = cancel). - public bool IsCenterHot => _externalCenterHot; - - /// Currently emphasised slice (pointer hover or external highlight), or -1. - public int ActiveIndex => _externalHighlight >= 0 ? _externalHighlight : _hoverIndex; - - /// A slice was clicked (live toolbar, click mode). - public event EventHandler? SegmentInvoked; - - /// A slice of a group's ring was clicked: (group, slice in the group). - public event EventHandler<(int Group, int Sub)>? SubSegmentInvoked; - - /// The hovered slice of a group's ring, or -1. is raised when it changes. - public int ActiveSubIndex => _subHover; - - /// Right-click or ✎ on a slice. - public event EventHandler? SegmentContextRequested; - - /// Right-click on a slice of a group's ring (live toolbar, click mode): (group, slice in the group). - public event EventHandler<(int Group, int Sub)>? SubSegmentContextRequested; - - /// The hole was clicked. - public event EventHandler? CenterInvoked; - - /// Edit mode: the user started dragging slice Index. - public event EventHandler<(int Index, PointerEventArgs Args)>? SegmentDragStarted; - - /// The emphasised slice changed (-1 = none). Useful to update the centre text. - public event EventHandler? ActiveIndexChanged; - - private int Count => Items?.Count ?? 0; - private Point Center => new(Bounds.Width / 2, Bounds.Height / 2); - private double InnerRadius => OuterRadius * Math.Clamp(HoleRatio, 0.2, 0.75); - - // ───────────────────────── Public API ───────────────────────── - - /// Plays the "pop out from the centre" animation. - public void PlayOpenAnimation() - { - ResetProgress(); - _closing = false; - _closeCallback = null; - if (!Motion.Enabled) - { - Array.Fill(_open, 1); - InvalidateVisual(); - return; - } - Array.Fill(_open, 0); - _openClock = 0; - StartAnimation(); - } - - /// Shrinks the donut back into the centre, then calls . - public void PlayCloseAnimation(Action done) - { - if (IsFlipping) - FinishFlip(); - if (!Motion.Enabled || Count == 0) - { - done(); - return; - } - _closing = true; - _closeCallback = done; - _openClock = 0; - StartAnimation(); - } - - /// Short "boing" of the centre, e.g. after a drop. - public void PulseCenter() - { - if (!Motion.Enabled) - return; - _centerPulse.Velocity = 6; - StartAnimation(); - } - - /// Highlights a slice from outside (drag-over in the live toolbar). -1 clears. - public void SetExternalHighlight(int index, bool centerHot = false) - { - if (_externalHighlight == index && _externalCenterHot == centerHot) - return; - _externalHighlight = index; - _externalCenterHot = centerHot; - ActiveIndexChanged?.Invoke(this, ActiveIndex); - StartAnimation(); - } - - /// How far slice is drawn lit up right now (0 to 1), for the drag log. - public double LitAmount(int index) => index >= 0 && index < _hover.Length ? _hover[index].Value : 0; - - /// Edit mode: the slice being dragged is drawn faded. - public void SetDimmedIndex(int index) - { - _dimmedIndex = index; - InvalidateVisual(); - } - - /// Edit mode: highlights where a dragged item would land (-1 none, Count = empty ring). - public void SetDropTarget(int index) - { - if (_dropTargetIndex == index) - return; - _dropTargetIndex = index; - InvalidateVisual(); - } - - /// - /// Edit mode: opens a gap at (the position in the list after the drop) and shows a - /// translucent slice labelled there. is the slice being moved, - /// which leaves its place. -1 closes the gap. - /// - public void SetDropPreview(int slot, string? label = null, int removed = -1) - { - if (_previewSlot == slot && _previewRemoved == removed && _previewLabel == label) - return; - if (_previewSlot < 0 && slot >= 0) - { - // The new slice grows out of nothing where it appears. - _previewMid = new Spring { Value = DonutGeometry.MidAngle(slot, SlotCount(removed)) }; - _previewSpan = default; - } - _previewSlot = slot; - _previewRemoved = removed; - _previewLabel = label; - StartAnimation(); - } - - /// The slot under when the donut has slices; -1 off the ring. - public int SlotAt(Point p, int slots) - { - var hit = DonutGeometry.HitTest(Center, p, InnerRadius, OuterRadius, Math.Max(slots, 1), PopOut); - return hit.Kind == DonutHitKind.Segment ? hit.Index : -1; - } - - /// Slices while previewing: the items, minus the one being moved, plus the preview. - private int SlotCount(int removed) => Count + 1 - (removed >= 0 ? 1 : 0); - - /// Where slice belongs: its usual place, or shifted to make room for the preview. - private (double Mid, double Span) LayoutTarget(int i) - { - var n = Count; - if (_previewSlot < 0) - return (DonutGeometry.MidAngle(i, n), DonutGeometry.Span(n)); - if (i == _previewRemoved) - return (i < _mid.Length ? _mid[i].Value : DonutGeometry.MidAngle(i, n), 0); // it becomes the preview - var slot = _previewRemoved >= 0 && i > _previewRemoved ? i - 1 : i; - if (slot >= _previewSlot) - slot++; - var m = SlotCount(_previewRemoved); - return (DonutGeometry.MidAngle(slot, m), DonutGeometry.Span(m)); - } - - /// Moves the slices and the preview towards their places. Returns true while they are still moving. - private bool StepLayout(double dt, bool snap = false) - { - snap |= !Motion.Enabled; - var active = false; - for (var i = 0; i < _mid.Length; i++) - { - var (mid, span) = LayoutTarget(i); - if (snap) - { - _mid[i] = new Spring { Value = mid }; - _span[i] = new Spring { Value = span }; - continue; - } - active |= _mid[i].Step(mid, dt, 420, 36); - active |= _span[i].Step(span, dt, 420, 36); - } - var m = SlotCount(_previewRemoved); - var previewMid = _previewSlot >= 0 ? DonutGeometry.MidAngle(_previewSlot, m) : _previewMid.Value; - var previewSpan = _previewSlot >= 0 ? DonutGeometry.Span(m) : 0; - if (snap) - { - _previewMid = new Spring { Value = previewMid }; - _previewSpan = new Spring { Value = previewSpan }; - return false; - } - active |= _previewMid.Step(previewMid, dt, 420, 36); - active |= _previewSpan.Step(previewSpan, dt, 420, 36); - return active; - } - - /// Hit-tests a point in this control's coordinates. - public DonutHit HitTest(Point p) - { - if (_progressMode) - return HitTestProgress(p); - if (IsEditMode && _hoverIndex >= 0 && _hoverIndex < Count && - Distance(p, EditButtonCenter(_hoverIndex)) <= 13) - return new DonutHit(DonutHitKind.EditButton, _hoverIndex); - var hit = DonutGeometry.HitTest(Center, p, InnerRadius, OuterRadius, Count, PopOut); - if (ExpandedChildren is { } children) - { - var (inner, outer) = SubRadii; - var parentMid = DonutGeometry.MidAngle(_expanded, Count); - var subSpan = SubSpanOf(children.Count); - var sub = DonutGeometry.SubIndexAt(Center, p, inner, outer, parentMid, children.Count, subSpan); - if (sub >= 0) - return new DonutHit(DonutHitKind.SubSegment, _expanded, sub); - // On the way out to the ring the pointer crosses the gap before it: there it still belongs to the group, or - // the ring would fold away (or the hint and the drag cursor flicker) just before it is reached. A neighbour's - // outer half stays that neighbour, so a click or a drop there converts with it; TrackGroups keeps the ring - // open over it (OnWayToRing). - if (hit.Kind == DonutHitKind.Outside && Distance(p, Center) < inner && InFan(p, _expanded)) - return new DonutHit(DonutHitKind.Segment, _expanded); - } - return hit; - } - - // ───────────────────────── Layout ───────────────────────── - - protected override Size MeasureOverride(Size availableSize) - { - var side = SideFor(OuterRadius, ReserveSubRing || (!IsEditMode && Items?.Any(i => i.IsGroup) == true)); - return new Size(side, side); - } - - private void OnItemsChanged() - { - var n = Count; - _hover = new Spring[n]; - _open = Enumerable.Repeat(1.0, n).ToArray(); - _hoverIndex = -1; - _externalHighlight = -1; - _dimmedIndex = -1; - _dropTargetIndex = -1; - // A drop changes the items into exactly what the preview showed: start there, without a jump. - _previewSlot = -1; - _previewRemoved = -1; - _previewSpan = default; - _mid = Enumerable.Range(0, n).Select(i => new Spring { Value = DonutGeometry.MidAngle(i, n) }).ToArray(); - _span = Enumerable.Range(0, n).Select(_ => new Spring { Value = DonutGeometry.Span(n) }).ToArray(); - _expanded = _shownExpanded = _subHover = -1; - ClearPending(); - _expand = default; - // New files (or none): a flip of the old ones is over. A flip's own swap keeps going (SwapFaces). - if (!_swapping) - CancelFlip(); - _lastExternalAt = null; - // The window is reused: a keyboard hold from the last time it was open must not swallow the first moves now. - _keyboardHold = _ringFromKeys = false; - _keyAnchor = _lastPointer = null; - InvalidateMeasure(); - InvalidateVisual(); - } - - // ───────────────────────── Input ───────────────────────── - - protected override void OnPointerMoved(PointerEventArgs e) - { - base.OnPointerMoved(e); - var p = e.GetPosition(this); - if (_progressMode) - { - OnProgressPointerMoved(p); - return; - } - - if (_press is { } press && IsEditMode && Distance(p, press.Start) > DragStartDistance && press.Index >= 0) - { - _press = null; - SegmentDragStarted?.Invoke(this, (press.Index, e)); - return; - } - - if (_keyboardHold) - { - if (_keyAnchor is not { } anchor) - _keyAnchor = anchor = p; - if (Distance(p, anchor) < KeyboardJitter) - return; - _keyboardHold = false; - } - _lastPointer = p; - var hit = HitTest(p); - if (IsFlipping) - PointAtAngle(DonutGeometry.AngleOf(Center, p), hit.Kind is DonutHitKind.Segment or DonutHitKind.SubSegment); - TrackGroups(hit, p); - SetHover(hit.Kind switch - { - DonutHitKind.Segment or DonutHitKind.EditButton or DonutHitKind.SubSegment => hit.Index, - _ => -1, - }); - // On a group's ring, why that slice (not the group) is disabled. - var hovered = hit.Kind == DonutHitKind.SubSegment ? Items![hit.Index].Children![hit.Sub] - : _hoverIndex >= 0 ? Items![_hoverIndex] : null; - ToolTip.SetTip(this, hovered is { IsEnabled: false } ? hovered.DisabledReason : null); - } - - protected override void OnPointerExited(PointerEventArgs e) - { - base.OnPointerExited(e); - if (_progressMode) - { - OnProgressPointerMoved(new Point(double.MaxValue / 4, 0)); - return; - } - if (_press is null && !LeftThroughSeam(e.GetPosition(this))) - { - CollapseGroup(); - SetHover(-1); - } - } - - protected override void OnPointerPressed(PointerPressedEventArgs e) - { - base.OnPointerPressed(e); - var point = e.GetCurrentPoint(this); - var hit = HitTest(point.Position); - - if (_progressMode) - { - if (point.Properties.IsLeftButtonPressed) - { - _press = (-1, point.Position, e); - e.Handled = true; - } - return; - } - - if (point.Properties.IsRightButtonPressed) - { - if (hit.Kind is DonutHitKind.Segment or DonutHitKind.EditButton && (IsEditMode || AllowContextEdit)) - { - SegmentContextRequested?.Invoke(this, hit.Index); - e.Handled = true; - } - else if (hit.Kind == DonutHitKind.SubSegment && AllowContextEdit) - { - SubSegmentContextRequested?.Invoke(this, (hit.Index, hit.Sub)); - e.Handled = true; - } - return; - } - - if (point.Properties.IsLeftButtonPressed) - { - _press = (hit.Kind == DonutHitKind.Segment ? hit.Index : -1, point.Position, e); - e.Handled = true; - } - } - - protected override void OnPointerReleased(PointerReleasedEventArgs e) - { - base.OnPointerReleased(e); - if (_press is null) - return; - _press = null; - - if (_progressMode) - { - OnProgressPointerReleased(e.GetPosition(this)); - e.Handled = true; - return; - } - - var hit = HitTest(e.GetPosition(this)); - switch (hit.Kind) - { - case DonutHitKind.EditButton: - SegmentContextRequested?.Invoke(this, hit.Index); - break; - case DonutHitKind.Segment when !IsEditMode && Items![hit.Index] is { IsEnabled: true, IsGroup: false }: - SegmentInvoked?.Invoke(this, hit.Index); - break; - case DonutHitKind.Segment when !IsEditMode && Items![hit.Index].Children is { Count: > 0 }: - Expand(hit.Index); // no waiting when clicked; a tap has no hover to rest on at all - break; - case DonutHitKind.Outside when !IsEditMode && _expanded >= 0: - CollapseGroup(); // clicked past the ring - break; - case DonutHitKind.SubSegment when !IsEditMode && Items![hit.Index].Children![hit.Sub].IsEnabled: - SubSegmentInvoked?.Invoke(this, (hit.Index, hit.Sub)); - break; - case DonutHitKind.Center when !IsEditMode: - CenterInvoked?.Invoke(this, EventArgs.Empty); - break; - } - e.Handled = true; - } - - private void SetHover(int index) - { - if (_hoverIndex == index) - return; - _hoverIndex = index; - ActiveIndexChanged?.Invoke(this, ActiveIndex); - StartAnimation(); - } - - // ───────────────────────── Animation ───────────────────────── - - /// Damping at which a spring of stiffness 320 settles without overshooting (2·√320). - private const double CriticalDamping = 36; - - /// Underdamped spring: gives the soft "boing" when a slice pops out. - private struct Spring - { - public double Value; - public double Velocity; - - /// - /// Longest time one integration step covers. With these stiffnesses a single step of a late frame (50 ms) - /// overshoots more each frame instead of settling: slices stayed lit and flew apart on a busy machine. - /// - private const double MaxStep = 1 / 120.0; - - public bool Step(double target, double dt, double stiffness = 320, double damping = 20) - { - var steps = Math.Max(1, (int)Math.Ceiling(dt / MaxStep)); - var h = dt / steps; - for (var i = 0; i < steps; i++) - { - var force = stiffness * (target - Value) - damping * Velocity; - Velocity += force * h; - Value += Velocity * h; - } - var settled = Math.Abs(target - Value) < 0.001 && Math.Abs(Velocity) < 0.01; - if (settled) - { - Value = target; - Velocity = 0; - } - return !settled; - } - } - - /// - /// A frame loop that got no frame for this long is treated as stopped: a frame request can be lost (the window - /// hidden in between, for one), and every later animation would wait for it, so the slice under a dragged file - /// would never light up. - /// - internal const long StalledLoopMs = 250; - - private int _loop; // the current frame loop; frames of an older one are ignored - private long _lastTick; // Environment.TickCount64 of the last frame or frame request - - /// Frame requests asked again before the animation is finished at once (see ). - internal const int FrameRetries = 2; - - private int _stalls; // times the current animation's frames stopped coming - - private void StartAnimation() - { - InvalidateVisual(); - if (_animating && Environment.TickCount64 - _lastTick < StalledLoopMs) - return; - _stalls = 0; - StartLoop(); - } - - private void StartLoop() - { - var top = TopLevel.GetTopLevel(this); - if (top is null) - { - _animating = false; - return; - } - _animating = true; - _lastFrame = null; - _lastTick = Environment.TickCount64; - var loop = ++_loop; - top.RequestAnimationFrame(time => OnFrame(time, loop)); - Watch(loop); - } - - private void Watch(int loop) => - DispatcherTimer.RunOnce(() => - { - if (loop == _loop) - CheckLoop(); - }, TimeSpan.FromMilliseconds(StalledLoopMs + 50)); - - /// - /// Watches the frame loop without waiting for another animation call. When its frames stop (a request lost - /// while the window was hidden, or the renderer waking up after a long idle), the frame is asked for again; after - /// stalls the animations jump to their end, so an opening donut always appears. Stalls - /// are counted per animation: frames that trickle in once per retry would otherwise open the donut over seconds. - /// - internal void CheckLoop() - { - if (!_animating) - return; - if (Environment.TickCount64 - _lastTick < StalledLoopMs) - { - Watch(_loop); // frames are coming: keep watching until the loop ends - return; - } - if (++_stalls <= FrameRetries) - { - StartLoop(); - return; - } - _animating = false; - _loop++; - FinishAnimations(); - } - - /// - /// Sets the opening, the highlights and the slice layout to where they end (a closing donut closes). The progress - /// ring keeps its own timers. - /// - internal void FinishAnimations() - { - for (var i = 0; i < _hover.Length; i++) - _hover[i] = new Spring { Value = i == ActiveIndex ? 1 : 0 }; - _centerPulse = default; - _expand = new Spring { Value = _expanded >= 0 ? 1 : 0 }; - if (_expanded < 0) - _shownExpanded = -1; - StepLayout(0, snap: true); - FinishFlip(); - if (_openClock < double.MaxValue) - { - _openClock = double.MaxValue; - Array.Fill(_open, _closing ? 0 : 1); - if (_closing) - FinishClose(); - } - InvalidateVisual(); - } - - private void OnFrame(TimeSpan time, int loop) - { - if (loop != _loop) - return; // a loop that was given up on as stalled, replaced by a newer one - _lastTick = Environment.TickCount64; - var dt = _lastFrame is { } last ? Math.Clamp((time - last).TotalSeconds, 0, 0.05) : 1 / 60.0; - _lastFrame = time; - var active = Advance(dt); - - InvalidateVisual(); - if (active && TopLevel.GetTopLevel(this) is { } top) - top.RequestAnimationFrame(next => OnFrame(next, loop)); - else - _animating = false; - } - - /// - /// Advances every animation by seconds. Returns true while more frames are needed. - /// Internal so tests can step time deterministically. - /// - internal bool Advance(double dt) - { - var active = false; - - if (!Motion.Enabled) - { - for (var i = 0; i < _hover.Length; i++) - { - _hover[i].Value = i == ActiveIndex ? 1 : 0; - _hover[i].Velocity = 0; - } - Array.Fill(_open, _closing ? 0 : 1); - _centerPulse = default; - _expand = new Spring { Value = _expanded >= 0 ? 1 : 0 }; - if (_expanded < 0) - _shownExpanded = -1; - StepLayout(dt); - FinishFlip(); // animations switched off halfway through a flip - if (_closing) - FinishClose(); - active |= StepProgress(dt); // hold timers still run without animation - } - else - { - // Popping out bounces; letting go settles without a bounce: one below zero would make the faded accent - // flash once more on its way back. - for (var i = 0; i < _hover.Length; i++) - active |= i == ActiveIndex ? _hover[i].Step(1, dt) : _hover[i].Step(0, dt, 320, CriticalDamping); - active |= _centerPulse.Step(0, dt, 260, 12); - // A soft bounce opening, like the slices popping out; folding away settles without one. The bounce grows the - // ring by about a pixel, well inside the room kept for it (SubRingSpace). - active |= _expanded >= 0 ? _expand.Step(1, dt, 320, 24) : _expand.Step(0, dt, 320, CriticalDamping); - if (_expanded < 0 && _expand.Value <= 0.001) - _shownExpanded = -1; - active |= StepProgress(dt); - active |= StepLayout(dt); - active |= StepFlip(dt); - - if (_openClock < double.MaxValue) - { - _openClock += dt; - var done = true; - for (var i = 0; i < _open.Length; i++) - { - var t = Math.Clamp((_openClock - i * (_closing ? 0.01 : OpenStagger)) / (_closing ? 0.16 : OpenDuration), 0, 1); - _open[i] = _closing ? 1 - EaseInCubic(t) : EaseOutBack(t); - done &= t >= 1; - } - if (done) - { - _openClock = double.MaxValue; - if (_closing) - FinishClose(); - } - else - { - active = true; - } - } - } - - return active; - } - - private void FinishClose() - { - _closing = false; - var callback = _closeCallback; - _closeCallback = null; - callback?.Invoke(); - } - - private static double EaseOutBack(double t) - { - const double c1 = 1.55; - const double c3 = c1 + 1; - return 1 + c3 * Math.Pow(t - 1, 3) + c1 * Math.Pow(t - 1, 2); - } - - private static double EaseInCubic(double t) => t * t * t; - - // ───────────────────────── Rendering ───────────────────────── - - public override void Render(DrawingContext context) - { - var center = Center; - var r2 = OuterRadius; - var r1 = InnerRadius; - var n = Count; - var typeface = new Typeface(GetValue(TextElement.FontFamilyProperty), FontStyle.Normal, FontWeight.SemiBold); - var typefaceRegular = new Typeface(GetValue(TextElement.FontFamilyProperty)); - - if (_progressMode) - { - RenderProgress(context, center, r1, r2, typeface, typefaceRegular); - return; - } - - RenderRing(context, center, r1, r2, n, typeface, typefaceRegular); - - var reaching = _expanded >= 0 ? _expanded : _pendingGroup; - if (!IsEditMode && reaching >= 0 && reaching < n && Items![reaching].Children is { Count: > 0 } reachable) - DrawHitBackdrop(context, center, reaching, reachable.Count); - if (!IsEditMode && _shownExpanded >= 0 && _shownExpanded < n && _expand.Value > 0.01) - DrawSubRing(context, center, typeface, typefaceRegular); - if (HasOldGroupRing) - DrawOldSubRing(context, center, typeface, typefaceRegular); - - DrawCenter(context, center, r1, typeface, typefaceRegular); - - if (IsEditMode && _hoverIndex >= 0 && _hoverIndex < n && _dimmedIndex < 0 && !IsFlipping) - DrawEditButton(context, EditButtonCenter(_hoverIndex)); - } - - /// The shadow, the slices and the edit mode's drop preview. - private void RenderRing(DrawingContext context, Point center, double r1, double r2, int n, Typeface typeface, Typeface typefaceRegular) - { - DrawSoftShadow(context, center, r2); - - if (n == 0) - { - var ring = CreateRing(center, r1, r2); - context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity), BorderPen(), ring); - if (_dropTargetIndex >= 0) - context.DrawGeometry(WithOpacity(AccentBrush, 0.35), null, ring); - } - - for (var i = 0; i < n; i++) - { - if (IsFlipping && SliceFlipWidth(false, i) == 0) - continue; - var item = Items![i]; - var open = i < _open.Length ? _open[i] : 1; - if (open <= 0.001) - continue; - - var previewing = IsEditMode && i < _mid.Length; - var hover = i < _hover.Length && _previewSlot < 0 ? _hover[i].Value : 0; - var mid = previewing ? _mid[i].Value : DonutGeometry.MidAngle(i, n); - var span = previewing ? _span[i].Value : DonutGeometry.Span(n); - if (span < 0.01) - continue; - var offset = DonutGeometry.PointAt(new Point(0, 0), hover * PopOut, mid); - - // Open animation: scale up from the centre with a small twist. - var scale = 0.55 + 0.45 * open; - var rotate = (1 - Math.Min(open, 1)) * -0.35; - var transform = - Matrix.CreateTranslation(-center.X, -center.Y) * - Matrix.CreateRotation(rotate) * - Matrix.CreateScale(scale * (1 + hover * 0.025), scale * (1 + hover * 0.025)) * - Matrix.CreateTranslation(center.X + offset.X, center.Y + offset.Y) * - SliceFlipTransform(false, i); - - using (context.PushTransform(transform)) - using (context.PushOpacity(Math.Clamp(open, 0, 1) * (i == _dimmedIndex ? 0.3 : 1))) - { - var wedge = CreateWedge(center, r1, r2, mid, span); - context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity * (item.IsEnabled ? 1 : 0.55)), BorderPen(), wedge); - - // The accent fades in and out with the highlight (no floor: a floor made it vanish with a jump). - var emphasis = i == _dropTargetIndex ? 1.0 : hover; - if (emphasis > 0.01 && item.IsEnabled) - context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(emphasis, 0, 1)), null, wedge); - - var hot = (i == ActiveIndex || i == _dropTargetIndex) && item.IsEnabled; - DrawLabel(context, item, center, (r1 + r2) / 2, mid, span * (r1 + r2) / 2 - 10, - r2 - r1 - 16, typeface, typefaceRegular, hot); - } - } - - if (_oldFace is { } old) - for (var i = 0; i < old.Length; i++) - { - var slice = old[i]; - if (SliceFlipWidth(true, i) == 0 || slice.Open <= 0.001) - continue; - var offset = DonutGeometry.PointAt(new Point(0, 0), slice.Hover * PopOut, slice.Mid); - var scale = 0.55 + 0.45 * slice.Open; - var rotate = (1 - Math.Min(slice.Open, 1)) * -0.35; - var transform = Matrix.CreateTranslation(-center.X, -center.Y) * Matrix.CreateRotation(rotate) * - Matrix.CreateScale(scale * (1 + slice.Hover * 0.025), scale * (1 + slice.Hover * 0.025)) * - Matrix.CreateTranslation(center.X + offset.X, center.Y + offset.Y) * - SliceFlipTransform(true, i); - using (context.PushTransform(transform)) - using (context.PushOpacity(Math.Clamp(slice.Open, 0, 1) * (slice.Dimmed ? 0.3 : 1))) - { - var wedge = CreateWedge(center, r1, r2, slice.Mid, slice.Span); - context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity * (slice.Item.IsEnabled ? 1 : 0.55)), BorderPen(), wedge); - if (slice.Emphasis > 0.01 && slice.Item.IsEnabled) - context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(slice.Emphasis, 0, 1)), null, wedge); - DrawLabel(context, slice.Item, center, (r1 + r2) / 2, slice.Mid, - slice.Span * (r1 + r2) / 2 - 10, r2 - r1 - 16, typeface, typefaceRegular, - slice.Hot && slice.Item.IsEnabled); - } - } - - if (IsEditMode && _previewSpan.Value > 0.01) - DrawPreview(context, center, r1, r2, typeface, typefaceRegular); - } - - private void DrawSoftShadow(DrawingContext context, Point center, double r2) - { - // A radial gradient ring fakes a soft drop shadow without blur effects (cheap on any GPU). - var shadowRadius = r2 + 14; - var brush = new RadialGradientBrush - { - GradientStops = - { - new GradientStop(Color.FromArgb(0, 0, 0, 0), 0), - new GradientStop(Color.FromArgb(0, 0, 0, 0), (r2 - 18) / shadowRadius), - new GradientStop(Color.FromArgb(46, 0, 0, 0), (r2 - 2) / shadowRadius), - new GradientStop(Color.FromArgb(0, 0, 0, 0), 1), - }, - }; - var opening = _open.Length == 0 ? 1 : Math.Clamp(_open.Average(), 0, 1); - using (context.PushOpacity(opening * ShadowCoverage)) - context.DrawEllipse(brush, null, new Point(center.X, center.Y + 4), shadowRadius, shadowRadius); - } - - private void DrawCenter(DrawingContext context, Point center, double r1, Typeface bold, Typeface regular) - { - var pulse = 1 + _centerPulse.Value * 0.06; - var radius = (r1 - 6) * pulse; - var cancelHot = _externalCenterHot; - context.DrawEllipse(CenterBrush, BorderPen(), center, radius, radius); - if (cancelHot) - context.DrawEllipse(WithOpacity(DangerBrush, 0.18), null, center, radius, radius); - - var maxWidth = radius * 1.6; - var title = MakeText(CenterTitle ?? "", bold, 14, Foreground, maxWidth, maxLines: 2); - var subtitle = MakeText(CenterSubtitle ?? "", regular, 11.5, cancelHot ? DangerBrush : SecondaryForeground, maxWidth, maxLines: 2); - // "⟳ Tab back": small, under the rest, a little narrower (the hub is round), with room for its icons. - var hintIcons = FlipArrowSize + (CenterHintWheel ? MouseWidth + MouseGap : 0) + IconGap; - var hint = string.IsNullOrEmpty(CenterHint) ? null : MakeText(CenterHint, regular, 10.5, SecondaryForeground, maxWidth * 0.85 - hintIcons); - var total = title.Height + (string.IsNullOrEmpty(CenterSubtitle) ? 0 : subtitle.Height + 2) + (hint is null ? 0 : hint.Height + 5); - var y = center.Y - total / 2; - DrawCentered(context, title, center.X, y); - y += title.Height; - if (!string.IsNullOrEmpty(CenterSubtitle)) - { - DrawCentered(context, subtitle, center.X, y + 2); - y += subtitle.Height + 2; - } - if (hint is not null) - { - using (context.PushOpacity(0.85)) - { - // The flip arrow is drawn, not typed: a font without "↻" showed a box in its place (#554). A small mouse - // comes first when the wheel flips and no key does. The icons stand at the start of the line: on its - // left, or on its right in a right-to-left language, in reading order there too. - var left = center.X - (hintIcons + hint.Width) / 2; - var rtl = TextDirection == FlowDirection.RightToLeft; - var textLeft = rtl ? left : left + hintIcons; - var middle = y + 5 + hint.Height / 2; - var x = rtl ? left + hint.Width + IconGap : left; - var arrowFirst = rtl && CenterHintWheel; - if (CenterHintWheel && !arrowFirst) - { - DrawMouse(context, new Rect(x, middle - 5, MouseWidth, 10), SecondaryForeground); - x += MouseWidth + MouseGap; - } - DrawFlipArrow(context, new Point(x + FlipArrowSize / 2, middle), FlipArrowSize / 2, SecondaryForeground); - x += FlipArrowSize + (arrowFirst ? MouseGap : IconGap); - if (arrowFirst) - DrawMouse(context, new Rect(x, middle - 5, MouseWidth, 10), SecondaryForeground); - DrawCentered(context, hint, textLeft + hint.Width / 2, y + 5); - } - } - } - - // The hub hint's icons: the flip arrow, the mouse (wheel only) and the room between them and before the text. The - // arrow's head reaches a little past its circle towards the mouse, so that gap is wider. - private const double FlipArrowSize = 9, MouseWidth = 7, IconGap = 4, MouseGap = 6; - - /// - /// A small arrow turning clockwise round , the "flip" in the hub hint: most of a circle with - /// its head at the end. - /// - internal static void DrawFlipArrow(DrawingContext context, Point center, double radius, IBrush? brush) - { - var pen = new Pen(brush, 1.2, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round); - // Angles in radians from 3 o'clock, growing clockwise on screen (y points down). From 1 o'clock almost round to - // 11 o'clock, leaving a gap at the top where the head points into. - const double start = -Math.PI / 3, sweep = Math.PI * 5 / 3; - Point At(double angle) => new(center.X + radius * Math.Cos(angle), center.Y + radius * Math.Sin(angle)); - var end = start + sweep; - var arc = new StreamGeometry(); - using (var g = arc.Open()) - { - g.BeginFigure(At(start), false); - g.ArcTo(At(end), new Size(radius, radius), 0, sweep > Math.PI, SweepDirection.Clockwise); - g.EndFigure(false); - } - context.DrawGeometry(null, pen, arc); - - // The head: two strokes back from the end, along the circle's tangent there and either side of it. - var tip = At(end); - var along = new Vector(-Math.Sin(end), Math.Cos(end)); // clockwise - var outward = new Vector(Math.Cos(end), Math.Sin(end)); - var head = radius * 0.75; - var back = tip - along * head; - context.DrawLine(pen, back + outward * head * 0.6, tip); - context.DrawLine(pen, back - outward * head * 0.6, tip); - } - - /// An outline of a mouse with its wheel, filling . - private static void DrawMouse(DrawingContext context, Rect box, IBrush? brush) - { - var pen = new Pen(brush, 1.1); - context.DrawRectangle(null, pen, box, box.Width / 2, box.Width / 2); - context.DrawLine(pen, new Point(box.Center.X, box.Top + 2), new Point(box.Center.X, box.Top + 4.5)); - } - - private void DrawLabel(DrawingContext context, DonutItem item, Point center, double radius, double angle, - double maxWidth, double maxHeight, Typeface bold, Typeface regular, bool hot) - { - var foreground = hot ? OnAccentBrush : item.IsEnabled ? Foreground : SecondaryForeground; - var width = Math.Clamp(maxWidth, 30, 120); - var text = KeepKoreanWords(item.Label); - var label = MakeText(text, bold, LabelSize(text, bold, width), foreground, width, maxLines: 2); - var showCaption = !string.IsNullOrEmpty(item.Caption) && - !string.Equals(item.Caption, item.Label, StringComparison.OrdinalIgnoreCase) && - label.Height + 14 < maxHeight; - var caption = showCaption - ? MakeCaption(item.Caption!, regular, hot ? OnAccentBrush : SecondaryForeground, width, label.Height, maxHeight) - : null; - - var total = label.Height + (caption?.Height ?? 0); - var anchor = DonutGeometry.PointAt(center, radius, angle); - var y = anchor.Y - total / 2; - DrawCentered(context, label, anchor.X, y); - if (caption is not null) - DrawCentered(context, caption, anchor.X, y + label.Height); - } - - /// - /// A slice's caption under its name: one line, or two where the slice is deep enough, so a caption wider than the - /// slice ("Same format" on a small donut) wraps instead of ending in "Same for…". - /// - internal static FormattedText MakeCaption(string caption, Typeface typeface, IBrush? brush, double width, - double labelHeight, double maxHeight) => - MakeText(caption, typeface, 10.5, brush, width, maxLines: labelHeight + 28 < maxHeight ? 2 : 1); - - private const double LabelFontSize = 13; - private const double MinLabelFontSize = 10; - private readonly Dictionary<(string Text, Typeface Typeface, double Width), double> _labelSizes = new(); - - /// - /// The size a slice's name is drawn at: 13, or smaller (down to 10) when one of its words is wider than the slice - /// (), which would otherwise break in the middle ("Grayscal|e" on a thin slice). Words in - /// Chinese or Japanese are left out: those break between any two characters. - /// - internal double LabelSize(string text, Typeface typeface, double width) - { - var line = Math.Round(width); - var key = (text, typeface, line); - if (_labelSizes.TryGetValue(key, out var size)) - return size; - if (_labelSizes.Count >= MaxCachedWedges) - _labelSizes.Clear(); - var widest = text.Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Where(word => !word.Any(IsHanOrKana)) - .Select(word => new FormattedText(word, CultureInfo.CurrentUICulture, TextDirection, typeface, LabelFontSize, null).Width) - .DefaultIfEmpty(0) - .Max(); - // A pixel to spare: a word exactly as wide as the line still wraps. - size = widest < line ? LabelFontSize : Math.Max(MinLabelFontSize, LabelFontSize * (line - 1) / widest); - return _labelSizes[key] = size; - } - - /// Chinese characters, kana and the CJK symbols and full-width forms: a line may break between any two. - private static bool IsHanOrKana(char c) => - c is >= '\u2E80' and <= '\u312F' or >= '\u3190' and <= '\u9FFF' or >= '\uF900' and <= '\uFAFF' or >= '\uFF00' and <= '\uFFEF'; - - /// - /// Korean is written with spaces between words and wraps there, but the text layout also breaks between any two - /// Hangul syllables ("ZIP 하나|로"). A word joiner between them keeps each word on one line. - /// - internal static string KeepKoreanWords(string text) - { - if (!text.Any(IsHangul)) - return text; - var kept = new System.Text.StringBuilder(text.Length * 2); - for (var i = 0; i < text.Length; i++) - { - kept.Append(text[i]); - if (i + 1 < text.Length && IsHangul(text[i]) && IsHangul(text[i + 1])) - kept.Append('\u2060'); - } - return kept.ToString(); - } - - private static bool IsHangul(char c) => c is >= '\uAC00' and <= '\uD7A3'; - - private Point EditButtonCenter(int index) - { - var mid = DonutGeometry.MidAngle(index, Count); - return DonutGeometry.PointAt(Center, OuterRadius - 14 + PopOut, mid); - } - - private void DrawEditButton(DrawingContext context, Point at) - { - context.DrawEllipse(AccentBrush, new Pen(CenterBrush, 2), at, 12, 12); - var bounds = PencilIcon.Bounds; - var s = 13 / Math.Max(bounds.Width, bounds.Height); - var matrix = Matrix.CreateTranslation(-bounds.Center.X, -bounds.Center.Y) * Matrix.CreateScale(s, s) * Matrix.CreateTranslation(at.X, at.Y); - using (context.PushTransform(matrix)) - context.DrawGeometry(OnAccentBrush, null, PencilIcon); - } - - /// - /// Draws text horizontally centred on . - /// The text is laid out with inside a box of , - /// so the box (not the measured ) must be centred — otherwise short labels - /// drift right by (box − text) / 2. - /// - internal static void DrawCentered(DrawingContext context, FormattedText text, double centerX, double top) => - context.DrawText(text, new Point(centerX - text.MaxTextWidth / 2, top)); - - private Pen? BorderPen() => SliceBorderBrush is null ? null : new Pen(SliceBorderBrush, 1); - - private static FormattedText MakeText(string text, Typeface typeface, double size, IBrush? brush, double maxWidth, int maxLines = 1) => - new(text, CultureInfo.CurrentUICulture, TextDirection, typeface, size, brush) - { - MaxTextWidth = Math.Max(10, maxWidth), - MaxLineCount = maxLines, - Trimming = TextTrimming.CharacterEllipsis, - TextAlignment = TextAlignment.Center, - }; - - private static FlowDirection TextDirection => CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft - ? FlowDirection.RightToLeft : FlowDirection.LeftToRight; - - private static IBrush? WithOpacity(IBrush? brush, double opacity) => brush switch - { - ISolidColorBrush solid => new SolidColorBrush(solid.Color, solid.Opacity * opacity), - _ => brush, - }; - - private static double Distance(Point a, Point b) => Math.Sqrt(Math.Pow(a.X - b.X, 2) + Math.Pow(a.Y - b.Y, 2)); - - /// The translucent slice where a dragged preset would land, with its name. - private void DrawPreview(DrawingContext context, Point center, double r1, double r2, Typeface bold, Typeface regular) - { - var mid = _previewMid.Value; - var span = _previewSpan.Value; - var wedge = CreateWedge(center, r1, r2, mid, span); - var outline = new Pen(WithOpacity(AccentBrush, 0.9), 1.5, new DashStyle([4, 3], 0)); - context.DrawGeometry(WithOpacity(AccentBrush, 0.22), outline, wedge); - if (_previewLabel is null || span < 0.25) - return; - using (context.PushOpacity(Math.Clamp((span - 0.25) / 0.3, 0, 1) * 0.9)) - DrawLabel(context, new DonutItem { Id = "", Label = _previewLabel }, center, (r1 + r2) / 2, mid, - span * (r1 + r2) / 2 - 10, r2 - r1 - 16, bold, regular, hot: false); - } - - /// - /// How round the slices' corners are: half the theme's softness (0 to 28), so the largest setting still leaves the - /// slices clearly apart. shrinks it for slices too thin for it. - /// - internal double SliceCornerRadius => Math.Clamp(CornerRadius.TopLeft, 0, 28) * 0.5; - - private StreamGeometry CreateWedge(Point c, double r1, double r2, int index, int count) => - count == 1 ? CreateRing(c, r1, r2) : CreateWedge(c, r1, r2, DonutGeometry.MidAngle(index, count), DonutGeometry.Span(count)); - - /// A rounded slice of radians centred on (a ring when it is all of it). - private StreamGeometry CreateWedge(Point c, double r1, double r2, double mid, double span) => - CreateWedge(c, r1, r2, mid, span, SliceCornerRadius); - - // Slices are drawn every frame while the donut animates, but their shapes only change with the size, the layout - // or the softness: the shapes are kept and reused. Animations move them with transforms; only the edit mode's - // sliding slices make new ones, and the cache starts over when it fills up. - private readonly Dictionary<(Point C, double R1, double R2, double Mid, double Span, double Corner), StreamGeometry> _wedges = new(); - private const int MaxCachedWedges = 256; - - private StreamGeometry CreateWedge(Point c, double r1, double r2, double mid, double span, double cornerRadius) - { - var key = (c, r1, r2, mid, span, cornerRadius); - if (_wedges.TryGetValue(key, out var cached)) - return cached; - if (_wedges.Count >= MaxCachedWedges) - _wedges.Clear(); - return _wedges[key] = BuildWedge(c, r1, r2, mid, span, cornerRadius); - } - - /// A slice with a constant-width gap to its neighbours and corners rounded by . - private static StreamGeometry BuildWedge(Point c, double r1, double r2, double mid, double span, double cornerRadius) - { - if (span >= 2 * Math.PI - 0.001) - return CreateRing(c, r1, r2); - - var outline = DonutGeometry.Wedge(c, r1, r2, mid, span, GapPixels, cornerRadius); - var geometry = new StreamGeometry(); - using var ctx = geometry.Open(); - ctx.BeginFigure(outline.Start, true); - foreach (var step in outline.Steps) - { - if (step.IsArc) - ctx.ArcTo(step.To, new Size(step.ArcRadius, step.ArcRadius), 0, step.IsLargeArc, - step.Clockwise ? SweepDirection.Clockwise : SweepDirection.CounterClockwise); - else - ctx.LineTo(step.To); - } - ctx.EndFigure(true); - return geometry; - } - - /// Full ring (used for a single slice and for the empty donut). - private static StreamGeometry CreateRing(Point c, double r1, double r2) - { - var geometry = new StreamGeometry(); - using var ctx = geometry.Open(); - ctx.SetFillRule(FillRule.EvenOdd); - foreach (var r in new[] { r2, r1 }) - { - ctx.BeginFigure(new Point(c.X, c.Y - r), true); - ctx.ArcTo(new Point(c.X, c.Y + r), new Size(r, r), 0, false, SweepDirection.Clockwise); - ctx.ArcTo(new Point(c.X, c.Y - r), new Size(r, r), 0, false, SweepDirection.Clockwise); - ctx.EndFigure(true); - } - return geometry; - } -} diff --git a/RadialController.cs b/RadialController.cs deleted file mode 100644 index bfcd335ba..000000000 --- a/RadialController.cs +++ /dev/null @@ -1,645 +0,0 @@ -// Opens, fills and closes the live donut toolbar. -// -// Drag mode (modifier + drag in Explorer): -// gesture detected → an invisible window waits at the cursor (not activated) → DragEnter brings the file list → -// the donut pops out → drop on a slice converts / drop in the hole or release elsewhere closes. Without files -// (text, a rubber-band selection) nothing ever shows. -// Files from a web browser (a picture, a link to a PDF) stand as placeholder paths named like the files will be, so the -// donut shows their slices at once; they are saved when the conversion starts (WebDrop, ConversionService). -// Click mode (keyboard shortcut, context menu, drop zone): -// files are known up front → donut appears activated → click a slice to convert, right-click to edit, -// Esc / click outside closes. -// The flip key (Tab) and the mouse wheel flip the open donut to its other face (DonutMenu.Flip.cs): in click mode the -// focused window gets them, in drag mode (no focus during an OS drag) the global hook reports them while the donut -// shows files. -// After a drop / click the donut becomes a progress ring at the same spot (DonutMenu.Progress.cs): -// finished quickly → ✓ pops up in the centre, then the ring fades out; -// still running after HandOffAfter, failed, Esc, click elsewhere or a new gesture → the toast takes over. - -using System.ComponentModel; -using Avalonia; -using Avalonia.Threading; -using Filee.App.Controls; -using Filee.App.Services.Triggers; -using Filee.App.ViewModels; -using Filee.App.Views; -using Filee.Core.Conversion; -using Filee.Core.Platform; -using Filee.Core.Presets; -using Filee.Core.Profiles; -using Filee.Core.Settings; -using Filee.Core.Web; -using Microsoft.Extensions.Logging; - -namespace Filee.App.Services; - -public sealed class RadialController( - RadialViewModel viewModel, - ConversionService conversions, - WindowService windows, - UserDataStore store, - IPlatformServices platform, - TriggerService triggers, - ILogger log) -{ - /// - /// How long the ring shows a running job before the toast window takes over. Long enough for a typical office - /// document (LibreOffice needs a few seconds), so the user sees it finish where they dropped it. - /// - private static readonly TimeSpan HandOffAfter = TimeSpan.FromSeconds(5); - - private RadialWindow? _window; - private bool _visible; - private bool _dropped; - private bool _editing; - private JobViewModel? _progressJob; // job currently shown by the ring (null once finished / handed off) - private JobViewModel? _lastRingJob; // for "click the ✓ to open the folder" - private IDisposable? _handOffTimer; - private int _dragGeneration; - private bool _dragReachedDonut; - private bool _fileDragsAccepted; - private readonly ActivationGrace _grace = new(); - - /// A gesture over something that isn't a file drag (a rubber-band selection) is common: a line now and then. - private static readonly LogThrottle Throttle = new(TimeSpan.FromMinutes(1)); - private bool _converted; // a conversion started from the files on the donut now - private readonly HashSet _formatRequestsOffered = new(StringComparer.OrdinalIgnoreCase); - private WebDrop? _web; // the browser drop the click-mode donut's files stand for, until a conversion takes it - - /// - /// How long a drag that never brought files may keep the invisible window up, in case the button release is - /// missed (the system dropped the input hook, a UAC prompt took the desktop): the window catches clicks. - /// - private static readonly TimeSpan EmptyDragLimit = TimeSpan.FromSeconds(10); - - public bool IsVisible => _visible; - - internal RadialWindow? Window => _window; - - /// - /// Drag gesture started at the given physical screen point. The window is placed under the cursor but stays - /// invisible until files are dragged into it (FilesEntered); a gesture without files (text, a rubber-band - /// selection, nothing at all) never shows anything and closes when the button is released. - /// - public void ShowForDrag(int x, int y) - { - ReleaseProgressToToast(); - _grace.Cancel(); - ReplaceWeb(null); - var window = EnsureWindow(); - // A ring from an earlier conversion (finished or handed to the toast) must not keep this gesture's window up. - window.DonutControl.LeaveProgressMode(); - _dropped = false; - _dragReachedDonut = false; - _converted = false; - viewModel.ResetForDrag(); - window.DonutControl.AllowContextEdit = false; - PlaceAndShow(window, x, y, activate: false); - WatchFlipKey(); - - var generation = ++_dragGeneration; - DispatcherTimer.RunOnce(() => - { - if (generation == _dragGeneration && _visible && !viewModel.HasFiles && !viewModel.IsClickMode) - Close(animate: false); - }, EmptyDragLimit); - } - - /// - /// Opens the donut for known files (click mode). Shortcuts among them stand for the files they point to. - /// : the files came from a later launch or the Services menu, so on macOS the - /// donut may lose focus once while Filee becomes the active app. : the files are placeholders - /// of this browser drop (the drop zone), which the donut now owns. - /// - public void ShowForFiles(int x, int y, IReadOnlyList files, bool forAnotherProcess = false, WebDrop? web = null) - { - if (web is null) - files = platform.ResolveShortcuts(files); - if (files.Count == 0) - { - web?.Dispose(); - return; - } - ReleaseProgressToToast(); - ReplaceWeb(web); - if (forAnotherProcess && OperatingSystem.IsMacOS()) - _grace.Start(); - else - _grace.Cancel(); - var window = EnsureWindow(); - _dropped = false; - _converted = false; - viewModel.Load(files, clickMode: true); - viewModel.FitToThisDonut(); - window.DonutControl.AllowContextEdit = true; - PlaceAndShow(window, x, y, activate: true); - WatchFlipKey(); - window.DonutControl.PlayOpenAnimation(); - } - - /// The mouse button of the drag gesture was released somewhere. - public void OnDragGestureEnded() - { - if (!_visible || viewModel.IsClickMode) - return; - // No drag ever entered the donut's window: nothing was dragged (a rubber-band selection), or Windows kept - // Explorer's drag out (Filee runs as administrator). Tells "the donut didn't react" reports apart. - if (!_dragReachedDonut && Throttle.TryEnter("no-drag-enter", out var held)) - log.LogInformation("Drag gesture ended without a drag entering the donut{Elevation}{Held}", - Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator: Windows blocks drops from Explorer)" : "", - LogThrottle.HeldText(held)); - // Nothing was brought to the donut: the window is invisible but catches clicks, so go right away. - if (!viewModel.HasFiles && !_window!.DonutControl.IsInProgressMode) - { - Close(animate: false); - return; - } - // A drop on the donut arrives around the same time as the button release; give it a moment. - DispatcherTimer.RunOnce(() => - { - if (_visible && !_dropped && !viewModel.IsClickMode) - Close(animate: true); - }, TimeSpan.FromMilliseconds(250)); - } - - public void Close(bool animate = true) - { - if (!_visible || _window is null) - return; - if (_window.DonutControl.IsInProgressMode) - { - // Esc / click elsewhere while the ring is showing: let the toast carry on (a finishing ✓ just fades). - if (_progressJob is not null) - { - HandOffToToast(); - return; - } - if (_window.DonutControl.IsFinishingProgress) - return; - // A ring left over after it finished: nothing is showing any more, so close like any donut. - _window.DonutControl.LeaveProgressMode(); - } - _visible = false; - WatchFlipKey(); - _grace.Cancel(); - ReplaceWeb(null); - var window = _window; - // A group about to open (its dwell timer running) must not open on a closing donut. - window.DonutControl.CollapseGroup(); - if (animate) - window.DonutControl.PlayCloseAnimation(() => - { - if (!_visible) - window.Hide(); - }); - else - window.Hide(); - OfferFormatRequest(); - } - - /// - /// The donut closed on files Filee can't read at all (".ncm"): offer to request the format, once per extension - /// while Filee runs. Files it reads, even mixed with unknown ones, get a conversion and the toast instead. - /// - private void OfferFormatRequest() - { - if (_converted || viewModel.Files.Count == 0 || ProfileSelector.DetectFormats(viewModel.Files).Count > 0) - return; - var extensions = FormatRequest.UnknownExtensions(viewModel.Files).Where(_formatRequestsOffered.Add).ToList(); - if (extensions.Count == 0) - return; - log.LogInformation("Donut closed on files Filee can't read: {Extensions}", FormatRequest.Label(extensions)); - windows.ShowFormatRequestNotice(extensions); - } - - private RadialWindow EnsureWindow() - { - if (_window is not null) - return _window; - - var window = new RadialWindow { DataContext = viewModel }; - var donut = window.DonutControl; - - // On native Wayland, hiding an xdg_toplevel surface causes the compositor to destroy its surface, - // and re-showing raises an InvalidOperationException. Track Closed to re-create on the next gesture. - if (Filee.Platform.Linux.WaylandSupport.NativeWaylandRequested) - { - window.Closed += (_, _) => - { - if (ReferenceEquals(_window, window)) - _window = null; - }; - } - - window.DragEntered += () => _dragReachedDonut = true; - // Tells "the drag reached the donut but brought no files" apart, with what it brought (file promises, say). - window.DragWithoutFiles += formats => - { - if (Throttle.TryEnter("drag-without-files", out var held)) - log.LogInformation("A drag without files Filee reads entered the donut; it offered: {Formats}{Held}", - formats, LogThrottle.HeldText(held)); - }; - window.FilesEntered += files => - { - if (viewModel.IsClickMode || SameFiles(files, viewModel.Files) || SameWebFiles(files, viewModel.Files)) - return; - viewModel.Load(files, clickMode: false); - donut.PlayOpenAnimation(); - WatchFlipKey(); - }; - window.Dropped += (hit, dropped) => - { - _dropped = true; - WatchFlipKey(); - if (hit.Kind is DonutHitKind.Segment or DonutHitKind.SubSegment) - { - Convert(hit.Index, hit.Sub, dropped.Files.Count > 0 ? dropped.Files : viewModel.Files, dropped.Web); - } - else - { - dropped.Web?.Dispose(); - Close(); - } - }; - // One line per drag, to tell "the drag never reached the donut" from "it did but nothing lit up" when someone - // reports a donut that doesn't react. - window.DragSummary += (events, hit, lit) => - log.LogInformation("Drag over the donut: {Events} move event(s), ended on {Kind} {Index}, drawn lit {Lit:0.00}", - events, hit.Kind, hit.Index, lit); - window.EscapePressed += () => Close(); - window.Deactivated += (_, _) => - { - if (!viewModel.IsClickMode || _editing) - return; - if (_grace.Forgives()) - { - log.LogInformation("The donut lost focus as it opened for another app's request; it stays open"); - Dispatcher.UIThread.Post(() => - { - if (_visible && viewModel.IsClickMode && !window.IsActive) - window.Activate(); - }); - return; - } - Close(); - }; - - donut.ActiveIndexChanged += (_, index) => viewModel.ShowHint(index, donut.ActiveSubIndex, donut.IsCenterHot); - // Drag mode: the flip key and the wheel seen by the global hook (click mode gets them in the focused window). - triggers.FlipKeyPressed += (_, _) => - { - if (FlipsFromHook) - donut.Flip(); - }; - triggers.FlipWheelTurned += (_, _) => - { - if (FlipsFromHook) - donut.FlipFromWheel(Environment.TickCount64); - }; - donut.SegmentInvoked += (_, index) => Convert(index, -1, viewModel.Files, TakeWeb()); - donut.SubSegmentInvoked += (_, hit) => Convert(hit.Group, hit.Sub, viewModel.Files, TakeWeb()); - donut.CenterInvoked += (_, _) => Close(); - donut.SegmentContextRequested += async (_, index) => await EditPresetAsync(index); - donut.SubSegmentContextRequested += async (_, hit) => await EditPresetAsync(hit.Group, hit.Sub); - donut.ProgressCancelRequested += (_, _) => _progressJob?.CancelCommand.Execute(null); - donut.ProgressClicked += (_, _) => - { - if (_lastRingJob is { HasOutputs: true } job) - job.OpenFolderCommand.Execute(null); - }; - - _window = window; - return window; - } - - /// - /// Converts with slice , or with slice of that group's ring. - /// : the browser drop the files stand for, handed to the conversion (or disposed). - /// - private void Convert(int index, int sub, IReadOnlyList files, WebDrop? web = null) - { - // Placeholders whose drop is gone (the drag listed files it didn't hand over in the end) can't be converted. - var orphaned = web is null && files.Any(WebDrop.IsPlaceholder); - if (viewModel.PresetAt(index, sub) is not { } preset || SourcesFor(preset, files) is not { Count: > 0 } sources || orphaned) - { - web?.Dispose(); - Close(); - return; - } - - if (preset.IsTool) - { - var near = _window?.Position; - Close(animate: false); - if (web is null) - windows.ShowTool(sources, preset, near); - else - _ = ShowToolForBrowserFilesAsync(web, preset, near); - return; - } - - // Merged PDFs, joined clips, slideshows and merged pictures follow the order the user picks. - if ((preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle || preset.JoinsIntoOneMedia || preset.JoinsImages) && sources.Count > 1) - { - Close(animate: false); - _ = MergeInOrderAsync(sources, preset.Clone(), web); - return; - } - - // "Ask every time" (#521): the save dialog first; the toast shows the job, the donut has closed for the dialog. - // shortcut: files from a browser aren't asked about, they save into the download folder as "next to the source". - if (preset.Output.AskEveryTime && web is null) - { - Close(animate: false); - _ = AskThenConvertAsync(sources, preset); - return; - } - - log.LogInformation("Converting {Count} file(s) with preset {Preset}{Web}", sources.Count, preset.Id, - web is null ? "" : " (from a browser)"); - var job = conversions.Start(sources, preset, showInToast: false, web); - if (job is null || _window is null) - { - Close(); - return; - } - _converted = true; - ShowProgress(job); - } - - private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, WebDrop? web) - { - try - { - var ordered = await windows.ChooseMergeOrderAsync(files, preset); - if (ordered is not null && preset.JoinsImages) - RememberMergeLayout(preset); - if (ordered is not null && preset.Output.AskEveryTime && web is null) - await AskThenConvertAsync(ordered, preset); - else if (ordered is not null) - { - conversions.Start(ordered, preset, web: web); - web = null; // the conversion owns the browser drop now - } - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - log.LogError(ex, "Could not start the ordered merge"); - } - finally - { - web?.Dispose(); - } - } - - /// - /// "Merge images": the layout chosen in the merge order window (, a copy of the preset) - /// goes into the preset as saved, so the window opens with it next time. - /// - private void RememberMergeLayout(Preset chosen) - { - if (store.FindPreset(chosen.Id) is not { JoinsImages: true } saved) - return; - var image = saved.Image; - if (image.JoinDirection == chosen.Image.JoinDirection && image.JoinFit == chosen.Image.JoinFit - && image.JoinGap == chosen.Image.JoinGap && image.JoinBackground == chosen.Image.JoinBackground) - return; - image.JoinDirection = chosen.Image.JoinDirection; - image.JoinFit = chosen.Image.JoinFit; - image.JoinGap = chosen.Image.JoinGap; - image.JoinBackground = chosen.Image.JoinBackground; - try - { - store.SaveLibrary(); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - log.LogWarning(ex, "Could not remember the layout of merged pictures"); - } - } - - private async Task AskThenConvertAsync(IReadOnlyList sources, Preset preset) - { - try - { - // Asked as it will run (a ZIP from Settings › General is a .zip), for one file or a folder as the job makes them. - var running = store.Settings.WithDefaultOutput(preset); - if (await windows.AskSaveLocationAsync(sources, running, AskedLocation.MakesOneResult(running, sources.Count)) is { } asked) - { - log.LogInformation("Converting {Count} file(s) with preset {Preset} where the user chose", sources.Count, preset.Id); - conversions.Start(sources, asked); - } - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - log.LogError(ex, "Could not ask where to save"); - } - } - - /// A tool (crop, clip) on files from a browser: they are saved first, then it opens on them. - private async Task ShowToolForBrowserFilesAsync(WebDrop web, Filee.Core.Presets.Preset preset, PixelPoint? near) - { - var files = await conversions.SaveBrowserFilesAsync(web, (placeholder, ex) => - log.LogWarning(ex, "Saving {File} from a browser for {Preset} failed", Path.GetFileName(placeholder), preset.Id)); - if (files.Count > 0) - windows.ShowTool(files, preset, near); - } - - /// - /// What converts of : folders only go into "Compress into one - /// archive"; any other preset converts the files beside them (issue #186). - /// - internal static IReadOnlyList SourcesFor(Preset preset, IReadOnlyList files) => - preset.PacksIntoOneArchive ? files : [.. files.Where(path => !Directory.Exists(path))]; - - // ───────── Progress ring ───────── - - private void ShowProgress(JobViewModel job) - { - var donut = _window!.DonutControl; - _dropped = true; - _progressJob = job; - viewModel.ProgressJob = job; - _lastRingJob = job; - donut.BeginProgress(); - donut.SetProgress(job.Progress / 100); - job.PropertyChanged += OnProgressJobChanged; - _handOffTimer = DispatcherTimer.RunOnce(() => - { - if (_progressJob == job && job.IsRunning) - HandOffToToast(); - }, HandOffAfter); - } - - private void OnProgressJobChanged(object? sender, PropertyChangedEventArgs e) - { - if (sender is not JobViewModel job || job != _progressJob || _window is null) - return; - var donut = _window.DonutControl; - donut.SetProgress(job.Progress / 100); - if (job.IsRunning || e.PropertyName != nameof(JobViewModel.IsRunning)) - return; - - var outcome = job.Job.State switch - { - JobState.Completed => DonutProgressOutcome.Succeeded, - JobState.Cancelled => DonutProgressOutcome.Cancelled, - _ => DonutProgressOutcome.Failed, - }; - if (outcome == DonutProgressOutcome.Failed) - conversions.ShowInToast(job); // the toast explains what went wrong - DetachProgress(); - donut.CompleteProgress(outcome, HideWindow); - } - - /// Hands the running job to the toast with a short "shrink away" of the ring. - private void HandOffToToast() - { - if (_progressJob is null || _window is null) - return; - conversions.ShowInToast(_progressJob); - DetachProgress(); - _window.DonutControl.HandOffProgress(HideWindow); - } - - /// A new gesture needs the window right now: move the job to the toast without animation. - private void ReleaseProgressToToast() - { - if (_progressJob is not null) - conversions.ShowInToast(_progressJob); - DetachProgress(); - } - - private void DetachProgress() - { - _handOffTimer?.Dispose(); - _handOffTimer = null; - if (_progressJob is not null) - _progressJob.PropertyChanged -= OnProgressJobChanged; - _progressJob = null; - viewModel.ProgressJob = null; - } - - private void HideWindow() - { - _visible = false; - WatchFlipKey(); - _window?.DonutControl.CollapseGroup(); - _window?.Hide(); - } - - /// Edits the preset of slice , or of slice of its group's ring. - private async Task EditPresetAsync(int index, int sub = -1) - { - if (_window is null || viewModel.PresetShownAt(index, sub) is not { } preset) - return; - _editing = true; - try - { - if (await windows.EditPresetAsync(preset, _window)) - { - store.SaveLibrary(); - viewModel.Load(viewModel.Files, viewModel.IsClickMode, viewModel.IsBackFace); - } - } - finally - { - _editing = false; - } - if (_visible) - _window.Activate(); - } - - /// - /// Has the global hook report the flip key and the wheel only while a drag-mode donut shows files that could flip; - /// they are left alone otherwise (see ). - /// - private void WatchFlipKey() - { - triggers.WatchFlipKey = FlipsFromHook; - // One source per press: the hook in drag mode, the window in click mode (#504). - if (_window is not null) - _window.DonutControl.FlipKeyFromHook = FlipsFromHook; - } - - /// A drag-mode donut shows files and nothing was dropped yet: the hook's flip key and wheel flip it. - private bool FlipsFromHook => _visible && !_dropped && !viewModel.IsClickMode && viewModel.HasFiles; - - /// - /// Top-left corner of a square window centred on , moved into - /// when that would cross its edge. With group slices the window holds the ring they fan out - /// (), so near a screen edge the donut moves in far enough for every ring to fit. - /// - internal static PixelPoint WindowPosition(PixelPoint cursor, PixelRect? area, int sidePx) - { - var position = new PixelPoint(cursor.X - sidePx / 2, cursor.Y - sidePx / 2); - if (area is not { } a) - return position; - return new PixelPoint( - Math.Clamp(position.X, a.X, Math.Max(a.X, a.Right - sidePx)), - Math.Clamp(position.Y, a.Y, Math.Max(a.Y, a.Bottom - sidePx))); - } - - private void PlaceAndShow(RadialWindow window, int x, int y, bool activate) - { - var cursor = new PixelPoint(x, y); - var screen = window.Screens.ScreenFromPoint(cursor) ?? window.Screens.Primary; - var scaling = OperatingSystem.IsMacOS() ? 1 : screen?.Scaling ?? 1; - - // Window size follows the donut size (SizeToContent); compute it up front to centre on the cursor. - var side = DonutMenu.SideFor(viewModel.OuterRadius, viewModel.ReserveSubRing); - var sidePx = (int)Math.Ceiling(side * scaling); - var position = WindowPosition(cursor, screen?.WorkingArea, sidePx); - - Motion.Refresh(); - window.Position = position; - if (!window.IsVisible) - window.Show(); - window.Position = position; // again, in case showing changed the DPI scale - // Showing a window that is already marked topmost does not move it in the z-order, and Windows can push it - // below normal windows while it keeps the mark (after the Photos app opened, for one). Then Explorer stays on - // top of the donut and the drop never reaches it, so put it back on top every time. - if (window.TryGetPlatformHandle()?.Handle is { } handle) - { - platform.RaiseTopmost(handle); - if (!_fileDragsAccepted && OperatingSystem.IsMacOS() && platform is Filee.Platform.MacOS.MacOSPlatformServices mac) - { - mac.AcceptFileDrags(handle); - _fileDragsAccepted = true; - } - } - _visible = true; - if (activate) - { - window.Activate(); - // ShowActivated is false because the same window is also used during an OS drag, where taking focus would - // break Explorer's drag. Click mode is different: the donut owns the keyboard until it closes, so focus it - // explicitly for Tab/arrow/Enter handling. - window.DonutControl.Focus(); - } - } - - private static bool SameFiles(IReadOnlyList a, IReadOnlyList b) => - a.Count == b.Count && a.SequenceEqual(b, FileSystemPaths.Comparer); - - /// The same browser files dragged in again: each entry makes new placeholders, with the same names. - private static bool SameWebFiles(IReadOnlyList a, IReadOnlyList b) => - a.Count == b.Count && a.All(WebDrop.IsPlaceholder) && b.All(WebDrop.IsPlaceholder) - && a.Select(Path.GetFileName).SequenceEqual(b.Select(Path.GetFileName), StringComparer.Ordinal); - - /// Hands the click-mode donut's browser drop to a conversion. - private WebDrop? TakeWeb() - { - var web = _web; - _web = null; - return web; - } - - /// The click-mode donut now stands for 's files (or local ones): the old drop is done. - private void ReplaceWeb(WebDrop? web) - { - if (!ReferenceEquals(_web, web)) - _web?.Dispose(); - _web = web; - } -} diff --git a/RadialControllerTests.cs b/RadialControllerTests.cs deleted file mode 100644 index ede1551c7..000000000 --- a/RadialControllerTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -// The drag-wait donut is an invisible, topmost window that catches clicks so a drag can reach it: it must close when -// the gesture ends without files, even after a conversion left the ring behind (issue: desktop icons stopped reacting -// to clicks until Filee quit). - -using Avalonia.Headless; -using Avalonia.Headless.XUnit; -using Avalonia.Input; -using Filee.App.Controls; -using Filee.App.Services; -using Filee.Core.Presets; - -namespace Filee.App.Tests; - -public class RadialControllerTests -{ - /// A conversion ran through the donut and its ring finished: progress mode stays on, nothing is pending. - private static void FinishARing(DonutMenu donut) - { - donut.BeginProgress(); - Motion.Set(false); - try - { - donut.HandOffProgress(() => { }); - } - finally - { - Motion.Set(true); - } - Assert.True(donut.IsInProgressMode); - Assert.False(donut.IsFinishingProgress); - } - - [AvaloniaFact] - public void A_drag_without_files_after_a_conversion_closes_the_invisible_window() - { - TestServices.EnsureInitialized("en"); - var radial = AppHost.Get(); - radial.ShowForDrag(400, 400); - FinishARing(radial.Window!.DonutControl); - - // A later Ctrl+drag that brings no files: a rubber-band selection on the desktop, a folder. - radial.ShowForDrag(600, 500); - Assert.False(radial.Window.DonutControl.IsInProgressMode); - radial.OnDragGestureEnded(); - - Assert.False(radial.IsVisible); - Assert.False(radial.Window.IsVisible); - } - - [AvaloniaFact] - public void Esc_closes_a_donut_whose_ring_already_finished() - { - TestServices.EnsureInitialized("en"); - var radial = AppHost.Get(); - radial.ShowForDrag(400, 400); - FinishARing(radial.Window!.DonutControl); - - radial.Close(animate: false); - - Assert.False(radial.IsVisible); - Assert.False(radial.Window.IsVisible); - } - - [AvaloniaFact] - public void Click_mode_focuses_the_donut_for_tab_flip() - { - TestServices.EnsureInitialized("en"); - var radial = AppHost.Get(); - try - { - radial.ShowForFiles(400, 400, [@"C:\photos\a.jpg"]); - var window = radial.Window!; - - Assert.True(window.DonutControl.IsFocused); - window.KeyPress(Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); - Assert.True(AppHost.Get().IsBackFace); - } - finally - { - radial.Close(animate: false); - } - } - - [Fact] - public void Folders_only_go_into_one_archive() - { - var folder = Directory.CreateTempSubdirectory("filee-donut-folder-").FullName; - try - { - var file = Path.Combine(folder, "1.png"); - var zip = new Preset { TargetFormat = "zip", Archive = { CombineIntoOne = true } }; - - // Issue #186: a dragged folder goes into "Compress into one archive"; other presets take only the files. - Assert.Equal([folder, file], RadialController.SourcesFor(zip, [folder, file])); - Assert.Equal([file], RadialController.SourcesFor(new Preset { TargetFormat = "jpg" }, [folder, file])); - Assert.Empty(RadialController.SourcesFor(new Preset { TargetFormat = "zip" }, [folder])); - } - finally - { - Directory.Delete(folder, recursive: true); - } - } -} diff --git a/src/Filee.App/Controls/DonutMenu.cs b/src/Filee.App/Controls/DonutMenu.cs index d26a890ec..04b6ff6fb 100644 --- a/src/Filee.App/Controls/DonutMenu.cs +++ b/src/Filee.App/Controls/DonutMenu.cs @@ -143,6 +143,11 @@ public DonutMenu() FlowDirection = FlowDirection.LeftToRight; ClipToBounds = false; Cursor = new Cursor(StandardCursorType.Hand); + // The live click-mode donut handles Tab, arrows, Enter and Escape itself. It must be the focused control + // after a context-menu/shortcut launch; otherwise Avalonia has no focused target to which the key events can + // be routed. It is focused explicitly by RadialController, so it must not participate in normal Tab order. + Focusable = true; + IsTabStop = false; } public IReadOnlyList? Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); } diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 943831e63..bfcd335ba 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -610,7 +610,13 @@ private void PlaceAndShow(RadialWindow window, int x, int y, bool activate) } _visible = true; if (activate) + { window.Activate(); + // ShowActivated is false because the same window is also used during an OS drag, where taking focus would + // break Explorer's drag. Click mode is different: the donut owns the keyboard until it closes, so focus it + // explicitly for Tab/arrow/Enter handling. + window.DonutControl.Focus(); + } } private static bool SameFiles(IReadOnlyList a, IReadOnlyList b) => diff --git a/tests/Filee.App.Tests/RadialControllerTests.cs b/tests/Filee.App.Tests/RadialControllerTests.cs index 2c99a4848..ede1551c7 100644 --- a/tests/Filee.App.Tests/RadialControllerTests.cs +++ b/tests/Filee.App.Tests/RadialControllerTests.cs @@ -2,7 +2,9 @@ // the gesture ends without files, even after a conversion left the ring behind (issue: desktop icons stopped reacting // to clicks until Filee quit). +using Avalonia.Headless; using Avalonia.Headless.XUnit; +using Avalonia.Input; using Filee.App.Controls; using Filee.App.Services; using Filee.Core.Presets; @@ -59,6 +61,26 @@ public void Esc_closes_a_donut_whose_ring_already_finished() Assert.False(radial.Window.IsVisible); } + [AvaloniaFact] + public void Click_mode_focuses_the_donut_for_tab_flip() + { + TestServices.EnsureInitialized("en"); + var radial = AppHost.Get(); + try + { + radial.ShowForFiles(400, 400, [@"C:\photos\a.jpg"]); + var window = radial.Window!; + + Assert.True(window.DonutControl.IsFocused); + window.KeyPress(Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); + Assert.True(AppHost.Get().IsBackFace); + } + finally + { + radial.Close(animate: false); + } + } + [Fact] public void Folders_only_go_into_one_archive() {