Skip to content

Commit 23f227f

Browse files
committed
fix: lines with asian chars sometimes not rendered in live progress mode
1 parent 9ecce05 commit 23f227f

3 files changed

Lines changed: 72 additions & 14 deletions

File tree

Sockseek.Cli.Tests/ProgressReporterTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,20 @@ public void EventLogger_LiveMode_RoutesActivityLogsToNonConsoleOnly()
9999
StringAssert.StartsWith(sinkMessages[0], @"[jobs] [9] SongJob: downloading: Artist - Song: user\Music\Artist\Song.flac");
100100
}
101101

102+
[TestMethod]
103+
public void TerminalLiveRenderer_WrapsWideUnicodeByCellWidth()
104+
{
105+
var text = "failed [No suitable file found]: サン";
106+
107+
Assert.IsTrue(TerminalLiveRenderer.CellCount(text) > text.Length,
108+
"Japanese kana should count wider than one terminal cell per UTF-16 char.");
109+
110+
var wrapped = TerminalLiveRenderer.WrapContentForWidth(text, text.Length + 1);
111+
112+
Assert.IsTrue(wrapped.Count > 1,
113+
"Live log wrapping must use terminal cell width, not string.Length, so wide Unicode does not wrap underneath Spectre.Live.");
114+
}
115+
102116
[TestMethod]
103117
public void EventLogger_NoProgressMode_RoutesActivityLogsToConsoleAndNonConsole()
104118
{

Sockseek.Cli/Utilities/TerminalRendering.cs

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System.Collections.Concurrent;
2+
using System.Globalization;
3+
using System.Text;
24
using Spectre.Console;
35
using Spectre.Console.Rendering;
46

@@ -291,7 +293,7 @@ private void FlushLogs()
291293
{
292294
_printedLogHistory.Add(new PrintedLogLine.Structured(line));
293295
var markup = FormatLogMarkup(line);
294-
var visualLength = Markup.Remove(markup).Length;
296+
var visualLength = CellCount(Markup.Remove(markup));
295297
int width = LogLineWidth();
296298
if (markup.Contains('\n') || (!Console.IsOutputRedirected && visualLength >= width))
297299
{
@@ -328,7 +330,7 @@ private void ReplayPrintedLogHistory()
328330
private static void WriteStructuredLogLine(TerminalLogLine line)
329331
{
330332
var markup = FormatLogMarkup(line);
331-
var visualLength = Markup.Remove(markup).Length;
333+
var visualLength = CellCount(Markup.Remove(markup));
332334
int width = LogLineWidth();
333335
if (markup.Contains('\n') || (!Console.IsOutputRedirected && visualLength >= width))
334336
{
@@ -345,7 +347,7 @@ private static void WritePlainLogLines(string text)
345347
foreach (var line in normalized.Split('\n'))
346348
{
347349
foreach (var visualLine in WrapPlainLogLine(line))
348-
AnsiConsole.WriteLine(visualLine + PaddingFor(visualLine.Length));
350+
AnsiConsole.WriteLine(visualLine + PaddingFor(CellCount(visualLine)));
349351
}
350352
}
351353

@@ -388,7 +390,7 @@ void WriteWrappedMarkupContent(
388390
string contPrefixText = continuationPrefixText ?? continuationPrefix;
389391
string contPrefixMarkup = Markup.Escape(contPrefixText);
390392

391-
int lineWidth = LogLineWidth() - firstPrefixText.Length;
393+
int lineWidth = LogLineWidth() - CellCount(firstPrefixText);
392394
var chunks = WrapContent(content, lineWidth).ToList();
393395

394396
for (int i = 0; i < chunks.Count; i++)
@@ -399,7 +401,7 @@ void WriteWrappedMarkupContent(
399401
var prefixMarkupForChunk = isFirst ? firstPrefixMarkup : contPrefixMarkup;
400402

401403
string contentMarkup = (isFirst ? first : continuation)(chunk);
402-
AnsiConsole.MarkupLine(prefixMarkupForChunk + contentMarkup + PaddingFor(prefixTextForChunk.Length + chunk.Length));
404+
AnsiConsole.MarkupLine(prefixMarkupForChunk + contentMarkup + PaddingFor(CellCount(prefixTextForChunk) + CellCount(chunk)));
403405
}
404406
}
405407
}
@@ -421,13 +423,34 @@ private static IEnumerable<string> WrapContent(string content, int availableWidt
421423
if (Console.IsOutputRedirected)
422424
return [content];
423425

426+
return WrapContentByCellWidth(content, availableWidth);
427+
}
428+
429+
private static IEnumerable<string> WrapContentByCellWidth(string content, int availableWidth)
430+
{
424431
int width = Math.Max(1, availableWidth);
425-
if (content.Length < width)
432+
if (CellCount(content) < width)
426433
return [content];
427434

428435
var wrapped = new List<string>();
429-
for (int offset = 0; offset < content.Length; offset += width)
430-
wrapped.Add(content.Substring(offset, Math.Min(width, content.Length - offset)));
436+
var current = new StringBuilder();
437+
var currentWidth = 0;
438+
foreach (var element in TextElements(content))
439+
{
440+
var elementWidth = CellCount(element);
441+
if (current.Length > 0 && currentWidth + elementWidth > width)
442+
{
443+
wrapped.Add(current.ToString());
444+
current.Clear();
445+
currentWidth = 0;
446+
}
447+
448+
current.Append(element);
449+
currentWidth += elementWidth;
450+
}
451+
452+
if (current.Length > 0)
453+
wrapped.Add(current.ToString());
431454
return wrapped;
432455
}
433456

@@ -437,18 +460,28 @@ private static IEnumerable<string> WrapPlainLogLine(string line)
437460
return [line];
438461

439462
int width = LogLineWidth();
440-
if (line.Length < width)
463+
if (CellCount(line) < width)
441464
return [line];
442465

443-
var wrapped = new List<string>();
444-
for (int offset = 0; offset < line.Length; offset += width)
445-
wrapped.Add(line.Substring(offset, Math.Min(width, line.Length - offset)));
446-
return wrapped;
466+
return WrapContent(line, width);
447467
}
448468

449469
private static string PaddingFor(int visualLength)
450470
=> Console.IsOutputRedirected ? "" : new string(' ', Math.Max(0, LogLineWidth() - visualLength));
451471

472+
internal static int CellCount(string text)
473+
=> new Segment(text).CellCount();
474+
475+
internal static IReadOnlyList<string> WrapContentForWidth(string content, int availableWidth)
476+
=> WrapContentByCellWidth(content, availableWidth).ToList();
477+
478+
private static IEnumerable<string> TextElements(string text)
479+
{
480+
var enumerator = StringInfo.GetTextElementEnumerator(text);
481+
while (enumerator.MoveNext())
482+
yield return enumerator.GetTextElement();
483+
}
484+
452485
private static int LogLineWidth()
453486
{
454487
if (Console.IsOutputRedirected)

Sockseek.Server.Tests/OpenApiContractTests.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Net;
22
using System.Net.Sockets;
3+
using System.Reflection;
34
using System.Text.Json;
45
using Microsoft.VisualStudio.TestTools.UnitTesting;
56
using Sockseek.Core;
@@ -55,7 +56,7 @@ public async Task OpenApiDocument_ContainsCoreServerContractSchemas()
5556
.GetProperty("version")
5657
.GetString();
5758

58-
Assert.AreEqual("3.0.0-dev.10", version);
59+
Assert.AreEqual(ExpectedOpenApiVersion(), version);
5960

6061
StringAssert.Contains(json, nameof(JobSummaryDto));
6162
StringAssert.Contains(json, nameof(SubmitAlbumJobRequestDto));
@@ -76,6 +77,16 @@ public async Task OpenApiDocument_ContainsCoreServerContractSchemas()
7677
}
7778
}
7879

80+
private static string ExpectedOpenApiVersion()
81+
{
82+
var assemblyVersion = typeof(ServerHost).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
83+
?? typeof(ServerHost).Assembly.GetName().Version?.ToString()
84+
?? "0.0.0";
85+
86+
var metadataIndex = assemblyVersion.IndexOf('+', StringComparison.Ordinal);
87+
return metadataIndex >= 0 ? assemblyVersion[..metadataIndex] : assemblyVersion;
88+
}
89+
7990
[TestMethod]
8091
public void SockseekApiJsonContext_CoversApiDtoContracts()
8192
{

0 commit comments

Comments
 (0)