OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 52962c7

A keyframe edit did not reach the viewport until the frame changed
"if you edit a keyframe there it won't update the viewport until you change the frame." AnimationView.TimeChanged re-evaluated the dopesheet onto the fractal and invalidated the params buffer. AnimationView.Changed - which every keyframe edit raises, a value, a type, a handle, a drag - pushed the panel state and stopped. So the fractal kept whatever values the last EvaluateAt had written onto it, and the edit became visible only when the playhead next moved and re-evaluated for its own reasons. Routed through PostThenRefresh, which exists for this: it orders the UI read after the queued mutation instead of racing it. The previous code also read the fractal on the UI thread while nothing guaranteed the evaluation had run, so the sliders could show the values being replaced. The third instance of this ordering bug in this shell, after nodes and post effects. FakeRenderQueue defers exactly as the real service does precisely so a fix that only mutated the model on the UI thread cannot pass; confirmed red against the previous wiring before being committed green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
52962c751d010e4baccc58c7708cdf0671afbc1e
by plankatron <fleeky@gmail.com> · 2026-09-05T18:49:03.000Z

Changed files (2) +108 -3

AvaloniaShell.Tests/AnimationViewportRefreshTests.cs +76 -0
……@@ -0,0 +1,76 @@
1using System;
2using System.Linq;
3
4using Avalonia;
5using Avalonia.Controls;
6using Avalonia.Headless.XUnit;
7using Avalonia.LogicalTree;
8
9using IFSEngine.Animation;
10
11using IFSRenderer.AvaloniaShell.Tests.Fakes;
12
13using Xunit;
14
15namespace IFSRenderer.AvaloniaShell.Tests;
16
17/// <summary>
18/// Editing a keyframe has to reach the viewport.
19/// </summary>
20/// <remarks>
21/// <para>
22/// "if you edit a keyframe there it won't update the viewport until you change the frame."
23/// </para>
24/// <para>
25/// The same ORDERING bug this harness was built for, in a third place. <c>TimeChanged</c>
26/// re-evaluated the dopesheet onto the fractal and invalidated the params buffer;
27/// <c>Changed</c> - which every keyframe edit raises - pushed the panel state and stopped. The
28/// fractal kept the values the last <c>EvaluateAt</c> had written, so the edit became visible
29/// only when the playhead next moved and re-evaluated for its own reasons.
30/// </para>
31/// <para>
32/// Asserted through the queue rather than by reading the fractal: the point is that the engine
33/// was asked to re-render, and <see cref="FakeRenderQueue"/> defers exactly as the real service
34/// does, so a fix that merely mutated the model on the UI thread would not pass.
35/// </para>
36/// </remarks>
37public class AnimationViewportRefreshTests
38{
39 [AvaloniaFact]
40 public void Editing_a_keyframe_re_evaluates_without_moving_the_playhead()
41 {
42 (MainWindow window, FakeRenderQueue queue) = MainWindowHarness.Start();
43
44 // By name, and through the LOGICAL tree: the animation panel lives in a tab that the
45 // headless run never realizes, so it has no visual tree to walk.
46 AnimationPanel panel = window.FindControl<AnimationPanel>("AnimationView");
47 Assert.NotNull(panel);
48 Dopesheet sheet = queue.Current.Dopesheet;
49
50 // Two keys on one channel, made the way the ◇ diamond makes them.
51 foreach ((double t, double v) in new[] { (0.0, 1.0), (4.0, 5.0) })
52 sheet.AddOrUpdateChannel("Brightness", "Brightness",
53 TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(t)), v);
54 panel.SetFractal(queue.Current);
55 panel.ReloadChannels();
56 MainWindowHarness.Settle(queue);
57
58 CurveCanvas curve = panel.GetLogicalDescendants().OfType<CurveCanvas>().Single();
59 Keyframe key = sheet.Channels["Brightness"].Keyframes[0];
60 curve.BeginDrag(key, new Point(0, 0));
61
62 queue.Renderer.Calls.Clear();
63 int postsBefore = queue.PostCount;
64
65 // The edit, with the playhead left exactly where it was.
66 key.Value = 42;
67 curve.EndDrag();
68 MainWindowHarness.Settle(queue);
69
70 Assert.True(queue.PostCount > postsBefore,
71 "editing a keyframe queued no engine work at all");
72 Assert.Contains(nameof(FakeRenderer.InvalidateParamsBuffer), queue.Renderer.Calls);
73 // The evaluation ran, so the fractal now carries the edited value.
74 Assert.Equal(42, queue.Current.Brightness, 6);
75 }
76}
AvaloniaShell/MainWindow.axaml.cs +32 -3
……@@ -1,4 +1,4 @@
1using System;
1using System;
22using System.Collections.Generic;
33using System.Globalization;
44using System.IO;
……@@ -768,10 +768,39 @@ public partial class MainWindow : Window
768768 // drifted", so every diamond has to be repainted.
769769 ValueSlider.NotifyAnimationStateChanged();
770770 };
771 // Raised by every keyframe edit - a value, an interpolation type, a handle, a drag.
772 //
773 // This used to push the panel state and stop there, so an edit did NOT reach the
774 // viewport: the fractal kept whatever values the last EvaluateAt had written onto it,
775 // and TimeChanged above is the only other thing that calls one. The edit appeared the
776 // moment the playhead moved, which is exactly how it was reported - "if you edit a
777 // keyframe there it won't update the viewport until you change the frame".
771778 AnimationView.Changed += () =>
772779 {
773 PushStateToUi(_service.Current);
774 ValueSlider.NotifyAnimationStateChanged();
780 IFS f = _service.Current;
781 if (f is null)
782 return;
783
784 if (f.Dopesheet.Channels.Count == 0)
785 {
786 PushStateToUi(f);
787 ValueSlider.NotifyAnimationStateChanged();
788 return;
789 }
790
791 PostThenRefresh(
792 r =>
793 {
794 f.EvaluateAt(TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(AnimationView.CurrentTime)));
795 r.InvalidateParamsBuffer();
796 },
797 () =>
798 {
799 // Ordered AFTER the evaluation, so the sliders show the values that were
800 // just written rather than the ones being replaced.
801 PushStateToUi(f);
802 ValueSlider.NotifyAnimationStateChanged();
803 });
775804 };
776805 AnimationView.ExportRequested += async () => await ExportClip();
777806