Skip to content

Commit 41652aa

Browse files
committed
feat: instant input response and table Unicode/resize fixes
Replace Thread.Sleep in main event loop with ManualResetEventSlim signaled wait for zero-latency input processing. Input and UI actions wake the loop immediately; animations cap the timeout. Fix wide character truncation in TableControl rendering — drop continuation cells no longer corrupt subsequent columns. Fix column resize shrinking fixed-width columns — only auto-width columns are shrunk when the table is narrower than total content.
1 parent c32134a commit 41652aa

6 files changed

Lines changed: 230 additions & 21 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using SharpConsoleUI.Core;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using Xunit;
5+
6+
namespace SharpConsoleUI.Tests.Core;
7+
8+
public class InputStateServiceWakeTests
9+
{
10+
[Fact]
11+
public void EnqueueKey_CallsWakeCallback()
12+
{
13+
// Arrange
14+
using var service = new InputStateService();
15+
int callCount = 0;
16+
service.WakeCallback = () => callCount++;
17+
18+
// Act
19+
service.EnqueueKey(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false));
20+
21+
// Assert
22+
Assert.Equal(1, callCount);
23+
}
24+
25+
[Fact]
26+
public void EnqueueKey_WithoutWakeCallback_DoesNotThrow()
27+
{
28+
// Arrange
29+
using var service = new InputStateService();
30+
// No WakeCallback set
31+
32+
// Act & Assert — should not throw
33+
service.EnqueueKey(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false));
34+
}
35+
36+
[Fact]
37+
public void EnqueueKey_MultipleKeys_CallsWakeCallbackEachTime()
38+
{
39+
// Arrange
40+
using var service = new InputStateService();
41+
int callCount = 0;
42+
service.WakeCallback = () => callCount++;
43+
44+
// Act
45+
service.EnqueueKey(new ConsoleKeyInfo('a', ConsoleKey.A, false, false, false));
46+
service.EnqueueKey(new ConsoleKeyInfo('b', ConsoleKey.B, false, false, false));
47+
service.EnqueueKey(new ConsoleKeyInfo('c', ConsoleKey.C, false, false, false));
48+
49+
// Assert
50+
Assert.Equal(3, callCount);
51+
}
52+
53+
[Fact]
54+
public void WakeSignal_IsSignaledByEnqueueOnUIThread()
55+
{
56+
// Arrange
57+
using var wakeSignal = new ManualResetEventSlim(false);
58+
Action wake = () => wakeSignal.Set();
59+
60+
// Act
61+
wake();
62+
63+
// Assert
64+
Assert.True(wakeSignal.IsSet);
65+
}
66+
67+
[Fact]
68+
public void WakeSignal_WaitReturnsImmediatelyWhenSignaled()
69+
{
70+
// Arrange
71+
using var wakeSignal = new ManualResetEventSlim(false);
72+
73+
// Act — signal from another thread, then wait
74+
Task.Run(() => wakeSignal.Set());
75+
bool signaled = wakeSignal.Wait(TimeSpan.FromSeconds(1));
76+
77+
// Assert
78+
Assert.True(signaled);
79+
}
80+
81+
[Fact]
82+
public void WakeSignal_WaitTimesOutWhenNotSignaled()
83+
{
84+
// Arrange
85+
using var wakeSignal = new ManualResetEventSlim(false);
86+
87+
// Act
88+
bool signaled = wakeSignal.Wait(TimeSpan.FromMilliseconds(10));
89+
90+
// Assert
91+
Assert.False(signaled);
92+
}
93+
}

SharpConsoleUI.Tests/InputHandling/ShortcutsTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ public void Escape_OnDesktopPortal_DismissesPortal()
225225
var system = TestWindowSystemBuilder.CreateTestSystem();
226226
var label = new SharpConsoleUI.Controls.MarkupControl(new List<string> { "Portal Content" });
227227

228-
system.DesktopPortalService.CreatePortal(new Core.DesktopPortalOptions(
228+
system.DesktopPortalService.CreatePortal(new SharpConsoleUI.Core.DesktopPortalOptions(
229229
Content: label,
230230
Bounds: new System.Drawing.Rectangle(0, 0, 40, 10),
231231
DismissOnClickOutside: true));

SharpConsoleUI/ConsoleWindowSystem.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// -----------------------------------------------------------------------
88

99
using System.Collections.Concurrent;
10+
using System.Threading;
1011
using SharpConsoleUI.Themes;
1112
using SharpConsoleUI.Helpers;
1213
using SharpConsoleUI.Events;
@@ -51,6 +52,9 @@ public class ConsoleWindowSystem
5152
private readonly ConcurrentQueue<Action> _uiActionQueue = new();
5253
private volatile bool _uiActionsPending;
5354

55+
// Signal to wake the main loop when input or UI actions arrive
56+
private readonly ManualResetEventSlim _wakeSignal = new(false);
57+
5458
// Frame rate limiting (configured via ConsoleWindowSystemOptions)
5559
private DateTime _lastRenderTime = DateTime.UtcNow;
5660

@@ -199,6 +203,7 @@ public ConsoleWindowSystem(IConsoleDriver driver, ITheme theme, PluginConfigurat
199203
_modalStateService = new ModalStateService(_logService);
200204
_themeStateService = new ThemeStateService(_theme, _logService);
201205
_inputStateService = new InputStateService();
206+
_inputStateService.WakeCallback = () => _wakeSignal.Set();
202207
_panelStateService = new PanelStateService(_logService, () => this);
203208
_desktopPortalService = new Core.DesktopPortalService(_logService, this);
204209

@@ -517,6 +522,7 @@ public void EnqueueOnUIThread(Action action)
517522
ArgumentNullException.ThrowIfNull(action);
518523
_uiActionQueue.Enqueue(action);
519524
_uiActionsPending = true;
525+
_wakeSignal.Set();
520526
}
521527

522528
#region Window Management
@@ -701,7 +707,8 @@ public int Run()
701707
UpdateCursor();
702708
if (_uiActionsPending && _idleTime > Configuration.SystemDefaults.MinSleepDurationMs)
703709
_idleTime = Configuration.SystemDefaults.MinSleepDurationMs;
704-
Thread.Sleep(_idleTime);
710+
_wakeSignal.Wait(_idleTime);
711+
_wakeSignal.Reset();
705712
}
706713
}
707714
catch (Exception ex)
@@ -763,6 +770,9 @@ public void Shutdown(int exitCode = 0)
763770
// Dispose desktop background service (stops animation timer)
764771
_desktopBackgroundService.Dispose();
765772

773+
// Dispose wake signal
774+
_wakeSignal.Dispose();
775+
766776
// Save registry on shutdown (auto-on-shutdown flush mode)
767777
_registryStateService?.Dispose();
768778
}

SharpConsoleUI/Controls/TableControl/TableControl.Rendering.cs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,23 @@ private void DrawDataRow(CharacterBuffer buffer, int x, int y, int[] colWidths,
217217
}
218218
if (i < visLen)
219219
{
220-
var editCell = new Cell(editCells[i].Character, fg, bg)
220+
var srcCell = editCells[i];
221+
// If this is a wide base char at the last column position,
222+
// replace with space to avoid rendering half a glyph
223+
if (i == colW - 1 && !srcCell.IsWideContinuation
224+
&& Helpers.UnicodeWidth.IsWideRune(srcCell.Character))
221225
{
222-
IsWideContinuation = editCells[i].IsWideContinuation,
223-
Combiners = editCells[i].Combiners
224-
};
225-
buffer.SetCell(cx, y, editCell);
226+
buffer.SetNarrowCell(cx, y, ' ', fg, bg);
227+
}
228+
else
229+
{
230+
var editCell = new Cell(srcCell.Character, fg, bg, srcCell.Decorations)
231+
{
232+
IsWideContinuation = srcCell.IsWideContinuation,
233+
Combiners = srcCell.Combiners
234+
};
235+
buffer.SetCell(cx, y, editCell);
236+
}
226237
}
227238
else
228239
{
@@ -239,7 +250,24 @@ private void DrawDataRow(CharacterBuffer buffer, int x, int y, int[] colWidths,
239250

240251
if (visLen > colW)
241252
{
253+
// Wide-character-aware truncation: if the last kept cell
254+
// is the base of a wide character (next cell is continuation),
255+
// replace it with a space to avoid rendering half a glyph.
242256
cellCells = cellCells.GetRange(0, colW);
257+
if (colW > 0 && colW < visLen)
258+
{
259+
var lastKept = cellCells[colW - 1];
260+
if (!lastKept.IsWideContinuation && visLen > colW)
261+
{
262+
// Check if original list had a continuation cell after this one
263+
// A wide base char always has IsWideContinuation on the next cell
264+
// We can detect it: if this cell's character is wide, it was split
265+
if (Helpers.UnicodeWidth.IsWideRune(lastKept.Character))
266+
{
267+
cellCells[colW - 1] = new Cell(' ', lastKept.Foreground, lastKept.Background);
268+
}
269+
}
270+
}
243271
visLen = colW;
244272
}
245273

SharpConsoleUI/Controls/TableControl/TableControl.cs

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -971,14 +971,45 @@ internal int[] ComputeColumnWidths(int availableWidth, List<TableColumn> cols, L
971971
}
972972
else if (totalNatural > contentWidth)
973973
{
974-
double ratio = (double)contentWidth / totalNatural;
975-
int assigned = 0;
976-
for (int c = 0; c < colCount - 1; c++)
974+
// Shrink only auto-width columns first; preserve fixed-width columns
975+
int fixedTotal = 0;
976+
int autoTotal = 0;
977+
for (int c = 0; c < colCount; c++)
978+
{
979+
if (cols[c].Width.HasValue)
980+
fixedTotal += widths[c];
981+
else
982+
autoTotal += widths[c];
983+
}
984+
985+
int autoTarget = contentWidth - fixedTotal;
986+
if (autoTarget > 0 && autoTotal > 0)
977987
{
978-
widths[c] = Math.Max(1, (int)(widths[c] * ratio));
979-
assigned += widths[c];
988+
double ratio = (double)autoTarget / autoTotal;
989+
int assigned = fixedTotal;
990+
int lastAutoCol = -1;
991+
for (int c = 0; c < colCount; c++)
992+
{
993+
if (cols[c].Width.HasValue) continue;
994+
widths[c] = Math.Max(1, (int)(widths[c] * ratio));
995+
assigned += widths[c];
996+
lastAutoCol = c;
997+
}
998+
if (lastAutoCol >= 0)
999+
widths[lastAutoCol] = Math.Max(1, widths[lastAutoCol] + (contentWidth - assigned));
1000+
}
1001+
else
1002+
{
1003+
// Not enough space even for fixed columns — shrink everything
1004+
double ratio = (double)contentWidth / totalNatural;
1005+
int assigned = 0;
1006+
for (int c = 0; c < colCount - 1; c++)
1007+
{
1008+
widths[c] = Math.Max(1, (int)(widths[c] * ratio));
1009+
assigned += widths[c];
1010+
}
1011+
widths[colCount - 1] = Math.Max(1, contentWidth - assigned);
9801012
}
981-
widths[colCount - 1] = Math.Max(1, contentWidth - assigned);
9821013
}
9831014

9841015
// Cache results
@@ -1046,23 +1077,56 @@ internal int[] ComputeColumnWidthsFromDataSource(int availableWidth, int scrollO
10461077

10471078
for (int c = 0; c < colCount; c++)
10481079
{
1049-
int? colWidth = _dataSource.GetColumnWidth(c);
1050-
bool isAutoCol = !colWidth.HasValue;
1080+
int? dsColWidth = _dataSource.GetColumnWidth(c);
1081+
bool isAutoCol = !dsColWidth.HasValue && !_columnWidthOverrides.ContainsKey(c);
10511082
if (autoCount > 0 && !isAutoCol) continue;
10521083
widths[c] += perCol;
10531084
if (extraCols > 0) { widths[c]++; extraCols--; }
10541085
}
10551086
}
10561087
else if (totalNatural > contentWidth)
10571088
{
1058-
double ratio = (double)contentWidth / totalNatural;
1059-
int assigned = 0;
1060-
for (int c = 0; c < colCount - 1; c++)
1089+
// Shrink only auto-width columns first; preserve fixed/overridden columns
1090+
int fixedTotal = 0;
1091+
int autoTotal = 0;
1092+
for (int c = 0; c < colCount; c++)
1093+
{
1094+
bool isFixed = _columnWidthOverrides.ContainsKey(c) || _dataSource.GetColumnWidth(c).HasValue;
1095+
if (isFixed)
1096+
fixedTotal += widths[c];
1097+
else
1098+
autoTotal += widths[c];
1099+
}
1100+
1101+
int autoTarget = contentWidth - fixedTotal;
1102+
if (autoTarget > 0 && autoTotal > 0)
10611103
{
1062-
widths[c] = Math.Max(1, (int)(widths[c] * ratio));
1063-
assigned += widths[c];
1104+
double ratio = (double)autoTarget / autoTotal;
1105+
int assigned = fixedTotal;
1106+
int lastAutoCol = -1;
1107+
for (int c = 0; c < colCount; c++)
1108+
{
1109+
bool isFixed = _columnWidthOverrides.ContainsKey(c) || _dataSource.GetColumnWidth(c).HasValue;
1110+
if (isFixed) continue;
1111+
widths[c] = Math.Max(1, (int)(widths[c] * ratio));
1112+
assigned += widths[c];
1113+
lastAutoCol = c;
1114+
}
1115+
if (lastAutoCol >= 0)
1116+
widths[lastAutoCol] = Math.Max(1, widths[lastAutoCol] + (contentWidth - assigned));
1117+
}
1118+
else
1119+
{
1120+
// Not enough space even for fixed columns — shrink everything
1121+
double ratio = (double)contentWidth / totalNatural;
1122+
int assigned = 0;
1123+
for (int c = 0; c < colCount - 1; c++)
1124+
{
1125+
widths[c] = Math.Max(1, (int)(widths[c] * ratio));
1126+
assigned += widths[c];
1127+
}
1128+
widths[colCount - 1] = Math.Max(1, contentWidth - assigned);
10641129
}
1065-
widths[colCount - 1] = Math.Max(1, contentWidth - assigned);
10661130
}
10671131

10681132
return widths;

SharpConsoleUI/Core/InputStateService.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,20 @@ public class InputStateService : IDisposable
8383
private bool _isIdle = true;
8484
private readonly TimeSpan _idleThreshold = TimeSpan.FromMilliseconds(500);
8585
private bool _isDisposed;
86+
private Action? _wakeCallback;
8687

8788
#region Properties
8889

90+
/// <summary>
91+
/// Optional callback invoked after a key is enqueued, used to wake the main loop.
92+
/// Set once during initialization. Thread-safe: called outside the input lock.
93+
/// </summary>
94+
public Action? WakeCallback
95+
{
96+
get => _wakeCallback;
97+
set => _wakeCallback = value;
98+
}
99+
89100
/// <summary>
90101
/// Gets whether there are pending keys in the queue
91102
/// </summary>
@@ -211,6 +222,9 @@ public void EnqueueKey(ConsoleKeyInfo key)
211222
// Swallow exceptions from event handlers
212223
}
213224
});
225+
226+
// Wake the main loop
227+
_wakeCallback?.Invoke();
214228
}
215229

216230
/// <summary>

0 commit comments

Comments
 (0)