Merge pull request #3734 from tznind/sixel-encoder-tinkering

Fixes #1265 - Adds Sixel rendering support
This commit is contained in:
Tig
2024-10-28 06:13:20 -07:00
committed by GitHub
19 changed files with 2101 additions and 64 deletions

View File

@@ -26,4 +26,10 @@ public static partial class Application // Driver abstractions
/// </remarks>
[SerializableConfigurationProperty (Scope = typeof (SettingsScope))]
public static string ForceDriver { get; set; } = string.Empty;
/// <summary>
/// Collection of sixel images to write out to screen when updating.
/// Only add to this collection if you are sure terminal supports sixel format.
/// </summary>
public static List<SixelToRender> Sixel = new List<SixelToRender> ();
}

View File

@@ -420,6 +420,13 @@ internal class CursesDriver : ConsoleDriver
}
}
// SIXELS
foreach (var s in Application.Sixel)
{
SetCursorPosition (s.ScreenPosition.X, s.ScreenPosition.Y);
Console.Write(s.SixelData);
}
SetCursorPosition (0, 0);
_currentCursorVisibility = savedVisibility;

View File

@@ -1356,6 +1356,19 @@ public static class EscSeqUtils
/// </summary>
public const string CSI_ReportDeviceAttributes_Terminator = "c";
/*
TODO: depends on https://github.com/gui-cs/Terminal.Gui/pull/3768
/// <summary>
/// CSI 16 t - Request sixel resolution (width and height in pixels)
/// </summary>
public static readonly AnsiEscapeSequenceRequest CSI_RequestSixelResolution = new () { Request = CSI + "16t", Terminator = "t" };
/// <summary>
/// CSI 14 t - Request window size in pixels (width x height)
/// </summary>
public static readonly AnsiEscapeSequenceRequest CSI_RequestWindowSizeInPixels = new () { Request = CSI + "14t", Terminator = "t" };
*/
/// <summary>
/// CSI 1 8 t | yes | yes | yes | report window size in chars
/// https://terminalguide.namepad.de/seq/csi_st-18/

View File

@@ -1020,6 +1020,15 @@ internal class NetDriver : ConsoleDriver
SetCursorPosition (lastCol, row);
Console.Write (output);
}
foreach (var s in Application.Sixel)
{
if (!string.IsNullOrWhiteSpace (s.SixelData))
{
SetCursorPosition (s.ScreenPosition.X, s.ScreenPosition.Y);
Console.Write (s.SixelData);
}
}
}
SetCursorPosition (0, 0);
@@ -1126,9 +1135,10 @@ internal class NetDriver : ConsoleDriver
_mainLoopDriver = new NetMainLoop (this);
_mainLoopDriver.ProcessInput = ProcessInput;
return new MainLoop (_mainLoopDriver);
}
private void ProcessInput (InputResult inputEvent)
{
switch (inputEvent.EventType)

View File

@@ -37,6 +37,7 @@ internal class WindowsConsole
private CursorVisibility? _currentCursorVisibility;
private CursorVisibility? _pendingCursorVisibility;
private readonly StringBuilder _stringBuilder = new (256 * 1024);
private string _lastWrite = string.Empty;
public WindowsConsole ()
{
@@ -118,7 +119,21 @@ internal class WindowsConsole
var s = _stringBuilder.ToString ();
result = WriteConsole (_screenBuffer, s, (uint)s.Length, out uint _, nint.Zero);
// TODO: requires extensive testing if we go down this route
// If console output has changed
if (s != _lastWrite)
{
// supply console with the new content
result = WriteConsole (_screenBuffer, s, (uint)s.Length, out uint _, nint.Zero);
}
_lastWrite = s;
foreach (var sixel in Application.Sixel)
{
SetCursorPosition (new Coord ((short)sixel.ScreenPosition.X, (short)sixel.ScreenPosition.Y));
WriteConsole (_screenBuffer, sixel.SixelData, (uint)sixel.SixelData.Length, out uint _, nint.Zero);
}
}
if (!result)

View File

@@ -0,0 +1,20 @@
namespace Terminal.Gui;
/// <summary>
/// Implementation of <see cref="ISixelSupportDetector"/> that assumes best
/// case scenario (full support including transparency with 10x20 resolution).
/// </summary>
public class AssumeSupportDetector : ISixelSupportDetector
{
/// <inheritdoc/>
public SixelSupportResult Detect ()
{
return new()
{
IsSupported = true,
MaxPaletteColors = 256,
Resolution = new (10, 20),
SupportsTransparency = true
};
}
}

View File

@@ -0,0 +1,15 @@
namespace Terminal.Gui;
/// <summary>
/// Interface for detecting sixel support. Either through
/// ansi requests to terminal or config file etc.
/// </summary>
public interface ISixelSupportDetector
{
/// <summary>
/// Gets the supported sixel state e.g. by sending Ansi escape sequences
/// or from a config file etc.
/// </summary>
/// <returns>Description of sixel support.</returns>
public SixelSupportResult Detect ();
}

View File

@@ -0,0 +1,91 @@
using System.Collections.Concurrent;
namespace Terminal.Gui;
/// <summary>
/// Translates colors in an image into a Palette of up to <see cref="MaxColors"/> colors (typically 256).
/// </summary>
public class ColorQuantizer
{
/// <summary>
/// Gets the current colors in the palette based on the last call to
/// <see cref="BuildPalette"/>.
/// </summary>
public IReadOnlyCollection<Color> Palette { get; private set; } = new List<Color> ();
/// <summary>
/// Gets or sets the maximum number of colors to put into the <see cref="Palette"/>.
/// Defaults to 256 (the maximum for sixel images).
/// </summary>
public int MaxColors { get; set; } = 256;
/// <summary>
/// Gets or sets the algorithm used to map novel colors into existing
/// palette colors (closest match). Defaults to <see cref="EuclideanColorDistance"/>
/// </summary>
public IColorDistance DistanceAlgorithm { get; set; } = new EuclideanColorDistance ();
/// <summary>
/// Gets or sets the algorithm used to build the <see cref="Palette"/>.
/// </summary>
public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (), 8);
private readonly ConcurrentDictionary<Color, int> _nearestColorCache = new ();
/// <summary>
/// Builds a <see cref="Palette"/> of colors that most represent the colors used in <paramref name="pixels"/> image.
/// This is based on the currently configured <see cref="PaletteBuildingAlgorithm"/>.
/// </summary>
/// <param name="pixels"></param>
public void BuildPalette (Color [,] pixels)
{
List<Color> allColors = new ();
int width = pixels.GetLength (0);
int height = pixels.GetLength (1);
for (var x = 0; x < width; x++)
{
for (var y = 0; y < height; y++)
{
allColors.Add (pixels [x, y]);
}
}
_nearestColorCache.Clear ();
Palette = PaletteBuildingAlgorithm.BuildPalette (allColors, MaxColors);
}
/// <summary>
/// Returns the closest color in <see cref="Palette"/> that matches <paramref name="toTranslate"/>
/// based on the color comparison algorithm defined by <see cref="DistanceAlgorithm"/>
/// </summary>
/// <param name="toTranslate"></param>
/// <returns></returns>
public int GetNearestColor (Color toTranslate)
{
if (_nearestColorCache.TryGetValue (toTranslate, out int cachedAnswer))
{
return cachedAnswer;
}
// Simple nearest color matching based on DistanceAlgorithm
var minDistance = double.MaxValue;
var nearestIndex = 0;
for (var index = 0; index < Palette.Count; index++)
{
Color color = Palette.ElementAt (index);
double distance = DistanceAlgorithm.CalculateDistance (color, toTranslate);
if (distance < minDistance)
{
minDistance = distance;
nearestIndex = index;
}
}
_nearestColorCache.TryAdd (toTranslate, nearestIndex);
return nearestIndex;
}
}

View File

@@ -0,0 +1,31 @@
namespace Terminal.Gui;
/// <summary>
/// <para>
/// Calculates the distance between two colors using Euclidean distance in 3D RGB space.
/// This measures the straight-line distance between the two points representing the colors.
/// </para>
/// <para>
/// Euclidean distance in RGB space is calculated as:
/// </para>
/// <code>
/// √((R2 - R1)² + (G2 - G1)² + (B2 - B1)²)
/// </code>
/// <remarks>Values vary from 0 to ~441.67 linearly</remarks>
/// <remarks>
/// This distance metric is commonly used for comparing colors in RGB space, though
/// it doesn't account for perceptual differences in color.
/// </remarks>
/// </summary>
public class EuclideanColorDistance : IColorDistance
{
/// <inheritdoc/>
public double CalculateDistance (Color c1, Color c2)
{
int rDiff = c1.R - c2.R;
int gDiff = c1.G - c2.G;
int bDiff = c1.B - c2.B;
return Math.Sqrt (rDiff * rDiff + gDiff * gDiff + bDiff * bDiff);
}
}

View File

@@ -0,0 +1,18 @@
namespace Terminal.Gui;
/// <summary>
/// Interface for algorithms that compute the relative distance between pairs of colors.
/// This is used for color matching to a limited palette, such as in Sixel rendering.
/// </summary>
public interface IColorDistance
{
/// <summary>
/// Computes a similarity metric between two <see cref="Color"/> instances.
/// A larger value indicates more dissimilar colors, while a smaller value indicates more similar colors.
/// The metric is internally consistent for the given algorithm.
/// </summary>
/// <param name="c1">The first color.</param>
/// <param name="c2">The second color.</param>
/// <returns>A numeric value representing the distance between the two colors.</returns>
double CalculateDistance (Color c1, Color c2);
}

View File

@@ -0,0 +1,19 @@
namespace Terminal.Gui;
/// <summary>
/// Builds a palette of a given size for a given set of input colors.
/// </summary>
public interface IPaletteBuilder
{
/// <summary>
/// Reduce the number of <paramref name="colors"/> to <paramref name="maxColors"/> (or less)
/// using an appropriate selection algorithm.
/// </summary>
/// <param name="colors">
/// Color of every pixel in the image. Contains duplication in order
/// to support algorithms that weigh how common a color is.
/// </param>
/// <param name="maxColors">The maximum number of colours that should be represented.</param>
/// <returns></returns>
List<Color> BuildPalette (List<Color> colors, int maxColors);
}

View File

@@ -0,0 +1,112 @@
using Terminal.Gui;
using Color = Terminal.Gui.Color;
/// <summary>
/// Simple fast palette building algorithm which uses the frequency that a color is seen
/// to determine whether it will appear in the final palette. Includes a threshold where
/// by colors will be considered 'the same'. This reduces the chance of under represented
/// colors being missed completely.
/// </summary>
public class PopularityPaletteWithThreshold : IPaletteBuilder
{
private readonly IColorDistance _colorDistance;
private readonly double _mergeThreshold;
/// <summary>
/// Creates a new instance with the given color grouping parameters.
/// </summary>
/// <param name="colorDistance">Determines which different colors can be considered the same.</param>
/// <param name="mergeThreshold">Threshold for merging two colors together.</param>
public PopularityPaletteWithThreshold (IColorDistance colorDistance, double mergeThreshold)
{
_colorDistance = colorDistance;
_mergeThreshold = mergeThreshold; // Set the threshold for merging similar colors
}
/// <inheritdoc/>
public List<Color> BuildPalette (List<Color> colors, int maxColors)
{
if (colors == null || colors.Count == 0 || maxColors <= 0)
{
return new ();
}
// Step 1: Build the histogram of colors (count occurrences)
Dictionary<Color, int> colorHistogram = new ();
foreach (Color color in colors)
{
if (colorHistogram.ContainsKey (color))
{
colorHistogram [color]++;
}
else
{
colorHistogram [color] = 1;
}
}
// If we already have fewer or equal colors than the limit, no need to merge
if (colorHistogram.Count <= maxColors)
{
return colorHistogram.Keys.ToList ();
}
// Step 2: Merge similar colors using the color distance threshold
Dictionary<Color, int> mergedHistogram = MergeSimilarColors (colorHistogram, maxColors);
// Step 3: Sort the histogram by frequency (most frequent colors first)
List<Color> sortedColors = mergedHistogram.OrderByDescending (c => c.Value)
.Take (maxColors) // Keep only the top `maxColors` colors
.Select (c => c.Key)
.ToList ();
return sortedColors;
}
/// <summary>
/// Merge colors in the histogram if they are within the threshold distance
/// </summary>
/// <param name="colorHistogram"></param>
/// <param name="maxColors"></param>
/// <returns></returns>
private Dictionary<Color, int> MergeSimilarColors (Dictionary<Color, int> colorHistogram, int maxColors)
{
Dictionary<Color, int> mergedHistogram = new ();
foreach (KeyValuePair<Color, int> entry in colorHistogram)
{
Color currentColor = entry.Key;
var merged = false;
// Try to merge the current color with an existing entry in the merged histogram
foreach (Color mergedEntry in mergedHistogram.Keys.ToList ())
{
double distance = _colorDistance.CalculateDistance (currentColor, mergedEntry);
// If the colors are similar enough (within the threshold), merge them
if (distance <= _mergeThreshold)
{
mergedHistogram [mergedEntry] += entry.Value; // Add the color frequency to the existing one
merged = true;
break;
}
}
// If no similar color is found, add the current color as a new entry
if (!merged)
{
mergedHistogram [currentColor] = entry.Value;
}
// Early exit if we've reduced the colors to the maxColors limit
if (mergedHistogram.Count >= maxColors)
{
return mergedHistogram;
}
}
return mergedHistogram;
}
}

View File

@@ -0,0 +1,252 @@
// This code is based on existing implementations of sixel algorithm in MIT licensed open source libraries
// node-sixel (Typescript) - https://github.com/jerch/node-sixel/tree/master/src
// Copyright (c) 2019, Joerg Breitbart @license MIT
// libsixel (C/C++) - https://github.com/saitoha/libsixel
// Copyright (c) 2014-2016 Hayaki Saito @license MIT
namespace Terminal.Gui;
/// <summary>
/// Encodes a images into the sixel console image output format.
/// </summary>
public class SixelEncoder
{
/*
A sixel is a column of 6 pixels - with a width of 1 pixel
Column controlled by one sixel character:
[ ] - Bit 0 (top-most pixel)
[ ] - Bit 1
[ ] - Bit 2
[ ] - Bit 3
[ ] - Bit 4
[ ] - Bit 5 (bottom-most pixel)
Special Characters
The '-' acts like '\n'. It moves the drawing cursor
to beginning of next line
The '$' acts like the <Home> key. It moves drawing
cursor back to beginning of the current line
e.g. to draw more color layers.
*/
/// <summary>
/// Gets or sets the quantizer responsible for building a representative
/// limited color palette for images and for mapping novel colors in
/// images to their closest palette color
/// </summary>
public ColorQuantizer Quantizer { get; set; } = new ();
/// <summary>
/// Encode the given bitmap into sixel encoding
/// </summary>
/// <param name="pixels"></param>
/// <returns></returns>
public string EncodeSixel (Color [,] pixels)
{
const string start = "\u001bP"; // Start sixel sequence
string defaultRatios = AnyHasAlphaOfZero (pixels) ? "0;1;0" : "0;0;0"; // Defaults for aspect ratio and grid size
const string completeStartSequence = "q"; // Signals beginning of sixel image data
const string noScaling = "\"1;1;"; // no scaling factors (1x1);
string fillArea = GetFillArea (pixels);
string pallette = GetColorPalette (pixels);
string pixelData = WriteSixel (pixels);
const string terminator = "\u001b\\"; // End sixel sequence
return start + defaultRatios + completeStartSequence + noScaling + fillArea + pallette + pixelData + terminator;
}
private string WriteSixel (Color [,] pixels)
{
var sb = new StringBuilder ();
int height = pixels.GetLength (1);
int width = pixels.GetLength (0);
// Iterate over each 'row' of the image. Because each sixel write operation
// outputs a screen area 6 pixels high (and 1+ across) we must process the image
// 6 'y' units at once (1 band)
for (var y = 0; y < height; y += 6)
{
sb.Append (ProcessBand (pixels, y, Math.Min (6, height - y), width));
// Line separator between bands
if (y + 6 < height) // Only add separator if not the last band
{
// This completes the drawing of the current line of sixel and
// returns the 'cursor' to beginning next line, newly drawn sixel
// after this will draw in the next 6 pixel high band (i.e. below).
sb.Append ("-");
}
}
return sb.ToString ();
}
private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int width)
{
var last = new sbyte [Quantizer.Palette.Count + 1];
var code = new byte [Quantizer.Palette.Count + 1];
var accu = new ushort [Quantizer.Palette.Count + 1];
var slots = new short [Quantizer.Palette.Count + 1];
Array.Fill (last, (sbyte)-1);
Array.Fill (accu, (ushort)1);
Array.Fill (slots, (short)-1);
List<int> usedColorIdx = new List<int> ();
List<List<string>> targets = new List<List<string>> ();
// Process columns within the band
for (var x = 0; x < width; ++x)
{
Array.Clear (code, 0, usedColorIdx.Count);
// Process each row in the 6-pixel high band
for (var row = 0; row < bandHeight; ++row)
{
Color color = pixels [x, startY + row];
int colorIndex = Quantizer.GetNearestColor (color);
if (color.A == 0) // Skip fully transparent pixels
{
continue;
}
if (slots [colorIndex] == -1)
{
targets.Add (new ());
if (x > 0)
{
last [usedColorIdx.Count] = 0;
accu [usedColorIdx.Count] = (ushort)x;
}
slots [colorIndex] = (short)usedColorIdx.Count;
usedColorIdx.Add (colorIndex);
}
code [slots [colorIndex]] |= (byte)(1 << row); // Accumulate SIXEL data
}
// Handle transitions between columns
for (var j = 0; j < usedColorIdx.Count; ++j)
{
if (code [j] == last [j])
{
accu [j]++;
}
else
{
if (last [j] != -1)
{
targets [j].Add (CodeToSixel (last [j], accu [j]));
}
last [j] = (sbyte)code [j];
accu [j] = 1;
}
}
}
// Process remaining data for this band
for (var j = 0; j < usedColorIdx.Count; ++j)
{
if (last [j] != 0)
{
targets [j].Add (CodeToSixel (last [j], accu [j]));
}
}
// Build the final output for this band
var result = new StringBuilder ();
for (var j = 0; j < usedColorIdx.Count; ++j)
{
result.Append ($"#{usedColorIdx [j]}{string.Join ("", targets [j])}$");
}
return result.ToString ();
}
private static string CodeToSixel (int code, int repeat)
{
var c = (char)(code + 63);
if (repeat > 3)
{
return "!" + repeat + c;
}
if (repeat == 3)
{
return c.ToString () + c + c;
}
if (repeat == 2)
{
return c.ToString () + c;
}
return c.ToString ();
}
private string GetColorPalette (Color [,] pixels)
{
Quantizer.BuildPalette (pixels);
var paletteSb = new StringBuilder ();
for (var i = 0; i < Quantizer.Palette.Count; i++)
{
Color color = Quantizer.Palette.ElementAt (i);
paletteSb.AppendFormat (
"#{0};2;{1};{2};{3}",
i,
color.R * 100 / 255,
color.G * 100 / 255,
color.B * 100 / 255);
}
return paletteSb.ToString ();
}
private string GetFillArea (Color [,] pixels)
{
int widthInChars = pixels.GetLength (0);
int heightInChars = pixels.GetLength (1);
return $"{widthInChars};{heightInChars}";
}
private bool AnyHasAlphaOfZero (Color [,] pixels)
{
int width = pixels.GetLength (0);
int height = pixels.GetLength (1);
// Loop through each pixel in the 2D array
for (var x = 0; x < width; x++)
{
for (var y = 0; y < height; y++)
{
// Check if the alpha component (A) is 0
if (pixels [x, y].A == 0)
{
return true; // Found a pixel with A of 0
}
}
}
return false; // No pixel with A of 0 was found
}
}

View File

@@ -0,0 +1,133 @@
using System.Text.RegularExpressions;
namespace Terminal.Gui;
/* TODO : Depends on https://github.com/gui-cs/Terminal.Gui/pull/3768
/// <summary>
/// Uses Ansi escape sequences to detect whether sixel is supported
/// by the terminal.
/// </summary>
public class SixelSupportDetector : ISixelSupportDetector
{
/// <summary>
/// Sends Ansi escape sequences to the console to determine whether
/// sixel is supported (and <see cref="SixelSupportResult.Resolution"/>
/// etc).
/// </summary>
/// <returns>Description of sixel support, may include assumptions where
/// expected response codes are not returned by console.</returns>
public SixelSupportResult Detect ()
{
var result = new SixelSupportResult ();
result.IsSupported = IsSixelSupportedByDar ();
if (result.IsSupported)
{
if (TryGetResolutionDirectly (out var res))
{
result.Resolution = res;
}
else if(TryComputeResolution(out res))
{
result.Resolution = res;
}
result.SupportsTransparency = IsWindowsTerminal () || IsXtermWithTransparency ();
}
return result;
}
private bool TryGetResolutionDirectly (out Size resolution)
{
// Expect something like:
//<esc>[6;20;10t
if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestSixelResolution, out var response))
{
// Terminal supports directly responding with resolution
var match = Regex.Match (response.Response, @"\[\d+;(\d+);(\d+)t$");
if (match.Success)
{
if (int.TryParse (match.Groups [1].Value, out var ry) &&
int.TryParse (match.Groups [2].Value, out var rx))
{
resolution = new Size (rx, ry);
return true;
}
}
}
resolution = default;
return false;
}
private bool TryComputeResolution (out Size resolution)
{
// Fallback to window size in pixels and characters
if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestWindowSizeInPixels, out var pixelSizeResponse)
&& AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_ReportTerminalSizeInChars, out var charSizeResponse))
{
// Example [4;600;1200t
var pixelMatch = Regex.Match (pixelSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$");
// Example [8;30;120t
var charMatch = Regex.Match (charSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$");
if (pixelMatch.Success && charMatch.Success)
{
// Extract pixel dimensions
if (int.TryParse (pixelMatch.Groups [1].Value, out var pixelHeight)
&& int.TryParse (pixelMatch.Groups [2].Value, out var pixelWidth)
&&
// Extract character dimensions
int.TryParse (charMatch.Groups [1].Value, out var charHeight)
&& int.TryParse (charMatch.Groups [2].Value, out var charWidth)
&& charWidth != 0
&& charHeight != 0) // Avoid divide by zero
{
// Calculate the character cell size in pixels
var cellWidth = (int)Math.Round ((double)pixelWidth / charWidth);
var cellHeight = (int)Math.Round ((double)pixelHeight / charHeight);
// Set the resolution based on the character cell size
resolution = new Size (cellWidth, cellHeight);
return true;
}
}
}
resolution = default;
return false;
}
private bool IsSixelSupportedByDar ()
{
return AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_SendDeviceAttributes, out AnsiEscapeSequenceResponse darResponse)
? darResponse.Response.Split (';').Contains ("4")
: false;
}
private bool IsWindowsTerminal ()
{
return !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable ("WT_SESSION"));;
}
private bool IsXtermWithTransparency ()
{
// Check if running in real xterm (XTERM_VERSION is more reliable than TERM)
var xtermVersionStr = Environment.GetEnvironmentVariable ("XTERM_VERSION");
// If XTERM_VERSION exists, we are in a real xterm
if (!string.IsNullOrWhiteSpace (xtermVersionStr) && int.TryParse (xtermVersionStr, out var xtermVersion) && xtermVersion >= 370)
{
return true;
}
return false;
}
}*/

View File

@@ -0,0 +1,33 @@
namespace Terminal.Gui;
/// <summary>
/// Describes the discovered state of sixel support and ancillary information
/// e.g. <see cref="Resolution"/>. You can use any <see cref="ISixelSupportDetector"/>
/// to discover this information.
/// </summary>
public class SixelSupportResult
{
/// <summary>
/// Whether the terminal supports sixel graphic format.
/// Defaults to false.
/// </summary>
public bool IsSupported { get; set; }
/// <summary>
/// The number of pixels of sixel that corresponds to each Col (<see cref="Size.Width"/>)
/// and each Row (<see cref="Size.Height"/>. Defaults to 10x20.
/// </summary>
public Size Resolution { get; set; } = new (10, 20);
/// <summary>
/// The maximum number of colors that can be included in a sixel image. Defaults
/// to 256.
/// </summary>
public int MaxPaletteColors { get; set; } = 256;
/// <summary>
/// Whether the terminal supports transparent background sixels.
/// Defaults to false
/// </summary>
public bool SupportsTransparency { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace Terminal.Gui;
/// <summary>
/// Describes a request to render a given <see cref="SixelData"/> at a given <see cref="ScreenPosition"/>.
/// Requires that the terminal and <see cref="ConsoleDriver"/> both support sixel.
/// </summary>
public class SixelToRender
{
/// <summary>
/// gets or sets the encoded sixel data. Use <see cref="SixelEncoder"/> to convert bitmaps
/// into encoded sixel data.
/// </summary>
public string SixelData { get; set; }
/// <summary>
/// gets or sets where to move the cursor to before outputting the <see cref="SixelData"/>.
/// </summary>
public Point ScreenPosition { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
namespace Terminal.Gui.DrawingTests;
public class PopularityPaletteWithThresholdTests
{
private readonly IColorDistance _colorDistance;
public PopularityPaletteWithThresholdTests () { _colorDistance = new EuclideanColorDistance (); }
[Fact]
public void BuildPalette_EmptyColorList_ReturnsEmptyPalette ()
{
// Arrange
var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50);
List<Color> colors = new ();
// Act
List<Color> result = paletteBuilder.BuildPalette (colors, 256);
// Assert
Assert.Empty (result);
}
[Fact]
public void BuildPalette_MaxColorsZero_ReturnsEmptyPalette ()
{
// Arrange
var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50);
List<Color> colors = new () { new (255, 0), new (0, 255) };
// Act
List<Color> result = paletteBuilder.BuildPalette (colors, 0);
// Assert
Assert.Empty (result);
}
[Fact]
public void BuildPalette_SingleColorList_ReturnsSingleColor ()
{
// Arrange
var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50);
List<Color> colors = new () { new (255, 0), new (255, 0) };
// Act
List<Color> result = paletteBuilder.BuildPalette (colors, 256);
// Assert
Assert.Single (result);
Assert.Equal (new (255, 0), result [0]);
}
[Fact]
public void BuildPalette_ThresholdMergesSimilarColors_WhenColorCountExceedsMax ()
{
// Arrange
var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); // Set merge threshold to 50
List<Color> colors = new()
{
new (255, 0), // Red
new (250, 0), // Very close to Red
new (0, 255), // Green
new (0, 250) // Very close to Green
};
// Act
List<Color> result = paletteBuilder.BuildPalette (colors, 2); // Limit palette to 2 colors
// Assert
Assert.Equal (2, result.Count); // Red and Green should be merged with their close colors
Assert.Contains (new (255, 0), result); // Red (or close to Red) should be present
Assert.Contains (new (0, 255), result); // Green (or close to Green) should be present
}
[Fact]
public void BuildPalette_NoMergingIfColorCountIsWithinMax ()
{
// Arrange
var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50);
List<Color> colors = new ()
{
new (255, 0), // Red
new (0, 255) // Green
};
// Act
List<Color> result = paletteBuilder.BuildPalette (colors, 256); // Set maxColors higher than the number of unique colors
// Assert
Assert.Equal (2, result.Count); // No merging should occur since we are under the limit
Assert.Contains (new (255, 0), result);
Assert.Contains (new (0, 255), result);
}
[Fact]
public void BuildPalette_MergesUntilMaxColorsReached ()
{
// Arrange
var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50);
List<Color> colors = new()
{
new (255, 0), // Red
new (254, 0), // Close to Red
new (0, 255), // Green
new (0, 254) // Close to Green
};
// Act
List<Color> result = paletteBuilder.BuildPalette (colors, 2); // Set maxColors to 2
// Assert
Assert.Equal (2, result.Count); // Only two colors should be in the final palette
Assert.Contains (new (255, 0), result);
Assert.Contains (new (0, 255), result);
}
}

View File

@@ -0,0 +1,233 @@
using Color = Terminal.Gui.Color;
namespace UnitTests.Drawing;
public class SixelEncoderTests
{
[Fact]
public void EncodeSixel_RedSquare12x12_ReturnsExpectedSixel ()
{
string expected = "\u001bP" // Start sixel sequence
+ "0;0;0" // Defaults for aspect ratio and grid size
+ "q" // Signals beginning of sixel image data
+ "\"1;1;12;12" // no scaling factors (1x1) and filling 12x12 pixel area
/*
* Definition of the color palette
* #<index>;<type>;<R>;<G>;<B>" - 2 means RGB. The values range 0 to 100
*/
+ "#0;2;100;0;0" // Red color definition
/*
* Start of the Pixel data
* We draw 6 rows at once, so end up with 2 'lines'
* Both are basically the same and terminate with dollar hyphen (except last row)
* Format is:
* #0 (selects to use color palette index 0 i.e. red)
* !12 (repeat next byte 12 times i.e. the whole length of the row)
* ~ (the byte 111111 i.e. fill completely)
* $ (return to start of line)
* - (move down to next line)
*/
+ "#0!12~$-"
+ "#0!12~$" // Next 6 rows of red pixels
+ "\u001b\\"; // End sixel sequence
// Arrange: Create a 12x12 bitmap filled with red
Color [,] pixels = new Color [12, 12];
for (var x = 0; x < 12; x++)
{
for (var y = 0; y < 12; y++)
{
pixels [x, y] = new (255, 0, 0);
}
}
// Act: Encode the image
var encoder = new SixelEncoder (); // Assuming SixelEncoder is the class that contains the EncodeSixel method
string result = encoder.EncodeSixel (pixels);
// Since image is only red we should only have 1 color definition
Color c1 = Assert.Single (encoder.Quantizer.Palette);
Assert.Equal (new (255, 0, 0), c1);
Assert.Equal (expected, result);
}
[Fact]
public void EncodeSixel_12x12GridPattern3x3_ReturnsExpectedSixel ()
{
/*
* Each block is a 3x3 square, alternating black and white.
* The pattern alternates between rows, creating a checkerboard.
* We have 4 blocks per row, and this repeats over 12x12 pixels.
*
* ███...███...
* ███...███...
* ███...███...
* ...███...███
* ...███...███
* ...███...███
* ███...███...
* ███...███...
* ███...███...
* ...███...███
* ...███...███
* ...███...███
*
* Because we are dealing with sixels (drawing 6 rows at once), we will
* see 2 bands being drawn. We will also see how we have to 'go back over'
* the current line after drawing the black (so we can draw the white).
*/
string expected = "\u001bP" // Start sixel sequence
+ "0;0;0" // Defaults for aspect ratio and grid size
+ "q" // Signals beginning of sixel image data
+ "\"1;1;12;12" // no scaling factors (1x1) and filling 12x12 pixel area
/*
* Definition of the color palette
*/
+ "#0;2;0;0;0" // Black color definition (index 0: RGB 0,0,0)
+ "#1;2;100;100;100" // White color definition (index 1: RGB 100,100,100)
/*
* Start of the Pixel data
*
* Lets consider only the first 6 pixel (vertically). We have to fill the top 3 black and bottom 3 white.
* So we need to select black and fill 000111. To convert this into a character we must +63 and convert to ASCII.
* Later on we will also need to select white and fill the inverse, i.e. 111000.
*
* 111000 (binary) → w (ASCII 119).
* 000111 (binary) → F (ASCII 70).
*
* Therefore the lines become
*
* #0 (Select black)
* FFF (fill first 3 pixels horizontally - and top half of band black)
* www (fill next 3 pixels horizontally - bottom half of band black)
* FFFwww (as above to finish the line)
*
* Next we must go back and fill the white (on the same band)
* #1 (Select white)
*/
+ "#0FFFwwwFFFwww$" // First pass of top band (Filling black)
+ "#1wwwFFFwwwFFF$-" // Second pass of top band (Filling white)
// Sequence repeats exactly the same because top band is actually identical pixels to bottom band
+ "#0FFFwwwFFFwww$" // First pass of bottom band (Filling black)
+ "#1wwwFFFwwwFFF$" // Second pass of bottom band (Filling white)
+ "\u001b\\"; // End sixel sequence
// Arrange: Create a 12x12 bitmap with a 3x3 checkerboard pattern
Color [,] pixels = new Color [12, 12];
for (var y = 0; y < 12; y++)
{
for (var x = 0; x < 12; x++)
{
// Create a 3x3 checkerboard by alternating the color based on pixel coordinates
if ((x / 3 + y / 3) % 2 == 0)
{
pixels [x, y] = new (0, 0, 0); // Black
}
else
{
pixels [x, y] = new (255, 255, 255); // White
}
}
}
// Act: Encode the image
var encoder = new SixelEncoder (); // Assuming SixelEncoder is the class that contains the EncodeSixel method
string result = encoder.EncodeSixel (pixels);
// We should have only black and white in the palette
Assert.Equal (2, encoder.Quantizer.Palette.Count);
Color black = encoder.Quantizer.Palette.ElementAt (0);
Color white = encoder.Quantizer.Palette.ElementAt (1);
Assert.Equal (new (0, 0, 0), black);
Assert.Equal (new (255, 255, 255), white);
// Compare the generated SIXEL string with the expected one
Assert.Equal (expected, result);
}
[Fact]
public void EncodeSixel_Transparent12x12_ReturnsExpectedSixel ()
{
string expected = "\u001bP" // Start sixel sequence
+ "0;1;0" // Defaults for aspect ratio and grid size
+ "q" // Signals beginning of sixel image data
+ "\"1;1;12;12" // no scaling factors (1x1) and filling 12x12 pixel area
+ "#0;2;0;0;0" // Black transparent (TODO: Shouldn't really be output this if it is transparent)
// Since all pixels are transparent we don't output any colors at all, so its just newline
+ "-" // Nothing on first or second lines
+ "\u001b\\"; // End sixel sequence
// Arrange: Create a 12x12 bitmap filled with fully transparent pixels
Color [,] pixels = new Color [12, 12];
for (var x = 0; x < 12; x++)
{
for (var y = 0; y < 12; y++)
{
pixels [x, y] = new (0, 0, 0, 0); // Fully transparent
}
}
// Act: Encode the image
var encoder = new SixelEncoder ();
string result = encoder.EncodeSixel (pixels);
// Assert: Expect the result to be fully transparent encoded output
Assert.Equal (expected, result);
}
[Fact]
public void EncodeSixel_VerticalMix_TransparentAndColor_ReturnsExpectedSixel ()
{
string expected = "\u001bP" // Start sixel sequence
+ "0;1;0" // Defaults for aspect ratio and grid size (1 indicates support for transparent pixels)
+ "q" // Signals beginning of sixel image data
+ "\"1;1;12;12" // No scaling factors (1x1) and filling 12x12 pixel area
/*
* Define the color palette:
* We'll use one color (Red) for the colored pixels.
*/
+ "#0;2;100;0;0" // Red color definition (index 0: RGB 100,0,0)
+ "#1;2;0;0;0" // Black transparent (TODO: Shouldn't really be output this if it is transparent)
/*
* Start of the Pixel data
* We have alternating transparent (0) and colored (red) pixels in a vertical band.
* The pattern for each sixel byte is 101010, which in binary (+63) converts to ASCII character 'T'.
* Since we have 12 pixels horizontally, we'll see this pattern repeat across the row so we see
* the 'sequence repeat' 12 times i.e. !12 (do the next letter 'T' 12 times).
*/
+ "#0!12T$-" // First band of alternating red and transparent pixels
+ "#0!12T$" // Second band, same alternating red and transparent pixels
+ "\u001b\\"; // End sixel sequence
// Arrange: Create a 12x12 bitmap with alternating transparent and red pixels in a vertical band
Color [,] pixels = new Color [12, 12];
for (var x = 0; x < 12; x++)
{
for (var y = 0; y < 12; y++)
{
// For simplicity, we'll make every other row transparent
if (y % 2 == 0)
{
pixels [x, y] = new (255, 0, 0); // Red pixel
}
else
{
pixels [x, y] = new (0, 0, 0, 0); // Transparent pixel
}
}
}
// Act: Encode the image
var encoder = new SixelEncoder ();
string result = encoder.EncodeSixel (pixels);
// Assert: Expect the result to match the expected sixel output
Assert.Equal (expected, result);
}
}