From 4bcf45155938c0bcffa425479144fab9033083c5 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 4 Oct 2026 03:08:29 +0900 Subject: [PATCH 1/2] Donut: a lost animation frame no longer keeps the slice under a dragged file dark Reported: Ctrl+drag opened the donut, but the slices didn't light up under the files, then later they did. The donut's animations run in one frame loop; when a frame request is lost (e.g. requested while the window was hidden), the loop still counted as running and every later animation waited for it, so the highlight springs never moved. A loop without a frame for 250 ms is now treated as stopped and a new one starts (frames of the old one are ignored). Each drag now also logs one line (move events, where it ended, how lit that slice was drawn), to tell "the drag never reached the donut" from "it did but nothing lit up" if this comes back. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Controls/DonutMenu.cs | 29 +++++++++++-- src/Filee.App/Services/RadialController.cs | 5 +++ src/Filee.App/Views/RadialWindow.axaml.cs | 14 +++++++ tests/Filee.App.Tests/DonutHighlightTests.cs | 43 ++++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 tests/Filee.App.Tests/DonutHighlightTests.cs diff --git a/src/Filee.App/Controls/DonutMenu.cs b/src/Filee.App/Controls/DonutMenu.cs index 88ad36d..5417530 100644 --- a/src/Filee.App/Controls/DonutMenu.cs +++ b/src/Filee.App/Controls/DonutMenu.cs @@ -221,6 +221,9 @@ public void SetExternalHighlight(int index, bool centerHot = false) 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) { @@ -485,28 +488,46 @@ public bool Step(double target, double dt, double stiffness = 320, double dampin } } + /// + /// 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 + private void StartAnimation() { InvalidateVisual(); - if (_animating) + if (_animating && Environment.TickCount64 - _lastTick < StalledLoopMs) return; var top = TopLevel.GetTopLevel(this); if (top is null) + { + _animating = false; return; + } _animating = true; _lastFrame = null; - top.RequestAnimationFrame(OnFrame); + _lastTick = Environment.TickCount64; + var loop = ++_loop; + top.RequestAnimationFrame(time => OnFrame(time, loop)); } - private void OnFrame(TimeSpan time) + 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(OnFrame); + top.RequestAnimationFrame(next => OnFrame(next, loop)); else _animating = false; } diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index cc4e751..8a51c98 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -136,6 +136,11 @@ private RadialWindow EnsureWindow() else 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 += (_, _) => { diff --git a/src/Filee.App/Views/RadialWindow.axaml.cs b/src/Filee.App/Views/RadialWindow.axaml.cs index f9f4ed8..869ff92 100644 --- a/src/Filee.App/Views/RadialWindow.axaml.cs +++ b/src/Filee.App/Views/RadialWindow.axaml.cs @@ -31,6 +31,15 @@ public RadialWindow() public event Action? DragLeft; + /// + /// After a drag ends (drop or leave): how many drag-over events came in, where the last one was and how far that + /// slice was drawn lit up (0 to 1). + /// + public event Action? DragSummary; + + private int _dragOverEvents; + private DonutHit _lastDragHit; + private void OnDragEnter(object? sender, DragEventArgs e) { var files = LocalFiles(e); @@ -39,6 +48,7 @@ private void OnDragEnter(object? sender, DragEventArgs e) e.DragEffects = DragDropEffects.None; return; } + _dragOverEvents = 0; FilesEntered?.Invoke(files); OnDragOver(sender, e); } @@ -46,6 +56,8 @@ private void OnDragEnter(object? sender, DragEventArgs e) private void OnDragOver(object? sender, DragEventArgs e) { var hit = Donut.HitTest(e.GetPosition(Donut)); + _dragOverEvents++; + _lastDragHit = hit; var files = e.DataTransfer.Contains(DataFormat.File); Donut.SetExternalHighlight(hit.Kind == DonutHitKind.Segment ? hit.Index : -1, hit.Kind == DonutHitKind.Center); @@ -56,6 +68,7 @@ private void OnDragOver(object? sender, DragEventArgs e) private void OnDragLeave(object? sender, DragEventArgs e) { + DragSummary?.Invoke(_dragOverEvents, _lastDragHit, Donut.LitAmount(_lastDragHit.Index)); Donut.SetExternalHighlight(-1); DragLeft?.Invoke(); } @@ -63,6 +76,7 @@ private void OnDragLeave(object? sender, DragEventArgs e) private void OnDrop(object? sender, DragEventArgs e) { var hit = Donut.HitTest(e.GetPosition(Donut)); + DragSummary?.Invoke(_dragOverEvents, hit, Donut.LitAmount(hit.Index)); Donut.SetExternalHighlight(-1); e.DragEffects = hit.Kind == DonutHitKind.Segment ? DragDropEffects.Copy : DragDropEffects.None; Dropped?.Invoke(hit, LocalFiles(e)); diff --git a/tests/Filee.App.Tests/DonutHighlightTests.cs b/tests/Filee.App.Tests/DonutHighlightTests.cs new file mode 100644 index 0000000..bff8df2 --- /dev/null +++ b/tests/Filee.App.Tests/DonutHighlightTests.cs @@ -0,0 +1,43 @@ +// The slice under a dragged file lights up even after a frame of the donut's animation loop got lost. + +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Filee.App.Controls; + +namespace Filee.App.Tests; + +public class DonutHighlightTests +{ + [AvaloniaFact] + public void A_lost_animation_frame_does_not_keep_the_slice_dark() + { + TestServices.EnsureInitialized("ko"); + var donut = new DonutMenu + { + Items = new[] { "PNG", "JPG", "WEBP", "PDF" }.Select(l => new DonutItem { Id = l, Label = l }).ToList(), + }; + var window = new Window { Content = donut, SizeToContent = SizeToContent.WidthAndHeight }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + // As after a frame request that never came back: the loop still counts as running, its last frame long ago. + Set(donut, "_animating", true); + Set(donut, "_lastTick", Environment.TickCount64 - DonutMenu.StalledLoopMs * 4); + + donut.SetExternalHighlight(2); + for (var i = 0; i < 90 && donut.LitAmount(2) < 0.9; i++) + { + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Dispatcher.UIThread.RunJobs(); + } + + Assert.True(donut.LitAmount(2) > 0.9, $"lit {donut.LitAmount(2):0.00}"); + window.Close(); + } + + private static void Set(DonutMenu donut, string field, object value) => + typeof(DonutMenu).GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(donut, value); +} From e6ffc0aa19aff75775811ff05072df3c69104d5b Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 4 Oct 2026 03:18:30 +0900 Subject: [PATCH 2/2] Version 1.5.1 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++++++++++ Directory.Build.props | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 035b309..36f4175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ version it publishes at the top of the GitHub release notes, above the list of p Add a `## ` section before tagging a release. Write what someone using Filee notices: what's new, where to find it, what it does and doesn't do. +## 1.5.1 + +### Fixes + +- Sometimes, after Ctrl+drag opened the donut, the format under the dragged files didn't light up and dropping + didn't pick it, until a while later. The donut's animation could wait forever for a frame that never came; it now + starts again after a quarter of a second. +- Each drag over the donut writes one line to the log (`%APPDATA%\Filee\logs`), so if a donut ever ignores a drag + again, the log shows whether the drag reached it. + ## 1.5.0 ### FFmpeg, calibre and the others you already have are used by themselves diff --git a/Directory.Build.props b/Directory.Build.props index 4029204..deac5c3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,7 @@ parameters of a member is fine (CS1573). --> $(NoWarn);CS1591;CS1573 - 1.5.0 + 1.5.1 Filee Filee contributors Copyright (c) Filee contributors