From fa071a9368c08572ac24920d0d54cc518ba7d6d4 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sat, 10 Jan 2026 16:55:54 +0100 Subject: [PATCH] Add half-block support to Canvas --- .../Spectre.Console.ImageSharp/CanvasImage.cs | 21 ++-- .../CanvasImageExtensions.cs | 1 + .../Extensions/TestConsoleExtensions.cs | 12 ++ ...ied.txt => Render.NonUnicode.verified.txt} | 0 .../Canvas/Render.Unicode.verified.txt | 3 + ...> Render_MaxWidth.NonUnicode.verified.txt} | 0 .../Render_MaxWidth.Unicode.verified.txt | 3 + ...er_NarrowTerminal.NonUnicode.verified.txt} | 0 ...Render_NarrowTerminal.Unicode.verified.txt | 3 + ... => Render_Nested.NonUnicode.verified.txt} | 0 .../Canvas/Render_Nested.Unicode.verified.txt | 3 + .../Unit/Widgets/CanvasTests.cs | 59 ++++++--- src/Spectre.Console/Widgets/Canvas.cs | 116 ++++++++++++++---- 13 files changed, 171 insertions(+), 50 deletions(-) rename src/Spectre.Console.Tests/Expectations/Widgets/Canvas/{Render.Output.verified.txt => Render.NonUnicode.verified.txt} (100%) create mode 100644 src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render.Unicode.verified.txt rename src/Spectre.Console.Tests/Expectations/Widgets/Canvas/{Render_MaxWidth.Output.verified.txt => Render_MaxWidth.NonUnicode.verified.txt} (100%) create mode 100644 src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_MaxWidth.Unicode.verified.txt rename src/Spectre.Console.Tests/Expectations/Widgets/Canvas/{Render_NarrowTerminal.Output.verified.txt => Render_NarrowTerminal.NonUnicode.verified.txt} (100%) create mode 100644 src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_NarrowTerminal.Unicode.verified.txt rename src/Spectre.Console.Tests/Expectations/Widgets/Canvas/{Render_Nested.Output.verified.txt => Render_Nested.NonUnicode.verified.txt} (100%) create mode 100644 src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_Nested.Unicode.verified.txt diff --git a/src/Extensions/Spectre.Console.ImageSharp/CanvasImage.cs b/src/Extensions/Spectre.Console.ImageSharp/CanvasImage.cs index aba8b016..c4d36cc6 100644 --- a/src/Extensions/Spectre.Console.ImageSharp/CanvasImage.cs +++ b/src/Extensions/Spectre.Console.ImageSharp/CanvasImage.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing.Processors.Transforms; @@ -33,6 +34,7 @@ public sealed class CanvasImage : Renderable /// /// Gets or sets the render width of the canvas. /// + [Obsolete("Not used anymore. Will be removed in future update.")] public int PixelWidth { get; set; } = 2; /// @@ -73,27 +75,23 @@ public sealed class CanvasImage : Renderable /// protected override Measurement Measure(RenderOptions options, int maxWidth) { - if (PixelWidth < 0) - { - throw new InvalidOperationException("Pixel width must be greater than zero."); - } - + var pixelWidth = options.Unicode ? 1 : 2; var width = MaxWidth ?? Width; - if (maxWidth < width * PixelWidth) + if (maxWidth < width * pixelWidth) { return new Measurement(maxWidth, maxWidth); } - return new Measurement(width * PixelWidth, width * PixelWidth); + return new Measurement(width * pixelWidth, width * pixelWidth); } /// protected override IEnumerable Render(RenderOptions options, int maxWidth) { var image = Image; - var width = Width; var height = Height; + var pixelWidth = options.Unicode ? 1 : 2; // Got a max width? if (MaxWidth != null) @@ -103,10 +101,10 @@ public sealed class CanvasImage : Renderable } // Exceed the max width when we take pixel width into account? - if (width * PixelWidth > maxWidth) + if (width * pixelWidth > maxWidth) { - height = (int)(height * (maxWidth / (float)(width * PixelWidth))); - width = maxWidth / PixelWidth; + height = (int)(height * (maxWidth / (float)(width * pixelWidth))); + width = maxWidth / pixelWidth; } // Need to rescale the pixel buffer? @@ -120,7 +118,6 @@ public sealed class CanvasImage : Renderable var canvas = new Canvas(width, height) { MaxWidth = MaxWidth, - PixelWidth = PixelWidth, Scale = false, }; diff --git a/src/Extensions/Spectre.Console.ImageSharp/CanvasImageExtensions.cs b/src/Extensions/Spectre.Console.ImageSharp/CanvasImageExtensions.cs index 0da36c36..88724357 100644 --- a/src/Extensions/Spectre.Console.ImageSharp/CanvasImageExtensions.cs +++ b/src/Extensions/Spectre.Console.ImageSharp/CanvasImageExtensions.cs @@ -47,6 +47,7 @@ public static class CanvasImageExtensions /// The canvas image. /// The pixel width. /// The same instance so that multiple calls can be chained. + [Obsolete("Not used anymore. Will be removed in future update.")] public static CanvasImage PixelWidth(this CanvasImage image, int width) { if (image is null) diff --git a/src/Spectre.Console.Testing/Extensions/TestConsoleExtensions.cs b/src/Spectre.Console.Testing/Extensions/TestConsoleExtensions.cs index ffe6a8b1..47f681e7 100644 --- a/src/Spectre.Console.Testing/Extensions/TestConsoleExtensions.cs +++ b/src/Spectre.Console.Testing/Extensions/TestConsoleExtensions.cs @@ -29,6 +29,18 @@ public static partial class TestConsoleExtensions return console; } + /// + /// Sets whether or not Unicode is supported. + /// + /// The console. + /// Whether or not Unicode is supported. + /// The same instance so that multiple calls can be chained. + public static TestConsole SupportsUnicode(this TestConsole console, bool enable) + { + console.Profile.Capabilities.Unicode = enable; + return console; + } + /// /// Makes the console interactive. /// diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render.NonUnicode.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render.NonUnicode.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render.Unicode.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render.Unicode.verified.txt new file mode 100644 index 00000000..cd18c3cc --- /dev/null +++ b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render.Unicode.verified.txt @@ -0,0 +1,3 @@ +▀ ▀ + +▀ ▀ diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_MaxWidth.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_MaxWidth.NonUnicode.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_MaxWidth.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_MaxWidth.NonUnicode.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_MaxWidth.Unicode.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_MaxWidth.Unicode.verified.txt new file mode 100644 index 00000000..8b5944e7 --- /dev/null +++ b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_MaxWidth.Unicode.verified.txt @@ -0,0 +1,3 @@ +▀ + + diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_NarrowTerminal.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_NarrowTerminal.NonUnicode.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_NarrowTerminal.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_NarrowTerminal.NonUnicode.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_NarrowTerminal.Unicode.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_NarrowTerminal.Unicode.verified.txt new file mode 100644 index 00000000..8b5944e7 --- /dev/null +++ b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_NarrowTerminal.Unicode.verified.txt @@ -0,0 +1,3 @@ +▀ + + diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_Nested.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_Nested.NonUnicode.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_Nested.Output.verified.txt rename to src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_Nested.NonUnicode.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_Nested.Unicode.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_Nested.Unicode.verified.txt new file mode 100644 index 00000000..de0fc78b --- /dev/null +++ b/src/Spectre.Console.Tests/Expectations/Widgets/Canvas/Render_Nested.Unicode.verified.txt @@ -0,0 +1,3 @@ +┌────┐ +│ ▀▄ │ +└────┘ diff --git a/src/Spectre.Console.Tests/Unit/Widgets/CanvasTests.cs b/src/Spectre.Console.Tests/Unit/Widgets/CanvasTests.cs index cd6891e2..bbad2676 100644 --- a/src/Spectre.Console.Tests/Unit/Widgets/CanvasTests.cs +++ b/src/Spectre.Console.Tests/Unit/Widgets/CanvasTests.cs @@ -28,13 +28,16 @@ public class CanvasTests } } - [Fact] + [Theory] + [InlineData(true)] + [InlineData(false)] [Expectation("Render")] - public async Task Should_Render_Canvas_Correctly() + public async Task Should_Render_Canvas_Correctly(bool supportsUnicode) { // Given var console = new TestConsole() .Colors(ColorSystem.Standard) + .SupportsUnicode(supportsUnicode) .EmitAnsiSequences(); var canvas = new Canvas(width: 5, height: 5); @@ -47,16 +50,21 @@ public class CanvasTests console.Write(canvas); // Then - await Verifier.Verify(console.Output); + await Verifier + .Verify(console.Output) + .UseMethodName(supportsUnicode ? "Unicode" : "NonUnicode"); } - [Fact] + [Theory] + [InlineData(true)] + [InlineData(false)] [Expectation("Render_Nested")] - public async Task Simple_Measure() + public async Task Simple_Measure(bool supportsUnicode) { // Given var console = new TestConsole() .Colors(ColorSystem.Standard) + .SupportsUnicode(supportsUnicode) .EmitAnsiSequences(); var panel = new Panel(new Canvas(width: 2, height: 2) @@ -67,17 +75,22 @@ public class CanvasTests console.Write(panel); // Then - await Verifier.Verify(console.Output); + await Verifier + .Verify(console.Output) + .UseMethodName(supportsUnicode ? "Unicode" : "NonUnicode"); } - [Fact] + [Theory] + [InlineData(true)] + [InlineData(false)] [Expectation("Render_NarrowTerminal")] - public async Task Should_Scale_Down_Canvas_Is_Bigger_Than_Terminal() + public async Task Should_Scale_Down_Canvas_Is_Bigger_Than_Terminal(bool supportsUnicode) { // Given var console = new TestConsole() .Width(10) .Colors(ColorSystem.Standard) + .SupportsUnicode(supportsUnicode) .EmitAnsiSequences(); var canvas = new Canvas(width: 20, height: 10); @@ -88,19 +101,27 @@ public class CanvasTests console.Write(canvas); // Then - await Verifier.Verify(console.Output); + await Verifier + .Verify(console.Output) + .UseMethodName(supportsUnicode ? "Unicode" : "NonUnicode"); } - [Fact] + [Theory] + [InlineData(true)] + [InlineData(false)] [Expectation("Render_MaxWidth")] - public async Task Should_Scale_Down_Canvas_If_MaxWidth_Is_Set() + public async Task Should_Scale_Down_Canvas_If_MaxWidth_Is_Set(bool supportsUnicode) { // Given var console = new TestConsole() .Colors(ColorSystem.Standard) + .SupportsUnicode(supportsUnicode) .EmitAnsiSequences(); - var canvas = new Canvas(width: 20, height: 10) { MaxWidth = 10 }; + var canvas = new Canvas(width: 20, height: 10) + { + MaxWidth = 10 + }; canvas.SetPixel(0, 0, Color.Aqua); canvas.SetPixel(19, 9, Color.Aqua); @@ -108,16 +129,22 @@ public class CanvasTests console.Write(canvas); // Then - await Verifier.Verify(console.Output); + await Verifier + .Verify(console.Output) + .UseMethodName(supportsUnicode ? "Unicode" : "NonUnicode"); } - [Fact] - public void Should_Not_Render_Canvas_If_Canvas_Cannot_Be_Scaled_Down() + [Theory] + [InlineData(true, 5)] + [InlineData(false, 10)] + public void Should_Not_Render_Canvas_If_Canvas_Cannot_Be_Scaled_Down( + bool supportsUnicode, int consoleWidth) { // Given var console = new TestConsole() - .Width(10) + .Width(consoleWidth) .Colors(ColorSystem.Standard) + .SupportsUnicode(supportsUnicode) .EmitAnsiSequences(); var canvas = new Canvas(width: 20, height: 2); diff --git a/src/Spectre.Console/Widgets/Canvas.cs b/src/Spectre.Console/Widgets/Canvas.cs index cdabca05..5106a4f2 100644 --- a/src/Spectre.Console/Widgets/Canvas.cs +++ b/src/Spectre.Console/Widgets/Canvas.cs @@ -7,6 +7,11 @@ public sealed class Canvas : Renderable { private readonly Color?[,] _pixels; + private const string Transparent = " "; + private const string DoubleTransparent = " "; + private const string UpperHalfBlock = "▀"; + private const string LowerHalfBlock = "▄"; + /// /// Gets the width of the canvas. /// @@ -31,6 +36,7 @@ public sealed class Canvas : Renderable /// /// Gets or sets the pixel width. /// + [Obsolete("Not used anymore. Will be removed in future update.")] public int PixelWidth { get; set; } = 2; /// @@ -72,31 +78,25 @@ public sealed class Canvas : Renderable /// protected override Measurement Measure(RenderOptions options, int maxWidth) { - if (PixelWidth < 0) - { - throw new InvalidOperationException("Pixel width must be greater than zero."); - } - + var pixelWidth = options.Unicode ? 1 : 2; var width = MaxWidth ?? Width; - if (maxWidth < width * PixelWidth) - { - return new Measurement(maxWidth, maxWidth); - } - - return new Measurement(width * PixelWidth, width * PixelWidth); + return maxWidth < width * pixelWidth + ? new Measurement(maxWidth, maxWidth) + : new Measurement(width * pixelWidth, width * pixelWidth); } /// protected override IEnumerable Render(RenderOptions options, int maxWidth) { - if (PixelWidth < 0) - { - throw new InvalidOperationException("Pixel width must be greater than zero."); - } + return options.Unicode + ? RenderUnicode(maxWidth) + : RenderNonUnicode(maxWidth); + } + private IEnumerable RenderUnicode(int maxWidth) + { var pixels = _pixels; - var pixel = new string(' ', PixelWidth); var width = Width; var height = Height; @@ -108,14 +108,86 @@ public sealed class Canvas : Renderable } // Exceed the max width when we take pixel width into account? - if (width * PixelWidth > maxWidth) + if (width > maxWidth) { - height = (int)(height * (maxWidth / (float)(width * PixelWidth))); - width = maxWidth / PixelWidth; + height = (int)(height * (maxWidth / (float)(width))); + width = maxWidth; - // If it's not possible to scale the canvas sufficiently, it's too small to render. if (height == 0) { + // If it's not possible to scale the canvas sufficiently, it's too small to render. + yield break; + } + } + + // Need to rescale the pixel buffer? + if (Scale && (width != Width || height != Height)) + { + pixels = ScaleDown(width, height); + } + + for (var y = 0; y < height; y += 2) + { + for (var x = 0; x < width; x++) + { + var upper = pixels[x, y]; + var lower = y < height - 1 ? pixels[x, y + 1] : null; + + if (upper == null && lower == null) + { + // None visible + yield return new Segment(Transparent); + } + else if (upper != null && lower != null) + { + // Both pixels visible + yield return new Segment( + UpperHalfBlock, new Style( + foreground: upper, + background: lower)); + } + else if (upper != null) + { + // Upper visible + yield return new Segment( + UpperHalfBlock, new Style( + foreground: upper)); + } + else if (lower != null) + { + // Lower visible + yield return new Segment( + LowerHalfBlock, new Style( + foreground: lower)); + } + } + + yield return Segment.LineBreak; + } + } + + private IEnumerable RenderNonUnicode(int maxWidth) + { + var pixels = _pixels; + var width = Width; + var height = Height; + + // Got a max width? + if (MaxWidth != null) + { + height = (int)(height * ((float)MaxWidth.Value) / Width); + width = MaxWidth.Value; + } + + // Exceed the max width when we take pixel width into account? + if (width * 2 > maxWidth) + { + height = (int)(height * (maxWidth / (float)(width * 2))); + width = maxWidth / 2; + + if (height == 0) + { + // If it's not possible to scale the canvas sufficiently, it's too small to render. yield break; } } @@ -133,11 +205,11 @@ public sealed class Canvas : Renderable var color = pixels[x, y]; if (color != null) { - yield return new Segment(pixel, new Style(background: color)); + yield return new Segment(DoubleTransparent, new Style(background: color)); } else { - yield return new Segment(pixel); + yield return new Segment(DoubleTransparent); } }