Skip to content

Commit dbb9fdf

Browse files
author
Nikolaos Protopapas
committed
fix(cursor): stop redundant cursor escape re-emission (calm blink); add CursorBlink option and opt-in software cursor
- CursorStateService caches last-applied (visible,position,shape) and emits only on change, with InvalidatePhysicalCursor() after each rendered frame — fixes the too-fast blink caused by re-emitting CUP/DECTCEM/DECSCUSR every main-loop iteration (terminals reset blink on move) - NetConsoleDriverOptions.CursorBlink (Steady/Blinking/TerminalDefault, default Blinking) via DECSCUSR shape x blink codes - Opt-in NetConsoleDriverOptions.UseSoftwareCursor: CursorBlinkClock-driven inverse-video caret overlay at the logical cursor position, blinking at CursorBlinkRate, with render keep-alive Closes #37
1 parent b2e4700 commit dbb9fdf

14 files changed

Lines changed: 421 additions & 28 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using SharpConsoleUI.Core;
2+
using Xunit;
3+
4+
namespace SharpConsoleUI.Tests.Core;
5+
6+
public class CursorBlinkClockTests
7+
{
8+
[Fact]
9+
public void StartsOn_TogglesAfterFullRate_ResetReturnsOn()
10+
{
11+
var clock = new CursorBlinkClock();
12+
Assert.True(clock.IsOn(rateMs: 500)); // solid initially
13+
14+
clock.Advance(300);
15+
Assert.True(clock.IsOn(500)); // still within first on-phase
16+
17+
clock.Advance(300); // total 600 > 500 -> off phase
18+
Assert.False(clock.IsOn(500));
19+
20+
clock.Advance(500); // into next on-phase (total 1100 -> cycle 2)
21+
Assert.True(clock.IsOn(500));
22+
23+
clock.Advance(400); // total 1500 -> cycle 3 -> off
24+
Assert.False(clock.IsOn(500));
25+
clock.Reset();
26+
Assert.True(clock.IsOn(500)); // reset forces solid on
27+
}
28+
29+
[Fact]
30+
public void NonPositiveRate_AlwaysOn()
31+
{
32+
var clock = new CursorBlinkClock();
33+
clock.Advance(99999);
34+
Assert.True(clock.IsOn(0));
35+
}
36+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Drawing;
2+
using SharpConsoleUI;
3+
using SharpConsoleUI.Core;
4+
using SharpConsoleUI.Drivers;
5+
using Xunit;
6+
7+
namespace SharpConsoleUI.Tests.Core;
8+
9+
public class CursorStateServiceChangeDetectionTests
10+
{
11+
private static (CursorStateService svc, HeadlessConsoleDriver drv, Window win) Make()
12+
{
13+
var driver = new HeadlessConsoleDriver(80, 25);
14+
var system = new ConsoleWindowSystem(driver);
15+
var win = new Window(system);
16+
var svc = new CursorStateService(driver);
17+
return (svc, driver, win);
18+
}
19+
20+
[Fact]
21+
public void ApplyCursor_DoesNotReemit_WhenStateUnchanged()
22+
{
23+
var (svc, drv, win) = Make();
24+
svc.UpdateFromWindowSystem(win, new Point(2, 3), new Point(2, 3), null, CursorShape.Block);
25+
for (int i = 0; i < 5; i++) svc.ApplyCursorToConsole(80, 25);
26+
Assert.Equal(1, drv.SetCursorPositionCallCount);
27+
Assert.Equal(1, drv.SetCursorVisibleCallCount);
28+
}
29+
30+
[Fact]
31+
public void ApplyCursor_Reemits_AfterPhysicalCursorInvalidated()
32+
{
33+
var (svc, drv, win) = Make();
34+
svc.UpdateFromWindowSystem(win, new Point(2, 3), new Point(2, 3), null, CursorShape.Block);
35+
svc.ApplyCursorToConsole(80, 25);
36+
Assert.Equal(1, drv.SetCursorPositionCallCount);
37+
svc.InvalidatePhysicalCursor();
38+
svc.ApplyCursorToConsole(80, 25);
39+
Assert.Equal(2, drv.SetCursorPositionCallCount);
40+
}
41+
42+
[Fact]
43+
public void ApplyCursor_Reemits_WhenPositionChanges()
44+
{
45+
var (svc, drv, win) = Make();
46+
svc.UpdateFromWindowSystem(win, new Point(2, 3), new Point(2, 3), null, CursorShape.Block);
47+
svc.ApplyCursorToConsole(80, 25);
48+
svc.UpdateFromWindowSystem(win, new Point(5, 3), new Point(5, 3), null, CursorShape.Block);
49+
svc.ApplyCursorToConsole(80, 25);
50+
Assert.Equal(2, drv.SetCursorPositionCallCount);
51+
}
52+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using SharpConsoleUI.Core;
2+
using SharpConsoleUI.Drivers;
3+
using Xunit;
4+
5+
namespace SharpConsoleUI.Tests.Drivers;
6+
7+
public class DecscusrCodeTests
8+
{
9+
[Theory]
10+
[InlineData(CursorShape.Block, CursorBlink.Blinking, 1)]
11+
[InlineData(CursorShape.Block, CursorBlink.Steady, 2)]
12+
[InlineData(CursorShape.Underline, CursorBlink.Blinking, 3)]
13+
[InlineData(CursorShape.Underline, CursorBlink.Steady, 4)]
14+
[InlineData(CursorShape.VerticalBar, CursorBlink.Blinking, 5)]
15+
[InlineData(CursorShape.VerticalBar, CursorBlink.Steady, 6)]
16+
[InlineData(CursorShape.Block, CursorBlink.TerminalDefault, 0)]
17+
public void DecscusrCode_MapsShapeAndBlink(CursorShape shape, CursorBlink blink, int expected)
18+
{
19+
Assert.Equal(expected, NetConsoleDriver.DecscusrCode(shape, blink));
20+
}
21+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using System.Drawing;
2+
using SharpConsoleUI.Drivers;
3+
using Xunit;
4+
5+
namespace SharpConsoleUI.Tests.Drivers;
6+
7+
public class TryGetCellTests
8+
{
9+
[Fact]
10+
public void TryGetCell_ReturnsWrittenCell()
11+
{
12+
var buffer = new ConsoleBuffer(10, 3);
13+
buffer.SetNarrowCell(4, 1, 'Z', Color.White, Color.Black);
14+
15+
Assert.True(buffer.TryGetCell(4, 1, out var ch, out var fg, out var bg));
16+
Assert.Equal('Z', ch);
17+
Assert.Equal(Color.White, fg);
18+
Assert.Equal(Color.Black, bg);
19+
20+
Assert.False(buffer.TryGetCell(-1, 0, out _, out _, out _));
21+
}
22+
}

SharpConsoleUI/ConsoleWindowSystem.cs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ public class ConsoleWindowSystem
5656
// Managed thread id of the main-loop (UI) thread; set at Run() start. -1 until then.
5757
private int _uiThreadId = -1;
5858

59+
// Software cursor: opt-in caret drawn by the app (blinking) with hardware cursor hidden.
60+
// Resolved once at startup from the driver's options.
61+
private bool _useSoftwareCursor;
62+
private int _cursorBlinkRateMs = 500;
63+
private readonly Core.CursorBlinkClock _blinkClock = new();
64+
private System.Drawing.Point _lastSoftwareCursorPos = new(-1, -1);
65+
5966
// Signal to wake the main loop when input or UI actions arrive
6067
private readonly ManualResetEventSlim _wakeSignal = new(false);
6168

@@ -407,6 +414,14 @@ public ConsoleWindowSystem(IConsoleDriver driver, ITheme theme, PluginConfigurat
407414

408415
// Initialize panels from config or legacy StatusBarOptions
409416
_panelStateService.InitializePanels(_options);
417+
418+
// Resolve software-cursor settings once from the driver's options (if it exposes them).
419+
// Headless/other drivers leave the defaults (false / 500ms).
420+
if (_consoleDriver is Drivers.NetConsoleDriver netDriver)
421+
{
422+
_useSoftwareCursor = netDriver.Options.UseSoftwareCursor;
423+
_cursorBlinkRateMs = netDriver.Options.CursorBlinkRate;
424+
}
410425
}
411426

412427
#endregion
@@ -460,6 +475,10 @@ public IConsoleDriver ConsoleDriver
460475
/// </summary>
461476
internal CursorStateService CursorStateService => _cursorStateService;
462477

478+
/// <summary>True when a software caret should currently be drawn.</summary>
479+
internal bool SoftwareCursorOn =>
480+
_useSoftwareCursor && _cursorStateService.CurrentState.IsVisible && _blinkClock.IsOn(_cursorBlinkRateMs);
481+
463482
/// <summary>
464483
/// Gets the window state service for managing window lifecycle and state.
465484
/// </summary>
@@ -920,6 +939,8 @@ public int Run()
920939
var now = DateTime.UtcNow;
921940
var elapsed = (now - _lastRenderTime).TotalMilliseconds;
922941

942+
if (_useSoftwareCursor) _blinkClock.Advance(elapsed);
943+
923944
// Track performance metrics on EVERY iteration (independent of rendering)
924945
bool metricsNeedUpdate = false;
925946
if (Performance.IsPerformanceMetricsEnabled)
@@ -943,7 +964,8 @@ public int Run()
943964
}
944965

945966
// Frame pacing: render if windows are dirty OR metrics need update OR desktop needs render OR animations active
946-
bool shouldRender = AnyWindowDirty() || metricsNeedUpdate || Render.DesktopNeedsRender || Animations.HasActiveAnimations || Parsing.MarkupSpinnerClock.ShouldKeepRendering(Animations.IsEnabled) || Render.IsStatusBarDirty() || _desktopPortalService.AnyPortalDirty();
967+
bool shouldRender = AnyWindowDirty() || metricsNeedUpdate || Render.DesktopNeedsRender || Animations.HasActiveAnimations || Parsing.MarkupSpinnerClock.ShouldKeepRendering(Animations.IsEnabled) || Render.IsStatusBarDirty() || _desktopPortalService.AnyPortalDirty()
968+
|| (_useSoftwareCursor && _cursorStateService.CurrentState.IsVisible);
947969

948970
// Calculate recommended sleep duration once (used in both branches)
949971
var recommendedSleep = _inputStateService.GetRecommendedSleepDuration(
@@ -986,6 +1008,9 @@ public int Run()
9861008
UpdateCursor();
9871009
if (_uiActionsPending && _idleTime > Configuration.SystemDefaults.MinSleepDurationMs)
9881010
_idleTime = Configuration.SystemDefaults.MinSleepDurationMs;
1011+
// Keep the loop ticking fast enough for smooth caret blinking.
1012+
if (_useSoftwareCursor && _cursorStateService.CurrentState.IsVisible)
1013+
_idleTime = Math.Min(_idleTime, _cursorBlinkRateMs);
9891014
_currentPhase = Core.MainLoopPhase.Idle;
9901015
try
9911016
{
@@ -1335,6 +1360,18 @@ private void UpdateCursor()
13351360
_cursorStateService.HideCursor();
13361361
}
13371362

1363+
// Software cursor: reset the blink clock whenever the caret moves so the new
1364+
// position starts in the "on" phase. CurrentState holds the real (visible) caret.
1365+
if (_useSoftwareCursor)
1366+
{
1367+
var caretPos = _cursorStateService.CurrentState.AbsolutePosition;
1368+
if (caretPos != _lastSoftwareCursorPos)
1369+
{
1370+
_blinkClock.Reset();
1371+
_lastSoftwareCursorPos = caretPos;
1372+
}
1373+
}
1374+
13381375
// Apply cursor state to the actual console
13391376
// CRITICAL: Protect Console I/O with lock to prevent concurrent writes
13401377
// from corrupting ANSI sequences during InputLoop's mouse parsing
@@ -1343,6 +1380,15 @@ private void UpdateCursor()
13431380
_cursorStateService.ApplyCursorToConsole(
13441381
_consoleDriver.ScreenSize.Width,
13451382
_consoleDriver.ScreenSize.Height);
1383+
1384+
// Software cursor: keep CurrentState as the real (visible) caret for the overlay,
1385+
// but force the HARDWARE cursor hidden so only the app-drawn caret is seen.
1386+
// Only emit when the caret is logically visible (avoids churn; the overlay
1387+
// redraws every frame while focused — see the keep-alive in the main loop).
1388+
if (_useSoftwareCursor && _cursorStateService.CurrentState.IsVisible)
1389+
{
1390+
_consoleDriver.SetCursorVisible(false);
1391+
}
13461392
}
13471393
}
13481394

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// -----------------------------------------------------------------------
2+
// ConsoleEx - A simple console window system for .NET Core
3+
//
4+
// Author: Nikolaos Protopapas
5+
// Email: nikolaos.protopapas@gmail.com
6+
// License: MIT
7+
// -----------------------------------------------------------------------
8+
9+
namespace SharpConsoleUI.Core
10+
{
11+
/// <summary>
12+
/// Rate-driven on/off state for the opt-in software cursor. The loop feeds elapsed time via
13+
/// Advance(); IsOn(rate) returns whether the caret should currently be drawn. Reset() forces a
14+
/// solid "on" phase (called when the caret moves, so it is solid right after typing).
15+
/// </summary>
16+
public sealed class CursorBlinkClock
17+
{
18+
private double _accumulatedMs;
19+
20+
/// <summary>Advances the clock by the elapsed milliseconds of a loop iteration.</summary>
21+
public void Advance(double elapsedMs)
22+
{
23+
if (elapsedMs > 0) _accumulatedMs += elapsedMs;
24+
}
25+
26+
/// <summary>Forces a fresh solid-on phase.</summary>
27+
public void Reset() => _accumulatedMs = 0;
28+
29+
/// <summary>True when the caret should be drawn for the given blink rate (ms per half-cycle).</summary>
30+
public bool IsOn(int rateMs)
31+
{
32+
if (rateMs <= 0) return true;
33+
long cycle = (long)(_accumulatedMs / rateMs);
34+
return (cycle % 2) == 0;
35+
}
36+
}
37+
}

SharpConsoleUI/Core/CursorState.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ public enum CursorShape
3939
Hidden
4040
}
4141

42+
/// <summary>
43+
/// Controls hardware-cursor blinking via DECSCUSR. The terminal owns the blink rate;
44+
/// only blinking-vs-steady (and the terminal default) can be selected.
45+
/// </summary>
46+
public enum CursorBlink
47+
{
48+
/// <summary>Non-blinking cursor (DECSCUSR even codes).</summary>
49+
Steady,
50+
/// <summary>Blinking at the terminal's native rate (DECSCUSR odd codes).</summary>
51+
Blinking,
52+
/// <summary>Terminal default (DECSCUSR 0).</summary>
53+
TerminalDefault
54+
}
55+
4256
/// <summary>
4357
/// Immutable record representing the current state of the cursor.
4458
/// Provides a single source of truth for cursor visibility, position, and ownership.

SharpConsoleUI/Core/CursorStateService.cs

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ public class CursorStateService : IDisposable
2626
private const int MaxHistorySize = 100;
2727
private bool _isDisposed;
2828

29+
private readonly record struct AppliedCursor(bool Visible, System.Drawing.Point Position, CursorShape Shape);
30+
private AppliedCursor? _lastApplied;
31+
private bool _physicalCursorDirty = true;
32+
33+
/// <summary>
34+
/// Marks the physical terminal cursor as moved (e.g. after a frame render writes cells),
35+
/// forcing the next ApplyCursorToConsole to reposition it.
36+
/// </summary>
37+
public void InvalidatePhysicalCursor() => _physicalCursorDirty = true;
38+
2939
/// <summary>
3040
/// Initializes a new instance of the <see cref="CursorStateService"/> class.
3141
/// </summary>
@@ -235,27 +245,36 @@ public bool ApplyCursorToConsole(int screenWidth, int screenHeight)
235245
{
236246
var state = CurrentState;
237247

238-
if (!state.IsVisible || state.Shape == CursorShape.Hidden)
239-
{
240-
_driver.SetCursorVisible(false);
248+
bool inBounds =
249+
state.AbsolutePosition.X >= 0 && state.AbsolutePosition.X < screenWidth &&
250+
state.AbsolutePosition.Y >= 0 && state.AbsolutePosition.Y < screenHeight;
251+
bool visible = state.IsVisible && state.Shape != CursorShape.Hidden && inBounds;
252+
var target = new AppliedCursor(visible, state.AbsolutePosition, state.Shape);
253+
254+
if (!_physicalCursorDirty && _lastApplied.HasValue && _lastApplied.Value == target)
241255
return true;
242-
}
243256

244-
var pos = state.AbsolutePosition;
257+
bool emitAll = _physicalCursorDirty || !_lastApplied.HasValue;
258+
var last = _lastApplied;
245259

246-
// Bounds check
247-
if (pos.X < 0 || pos.X >= screenWidth || pos.Y < 0 || pos.Y >= screenHeight)
260+
if (!visible)
248261
{
249-
_driver.SetCursorVisible(false);
250-
return false;
262+
if (emitAll || last!.Value.Visible)
263+
_driver.SetCursorVisible(false);
264+
}
265+
else
266+
{
267+
if (emitAll || last!.Value.Shape != target.Shape)
268+
_driver.SetCursorShape(target.Shape);
269+
if (emitAll || last!.Value.Position != target.Position)
270+
_driver.SetCursorPosition(target.Position.X, target.Position.Y);
271+
if (emitAll || !last!.Value.Visible)
272+
_driver.SetCursorVisible(true);
251273
}
252274

253-
// Apply cursor shape, position, and make visible
254-
_driver.SetCursorShape(state.Shape);
255-
_driver.SetCursorPosition(pos.X, pos.Y);
256-
_driver.SetCursorVisible(true);
257-
258-
return true;
275+
_lastApplied = target;
276+
_physicalCursorDirty = false;
277+
return target.Visible;
259278
}
260279

261280
/// <inheritdoc/>

0 commit comments

Comments
 (0)