diff --git a/Terminal.Gui/Application/Application.Driver.cs b/Terminal.Gui/Application/Application.Driver.cs
index 2abeb1337a..c2f6431db7 100644
--- a/Terminal.Gui/Application/Application.Driver.cs
+++ b/Terminal.Gui/Application/Application.Driver.cs
@@ -26,4 +26,10 @@ public static partial class Application // Driver abstractions
///
[SerializableConfigurationProperty (Scope = typeof (SettingsScope))]
public static string ForceDriver { get; set; } = string.Empty;
+
+ ///
+ /// Collection of sixel images to write out to screen when updating.
+ /// Only add to this collection if you are sure terminal supports sixel format.
+ ///
+ public static List Sixel = new List ();
}
diff --git a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs
index b806457d46..88005861ad 100644
--- a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs
+++ b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs
@@ -420,6 +420,13 @@ public override void UpdateScreen ()
}
}
+ // SIXELS
+ foreach (var s in Application.Sixel)
+ {
+ SetCursorPosition (s.ScreenPosition.X, s.ScreenPosition.Y);
+ Console.Write(s.SixelData);
+ }
+
SetCursorPosition (0, 0);
_currentCursorVisibility = savedVisibility;
diff --git a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs
index 1eb63e34aa..6330c33702 100644
--- a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs
+++ b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs
@@ -1356,6 +1356,19 @@ public enum DECSCUSR_Style
///
public const string CSI_ReportDeviceAttributes_Terminator = "c";
+ /*
+ TODO: depends on https://github.com/gui-cs/Terminal.Gui/pull/3768
+ ///
+ /// CSI 16 t - Request sixel resolution (width and height in pixels)
+ ///
+ public static readonly AnsiEscapeSequenceRequest CSI_RequestSixelResolution = new () { Request = CSI + "16t", Terminator = "t" };
+
+ ///
+ /// CSI 14 t - Request window size in pixels (width x height)
+ ///
+ public static readonly AnsiEscapeSequenceRequest CSI_RequestWindowSizeInPixels = new () { Request = CSI + "14t", Terminator = "t" };
+ */
+
///
/// CSI 1 8 t | yes | yes | yes | report window size in chars
/// https://terminalguide.namepad.de/seq/csi_st-18/
diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs
index 8aaddc3215..cab7d3e6bf 100644
--- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs
+++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs
@@ -1020,6 +1020,15 @@ public override void UpdateScreen ()
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 override MainLoop Init ()
_mainLoopDriver = new NetMainLoop (this);
_mainLoopDriver.ProcessInput = ProcessInput;
+
return new MainLoop (_mainLoopDriver);
}
-
+
private void ProcessInput (InputResult inputEvent)
{
switch (inputEvent.EventType)
diff --git a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs
index fd5c6901c1..0b15245427 100644
--- a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs
+++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs
@@ -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 @@ public bool WriteToConsole (Size size, ExtendedCharInfo [] charInfoBuffer, Coord
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)
diff --git a/Terminal.Gui/Drawing/AssumeSupportDetector.cs b/Terminal.Gui/Drawing/AssumeSupportDetector.cs
new file mode 100644
index 0000000000..46081714ac
--- /dev/null
+++ b/Terminal.Gui/Drawing/AssumeSupportDetector.cs
@@ -0,0 +1,20 @@
+namespace Terminal.Gui;
+
+///
+/// Implementation of that assumes best
+/// case scenario (full support including transparency with 10x20 resolution).
+///
+public class AssumeSupportDetector : ISixelSupportDetector
+{
+ ///
+ public SixelSupportResult Detect ()
+ {
+ return new()
+ {
+ IsSupported = true,
+ MaxPaletteColors = 256,
+ Resolution = new (10, 20),
+ SupportsTransparency = true
+ };
+ }
+}
diff --git a/Terminal.Gui/Drawing/ISixelSupportDetector.cs b/Terminal.Gui/Drawing/ISixelSupportDetector.cs
new file mode 100644
index 0000000000..eb0bb9f120
--- /dev/null
+++ b/Terminal.Gui/Drawing/ISixelSupportDetector.cs
@@ -0,0 +1,15 @@
+namespace Terminal.Gui;
+
+///
+/// Interface for detecting sixel support. Either through
+/// ansi requests to terminal or config file etc.
+///
+public interface ISixelSupportDetector
+{
+ ///
+ /// Gets the supported sixel state e.g. by sending Ansi escape sequences
+ /// or from a config file etc.
+ ///
+ /// Description of sixel support.
+ public SixelSupportResult Detect ();
+}
diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs
new file mode 100644
index 0000000000..ec6d102ea8
--- /dev/null
+++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs
@@ -0,0 +1,91 @@
+using System.Collections.Concurrent;
+
+namespace Terminal.Gui;
+
+///
+/// Translates colors in an image into a Palette of up to colors (typically 256).
+///
+public class ColorQuantizer
+{
+ ///
+ /// Gets the current colors in the palette based on the last call to
+ /// .
+ ///
+ public IReadOnlyCollection Palette { get; private set; } = new List ();
+
+ ///
+ /// Gets or sets the maximum number of colors to put into the .
+ /// Defaults to 256 (the maximum for sixel images).
+ ///
+ public int MaxColors { get; set; } = 256;
+
+ ///
+ /// Gets or sets the algorithm used to map novel colors into existing
+ /// palette colors (closest match). Defaults to
+ ///
+ public IColorDistance DistanceAlgorithm { get; set; } = new EuclideanColorDistance ();
+
+ ///
+ /// Gets or sets the algorithm used to build the .
+ ///
+ public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (), 8);
+
+ private readonly ConcurrentDictionary _nearestColorCache = new ();
+
+ ///
+ /// Builds a of colors that most represent the colors used in image.
+ /// This is based on the currently configured .
+ ///
+ ///
+ public void BuildPalette (Color [,] pixels)
+ {
+ List 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);
+ }
+
+ ///
+ /// Returns the closest color in that matches
+ /// based on the color comparison algorithm defined by
+ ///
+ ///
+ ///
+ 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;
+ }
+}
diff --git a/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs b/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs
new file mode 100644
index 0000000000..935d598262
--- /dev/null
+++ b/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs
@@ -0,0 +1,31 @@
+namespace Terminal.Gui;
+
+///
+///
+/// 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.
+///
+///
+/// Euclidean distance in RGB space is calculated as:
+///
+///
+/// √((R2 - R1)² + (G2 - G1)² + (B2 - B1)²)
+///
+/// Values vary from 0 to ~441.67 linearly
+///
+/// This distance metric is commonly used for comparing colors in RGB space, though
+/// it doesn't account for perceptual differences in color.
+///
+///
+public class EuclideanColorDistance : IColorDistance
+{
+ ///
+ 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);
+ }
+}
diff --git a/Terminal.Gui/Drawing/Quant/IColorDistance.cs b/Terminal.Gui/Drawing/Quant/IColorDistance.cs
new file mode 100644
index 0000000000..fb8dc3aa23
--- /dev/null
+++ b/Terminal.Gui/Drawing/Quant/IColorDistance.cs
@@ -0,0 +1,18 @@
+namespace Terminal.Gui;
+
+///
+/// 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.
+///
+public interface IColorDistance
+{
+ ///
+ /// Computes a similarity metric between two 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.
+ ///
+ /// The first color.
+ /// The second color.
+ /// A numeric value representing the distance between the two colors.
+ double CalculateDistance (Color c1, Color c2);
+}
diff --git a/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs
new file mode 100644
index 0000000000..232842d998
--- /dev/null
+++ b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs
@@ -0,0 +1,19 @@
+namespace Terminal.Gui;
+
+///
+/// Builds a palette of a given size for a given set of input colors.
+///
+public interface IPaletteBuilder
+{
+ ///
+ /// Reduce the number of to (or less)
+ /// using an appropriate selection algorithm.
+ ///
+ ///
+ /// Color of every pixel in the image. Contains duplication in order
+ /// to support algorithms that weigh how common a color is.
+ ///
+ /// The maximum number of colours that should be represented.
+ ///
+ List BuildPalette (List colors, int maxColors);
+}
diff --git a/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs
new file mode 100644
index 0000000000..86bbed404d
--- /dev/null
+++ b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs
@@ -0,0 +1,112 @@
+using Terminal.Gui;
+using Color = Terminal.Gui.Color;
+
+///
+/// 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.
+///
+public class PopularityPaletteWithThreshold : IPaletteBuilder
+{
+ private readonly IColorDistance _colorDistance;
+ private readonly double _mergeThreshold;
+
+ ///
+ /// Creates a new instance with the given color grouping parameters.
+ ///
+ /// Determines which different colors can be considered the same.
+ /// Threshold for merging two colors together.
+ public PopularityPaletteWithThreshold (IColorDistance colorDistance, double mergeThreshold)
+ {
+ _colorDistance = colorDistance;
+ _mergeThreshold = mergeThreshold; // Set the threshold for merging similar colors
+ }
+
+ ///
+ public List BuildPalette (List colors, int maxColors)
+ {
+ if (colors == null || colors.Count == 0 || maxColors <= 0)
+ {
+ return new ();
+ }
+
+ // Step 1: Build the histogram of colors (count occurrences)
+ Dictionary 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 mergedHistogram = MergeSimilarColors (colorHistogram, maxColors);
+
+ // Step 3: Sort the histogram by frequency (most frequent colors first)
+ List sortedColors = mergedHistogram.OrderByDescending (c => c.Value)
+ .Take (maxColors) // Keep only the top `maxColors` colors
+ .Select (c => c.Key)
+ .ToList ();
+
+ return sortedColors;
+ }
+
+ ///
+ /// Merge colors in the histogram if they are within the threshold distance
+ ///
+ ///
+ ///
+ ///
+ private Dictionary MergeSimilarColors (Dictionary colorHistogram, int maxColors)
+ {
+ Dictionary mergedHistogram = new ();
+
+ foreach (KeyValuePair 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;
+ }
+}
diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs
new file mode 100644
index 0000000000..70d9a44bcf
--- /dev/null
+++ b/Terminal.Gui/Drawing/SixelEncoder.cs
@@ -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;
+
+///
+/// Encodes a images into the sixel console image output format.
+///
+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 key. It moves drawing
+ cursor back to beginning of the current line
+ e.g. to draw more color layers.
+
+ */
+
+ ///
+ /// 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
+ ///
+ public ColorQuantizer Quantizer { get; set; } = new ();
+
+ ///
+ /// Encode the given bitmap into sixel encoding
+ ///
+ ///
+ ///
+ 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 usedColorIdx = new List ();
+ List> targets = new List> ();
+
+ // 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
+ }
+}
diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs
new file mode 100644
index 0000000000..d6044ff483
--- /dev/null
+++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs
@@ -0,0 +1,133 @@
+using System.Text.RegularExpressions;
+
+namespace Terminal.Gui;
+/* TODO : Depends on https://github.com/gui-cs/Terminal.Gui/pull/3768
+///
+/// Uses Ansi escape sequences to detect whether sixel is supported
+/// by the terminal.
+///
+public class SixelSupportDetector : ISixelSupportDetector
+{
+ ///
+ /// Sends Ansi escape sequences to the console to determine whether
+ /// sixel is supported (and
+ /// etc).
+ ///
+ /// Description of sixel support, may include assumptions where
+ /// expected response codes are not returned by console.
+ 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:
+ //[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;
+ }
+}*/
\ No newline at end of file
diff --git a/Terminal.Gui/Drawing/SixelSupportResult.cs b/Terminal.Gui/Drawing/SixelSupportResult.cs
new file mode 100644
index 0000000000..bb8a61e0d2
--- /dev/null
+++ b/Terminal.Gui/Drawing/SixelSupportResult.cs
@@ -0,0 +1,33 @@
+namespace Terminal.Gui;
+
+///
+/// Describes the discovered state of sixel support and ancillary information
+/// e.g. . You can use any
+/// to discover this information.
+///
+public class SixelSupportResult
+{
+ ///
+ /// Whether the terminal supports sixel graphic format.
+ /// Defaults to false.
+ ///
+ public bool IsSupported { get; set; }
+
+ ///
+ /// The number of pixels of sixel that corresponds to each Col ()
+ /// and each Row (. Defaults to 10x20.
+ ///
+ public Size Resolution { get; set; } = new (10, 20);
+
+ ///
+ /// The maximum number of colors that can be included in a sixel image. Defaults
+ /// to 256.
+ ///
+ public int MaxPaletteColors { get; set; } = 256;
+
+ ///
+ /// Whether the terminal supports transparent background sixels.
+ /// Defaults to false
+ ///
+ public bool SupportsTransparency { get; set; }
+}
diff --git a/Terminal.Gui/Drawing/SixelToRender.cs b/Terminal.Gui/Drawing/SixelToRender.cs
new file mode 100644
index 0000000000..dedd399ef9
--- /dev/null
+++ b/Terminal.Gui/Drawing/SixelToRender.cs
@@ -0,0 +1,19 @@
+namespace Terminal.Gui;
+
+///
+/// Describes a request to render a given at a given .
+/// Requires that the terminal and both support sixel.
+///
+public class SixelToRender
+{
+ ///
+ /// gets or sets the encoded sixel data. Use to convert bitmaps
+ /// into encoded sixel data.
+ ///
+ public string SixelData { get; set; }
+
+ ///
+ /// gets or sets where to move the cursor to before outputting the .
+ ///
+ public Point ScreenPosition { get; set; }
+}
diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs
index 87ec389842..e1cc7cac25 100644
--- a/UICatalog/Scenarios/Images.cs
+++ b/UICatalog/Scenarios/Images.cs
@@ -1,7 +1,10 @@
using System;
using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Text;
+using ColorHelper;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
@@ -15,15 +18,73 @@ namespace UICatalog.Scenarios;
[ScenarioCategory ("Drawing")]
public class Images : Scenario
{
+ private ImageView _imageView;
+ private Point _screenLocationForSixel;
+ private string _encodedSixelData;
+ private Window _win;
+
+ ///
+ /// Number of sixel pixels per row of characters in the console.
+ ///
+ private NumericUpDown _pxY;
+
+ ///
+ /// Number of sixel pixels per column of characters in the console
+ ///
+ private NumericUpDown _pxX;
+
+ ///
+ /// View shown in sixel tab if sixel is supported
+ ///
+ private View _sixelSupported;
+
+ ///
+ /// View shown in sixel tab if sixel is not supported
+ ///
+ private View _sixelNotSupported;
+
+ private Tab _tabSixel;
+ private TabView _tabView;
+
+ ///
+ /// The view into which the currently opened sixel image is bounded
+ ///
+ private View _sixelView;
+
+ private DoomFire _fire;
+ private SixelEncoder _fireEncoder;
+ private SixelToRender _fireSixel;
+ private int _fireFrameCounter;
+ private bool _isDisposed;
+ private RadioGroup _rgPaletteBuilder;
+ private RadioGroup _rgDistanceAlgorithm;
+ private NumericUpDown _popularityThreshold;
+ private SixelToRender _sixelImage;
+ private SixelSupportResult _sixelSupportResult;
+
public override void Main ()
{
+ // TODO: Change to the one that uses Ansi Requests later
+ var sixelSupportDetector = new AssumeSupportDetector ();
+ _sixelSupportResult = sixelSupportDetector.Detect ();
+
Application.Init ();
- var win = new Window { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName()}" };
+ _win = new () { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" };
bool canTrueColor = Application.Driver?.SupportsTrueColor ?? false;
+ var tabBasic = new Tab
+ {
+ DisplayText = "Basic"
+ };
+
+ _tabSixel = new ()
+ {
+ DisplayText = "Sixel"
+ };
+
var lblDriverName = new Label { X = 0, Y = 0, Text = $"Driver is {Application.Driver?.GetType ().Name}" };
- win.Add (lblDriverName);
+ _win.Add (lblDriverName);
var cbSupportsTrueColor = new CheckBox
{
@@ -33,7 +94,36 @@ public override void Main ()
CanFocus = false,
Text = "supports true color "
};
- win.Add (cbSupportsTrueColor);
+ _win.Add (cbSupportsTrueColor);
+
+ var cbSupportsSixel = new CheckBox
+ {
+ X = Pos.Right (lblDriverName) + 2,
+ Y = 1,
+ CheckedState = CheckState.UnChecked,
+ Text = "Supports Sixel"
+ };
+
+ var lblSupportsSixel = new Label ()
+ {
+
+ X = Pos.Right (lblDriverName) + 2,
+ Y = Pos.Bottom (cbSupportsSixel),
+ Text = "(Check if your terminal supports Sixel)"
+ };
+
+
+/* CheckedState = _sixelSupportResult.IsSupported
+ ? CheckState.Checked
+ : CheckState.UnChecked;*/
+ cbSupportsSixel.CheckedStateChanging += (s, e) =>
+ {
+ _sixelSupportResult.IsSupported = e.NewValue == CheckState.Checked;
+ SetupSixelSupported (e.NewValue == CheckState.Checked);
+ ApplyShowTabViewHack ();
+ };
+
+ _win.Add (cbSupportsSixel);
var cbUseTrueColor = new CheckBox
{
@@ -44,81 +134,501 @@ public override void Main ()
Text = "Use true color"
};
cbUseTrueColor.CheckedStateChanging += (_, evt) => Application.Force16Colors = evt.NewValue == CheckState.UnChecked;
- win.Add (cbUseTrueColor);
+ _win.Add (cbUseTrueColor);
var btnOpenImage = new Button { X = Pos.Right (cbUseTrueColor) + 2, Y = 0, Text = "Open Image" };
- win.Add (btnOpenImage);
+ _win.Add (btnOpenImage);
- var imageView = new ImageView
+ _tabView = new ()
{
- X = 0, Y = Pos.Bottom (lblDriverName), Width = Dim.Fill (), Height = Dim.Fill ()
+ Y = Pos.Bottom (lblSupportsSixel), Width = Dim.Fill (), Height = Dim.Fill ()
};
- win.Add (imageView);
- btnOpenImage.Accepting += (_, _) =>
- {
- var ofd = new OpenDialog { Title = "Open Image", AllowsMultipleSelection = false };
- Application.Run (ofd);
+ _tabView.AddTab (tabBasic, true);
+ _tabView.AddTab (_tabSixel, false);
+
+ BuildBasicTab (tabBasic);
+ BuildSixelTab ();
+
+ SetupSixelSupported (cbSupportsSixel.CheckedState == CheckState.Checked);
- if (ofd.Path is { })
- {
- Directory.SetCurrentDirectory (Path.GetFullPath (Path.GetDirectoryName (ofd.Path)!));
- }
+ btnOpenImage.Accepting += OpenImage;
- if (ofd.Canceled)
- {
- ofd.Dispose ();
+ _win.Add (lblSupportsSixel);
+ _win.Add (_tabView);
+ Application.Run (_win);
+ _win.Dispose ();
+ Application.Shutdown ();
+ }
- return;
- }
+ private void SetupSixelSupported (bool isSupported)
+ {
+ _tabSixel.View = isSupported ? _sixelSupported : _sixelNotSupported;
+ _tabView.SetNeedsDisplay ();
+ }
- string path = ofd.FilePaths [0];
+ private void BtnStartFireOnAccept (object sender, CommandEventArgs e)
+ {
+ if (_fire != null)
+ {
+ return;
+ }
- ofd.Dispose ();
+ if (!_sixelSupportResult.SupportsTransparency)
+ {
+ if (MessageBox.Query (
+ "Transparency Not Supported",
+ "It looks like your terminal does not support transparent sixel backgrounds. Do you want to try anyway?",
+ "Yes",
+ "No")
+ != 0)
+ {
+ return;
+ }
+ }
- if (string.IsNullOrWhiteSpace (path))
- {
- return;
- }
+ _fire = new (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value);
+ _fireEncoder = new ();
+ _fireEncoder.Quantizer.MaxColors = Math.Min (_fireEncoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors);
+ _fireEncoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (_fire.Palette);
- if (!File.Exists (path))
- {
- return;
- }
+ _fireFrameCounter = 0;
- Image img;
+ Application.AddTimeout (TimeSpan.FromMilliseconds (30), AdvanceFireTimerCallback);
+ }
- try
- {
- img = Image.Load (File.ReadAllBytes (path));
- }
- catch (Exception ex)
- {
- MessageBox.ErrorQuery ("Could not open file", ex.Message, "Ok");
+ private bool AdvanceFireTimerCallback ()
+ {
+ _fire.AdvanceFrame ();
+ _fireFrameCounter++;
- return;
- }
+ // Control frame rate by adjusting this
+ // Lower number means more FPS
+ if (_fireFrameCounter % 2 != 0 || _isDisposed)
+ {
+ return !_isDisposed;
+ }
- imageView.SetImage (img);
- Application.Refresh ();
- };
+ Color [,] bmp = _fire.GetFirePixels ();
- Application.Run (win);
- win.Dispose ();
- Application.Shutdown ();
+ // TODO: Static way of doing this, suboptimal
+ if (_fireSixel != null)
+ {
+ Application.Sixel.Remove (_fireSixel);
+ }
+
+ _fireSixel = new ()
+ {
+ SixelData = _fireEncoder.EncodeSixel (bmp),
+ ScreenPosition = new (0, 0)
+ };
+
+ Application.Sixel.Add (_fireSixel);
+
+ _win.SetNeedsDisplay ();
+
+ return !_isDisposed;
+ }
+
+ ///
+ protected override void Dispose (bool disposing)
+ {
+ base.Dispose (disposing);
+ _imageView.Dispose ();
+ _sixelNotSupported.Dispose ();
+ _sixelSupported.Dispose ();
+ _isDisposed = true;
+
+ Application.Sixel.Clear ();
+ }
+
+ private void OpenImage (object sender, CommandEventArgs e)
+ {
+ var ofd = new OpenDialog { Title = "Open Image", AllowsMultipleSelection = false };
+ Application.Run (ofd);
+
+ if (ofd.Path is { })
+ {
+ Directory.SetCurrentDirectory (Path.GetFullPath (Path.GetDirectoryName (ofd.Path)!));
+ }
+
+ if (ofd.Canceled)
+ {
+ ofd.Dispose ();
+
+ return;
+ }
+
+ string path = ofd.FilePaths [0];
+
+ ofd.Dispose ();
+
+ if (string.IsNullOrWhiteSpace (path))
+ {
+ return;
+ }
+
+ if (!File.Exists (path))
+ {
+ return;
+ }
+
+ Image img;
+
+ try
+ {
+ img = Image.Load (File.ReadAllBytes (path));
+ }
+ catch (Exception ex)
+ {
+ MessageBox.ErrorQuery ("Could not open file", ex.Message, "Ok");
+
+ return;
+ }
+
+ _imageView.SetImage (img);
+ ApplyShowTabViewHack ();
+ Application.Refresh ();
+ }
+
+ private void ApplyShowTabViewHack ()
+ {
+ // TODO HACK: This hack seems to be required to make tabview actually refresh itself
+ _tabView.SetNeedsDisplay();
+ var orig = _tabView.SelectedTab;
+ _tabView.SelectedTab = _tabView.Tabs.Except (new []{orig}).ElementAt (0);
+ _tabView.SelectedTab = orig;
+ }
+
+ private void BuildBasicTab (Tab tabBasic)
+ {
+ _imageView = new ()
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ CanFocus = true
+ };
+
+ tabBasic.View = _imageView;
+ }
+
+ private void BuildSixelTab ()
+ {
+ _sixelSupported = new ()
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ CanFocus = true
+ };
+
+ _sixelNotSupported = new ()
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ CanFocus = true
+ };
+
+ _sixelNotSupported.Add (
+ new Label
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ TextAlignment = Alignment.Center,
+ Text = "Your driver does not support Sixel image format",
+ VerticalTextAlignment = Alignment.Center
+ });
+
+ _sixelView = new ()
+ {
+ Width = Dim.Percent (50),
+ Height = Dim.Fill (),
+ BorderStyle = LineStyle.Dotted
+ };
+
+ _sixelSupported.Add (_sixelView);
+
+ var btnSixel = new Button
+ {
+ X = Pos.Right (_sixelView),
+ Y = 0,
+ Text = "Output Sixel", Width = Dim.Auto ()
+ };
+ btnSixel.Accepting += OutputSixelButtonClick;
+ _sixelSupported.Add (btnSixel);
+
+ var btnStartFire = new Button
+ {
+ X = Pos.Right (_sixelView),
+ Y = Pos.Bottom (btnSixel),
+ Text = "Start Fire"
+ };
+ btnStartFire.Accepting += BtnStartFireOnAccept;
+ _sixelSupported.Add (btnStartFire);
+
+ var lblPxX = new Label
+ {
+ X = Pos.Right (_sixelView),
+ Y = Pos.Bottom (btnStartFire) + 1,
+ Text = "Pixels per Col:"
+ };
+
+ _pxX = new ()
+ {
+ X = Pos.Right (lblPxX),
+ Y = Pos.Bottom (btnStartFire) + 1,
+ Value = _sixelSupportResult.Resolution.Width
+ };
+
+ var lblPxY = new Label
+ {
+ X = lblPxX.X,
+ Y = Pos.Bottom (_pxX),
+ Text = "Pixels per Row:"
+ };
+
+ _pxY = new ()
+ {
+ X = Pos.Right (lblPxY),
+ Y = Pos.Bottom (_pxX),
+ Value = _sixelSupportResult.Resolution.Height
+ };
+
+ var l1 = new Label
+ {
+ Text = "Palette Building Algorithm",
+ Width = Dim.Auto (),
+ X = Pos.Right (_sixelView),
+ Y = Pos.Bottom (_pxY) + 1
+ };
+
+ _rgPaletteBuilder = new()
+ {
+ RadioLabels = new []
+ {
+ "Popularity",
+ "Median Cut"
+ },
+ X = Pos.Right (_sixelView) + 2,
+ Y = Pos.Bottom (l1),
+ SelectedItem = 1
+ };
+
+ _popularityThreshold = new ()
+ {
+ X = Pos.Right (_rgPaletteBuilder) + 1,
+ Y = Pos.Top (_rgPaletteBuilder),
+ Value = 8
+ };
+
+ var lblPopThreshold = new Label
+ {
+ Text = "(threshold)",
+ X = Pos.Right (_popularityThreshold),
+ Y = Pos.Top (_popularityThreshold)
+ };
+
+ var l2 = new Label
+ {
+ Text = "Color Distance Algorithm",
+ Width = Dim.Auto (),
+ X = Pos.Right (_sixelView),
+ Y = Pos.Bottom (_rgPaletteBuilder) + 1
+ };
+
+ _rgDistanceAlgorithm = new()
+ {
+ RadioLabels = new []
+ {
+ "Euclidian",
+ "CIE76"
+ },
+ X = Pos.Right (_sixelView) + 2,
+ Y = Pos.Bottom (l2)
+ };
+
+ _sixelSupported.Add (lblPxX);
+ _sixelSupported.Add (_pxX);
+ _sixelSupported.Add (lblPxY);
+ _sixelSupported.Add (_pxY);
+ _sixelSupported.Add (l1);
+ _sixelSupported.Add (_rgPaletteBuilder);
+
+ _sixelSupported.Add (l2);
+ _sixelSupported.Add (_rgDistanceAlgorithm);
+ _sixelSupported.Add (_popularityThreshold);
+ _sixelSupported.Add (lblPopThreshold);
+
+ _sixelView.DrawContent += SixelViewOnDrawContent;
+ }
+
+ private IPaletteBuilder GetPaletteBuilder ()
+ {
+ switch (_rgPaletteBuilder.SelectedItem)
+ {
+ case 0: return new PopularityPaletteWithThreshold (GetDistanceAlgorithm (), _popularityThreshold.Value);
+ case 1: return new MedianCutPaletteBuilder (GetDistanceAlgorithm ());
+ default: throw new ArgumentOutOfRangeException ();
+ }
+ }
+
+ private IColorDistance GetDistanceAlgorithm ()
+ {
+ switch (_rgDistanceAlgorithm.SelectedItem)
+ {
+ case 0: return new EuclideanColorDistance ();
+ case 1: return new CIE76ColorDistance ();
+ default: throw new ArgumentOutOfRangeException ();
+ }
+ }
+
+ private void OutputSixelButtonClick (object sender, CommandEventArgs e)
+ {
+ if (_imageView.FullResImage == null)
+ {
+ MessageBox.Query ("No Image Loaded", "You must first open an image. Use the 'Open Image' button above.", "Ok");
+
+ return;
+ }
+
+ _screenLocationForSixel = _sixelView.FrameToScreen ().Location;
+
+ _encodedSixelData = GenerateSixelData (
+ _imageView.FullResImage,
+ _sixelView.Frame.Size,
+ _pxX.Value,
+ _pxY.Value);
+
+ if (_sixelImage == null)
+ {
+ _sixelImage = new ()
+ {
+ SixelData = _encodedSixelData,
+ ScreenPosition = _screenLocationForSixel
+ };
+
+ Application.Sixel.Add (_sixelImage);
+ }
+ else
+ {
+ _sixelImage.ScreenPosition = _screenLocationForSixel;
+ _sixelImage.SixelData = _encodedSixelData;
+ }
+
+ _sixelView.SetNeedsDisplay ();
+ }
+
+ private void SixelViewOnDrawContent (object sender, DrawEventArgs e)
+ {
+ if (!string.IsNullOrWhiteSpace (_encodedSixelData))
+ {
+ // Does not work (see https://github.com/gui-cs/Terminal.Gui/issues/3763)
+ // Application.Driver?.Move (_screenLocationForSixel.X, _screenLocationForSixel.Y);
+ // Application.Driver?.AddStr (_encodedSixelData);
+
+ // Works in NetDriver but results in screen flicker when moving mouse but vanish instantly
+ // Console.SetCursorPosition (_screenLocationForSixel.X, _screenLocationForSixel.Y);
+ // Console.Write (_encodedSixelData);
+ }
+ }
+
+ public string GenerateSixelData (
+ Image fullResImage,
+ Size maxSize,
+ int pixelsPerCellX,
+ int pixelsPerCellY
+ )
+ {
+ var encoder = new SixelEncoder ();
+ encoder.Quantizer.MaxColors = Math.Min (encoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors);
+ encoder.Quantizer.PaletteBuildingAlgorithm = GetPaletteBuilder ();
+ encoder.Quantizer.DistanceAlgorithm = GetDistanceAlgorithm ();
+
+ // Calculate the target size in pixels based on console units
+ int targetWidthInPixels = maxSize.Width * pixelsPerCellX;
+ int targetHeightInPixels = maxSize.Height * pixelsPerCellY;
+
+ // Get the original image dimensions
+ int originalWidth = fullResImage.Width;
+ int originalHeight = fullResImage.Height;
+
+ // Use the helper function to get the resized dimensions while maintaining the aspect ratio
+ Size newSize = CalculateAspectRatioFit (originalWidth, originalHeight, targetWidthInPixels, targetHeightInPixels);
+
+ // Resize the image to match the console size
+ Image resizedImage = fullResImage.Clone (x => x.Resize (newSize.Width, newSize.Height));
+
+ string encoded = encoder.EncodeSixel (ConvertToColorArray (resizedImage));
+
+ var pv = new PaletteView (encoder.Quantizer.Palette.ToList ());
+
+ var dlg = new Dialog
+ {
+ Title = "Palette (Esc to close)",
+ Width = Dim.Fill (2),
+ Height = Dim.Fill (1)
+ };
+
+ var btn = new Button
+ {
+ Text = "Ok"
+ };
+
+ btn.Accepting += (s, e) => Application.RequestStop ();
+ dlg.Add (pv);
+ dlg.AddButton (btn);
+ Application.Run (dlg);
+ dlg.Dispose ();
+
+ return encoded;
+ }
+
+ private Size CalculateAspectRatioFit (int originalWidth, int originalHeight, int targetWidth, int targetHeight)
+ {
+ // Calculate the scaling factor for width and height
+ double widthScale = (double)targetWidth / originalWidth;
+ double heightScale = (double)targetHeight / originalHeight;
+
+ // Use the smaller scaling factor to maintain the aspect ratio
+ double scale = Math.Min (widthScale, heightScale);
+
+ // Calculate the new width and height while keeping the aspect ratio
+ var newWidth = (int)(originalWidth * scale);
+ var newHeight = (int)(originalHeight * scale);
+
+ // Return the new size as a Size object
+ return new (newWidth, newHeight);
+ }
+
+ public static Color [,] ConvertToColorArray (Image image)
+ {
+ int width = image.Width;
+ int height = image.Height;
+ Color [,] colors = new Color [width, height];
+
+ // Loop through each pixel and convert Rgba32 to Terminal.Gui color
+ for (var x = 0; x < width; x++)
+ {
+ for (var y = 0; y < height; y++)
+ {
+ Rgba32 pixel = image [x, y];
+ colors [x, y] = new (pixel.R, pixel.G, pixel.B); // Convert Rgba32 to Terminal.Gui color
+ }
+ }
+
+ return colors;
}
private class ImageView : View
{
private readonly ConcurrentDictionary _cache = new ();
- private Image _fullResImage;
+ public Image FullResImage;
private Image _matchSize;
public override void OnDrawContent (Rectangle bounds)
{
base.OnDrawContent (bounds);
- if (_fullResImage == null)
+ if (FullResImage == null)
{
return;
}
@@ -127,7 +637,7 @@ public override void OnDrawContent (Rectangle bounds)
if (_matchSize == null || bounds.Width != _matchSize.Width || bounds.Height != _matchSize.Height)
{
// generate one
- _matchSize = _fullResImage.Clone (x => x.Resize (bounds.Width, bounds.Height));
+ _matchSize = FullResImage.Clone (x => x.Resize (bounds.Width, bounds.Height));
}
for (var y = 0; y < bounds.Height; y++)
@@ -138,10 +648,10 @@ public override void OnDrawContent (Rectangle bounds)
Attribute attr = _cache.GetOrAdd (
rgb,
- rgb => new Attribute (
- new Color (),
- new Color (rgb.R, rgb.G, rgb.B)
- )
+ rgb => new (
+ new Color (),
+ new Color (rgb.R, rgb.G, rgb.B)
+ )
);
Driver.SetAttribute (attr);
@@ -152,8 +662,390 @@ public override void OnDrawContent (Rectangle bounds)
internal void SetImage (Image image)
{
- _fullResImage = image;
+ FullResImage = image;
SetNeedsDisplay ();
}
}
+
+ public class PaletteView : View
+ {
+ private readonly List _palette;
+
+ public PaletteView (List palette)
+ {
+ _palette = palette ?? new List ();
+ Width = Dim.Fill ();
+ Height = Dim.Fill ();
+ }
+
+ // Automatically calculates rows and columns based on the available bounds
+ private (int columns, int rows) CalculateGridSize (Rectangle bounds)
+ {
+ // Characters are twice as wide as they are tall, so use 2:1 width-to-height ratio
+ int availableWidth = bounds.Width / 2; // Each color block is 2 character wide
+ int availableHeight = bounds.Height;
+
+ int numColors = _palette.Count;
+
+ // Calculate the number of columns and rows we can fit within the bounds
+ int columns = Math.Min (availableWidth, numColors);
+ int rows = (numColors + columns - 1) / columns; // Ceiling division for rows
+
+ // Ensure we do not exceed the available height
+ if (rows > availableHeight)
+ {
+ rows = availableHeight;
+ columns = (numColors + rows - 1) / rows; // Recalculate columns if needed
+ }
+
+ return (columns, rows);
+ }
+
+ public override void OnDrawContent (Rectangle bounds)
+ {
+ base.OnDrawContent (bounds);
+
+ if (_palette == null || _palette.Count == 0)
+ {
+ return;
+ }
+
+ // Calculate the grid size based on the bounds
+ (int columns, int rows) = CalculateGridSize (bounds);
+
+ // Draw the colors in the palette
+ for (var i = 0; i < _palette.Count && i < columns * rows; i++)
+ {
+ int row = i / columns;
+ int col = i % columns;
+
+ // Calculate position in the grid
+ int x = col * 2; // Each color block takes up 2 horizontal spaces
+ int y = row;
+
+ // Set the color attribute for the block
+ Driver.SetAttribute (new (_palette [i], _palette [i]));
+
+ // Draw the block (2 characters wide per block)
+ for (var dx = 0; dx < 2; dx++) // Fill the width of the block
+ {
+ AddRune (x + dx, y, (Rune)' ');
+ }
+ }
+ }
+ }
+}
+
+internal class ConstPalette : IPaletteBuilder
+{
+ private readonly List _palette;
+
+ public ConstPalette (Color [] palette) { _palette = palette.ToList (); }
+
+ ///
+ public List BuildPalette (List colors, int maxColors) { return _palette; }
+}
+
+public abstract class LabColorDistance : IColorDistance
+{
+ // Reference white point for D65 illuminant (can be moved to constants)
+ private const double RefX = 95.047;
+ private const double RefY = 100.000;
+ private const double RefZ = 108.883;
+
+ // Conversion from RGB to Lab
+ protected LabColor RgbToLab (Color c)
+ {
+ XYZ xyz = ColorConverter.RgbToXyz (new (c.R, c.G, c.B));
+
+ // Normalize XYZ values by reference white point
+ double x = xyz.X / RefX;
+ double y = xyz.Y / RefY;
+ double z = xyz.Z / RefZ;
+
+ // Apply the nonlinear transformation for Lab
+ x = x > 0.008856 ? Math.Pow (x, 1.0 / 3.0) : 7.787 * x + 16.0 / 116.0;
+ y = y > 0.008856 ? Math.Pow (y, 1.0 / 3.0) : 7.787 * y + 16.0 / 116.0;
+ z = z > 0.008856 ? Math.Pow (z, 1.0 / 3.0) : 7.787 * z + 16.0 / 116.0;
+
+ // Calculate Lab values
+ double l = 116.0 * y - 16.0;
+ double a = 500.0 * (x - y);
+ double b = 200.0 * (y - z);
+
+ return new (l, a, b);
+ }
+
+ // LabColor class encapsulating L, A, and B values
+ protected class LabColor
+ {
+ public double L { get; }
+ public double A { get; }
+ public double B { get; }
+
+ public LabColor (double l, double a, double b)
+ {
+ L = l;
+ A = a;
+ B = b;
+ }
+ }
+
+ ///
+ public abstract double CalculateDistance (Color c1, Color c2);
+}
+
+///
+/// This is the simplest method to measure color difference in the CIE Lab color space. The Euclidean distance in Lab
+/// space is more aligned with human perception than RGB space, as Lab attempts to model how humans perceive color
+/// differences.
+///
+public class CIE76ColorDistance : LabColorDistance
+{
+ public override double CalculateDistance (Color c1, Color c2)
+ {
+ LabColor lab1 = RgbToLab (c1);
+ LabColor lab2 = RgbToLab (c2);
+
+ // Euclidean distance in Lab color space
+ return Math.Sqrt (Math.Pow (lab1.L - lab2.L, 2) + Math.Pow (lab1.A - lab2.A, 2) + Math.Pow (lab1.B - lab2.B, 2));
+ }
+}
+
+public class MedianCutPaletteBuilder : IPaletteBuilder
+{
+ private readonly IColorDistance _colorDistance;
+
+ public MedianCutPaletteBuilder (IColorDistance colorDistance) { _colorDistance = colorDistance; }
+
+ public List BuildPalette (List colors, int maxColors)
+ {
+ if (colors == null || colors.Count == 0 || maxColors <= 0)
+ {
+ return new ();
+ }
+
+ return MedianCut (colors, maxColors);
+ }
+
+ private List MedianCut (List colors, int maxColors)
+ {
+ List> cubes = new () { colors };
+
+ // Recursively split color regions
+ while (cubes.Count < maxColors)
+ {
+ var added = false;
+ cubes.Sort ((a, b) => Volume (a).CompareTo (Volume (b)));
+
+ List largestCube = cubes.Last ();
+ cubes.RemoveAt (cubes.Count - 1);
+
+ // Check if the largest cube contains only one unique color
+ if (IsSingleColorCube (largestCube))
+ {
+ // Add back and stop splitting this cube
+ cubes.Add (largestCube);
+
+ break;
+ }
+
+ (List cube1, List cube2) = SplitCube (largestCube);
+
+ if (cube1.Any ())
+ {
+ cubes.Add (cube1);
+ added = true;
+ }
+
+ if (cube2.Any ())
+ {
+ cubes.Add (cube2);
+ added = true;
+ }
+
+ // Break the loop if no new cubes were added
+ if (!added)
+ {
+ break;
+ }
+ }
+
+ // Calculate average color for each cube
+ return cubes.Select (AverageColor).Distinct ().ToList ();
+ }
+
+ // Checks if all colors in the cube are the same
+ private bool IsSingleColorCube (List cube)
+ {
+ Color firstColor = cube.First ();
+
+ return cube.All (c => c.R == firstColor.R && c.G == firstColor.G && c.B == firstColor.B);
+ }
+
+ // Splits the cube based on the largest color component range
+ private (List, List) SplitCube (List cube)
+ {
+ (int component, int range) = FindLargestRange (cube);
+
+ // Sort by the largest color range component (either R, G, or B)
+ cube.Sort (
+ (c1, c2) => component switch
+ {
+ 0 => c1.R.CompareTo (c2.R),
+ 1 => c1.G.CompareTo (c2.G),
+ 2 => c1.B.CompareTo (c2.B),
+ _ => 0
+ });
+
+ int medianIndex = cube.Count / 2;
+ List cube1 = cube.Take (medianIndex).ToList ();
+ List cube2 = cube.Skip (medianIndex).ToList ();
+
+ return (cube1, cube2);
+ }
+
+ private (int, int) FindLargestRange (List cube)
+ {
+ byte minR = cube.Min (c => c.R);
+ byte maxR = cube.Max (c => c.R);
+ byte minG = cube.Min (c => c.G);
+ byte maxG = cube.Max (c => c.G);
+ byte minB = cube.Min (c => c.B);
+ byte maxB = cube.Max (c => c.B);
+
+ int rangeR = maxR - minR;
+ int rangeG = maxG - minG;
+ int rangeB = maxB - minB;
+
+ if (rangeR >= rangeG && rangeR >= rangeB)
+ {
+ return (0, rangeR);
+ }
+
+ if (rangeG >= rangeR && rangeG >= rangeB)
+ {
+ return (1, rangeG);
+ }
+
+ return (2, rangeB);
+ }
+
+ private Color AverageColor (List cube)
+ {
+ var avgR = (byte)cube.Average (c => c.R);
+ var avgG = (byte)cube.Average (c => c.G);
+ var avgB = (byte)cube.Average (c => c.B);
+
+ return new (avgR, avgG, avgB);
+ }
+
+ private int Volume (List cube)
+ {
+ if (cube == null || cube.Count == 0)
+ {
+ // Return a volume of 0 if the cube is empty or null
+ return 0;
+ }
+
+ byte minR = cube.Min (c => c.R);
+ byte maxR = cube.Max (c => c.R);
+ byte minG = cube.Min (c => c.G);
+ byte maxG = cube.Max (c => c.G);
+ byte minB = cube.Min (c => c.B);
+ byte maxB = cube.Max (c => c.B);
+
+ return (maxR - minR) * (maxG - minG) * (maxB - minB);
+ }
+}
+
+public class DoomFire
+{
+ private readonly int _width;
+ private readonly int _height;
+ private readonly Color [,] _firePixels;
+ private static Color [] _palette;
+ public Color [] Palette => _palette;
+ private readonly Random _random = new ();
+
+ public DoomFire (int width, int height)
+ {
+ _width = width;
+ _height = height;
+ _firePixels = new Color [width, height];
+ InitializePalette ();
+ InitializeFire ();
+ }
+
+ private void InitializePalette ()
+ {
+ // Initialize a basic fire palette. You can modify these colors as needed.
+ _palette = new Color [37]; // Using 37 colors as per the original Doom fire palette scale.
+
+ // First color is transparent black
+ _palette [0] = new (0, 0, 0, 0); // Transparent black (ARGB)
+
+ // The rest of the palette is fire colors
+ for (var i = 1; i < 37; i++)
+ {
+ var r = (byte)Math.Min (255, i * 7);
+ var g = (byte)Math.Min (255, i * 5);
+ var b = (byte)Math.Min (255, i * 2);
+ _palette [i] = new (r, g, b); // Full opacity
+ }
+ }
+
+ public void InitializeFire ()
+ {
+ // Set the bottom row to full intensity (simulate the base of the fire).
+ for (var x = 0; x < _width; x++)
+ {
+ _firePixels [x, _height - 1] = _palette [36]; // Max intensity fire.
+ }
+
+ // Set the rest of the pixels to black (transparent).
+ for (var y = 0; y < _height - 1; y++)
+ {
+ for (var x = 0; x < _width; x++)
+ {
+ _firePixels [x, y] = _palette [0]; // Transparent black
+ }
+ }
+ }
+
+ public void AdvanceFrame ()
+ {
+ // Process every pixel except the bottom row
+ for (var x = 0; x < _width; x++)
+ {
+ for (var y = 1; y < _height; y++) // Skip the last row (which is always max intensity)
+ {
+ int srcX = x;
+ int srcY = y;
+ int dstY = y - 1;
+
+ // Spread fire upwards with randomness
+ int decay = _random.Next (0, 2);
+ int dstX = srcX + _random.Next (-1, 2);
+
+ if (dstX < 0 || dstX >= _width) // Prevent out of bounds
+ {
+ dstX = srcX;
+ }
+
+ // Get the fire color from below and reduce its intensity
+ Color srcColor = _firePixels [srcX, srcY];
+ int intensity = Array.IndexOf (_palette, srcColor) - decay;
+
+ if (intensity < 0)
+ {
+ intensity = 0;
+ }
+
+ _firePixels [dstX, dstY] = _palette [intensity];
+ }
+ }
+ }
+
+ public Color [,] GetFirePixels () { return _firePixels; }
}
diff --git a/UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs b/UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs
new file mode 100644
index 0000000000..9a89c70c02
--- /dev/null
+++ b/UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs
@@ -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 colors = new ();
+
+ // Act
+ List result = paletteBuilder.BuildPalette (colors, 256);
+
+ // Assert
+ Assert.Empty (result);
+ }
+
+ [Fact]
+ public void BuildPalette_MaxColorsZero_ReturnsEmptyPalette ()
+ {
+ // Arrange
+ var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50);
+ List colors = new () { new (255, 0), new (0, 255) };
+
+ // Act
+ List result = paletteBuilder.BuildPalette (colors, 0);
+
+ // Assert
+ Assert.Empty (result);
+ }
+
+ [Fact]
+ public void BuildPalette_SingleColorList_ReturnsSingleColor ()
+ {
+ // Arrange
+ var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50);
+ List colors = new () { new (255, 0), new (255, 0) };
+
+ // Act
+ List 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 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 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 colors = new ()
+ {
+ new (255, 0), // Red
+ new (0, 255) // Green
+ };
+
+ // Act
+ List 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 colors = new()
+ {
+ new (255, 0), // Red
+ new (254, 0), // Close to Red
+ new (0, 255), // Green
+ new (0, 254) // Close to Green
+ };
+
+ // Act
+ List 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);
+ }
+}
diff --git a/UnitTests/Drawing/SixelEncoderTests.cs b/UnitTests/Drawing/SixelEncoderTests.cs
new file mode 100644
index 0000000000..65d9e423af
--- /dev/null
+++ b/UnitTests/Drawing/SixelEncoderTests.cs
@@ -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
+ * #;;;;" - 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);
+ }
+}