Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions Examples/UICatalog/Scenarios/MinimalSpinnerDemo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
namespace UICatalog.Scenarios;

[ScenarioMetadata ("Spinner Demo", "Minimal SpinnerView demo with auto-spin.")]
[ScenarioCategory ("Controls")]
[ScenarioCategory ("Progress")]
public class MinimalSpinnerDemo : Scenario
{
public override void Main ()
{
using IApplication app = Application.Create ();
app.Init ();

using Window main = new () { Title = GetQuitKeyAndName () };

SpinnerView spinner = new ()
{
X = Pos.Center (),
Y = Pos.Center (),
AutoSpin = true
};

CheckBox chkAutoSpin = new ()
{
Text = "AutoSpin",
X = Pos.Center (),
Y = Pos.Bottom (spinner) + 1,
Value = CheckState.Checked
};
chkAutoSpin.ValueChanged += (_, e) => spinner.AutoSpin = e.NewValue == CheckState.Checked;

Label lblSequence = new ()
{
Text = "Sequence (comma-separated):",
X = Pos.Center (),
Y = Pos.Bottom (chkAutoSpin) + 1
};

TextField tfSequence = new ()
{
Text = string.Join (",", spinner.Sequence),
X = Pos.Center (),
Y = Pos.Bottom (lblSequence),
Width = 30
};
tfSequence.Accepting += (_, _) =>
{
string [] frames = tfSequence.Text
.Split (',', StringSplitOptions.RemoveEmptyEntries);

if (frames.Length > 0)
{
spinner.Sequence = frames;
}
};

Button btnAdvance = new ()
{
Text = "Advance",
X = Pos.Center (),
Y = Pos.Bottom (tfSequence) + 1
};
btnAdvance.Accepting += (_, _) => spinner.AdvanceAnimation ();

main.Add (spinner, chkAutoSpin, lblSequence, tfSequence, btnAdvance);

app.Run (main);
}
}
16 changes: 15 additions & 1 deletion Terminal.Gui/Views/SpinnerView/SpinnerView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class SpinnerView : View, IDesignable
private const int DEFAULT_DELAY = 130;
private static readonly SpinnerStyle DEFAULT_STYLE = new SpinnerStyle.Line ();

private bool _autoSpin;
private bool _bounce = DEFAULT_STYLE.SpinBounce;
private bool _bounceReverse;
private int _currentIdx;
Expand Down Expand Up @@ -46,9 +47,11 @@ public SpinnerView ()
/// </summary>
public bool AutoSpin
{
get => _timeout != null;
get => _autoSpin;
set
{
_autoSpin = value;

if (value)
{
AddAutoSpinTimeout ();
Expand Down Expand Up @@ -199,6 +202,17 @@ protected override void Dispose (bool disposing)
base.Dispose (disposing);
}

/// <inheritdoc/>
public override void EndInit ()
{
base.EndInit ();

if (_autoSpin)
{
AddAutoSpinTimeout ();
}
}

private void AddAutoSpinTimeout ()
{
// Only add timeout if we are initialized and not already spinning
Expand Down
43 changes: 43 additions & 0 deletions Tests/IntegrationTests/FluentTests/SpinnerViewTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copilot
using System.Reflection;
using AppTestHelpers;
using AppTestHelpers.XunitHelpers;

namespace IntegrationTests;

/// <summary>
/// Integration tests for <see cref="SpinnerView"/> that require a running application.
/// </summary>
public class SpinnerViewTests (ITestOutputHelper outputHelper) : TestsAllDrivers
{
private readonly TextWriter _out = new TestOutputWriter (outputHelper);

[Theory]
[MemberData (nameof (GetAllDriverNames))]
public void AutoSpin_SetBeforeAppRun_TimeoutRegisteredAfterEndInit (string d)
{
// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4879.
//
// Before the fix: AutoSpin = true set before App.Run() left _timeout null because
// AddAutoSpinTimeout() silently exited early (App was null). The spinner showed its
// first frame and never moved.
//
// After the fix: EndInit() calls AddAutoSpinTimeout() again once App is set, so the
// timeout IS registered and the spinner animates.

SpinnerView spinner = new () { AutoSpin = true };

FieldInfo timeoutField = typeof (SpinnerView)
.GetField ("_timeout", BindingFlags.NonPublic | BindingFlags.Instance)!;

// Before being part of a running application, _timeout must be null.
Assert.Null (timeoutField.GetValue (spinner));

// Add the spinner to a running Window. Because the Window is already initialised,
// View.Add() triggers BeginInit()/EndInit() on the spinner immediately, with App set.
using AppTestHelper _ = With.A<Window> (40, 10, d, _out).Add (spinner);

// EndInit() should have called AddAutoSpinTimeout(), which registers the timeout.
Assert.NotNull (timeoutField.GetValue (spinner));
}
}
20 changes: 20 additions & 0 deletions Tests/UnitTestsParallelizable/Views/SpinnerViewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ public void AdvanceAnimation_DefaultDelay_ThrottlesRapidCalls ()
Assert.Equal (frame1, frame2);
}

[Fact]
public void AutoSpin_SetBeforeEndInit_GetterReturnsTrueWithNoApp ()
{
// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4879
// Before the fix AutoSpin returned `_timeout != null`. When App is null the timeout
// can never be registered, so the getter falsely returned false even though the
// caller had set AutoSpin = true. The fix uses a dedicated _autoSpin backing field.
SpinnerView spinner = new () { AutoSpin = true };

// App is null here (no running application), so _timeout is null.
// The getter must still report true based on the backing field.
Assert.True (spinner.AutoSpin);

spinner.BeginInit ();
spinner.EndInit ();

// After init the intent must be preserved.
Assert.True (spinner.AutoSpin);
}

[Fact]
public void AdvanceAnimation_ZeroDelay_AdvancesFrame ()
{
Expand Down
Loading