Skip to content

Commit cffecd6

Browse files
committed
refactor(cursor): remove software cursor; make hardware blink per-cursor
The blink jitter fixed by the re-emission change (dbb9fdf) made the opt-in software cursor unnecessary, so remove it entirely: - Delete CursorBlinkClock, IConsoleDriver/ConsoleBuffer TryGetCell + ParseAnsiColors, the UseSoftwareCursor option, the render overlay, and the main-loop blink-tick/keep-alive/idle-clamp hooks. Promote hardware blink to a first-class per-cursor property, mirroring ICursorShapeProvider: - Add ICursorBlinkProvider.PreferredCursorBlink and CursorState.Blink. - Thread blink through UpdateFromWindowSystem and the apply-diff via a new additive SetCursorShape(shape, blink) overload (single-arg preserved; default-interface impl keeps external drivers compiling). - ConsoleWindowSystem resolves blink beside shape, seeded from the system default (Options.CursorBlink, resolved once at startup, fallback Blinking). Keep the re-emission fix and InvalidatePhysicalCursor untouched. Keep the pre-existing CursorBlinkRate option. Backward compatible: defaults preserve existing behavior; no published API removed.
1 parent dbb9fdf commit cffecd6

13 files changed

Lines changed: 157 additions & 267 deletions

SharpConsoleUI.Tests/Core/CursorBlinkClockTests.cs

Lines changed: 0 additions & 36 deletions
This file was deleted.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
/// <summary>
10+
/// Verifies that the per-cursor blink value carried on <see cref="CursorState"/> is applied to the
11+
/// driver via the two-arg <c>SetCursorShape(shape, blink)</c> overload, and re-emitted when it changes.
12+
/// </summary>
13+
public class CursorBlinkResolutionTests
14+
{
15+
private static (CursorStateService svc, HeadlessConsoleDriver drv, Window win) Make()
16+
{
17+
var driver = new HeadlessConsoleDriver(80, 25);
18+
var system = new ConsoleWindowSystem(driver);
19+
var win = new Window(system);
20+
var svc = new CursorStateService(driver);
21+
return (svc, driver, win);
22+
}
23+
24+
[Theory]
25+
[InlineData(CursorBlink.Steady)]
26+
[InlineData(CursorBlink.Blinking)]
27+
[InlineData(CursorBlink.TerminalDefault)]
28+
public void ApplyCursor_ForwardsBlinkToDriver(CursorBlink blink)
29+
{
30+
var (svc, drv, win) = Make();
31+
svc.UpdateFromWindowSystem(win, new Point(2, 3), new Point(2, 3), null, CursorShape.Block, blink);
32+
svc.ApplyCursorToConsole(80, 25);
33+
Assert.Equal(blink, drv.LastCursorBlink);
34+
}
35+
36+
[Fact]
37+
public void ApplyCursor_Reemits_WhenOnlyBlinkChanges()
38+
{
39+
var (svc, drv, win) = Make();
40+
svc.UpdateFromWindowSystem(win, new Point(2, 3), new Point(2, 3), null, CursorShape.Block, CursorBlink.Blinking);
41+
svc.ApplyCursorToConsole(80, 25);
42+
int afterFirst = drv.SetCursorShapeCallCount;
43+
44+
// Same shape/position, only blink changes -> shape must be re-emitted (it carries blink).
45+
svc.UpdateFromWindowSystem(win, new Point(2, 3), new Point(2, 3), null, CursorShape.Block, CursorBlink.Steady);
46+
svc.ApplyCursorToConsole(80, 25);
47+
48+
Assert.Equal(afterFirst + 1, drv.SetCursorShapeCallCount);
49+
Assert.Equal(CursorBlink.Steady, drv.LastCursorBlink);
50+
}
51+
52+
[Fact]
53+
public void ApplyCursor_DoesNotReemit_WhenBlinkUnchanged()
54+
{
55+
var (svc, drv, win) = Make();
56+
svc.UpdateFromWindowSystem(win, new Point(2, 3), new Point(2, 3), null, CursorShape.Block, CursorBlink.Steady);
57+
svc.ApplyCursorToConsole(80, 25);
58+
int afterFirst = drv.SetCursorShapeCallCount;
59+
60+
for (int i = 0; i < 3; i++) svc.ApplyCursorToConsole(80, 25);
61+
62+
Assert.Equal(afterFirst, drv.SetCursorShapeCallCount);
63+
}
64+
}

SharpConsoleUI.Tests/Drivers/TryGetCellTests.cs

Lines changed: 0 additions & 22 deletions
This file was deleted.

SharpConsoleUI/ConsoleWindowSystem.cs

Lines changed: 23 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,9 @@ 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);
59+
// System-wide default hardware-cursor blink behavior, resolved once at startup from the
60+
// driver's options. Per-cursor providers (ICursorBlinkProvider) override this per frame.
61+
private Core.CursorBlink _defaultCursorBlink = Core.CursorBlink.Blinking;
6562

6663
// Signal to wake the main loop when input or UI actions arrive
6764
private readonly ManualResetEventSlim _wakeSignal = new(false);
@@ -415,12 +412,11 @@ public ConsoleWindowSystem(IConsoleDriver driver, ITheme theme, PluginConfigurat
415412
// Initialize panels from config or legacy StatusBarOptions
416413
_panelStateService.InitializePanels(_options);
417414

418-
// Resolve software-cursor settings once from the driver's options (if it exposes them).
419-
// Headless/other drivers leave the defaults (false / 500ms).
415+
// Resolve the system-wide default cursor-blink behavior once from the driver's options
416+
// (if it exposes them). Headless/other drivers leave the default (Blinking).
420417
if (_consoleDriver is Drivers.NetConsoleDriver netDriver)
421418
{
422-
_useSoftwareCursor = netDriver.Options.UseSoftwareCursor;
423-
_cursorBlinkRateMs = netDriver.Options.CursorBlinkRate;
419+
_defaultCursorBlink = netDriver.Options.CursorBlink;
424420
}
425421
}
426422

@@ -475,10 +471,6 @@ public IConsoleDriver ConsoleDriver
475471
/// </summary>
476472
internal CursorStateService CursorStateService => _cursorStateService;
477473

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-
482474
/// <summary>
483475
/// Gets the window state service for managing window lifecycle and state.
484476
/// </summary>
@@ -939,8 +931,6 @@ public int Run()
939931
var now = DateTime.UtcNow;
940932
var elapsed = (now - _lastRenderTime).TotalMilliseconds;
941933

942-
if (_useSoftwareCursor) _blinkClock.Advance(elapsed);
943-
944934
// Track performance metrics on EVERY iteration (independent of rendering)
945935
bool metricsNeedUpdate = false;
946936
if (Performance.IsPerformanceMetricsEnabled)
@@ -964,8 +954,7 @@ public int Run()
964954
}
965955

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

970959
// Calculate recommended sleep duration once (used in both branches)
971960
var recommendedSleep = _inputStateService.GetRecommendedSleepDuration(
@@ -1008,9 +997,6 @@ public int Run()
1008997
UpdateCursor();
1009998
if (_uiActionsPending && _idleTime > Configuration.SystemDefaults.MinSleepDurationMs)
1010999
_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);
10141000
_currentPhase = Core.MainLoopPhase.Idle;
10151001
try
10161002
{
@@ -1320,6 +1306,8 @@ private void UpdateCursor()
13201306
// Get the owner control if available
13211307
IWindowControl? ownerControl = null;
13221308
CursorShape cursorShape = CursorShape.Block;
1309+
// Seed blink from the system-wide default; a per-cursor provider may override it below.
1310+
CursorBlink cursorBlink = _defaultCursorBlink;
13231311

13241312
if (ActiveWindow.EventDispatcher != null && ActiveWindow.EventDispatcher.HasActiveInteractiveContent(out var interactiveContent) &&
13251313
interactiveContent is IWindowControl windowControl)
@@ -1340,6 +1328,18 @@ private void UpdateCursor()
13401328
{
13411329
cursorShape = shapeProvider.PreferredCursorShape.Value;
13421330
}
1331+
1332+
// Blink resolves the same way: deepest provider first, then top-level control.
1333+
if (deepestControl is ICursorBlinkProvider deepBlinkProvider &&
1334+
deepBlinkProvider.PreferredCursorBlink.HasValue)
1335+
{
1336+
cursorBlink = deepBlinkProvider.PreferredCursorBlink.Value;
1337+
}
1338+
else if (windowControl is ICursorBlinkProvider blinkProvider &&
1339+
blinkProvider.PreferredCursorBlink.HasValue)
1340+
{
1341+
cursorBlink = blinkProvider.PreferredCursorBlink.Value;
1342+
}
13431343
}
13441344

13451345
// Update cursor state service with new state
@@ -1348,7 +1348,8 @@ private void UpdateCursor()
13481348
logicalPosition: cursorPosition,
13491349
absolutePosition: new Point(absoluteLeft, absoluteTop),
13501350
ownerControl: ownerControl,
1351-
shape: cursorShape);
1351+
shape: cursorShape,
1352+
blink: cursorBlink);
13521353
}
13531354
else
13541355
{
@@ -1360,18 +1361,6 @@ private void UpdateCursor()
13601361
_cursorStateService.HideCursor();
13611362
}
13621363

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-
13751364
// Apply cursor state to the actual console
13761365
// CRITICAL: Protect Console I/O with lock to prevent concurrent writes
13771366
// from corrupting ANSI sequences during InputLoop's mouse parsing
@@ -1380,15 +1369,6 @@ private void UpdateCursor()
13801369
_cursorStateService.ApplyCursorToConsole(
13811370
_consoleDriver.ScreenSize.Width,
13821371
_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-
}
13921372
}
13931373
}
13941374

SharpConsoleUI/Core/CursorBlinkClock.cs

Lines changed: 0 additions & 37 deletions
This file was deleted.

SharpConsoleUI/Core/CursorState.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ public interface ICursorShapeProvider
2424
CursorShape? PreferredCursorShape { get; }
2525
}
2626

27+
/// <summary>
28+
/// Interface for controls that can specify a preferred cursor blink behavior.
29+
/// Controls implementing this interface can customize whether the cursor blinks when focused.
30+
/// </summary>
31+
public interface ICursorBlinkProvider
32+
{
33+
/// <summary>
34+
/// Gets the preferred cursor blink behavior for this control.
35+
/// Return null to use the system default blink.
36+
/// </summary>
37+
CursorBlink? PreferredCursorBlink { get; }
38+
}
39+
2740
/// <summary>
2841
/// Represents the shape/style of the cursor
2942
/// </summary>
@@ -89,6 +102,11 @@ public record CursorState
89102
/// </summary>
90103
public CursorShape Shape { get; init; }
91104

105+
/// <summary>
106+
/// The blink behavior of the cursor
107+
/// </summary>
108+
public CursorBlink Blink { get; init; }
109+
92110
/// <summary>
93111
/// Timestamp when this state was created
94112
/// </summary>
@@ -103,14 +121,16 @@ public CursorState(
103121
Point? logicalPosition = null,
104122
IWindowControl? ownerControl = null,
105123
Window? ownerWindow = null,
106-
CursorShape shape = CursorShape.Block)
124+
CursorShape shape = CursorShape.Block,
125+
CursorBlink blink = CursorBlink.Blinking)
107126
{
108127
IsVisible = isVisible;
109128
AbsolutePosition = absolutePosition ?? Point.Empty;
110129
LogicalPosition = logicalPosition;
111130
OwnerControl = ownerControl;
112131
OwnerWindow = ownerWindow;
113132
Shape = shape;
133+
Blink = blink;
114134
UpdateTime = DateTime.UtcNow;
115135
}
116136

0 commit comments

Comments
 (0)