From 91b74a462ee8a1fa20a1b7bd9b6fbc9e3bf989d2 Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 27 Mar 2023 20:13:39 +0100 Subject: [PATCH 1/5] Add SpinnerView --- Terminal.Gui/Views/SpinnerView.cs | 74 +++++++++++++++++++++++++++++ UICatalog/Scenarios/Progress.cs | 12 ++++- UnitTests/Views/SpinnerViewTests.cs | 71 +++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 Terminal.Gui/Views/SpinnerView.cs create mode 100644 UnitTests/Views/SpinnerViewTests.cs diff --git a/Terminal.Gui/Views/SpinnerView.cs b/Terminal.Gui/Views/SpinnerView.cs new file mode 100644 index 000000000..06984462c --- /dev/null +++ b/Terminal.Gui/Views/SpinnerView.cs @@ -0,0 +1,74 @@ +using System; + +namespace Terminal.Gui { + + /// + /// A 1x1 based on which displays a spinning + /// line character. + /// + /// + /// By default animation only occurs when you call . + /// Use to make the automate calls to . + /// + public class SpinnerView : Label { + private Rune [] runes = new Rune [] { '|', '/', '\u2500', '\\' }; + private int currentIdx = 0; + private DateTime lastRender = DateTime.MinValue; + private object _timeout; + + /// + /// Gets or sets the number of milliseconds to wait between characters + /// in the spin. Defaults to 250. + /// + /// This is the maximum speed the spinner will rotate at. You still need to + /// call or to + /// advance/start animation. + public int SpinDelayInMilliseconds { get; set; } = 250; + + /// + /// Creates a new instance of the class. + /// + public SpinnerView () + { + Width = 1; Height = 1; + } + + /// + public override void Redraw (Rect bounds) + { + if (DateTime.Now - lastRender > TimeSpan.FromMilliseconds (SpinDelayInMilliseconds)) { + currentIdx = (currentIdx + 1) % runes.Length; + Text = "" + runes [currentIdx]; + lastRender = DateTime.Now; + } + + base.Redraw (bounds); + } + + /// + /// Automates spinning + /// + public void AutoSpin() + { + if(_timeout != null) { + return; + } + + _timeout = Application.MainLoop.AddTimeout ( + TimeSpan.FromMilliseconds (SpinDelayInMilliseconds), (m) => { + Application.MainLoop.Invoke (this.SetNeedsDisplay); + return true; + }); + } + + /// + protected override void Dispose (bool disposing) + { + if (_timeout != null) { + Application.MainLoop.RemoveTimeout (_timeout); + } + + base.Dispose (disposing); + } + } +} \ No newline at end of file diff --git a/UICatalog/Scenarios/Progress.cs b/UICatalog/Scenarios/Progress.cs index 40cbb5aa9..94f278e71 100644 --- a/UICatalog/Scenarios/Progress.cs +++ b/UICatalog/Scenarios/Progress.cs @@ -3,6 +3,7 @@ using System; using System.Threading; using Terminal.Gui; using System.Linq; +using System.Runtime.CompilerServices; namespace UICatalog.Scenarios { // @@ -20,6 +21,7 @@ namespace UICatalog.Scenarios { internal TextField Speed { get; private set; } internal ProgressBar ActivityProgressBar { get; private set; } internal ProgressBar PulseProgressBar { get; private set; } + internal SpinnerView Spinner { get; private set; } internal Action StartBtnClick; internal Action StopBtnClick; internal Action PulseBtnClick = null; @@ -77,13 +79,19 @@ namespace UICatalog.Scenarios { ActivityProgressBar = new ProgressBar () { X = Pos.Right (LeftFrame) + 1, Y = Pos.Bottom (startButton) + 1, - Width = Dim.Fill (), + Width = Dim.Fill (1), Height = 1, Fraction = 0.25F, ColorScheme = Colors.Error }; Add (ActivityProgressBar); + Spinner = new SpinnerView { + X = Pos.Right (ActivityProgressBar), + Y = ActivityProgressBar.Y + }; + Add (Spinner); + PulseProgressBar = new ProgressBar () { X = Pos.Right (LeftFrame) + 1, Y = Pos.Bottom (ActivityProgressBar) + 1, @@ -129,6 +137,7 @@ namespace UICatalog.Scenarios { ActivityProgressBar.Fraction += 0.01F; } PulseProgressBar.Pulse (); + Spinner.SetNeedsDisplay (); } } } @@ -196,6 +205,7 @@ namespace UICatalog.Scenarios { _mainLoopTimeout = Application.MainLoop.AddTimeout (TimeSpan.FromMilliseconds (_mainLooopTimeoutTick), (loop) => { mainLoopTimeoutDemo.Pulse (); + return true; }); }; diff --git a/UnitTests/Views/SpinnerViewTests.cs b/UnitTests/Views/SpinnerViewTests.cs new file mode 100644 index 000000000..3a6d6c482 --- /dev/null +++ b/UnitTests/Views/SpinnerViewTests.cs @@ -0,0 +1,71 @@ +using Terminal.Gui; +using Xunit; +using Xunit.Abstractions; + +namespace UnitTests.Views { + public class SpinnerViewTests { + + readonly ITestOutputHelper output; + + public SpinnerViewTests (ITestOutputHelper output) + { + this.output = output; + } + + + [Fact,AutoInitShutdown] + public void TestSpinnerView_ThrottlesAnimation() + { + var view = GetSpinnerView (); + + view.Redraw (view.Bounds); + + var expected = "/"; + TestHelpers.AssertDriverContentsWithFrameAre (expected, output); + + view.SetNeedsDisplay (); + view.Redraw (view.Bounds); + + expected = "/"; + TestHelpers.AssertDriverContentsWithFrameAre (expected, output); + + view.SetNeedsDisplay (); + view.Redraw (view.Bounds); + + expected = "/"; + TestHelpers.AssertDriverContentsWithFrameAre (expected, output); + } + [Fact, AutoInitShutdown] + public void TestSpinnerView_NoThrottle() + { + var view = GetSpinnerView (); + view.SpinDelayInMilliseconds = 0; + + view.Redraw (view.Bounds); + + + var expected = @"─"; + TestHelpers.AssertDriverContentsWithFrameAre (expected, output); + + view.SetNeedsDisplay (); + view.Redraw (view.Bounds); + + + expected = @"\"; + TestHelpers.AssertDriverContentsWithFrameAre (expected, output); + } + + private SpinnerView GetSpinnerView () + { + var view = new SpinnerView (); + + Application.Top.Add (view); + Application.Begin (Application.Top); + + Assert.Equal (1, view.Width); + Assert.Equal (1, view.Height); + + return view; + } + } +} \ No newline at end of file From b29839a2aa3d37eb2826dcf7b5cdc73c14e3f582 Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 27 Mar 2023 20:16:34 +0100 Subject: [PATCH 2/5] Code formatting --- Terminal.Gui/Views/SpinnerView.cs | 18 +++++++++--------- UnitTests/Views/SpinnerViewTests.cs | 12 ++++++------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Terminal.Gui/Views/SpinnerView.cs b/Terminal.Gui/Views/SpinnerView.cs index 06984462c..dccf19eec 100644 --- a/Terminal.Gui/Views/SpinnerView.cs +++ b/Terminal.Gui/Views/SpinnerView.cs @@ -11,9 +11,9 @@ namespace Terminal.Gui { /// Use to make the automate calls to . /// public class SpinnerView : Label { - private Rune [] runes = new Rune [] { '|', '/', '\u2500', '\\' }; - private int currentIdx = 0; - private DateTime lastRender = DateTime.MinValue; + private Rune [] _runes = new Rune [] { '|', '/', '\u2500', '\\' }; + private int _currentIdx = 0; + private DateTime _lastRender = DateTime.MinValue; private object _timeout; /// @@ -36,10 +36,10 @@ namespace Terminal.Gui { /// public override void Redraw (Rect bounds) { - if (DateTime.Now - lastRender > TimeSpan.FromMilliseconds (SpinDelayInMilliseconds)) { - currentIdx = (currentIdx + 1) % runes.Length; - Text = "" + runes [currentIdx]; - lastRender = DateTime.Now; + if (DateTime.Now - _lastRender > TimeSpan.FromMilliseconds (SpinDelayInMilliseconds)) { + _currentIdx = (_currentIdx + 1) % _runes.Length; + Text = "" + _runes [_currentIdx]; + _lastRender = DateTime.Now; } base.Redraw (bounds); @@ -48,9 +48,9 @@ namespace Terminal.Gui { /// /// Automates spinning /// - public void AutoSpin() + public void AutoSpin () { - if(_timeout != null) { + if (_timeout != null) { return; } diff --git a/UnitTests/Views/SpinnerViewTests.cs b/UnitTests/Views/SpinnerViewTests.cs index 3a6d6c482..67f45c283 100644 --- a/UnitTests/Views/SpinnerViewTests.cs +++ b/UnitTests/Views/SpinnerViewTests.cs @@ -13,13 +13,13 @@ namespace UnitTests.Views { } - [Fact,AutoInitShutdown] - public void TestSpinnerView_ThrottlesAnimation() + [Fact, AutoInitShutdown] + public void TestSpinnerView_ThrottlesAnimation () { var view = GetSpinnerView (); view.Redraw (view.Bounds); - + var expected = "/"; TestHelpers.AssertDriverContentsWithFrameAre (expected, output); @@ -36,7 +36,7 @@ namespace UnitTests.Views { TestHelpers.AssertDriverContentsWithFrameAre (expected, output); } [Fact, AutoInitShutdown] - public void TestSpinnerView_NoThrottle() + public void TestSpinnerView_NoThrottle () { var view = GetSpinnerView (); view.SpinDelayInMilliseconds = 0; @@ -57,8 +57,8 @@ namespace UnitTests.Views { private SpinnerView GetSpinnerView () { - var view = new SpinnerView (); - + var view = new SpinnerView (); + Application.Top.Add (view); Application.Begin (Application.Top); From c052a30f47ff37de8de8fa157c3658afc97404b3 Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 27 Mar 2023 20:26:36 +0100 Subject: [PATCH 3/5] Add AutoSpin test --- UnitTests/Views/SpinnerViewTests.cs | 32 ++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/UnitTests/Views/SpinnerViewTests.cs b/UnitTests/Views/SpinnerViewTests.cs index 67f45c283..d43f56c19 100644 --- a/UnitTests/Views/SpinnerViewTests.cs +++ b/UnitTests/Views/SpinnerViewTests.cs @@ -1,4 +1,5 @@ -using Terminal.Gui; +using System.Threading.Tasks; +using Terminal.Gui; using Xunit; using Xunit.Abstractions; @@ -12,6 +13,27 @@ namespace UnitTests.Views { this.output = output; } + [Fact, AutoInitShutdown] + public void TestSpinnerView_AutoSpin() + { + var view = GetSpinnerView (); + + Assert.Empty (Application.MainLoop.timeouts); + view.AutoSpin (); + Assert.NotEmpty (Application.MainLoop.timeouts); + + //More calls to AutoSpin do not add more timeouts + Assert.Equal (1,Application.MainLoop.timeouts.Count); + view.AutoSpin (); + view.AutoSpin (); + view.AutoSpin (); + Assert.Equal (1, Application.MainLoop.timeouts.Count); + + // Dispose clears timeout + Assert.NotEmpty (Application.MainLoop.timeouts); + view.Dispose (); + Assert.Empty (Application.MainLoop.timeouts); + } [Fact, AutoInitShutdown] public void TestSpinnerView_ThrottlesAnimation () @@ -34,6 +56,14 @@ namespace UnitTests.Views { expected = "/"; TestHelpers.AssertDriverContentsWithFrameAre (expected, output); + + Task.Delay (400).Wait(); + + view.SetNeedsDisplay (); + view.Redraw (view.Bounds); + + expected = "─"; + TestHelpers.AssertDriverContentsWithFrameAre (expected, output); } [Fact, AutoInitShutdown] public void TestSpinnerView_NoThrottle () From e88763a3e106cc9cd7ac03df0341f71fd995fdfc Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 27 Mar 2023 20:31:05 +0100 Subject: [PATCH 4/5] Avoid ever removing spinner timeout twice --- Terminal.Gui/Views/SpinnerView.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Terminal.Gui/Views/SpinnerView.cs b/Terminal.Gui/Views/SpinnerView.cs index dccf19eec..6cd25d698 100644 --- a/Terminal.Gui/Views/SpinnerView.cs +++ b/Terminal.Gui/Views/SpinnerView.cs @@ -66,6 +66,7 @@ namespace Terminal.Gui { { if (_timeout != null) { Application.MainLoop.RemoveTimeout (_timeout); + _timeout = null; } base.Dispose (disposing); From 30f830e22decd4f445da3797a18ebdfc778e7c81 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 27 Mar 2023 20:53:48 +0100 Subject: [PATCH 5/5] Make SpinnerView show/hide instead of stopping --- UICatalog/Scenarios/Progress.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/UICatalog/Scenarios/Progress.cs b/UICatalog/Scenarios/Progress.cs index 94f278e71..9c809f23e 100644 --- a/UICatalog/Scenarios/Progress.cs +++ b/UICatalog/Scenarios/Progress.cs @@ -79,7 +79,7 @@ namespace UICatalog.Scenarios { ActivityProgressBar = new ProgressBar () { X = Pos.Right (LeftFrame) + 1, Y = Pos.Bottom (startButton) + 1, - Width = Dim.Fill (1), + Width = Dim.Fill (), Height = 1, Fraction = 0.25F, ColorScheme = Colors.Error @@ -88,7 +88,9 @@ namespace UICatalog.Scenarios { Spinner = new SpinnerView { X = Pos.Right (ActivityProgressBar), - Y = ActivityProgressBar.Y + Y = ActivityProgressBar.Y, + Visible = false, + }; Add (Spinner); @@ -117,12 +119,23 @@ namespace UICatalog.Scenarios { { Started = true; StartBtnClick?.Invoke (); + Application.MainLoop.Invoke(()=>{ + Spinner.Visible = true; + ActivityProgressBar.Width = Dim.Fill(1); + this.LayoutSubviews(); + }); } internal void Stop () { Started = false; StopBtnClick?.Invoke (); + + Application.MainLoop.Invoke(()=>{ + Spinner.Visible = false; + ActivityProgressBar.Width = Dim.Fill(); + this.LayoutSubviews(); + }); } internal void Pulse ()