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); + } +}