From fd87e87ccdf8d5174805d6c216a702906ae29fdf Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 31 May 2026 22:06:20 -0600 Subject: [PATCH 01/13] Prototype integrated sixel raster output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/UICatalog/Scenarios/Mandelbrot.cs | 319 ++++++++++++++++++ Terminal.Gui/Drivers/Output/IOutputBuffer.cs | 18 + Terminal.Gui/Drivers/Output/OutputBase.cs | 137 ++++++++ .../Drivers/Output/OutputBufferImpl.cs | 78 +++++ .../Drivers/Output/RasterImageCommand.cs | 42 +++ Terminal.Gui/Views/ImageView.cs | 86 ++--- .../Drivers/Output/OutputBaseTests.cs | 127 +++++++ 7 files changed, 755 insertions(+), 52 deletions(-) create mode 100644 Examples/UICatalog/Scenarios/Mandelbrot.cs create mode 100644 Terminal.Gui/Drivers/Output/RasterImageCommand.cs diff --git a/Examples/UICatalog/Scenarios/Mandelbrot.cs b/Examples/UICatalog/Scenarios/Mandelbrot.cs new file mode 100644 index 0000000000..4a28d91d20 --- /dev/null +++ b/Examples/UICatalog/Scenarios/Mandelbrot.cs @@ -0,0 +1,319 @@ +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("Mandelbrot", "Displays a sixel-rendered Mandelbrot set with live settings and an overlay dialog.")] +[ScenarioCategory ("Colors")] +[ScenarioCategory ("Drawing")] +public class Mandelbrot : Scenario +{ + private const int ImageColumns = 30; + private const int ImageRows = 20; + + private IApplication _app = null!; + private NumericUpDown _centerX = null!; + private NumericUpDown _centerY = null!; + private NumericUpDown _iterations = null!; + private MandelbrotImageView _mandelbrotView = null!; + private NumericUpDown _span = null!; + private Label _status = null!; + private Window _window = null!; + + public override void Main () + { + using IApplication app = Application.Create ().Init (); + _app = app; + + _window = new () { Title = $"{Application.GetDefaultKey (Command.Quit)} to Quit - Scenario: {GetName ()}" }; + + FrameView settings = new () + { + Title = "Settings", + Width = 34, + Height = Dim.Fill () + }; + + View display = new () + { + X = Pos.Right (settings), + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + _status = new () + { + X = 1, + Y = 1, + Width = Dim.Fill (1), + Height = 1 + }; + + _mandelbrotView = new () + { + X = Pos.Center (), + Y = Pos.Center (), + Width = ImageColumns, + Height = ImageRows, + UseSixel = true + }; + + BuildSettings (settings); + display.Add (_status, _mandelbrotView); + _window.Add (settings, display); + + _window.Initialized += (_, _) => RenderMandelbrot (); + + app.Run (_window); + _window.Dispose (); + } + + private void BuildSettings (View settings) + { + settings.Add ( + new Label { X = 1, Y = 1, Text = "Center X:" }, + new Label { X = 1, Y = 3, Text = "Center Y:" }, + new Label { X = 1, Y = 5, Text = "Span:" }, + new Label { X = 1, Y = 7, Text = "Iterations:" }); + + _centerX = new () + { + X = 14, + Y = 1, + Value = -0.5, + Increment = 0.05, + Format = "{0:0.000}" + }; + + _centerY = new () + { + X = 14, + Y = 3, + Value = 0, + Increment = 0.05, + Format = "{0:0.000}" + }; + + _span = new () + { + X = 14, + Y = 5, + Value = 3, + Increment = 0.1, + Format = "{0:0.000}" + }; + + _iterations = new () + { + X = 14, + Y = 7, + Value = 80, + Increment = 10 + }; + + _span.ValueChanging += (_, args) => + { + if (args.NewValue <= 0.05) + { + args.Handled = true; + } + }; + + _iterations.ValueChanging += (_, args) => + { + if (args.NewValue < 8) + { + args.Handled = true; + } + }; + + _centerX.ValueChanged += (_, _) => RenderMandelbrot (); + _centerY.ValueChanged += (_, _) => RenderMandelbrot (); + _span.ValueChanged += (_, _) => RenderMandelbrot (); + _iterations.ValueChanged += (_, _) => RenderMandelbrot (); + + Button reset = new () + { + X = 1, + Y = 10, + Text = "_Reset" + }; + + reset.Accepted += (_, _) => ResetSettings (); + + Button overlay = new () + { + X = Pos.Right (reset) + 2, + Y = 10, + Text = "_Overlay" + }; + + overlay.Accepted += (_, _) => ShowOverlay (); + + Label note = new () + { + X = 1, + Y = 13, + Width = Dim.Fill (1), + Height = 4, + Text = "The image is a 30 x 20\nImageView SubView drawn\nthrough sixel raster\ncommands." + }; + + settings.Add (_centerX, _centerY, _span, _iterations, reset, overlay, note); + } + + private void ResetSettings () + { + _centerX.Value = -0.5; + _centerY.Value = 0; + _span.Value = 3; + _iterations.Value = 80; + RenderMandelbrot (); + } + + private void RenderMandelbrot () + { + if (_mandelbrotView is null) + { + return; + } + + SixelSupportResult support = EnsureSixelSupportForDemo (); + int pixelWidth = ImageColumns * Math.Max (1, support.Resolution.Width); + int pixelHeight = ImageRows * Math.Max (1, support.Resolution.Height); + int iterations = _iterations.Value; + double centerX = _centerX.Value; + double centerY = _centerY.Value; + double span = _span.Value; + + _mandelbrotView.Render (pixelWidth, pixelHeight, centerX, centerY, span, iterations, support); + _status.Text = + $"Sixel raster: {pixelWidth} x {pixelHeight}px, {support.Resolution.Width} x {support.Resolution.Height}px/cell"; + _status.SetNeedsDraw (); + } + + private SixelSupportResult EnsureSixelSupportForDemo () + { + SixelSupportResult source = _app.Driver?.SixelSupport ?? new (); + + if (source.IsSupported) + { + return source; + } + + SixelSupportResult forced = new () + { + IsSupported = true, + Resolution = source.Resolution, + MaxPaletteColors = source.MaxPaletteColors, + SupportsTransparency = source.SupportsTransparency + }; + + if (_app.Driver is DriverImpl driver) + { + driver.SetSixelSupport (forced); + } + + return forced; + } + + private void ShowOverlay () + { + _mandelbrotView.SetNeedsDraw (); + + Dialog dialog = new () + { + Title = "Overlay Runnable", + Width = 38, + Height = 9 + }; + + dialog.Add ( + new Label + { + X = 1, + Y = 1, + Width = Dim.Fill (2), + Height = 3, + Text = "This dialog is a runnable\nshown over the sixel image\nto exercise clipping." + }); + + dialog.AddButton (new () { Text = "_OK", IsDefault = true }); + _app.Run (dialog); + dialog.Dispose (); + + _mandelbrotView.SetNeedsDraw (); + } + + private sealed class MandelbrotImageView : ImageView + { + public void Render (int pixelWidth, + int pixelHeight, + double centerX, + double centerY, + double span, + int maxIterations, + SixelSupportResult support) + { + SixelEncoder = new (); + SixelEncoder.Quantizer.MaxColors = Math.Min (support.MaxPaletteColors, 64); + Image = CreateMandelbrotPixels (pixelWidth, pixelHeight, centerX, centerY, span, maxIterations); + } + + private static Color [,] CreateMandelbrotPixels (int width, + int height, + double centerX, + double centerY, + double span, + int maxIterations) + { + Color [,] pixels = new Color [width, height]; + double spanY = span * height / width; + double xMin = centerX - span / 2; + double yMin = centerY - spanY / 2; + + for (int y = 0; y < height; y++) + { + double cy = yMin + spanY * y / Math.Max (1, height - 1); + + for (int x = 0; x < width; x++) + { + double cx = xMin + span * x / Math.Max (1, width - 1); + int iterations = CountIterations (cx, cy, maxIterations); + pixels [x, y] = GetMandelbrotColor (iterations, maxIterations); + } + } + + return pixels; + } + + private static int CountIterations (double cx, double cy, int maxIterations) + { + double zx = 0; + double zy = 0; + int iterations = 0; + + while (zx * zx + zy * zy <= 4 && iterations < maxIterations) + { + double nextX = zx * zx - zy * zy + cx; + zy = 2 * zx * zy + cy; + zx = nextX; + iterations++; + } + + return iterations; + } + + private static Color GetMandelbrotColor (int iterations, int maxIterations) + { + if (iterations >= maxIterations) + { + return Color.Black; + } + + double t = (double)iterations / maxIterations; + byte red = (byte)(9 * (1 - t) * t * t * t * 255); + byte green = (byte)(15 * (1 - t) * (1 - t) * t * t * 255); + byte blue = (byte)(8.5 * (1 - t) * (1 - t) * (1 - t) * t * 255); + + return new Color (red, green, blue); + } + } +} diff --git a/Terminal.Gui/Drivers/Output/IOutputBuffer.cs b/Terminal.Gui/Drivers/Output/IOutputBuffer.cs index 475201a01c..108a66ac84 100644 --- a/Terminal.Gui/Drivers/Output/IOutputBuffer.cs +++ b/Terminal.Gui/Drivers/Output/IOutputBuffer.cs @@ -112,6 +112,24 @@ public interface IOutputBuffer /// The row to move to. void Move (int col, int row); + /// + /// Adds or replaces a raster image command, capturing the current for later composition. + /// + /// The raster image command to add. + void AddRasterImage (RasterImageCommand command); + + /// + /// Removes a raster image command from the output buffer. + /// + /// The raster image command identifier. + void RemoveRasterImage (string id); + + /// + /// Gets the raster image commands currently held by the output buffer. + /// + /// The raster image commands. + IReadOnlyList GetRasterImages (); + /// /// Gets the row last set by . and are used by /// and to determine where to add content. diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 8c66057aba..c019f9d925 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -93,6 +93,11 @@ public virtual void Write (IOutputBuffer buffer) InvalidateRowsWithUrlsIfStale (buffer, rows, cols); + if (!IsLegacyConsole) + { + RenderRasterImages (buffer); + } + // Process each row for (int row = top; row < rows; row++) { @@ -499,10 +504,142 @@ public string ToAnsi (IOutputBuffer buffer) Attribute? lastAttr = null; BuildAnsiForRegion (buffer, 0, buffer.Rows, 0, buffer.Cols, ansiOutput, ref lastAttr); + AppendRasterImageAnsi (buffer, ansiOutput); return ansiOutput.ToString (); } + private void RenderRasterImages (IOutputBuffer buffer) + { + foreach (RasterImageCommand command in buffer.GetRasterImages ()) + { + if (!command.IsDirty && !command.AlwaysRender) + { + continue; + } + + foreach (Rectangle visibleCells in GetVisibleRasterCellRectangles (command)) + { + if (!TryCropRasterImagePixels (command.Pixels!, command.DestinationCells, visibleCells, out Color [,] pixels)) + { + continue; + } + + SetCursorPositionImpl (visibleCells.X, visibleCells.Y); + SixelEncoder encoder = command.Encoder ?? new (); + Write (new StringBuilder (encoder.EncodeSixel (pixels))); + } + + command.IsDirty = false; + } + } + + private static void AppendRasterImageAnsi (IOutputBuffer buffer, StringBuilder output) + { + foreach (RasterImageCommand command in buffer.GetRasterImages ()) + { + foreach (Rectangle visibleCells in GetVisibleRasterCellRectangles (command)) + { + if (!TryCropRasterImagePixels (command.Pixels!, command.DestinationCells, visibleCells, out Color [,] pixels)) + { + continue; + } + + output.Append (EscSeqUtils.CSI_SetCursorPosition (visibleCells.Y + 1, visibleCells.X + 1)); + SixelEncoder encoder = command.Encoder ?? new (); + output.Append (encoder.EncodeSixel (pixels)); + } + } + } + + private static IEnumerable GetVisibleRasterCellRectangles (RasterImageCommand command) + { + if (command.Pixels is null || command.DestinationCells.Width <= 0 || command.DestinationCells.Height <= 0) + { + yield break; + } + + if (command.Clip is null) + { + yield return command.DestinationCells; + + yield break; + } + + foreach (Rectangle clipRect in command.Clip.GetRectangles ()) + { + Rectangle visible = Rectangle.Intersect (command.DestinationCells, clipRect); + + if (visible.Width <= 0 || visible.Height <= 0) + { + continue; + } + + yield return visible; + } + } + + private static bool TryCropRasterImagePixels (Color [,] source, + Rectangle destinationCells, + Rectangle visibleCells, + out Color [,] pixels) + { + pixels = source; + + int sourceWidth = source.GetLength (0); + int sourceHeight = source.GetLength (1); + + if (sourceWidth <= 0 + || sourceHeight <= 0 + || destinationCells.Width <= 0 + || destinationCells.Height <= 0 + || visibleCells.Width <= 0 + || visibleCells.Height <= 0) + { + return false; + } + + int xStart = ScaleCellOffsetToPixels (visibleCells.X - destinationCells.X, destinationCells.Width, sourceWidth); + int xEnd = ScaleCellOffsetToPixels (visibleCells.Right - destinationCells.X, destinationCells.Width, sourceWidth); + int yStart = ScaleCellOffsetToPixels (visibleCells.Y - destinationCells.Y, destinationCells.Height, sourceHeight); + int yEnd = ScaleCellOffsetToPixels (visibleCells.Bottom - destinationCells.Y, destinationCells.Height, sourceHeight); + + xStart = Math.Clamp (xStart, 0, sourceWidth); + xEnd = Math.Clamp (xEnd, xStart, sourceWidth); + yStart = Math.Clamp (yStart, 0, sourceHeight); + yEnd = Math.Clamp (yEnd, yStart, sourceHeight); + + int width = xEnd - xStart; + int height = yEnd - yStart; + + if (width <= 0 || height <= 0) + { + return false; + } + + if (width == sourceWidth && height == sourceHeight) + { + pixels = source; + + return true; + } + + pixels = new Color [width, height]; + + for (int x = 0; x < width; x++) + { + for (int y = 0; y < height; y++) + { + pixels [x, y] = source [xStart + x, yStart + y]; + } + } + + return true; + } + + private static int ScaleCellOffsetToPixels (int cellOffset, int cellCount, int pixelCount) => + (int)((long)cellOffset * pixelCount / cellCount); + /// /// Writes buffered output to console, then clears the buffer and advances /// by . diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs index dd6e814e88..6c9aff904f 100644 --- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs @@ -43,6 +43,7 @@ public class OutputBufferImpl : IOutputBuffer private Rune _column1ReplacementChar = Glyphs.WideGlyphReplacement; private Region? _clip; + private readonly List _rasterImages = []; /// /// The contents of the application output. The driver outputs this buffer to the terminal when @@ -113,6 +114,7 @@ public int Rows { _rows = value; ClearContents (); + _rasterImages.Clear (); } } @@ -124,6 +126,7 @@ public int Cols { _cols = value; ClearContents (); + _rasterImages.Clear (); } } @@ -227,6 +230,7 @@ public void SetSize (int cols, int rows) _cols = cols; _rows = rows; ClearContentsCore (!InlineMode); + _rasterImages.Clear (); } } @@ -290,6 +294,80 @@ public void Move (int col, int row) Row = row; } + /// + public void AddRasterImage (RasterImageCommand command) + { + ArgumentNullException.ThrowIfNull (command); + + if (command.Pixels is null) + { + throw new ArgumentException ("Raster image pixels are required.", nameof (command)); + } + + if (command.DestinationCells.Width <= 0 || command.DestinationCells.Height <= 0) + { + throw new ArgumentException ("Raster image destination must have a positive size.", nameof (command)); + } + + lock (_contentsLock) + { + command.Clip = Clip?.Clone (); + MarkRasterImageCellsClean (command); + int index = _rasterImages.FindIndex (existing => existing.Id is not null && existing.Id == command.Id); + + if (index >= 0) + { + _rasterImages [index] = command; + + return; + } + + _rasterImages.Add (command); + } + } + + /// + public void RemoveRasterImage (string id) + { + ArgumentException.ThrowIfNullOrEmpty (id); + + lock (_contentsLock) + { + _rasterImages.RemoveAll (command => command.Id == id); + } + } + + /// + public IReadOnlyList GetRasterImages () => _rasterImages; + + private void MarkRasterImageCellsClean (RasterImageCommand command) + { + if (Contents is null) + { + return; + } + + Region clip = command.Clip ?? new (Screen); + + foreach (Rectangle clipRect in clip.GetRectangles ()) + { + Rectangle visible = Rectangle.Intersect (Rectangle.Intersect (command.DestinationCells, clipRect), Screen); + + if (visible.Width <= 0 || visible.Height <= 0) + { + continue; + } + + for (int row = visible.Y; row < visible.Bottom; row++) + { + for (int col = visible.X; col < visible.Right; col++) + { + Contents [row, col].IsDirty = false; + } + } + } + } + /// Clears the of the driver. /// /// When (the default), all cells are marked dirty so the first render diff --git a/Terminal.Gui/Drivers/Output/RasterImageCommand.cs b/Terminal.Gui/Drivers/Output/RasterImageCommand.cs new file mode 100644 index 0000000000..9a7316cc2c --- /dev/null +++ b/Terminal.Gui/Drivers/Output/RasterImageCommand.cs @@ -0,0 +1,42 @@ +namespace Terminal.Gui.Drivers; + +/// +/// Describes a raster image to compose through the output buffer. +/// +public class RasterImageCommand +{ + /// + /// Gets or sets the stable identifier for this raster image. + /// + public string? Id { get; set; } + + /// + /// Gets or sets the image pixels to encode. + /// + public Color [,]? Pixels { get; set; } + + /// + /// Gets or sets the screen cells occupied by . + /// + public Rectangle DestinationCells { get; set; } + + /// + /// Gets or sets the clip region captured when the image is added to the output buffer. + /// + public Region? Clip { get; set; } + + /// + /// Gets or sets the sixel encoder to use. A default encoder is used when this is . + /// + public SixelEncoder? Encoder { get; set; } + + /// + /// Gets or sets whether the image needs to be emitted during the next driver write. + /// + public bool IsDirty { get; set; } = true; + + /// + /// Gets or sets whether the image should be emitted on every driver write. + /// + public bool AlwaysRender { get; set; } +} diff --git a/Terminal.Gui/Views/ImageView.cs b/Terminal.Gui/Views/ImageView.cs index 352a180b6a..67a21a3ab1 100644 --- a/Terminal.Gui/Views/ImageView.cs +++ b/Terminal.Gui/Views/ImageView.cs @@ -15,8 +15,8 @@ namespace Terminal.Gui.Views; /// /// When sixel is available (detected via ) and /// is , the view will encode the image as -/// sixel escape sequences and render it through the driver's sixel pipeline. Sixel data -/// is only re-encoded and re-sent to the terminal when is true, +/// sixel escape sequences and render it through the driver's output buffer. Sixel data +/// is only re-sent to the terminal when is true, /// avoiding redundant rendering of unchanged images. /// /// @@ -29,8 +29,7 @@ public class ImageView : View, IDesignable private Color [,]? _image; private Color [,]? _scaledImage; private Size? _scaledImageCellSize; - private SixelToRender? _sixelToRender; - private string? _cachedSixelData; + private string RasterImageId => $"ImageView_{GetHashCode ()}"; // Cell-based rendering cache private readonly Dictionary _attributeCache = new (); @@ -51,9 +50,14 @@ public class ImageView : View, IDesignable { _image = value; _scaledImage = null; - _cachedSixelData = null; _scaledImageCellSize = null; _attributeCache.Clear (); + + if (_image is null) + { + App?.Driver?.GetOutputBuffer ().RemoveRasterImage (RasterImageId); + } + UpdateSixelData (); SetNeedsDraw (); } @@ -202,23 +206,6 @@ protected override bool OnDrawingContent (DrawContext? context) Rectangle viewport = ViewportToScreen (); Rectangle dirtyRect = new (viewport.X, viewport.Y, Math.Min (viewport.Width, cellSize.Width), Math.Min (viewport.Height, cellSize.Height)); context?.AddDrawnRectangle (dirtyRect); - - // Mark the content buffer for the area we will draw as not dirty. - // This will avoid redrawing the area of the screen that will - // eventually be overwritten by the sixel anyway. - if (ScreenContents is { } contents && Driver is { } driver) - { - for (int y = dirtyRect.Y; y < dirtyRect.Bottom; y++) - { - for (int x = dirtyRect.X; x < dirtyRect.Right; x++) - { - if (x >= 0 && y >= 0 && x < driver.Cols && y < driver.Rows) - { - contents [y, x].IsDirty = false; - } - } - } - } } } else @@ -293,39 +280,40 @@ private void DrawCellBased () /// private void DrawSixel () { - SixelSupportResult? support = App?.Driver?.SixelSupport; - - if (support is null) + if (App?.Driver is not { } driver) { return; } - if (_cachedSixelData is null) + if (_scaledImage is null || _scaledImageCellSize is null) { UpdateSixelData (); } - // Get screen position for this view's viewport - Point screenPos = ViewportToScreen ().Location; - - if (_sixelToRender is null) + if (_scaledImage is null || _scaledImageCellSize is null || SixelEncoder is null) { - _sixelToRender = new SixelToRender - { - SixelData = _cachedSixelData, - ScreenPosition = screenPos, - Id = $"ImageView_{GetHashCode ()}", - IsDirty = true - }; - - App?.Driver?.GetOutput ().GetSixels ().Enqueue (_sixelToRender); + return; } - else + + Rectangle viewport = ViewportToScreen (); + Size cellSize = _scaledImageCellSize.Value; + Rectangle destinationCells = new (viewport.X, viewport.Y, Math.Min (viewport.Width, cellSize.Width), Math.Min (viewport.Height, cellSize.Height)); + + if (destinationCells.Width <= 0 || destinationCells.Height <= 0) { - _sixelToRender.SixelData = _cachedSixelData; - _sixelToRender.ScreenPosition = screenPos; - _sixelToRender.IsDirty = true; + return; } + + RasterImageCommand command = new () + { + Id = RasterImageId, + Pixels = _scaledImage, + DestinationCells = destinationCells, + Encoder = SixelEncoder, + IsDirty = true + }; + + driver.GetOutputBuffer ().AddRasterImage (command); } private void UpdateSixelData () @@ -336,7 +324,7 @@ private void UpdateSixelData () } // Use caller-provided encoder or create a default one - SixelEncoder ??= new SixelEncoder (); + SixelEncoder ??= new (); // Clamp MaxColors regardless of whether the encoder was provided SixelEncoder.Quantizer.MaxColors = Math.Min (SixelEncoder.Quantizer.MaxColors, support.MaxPaletteColors); @@ -352,8 +340,6 @@ private void UpdateSixelData () return; } - // Encode sixel data - _cachedSixelData = SixelEncoder.EncodeSixel (_scaledImage); } /// @@ -427,13 +413,9 @@ public static void ScaleNearestNeighbor (Color [,] source, Color [,] destination /// protected override void Dispose (bool disposing) { - if (disposing && _sixelToRender is { }) + if (disposing) { - // Clear the sixel data so it's not rendered anymore. - // The ConcurrentQueue doesn't support removal, but clearing the data - // ensures OutputBase.Write skips it. - _sixelToRender.SixelData = null; - _sixelToRender.IsDirty = false; + App?.Driver?.GetOutputBuffer ().RemoveRasterImage (RasterImageId); } base.Dispose (disposing); diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs index 5c92009c0f..b1221a1f14 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs @@ -948,6 +948,118 @@ public void Write_AlwaysRender_EmitsEveryFrame () driver.Dispose (); } + // Copilot - GPT-5.5 + [Fact] + public void AddRasterImage_CapturesCurrentClip () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (4, 4); + buffer.Clip = new Region (new Rectangle (1, 1, 2, 2)); + + RasterImageCommand command = new () + { + Id = "image", + Pixels = CreateSolidImage (4, 4, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 4, 4) + }; + + // Act + buffer.AddRasterImage (command); + buffer.Clip = new Region (new Rectangle (0, 0, 4, 4)); + + // Assert + RasterImageCommand captured = Assert.Single (buffer.GetRasterImages ()); + Assert.NotNull (captured.Clip); + Assert.Equal (new Rectangle (1, 1, 2, 2), captured.Clip!.GetBounds ()); + } + + // Copilot - GPT-5.5 + [Fact] + public void ToAnsi_RasterImage_CropsToClipAndMovesCursor () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (4, 4); + buffer.Clip = new Region (new Rectangle (1, 1, 2, 2)); + + RasterImageCommand command = new () + { + Id = "image", + Pixels = CreateSolidImage (4, 4, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 4, 4) + }; + + buffer.AddRasterImage (command); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert + Assert.Contains (EscSeqUtils.CSI_SetCursorPosition (2, 2), ansi); + Assert.Contains ("\u001bP0;0;0q\"1;1;2;2", ansi); + Assert.DoesNotContain ("\u001bP0;0;0q\"1;1;4;4", ansi); + } + + // Copilot - GPT-5.5 + [Fact] + public void ToAnsi_RasterImage_SkipsWhenClipDoesNotIntersect () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (4, 4); + buffer.Clip = new Region (new Rectangle (3, 3, 1, 1)); + + RasterImageCommand command = new () + { + Id = "image", + Pixels = CreateSolidImage (2, 2, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2) + }; + + buffer.AddRasterImage (command); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert + Assert.DoesNotContain ("\u001bP", ansi); + } + + // Copilot - GPT-5.5 + [Fact] + public void Write_RasterImage_RendersBeforeLaterDirtyCells () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (2, 2); + buffer.Clip = new Region (new Rectangle (0, 0, 2, 2)); + + RasterImageCommand command = new () + { + Id = "image", + Pixels = CreateSolidImage (2, 2, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2) + }; + + buffer.AddRasterImage (command); + buffer.Move (0, 0); + buffer.AddStr ("\u03a9"); + + // Act + output.Write (buffer); + string rendered = output.GetLastOutput (); + + // Assert + int imageIndex = rendered.IndexOf ("\u001bP", StringComparison.Ordinal); + int textIndex = rendered.IndexOf ("\u03a9", StringComparison.Ordinal); + Assert.InRange (imageIndex, 0, textIndex - 1); + } + [Fact] public void DriverImpl_SixelSupport_DefaultsToNull () { @@ -1043,4 +1155,19 @@ public void DriverImpl_SetSixelSupport_StoresResult () driver.Dispose (); } + + private static Color [,] CreateSolidImage (int width, int height, Color color) + { + Color [,] image = new Color [width, height]; + + for (int x = 0; x < width; x++) + { + for (int y = 0; y < height; y++) + { + image [x, y] = color; + } + } + + return image; + } } From 8c6f4f1fcbbb4ca1053e1f80d2d6fbfbdc3b51ca Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 1 Jun 2026 06:06:21 -0600 Subject: [PATCH 02/13] Make Mandelbrot image resizable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/UICatalog/Scenarios/Mandelbrot.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Examples/UICatalog/Scenarios/Mandelbrot.cs b/Examples/UICatalog/Scenarios/Mandelbrot.cs index 4a28d91d20..c5ef3f6582 100644 --- a/Examples/UICatalog/Scenarios/Mandelbrot.cs +++ b/Examples/UICatalog/Scenarios/Mandelbrot.cs @@ -52,6 +52,10 @@ public override void Main () Y = Pos.Center (), Width = ImageColumns, Height = ImageRows, + BorderStyle = LineStyle.Double, + CanFocus = true, + TabStop = TabBehavior.TabGroup, + Arrangement = ViewArrangement.Resizable, UseSixel = true }; From 7f12c371a21d014cc531249efd249e69bb316e50 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 1 Jun 2026 06:25:10 -0600 Subject: [PATCH 03/13] Address sixel raster review feedback Add regression coverage for raster command IDs, stale cell invalidation, and ToAnsi layering. Update raster buffer replacement/removal to dirty stale image cells and make ToAnsi emit raster images before text overlays. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Terminal.Gui/Drivers/Output/OutputBase.cs | 17 ++- .../Drivers/Output/OutputBufferImpl.cs | 33 ++++- .../Drivers/Output/OutputBaseTests.cs | 121 ++++++++++++++++++ 3 files changed, 164 insertions(+), 7 deletions(-) diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index c019f9d925..9add278f7d 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -501,10 +501,15 @@ public string ToAnsi (IOutputBuffer buffer) } StringBuilder ansiOutput = new (); - Attribute? lastAttr = null; + bool wroteRasterImages = AppendRasterImageAnsi (buffer, ansiOutput); + + if (wroteRasterImages) + { + ansiOutput.Append (EscSeqUtils.CSI_SetCursorPosition (1, 1)); + } + Attribute? lastAttr = null; BuildAnsiForRegion (buffer, 0, buffer.Rows, 0, buffer.Cols, ansiOutput, ref lastAttr); - AppendRasterImageAnsi (buffer, ansiOutput); return ansiOutput.ToString (); } @@ -534,22 +539,28 @@ private void RenderRasterImages (IOutputBuffer buffer) } } - private static void AppendRasterImageAnsi (IOutputBuffer buffer, StringBuilder output) + private static bool AppendRasterImageAnsi (IOutputBuffer buffer, StringBuilder output) { + bool wroteRasterImages = false; + foreach (RasterImageCommand command in buffer.GetRasterImages ()) { foreach (Rectangle visibleCells in GetVisibleRasterCellRectangles (command)) { if (!TryCropRasterImagePixels (command.Pixels!, command.DestinationCells, visibleCells, out Color [,] pixels)) { + continue; } output.Append (EscSeqUtils.CSI_SetCursorPosition (visibleCells.Y + 1, visibleCells.X + 1)); SixelEncoder encoder = command.Encoder ?? new (); output.Append (encoder.EncodeSixel (pixels)); + wroteRasterImages = true; } } + + return wroteRasterImages; } private static IEnumerable GetVisibleRasterCellRectangles (RasterImageCommand command) diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs index 6c9aff904f..2df5d3bb97 100644 --- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs @@ -298,6 +298,7 @@ public void Move (int col, int row) public void AddRasterImage (RasterImageCommand command) { ArgumentNullException.ThrowIfNull (command); + ArgumentException.ThrowIfNullOrEmpty (command.Id); if (command.Pixels is null) { @@ -312,16 +313,18 @@ public void AddRasterImage (RasterImageCommand command) lock (_contentsLock) { command.Clip = Clip?.Clone (); - MarkRasterImageCellsClean (command); - int index = _rasterImages.FindIndex (existing => existing.Id is not null && existing.Id == command.Id); + int index = _rasterImages.FindIndex (existing => existing.Id == command.Id); if (index >= 0) { + MarkRasterImageCellsDirty (_rasterImages [index]); + MarkRasterImageCellsClean (command); _rasterImages [index] = command; return; } + MarkRasterImageCellsClean (command); _rasterImages.Add (command); } } @@ -333,7 +336,17 @@ public void RemoveRasterImage (string id) lock (_contentsLock) { - _rasterImages.RemoveAll (command => command.Id == id); + for (int i = _rasterImages.Count - 1; i >= 0; i--) + { + if (_rasterImages [i].Id != id) + { + + continue; + } + + MarkRasterImageCellsDirty (_rasterImages [i]); + _rasterImages.RemoveAt (i); + } } } @@ -341,6 +354,16 @@ public void RemoveRasterImage (string id) public IReadOnlyList GetRasterImages () => _rasterImages; private void MarkRasterImageCellsClean (RasterImageCommand command) + { + SetRasterImageCellsDirtyState (command, false); + } + + private void MarkRasterImageCellsDirty (RasterImageCommand command) + { + SetRasterImageCellsDirtyState (command, true); + } + + private void SetRasterImageCellsDirtyState (RasterImageCommand command, bool isDirty) { if (Contents is null) { @@ -355,6 +378,7 @@ private void MarkRasterImageCellsClean (RasterImageCommand command) if (visible.Width <= 0 || visible.Height <= 0) { + continue; } @@ -362,7 +386,8 @@ private void MarkRasterImageCellsClean (RasterImageCommand command) { for (int col = visible.X; col < visible.Right; col++) { - Contents [row, col].IsDirty = false; + Contents [row, col].IsDirty = isDirty; + DirtyLines [row] |= isDirty; } } } diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs index b1221a1f14..3c55fb29bd 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs @@ -1029,6 +1029,127 @@ public void ToAnsi_RasterImage_SkipsWhenClipDoesNotIntersect () Assert.DoesNotContain ("\u001bP", ansi); } + // Copilot - GPT-5.5 + [Fact] + public void ToAnsi_RasterImage_RendersBeforeLaterTextCells () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (2, 2); + buffer.Clip = new Region (new Rectangle (0, 0, 2, 2)); + + RasterImageCommand command = new () + { + Id = "image", + Pixels = CreateSolidImage (2, 2, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2) + }; + + buffer.AddRasterImage (command); + buffer.Move (0, 0); + buffer.AddStr ("\u03a9"); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert + int imageIndex = ansi.IndexOf ("\u001bP", StringComparison.Ordinal); + int resetAfterImageIndex = ansi.IndexOf (EscSeqUtils.CSI_SetCursorPosition (1, 1), imageIndex, StringComparison.Ordinal); + int textIndex = ansi.IndexOf ("\u03a9", StringComparison.Ordinal); + Assert.InRange (imageIndex, 0, resetAfterImageIndex - 1); + Assert.InRange (resetAfterImageIndex, imageIndex + 1, textIndex - 1); + } + + // Copilot - GPT-5.5 + [Theory] + [InlineData (null)] + [InlineData ("")] + public void AddRasterImage_RequiresId (string? id) + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (2, 2); + + RasterImageCommand command = new () + { + Id = id, + Pixels = CreateSolidImage (2, 2, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2) + }; + + // Act & Assert + Assert.ThrowsAny (() => buffer.AddRasterImage (command)); + } + + // Copilot - GPT-5.5 + [Fact] + public void AddRasterImage_ReplacingExistingInvalidatesOldOnlyCells () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (4, 4); + buffer.Clip = new Region (new Rectangle (0, 0, 4, 4)); + + RasterImageCommand oldCommand = new () + { + Id = "image", + Pixels = CreateSolidImage (4, 4, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 4, 4) + }; + + buffer.AddRasterImage (oldCommand); + buffer.DirtyLines [3] = false; + + RasterImageCommand newCommand = new () + { + Id = "image", + Pixels = CreateSolidImage (2, 2, new Color (0, 255, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2) + }; + + // Act + buffer.AddRasterImage (newCommand); + + // Assert + RasterImageCommand captured = Assert.Single (buffer.GetRasterImages ()); + Assert.Equal (new Rectangle (0, 0, 2, 2), captured.DestinationCells); + Assert.True (buffer.Contents! [3, 3].IsDirty); + Assert.True (buffer.DirtyLines [3]); + Assert.False (buffer.Contents [0, 0].IsDirty); + } + + // Copilot - GPT-5.5 + [Fact] + public void RemoveRasterImage_InvalidatesCoveredCells () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (2, 2); + buffer.Clip = new Region (new Rectangle (0, 0, 2, 2)); + + RasterImageCommand command = new () + { + Id = "image", + Pixels = CreateSolidImage (2, 2, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2) + }; + + buffer.AddRasterImage (command); + buffer.DirtyLines [0] = false; + + // Act + buffer.RemoveRasterImage ("image"); + + // Assert + Assert.Empty (buffer.GetRasterImages ()); + Assert.True (buffer.Contents! [0, 0].IsDirty); + Assert.True (buffer.DirtyLines [0]); + } + // Copilot - GPT-5.5 [Fact] public void Write_RasterImage_RendersBeforeLaterDirtyCells () From dd9d9c800d2b66657485dfb741fb5c274eca11e6 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 1 Jun 2026 07:13:37 -0600 Subject: [PATCH 04/13] Add Mandelbrot recording docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/UICatalog/Scenarios/Mandelbrot.cs | 55 ++++++++++++++++++--- docfx/docs/drawing.md | 30 +++++++++++ docfx/docs/drivers.md | 6 ++- docfx/images/Mandelbrot.gif | Bin 0 -> 59095 bytes 4 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 docfx/images/Mandelbrot.gif diff --git a/Examples/UICatalog/Scenarios/Mandelbrot.cs b/Examples/UICatalog/Scenarios/Mandelbrot.cs index c5ef3f6582..38bc4f70cf 100644 --- a/Examples/UICatalog/Scenarios/Mandelbrot.cs +++ b/Examples/UICatalog/Scenarios/Mandelbrot.cs @@ -16,11 +16,13 @@ public class Mandelbrot : Scenario private NumericUpDown _span = null!; private Label _status = null!; private Window _window = null!; + private bool _forcingSixelSupport; public override void Main () { - using IApplication app = Application.Create ().Init (); + using IApplication app = Application.Create ().Init (DriverRegistry.Names.ANSI); _app = app; + _app.Driver!.SixelSupportChanged += OnSixelSupportChanged; _window = new () { Title = $"{Application.GetDefaultKey (Command.Quit)} to Quit - Scenario: {GetName ()}" }; @@ -35,7 +37,8 @@ public override void Main () { X = Pos.Right (settings), Width = Dim.Fill (), - Height = Dim.Fill () + Height = Dim.Fill (), + CanFocus = true }; _status = new () @@ -54,7 +57,7 @@ public override void Main () Height = ImageRows, BorderStyle = LineStyle.Double, CanFocus = true, - TabStop = TabBehavior.TabGroup, + TabStop = TabBehavior.TabStop, Arrangement = ViewArrangement.Resizable, UseSixel = true }; @@ -63,10 +66,27 @@ public override void Main () display.Add (_status, _mandelbrotView); _window.Add (settings, display); - _window.Initialized += (_, _) => RenderMandelbrot (); + _window.Initialized += (_, _) => + { + RenderMandelbrot (); + _app.AddTimeout (TimeSpan.FromMilliseconds (100), + () => + { + _mandelbrotView.SetFocus (); + + return false; + }); + }; - app.Run (_window); - _window.Dispose (); + try + { + app.Run (_window); + } + finally + { + _app.Driver!.SixelSupportChanged -= OnSixelSupportChanged; + _window.Dispose (); + } } private void BuildSettings (View settings) @@ -188,8 +208,9 @@ private void RenderMandelbrot () double span = _span.Value; _mandelbrotView.Render (pixelWidth, pixelHeight, centerX, centerY, span, iterations, support); + string renderMode = _mandelbrotView.IsUsingSixel ? "Sixel raster" : "Cell fallback"; _status.Text = - $"Sixel raster: {pixelWidth} x {pixelHeight}px, {support.Resolution.Width} x {support.Resolution.Height}px/cell"; + $"{renderMode}: {pixelWidth} x {pixelHeight}px, {support.Resolution.Width} x {support.Resolution.Height}px/cell"; _status.SetNeedsDraw (); } @@ -218,6 +239,26 @@ private SixelSupportResult EnsureSixelSupportForDemo () return forced; } + private void OnSixelSupportChanged (object sender, ValueChangedEventArgs args) + { + if (_forcingSixelSupport || args.NewValue is { IsSupported: true }) + { + return; + } + + try + { + _forcingSixelSupport = true; + EnsureSixelSupportForDemo (); + } + finally + { + _forcingSixelSupport = false; + } + + RenderMandelbrot (); + } + private void ShowOverlay () { _mandelbrotView.SetNeedsDraw (); diff --git a/docfx/docs/drawing.md b/docfx/docs/drawing.md index b081facff8..e59b9437f2 100644 --- a/docfx/docs/drawing.md +++ b/docfx/docs/drawing.md @@ -91,6 +91,36 @@ Most draw steps can be overridden using the [Cancellable Work Pattern](cancellab Clipping enables better performance and features like shadows by ensuring regions of the terminal that need to be drawn actually get drawn by the driver. Terminal.Gui supports non-rectangular clip regions with . The driver.Clip is the application managed clip region and is managed by . Developers cannot change this directly, but can use `SetClipToScreen()`, `SetClip()`(Terminal.Gui.Region), `SetClipToFrame()`, etc... +## Sixel Raster Images + +Terminal.Gui can render raster images through the same deferred drawing pipeline as text and line art. accepts a `Color[,]` pixel buffer. When `ImageView.UseSixel` is `true` and reports support, the view encodes the pixels with and stores a raster image command in the driver's output buffer. + +Raster image commands participate in normal composition: + +1. The current clip region is captured when the image is drawn. +2. The output layer crops the pixel buffer to the visible clipped cell rectangles. +3. The raster command is emitted before dirty text cells, so later-drawn SubViews, dialogs, borders, and overlays can paint over the image. +4. Replacing or removing the image invalidates the cells the previous raster occupied, so stale terminal graphics are cleared by the next refresh. + +To render an image, assign the pixel buffer and mark the view for drawing: + +```cs +ImageView imageView = new () +{ + Width = 30, + Height = 20, + UseSixel = true +}; + +imageView.Image = pixels; +``` + +To customize sixel encoding, assign `ImageView.SixelEncoder` before setting `Image`. Terminals that do not report sixel support use ImageView's cell-based fallback. + +The UICatalog Mandelbrot scenario demonstrates a resizable `ImageView` with a double-line border and a runnable dialog drawn over the raster image. + +![Mandelbrot sixel raster demo](../images/Mandelbrot.gif) + ## Cell The class represents a single cell on the screen. It contains a character and an attribute. The character is of type `Rune` and the attribute is of type . diff --git a/docfx/docs/drivers.md b/docfx/docs/drivers.md index 1aeb430101..bfb4987426 100644 --- a/docfx/docs/drivers.md +++ b/docfx/docs/drivers.md @@ -233,6 +233,7 @@ Renders the output buffer to the terminal. Platform-specific implementations: Responsibilities include: - Writing characters, strings, and ANSI escape sequences +- Emitting raster image commands such as sixel images through the normal output pass - Cursor positioning and visibility control - Querying terminal window size - Managing the active screen buffer @@ -254,6 +255,7 @@ Manages the screen buffer and drawing operations: - Provides methods like `AddRune()`, `AddStr()`, `Move()`, `FillRect()` - Handles clipping regions - Tracks dirty regions for efficient rendering +- Stores raster image commands (`AddRasterImage()`, `RemoveRasterImage()`, `GetRasterImages()`) alongside cells so images share clipping, dirty tracking, and overlay composition with the rest of the screen #### ISizeMonitor Detects terminal size changes and raises `SizeChanged` events when the terminal is resized. @@ -346,6 +348,7 @@ The main driver interface that the framework uses internally. `IDriver` is organ #### Color Support - `SupportsTrueColor` - 24-bit color capability - `Force16Colors` - Force 16-color mode +- `SixelSupport` - The detected sixel capability, including cell pixel resolution, palette size, and transparency support. `ImageView` uses this to decide whether to render via sixel or its cell-based fallback. - `DefaultAttribute` - The terminal's actual default foreground/background colors, detected at startup via OSC 10/11 queries. Used by to resolve 's `None` during role derivation. `null` if the terminal didn't respond (e.g., legacy console). - `ColorCapabilities` - The terminal's color capability level (`NoColor`, `Colors16`, `Colors256`, `TrueColor`), detected from `$TERM`, `$COLORTERM`, and other environment variables @@ -395,7 +398,8 @@ When `AppModel == Inline`, the ANSI driver changes behavior in several ways: - `Col`, `Row`, `CurrentAttribute` - Drawing state - `Move()`, `AddRune()`, `AddStr()`, `FillRect()` - Drawing operations - `SetAttribute()`, `GetAttribute()` - Attribute management -- `WriteRaw()`, `GetSixels()` - Raw output and graphics +- `AddRasterImage()`, `RemoveRasterImage()`, `GetRasterImages()` - Integrated raster graphics commands +- `WriteRaw()`, `GetSixels()` - Legacy raw output and graphics hooks - `Refresh()`, `ToString()`, `ToAnsi()` - Output rendering #### Cursor diff --git a/docfx/images/Mandelbrot.gif b/docfx/images/Mandelbrot.gif new file mode 100644 index 0000000000000000000000000000000000000000..f60f0dc9edec5079d15fc9845f5c750b37a78aff GIT binary patch literal 59095 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 OID<8KgQvFu0028DH*oy` literal 0 HcmV?d00001 From da75a99f22575f9e30748ef25a8041e4bb75255c Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 1 Jun 2026 08:25:26 -0600 Subject: [PATCH 05/13] Scale Mandelbrot rendering on resize Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/UICatalog/Scenarios/Mandelbrot.cs | 71 ++++++++++++++++++++-- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/Examples/UICatalog/Scenarios/Mandelbrot.cs b/Examples/UICatalog/Scenarios/Mandelbrot.cs index 38bc4f70cf..3aa21804ec 100644 --- a/Examples/UICatalog/Scenarios/Mandelbrot.cs +++ b/Examples/UICatalog/Scenarios/Mandelbrot.cs @@ -7,6 +7,9 @@ public class Mandelbrot : Scenario { private const int ImageColumns = 30; private const int ImageRows = 20; + private const double MinimumSpan = 0.05; + private const double ZoomInFactor = 0.5; + private const double ZoomOutFactor = 2; private IApplication _app = null!; private NumericUpDown _centerX = null!; @@ -63,6 +66,9 @@ public override void Main () }; BuildSettings (settings); + BuildZoomButtons (); + _mandelbrotView.ViewportChanged += (_, _) => RenderMandelbrot (); + display.Add (_status, _mandelbrotView); _window.Add (settings, display); @@ -134,7 +140,7 @@ private void BuildSettings (View settings) _span.ValueChanging += (_, args) => { - if (args.NewValue <= 0.05) + if (args.NewValue < MinimumSpan) { args.Handled = true; } @@ -176,13 +182,45 @@ private void BuildSettings (View settings) X = 1, Y = 13, Width = Dim.Fill (1), - Height = 4, - Text = "The image is a 30 x 20\nImageView SubView drawn\nthrough sixel raster\ncommands." + Height = 5, + Text = "The bordered image starts\nat 30 x 20 cells. Resize\nit or use +/- to rerender\nthrough sixel raster\ncommands." }; settings.Add (_centerX, _centerY, _span, _iterations, reset, overlay, note); } + private void BuildZoomButtons () + { + Button zoomOut = new () + { + X = Pos.AnchorEnd (2), + Y = Pos.AnchorEnd (1), + Width = 1, + Height = 1, + Text = "-", + NoDecorations = true, + NoPadding = true, + ShadowStyle = null + }; + + Button zoomIn = new () + { + X = Pos.AnchorEnd (1), + Y = Pos.AnchorEnd (1), + Width = 1, + Height = 1, + Text = "+", + NoDecorations = true, + NoPadding = true, + ShadowStyle = null + }; + + zoomOut.Accepted += (_, _) => Zoom (ZoomOutFactor); + zoomIn.Accepted += (_, _) => Zoom (ZoomInFactor); + + _mandelbrotView.Add (zoomOut, zoomIn); + } + private void ResetSettings () { _centerX.Value = -0.5; @@ -192,6 +230,18 @@ private void ResetSettings () RenderMandelbrot (); } + private void Zoom (double spanMultiplier) + { + double nextSpan = Math.Max (MinimumSpan, _span.Value * spanMultiplier); + + if (Math.Abs (nextSpan - _span.Value) < double.Epsilon) + { + return; + } + + _span.Value = nextSpan; + } + private void RenderMandelbrot () { if (_mandelbrotView is null) @@ -200,8 +250,17 @@ private void RenderMandelbrot () } SixelSupportResult support = EnsureSixelSupportForDemo (); - int pixelWidth = ImageColumns * Math.Max (1, support.Resolution.Width); - int pixelHeight = ImageRows * Math.Max (1, support.Resolution.Height); + Rectangle viewport = _mandelbrotView.Viewport; + int imageColumns = Math.Max (0, viewport.Width); + int imageRows = Math.Max (0, viewport.Height); + + if (imageColumns == 0 || imageRows == 0) + { + return; + } + + int pixelWidth = imageColumns * Math.Max (1, support.Resolution.Width); + int pixelHeight = imageRows * Math.Max (1, support.Resolution.Height); int iterations = _iterations.Value; double centerX = _centerX.Value; double centerY = _centerY.Value; @@ -210,7 +269,7 @@ private void RenderMandelbrot () _mandelbrotView.Render (pixelWidth, pixelHeight, centerX, centerY, span, iterations, support); string renderMode = _mandelbrotView.IsUsingSixel ? "Sixel raster" : "Cell fallback"; _status.Text = - $"{renderMode}: {pixelWidth} x {pixelHeight}px, {support.Resolution.Width} x {support.Resolution.Height}px/cell"; + $"{renderMode}: {imageColumns} x {imageRows} cells, {pixelWidth} x {pixelHeight}px"; _status.SetNeedsDraw (); } From 1928c83982af483888e060f987a80be99bb3cd19 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 1 Jun 2026 09:30:03 -0600 Subject: [PATCH 06/13] Add fire progress style and fix Mandelbrot layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/UICatalog/Scenarios/Mandelbrot.cs | 346 ++++++++++-------- Terminal.Gui/Views/ProgressBar.cs | 207 ++++++++++- .../Views/ProgressBarTests.cs | 159 ++++++-- 3 files changed, 510 insertions(+), 202 deletions(-) diff --git a/Examples/UICatalog/Scenarios/Mandelbrot.cs b/Examples/UICatalog/Scenarios/Mandelbrot.cs index 3aa21804ec..9266806ec5 100644 --- a/Examples/UICatalog/Scenarios/Mandelbrot.cs +++ b/Examples/UICatalog/Scenarios/Mandelbrot.cs @@ -8,12 +8,23 @@ public class Mandelbrot : Scenario private const int ImageColumns = 30; private const int ImageRows = 20; private const double MinimumSpan = 0.05; + private const int SettingLabelWidth = 11; + private const int CenterXLabelGroupId = 1; + private const int CenterYLabelGroupId = 2; + private const int SpanLabelGroupId = 3; + private const int IterationsLabelGroupId = 4; + private const int ResetGroupId = 5; + private const int NoteGroupId = 6; + private const int FireLabelGroupId = 7; + private const int FireProgressGroupId = 8; private const double ZoomInFactor = 0.5; private const double ZoomOutFactor = 2; private IApplication _app = null!; private NumericUpDown _centerX = null!; private NumericUpDown _centerY = null!; + private ProgressBar _fireProgress = null!; + private object? _fireProgressTimeout; private NumericUpDown _iterations = null!; private MandelbrotImageView _mandelbrotView = null!; private NumericUpDown _span = null!; @@ -27,32 +38,15 @@ public override void Main () _app = app; _app.Driver!.SixelSupportChanged += OnSixelSupportChanged; - _window = new () { Title = $"{Application.GetDefaultKey (Command.Quit)} to Quit - Scenario: {GetName ()}" }; + _window = new Window { Title = $"{Application.GetDefaultKey (Command.Quit)} to Quit - Scenario: {GetName ()}" }; - FrameView settings = new () - { - Title = "Settings", - Width = 34, - Height = Dim.Fill () - }; + FrameView settings = new () { Title = "Settings", Width = 34, Height = Dim.Fill () }; - View display = new () - { - X = Pos.Right (settings), - Width = Dim.Fill (), - Height = Dim.Fill (), - CanFocus = true - }; + View display = new () { X = Pos.Right (settings), Width = Dim.Fill (), Height = Dim.Fill (), CanFocus = true }; - _status = new () - { - X = 1, - Y = 1, - Width = Dim.Fill (1), - Height = 1 - }; + _status = new Label { X = Pos.Align (Alignment.Start), Y = Pos.Align (Alignment.Start), Width = Dim.Fill (), Height = 1 }; - _mandelbrotView = new () + _mandelbrotView = new MandelbrotImageView { X = Pos.Center (), Y = Pos.Center (), @@ -75,6 +69,8 @@ public override void Main () _window.Initialized += (_, _) => { RenderMandelbrot (); + StartFireProgress (); + _app.AddTimeout (TimeSpan.FromMilliseconds (100), () => { @@ -90,50 +86,67 @@ public override void Main () } finally { + StopFireProgress (); _app.Driver!.SixelSupportChanged -= OnSixelSupportChanged; _window.Dispose (); } } + private bool AdvanceFireProgress () + { + if (_fireProgressTimeout is null) + { + return false; + } + + float nextFraction = _fireProgress.Fraction + 0.02f; + _fireProgress.Fraction = nextFraction >= 1 ? 0 : nextFraction; + + return true; + } + private void BuildSettings (View settings) { - settings.Add ( - new Label { X = 1, Y = 1, Text = "Center X:" }, - new Label { X = 1, Y = 3, Text = "Center Y:" }, - new Label { X = 1, Y = 5, Text = "Span:" }, - new Label { X = 1, Y = 7, Text = "Iterations:" }); + Label centerXLabel = CreateSettingLabel ("Center X:", CenterXLabelGroupId); + Label centerYLabel = CreateSettingLabel ("Center Y:", CenterYLabelGroupId, centerXLabel); + Label spanLabel = CreateSettingLabel ("Span:", SpanLabelGroupId, centerYLabel); + Label iterationsLabel = CreateSettingLabel ("Iterations:", IterationsLabelGroupId, spanLabel); - _centerX = new () + _centerX = new NumericUpDown { - X = 14, - Y = 1, + X = Pos.Right (centerXLabel) + 1, + Y = Pos.Top (centerXLabel), + Width = Dim.Fill (), Value = -0.5, Increment = 0.05, Format = "{0:0.000}" }; - _centerY = new () + _centerY = new NumericUpDown { - X = 14, - Y = 3, + X = Pos.Right (centerYLabel) + 1, + Y = Pos.Top (centerYLabel), + Width = Dim.Fill (), Value = 0, Increment = 0.05, Format = "{0:0.000}" }; - _span = new () + _span = new NumericUpDown { - X = 14, - Y = 5, + X = Pos.Right (spanLabel) + 1, + Y = Pos.Top (spanLabel), + Width = Dim.Fill (), Value = 3, Increment = 0.1, Format = "{0:0.000}" }; - _iterations = new () + _iterations = new NumericUpDown { - X = 14, - Y = 7, + X = Pos.Right (iterationsLabel) + 1, + Y = Pos.Top (iterationsLabel), + Width = Dim.Fill (), Value = 80, Increment = 10 }; @@ -159,34 +172,48 @@ private void BuildSettings (View settings) _span.ValueChanged += (_, _) => RenderMandelbrot (); _iterations.ValueChanged += (_, _) => RenderMandelbrot (); - Button reset = new () - { - X = 1, - Y = 10, - Text = "_Reset" - }; + Button reset = new () { X = Pos.Align (Alignment.Start, groupId: ResetGroupId), Y = Pos.Bottom (iterationsLabel) + 2, Text = "_Reset" }; reset.Accepted += (_, _) => ResetSettings (); - Button overlay = new () - { - X = Pos.Right (reset) + 2, - Y = 10, - Text = "_Overlay" - }; + Button overlay = new () { X = Pos.Right (reset) + 1, Y = Pos.Top (reset), Text = "_Overlay" }; overlay.Accepted += (_, _) => ShowOverlay (); Label note = new () { - X = 1, - Y = 13, - Width = Dim.Fill (1), - Height = 5, + X = Pos.Align (Alignment.Start, groupId: NoteGroupId), + Y = Pos.Bottom (reset) + 2, + Width = Dim.Fill (), + Height = Dim.Auto (), Text = "The bordered image starts\nat 30 x 20 cells. Resize\nit or use +/- to rerender\nthrough sixel raster\ncommands." }; - settings.Add (_centerX, _centerY, _span, _iterations, reset, overlay, note); + Label fireLabel = new () { X = Pos.Align (Alignment.Start, groupId: FireLabelGroupId), Y = Pos.AnchorEnd (2), Text = "Fire progress:" }; + + _fireProgress = new ProgressBar + { + X = Pos.Align (Alignment.Start, groupId: FireProgressGroupId), + Y = Pos.Bottom (fireLabel), + Width = Dim.Fill (), + ProgressBarStyle = ProgressBarStyle.Fire, + ProgressBarFormat = ProgressBarFormat.SimplePlusPercentage, + CanFocus = true + }; + + settings.Add (centerXLabel, + centerYLabel, + spanLabel, + iterationsLabel, + _centerX, + _centerY, + _span, + _iterations, + reset, + overlay, + note, + fireLabel, + _fireProgress); } private void BuildZoomButtons () @@ -221,25 +248,59 @@ private void BuildZoomButtons () _mandelbrotView.Add (zoomOut, zoomIn); } - private void ResetSettings () + private static Label CreateSettingLabel (string text, int groupId, View? previous = null) => + new () + { + X = Pos.Align (Alignment.Start, groupId: groupId), + Y = previous is null ? Pos.Align (Alignment.Start) : Pos.Bottom (previous) + 1, + Width = SettingLabelWidth, + TextAlignment = Alignment.End, + Text = text + }; + + private SixelSupportResult EnsureSixelSupportForDemo () { - _centerX.Value = -0.5; - _centerY.Value = 0; - _span.Value = 3; - _iterations.Value = 80; - RenderMandelbrot (); + SixelSupportResult source = _app.Driver?.SixelSupport ?? new (); + + if (source.IsSupported) + { + return source; + } + + SixelSupportResult forced = new () + { + IsSupported = true, + Resolution = source.Resolution, + MaxPaletteColors = source.MaxPaletteColors, + SupportsTransparency = source.SupportsTransparency + }; + + if (_app.Driver is DriverImpl driver) + { + driver.SetSixelSupport (forced); + } + + return forced; } - private void Zoom (double spanMultiplier) + private void OnSixelSupportChanged (object sender, ValueChangedEventArgs args) { - double nextSpan = Math.Max (MinimumSpan, _span.Value * spanMultiplier); - - if (Math.Abs (nextSpan - _span.Value) < double.Epsilon) + if (_forcingSixelSupport || args.NewValue is { IsSupported: true }) { return; } - _span.Value = nextSpan; + try + { + _forcingSixelSupport = true; + EnsureSixelSupportForDemo (); + } + finally + { + _forcingSixelSupport = false; + } + + RenderMandelbrot (); } private void RenderMandelbrot () @@ -268,116 +329,104 @@ private void RenderMandelbrot () _mandelbrotView.Render (pixelWidth, pixelHeight, centerX, centerY, span, iterations, support); string renderMode = _mandelbrotView.IsUsingSixel ? "Sixel raster" : "Cell fallback"; - _status.Text = - $"{renderMode}: {imageColumns} x {imageRows} cells, {pixelWidth} x {pixelHeight}px"; + _status.Text = $"{renderMode}: {imageColumns} x {imageRows} cells, {pixelWidth} x {pixelHeight}px"; _status.SetNeedsDraw (); } - private SixelSupportResult EnsureSixelSupportForDemo () + private void ResetSettings () { - SixelSupportResult source = _app.Driver?.SixelSupport ?? new (); + _centerX.Value = -0.5; + _centerY.Value = 0; + _span.Value = 3; + _iterations.Value = 80; + RenderMandelbrot (); + } - if (source.IsSupported) - { - return source; - } + private void ShowOverlay () + { + _mandelbrotView.SetNeedsDraw (); - SixelSupportResult forced = new () - { - IsSupported = true, - Resolution = source.Resolution, - MaxPaletteColors = source.MaxPaletteColors, - SupportsTransparency = source.SupportsTransparency - }; + Dialog dialog = new () { Title = "Overlay Runnable", Width = 38, Height = 9 }; - if (_app.Driver is DriverImpl driver) + dialog.Add (new Label { - driver.SetSixelSupport (forced); - } + X = Pos.Center (), + Y = Pos.Center (), + Width = Dim.Auto (), + Height = Dim.Auto (), + Text = "This dialog is a runnable\nshown over the sixel image\nto exercise clipping." + }); - return forced; + dialog.AddButton (new Button { Text = "_OK", IsDefault = true }); + _app.Run (dialog); + dialog.Dispose (); + + _mandelbrotView.SetNeedsDraw (); } - private void OnSixelSupportChanged (object sender, ValueChangedEventArgs args) + private void StartFireProgress () => _fireProgressTimeout ??= _app.AddTimeout (TimeSpan.FromMilliseconds (80), AdvanceFireProgress); + + private void StopFireProgress () { - if (_forcingSixelSupport || args.NewValue is { IsSupported: true }) + if (_fireProgressTimeout is null) { return; } - try - { - _forcingSixelSupport = true; - EnsureSixelSupportForDemo (); - } - finally - { - _forcingSixelSupport = false; - } - - RenderMandelbrot (); + _app.RemoveTimeout (_fireProgressTimeout); + _fireProgressTimeout = null; } - private void ShowOverlay () + private void Zoom (double spanMultiplier) { - _mandelbrotView.SetNeedsDraw (); + double nextSpan = Math.Max (MinimumSpan, _span.Value * spanMultiplier); - Dialog dialog = new () + if (Math.Abs (nextSpan - _span.Value) < double.Epsilon) { - Title = "Overlay Runnable", - Width = 38, - Height = 9 - }; - - dialog.Add ( - new Label - { - X = 1, - Y = 1, - Width = Dim.Fill (2), - Height = 3, - Text = "This dialog is a runnable\nshown over the sixel image\nto exercise clipping." - }); - - dialog.AddButton (new () { Text = "_OK", IsDefault = true }); - _app.Run (dialog); - dialog.Dispose (); + return; + } - _mandelbrotView.SetNeedsDraw (); + _span.Value = nextSpan; } private sealed class MandelbrotImageView : ImageView { - public void Render (int pixelWidth, - int pixelHeight, - double centerX, - double centerY, - double span, - int maxIterations, - SixelSupportResult support) + public void Render (int pixelWidth, int pixelHeight, double centerX, double centerY, double span, int maxIterations, SixelSupportResult support) { SixelEncoder = new (); SixelEncoder.Quantizer.MaxColors = Math.Min (support.MaxPaletteColors, 64); Image = CreateMandelbrotPixels (pixelWidth, pixelHeight, centerX, centerY, span, maxIterations); } - private static Color [,] CreateMandelbrotPixels (int width, - int height, - double centerX, - double centerY, - double span, - int maxIterations) + private static int CountIterations (double cx, double cy, int maxIterations) + { + double zx = 0; + double zy = 0; + var iterations = 0; + + while (zx * zx + zy * zy <= 4 && iterations < maxIterations) + { + double nextX = zx * zx - zy * zy + cx; + zy = 2 * zx * zy + cy; + zx = nextX; + iterations++; + } + + return iterations; + } + + private static Color [,] CreateMandelbrotPixels (int width, int height, double centerX, double centerY, double span, int maxIterations) { Color [,] pixels = new Color [width, height]; double spanY = span * height / width; double xMin = centerX - span / 2; double yMin = centerY - spanY / 2; - for (int y = 0; y < height; y++) + for (var y = 0; y < height; y++) { double cy = yMin + spanY * y / Math.Max (1, height - 1); - for (int x = 0; x < width; x++) + for (var x = 0; x < width; x++) { double cx = xMin + span * x / Math.Max (1, width - 1); int iterations = CountIterations (cx, cy, maxIterations); @@ -388,23 +437,6 @@ public void Render (int pixelWidth, return pixels; } - private static int CountIterations (double cx, double cy, int maxIterations) - { - double zx = 0; - double zy = 0; - int iterations = 0; - - while (zx * zx + zy * zy <= 4 && iterations < maxIterations) - { - double nextX = zx * zx - zy * zy + cx; - zy = 2 * zx * zy + cy; - zx = nextX; - iterations++; - } - - return iterations; - } - private static Color GetMandelbrotColor (int iterations, int maxIterations) { if (iterations >= maxIterations) @@ -413,11 +445,11 @@ private static Color GetMandelbrotColor (int iterations, int maxIterations) } double t = (double)iterations / maxIterations; - byte red = (byte)(9 * (1 - t) * t * t * t * 255); - byte green = (byte)(15 * (1 - t) * (1 - t) * t * t * 255); - byte blue = (byte)(8.5 * (1 - t) * (1 - t) * (1 - t) * t * 255); + var red = (byte)(9 * (1 - t) * t * t * t * 255); + var green = (byte)(15 * (1 - t) * (1 - t) * t * t * 255); + var blue = (byte)(8.5 * (1 - t) * (1 - t) * (1 - t) * t * 255); - return new Color (red, green, blue); + return new (red, green, blue); } } } diff --git a/Terminal.Gui/Views/ProgressBar.cs b/Terminal.Gui/Views/ProgressBar.cs index af43985352..ae28d6a4b2 100644 --- a/Terminal.Gui/Views/ProgressBar.cs +++ b/Terminal.Gui/Views/ProgressBar.cs @@ -1,5 +1,3 @@ -using Terminal.Gui.Drivers; - namespace Terminal.Gui.Views; /// Specifies the style that a uses to indicate the progress of an operation. @@ -15,7 +13,10 @@ public enum ProgressBarStyle MarqueeBlocks, /// Indicates progress by continuously scrolling a block across a in a marquee fashion. - MarqueeContinuous + MarqueeContinuous, + + /// Indicates progress by filling the progress area with a sixel-rendered Doom fire effect when available. + Fire } /// Specifies the format that a uses to indicate the visual presentation. @@ -30,7 +31,7 @@ public enum ProgressBarFormat /// A Progress Bar view that can indicate progress of an activity visually. /// -/// ProgressBar demo +/// ProgressBar demo /// /// can operate in two modes, percentage mode, or activity mode. The progress bar /// starts in percentage mode and setting the Fraction property will reflect on the UI the progress made so far. @@ -40,8 +41,56 @@ public enum ProgressBarFormat /// public class ProgressBar : View, IDesignable { + private static readonly ProgressBarStyle [] _progressBarStyles = + [ + ProgressBarStyle.Blocks, ProgressBarStyle.Continuous, ProgressBarStyle.MarqueeBlocks, ProgressBarStyle.MarqueeContinuous, ProgressBarStyle.Fire + ]; + + private static readonly Color [] _firePalette = + [ + new (7, 7, 7), + new (31, 7, 7), + new (47, 15, 7), + new (71, 15, 7), + new (87, 23, 7), + new (103, 31, 7), + new (119, 31, 7), + new (143, 39, 7), + new (159, 47, 7), + new (175, 63, 7), + new (191, 71, 7), + new (199, 71, 7), + new (223, 79, 7), + new (223, 87, 7), + new (223, 87, 7), + new (215, 95, 7), + new (215, 95, 7), + new (215, 103, 15), + new (207, 111, 15), + new (207, 119, 15), + new (207, 127, 15), + new (207, 135, 23), + new (199, 135, 23), + new (199, 143, 23), + new (199, 151, 31), + new (191, 159, 31), + new (191, 159, 31), + new (191, 167, 39), + new (191, 167, 39), + new (191, 175, 47), + new (183, 175, 47), + new (183, 183, 47), + new (183, 183, 55), + new (207, 207, 111), + new (223, 223, 159), + new (239, 239, 199), + new (255, 255, 255) + ]; + private int []? _activityPos; private int _delta; + private int _fireFrame; + private readonly string _fireRasterImageId = $"{nameof (ProgressBar)}.{Guid.NewGuid ():N}.Fire"; private float _fraction; private bool _isActivity; private bool _syncWithTerminal; @@ -118,8 +167,6 @@ public ProgressBarStyle ProgressBarStyle get; set { - field = value; - switch (value) { case ProgressBarStyle.Blocks: @@ -141,8 +188,19 @@ public ProgressBarStyle ProgressBarStyle SegmentCharacter = Glyphs.ContinuousMeterSegment; break; + + case ProgressBarStyle.Fire: + SegmentCharacter = Glyphs.ContinuousMeterSegment; + + break; + } + + if (field == ProgressBarStyle.Fire && value != ProgressBarStyle.Fire) + { + RemoveFireRasterImage (); } + field = value; SetNeedsDraw (); } } = ProgressBarStyle.Blocks; @@ -176,6 +234,8 @@ protected override bool OnDrawingContent (DrawContext? context) if (_isActivity) { + RemoveFireRasterImage (); + for (var i = 0; i < Viewport.Width; i++) { if (Array.IndexOf (_activityPos!, i) != -1) @@ -191,16 +251,31 @@ protected override bool OnDrawingContent (DrawContext? context) else { var mid = (int)(_fraction * Viewport.Width); - int i; - for (i = 0; (i < mid) & (i < Viewport.Width); i++) + if (ProgressBarStyle == ProgressBarStyle.Fire && DrawFireProgress (mid)) { - AddRune (SegmentCharacter); - } + Move (Math.Clamp (mid, 0, Viewport.Width), 0); - for (; i < Viewport.Width; i++) + for (int i = Math.Clamp (mid, 0, Viewport.Width); i < Viewport.Width; i++) + { + AddRune ((Rune)' '); + } + } + else { - AddRune ((Rune)' '); + RemoveFireRasterImage (); + + int i; + + for (i = 0; i < Math.Clamp (mid, 0, Viewport.Width); i++) + { + AddRune (SegmentCharacter); + } + + for (; i < Viewport.Width; i++) + { + AddRune ((Rune)' '); + } } } @@ -208,17 +283,18 @@ protected override bool OnDrawingContent (DrawContext? context) { return true; } - var tf = new TextFormatter { Alignment = Alignment.Center, Text = Text }; - var attr = new Attribute (GetAttributeForRole (VisualRole.Normal).Foreground, - GetAttributeForRole (VisualRole.Normal).Background, - GetAttributeForRole (VisualRole.Normal).Style); + TextFormatter tf = new () { Alignment = Alignment.Center, Text = Text }; + + Attribute attr = new (GetAttributeForRole (VisualRole.Normal).Foreground, + GetAttributeForRole (VisualRole.Normal).Background, + GetAttributeForRole (VisualRole.Normal).Style); if (_fraction > .5) { - attr = new Attribute (GetAttributeForRole (VisualRole.Normal).Background, - GetAttributeForRole (VisualRole.Normal).Foreground, - GetAttributeForRole (VisualRole.Normal).Style); + attr = new (GetAttributeForRole (VisualRole.Normal).Background, + GetAttributeForRole (VisualRole.Normal).Foreground, + GetAttributeForRole (VisualRole.Normal).Style); } tf.Draw (Driver, @@ -230,6 +306,19 @@ protected override bool OnDrawingContent (DrawContext? context) return true; } + /// + protected override void OnActivated (ICommandContext? commandContext) + { + base.OnActivated (commandContext); + + if (!CanFocus) + { + return; + } + + CycleProgressBarStyle (); + } + /// Notifies the that some progress has taken place. /// /// If the is percentage mode, it switches to activity mode. If is in activity mode, the @@ -298,13 +387,91 @@ protected override void Dispose (bool disposing) ClearTerminalProgress (); } + if (disposing) + { + RemoveFireRasterImage (); + } + base.Dispose (disposing); } private void ClearTerminalProgress () => Driver?.ProgressIndicator?.Clear (); + private void CycleProgressBarStyle () + { + int current = Array.IndexOf (_progressBarStyles, ProgressBarStyle); + + if (current < 0) + { + ProgressBarStyle = ProgressBarStyle.Blocks; + + return; + } + + ProgressBarStyle = _progressBarStyles [(current + 1) % _progressBarStyles.Length]; + } + + private bool DrawFireProgress (int filledCells) + { + filledCells = Math.Clamp (filledCells, 0, Viewport.Width); + + if (filledCells <= 0 || Viewport.Height <= 0 || Driver?.SixelSupport is not { IsSupported: true } support) + { + return false; + } + + int cellWidthPixels = Math.Max (1, support.Resolution.Width); + int cellHeightPixels = Math.Max (1, support.Resolution.Height); + int pixelWidth = Math.Max (1, filledCells * cellWidthPixels); + int pixelHeight = Math.Max (1, Viewport.Height * cellHeightPixels); + + RasterImageCommand command = new () + { + Id = _fireRasterImageId, + Pixels = CreateFirePixels (pixelWidth, pixelHeight, _fireFrame++), + DestinationCells = ViewportToScreen (new Rectangle (0, 0, filledCells, Viewport.Height)), + Encoder = CreateFireEncoder (support) + }; + + Driver.GetOutputBuffer ().AddRasterImage (command); + + return true; + } + + private static SixelEncoder CreateFireEncoder (SixelSupportResult support) + { + SixelEncoder encoder = new (); + encoder.Quantizer.MaxColors = Math.Min (support.MaxPaletteColors, _firePalette.Length); + + return encoder; + } + + private static Color [,] CreateFirePixels (int width, int height, int frame) + { + Color [,] pixels = new Color [width, height]; + int maxIntensity = _firePalette.Length - 1; + + for (var y = 0; y < height; y++) + { + double vertical = 1d - (double)y / Math.Max (1, height - 1); + + for (var x = 0; x < width; x++) + { + double wave = (Math.Sin ((x + frame * 3) * 0.18d) + Math.Sin (x * 0.11d - frame * 0.27d)) * 0.08d; + double flicker = (((x * 17 + y * 31 + frame * 13) & 15) - 7) / 110d; + double heat = Math.Clamp (vertical + wave + flicker, 0, 1); + int intensity = Math.Clamp ((int)Math.Round (heat * maxIntensity), 0, maxIntensity); + pixels [x, y] = _firePalette [intensity]; + } + } + + return pixels; + } + private int GetProgressPercentage () => Math.Clamp ((int)Math.Round (_fraction * 100), 0, 100); + private void RemoveFireRasterImage () => Driver?.GetOutputBuffer ().RemoveRasterImage (_fireRasterImageId); + private void UpdateTerminalProgress () { if (!_syncWithTerminal || Driver?.ProgressIndicator is not { } progressIndicator) @@ -337,6 +504,7 @@ public bool EnableForDesign () { Width = Dim.Fill (); Height = Dim.Auto (DimAutoStyle.Text, 1); + CanFocus = true; Fraction = 0.75f; return true; @@ -344,5 +512,4 @@ public bool EnableForDesign () /// public string? GetDemoKeyStrokes () => "wait:2000"; - } diff --git a/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs b/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs index 8d6d7f3436..ec8e521aae 100644 --- a/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs @@ -1,5 +1,5 @@ // Copilot -#nullable enable + using UnitTests; namespace ViewsTests; @@ -25,6 +25,9 @@ public void ProgressBarStyle_Setter_UpdatesSegmentCharacter () pb.ProgressBarStyle = ProgressBarStyle.MarqueeContinuous; Assert.Equal (Glyphs.ContinuousMeterSegment, pb.SegmentCharacter); + + pb.ProgressBarStyle = ProgressBarStyle.Fire; + Assert.Equal (Glyphs.ContinuousMeterSegment, pb.SegmentCharacter); } [Fact] @@ -40,15 +43,61 @@ public void Text_Setter_NotMarqueeStyle_ShowsPercentage () [Fact] public void Fraction_Half_Renders_HalfFilledBar () { - // Width=4, Fraction=0.5 → mid = (int)(0.5*4) = 2 → blocks at cols 0,1; spaces at cols 2,3 + // Width=4, Fraction=0.5 means blocks at cols 0,1 and spaces at cols 2,3. + IDriver driver = CreateTestDriver (4, 1); + driver.Clip = new (driver.Screen); + + ProgressBar pb = new () { Driver = driver, Width = 4, Fraction = 0.5F }; + pb.BeginInit (); + pb.EndInit (); + pb.LayoutSubViews (); + + pb.Draw (); + + var block = Glyphs.BlocksMeterSegment.ToString (); + Assert.Equal (block, driver.Contents! [0, 0].Grapheme); + Assert.Equal (block, driver.Contents [0, 1].Grapheme); + Assert.Equal (" ", driver.Contents [0, 2].Grapheme); + Assert.Equal (" ", driver.Contents [0, 3].Grapheme); + } + + // Copilot - GPT-5.5 + [Fact] + public void FireStyle_NoSixelSupport_RendersContinuousFallback () + { IDriver driver = CreateTestDriver (4, 1); - driver.Clip = new Region (driver.Screen); + driver.Clip = new (driver.Screen); + + ProgressBar pb = new () { Driver = driver, Width = 4, Fraction = 0.5F, ProgressBarStyle = ProgressBarStyle.Fire }; + pb.BeginInit (); + pb.EndInit (); + pb.LayoutSubViews (); + + pb.Draw (); + + var continuous = Glyphs.ContinuousMeterSegment.ToString (); + Assert.Equal (continuous, driver.Contents! [0, 0].Grapheme); + Assert.Equal (continuous, driver.Contents [0, 1].Grapheme); + Assert.Equal (" ", driver.Contents [0, 2].Grapheme); + Assert.Equal (" ", driver.Contents [0, 3].Grapheme); + Assert.Empty (driver.GetOutputBuffer ().GetRasterImages ()); + } + + // Copilot - GPT-5.5 + [Fact] + public void FireStyle_SixelSupport_AddsRasterImageForFilledCells () + { + DriverImpl driver = (DriverImpl)CreateTestDriver (4, 2); + driver.Clip = new (driver.Screen); + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (2, 3) }); ProgressBar pb = new () { Driver = driver, Width = 4, - Fraction = 0.5F + Height = 2, + Fraction = 0.5F, + ProgressBarStyle = ProgressBarStyle.Fire }; pb.BeginInit (); pb.EndInit (); @@ -56,27 +105,43 @@ public void Fraction_Half_Renders_HalfFilledBar () pb.Draw (); - string block = Glyphs.BlocksMeterSegment.ToString (); - Assert.Equal (block, driver.Contents! [0, 0].Grapheme); - Assert.Equal (block, driver.Contents [0, 1].Grapheme); + RasterImageCommand command = Assert.Single (driver.GetOutputBuffer ().GetRasterImages ()); + Assert.Equal (new (0, 0, 2, 2), command.DestinationCells); + Assert.Equal (4, command.Pixels!.GetLength (0)); + Assert.Equal (6, command.Pixels.GetLength (1)); + Assert.False (driver.Contents! [0, 0].IsDirty); Assert.Equal (" ", driver.Contents [0, 2].Grapheme); - Assert.Equal (" ", driver.Contents [0, 3].Grapheme); + } + + // Copilot - GPT-5.5 + [Fact] + public void FireStyle_SwitchingAway_RemovesRasterImage () + { + DriverImpl driver = (DriverImpl)CreateTestDriver (4, 1); + driver.Clip = new (driver.Screen); + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (2, 3) }); + + ProgressBar pb = new () { Driver = driver, Width = 4, Fraction = 0.5F, ProgressBarStyle = ProgressBarStyle.Fire }; + pb.BeginInit (); + pb.EndInit (); + pb.LayoutSubViews (); + pb.Draw (); + Assert.Single (driver.GetOutputBuffer ().GetRasterImages ()); + + pb.ProgressBarStyle = ProgressBarStyle.Continuous; + + Assert.Empty (driver.GetOutputBuffer ().GetRasterImages ()); } [Fact] public void Pulse_FirstCall_DrawsMarkerAtStartPosition () { - // Width=5 → _activityPos.Length = Math.Min(5/3=1, 5) = 1, initialised at pos 0. + // Width=5 initializes a single activity marker at pos 0. // First Pulse sets activity mode; the single marker is at column 0. IDriver driver = CreateTestDriver (5, 1); - driver.Clip = new Region (driver.Screen); + driver.Clip = new (driver.Screen); - ProgressBar pb = new () - { - Driver = driver, - Width = 5, - ProgressBarStyle = ProgressBarStyle.MarqueeBlocks - }; + ProgressBar pb = new () { Driver = driver, Width = 5, ProgressBarStyle = ProgressBarStyle.MarqueeBlocks }; pb.BeginInit (); pb.EndInit (); pb.LayoutSubViews (); @@ -84,7 +149,7 @@ public void Pulse_FirstCall_DrawsMarkerAtStartPosition () pb.Pulse (); pb.Draw (); - string block = Glyphs.BlocksMeterSegment.ToString (); + var block = Glyphs.BlocksMeterSegment.ToString (); Assert.Equal (block, driver.Contents! [0, 0].Grapheme); Assert.Equal (" ", driver.Contents [0, 1].Grapheme); Assert.Equal (" ", driver.Contents [0, 2].Grapheme); @@ -96,7 +161,7 @@ public void Pulse_FirstCall_DrawsMarkerAtStartPosition () public void SyncWithTerminal_Fraction_Writes_Osc_Progress () { DriverImpl driver = (DriverImpl)CreateTestDriver (); - driver.ProgressIndicator = new ProgressIndicator (driver); + driver.ProgressIndicator = new (driver); ProgressBar pb = new () { Driver = driver, SyncWithTerminal = true }; pb.Fraction = 0.5F; @@ -108,13 +173,13 @@ public void SyncWithTerminal_Fraction_Writes_Osc_Progress () public void SyncWithTerminal_Pulse_Writes_Indeterminate_Osc_Progress () { DriverImpl driver = (DriverImpl)CreateTestDriver (5, 1); - driver.ProgressIndicator = new ProgressIndicator (driver); - driver.Clip = new Region (driver.Screen); + driver.ProgressIndicator = new (driver); + driver.Clip = new (driver.Screen); ProgressBar pb = new () { Driver = driver, SyncWithTerminal = true, Width = 5 }; pb.BeginInit (); pb.EndInit (); - pb.Frame = new Rectangle (0, 0, 5, 1); + pb.Frame = new (0, 0, 5, 1); pb.LayoutSubViews (); pb.Pulse (); @@ -126,7 +191,7 @@ public void SyncWithTerminal_Pulse_Writes_Indeterminate_Osc_Progress () public void SyncWithTerminal_Hidden_ProgressBar_Still_Writes_Osc_Progress () { DriverImpl driver = (DriverImpl)CreateTestDriver (); - driver.ProgressIndicator = new ProgressIndicator (driver); + driver.ProgressIndicator = new (driver); ProgressBar pb = new () { Driver = driver, SyncWithTerminal = true, Visible = false }; pb.Fraction = 0.25F; @@ -138,7 +203,7 @@ public void SyncWithTerminal_Hidden_ProgressBar_Still_Writes_Osc_Progress () public void SyncWithTerminal_LegacyConsole_Does_Not_Write_Osc_Progress () { DriverImpl driver = (DriverImpl)CreateTestDriver (); - driver.ProgressIndicator = new ProgressIndicator (driver); + driver.ProgressIndicator = new (driver); driver.IsLegacyConsole = true; ProgressBar pb = new () { Driver = driver, SyncWithTerminal = true }; @@ -151,7 +216,7 @@ public void SyncWithTerminal_LegacyConsole_Does_Not_Write_Osc_Progress () public void SyncWithTerminal_Disabling_Clears_Terminal_Progress () { DriverImpl driver = (DriverImpl)CreateTestDriver (); - driver.ProgressIndicator = new ProgressIndicator (driver); + driver.ProgressIndicator = new (driver); ProgressBar pb = new () { Driver = driver, SyncWithTerminal = true }; pb.Fraction = 0.5F; @@ -160,11 +225,55 @@ public void SyncWithTerminal_Disabling_Clears_Terminal_Progress () Assert.Contains (EscSeqUtils.OSC_ClearProgress (), driver.GetOutput ().GetLastOutput (), StringComparison.Ordinal); } + // Copilot - GPT-5.5 + [Fact] + public void Activate_CyclesStyles_WhenFocusable () + { + ProgressBar pb = new () { CanFocus = true }; + + pb.InvokeCommand (Command.Activate); + Assert.Equal (ProgressBarStyle.Continuous, pb.ProgressBarStyle); + + pb.InvokeCommand (Command.Activate); + Assert.Equal (ProgressBarStyle.MarqueeBlocks, pb.ProgressBarStyle); + + pb.InvokeCommand (Command.Activate); + Assert.Equal (ProgressBarStyle.MarqueeContinuous, pb.ProgressBarStyle); + + pb.InvokeCommand (Command.Activate); + Assert.Equal (ProgressBarStyle.Fire, pb.ProgressBarStyle); + + pb.InvokeCommand (Command.Activate); + Assert.Equal (ProgressBarStyle.Blocks, pb.ProgressBarStyle); + } + + // Copilot - GPT-5.5 + [Fact] + public void Activate_DoesNotCycleStyles_WhenNotFocusable () + { + ProgressBar pb = new (); + + pb.InvokeCommand (Command.Activate); + + Assert.Equal (ProgressBarStyle.Blocks, pb.ProgressBarStyle); + } + + // Copilot - GPT-5.5 + [Fact] + public void EnableForDesign_MakesProgressBarFocusable () + { + ProgressBar pb = new (); + + pb.EnableForDesign (); + + Assert.True (pb.CanFocus); + } + [Fact] public void Dispose_Without_SyncWithTerminal_Does_Not_Write_Clear_Progress () { DriverImpl driver = (DriverImpl)CreateTestDriver (); - driver.ProgressIndicator = new ProgressIndicator (driver); + driver.ProgressIndicator = new (driver); ProgressBar pb = new () { Driver = driver }; pb.Dispose (); From 20a5759f933c7368e596eb115b9d55b530314101 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 1 Jun 2026 11:04:06 -0600 Subject: [PATCH 07/13] Improve Mandelbrot interaction and fire overlay Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Examples/UICatalog/Scenarios/Mandelbrot.cs | 98 +++++++++++++------ Terminal.Gui/Drivers/Output/OutputBase.cs | 1 + .../Views/ProgressBarTests.cs | 34 +++++++ 3 files changed, 104 insertions(+), 29 deletions(-) diff --git a/Examples/UICatalog/Scenarios/Mandelbrot.cs b/Examples/UICatalog/Scenarios/Mandelbrot.cs index 9266806ec5..5337ad8a8d 100644 --- a/Examples/UICatalog/Scenarios/Mandelbrot.cs +++ b/Examples/UICatalog/Scenarios/Mandelbrot.cs @@ -5,20 +5,20 @@ namespace UICatalog.Scenarios; [ScenarioCategory ("Drawing")] public class Mandelbrot : Scenario { - private const int ImageColumns = 30; - private const int ImageRows = 20; - private const double MinimumSpan = 0.05; - private const int SettingLabelWidth = 11; - private const int CenterXLabelGroupId = 1; - private const int CenterYLabelGroupId = 2; - private const int SpanLabelGroupId = 3; - private const int IterationsLabelGroupId = 4; - private const int ResetGroupId = 5; - private const int NoteGroupId = 6; - private const int FireLabelGroupId = 7; - private const int FireProgressGroupId = 8; - private const double ZoomInFactor = 0.5; - private const double ZoomOutFactor = 2; + private const int IMAGE_COLUMNS = 30; + private const int IMAGE_ROWS = 20; + private const double MINIMUM_SPAN = 0.05; + private const int SETTING_LABEL_WIDTH = 11; + private const int CENTER_X_LABEL_GROUP_ID = 1; + private const int CENTER_Y_LABEL_GROUP_ID = 2; + private const int SPAN_LABEL_GROUP_ID = 3; + private const int ITERATIONS_LABEL_GROUP_ID = 4; + private const int RESET_GROUP_ID = 5; + private const int NOTE_GROUP_ID = 6; + private const int FIRE_LABEL_GROUP_ID = 7; + private const int FIRE_PROGRESS_GROUP_ID = 8; + private const double ZOOM_IN_FACTOR = 0.8; + private const double ZOOM_OUT_FACTOR = 1.25; private IApplication _app = null!; private NumericUpDown _centerX = null!; @@ -50,14 +50,15 @@ public override void Main () { X = Pos.Center (), Y = Pos.Center (), - Width = ImageColumns, - Height = ImageRows, + Width = IMAGE_COLUMNS, + Height = IMAGE_ROWS, BorderStyle = LineStyle.Double, CanFocus = true, TabStop = TabBehavior.TabStop, Arrangement = ViewArrangement.Resizable, UseSixel = true }; + _mandelbrotView.CenterRequested += CenterMandelbrot; BuildSettings (settings); BuildZoomButtons (); @@ -107,10 +108,10 @@ private bool AdvanceFireProgress () private void BuildSettings (View settings) { - Label centerXLabel = CreateSettingLabel ("Center X:", CenterXLabelGroupId); - Label centerYLabel = CreateSettingLabel ("Center Y:", CenterYLabelGroupId, centerXLabel); - Label spanLabel = CreateSettingLabel ("Span:", SpanLabelGroupId, centerYLabel); - Label iterationsLabel = CreateSettingLabel ("Iterations:", IterationsLabelGroupId, spanLabel); + Label centerXLabel = CreateSettingLabel ("Center X:", CENTER_X_LABEL_GROUP_ID); + Label centerYLabel = CreateSettingLabel ("Center Y:", CENTER_Y_LABEL_GROUP_ID, centerXLabel); + Label spanLabel = CreateSettingLabel ("Span:", SPAN_LABEL_GROUP_ID, centerYLabel); + Label iterationsLabel = CreateSettingLabel ("Iterations:", ITERATIONS_LABEL_GROUP_ID, spanLabel); _centerX = new NumericUpDown { @@ -153,7 +154,7 @@ private void BuildSettings (View settings) _span.ValueChanging += (_, args) => { - if (args.NewValue < MinimumSpan) + if (args.NewValue < MINIMUM_SPAN) { args.Handled = true; } @@ -172,7 +173,7 @@ private void BuildSettings (View settings) _span.ValueChanged += (_, _) => RenderMandelbrot (); _iterations.ValueChanged += (_, _) => RenderMandelbrot (); - Button reset = new () { X = Pos.Align (Alignment.Start, groupId: ResetGroupId), Y = Pos.Bottom (iterationsLabel) + 2, Text = "_Reset" }; + Button reset = new () { X = Pos.Align (Alignment.Start, groupId: RESET_GROUP_ID), Y = Pos.Bottom (iterationsLabel) + 2, Text = "_Reset" }; reset.Accepted += (_, _) => ResetSettings (); @@ -182,18 +183,18 @@ private void BuildSettings (View settings) Label note = new () { - X = Pos.Align (Alignment.Start, groupId: NoteGroupId), + X = Pos.Align (Alignment.Start, groupId: NOTE_GROUP_ID), Y = Pos.Bottom (reset) + 2, Width = Dim.Fill (), Height = Dim.Auto (), Text = "The bordered image starts\nat 30 x 20 cells. Resize\nit or use +/- to rerender\nthrough sixel raster\ncommands." }; - Label fireLabel = new () { X = Pos.Align (Alignment.Start, groupId: FireLabelGroupId), Y = Pos.AnchorEnd (2), Text = "Fire progress:" }; + Label fireLabel = new () { X = Pos.Align (Alignment.Start, groupId: FIRE_LABEL_GROUP_ID), Y = Pos.AnchorEnd (2), Text = "Fire progress:" }; _fireProgress = new ProgressBar { - X = Pos.Align (Alignment.Start, groupId: FireProgressGroupId), + X = Pos.Align (Alignment.Start, groupId: FIRE_PROGRESS_GROUP_ID), Y = Pos.Bottom (fireLabel), Width = Dim.Fill (), ProgressBarStyle = ProgressBarStyle.Fire, @@ -242,8 +243,8 @@ private void BuildZoomButtons () ShadowStyle = null }; - zoomOut.Accepted += (_, _) => Zoom (ZoomOutFactor); - zoomIn.Accepted += (_, _) => Zoom (ZoomInFactor); + zoomOut.Accepted += (_, _) => Zoom (ZOOM_OUT_FACTOR); + zoomIn.Accepted += (_, _) => Zoom (ZOOM_IN_FACTOR); _mandelbrotView.Add (zoomOut, zoomIn); } @@ -253,11 +254,31 @@ private static Label CreateSettingLabel (string text, int groupId, View? previou { X = Pos.Align (Alignment.Start, groupId: groupId), Y = previous is null ? Pos.Align (Alignment.Start) : Pos.Bottom (previous) + 1, - Width = SettingLabelWidth, + Width = SETTING_LABEL_WIDTH, TextAlignment = Alignment.End, Text = text }; + private void CenterMandelbrot (Point position) + { + Rectangle viewport = _mandelbrotView.Viewport; + + if (viewport.Width <= 0 || viewport.Height <= 0) + { + return; + } + + if (position.X < 0 || position.Y < 0 || position.X >= viewport.Width || position.Y >= viewport.Height) + { + return; + } + + double span = _span.Value; + double spanY = span * viewport.Height / viewport.Width; + _centerX.Value += (((double)position.X + 0.5d) / viewport.Width - 0.5d) * span; + _centerY.Value += (((double)position.Y + 0.5d) / viewport.Height - 0.5d) * spanY; + } + private SixelSupportResult EnsureSixelSupportForDemo () { SixelSupportResult source = _app.Driver?.SixelSupport ?? new (); @@ -379,7 +400,7 @@ private void StopFireProgress () private void Zoom (double spanMultiplier) { - double nextSpan = Math.Max (MinimumSpan, _span.Value * spanMultiplier); + double nextSpan = Math.Max (MINIMUM_SPAN, _span.Value * spanMultiplier); if (Math.Abs (nextSpan - _span.Value) < double.Epsilon) { @@ -391,6 +412,13 @@ private void Zoom (double spanMultiplier) private sealed class MandelbrotImageView : ImageView { + public MandelbrotImageView () + { + MouseBindings.Add (MouseFlags.LeftButtonDoubleClicked, Command.Accept); + } + + public event Action? CenterRequested; + public void Render (int pixelWidth, int pixelHeight, double centerX, double centerY, double span, int maxIterations, SixelSupportResult support) { SixelEncoder = new (); @@ -398,6 +426,18 @@ public void Render (int pixelWidth, int pixelHeight, double centerX, double cent Image = CreateMandelbrotPixels (pixelWidth, pixelHeight, centerX, centerY, span, maxIterations); } + protected override bool OnAccepting (CommandEventArgs args) + { + if (args.Context?.Binding is not MouseBinding { MouseEvent: { IsDoubleClicked: true, Position: { } position } }) + { + return base.OnAccepting (args); + } + + CenterRequested?.Invoke (position); + + return true; + } + private static int CountIterations (double cx, double cy, int maxIterations) { double zx = 0; diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 9add278f7d..462d6b3ff6 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -93,6 +93,7 @@ public virtual void Write (IOutputBuffer buffer) InvalidateRowsWithUrlsIfStale (buffer, rows, cols); + // Raster images must be written before dirty cells so later text draws above them. if (!IsLegacyConsole) { RenderRasterImages (buffer); diff --git a/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs b/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs index ec8e521aae..7b4f58a216 100644 --- a/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs @@ -113,6 +113,40 @@ public void FireStyle_SixelSupport_AddsRasterImageForFilledCells () Assert.Equal (" ", driver.Contents [0, 2].Grapheme); } + // Copilot - GPT-5.5 + [Fact] + public void FireStyle_SixelSupport_WritesPercentageAfterRasterImageOnRepeatedFrames () + { + DriverImpl driver = (DriverImpl)CreateTestDriver (8, 1); + driver.Clip = new (driver.Screen); + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (1, 6) }); + + ProgressBar pb = new () + { + Driver = driver, + Width = 8, + Fraction = 0.5F, + ProgressBarFormat = ProgressBarFormat.SimplePlusPercentage, + ProgressBarStyle = ProgressBarStyle.Fire + }; + pb.BeginInit (); + pb.EndInit (); + pb.LayoutSubViews (); + + pb.Draw (); + driver.Refresh (); + + pb.Draw (); + driver.Refresh (); + + string output = driver.GetOutput ().GetLastOutput (); + int sixelEnd = output.IndexOf ("\u001b\\", StringComparison.Ordinal); + int percentage = output.IndexOf ("50%", StringComparison.Ordinal); + + Assert.True (sixelEnd >= 0); + Assert.True (percentage > sixelEnd); + } + // Copilot - GPT-5.5 [Fact] public void FireStyle_SwitchingAway_RemovesRasterImage () From f0f7abe8b8e7a6a2fdc4974b70ecaec4b74a505b Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 1 Jun 2026 16:32:36 -0600 Subject: [PATCH 08/13] Refactoring --- Examples/UICatalog/Scenarios/Images.cs | 967 ------------------ .../Scenarios/Images/CIE76ColorDistance.cs | 13 + .../Scenarios/Images/ConstPalette.cs | 12 + .../UICatalog/Scenarios/Images/DoomFire.cs | 82 ++ Examples/UICatalog/Scenarios/Images/Images.cs | 472 +++++++++ .../Scenarios/Images/ImagesTestCard.cs | 252 +++++ .../Scenarios/Images/LabColorDistance.cs | 39 + .../Images/MedianCutPaletteBuilder.cs | 138 +++ Examples/UICatalog/Scenarios/Mandelbrot.cs | 93 +- Terminal.Gui/Drawing/Color/ColorQuantizer.cs | 56 +- .../Drawing/Quant/IStaticPaletteBuilder.cs | 14 + Terminal.Gui/Drawing/Sixel/SixelEncoder.cs | 4 +- Terminal.Gui/Input/Command.cs | 12 + Terminal.Gui/Views/ImageView.cs | 889 ++++++++++++++-- Terminal.Gui/Views/ProgressBar.cs | 13 +- .../Drawing/ColorQuantizerTests.cs | 58 ++ .../Views/ImageViewTests.cs | 545 ++++++++++ 17 files changed, 2522 insertions(+), 1137 deletions(-) delete mode 100644 Examples/UICatalog/Scenarios/Images.cs create mode 100644 Examples/UICatalog/Scenarios/Images/CIE76ColorDistance.cs create mode 100644 Examples/UICatalog/Scenarios/Images/ConstPalette.cs create mode 100644 Examples/UICatalog/Scenarios/Images/DoomFire.cs create mode 100644 Examples/UICatalog/Scenarios/Images/Images.cs create mode 100644 Examples/UICatalog/Scenarios/Images/ImagesTestCard.cs create mode 100644 Examples/UICatalog/Scenarios/Images/LabColorDistance.cs create mode 100644 Examples/UICatalog/Scenarios/Images/MedianCutPaletteBuilder.cs create mode 100644 Terminal.Gui/Drawing/Quant/IStaticPaletteBuilder.cs create mode 100644 Tests/UnitTestsParallelizable/Drawing/ColorQuantizerTests.cs diff --git a/Examples/UICatalog/Scenarios/Images.cs b/Examples/UICatalog/Scenarios/Images.cs deleted file mode 100644 index 0a118c84c2..0000000000 --- a/Examples/UICatalog/Scenarios/Images.cs +++ /dev/null @@ -1,967 +0,0 @@ -using System.Text; -using ColorHelper; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; - -namespace UICatalog.Scenarios; - -[ScenarioMetadata ("Images", "Demonstration of how to render an image with/without true color support.")] -[ScenarioCategory ("Colors")] -[ScenarioCategory ("Drawing")] -public class Images : Scenario -{ - private ImageView _imageView; - private Image _fullResImage; - 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 View _tabSixel; - - /// - /// The view into which the currently opened sixel image is bounded - /// - private ImageView _sixelView; - - private DoomFire _fire; - private SixelEncoder _fireEncoder; - private SixelToRender _fireSixel; - private int _fireFrameCounter; - private bool _isDisposed; - private OptionSelector _osPaletteBuilder; - private OptionSelector _osDistanceAlgorithm; - private NumericUpDown _popularityThreshold; - - // Start by assuming no support — updated from driver-level detection - private SixelSupportResult _sixelSupportResult = new (); - private CheckBox _cbSupportsSixel; - private IApplication _app; - - public override void Main () - { - ConfigurationManager.Enable (ConfigLocations.All); - using IApplication app = Application.Create (); - app.Init (); - _app = app; - - _win = new Window { Title = $"{Application.GetDefaultKey (Command.Quit)} to Quit - Scenario: {GetName ()}" }; - - bool canTrueColor = app.Driver?.SupportsTrueColor ?? false; - - View tabBasic = new () - { - Title = "Basic", - Width = Dim.Fill (), - Height = Dim.Fill () - }; - - _tabSixel = new () - { - Title = "Sixel", - Width = Dim.Fill (), - Height = Dim.Fill () - }; - - Label lblDriverName = new () { X = 0, Y = 0, Text = $"Driver is {app.Driver?.GetType ().Name}" }; - _win.Add (lblDriverName); - - CheckBox cbSupportsTrueColor = new () - { - X = Pos.Right (lblDriverName) + 2, - Y = 0, - Value = canTrueColor ? CheckState.Checked : CheckState.UnChecked, - CanFocus = false, - Text = "supports true color " - }; - _win.Add (cbSupportsTrueColor); - - _cbSupportsSixel = new CheckBox - { - X = Pos.Right (lblDriverName) + 2, - Y = 1, - Value = CheckState.UnChecked, - Text = "Supports Sixel" - }; - - Label lblSupportsSixel = new () - { - X = Pos.Right (lblDriverName) + 2, - Y = Pos.Bottom (_cbSupportsSixel), - Text = "(Check if your terminal supports Sixel)" - }; - - /* Value = _sixelSupportResult.IsSupported - ? CheckState.Checked - : CheckState.UnChecked;*/ - - _cbSupportsSixel.ValueChanging += (_, e) => - { - _sixelSupportResult.IsSupported = e.NewValue == CheckState.Checked; - SetupSixelSupported (e.NewValue == CheckState.Checked); - ApplyShowTabViewHack (); - }; - - _win.Add (_cbSupportsSixel); - - CheckBox cbUseTrueColor = new () - { - X = Pos.Right (cbSupportsTrueColor) + 2, - Y = 0, - Value = !Driver.Force16Colors ? CheckState.Checked : CheckState.UnChecked, - Enabled = canTrueColor, - Text = "Use true color" - }; - cbUseTrueColor.ValueChanging += (_, evt) => Driver.Force16Colors = evt.NewValue == CheckState.UnChecked; - _win.Add (cbUseTrueColor); - - Button btnOpenImage = new () { X = Pos.Right (cbUseTrueColor) + 2, Y = 0, Text = "Open Image" }; - _win.Add (btnOpenImage); - - tabBasic.Y = Pos.Bottom (lblSupportsSixel); - _tabSixel.Y = Pos.Bottom (lblSupportsSixel); - - BuildBasicTab (tabBasic); - BuildSixelTab (); - - SetupSixelSupported (_cbSupportsSixel.Value == CheckState.Checked); - - btnOpenImage.Accepting += OpenImage; - - _win.Add (lblSupportsSixel); - _win.Add (tabBasic); - _win.Add (_tabSixel); - - _win.SubViewsLaidOut += Win_SubViewsLaidOut; - _win.Initialized += (_, _) => - { - app.Driver?.SixelSupportChanged += (_, args) => UpdateSixelSupportState (args.NewValue); - if (app.Driver?.SixelSupport is { } support) - { - UpdateSixelSupportState (support); - } - }; - app.Run (_win); - _win.Dispose (); - } - - private Size _winSize; - - private void Win_SubViewsLaidOut (object sender, LayoutEventArgs e) - { - if (_winSize == e.OldContentSize) - { - return; - } - - _winSize = e.OldContentSize; - - if (_fireSixel is { }) - { - GenerateSixelFire (false); - } - } - - private void UpdateSixelSupportState (SixelSupportResult newResult) - { - _sixelSupportResult = newResult; - - _cbSupportsSixel.Value = newResult.IsSupported ? CheckState.Checked : CheckState.UnChecked; - _pxX.Value = _sixelSupportResult.Resolution.Width; - _pxY.Value = _sixelSupportResult.Resolution.Height; - SetupSixelSupported (newResult.IsSupported); - } - - private void SetupSixelSupported (bool isSupported) - { - _tabSixel.RemoveAll (); - _tabSixel.Add (isSupported ? _sixelSupported : _sixelNotSupported); - _tabSixel.SetNeedsDraw (); - } - - private void BtnStartFireOnAccept (object sender, CommandEventArgs e) - { - if (_fire != null) - { - return; - } - - if (!_sixelSupportResult.SupportsTransparency) - { - if (MessageBox.Query (_app!, - "Transparency Not Supported", - "It looks like your terminal does not support transparent sixel backgrounds. Do you want to try anyway?", - "Yes", - "No") - != 0) - { - return; - } - } - - _winSize = _win.Viewport.Size; - - GenerateSixelFire (true); - } - - private void GenerateSixelFire (bool addTimeout) - { - _fire = new DoomFire (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); - _fireEncoder = new SixelEncoder { AvoidBottomScroll = true }; - _fireEncoder.Quantizer.MaxColors = Math.Min (_fireEncoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); - _fireEncoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (_fire.Palette); - - _fireFrameCounter = 0; - - if (addTimeout) - { - _app?.AddTimeout (TimeSpan.FromMilliseconds (30), AdvanceFireTimerCallback); - } - } - - private bool AdvanceFireTimerCallback () - { - _fire.AdvanceFrame (); - _fireFrameCounter++; - - // Control frame rate by adjusting this - // Lower number means more FPS - if (_fireFrameCounter % 2 != 0 || _isDisposed) - { - return !_isDisposed; - } - - Color [,] bmp = _fire.GetFirePixels (); - - // TODO: Static way of doing this, suboptimal - // ConcurrentQueue doesn't support Remove, so we update the existing object - string sixelFireData = _fireEncoder.EncodeSixel (bmp); - - if (_fireSixel == null) - { - _fireSixel = new SixelToRender { SixelData = sixelFireData, ScreenPosition = new Point (0, 0), Id = "fireSixel", AlwaysRender = true }; - - _app.Driver?.GetOutput ().GetSixels ().Enqueue (_fireSixel); - } - else - { - _fireSixel.SixelData = sixelFireData; - _fireSixel.ScreenPosition = new Point (0, 0); - } - - _win.SetNeedsDraw (); - - return !_isDisposed; - } - - /// - protected override void Dispose (bool disposing) - { - base.Dispose (disposing); - _imageView.Dispose (); - _fullResImage?.Dispose (); - _sixelNotSupported.Dispose (); - _sixelSupported.Dispose (); - _isDisposed = true; - } - - private void OpenImage (object sender, CommandEventArgs e) - { - OpenDialog ofd = new () { Title = "Open Image", AllowsMultipleSelection = false }; - _app?.Run (ofd); - - 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 (_app!, "Could not open file", ex.Message, "Ok"); - - return; - } - - _fullResImage?.Dispose (); - _fullResImage = img; - _imageView.Image = ConvertToColorArray (img); - ApplyShowTabViewHack (); - _app?.LayoutAndDraw (); - } - - private void ApplyShowTabViewHack () - { - _win.SetNeedsDraw (); - } - - private void BuildBasicTab (View tabBasic) - { - _imageView = new ImageView - { - Width = Dim.Fill (), - Height = Dim.Fill (), - CanFocus = true, - UseSixel = false // Basic tab uses cell-based rendering - }; - - tabBasic.Add (_imageView); - } - - private void BuildSixelTab () - { - _sixelSupported = new View - { - Width = Dim.Fill (), - Height = Dim.Fill (), - CanFocus = true - }; - - _sixelNotSupported = new View - { - 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 ImageView - { - Width = Dim.Percent (50), - Height = Dim.Fill (), - BorderStyle = LineStyle.Dotted, - UseSixel = true - }; - _sixelView.SubViewsLaidOut += SixelView_SubViewsLaidOut; - _sixelSupported.Add (_sixelView); - - Button btnSixel = new () - { - X = Pos.Right (_sixelView), - Y = 0, - Text = "Output Sixel", Width = Dim.Auto () - }; - btnSixel.Accepting += OutputSixelButtonClick; - _sixelSupported.Add (btnSixel); - - Button btnStartFire = new () - { - X = Pos.Right (_sixelView), - Y = Pos.Bottom (btnSixel), - Text = "Start Fire" - }; - btnStartFire.Accepting += BtnStartFireOnAccept; - _sixelSupported.Add (btnStartFire); - - Label lblPxX = new () - { - X = Pos.Right (_sixelView), - Y = Pos.Bottom (btnStartFire) + 1, - Text = "Pixels per Col:" - }; - - _pxX = new NumericUpDown - { - X = Pos.Right (lblPxX), - Y = Pos.Bottom (btnStartFire) + 1, - Value = _sixelSupportResult.Resolution.Width - }; - - Label lblPxY = new () - { - X = lblPxX.X, - Y = Pos.Bottom (_pxX), - Text = "Pixels per Row:" - }; - - _pxY = new NumericUpDown - { - X = Pos.Right (lblPxY), - Y = Pos.Bottom (_pxX), - Value = _sixelSupportResult.Resolution.Height - }; - - Label l1 = new () - { - Text = "Palette Building Algorithm", - Width = Dim.Auto (), - X = Pos.Right (_sixelView), - Y = Pos.Bottom (_pxY) + 1 - }; - - _osPaletteBuilder = new OptionSelector - { - Labels = - [ - "Popularity", - "Median Cut" - ], - X = Pos.Right (_sixelView) + 2, - Y = Pos.Bottom (l1), - Value = 1 - }; - - _popularityThreshold = new NumericUpDown - { - X = Pos.Right (_osPaletteBuilder) + 1, - Y = Pos.Top (_osPaletteBuilder), - Value = 8 - }; - - Label lblPopThreshold = new () - { - Text = "(threshold)", - X = Pos.Right (_popularityThreshold), - Y = Pos.Top (_popularityThreshold) - }; - - Label l2 = new () - { - Text = "Color Distance Algorithm", - Width = Dim.Auto (), - X = Pos.Right (_sixelView), - Y = Pos.Bottom (_osPaletteBuilder) + 1 - }; - - _osDistanceAlgorithm = new OptionSelector - { - Labels = - [ - "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 (_osPaletteBuilder); - - _sixelSupported.Add (l2); - _sixelSupported.Add (_osDistanceAlgorithm); - _sixelSupported.Add (_popularityThreshold); - _sixelSupported.Add (lblPopThreshold); - - // This is already handled by the OutputBase - //_sixelView.DrawingContent += SixelViewOnDrawingContent; - } - - private Size _sixelImageSize; - - private void SixelView_SubViewsLaidOut (object sender, LayoutEventArgs e) - { - if (_sixelImageSize == e.OldContentSize) - { - return; - } - - _sixelImageSize = e.OldContentSize; - - - } - - private IPaletteBuilder GetPaletteBuilder () - { - switch (_osPaletteBuilder.Value) - { - case 0: return new PopularityPaletteWithThreshold (GetDistanceAlgorithm (), _popularityThreshold.Value); - case 1: return new MedianCutPaletteBuilder (GetDistanceAlgorithm ()); - default: throw new ArgumentOutOfRangeException (); - } - } - - private IColorDistance GetDistanceAlgorithm () - { - switch (_osDistanceAlgorithm.Value) - { - case 0: return new EuclideanColorDistance (); - case 1: return new CIE76ColorDistance (); - default: throw new ArgumentOutOfRangeException (); - } - } - - private void OutputSixelButtonClick (object sender, CommandEventArgs e) - { - if (_fullResImage == null) - { - MessageBox.Query (_app!, "No Image Loaded", "You must first open an image. Use the 'Open Image' button above.", "Ok"); - - return; - } - - _sixelImageSize = _sixelView.Viewport.Size; - _sixelView.Visible = false; - - GenerateSixelImage (true); - _sixelView.Visible = true; - } - - private void GenerateSixelImage (bool openDialog) - { - SixelEncoder encoder = new (); - encoder.Quantizer.MaxColors = Math.Min (encoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); - encoder.Quantizer.PaletteBuildingAlgorithm = GetPaletteBuilder (); - encoder.Quantizer.DistanceAlgorithm = GetDistanceAlgorithm (); - _sixelView.SixelEncoder = encoder; - - Size targetSize = _sixelView.FitImageInViewportInPixels (new Size (_fullResImage.Width, _fullResImage.Height)); - using Image resized = _fullResImage.Clone (i => i.Resize (targetSize.Width, targetSize.Height)); - _sixelView.Image = ConvertToColorArray (resized); - - if (openDialog) - { - PaletteView pv = new (encoder.Quantizer.Palette.ToList ()); - - Dialog dlg = new () { Title = "Palette", Buttons = [new Button { Title = Strings.btnOk }] }; - - dlg.Add (pv); - _app?.Run (dlg); - - dlg.Dispose (); - } - } - - 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 (int x = 0; x < width; x++) - { - for (int y = 0; y < height; y++) - { - Rgba32 pixel = image [x, y]; - colors [x, y] = new Color (pixel.R, pixel.G, pixel.B); // Convert Rgba32 to Terminal.Gui color - } - } - - return colors; - } - - public class PaletteView : View - { - private readonly List _palette; - - public PaletteView (List palette) - { - _palette = palette ?? []; - Width = Dim.Fill (0, minimumContentDim: 50); - Height = Dim.Fill (0, minimumContentDim: 10); - } - - // Automatically calculates rows and columns based on the available bounds - private (int columns, int rows) CalculateGridSize (Rectangle viewport) - { - // Characters are twice as wide as they are tall, so use 2:1 width-to-height ratio - int availableWidth = viewport.Width / 2; // Each color block is 2 character wide - int availableHeight = viewport.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 - // ReSharper disable once InvertIf - if (rows > availableHeight) - { - rows = availableHeight; - columns = (numColors + rows - 1) / rows; // Recalculate columns if needed - } - - return (columns, rows); - } - - protected override bool OnDrawingContent (DrawContext context) - { - if (_palette == null || _palette.Count == 0) - { - return false; - } - - // Calculate the grid size based on the bounds - (int columns, int rows) = CalculateGridSize (Viewport); - - // Draw the colors in the palette - for (int 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 - - // Set the color attribute for the block - SetAttribute (new Attribute (_palette [i], _palette [i])); - - // Draw the block (2 characters wide per block) - for (int dx = 0; dx < 2; dx++) // Fill the width of the block - { - AddRune (x + dx, row, (Rune)' '); - } - } - - return true; - } - } -} - -internal class ConstPalette (Color [] palette) : IPaletteBuilder -{ - private readonly List _palette = palette.ToList (); - - /// - public List BuildPalette (List colors, int maxColors) => _palette; -} - -public abstract class LabColorDistance : IColorDistance -{ - // Reference white point for D65 illuminant (can be moved to constants) - private const double REF_X = 95.047; - private const double REF_Y = 100.000; - private const double REF_Z = 108.883; - - // Conversion from RGB to Lab - protected LabColor RgbToLab (Color c) - { - XYZ xyz = ColorConverter.RgbToXyz (new RGB (c.R, c.G, c.B)); - - // Normalize XYZ values by reference white point - double x = xyz.X / REF_X; - double y = xyz.Y / REF_Y; - double z = xyz.Z / REF_Z; - - // 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 LabColor (l, a, b); - } - - // LabColor class encapsulating L, A, and B values - protected class LabColor (double l, double a, double b) - { - public double L { get; } = l; - public double A { get; } = a; - public double B { get; } = 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 []; - } - - return MedianCut (colors, maxColors); - } - - private List MedianCut (List colors, int maxColors) - { - List> cubes = [colors]; - - // Recursively split color regions - while (cubes.Count < maxColors) - { - bool 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 _) = 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) - { - byte avgR = (byte)cube.Average (c => c.R); - byte avgG = (byte)cube.Average (c => c.G); - byte avgB = (byte)cube.Average (c => c.B); - - return new Color (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 Color (0, 0, 0, 0); // Transparent black (ARGB) - - // The rest of the palette is fire colors - for (int i = 1; i < 37; i++) - { - byte r = (byte)Math.Min (255, i * 7); - byte g = (byte)Math.Min (255, i * 5); - byte b = (byte)Math.Min (255, i * 2); - _palette [i] = new Color (r, g, b); // Full opacity - } - } - - public void InitializeFire () - { - // Set the bottom row to full intensity (simulate the base of the fire). - for (int x = 0; x < _width; x++) - { - _firePixels [x, _height - 1] = _palette [36]; // Max intensity fire. - } - - // Set the rest of the pixels to black (transparent). - for (int y = 0; y < _height - 1; y++) - { - for (int x = 0; x < _width; x++) - { - _firePixels [x, y] = _palette [0]; // Transparent black - } - } - } - - public void AdvanceFrame () - { - // Process every pixel except the bottom row - for (int x = 0; x < _width; x++) - { - for (int y = 1; y < _height; y++) // Skip the last row (which is always max intensity) - { - int dstY = y - 1; - - // Spread fire upwards with randomness - int decay = _random.Next (0, 2); - int dstX = x + _random.Next (-1, 2); - - if (dstX < 0 || dstX >= _width) // Prevent out of bounds - { - dstX = x; - } - - // Get the fire color from below and reduce its intensity - Color srcColor = _firePixels [x, y]; - int intensity = Array.IndexOf (_palette, srcColor) - decay; - - if (intensity < 0) - { - intensity = 0; - } - - _firePixels [dstX, dstY] = _palette [intensity]; - } - } - } - - public Color [,] GetFirePixels () { return _firePixels; } -} \ No newline at end of file diff --git a/Examples/UICatalog/Scenarios/Images/CIE76ColorDistance.cs b/Examples/UICatalog/Scenarios/Images/CIE76ColorDistance.cs new file mode 100644 index 0000000000..8be4cfe193 --- /dev/null +++ b/Examples/UICatalog/Scenarios/Images/CIE76ColorDistance.cs @@ -0,0 +1,13 @@ +namespace UICatalog.Scenarios; + +internal class CIE76ColorDistance : LabColorDistance +{ + /// + public override double CalculateDistance (Color c1, Color c2) + { + LabColor lab1 = RgbToLab (c1); + LabColor lab2 = RgbToLab (c2); + + return Math.Sqrt (Math.Pow (lab1.L - lab2.L, 2) + Math.Pow (lab1.A - lab2.A, 2) + Math.Pow (lab1.B - lab2.B, 2)); + } +} diff --git a/Examples/UICatalog/Scenarios/Images/ConstPalette.cs b/Examples/UICatalog/Scenarios/Images/ConstPalette.cs new file mode 100644 index 0000000000..fdeca19a3f --- /dev/null +++ b/Examples/UICatalog/Scenarios/Images/ConstPalette.cs @@ -0,0 +1,12 @@ +namespace UICatalog.Scenarios; + +internal class ConstPalette (Color [] palette) : IStaticPaletteBuilder +{ + private readonly List _palette = palette.ToList (); + + /// + public List BuildPalette (List colors, int maxColors) => BuildPalette (maxColors); + + /// + public List BuildPalette (int maxColors) => [.. _palette.Take (maxColors)]; +} diff --git a/Examples/UICatalog/Scenarios/Images/DoomFire.cs b/Examples/UICatalog/Scenarios/Images/DoomFire.cs new file mode 100644 index 0000000000..60108ac37c --- /dev/null +++ b/Examples/UICatalog/Scenarios/Images/DoomFire.cs @@ -0,0 +1,82 @@ +namespace UICatalog.Scenarios; + +internal class DoomFire +{ + private static Color [] _palette; + + public DoomFire (int width, int height) + { + _width = width; + _height = height; + _firePixels = new Color [width, height]; + InitializePalette (); + InitializeFire (); + } + + private readonly int _width; + private readonly int _height; + private readonly Color [,] _firePixels; + private readonly Random _random = new (); + + public Color [] Palette => _palette; + + public void AdvanceFrame () + { + for (var x = 0; x < _width; x++) + { + for (var y = 1; y < _height; y++) + { + int dstY = y - 1; + int decay = _random.Next (0, 2); + int dstX = x + _random.Next (-1, 2); + + if (dstX < 0 || dstX >= _width) + { + dstX = x; + } + + Color srcColor = _firePixels [x, y]; + int intensity = Array.IndexOf (_palette, srcColor) - decay; + + if (intensity < 0) + { + intensity = 0; + } + + _firePixels [dstX, dstY] = _palette [intensity]; + } + } + } + + public Color [,] GetFirePixels () => _firePixels; + + public void InitializeFire () + { + for (var x = 0; x < _width; x++) + { + _firePixels [x, _height - 1] = _palette [36]; + } + + for (var y = 0; y < _height - 1; y++) + { + for (var x = 0; x < _width; x++) + { + _firePixels [x, y] = _palette [0]; + } + } + } + + private void InitializePalette () + { + _palette = new Color [37]; + _palette [0] = new Color (0, 0, 0, 0); + + 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 Color (r, g, b); + } + } +} diff --git a/Examples/UICatalog/Scenarios/Images/Images.cs b/Examples/UICatalog/Scenarios/Images/Images.cs new file mode 100644 index 0000000000..77c3320fa5 --- /dev/null +++ b/Examples/UICatalog/Scenarios/Images/Images.cs @@ -0,0 +1,472 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +// ReSharper disable AccessToDisposedClosure + +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("Images", "Demonstration of how to render an image with/without true color support.")] +[ScenarioCategory ("Colors")] +[ScenarioCategory ("Drawing")] +public class Images : Scenario +{ + private ImageView _basicImageView; + private Image _fullResImage; + 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; + + private View _sixelSettings; + private View _tabSixel; + + /// + /// The view into which the currently opened sixel image is bounded + /// + private ImageView _sixelImageView; + + private DoomFire _fire; + private SixelEncoder _fireEncoder; + private SixelToRender _fireSixel; + private int _fireFrameCounter; + private bool _isDisposed; + private OptionSelector _osPaletteBuilder; + private OptionSelector _osDistanceAlgorithm; + private NumericUpDown _popularityThreshold; + + // Start by assuming no support — updated from driver-level detection. + private SixelSupportResult _sixelSupportResult = new (); + private CheckBox _cbSupportsSixel; + private IApplication _app; + private Size _winSize; + + public override void Main () + { + ConfigurationManager.Enable (ConfigLocations.All); + using IApplication app = Application.Create (); + app.Init (); + _app = app; + + _win = new Window { Title = $"{Application.GetDefaultKey (Command.Quit)} to Quit - Scenario: {GetName ()}" }; + + bool canTrueColor = app.Driver?.SupportsTrueColor ?? false; + + View tabBasic = new () { Title = "_Cell-based", Width = Dim.Fill (), Height = Dim.Fill () }; + _tabSixel = new View { Title = "_Sixel-based", Width = Dim.Fill (), Height = Dim.Fill () }; + + CheckBox cbSupportsTrueColor = new () + { + Y = 0, Value = canTrueColor ? CheckState.Checked : CheckState.UnChecked, CanFocus = false, Text = "Driver supports true color" + }; + _win.Add (cbSupportsTrueColor); + + _cbSupportsSixel = new CheckBox { X = Pos.Right (cbSupportsTrueColor) + 2, Y = 1, Value = CheckState.UnChecked, Text = "Supports Sixel" }; + + _cbSupportsSixel.ValueChanging += (_, e) => + { + _sixelSupportResult.IsSupported = e.NewValue == CheckState.Checked; + _tabSixel.Enabled = _sixelSupportResult.IsSupported; + }; + + _win.Add (_cbSupportsSixel); + + CheckBox cbUseBackgroundRendering = new () + { + X = Pos.Right (_cbSupportsSixel) + 2, + Y = Pos.Top (_cbSupportsSixel), + Value = CheckState.Checked, + Text = "Async rendering" + }; + cbUseBackgroundRendering.ValueChanging += (_, evt) => SetBackgroundRendering (evt.NewValue == CheckState.Checked); + _win.Add (cbUseBackgroundRendering); + + CheckBox cbUseTrueColor = new () + { + X = Pos.Right (cbSupportsTrueColor) + 2, + Y = 0, + Value = !Driver.Force16Colors ? CheckState.Checked : CheckState.UnChecked, + Enabled = canTrueColor, + Text = "Use true color" + }; + cbUseTrueColor.ValueChanging += (_, evt) => Driver.Force16Colors = evt.NewValue == CheckState.UnChecked; + _win.Add (cbUseTrueColor); + + Button btnOpenImage = new () { Y = Pos.Bottom (cbUseTrueColor), Text = "_Open Image" }; + _win.Add (btnOpenImage); + + Button btnStartFire = new () { X = Pos.Right (btnOpenImage), Y = Pos.Bottom (cbUseTrueColor), Text = "Start _Fire" }; + btnStartFire.Accepting += BtnStartFireOnAccept; + _win.Add (btnStartFire); + + Tabs tabs = new () { Y = Pos.Bottom (btnOpenImage), Width = Dim.Fill (), Height = Dim.Fill () }; + BuildBasicTab (tabBasic); + BuildSixelTab (_tabSixel); + tabs.Add (tabBasic, _tabSixel); + + LoadDefaultImage (); + + btnOpenImage.Accepting += OpenImage; + + _win.Add (_cbSupportsSixel); + _win.Add (tabs); + + _win.SubViewsLaidOut += Win_SubViewsLaidOut; + _win.Initialized += (_, _) => UpdateSixelSupportState (app.Driver?.SixelSupport); + app.Driver!.SixelSupportChanged += Driver_SixelSupportChanged; + + try + { + app.Run (_win); + } + finally + { + app.Driver!.SixelSupportChanged -= Driver_SixelSupportChanged; + _win.Dispose (); + } + } + + /// + protected override void Dispose (bool disposing) + { + base.Dispose (disposing); + _fullResImage?.Dispose (); + _isDisposed = true; + } + + private bool AdvanceFireTimerCallback () + { + _fire.AdvanceFrame (); + _fireFrameCounter++; + + // Control frame rate by adjusting this. Lower number means more FPS. + if (_fireFrameCounter % 2 != 0 || _isDisposed) + { + return !_isDisposed; + } + + Color [,] bmp = _fire.GetFirePixels (); + string sixelFireData = _fireEncoder.EncodeSixel (bmp); + + if (_fireSixel == null) + { + _fireSixel = new SixelToRender { SixelData = sixelFireData, ScreenPosition = GetFireScreenPosition (), Id = "fireSixel", AlwaysRender = true }; + + _app.Driver?.GetOutput ().GetSixels ().Enqueue (_fireSixel); + } + else + { + _fireSixel.SixelData = sixelFireData; + _fireSixel.ScreenPosition = GetFireScreenPosition (); + } + + _win.SetNeedsDraw (); + + return !_isDisposed; + } + + private void BtnStartFireOnAccept (object sender, CommandEventArgs e) + { + if (_fire != null) + { + return; + } + + if (!_sixelSupportResult.SupportsTransparency) + { + if (MessageBox.Query (_app!, + "Transparency Not Supported", + "It looks like your terminal does not support transparent sixel backgrounds. Do you want to try anyway?", + "Yes", + "No") + != 0) + { + return; + } + } + + _winSize = _win.Viewport.Size; + + GenerateSixelFire (true); + } + + private void BuildBasicTab (View tabBasic) + { + _basicImageView = new ImageView + { + BorderStyle = LineStyle.Dotted, + Width = 30, + Height = 10, + CanFocus = true, + Arrangement = ViewArrangement.Resizable, + UseSixel = false, + UseBackgroundRendering = true + }; + + tabBasic.Add (_basicImageView); + } + + private void BuildSixelTab (View tabSixel) + { + _sixelSettings = new View { X = Pos.AnchorEnd (), Width = Dim.Auto (), Height = Dim.Auto (), CanFocus = true }; + + _sixelImageView = new ImageView + { + CanFocus = true, + Width = 30, + Height = 10, + BorderStyle = LineStyle.Dotted, + Arrangement = ViewArrangement.Resizable, + UseSixel = true, + UseBackgroundRendering = true + }; + + Label lblPxX = new () { Y = 0, Text = "Pixels per Col:" }; + _pxX = new NumericUpDown { X = Pos.Right (lblPxX), Y = 0, Value = GetDefaultFirePixelsPerColumn () }; + + Label lblPxY = new () { X = lblPxX.X, Y = Pos.Bottom (_pxX), Text = "Pixels per Row:" }; + _pxY = new NumericUpDown { X = Pos.Right (lblPxY), Y = Pos.Bottom (_pxX), Value = GetDefaultFirePixelsPerRow () }; + + Label paletteLabel = new () { Text = "Palette Building Algorithm", Width = Dim.Auto (), Y = Pos.Bottom (_pxY) + 1 }; + _osPaletteBuilder = new OptionSelector { Labels = ["Popularity", "Median Cut"], Y = Pos.Bottom (paletteLabel), Value = 1 }; + + _popularityThreshold = new NumericUpDown { X = Pos.Right (_osPaletteBuilder) + 1, Y = Pos.Top (_osPaletteBuilder), Value = 8 }; + + Label lblPopThreshold = new () { Text = "(threshold)", X = Pos.Right (_popularityThreshold), Y = Pos.Top (_popularityThreshold) }; + + Label distanceLabel = new () { Text = "Color Distance Algorithm", Width = Dim.Auto (), Y = Pos.Bottom (_osPaletteBuilder) + 1 }; + _osDistanceAlgorithm = new OptionSelector { Labels = ["Euclidian", "CIE76"], Y = Pos.Bottom (distanceLabel) }; + + _sixelSettings.Add (lblPxX); + _sixelSettings.Add (_pxX); + _sixelSettings.Add (lblPxY); + _sixelSettings.Add (_pxY); + _sixelSettings.Add (paletteLabel); + _sixelSettings.Add (_osPaletteBuilder); + _sixelSettings.Add (distanceLabel); + _sixelSettings.Add (_osDistanceAlgorithm); + _sixelSettings.Add (_popularityThreshold); + _sixelSettings.Add (lblPopThreshold); + + tabSixel.Add (_sixelImageView, _sixelSettings); + } + + private void SetBackgroundRendering (bool enabled) + { + if (_basicImageView is { }) + { + _basicImageView.UseBackgroundRendering = enabled; + } + + if (_sixelImageView is { }) + { + _sixelImageView.UseBackgroundRendering = enabled; + } + } + + private void Driver_SixelSupportChanged (object sender, ValueChangedEventArgs e) => UpdateSixelSupportState (e.NewValue); + + private void GenerateSixelFire (bool addTimeout) + { + int pixelsPerColumn = Math.Max (1, _pxX.Value); + int pixelsPerRow = Math.Max (1, _pxY.Value); + Size fireCellSize = GetFireTargetCellSize (); + _fire = new DoomFire (fireCellSize.Width * pixelsPerColumn, fireCellSize.Height * pixelsPerRow); + _fireEncoder = new SixelEncoder { AvoidBottomScroll = true }; + _fireEncoder.Quantizer.MaxColors = Math.Min (_fireEncoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); + _fireEncoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (_fire.Palette); + + _fireFrameCounter = 0; + + if (addTimeout) + { + _app?.AddTimeout (TimeSpan.FromMilliseconds (30), AdvanceFireTimerCallback); + } + } + + private Size GetFireTargetCellSize () + { + Size frameSize = _win.Frame.Size; + + return new Size (Math.Max (1, frameSize.Width), Math.Max (1, frameSize.Height)); + } + + private Size GetFireRenderedCellSize () + { + int pixelsPerColumn = Math.Max (1, _pxX.Value); + int pixelsPerRow = Math.Max (1, _pxY.Value); + Size fireCellSize = GetFireTargetCellSize (); + int resolutionWidth = Math.Max (1, _sixelSupportResult.Resolution.Width); + int resolutionHeight = Math.Max (1, _sixelSupportResult.Resolution.Height); + int pixelWidth = fireCellSize.Width * pixelsPerColumn; + int pixelHeight = Math.Max (1, fireCellSize.Height * pixelsPerRow - 6); + + return new Size (Math.Max (1, (pixelWidth + resolutionWidth - 1) / resolutionWidth), + Math.Max (1, (pixelHeight + resolutionHeight - 1) / resolutionHeight)); + } + + private Point GetFireScreenPosition () + { + Rectangle frameScreen = _win.FrameToScreen (); + Size fireCellSize = GetFireRenderedCellSize (); + + return new Point (frameScreen.X, Math.Max (frameScreen.Y, frameScreen.Bottom - fireCellSize.Height)); + } + + private int GetDefaultFirePixelsPerColumn () => Math.Max (1, _sixelSupportResult.Resolution.Width); + + private int GetDefaultFirePixelsPerRow () => Math.Max (1, _sixelSupportResult.Resolution.Height); + + private IColorDistance GetDistanceAlgorithm () + { + switch (_osDistanceAlgorithm.Value) + { + case 0: return new EuclideanColorDistance (); + + case 1: return new CIE76ColorDistance (); + + default: throw new ArgumentOutOfRangeException (); + } + } + + private IPaletteBuilder GetPaletteBuilder () + { + switch (_osPaletteBuilder.Value) + { + case 0: return new PopularityPaletteWithThreshold (GetDistanceAlgorithm (), _popularityThreshold.Value); + + case 1: return new MedianCutPaletteBuilder (GetDistanceAlgorithm ()); + + default: throw new ArgumentOutOfRangeException (); + } + } + + private void LoadDefaultImage () + { + Color [,] image = ImagesTestCard.Create (ImagesTestCard.DEFAULT_WIDTH, ImagesTestCard.DEFAULT_HEIGHT); + _basicImageView.Image = image; + UpdateSixelImage (image); + } + + private void LoadImage (string path, bool showError) + { + Image img; + + try + { + img = Image.Load (File.ReadAllBytes (path)); + } + catch (Exception ex) + { + if (showError) + { + MessageBox.ErrorQuery (_app!, "Could not open file", ex.Message, "Ok"); + } + + return; + } + + _fullResImage?.Dispose (); + _fullResImage = img; + Color [,] image = ConvertToColorArray (img); + _basicImageView.Image = image; + UpdateSixelImage (image); + } + + public static Color [,] ConvertToColorArray (Image image) + { + int width = image.Width; + int height = image.Height; + Color [,] colors = new Color [width, height]; + + for (var x = 0; x < width; x++) + { + for (var y = 0; y < height; y++) + { + Rgba32 pixel = image [x, y]; + colors [x, y] = new Color (pixel.R, pixel.G, pixel.B); + } + } + + return colors; + } + + private void OpenImage (object sender, CommandEventArgs e) + { + OpenDialog ofd = new () { Title = "Open Image", AllowsMultipleSelection = false }; + _app?.Run (ofd); + + 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; + } + + LoadImage (path, true); + _app?.LayoutAndDraw (); + } + + private void UpdateSixelImage (Color [,] image) + { + SixelEncoder encoder = new (); + encoder.Quantizer.MaxColors = Math.Min (encoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); + encoder.Quantizer.PaletteBuildingAlgorithm = GetPaletteBuilder (); + encoder.Quantizer.DistanceAlgorithm = GetDistanceAlgorithm (); + _sixelImageView.SixelEncoder = encoder; + _sixelImageView.Image = image; + } + + private void UpdateSixelSupportState (SixelSupportResult newResult) + { + newResult ??= new SixelSupportResult (); + _sixelSupportResult = newResult; + + _cbSupportsSixel.Value = newResult.IsSupported ? CheckState.Checked : CheckState.UnChecked; + _pxX.Value = GetDefaultFirePixelsPerColumn (); + _pxY.Value = GetDefaultFirePixelsPerRow (); + + if (_sixelImageView?.Image is { } image) + { + UpdateSixelImage (image); + } + } + + private void Win_SubViewsLaidOut (object sender, LayoutEventArgs e) + { + Size currentSize = _win.Viewport.Size; + + if (_winSize == currentSize) + { + return; + } + + _winSize = currentSize; + + if (_fireSixel is { }) + { + GenerateSixelFire (false); + } + } +} diff --git a/Examples/UICatalog/Scenarios/Images/ImagesTestCard.cs b/Examples/UICatalog/Scenarios/Images/ImagesTestCard.cs new file mode 100644 index 0000000000..8719520519 --- /dev/null +++ b/Examples/UICatalog/Scenarios/Images/ImagesTestCard.cs @@ -0,0 +1,252 @@ +namespace UICatalog.Scenarios; + +internal static class ImagesTestCard +{ + public const int DEFAULT_HEIGHT = 320; + public const int DEFAULT_WIDTH = 512; + + public static Color [,] Create (int width, int height) + { + Color [,] image = new Color [width, height]; + Fill (image, new Color (5, 7, 12)); + DrawGrid (image, 32, new Color (22, 31, 45)); + DrawColorBars (image, new Rectangle (0, 0, width, Math.Max (48, height / 5))); + DrawGrayscaleRamp (image, new Rectangle (32, height - 48, width - 64, 20)); + DrawLine (image, 0, 0, width - 1, height - 1, new Color (255, 255, 255)); + DrawLine (image, width - 1, 0, 0, height - 1, new Color (255, 255, 255)); + DrawCircle (image, width / 2, height / 2, Math.Min (width, height) / 3, new Color (255, 255, 255)); + DrawCircle (image, width / 2, height / 2, Math.Min (width, height) / 5, new Color (96, 200, 255)); + DrawTerminalBadge (image, new Rectangle (width / 2 - 116, height / 2 - 52, 232, 104)); + + return image; + } + + private static void DrawCircle (Color [,] image, int centerX, int centerY, int radius, Color color) + { + int x = radius; + var y = 0; + int decision = 1 - x; + + while (y <= x) + { + PutCirclePoints (image, centerX, centerY, x, y, color); + y++; + + if (decision <= 0) + { + decision += 2 * y + 1; + + continue; + } + + x--; + decision += 2 * (y - x) + 1; + } + } + + private static void DrawColorBars (Color [,] image, Rectangle rect) + { + Color [] bars = + [ + new (255, 255, 255), new (255, 255), new (0, 255, 255), new (0, 255), new (255, 0, 255), new (255, 0), new (0, 0, 255), new (16, 16, 16) + ]; + + int barWidth = Math.Max (1, rect.Width / bars.Length); + + for (var i = 0; i < bars.Length; i++) + { + int left = rect.X + i * barWidth; + int right = i == bars.Length - 1 ? rect.Right : Math.Min (rect.Right, left + barWidth); + FillRectangle (image, rect with { X = left, Width = right - left }, bars [i]); + } + } + + private static void DrawGlyph (Color [,] image, int x, int y, string [] glyph, int scale, Color color) + { + for (var row = 0; row < glyph.Length; row++) + { + for (var col = 0; col < glyph [row].Length; col++) + { + if (glyph [row] [col] != '1') + { + continue; + } + + FillRectangle (image, new Rectangle (x + col * scale, y + row * scale, scale, scale), color); + } + } + } + + private static void DrawGrayscaleRamp (Color [,] image, Rectangle rect) + { + for (var x = 0; x < rect.Width; x++) + { + var value = (byte)(x * 255 / Math.Max (1, rect.Width - 1)); + + for (var y = 0; y < rect.Height; y++) + { + PutPixel (image, rect.X + x, rect.Y + y, new Color (value, value, value)); + } + } + + DrawRectangle (image, rect, new Color (255, 255, 255)); + } + + private static void DrawGrid (Color [,] image, int spacing, Color color) + { + int width = image.GetLength (0); + int height = image.GetLength (1); + + for (var x = 0; x < width; x += spacing) + { + DrawLine (image, x, 0, x, height - 1, color); + } + + for (var y = 0; y < height; y += spacing) + { + DrawLine (image, 0, y, width - 1, y, color); + } + } + + private static void DrawLine (Color [,] image, int x0, int y0, int x1, int y1, Color color) + { + int dx = Math.Abs (x1 - x0); + int sx = x0 < x1 ? 1 : -1; + int dy = -Math.Abs (y1 - y0); + int sy = y0 < y1 ? 1 : -1; + int error = dx + dy; + + while (true) + { + PutPixel (image, x0, y0, color); + + if (x0 == x1 && y0 == y1) + { + return; + } + + int e2 = 2 * error; + + if (e2 >= dy) + { + error += dy; + x0 += sx; + } + + if (e2 > dx) + { + continue; + } + error += dx; + y0 += sy; + } + } + + private static void DrawRectangle (Color [,] image, Rectangle rect, Color color) + { + DrawLine (image, rect.X, rect.Y, rect.Right - 1, rect.Y, color); + DrawLine (image, rect.X, rect.Bottom - 1, rect.Right - 1, rect.Bottom - 1, color); + DrawLine (image, rect.X, rect.Y, rect.X, rect.Bottom - 1, color); + DrawLine (image, rect.Right - 1, rect.Y, rect.Right - 1, rect.Bottom - 1, color); + } + + private static void DrawTerminalBadge (Color [,] image, Rectangle rect) + { + FillRectangle (image, rect, new Color (9, 14, 20)); + DrawRectangle (image, rect, new Color (96, 200, 255)); + DrawRectangle (image, new Rectangle (rect.X + 3, rect.Y + 3, rect.Width - 6, rect.Height - 6), new Color (31, 65, 74)); + FillRectangle (image, new Rectangle (rect.X + 12, rect.Y + 12, rect.Width - 24, rect.Height - 24), new Color (4, 18, 13)); + + var tuiScale = 6; + var promptScale = 4; + int tuiWidth = GetTextWidth ("TUI", tuiScale); + int promptWidth = GetTextWidth (">_", promptScale); + DrawText (image, rect.X + (rect.Width - tuiWidth) / 2, rect.Y + 22, "TUI", tuiScale, new Color (72, 255, 142)); + DrawText (image, rect.X + (rect.Width - promptWidth) / 2, rect.Y + 68, ">_", promptScale, new Color (72, 255, 142)); + } + + private static void DrawText (Color [,] image, int x, int y, string text, int scale, Color color) + { + int cursor = x; + + foreach (char ch in text) + { + string [] glyph = GetGlyph (ch); + DrawGlyph (image, cursor, y, glyph, scale, color); + cursor += (glyph [0].Length + 1) * scale; + } + } + + private static void Fill (Color [,] image, Color color) + { + int width = image.GetLength (0); + int height = image.GetLength (1); + + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + image [x, y] = color; + } + } + } + + private static void FillRectangle (Color [,] image, Rectangle rect, Color color) + { + Rectangle bounds = new (0, 0, image.GetLength (0), image.GetLength (1)); + Rectangle clipped = Rectangle.Intersect (rect, bounds); + + for (int y = clipped.Y; y < clipped.Bottom; y++) + { + for (int x = clipped.X; x < clipped.Right; x++) + { + image [x, y] = color; + } + } + } + + private static string [] GetGlyph (char ch) => + ch switch + { + 'T' => ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + 'U' => ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], + 'I' => ["111", "010", "010", "010", "010", "010", "111"], + '>' => ["10000", "01000", "00100", "00010", "00100", "01000", "10000"], + '_' => ["00000", "00000", "00000", "00000", "00000", "00000", "11111"], + _ => ["0"] + }; + + private static int GetTextWidth (string text, int scale) + { + var width = 0; + + foreach (char ch in text) + { + width += (GetGlyph (ch) [0].Length + 1) * scale; + } + + return Math.Max (0, width - scale); + } + + private static void PutCirclePoints (Color [,] image, int centerX, int centerY, int x, int y, Color color) + { + PutPixel (image, centerX + x, centerY + y, color); + PutPixel (image, centerX + y, centerY + x, color); + PutPixel (image, centerX - y, centerY + x, color); + PutPixel (image, centerX - x, centerY + y, color); + PutPixel (image, centerX - x, centerY - y, color); + PutPixel (image, centerX - y, centerY - x, color); + PutPixel (image, centerX + y, centerY - x, color); + PutPixel (image, centerX + x, centerY - y, color); + } + + private static void PutPixel (Color [,] image, int x, int y, Color color) + { + if (x < 0 || y < 0 || x >= image.GetLength (0) || y >= image.GetLength (1)) + { + return; + } + + image [x, y] = color; + } +} diff --git a/Examples/UICatalog/Scenarios/Images/LabColorDistance.cs b/Examples/UICatalog/Scenarios/Images/LabColorDistance.cs new file mode 100644 index 0000000000..2b4f15ed51 --- /dev/null +++ b/Examples/UICatalog/Scenarios/Images/LabColorDistance.cs @@ -0,0 +1,39 @@ +using ColorHelper; + +namespace UICatalog.Scenarios; + +internal abstract class LabColorDistance : IColorDistance +{ + private const double REF_X = 95.047; + private const double REF_Y = 100.000; + private const double REF_Z = 108.883; + + /// + public abstract double CalculateDistance (Color c1, Color c2); + + protected LabColor RgbToLab (Color c) + { + XYZ xyz = ColorConverter.RgbToXyz (new RGB (c.R, c.G, c.B)); + + double x = xyz.X / REF_X; + double y = xyz.Y / REF_Y; + double z = xyz.Z / REF_Z; + + 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; + + double l = 116.0 * y - 16.0; + double a = 500.0 * (x - y); + double b = 200.0 * (y - z); + + return new LabColor (l, a, b); + } + + protected class LabColor (double l, double a, double b) + { + public double A { get; } = a; + public double B { get; } = b; + public double L { get; } = l; + } +} diff --git a/Examples/UICatalog/Scenarios/Images/MedianCutPaletteBuilder.cs b/Examples/UICatalog/Scenarios/Images/MedianCutPaletteBuilder.cs new file mode 100644 index 0000000000..780edd601e --- /dev/null +++ b/Examples/UICatalog/Scenarios/Images/MedianCutPaletteBuilder.cs @@ -0,0 +1,138 @@ +namespace UICatalog.Scenarios; + +internal class MedianCutPaletteBuilder : IPaletteBuilder +{ + public MedianCutPaletteBuilder (IColorDistance colorDistance) => _colorDistance = colorDistance; + + private readonly IColorDistance _colorDistance; + + public List BuildPalette (List colors, int maxColors) + { + if (colors == null || colors.Count == 0 || maxColors <= 0) + { + return []; + } + + return MedianCut (colors, maxColors); + } + + 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 Color (avgR, avgG, avgB); + } + + 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 bool IsSingleColorCube (List cube) + { + Color firstColor = cube.First (); + + return cube.All (c => c.R == firstColor.R && c.G == firstColor.G && c.B == firstColor.B); + } + + private List MedianCut (List colors, int maxColors) + { + List> cubes = [colors]; + + while (cubes.Count < maxColors) + { + bool added = false; + cubes.Sort ((a, b) => Volume (a).CompareTo (Volume (b))); + + List largestCube = cubes.Last (); + cubes.RemoveAt (cubes.Count - 1); + + if (IsSingleColorCube (largestCube)) + { + 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; + } + + if (!added) + { + break; + } + } + + return cubes.Select (AverageColor).Distinct ().ToList (); + } + + private (List, List) SplitCube (List cube) + { + (int component, int _) = FindLargestRange (cube); + + 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 Volume (List cube) + { + if (cube == null || cube.Count == 0) + { + 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); + } +} diff --git a/Examples/UICatalog/Scenarios/Mandelbrot.cs b/Examples/UICatalog/Scenarios/Mandelbrot.cs index 5337ad8a8d..c83ca2921e 100644 --- a/Examples/UICatalog/Scenarios/Mandelbrot.cs +++ b/Examples/UICatalog/Scenarios/Mandelbrot.cs @@ -1,3 +1,5 @@ +#nullable enable + namespace UICatalog.Scenarios; [ScenarioMetadata ("Mandelbrot", "Displays a sixel-rendered Mandelbrot set with live settings and an overlay dialog.")] @@ -30,7 +32,7 @@ public class Mandelbrot : Scenario private NumericUpDown _span = null!; private Label _status = null!; private Window _window = null!; - private bool _forcingSixelSupport; + private SixelSupportResult _sixelSupportResult = new (); public override void Main () { @@ -69,6 +71,7 @@ public override void Main () _window.Initialized += (_, _) => { + UpdateSixelSupport (_app.Driver?.SixelSupport); RenderMandelbrot (); StartFireProgress (); @@ -226,6 +229,7 @@ private void BuildZoomButtons () Width = 1, Height = 1, Text = "-", + CanFocus = false, NoDecorations = true, NoPadding = true, ShadowStyle = null @@ -238,13 +242,23 @@ private void BuildZoomButtons () Width = 1, Height = 1, Text = "+", + CanFocus = false, NoDecorations = true, NoPadding = true, ShadowStyle = null }; - zoomOut.Accepted += (_, _) => Zoom (ZOOM_OUT_FACTOR); - zoomIn.Accepted += (_, _) => Zoom (ZOOM_IN_FACTOR); + zoomOut.Accepted += (_, _) => + { + Zoom (ZOOM_OUT_FACTOR); + _mandelbrotView.SetFocus (); + }; + + zoomIn.Accepted += (_, _) => + { + Zoom (ZOOM_IN_FACTOR); + _mandelbrotView.SetFocus (); + }; _mandelbrotView.Add (zoomOut, zoomIn); } @@ -275,63 +289,21 @@ private void CenterMandelbrot (Point position) double span = _span.Value; double spanY = span * viewport.Height / viewport.Width; - _centerX.Value += (((double)position.X + 0.5d) / viewport.Width - 0.5d) * span; - _centerY.Value += (((double)position.Y + 0.5d) / viewport.Height - 0.5d) * spanY; + _centerX.Value += ((position.X + 0.5d) / viewport.Width - 0.5d) * span; + _centerY.Value += ((position.Y + 0.5d) / viewport.Height - 0.5d) * spanY; } - private SixelSupportResult EnsureSixelSupportForDemo () + private void OnSixelSupportChanged (object? sender, ValueChangedEventArgs args) { - SixelSupportResult source = _app.Driver?.SixelSupport ?? new (); - - if (source.IsSupported) - { - return source; - } - - SixelSupportResult forced = new () - { - IsSupported = true, - Resolution = source.Resolution, - MaxPaletteColors = source.MaxPaletteColors, - SupportsTransparency = source.SupportsTransparency - }; - - if (_app.Driver is DriverImpl driver) - { - driver.SetSixelSupport (forced); - } - - return forced; - } - - private void OnSixelSupportChanged (object sender, ValueChangedEventArgs args) - { - if (_forcingSixelSupport || args.NewValue is { IsSupported: true }) - { - return; - } - - try - { - _forcingSixelSupport = true; - EnsureSixelSupportForDemo (); - } - finally - { - _forcingSixelSupport = false; - } - + UpdateSixelSupport (args.NewValue); RenderMandelbrot (); } + private void UpdateSixelSupport (SixelSupportResult? support) => _sixelSupportResult = support ?? new SixelSupportResult (); + private void RenderMandelbrot () { - if (_mandelbrotView is null) - { - return; - } - - SixelSupportResult support = EnsureSixelSupportForDemo (); + SixelSupportResult support = _app.Driver?.SixelSupport ?? _sixelSupportResult; Rectangle viewport = _mandelbrotView.Viewport; int imageColumns = Math.Max (0, viewport.Width); int imageRows = Math.Max (0, viewport.Height); @@ -412,27 +384,16 @@ private void Zoom (double spanMultiplier) private sealed class MandelbrotImageView : ImageView { - public MandelbrotImageView () - { - MouseBindings.Add (MouseFlags.LeftButtonDoubleClicked, Command.Accept); - } - public event Action? CenterRequested; public void Render (int pixelWidth, int pixelHeight, double centerX, double centerY, double span, int maxIterations, SixelSupportResult support) { - SixelEncoder = new (); - SixelEncoder.Quantizer.MaxColors = Math.Min (support.MaxPaletteColors, 64); + SixelEncoder = new SixelEncoder { Quantizer = { MaxColors = Math.Min (support.MaxPaletteColors, 64) } }; Image = CreateMandelbrotPixels (pixelWidth, pixelHeight, centerX, centerY, span, maxIterations); } - protected override bool OnAccepting (CommandEventArgs args) + protected override bool CenterOnViewportPoint (Point position) { - if (args.Context?.Binding is not MouseBinding { MouseEvent: { IsDoubleClicked: true, Position: { } position } }) - { - return base.OnAccepting (args); - } - CenterRequested?.Invoke (position); return true; @@ -489,7 +450,7 @@ private static Color GetMandelbrotColor (int iterations, int maxIterations) var green = (byte)(15 * (1 - t) * (1 - t) * t * t * 255); var blue = (byte)(8.5 * (1 - t) * (1 - t) * (1 - t) * t * 255); - return new (red, green, blue); + return new Color (red, green, blue); } } } diff --git a/Terminal.Gui/Drawing/Color/ColorQuantizer.cs b/Terminal.Gui/Drawing/Color/ColorQuantizer.cs index b0930b0901..aff13a9bf3 100644 --- a/Terminal.Gui/Drawing/Color/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/Color/ColorQuantizer.cs @@ -1,5 +1,3 @@ -using System.Collections.Concurrent; - namespace Terminal.Gui.Drawing; /// @@ -13,6 +11,9 @@ public class ColorQuantizer /// public IReadOnlyCollection Palette { get; private set; } = new List (); + private Color [] _palette = []; + private Dictionary _paletteIndex = []; + /// /// Gets or sets the maximum number of colors to put into the . /// Defaults to 256 (the maximum for sixel images). @@ -30,7 +31,7 @@ public class ColorQuantizer /// public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (), 8); - private readonly ConcurrentDictionary _nearestColorCache = new (); + private readonly Dictionary _nearestColorCache = []; /// /// Builds a of colors that most represent the colors used in image. @@ -39,6 +40,13 @@ public class ColorQuantizer /// public void BuildPalette (Color [,] pixels) { + if (PaletteBuildingAlgorithm is IStaticPaletteBuilder staticPaletteBuilder) + { + SetPalette (staticPaletteBuilder.BuildPalette (MaxColors)); + + return; + } + List allColors = []; int width = pixels.GetLength (0); int height = pixels.GetLength (1); @@ -52,7 +60,20 @@ public void BuildPalette (Color [,] pixels) } _nearestColorCache.Clear (); - Palette = PaletteBuildingAlgorithm.BuildPalette (allColors, MaxColors); + SetPalette (PaletteBuildingAlgorithm.BuildPalette (allColors, MaxColors)); + } + + private void SetPalette (List palette) + { + _nearestColorCache.Clear (); + Palette = palette; + _palette = [.. palette]; + _paletteIndex = new (_palette.Length); + + for (int i = 0; i < _palette.Length; i++) + { + _paletteIndex.TryAdd (_palette [i], i); + } } /// @@ -63,19 +84,25 @@ public void BuildPalette (Color [,] pixels) /// public int GetNearestColor (Color toTranslate) { + if (_paletteIndex.TryGetValue (toTranslate, out int exactIndex)) + { + return exactIndex; + } + if (_nearestColorCache.TryGetValue (toTranslate, out int cachedAnswer)) { return cachedAnswer; } - // Simple nearest color matching based on DistanceAlgorithm - var minDistance = double.MaxValue; - var nearestIndex = 0; + double minDistance = double.MaxValue; + int nearestIndex = 0; - for (var index = 0; index < Palette.Count; index++) + for (int index = 0; index < _palette.Length; index++) { - Color color = Palette.ElementAt (index); - double distance = DistanceAlgorithm.CalculateDistance (color, toTranslate); + Color color = _palette [index]; + double distance = DistanceAlgorithm is EuclideanColorDistance + ? CalculateEuclideanDistanceSquared (color, toTranslate) + : DistanceAlgorithm.CalculateDistance (color, toTranslate); if (distance < minDistance) { @@ -88,4 +115,13 @@ public int GetNearestColor (Color toTranslate) return nearestIndex; } + + private static double CalculateEuclideanDistanceSquared (Color c1, Color c2) + { + int rDiff = c1.R - c2.R; + int gDiff = c1.G - c2.G; + int bDiff = c1.B - c2.B; + + return rDiff * rDiff + gDiff * gDiff + bDiff * bDiff; + } } diff --git a/Terminal.Gui/Drawing/Quant/IStaticPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/IStaticPaletteBuilder.cs new file mode 100644 index 0000000000..e810dd88f3 --- /dev/null +++ b/Terminal.Gui/Drawing/Quant/IStaticPaletteBuilder.cs @@ -0,0 +1,14 @@ +namespace Terminal.Gui.Drawing; + +/// +/// Builds a palette without needing to inspect every source pixel. +/// +public interface IStaticPaletteBuilder : IPaletteBuilder +{ + /// + /// Builds the static palette, limited to colors. + /// + /// The maximum number of colors that should be represented. + /// The static palette colors. + List BuildPalette (int maxColors); +} diff --git a/Terminal.Gui/Drawing/Sixel/SixelEncoder.cs b/Terminal.Gui/Drawing/Sixel/SixelEncoder.cs index efb8501d68..7ad94fa8fe 100644 --- a/Terminal.Gui/Drawing/Sixel/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/Sixel/SixelEncoder.cs @@ -126,13 +126,13 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi { Color color = pixels [x, startY + row]; - int colorIndex = Quantizer.GetNearestColor (color); - if (color.A == 0) // Skip fully transparent pixels { continue; } + int colorIndex = Quantizer.GetNearestColor (color); + if (slots [colorIndex] == -1) { targets.Add (new ()); diff --git a/Terminal.Gui/Input/Command.cs b/Terminal.Gui/Input/Command.cs index 16e5dbd439..31e35f016b 100644 --- a/Terminal.Gui/Input/Command.cs +++ b/Terminal.Gui/Input/Command.cs @@ -102,6 +102,9 @@ public enum Command /// Moves to the start (e.g. the top or home). Start, + /// Moves or resets to the home position. + Home, + /// Moves to the end (e.g. the bottom). End, @@ -357,6 +360,15 @@ public enum Command /// Edit, + /// Centers the current item or viewport. + Center, + + /// Zooms in. + ZoomIn, + + /// Zooms out. + ZoomOut, + #endregion #region Multi-Caret Commands diff --git a/Terminal.Gui/Views/ImageView.cs b/Terminal.Gui/Views/ImageView.cs index 67a21a3ab1..4dde2111e6 100644 --- a/Terminal.Gui/Views/ImageView.cs +++ b/Terminal.Gui/Views/ImageView.cs @@ -26,13 +26,102 @@ namespace Terminal.Gui.Views; /// public class ImageView : View, IDesignable { + private const double MAX_ZOOM_LEVEL = 64d; + private const double MIN_ZOOM_LEVEL = 1d; + private const double ZOOM_FACTOR = 1.25d; + private const int DEFAULT_MAX_SIXEL_PALETTE_COLORS = 64; + private Color [,]? _image; private Color [,]? _scaledImage; private Size? _scaledImageCellSize; + private RenderKey? _scaledImageRenderKey; + private double _centerX = 0.5d; + private double _centerY = 0.5d; + private RenderKey? _backgroundRenderKey; + private bool _backgroundRenderRunning; + private bool _disposed; + private Point? _lastDragPosition; + private int _imageVersion; + private SixelEncoder? _sixelEncoder; + private RenderRequest? _queuedRenderRequest; + private bool _usesDefaultSixelEncoder; + private double _zoomLevel = MIN_ZOOM_LEVEL; private string RasterImageId => $"ImageView_{GetHashCode ()}"; // Cell-based rendering cache private readonly Dictionary _attributeCache = new (); + private readonly Lock _renderLock = new (); + + private readonly Label _renderingOverlay = new () + { + Text = "Rendering...", + X = Pos.Center (), + Y = Pos.Center (), + CanFocus = false, + Visible = false + }; + + /// + /// Gets or sets the default key bindings for . + /// + public new static Dictionary? DefaultKeyBindings { get; set; } = new () + { + [Command.ScrollLeft] = Bind.All (Key.CursorLeft), + [Command.ScrollRight] = Bind.All (Key.CursorRight), + [Command.ScrollUp] = Bind.All (Key.CursorUp), + [Command.ScrollDown] = Bind.All (Key.CursorDown), + [Command.Home] = Bind.All (Key.Home), + [Command.ZoomOut] = Bind.All (Key.PageUp), + [Command.ZoomIn] = Bind.All (Key.PageDown) + }; + + /// + /// Gets or sets the default mouse bindings for . + /// + public new static Dictionary? DefaultMouseBindings { get; set; } = new () + { + [Command.ZoomIn] = BindMouse.All (MouseFlags.WheeledUp), + [Command.ZoomOut] = BindMouse.All (MouseFlags.WheeledDown), + [Command.Center] = BindMouse.All (MouseFlags.LeftButtonDoubleClicked) + }; + + /// Initializes a new instance of the class. + public ImageView () + { + CanFocus = true; + MousePositionTracking = true; + + AddCommand (Command.ScrollLeft, () => ScrollFromCommand (-1, 0)); + AddCommand (Command.ScrollRight, () => ScrollFromCommand (1, 0)); + AddCommand (Command.ScrollUp, () => ScrollFromCommand (0, -1)); + AddCommand (Command.ScrollDown, () => ScrollFromCommand (0, 1)); + AddCommand (Command.Home, () => ResetView ()); + AddCommand (Command.ZoomIn, context => ZoomFromCommand (context, _zoomLevel * ZOOM_FACTOR)); + AddCommand (Command.ZoomOut, context => ZoomFromCommand (context, _zoomLevel / ZOOM_FACTOR)); + AddCommand (Command.PageDown, context => ZoomFromCommand (context, _zoomLevel * ZOOM_FACTOR)); + AddCommand (Command.PageUp, context => ZoomFromCommand (context, _zoomLevel / ZOOM_FACTOR)); + AddCommand (Command.Center, CenterFromCommand); + + ApplyKeyBindings (DefaultKeyBindings, View.DefaultKeyBindings); + ApplyMouseBindings (DefaultMouseBindings, View.DefaultMouseBindings); + ReplacePanAndZoomBindings (); + Add (_renderingOverlay); + } + + private void ReplacePanAndZoomBindings () + { + KeyBindings.ReplaceCommands (Key.CursorLeft, Command.ScrollLeft); + KeyBindings.ReplaceCommands (Key.CursorRight, Command.ScrollRight); + KeyBindings.ReplaceCommands (Key.CursorUp, Command.ScrollUp); + KeyBindings.ReplaceCommands (Key.CursorDown, Command.ScrollDown); + KeyBindings.ReplaceCommands (Key.Home, Command.Home); + KeyBindings.ReplaceCommands (Key.PageUp, Command.ZoomOut); + KeyBindings.ReplaceCommands (Key.PageDown, Command.ZoomIn); + + MouseBindings.ReplaceCommands (MouseFlags.WheeledUp, Command.ZoomIn); + MouseBindings.ReplaceCommands (MouseFlags.WheeledDown, Command.ZoomOut); + MouseBindings.ReplaceCommands (MouseFlags.LeftButtonDoubleClicked, Command.Center); + } /// /// Gets or sets the pixel data to display. The array is indexed as [x, y] where @@ -49,17 +138,18 @@ public class ImageView : View, IDesignable set { _image = value; - _scaledImage = null; - _scaledImageCellSize = null; - _attributeCache.Clear (); + _imageVersion++; + ClampCenter (); if (_image is null) { + InvalidateScaledImage (true); App?.Driver?.GetOutputBuffer ().RemoveRasterImage (RasterImageId); + + return; } - UpdateSixelData (); - SetNeedsDraw (); + InvalidateScaledImage (); } } @@ -75,6 +165,91 @@ public class ImageView : View, IDesignable /// public bool UseSixel { get; set; } = true; + /// + /// Gets or sets whether ImageView scales image renders on a background thread. + /// + /// + /// The default is . When enabled, ImageView keeps the last completed render visible while + /// a newer render is being prepared and shows a centered "Rendering..." overlay until the background render is + /// ready. + /// + public bool UseBackgroundRendering + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + + if (!field) + { + SetRenderingOverlayVisible (false); + } + + InvalidateScaledImage (); + } + } + + /// + /// Gets or sets the maximum number of colors to use when encoding ImageView sixel output. + /// + /// + /// The default is 64 colors to keep interactive ImageView redraws responsive. The effective + /// encoder palette is also limited by and by + /// the configured MaxColors. + /// + public int MaxSixelPaletteColors + { + get; + set + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException (nameof (value), @"Maximum sixel palette colors must be greater than zero."); + } + + if (field == value) + { + return; + } + + field = value; + InvalidateScaledImage (); + } + } = DEFAULT_MAX_SIXEL_PALETTE_COLORS; + + /// + /// Gets or sets whether sixel rendering may upscale the visible image region above its source pixel size. + /// + /// + /// The default is so the image fills the Viewport at 1. + /// Set to to avoid encoding more pixels than the source image provides during fit-to-view + /// rendering. + /// + public bool AllowSixelUpscaling + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + InvalidateScaledImage (); + } + } = true; + + /// + /// Gets or sets the zoom level. A value of 1 fits the image in the viewport. + /// + public double ZoomLevel { get => _zoomLevel; set => SetZoomLevel (value, null); } + /// /// Gets or sets the used to encode images as sixel data. /// When , a default encoder is created lazily on first use. @@ -82,10 +257,26 @@ public class ImageView : View, IDesignable /// /// Set this to provide a custom encoder with specific quantizer settings, palette building /// algorithms, or color distance algorithms. The encoder's - /// MaxColors will be clamped to the - /// terminal's during rendering. + /// MaxColors will be clamped during + /// rendering. The default encoder is capped by and the + /// terminal's . Custom encoders are capped + /// only by the terminal's . /// - public SixelEncoder? SixelEncoder { get; set; } + public SixelEncoder? SixelEncoder + { + get => _sixelEncoder; + set + { + if (ReferenceEquals (_sixelEncoder, value)) + { + return; + } + + _sixelEncoder = value; + _usesDefaultSixelEncoder = false; + InvalidateScaledImage (); + } + } /// /// Gets whether the current rendering mode is using sixel. @@ -105,7 +296,7 @@ public class ImageView : View, IDesignable /// The screen coordinates of the Viewport in pixels. public Rectangle ViewportToScreenInPixels () { - SixelSupportResult? support = (App?.Driver?.SixelSupport) ?? throw new InvalidOperationException (@"No sixel support available."); + SixelSupportResult support = App?.Driver?.SixelSupport ?? throw new InvalidOperationException (@"No sixel support available."); int pixelsPerCellX = support.Resolution.Width; int pixelsPerCellY = support.Resolution.Height; @@ -139,7 +330,7 @@ public Size FitImageInViewportCells (Size imageSizeInPixels) // Account for the terminal cell aspect ratio double cellAspectRatio = App?.Driver?.SixelSupport is { } support ? (double)support.Resolution.Height / support.Resolution.Width : 2.0; - Size imageSize = new (imageSizeInPixels.Width, (int)(imageSizeInPixels.Height / cellAspectRatio)); + Size imageSize = imageSizeInPixels with { Height = (int)(imageSizeInPixels.Height / cellAspectRatio) }; // Calculate aspect-ratio-preserving size double widthScale = (double)Viewport.Width / imageSize.Width; @@ -201,23 +392,25 @@ protected override bool OnDrawingContent (DrawContext? context) { DrawSixel (); - if (_scaledImageCellSize is { } cellSize) + if (_scaledImageCellSize is not { } cellSize) { - Rectangle viewport = ViewportToScreen (); - Rectangle dirtyRect = new (viewport.X, viewport.Y, Math.Min (viewport.Width, cellSize.Width), Math.Min (viewport.Height, cellSize.Height)); - context?.AddDrawnRectangle (dirtyRect); + return true; } + Rectangle viewport = ViewportToScreen (); + Rectangle dirtyRect = viewport with { Width = Math.Min (viewport.Width, cellSize.Width), Height = Math.Min (viewport.Height, cellSize.Height) }; + context?.AddDrawnRectangle (dirtyRect); } else { DrawCellBased (); - if (_scaledImageCellSize is { } cellSize) + if (_scaledImageCellSize is not { } cellSize) { - Rectangle viewport = ViewportToScreen (); - Rectangle dirtyRect = new (viewport.X, viewport.Y, Math.Min (viewport.Width, cellSize.Width), Math.Min (viewport.Height, cellSize.Height)); - context?.AddDrawnRectangle (dirtyRect); + return true; } + Rectangle viewport = ViewportToScreen (); + Rectangle dirtyRect = viewport with { Width = Math.Min (viewport.Width, cellSize.Width), Height = Math.Min (viewport.Height, cellSize.Height) }; + context?.AddDrawnRectangle (dirtyRect); } return true; @@ -227,8 +420,7 @@ protected override bool OnDrawingContent (DrawContext? context) protected override void OnFrameChanged (in Rectangle frame) { base.OnFrameChanged (frame); - UpdateSixelData (); - SetNeedsDraw (); + InvalidateScaledImage (); } /// @@ -237,29 +429,26 @@ protected override void OnFrameChanged (in Rectangle frame) /// private void DrawCellBased () { - if (_image is null) + RenderRequest? request = CreateRenderRequest (false); + + if (request is null) { return; } - if (_scaledImage is null) - { - _scaledImage = GetScaledImage (_image, Viewport.Width, Viewport.Height); - - if (_scaledImage is null) - { - return; - } + EnsureScaledImage (request); - _scaledImageCellSize = new Size (_scaledImage.GetLength (0), _scaledImage.GetLength (1)); + if (_scaledImage is null || _scaledImageCellSize is null) + { + return; } int drawWidth = Math.Min (Viewport.Width, _scaledImage.GetLength (0)); int drawHeight = Math.Min (Viewport.Height, _scaledImage.GetLength (1)); - for (int y = 0; y < drawHeight; y++) + for (var y = 0; y < drawHeight; y++) { - for (int x = 0; x < drawWidth; x++) + for (var x = 0; x < drawWidth; x++) { Color pixel = _scaledImage [x, y]; @@ -280,24 +469,23 @@ private void DrawCellBased () /// private void DrawSixel () { - if (App?.Driver is not { } driver) + RenderRequest? request = CreateRenderRequest (true); + + if (request is null) { return; } - if (_scaledImage is null || _scaledImageCellSize is null) - { - UpdateSixelData (); - } + EnsureScaledImage (request); - if (_scaledImage is null || _scaledImageCellSize is null || SixelEncoder is null) + if (_scaledImage is null || _scaledImageCellSize is null || SixelEncoder is null || App?.Driver is not { } driver) { return; } Rectangle viewport = ViewportToScreen (); Size cellSize = _scaledImageCellSize.Value; - Rectangle destinationCells = new (viewport.X, viewport.Y, Math.Min (viewport.Width, cellSize.Width), Math.Min (viewport.Height, cellSize.Height)); + Rectangle destinationCells = viewport with { Width = Math.Min (viewport.Width, cellSize.Width), Height = Math.Min (viewport.Height, cellSize.Height) }; if (destinationCells.Width <= 0 || destinationCells.Height <= 0) { @@ -316,76 +504,527 @@ private void DrawSixel () driver.GetOutputBuffer ().AddRasterImage (command); } - private void UpdateSixelData () + private bool? CenterFromCommand (ICommandContext? context) => context?.Binding is MouseBinding { MouseEvent.Position: { } position } && CenterOnViewportPoint (position); + + /// + /// Centers the image on the specified viewport-relative point. + /// + /// The viewport-relative point. + /// if the view was centered; otherwise, . + protected virtual bool CenterOnViewportPoint (Point position) + { + if (!TryMapViewportPointToSourceCenter (position, out double centerX, out double centerY)) + { + return false; + } + + return SetCenter (centerX, centerY); + } + + private void ClampCenter () { - if (!IsUsingSixel || App?.Driver?.SixelSupport is not { } support || _image is null) + if (_image is null || _zoomLevel <= MIN_ZOOM_LEVEL) { + _centerX = 0.5d; + _centerY = 0.5d; + return; } - // Use caller-provided encoder or create a default one - SixelEncoder ??= new (); + double halfVisible = 0.5d / _zoomLevel; + _centerX = Math.Clamp (_centerX, halfVisible, 1d - halfVisible); + _centerY = Math.Clamp (_centerY, halfVisible, 1d - halfVisible); + } - // Clamp MaxColors regardless of whether the encoder was provided - SixelEncoder.Quantizer.MaxColors = Math.Min (SixelEncoder.Quantizer.MaxColors, support.MaxPaletteColors); + private RectangleF GetVisibleSourceRectangle () + { + if (_image is null) + { + return RectangleF.Empty; + } - Rectangle targetRect = ViewportToScreenInPixels (); + int srcWidth = _image.GetLength (0); + int srcHeight = _image.GetLength (1); + double visibleWidth = srcWidth / _zoomLevel; + double visibleHeight = srcHeight / _zoomLevel; + double x = _centerX * srcWidth - visibleWidth / 2d; + double y = _centerY * srcHeight - visibleHeight / 2d; + x = Math.Clamp (x, 0d, Math.Max (0d, srcWidth - visibleWidth)); + y = Math.Clamp (y, 0d, Math.Max (0d, srcHeight - visibleHeight)); - // Scale the image to the target pixel size while maintaining aspect ratio - _scaledImage = GetScaledImage (_image, targetRect.Width, targetRect.Height); - _scaledImageCellSize = FitImageInViewportCells (new Size (_image.GetLength (0), _image.GetLength (1))); + return new RectangleF ((float)x, (float)y, (float)visibleWidth, (float)visibleHeight); + } + + private static Size GetSixelCellSize (Size pixelSize, Size resolution) + { + int cellWidth = Math.Max (1, (int)Math.Ceiling ((double)pixelSize.Width / Math.Max (1, resolution.Width))); + int cellHeight = Math.Max (1, (int)Math.Ceiling ((double)pixelSize.Height / Math.Max (1, resolution.Height))); - if (_scaledImage is null) + return new Size (cellWidth, cellHeight); + } + + private void InvalidateScaledImage (bool clearCurrentRender = false) + { + if (clearCurrentRender || !UseBackgroundRendering || _scaledImage is null) { + _scaledImage = null; + _scaledImageCellSize = null; + _attributeCache.Clear (); + } + + _scaledImageRenderKey = null; + SetNeedsDraw (); + } + + private bool IsRenderCacheCurrent (RenderKey key) => _scaledImage is { } && _scaledImageCellSize is { } && _scaledImageRenderKey == key; + + private void EnsureScaledImage (RenderRequest request) + { + if (IsRenderCacheCurrent (request.Key)) + { + SetRenderingOverlayVisible (false); + return; } + if (UseBackgroundRendering && App?.Initialized == true) + { + ScheduleBackgroundRender (request); + + return; + } + + ApplyRenderResult (RenderScaledImage (request)); + SetRenderingOverlayVisible (false); } - /// - /// Scales the source image to the specified target dimensions using nearest-neighbor - /// interpolation while maintaining aspect ratio. - /// - /// The source image to scale. - /// The target width in the appropriate unit (cells or pixels). - /// The target height in the appropriate unit (cells or pixels). - /// The scaled image, or if the source image is null or the target size is invalid. - private static Color [,]? GetScaledImage (Color [,] image, int targetWidth, int targetHeight) + private RenderRequest? CreateRenderRequest (bool useSixel) { - if (image is null || targetWidth <= 0 || targetHeight <= 0) + if (_image is null) { return null; } - int srcWidth = image.GetLength (0); - int srcHeight = image.GetLength (1); + RectangleF visibleSource = GetVisibleSourceRectangle (); - if (srcWidth == 0 || srcHeight == 0) + if (visibleSource.Width <= 0 || visibleSource.Height <= 0) { return null; } - // Calculate aspect-ratio-preserving size - double widthScale = (double)targetWidth / srcWidth; - double heightScale = (double)targetHeight / srcHeight; - double scale = Math.Min (widthScale, heightScale); + Size? resolution = null; + int? maxColors = null; + Size targetSize; + var allowUpscale = true; + var preserveAspectRatio = false; + + if (useSixel) + { + if (!IsUsingSixel || App?.Driver?.SixelSupport is not { } support) + { + return null; + } + + SixelEncoder encoder = PrepareSixelEncoder (support); + Rectangle targetRect = ViewportToScreenInPixels (); + targetSize = targetRect.Size; + resolution = support.Resolution; + maxColors = encoder.Quantizer.MaxColors; + allowUpscale = AllowSixelUpscaling || _zoomLevel > MIN_ZOOM_LEVEL; + preserveAspectRatio = true; + } + else + { + targetSize = new Size (Viewport.Width, Viewport.Height); + } + + if (targetSize.Width <= 0 || targetSize.Height <= 0) + { + return null; + } + + RenderKey key = new (_imageVersion, + useSixel, + targetSize, + resolution, + maxColors, + _centerX, + _centerY, + _zoomLevel, + allowUpscale, + preserveAspectRatio); + + return new RenderRequest (key, _image, visibleSource, targetSize, resolution); + } + + private SixelEncoder PrepareSixelEncoder (SixelSupportResult support) + { + SixelEncoder? encoder = SixelEncoder; + + if (encoder is null) + { + encoder = new SixelEncoder (); + _sixelEncoder = encoder; + _usesDefaultSixelEncoder = true; + } + + int maxColors = _usesDefaultSixelEncoder + ? Math.Min (MaxSixelPaletteColors, support.MaxPaletteColors) + : Math.Min (encoder.Quantizer.MaxColors, support.MaxPaletteColors); + encoder.Quantizer.MaxColors = maxColors; + + return encoder; + } + + private void ApplyRenderResult (RenderResult result) + { + _scaledImage = result.ScaledImage; + _scaledImageCellSize = result.CellSize; + _scaledImageRenderKey = result.Key; + _attributeCache.Clear (); + } + + private static RenderResult RenderScaledImage (RenderRequest request) + { + Color [,] scaledImage = ScaleVisibleImage (request.Source, + request.VisibleSource, + request.TargetSize, + request.Key.AllowUpscale, + request.Key.PreserveAspectRatio); + + Size cellSize = request.Key.UseSixel && request.Resolution is { } resolution + ? GetSixelCellSize (new Size (scaledImage.GetLength (0), scaledImage.GetLength (1)), resolution) + : new Size (scaledImage.GetLength (0), scaledImage.GetLength (1)); + + return new RenderResult (request.Key, scaledImage, cellSize); + } + + private void ScheduleBackgroundRender (RenderRequest request) + { + if (_disposed) + { + return; + } + + SetRenderingOverlayVisible (true); + + RenderRequest? requestToStart; + + lock (_renderLock) + { + if (_backgroundRenderRunning) + { + if (_backgroundRenderKey == request.Key || _queuedRenderRequest?.Key == request.Key) + { + return; + } + + _queuedRenderRequest = request; + + return; + } + + _backgroundRenderRunning = true; + requestToStart = request; + } + + StartBackgroundRender (requestToStart); + } + + private void StartBackgroundRender (RenderRequest request) + { + lock (_renderLock) + { + _backgroundRenderKey = request.Key; + } + + Task task = Task.Run (() => RenderScaledImage (request)); + task.ContinueWith (CompleteBackgroundRender, TaskScheduler.Default); + } - int newWidth = Math.Max (1, (int)(srcWidth * scale)); - int newHeight = Math.Max (1, (int)(srcHeight * scale)); + private void CompleteBackgroundRender (Task completed) + { + AggregateException? exception = completed.Exception; + + if (_disposed) + { + _ = exception; + + return; + } + + IApplication? app = App; + + if (app is null || !app.Initialized) + { + _ = exception; + + return; + } + + try + { + if (exception is { }) + { + app.Invoke (() => FailBackgroundRender (exception.GetBaseException ())); + + return; + } + + app.Invoke (() => CompleteBackgroundRenderOnMainThread (completed.Result)); + } + catch (NotInitializedException) + { + _ = exception; + } + catch (ObjectDisposedException) + { + _ = exception; + } + } + + private void CompleteBackgroundRenderOnMainThread (RenderResult result) + { + if (_disposed) + { + return; + } + + RenderRequest? currentRequest = CreateRenderRequest (result.Key.UseSixel); + + if (UseBackgroundRendering && currentRequest?.Key == result.Key) + { + ApplyRenderResult (result); + } + + StartNextQueuedRenderOrFinish (result.Key); + SetNeedsDraw (); + } + + private void FailBackgroundRender (Exception exception) + { + StartNextQueuedRenderOrFinish (_backgroundRenderKey); + + throw new InvalidOperationException ("Background ImageView rendering failed.", exception); + } + + private void StartNextQueuedRenderOrFinish (RenderKey? completedKey) + { + RenderRequest? nextRequest = null; + + lock (_renderLock) + { + if (_queuedRenderRequest is { } queuedRequest && queuedRequest.Key != completedKey) + { + nextRequest = queuedRequest; + _queuedRenderRequest = null; + } + else + { + _queuedRenderRequest = null; + _backgroundRenderKey = null; + _backgroundRenderRunning = false; + } + } + + if (nextRequest is { }) + { + StartBackgroundRender (nextRequest); + + return; + } + + SetRenderingOverlayVisible (false); + } + + private void SetRenderingOverlayVisible (bool visible) + { + if (_renderingOverlay.Visible == visible) + { + return; + } + + _renderingOverlay.Visible = visible; + } + + private bool PanByCells (int deltaX, int deltaY) + { + if (_image is null || _zoomLevel <= MIN_ZOOM_LEVEL || Viewport.Width <= 0 || Viewport.Height <= 0) + { + return false; + } + + RectangleF visibleSource = GetVisibleSourceRectangle (); + double centerX = _centerX + deltaX * visibleSource.Width / Viewport.Width / _image.GetLength (0); + double centerY = _centerY + deltaY * visibleSource.Height / Viewport.Height / _image.GetLength (1); + + return SetCenter (centerX, centerY); + } + + private bool ScrollFromCommand (int deltaX, int deltaY) + { + PanByCells (deltaX, deltaY); + + return true; + } + + private bool ResetView () + { + if (_zoomLevel == MIN_ZOOM_LEVEL && Math.Abs (_centerX - 0.5d) < double.Epsilon && Math.Abs (_centerY - 0.5d) < double.Epsilon) + { + return false; + } + + _zoomLevel = MIN_ZOOM_LEVEL; + _centerX = 0.5d; + _centerY = 0.5d; + InvalidateScaledImage (); + + return true; + } + + private bool SetCenter (double centerX, double centerY) + { + double previousCenterX = _centerX; + double previousCenterY = _centerY; + _centerX = centerX; + _centerY = centerY; + ClampCenter (); + + if (Math.Abs (previousCenterX - _centerX) < double.Epsilon && Math.Abs (previousCenterY - _centerY) < double.Epsilon) + { + return false; + } + + InvalidateScaledImage (); + + return true; + } + + private bool SetZoomLevel (double zoomLevel, Point? anchor) + { + if (double.IsNaN (zoomLevel) || double.IsInfinity (zoomLevel)) + { + throw new ArgumentOutOfRangeException (nameof (zoomLevel), @"Zoom level must be a finite number."); + } + + double previousZoomLevel = _zoomLevel; + double clampedZoomLevel = Math.Clamp (zoomLevel, MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL); - // We can start with the input image, maybe it's the correct size already - Color [,] scaledImage = image; + if (Math.Abs (previousZoomLevel - clampedZoomLevel) < double.Epsilon) + { + return false; + } - // Nearest-neighbor scale - if (scaledImage.GetLength (0) != newWidth || scaledImage.GetLength (1) != newHeight) + if (anchor is { } position && TryMapViewportPointToSourceCenter (position, out double sourceX, out double sourceY)) { - scaledImage = new Color [newWidth, newHeight]; - ScaleNearestNeighbor (image, scaledImage); + _zoomLevel = clampedZoomLevel; + SetCenterForAnchor (position, sourceX, sourceY); } + else + { + _zoomLevel = clampedZoomLevel; + ClampCenter (); + } + + InvalidateScaledImage (); + + return true; + } + + private void SetCenterForAnchor (Point position, double sourceX, double sourceY) + { + if (_image is null || Viewport.Width <= 0 || Viewport.Height <= 0) + { + return; + } + + double visibleWidth = 1d / _zoomLevel; + double visibleHeight = 1d / _zoomLevel; + _centerX = sourceX - ((position.X + 0.5d) / Viewport.Width - 0.5d) * visibleWidth; + _centerY = sourceY - ((position.Y + 0.5d) / Viewport.Height - 0.5d) * visibleHeight; + ClampCenter (); + } + + private static Color [,] ScaleVisibleImage (Color [,] source, RectangleF visibleSource, Size targetSize, bool allowUpscale, bool preserveAspectRatio) + { + int newWidth; + int newHeight; + + if (preserveAspectRatio) + { + double widthScale = targetSize.Width / visibleSource.Width; + double heightScale = targetSize.Height / visibleSource.Height; + double scale = Math.Min (widthScale, heightScale); + + if (!allowUpscale) + { + scale = Math.Min (scale, 1d); + } + + newWidth = Math.Max (1, (int)(visibleSource.Width * scale)); + newHeight = Math.Max (1, (int)(visibleSource.Height * scale)); + } + else + { + newWidth = targetSize.Width; + newHeight = targetSize.Height; + } + + Color [,] scaledImage = new Color [newWidth, newHeight]; + ScaleVisibleNearestNeighbor (source, scaledImage, visibleSource); return scaledImage; } + private static void ScaleVisibleNearestNeighbor (Color [,] source, Color [,] destination, RectangleF visibleSource) + { + int srcWidth = source.GetLength (0); + int srcHeight = source.GetLength (1); + int newWidth = destination.GetLength (0); + int newHeight = destination.GetLength (1); + + for (var y = 0; y < newHeight; y++) + { + int srcY = Math.Clamp ((int)(visibleSource.Y + y * visibleSource.Height / newHeight), 0, srcHeight - 1); + + for (var x = 0; x < newWidth; x++) + { + int srcX = Math.Clamp ((int)(visibleSource.X + x * visibleSource.Width / newWidth), 0, srcWidth - 1); + destination [x, y] = source [srcX, srcY]; + } + } + } + + private bool TryMapViewportPointToSourceCenter (Point position, out double centerX, out double centerY) + { + centerX = 0.5d; + centerY = 0.5d; + + if (_image is null || Viewport.Width <= 0 || Viewport.Height <= 0) + { + return false; + } + + if (position.X < 0 || position.Y < 0 || position.X >= Viewport.Width || position.Y >= Viewport.Height) + { + return false; + } + + RectangleF visibleSource = GetVisibleSourceRectangle (); + centerX = (visibleSource.X + (position.X + 0.5d) * visibleSource.Width / Viewport.Width) / _image.GetLength (0); + centerY = (visibleSource.Y + (position.Y + 0.5d) * visibleSource.Height / Viewport.Height) / _image.GetLength (1); + + return true; + } + + private bool ZoomFromCommand (ICommandContext? context, double zoomLevel) + { + Point? anchor = context?.Binding is MouseBinding { MouseEvent.Position: { } position } ? position : null; + + return SetZoomLevel (zoomLevel, anchor); + } + /// /// Scales a Color[,] pixel array into a destination array using nearest-neighbor interpolation. /// @@ -398,11 +1037,11 @@ public static void ScaleNearestNeighbor (Color [,] source, Color [,] destination int newWidth = destination.GetLength (0); int newHeight = destination.GetLength (1); - for (int y = 0; y < newHeight; y++) + for (var y = 0; y < newHeight; y++) { int srcY = Math.Min (y * srcHeight / newHeight, srcHeight - 1); - for (int x = 0; x < newWidth; x++) + for (var x = 0; x < newWidth; x++) { int srcX = Math.Min (x * srcWidth / newWidth, srcWidth - 1); destination [x, y] = source [srcX, srcY]; @@ -410,32 +1049,112 @@ public static void ScaleNearestNeighbor (Color [,] source, Color [,] destination } } + /// + protected override bool OnMouseEvent (Mouse mouse) + { + if (HandleDrag (mouse)) + { + return true; + } + + return base.OnMouseEvent (mouse); + } + + private bool HandleDrag (Mouse mouse) + { + if (mouse.Position is not { } position) + { + return false; + } + + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed) && !mouse.Flags.FastHasFlags (MouseFlags.PositionReport)) + { + _lastDragPosition = position; + App?.Mouse.GrabMouse (this); + + return true; + } + + if (mouse.Flags == (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport) && _lastDragPosition is { } lastDragPosition) + { + bool panned = PanByCells (lastDragPosition.X - position.X, lastDragPosition.Y - position.Y); + _lastDragPosition = position; + + return panned; + } + + if (!mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) + { + return false; + } + _lastDragPosition = null; + + if (App is { } && App.Mouse.IsGrabbed (this)) + { + App.Mouse.UngrabMouse (); + } + + return true; + + } + /// protected override void Dispose (bool disposing) { if (disposing) { + _disposed = true; + + lock (_renderLock) + { + _queuedRenderRequest = null; + _backgroundRenderKey = null; + _backgroundRenderRunning = false; + } + App?.Driver?.GetOutputBuffer ().RemoveRasterImage (RasterImageId); } base.Dispose (disposing); } + private readonly record struct RenderKey (int ImageVersion, + bool UseSixel, + Size TargetSize, + Size? Resolution, + int? MaxColors, + double CenterX, + double CenterY, + double ZoomLevel, + bool AllowUpscale, + bool PreserveAspectRatio); + + private sealed class RenderRequest (RenderKey key, Color [,] source, RectangleF visibleSource, Size targetSize, Size? resolution) + { + public RenderKey Key { get; } = key; + public Color [,] Source { get; } = source; + public RectangleF VisibleSource { get; } = visibleSource; + public Size TargetSize { get; } = targetSize; + public Size? Resolution { get; } = resolution; + } + + private readonly record struct RenderResult (RenderKey Key, Color [,] ScaledImage, Size CellSize); + /// bool IDesignable.EnableForDesign () { // Create a simple gradient test image for the designer - int width = 20; - int height = 10; + var width = 20; + var height = 10; Color [,] testImage = new Color [width, height]; - for (int y = 0; y < height; y++) + for (var y = 0; y < height; y++) { - for (int x = 0; x < width; x++) + for (var x = 0; x < width; x++) { - byte r = (byte)(x * 255 / Math.Max (1, width - 1)); - byte g = (byte)(y * 255 / Math.Max (1, height - 1)); - byte b = (byte)(128); + var r = (byte)(x * 255 / Math.Max (1, width - 1)); + var g = (byte)(y * 255 / Math.Max (1, height - 1)); + var b = (byte)128; testImage [x, y] = new Color (r, g, b); } } @@ -444,4 +1163,4 @@ bool IDesignable.EnableForDesign () return true; } -} \ No newline at end of file +} diff --git a/Terminal.Gui/Views/ProgressBar.cs b/Terminal.Gui/Views/ProgressBar.cs index ae28d6a4b2..161d747d14 100644 --- a/Terminal.Gui/Views/ProgressBar.cs +++ b/Terminal.Gui/Views/ProgressBar.cs @@ -218,7 +218,7 @@ public override string Text get => string.IsNullOrEmpty (base.Text) ? $"{_fraction * 100:F0}%" : base.Text; set { - if (ProgressBarStyle is ProgressBarStyle.MarqueeBlocks or ProgressBarStyle.MarqueeContinuous) + if (ProgressBarStyle is ProgressBarStyle.MarqueeBlocks or ProgressBarStyle.MarqueeContinuous or ProgressBarStyle.Fire) { base.Text = value; } @@ -292,9 +292,9 @@ protected override bool OnDrawingContent (DrawContext? context) if (_fraction > .5) { - attr = new (GetAttributeForRole (VisualRole.Normal).Background, - GetAttributeForRole (VisualRole.Normal).Foreground, - GetAttributeForRole (VisualRole.Normal).Style); + attr = new Attribute (GetAttributeForRole (VisualRole.Normal).Background, + GetAttributeForRole (VisualRole.Normal).Foreground, + GetAttributeForRole (VisualRole.Normal).Style); } tf.Draw (Driver, @@ -440,8 +440,7 @@ private bool DrawFireProgress (int filledCells) private static SixelEncoder CreateFireEncoder (SixelSupportResult support) { - SixelEncoder encoder = new (); - encoder.Quantizer.MaxColors = Math.Min (support.MaxPaletteColors, _firePalette.Length); + SixelEncoder encoder = new () { Quantizer = { MaxColors = Math.Min (support.MaxPaletteColors, _firePalette.Length) } }; return encoder; } @@ -511,5 +510,5 @@ public bool EnableForDesign () } /// - public string? GetDemoKeyStrokes () => "wait:2000"; + public string GetDemoKeyStrokes () => "wait:2000"; } diff --git a/Tests/UnitTestsParallelizable/Drawing/ColorQuantizerTests.cs b/Tests/UnitTestsParallelizable/Drawing/ColorQuantizerTests.cs new file mode 100644 index 0000000000..dcfb194f24 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drawing/ColorQuantizerTests.cs @@ -0,0 +1,58 @@ +namespace DrawingTests; + +public class ColorQuantizerTests +{ + // Copilot - GPT-5.5 + [Fact] + public void GetNearestColor_ReturnsExactPaletteColorIndex () + { + ColorQuantizer quantizer = new (); + Color [,] pixels = new Color [2, 1]; + pixels [0, 0] = new (255, 0); + pixels [1, 0] = new (0, 255); + + quantizer.BuildPalette (pixels); + + Assert.Equal (0, quantizer.GetNearestColor (pixels [0, 0])); + Assert.Equal (1, quantizer.GetNearestColor (pixels [1, 0])); + } + + // Copilot - GPT-5.5 + [Fact] + public void GetNearestColor_CachesNearestColorForNonPaletteColor () + { + CountingColorDistance distance = new (); + ColorQuantizer quantizer = new () + { + DistanceAlgorithm = distance + }; + Color [,] pixels = new Color [2, 1]; + pixels [0, 0] = new (255, 0); + pixels [1, 0] = new (0, 255); + + quantizer.BuildPalette (pixels); + + Color nearRed = new (254, 0); + int first = quantizer.GetNearestColor (nearRed); + int callsAfterFirst = distance.CallCount; + int second = quantizer.GetNearestColor (nearRed); + + Assert.Equal (first, second); + Assert.Equal (callsAfterFirst, distance.CallCount); + } + + private sealed class CountingColorDistance : IColorDistance + { + public int CallCount { get; private set; } + + public double CalculateDistance (Color c1, Color c2) + { + CallCount++; + 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/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs b/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs index fd33aea010..e760598749 100644 --- a/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs @@ -16,12 +16,72 @@ public void Defaults_AreExpected () Assert.True (imageView.UseSixel); Assert.Null (imageView.SixelEncoder); Assert.False (imageView.IsUsingSixel); // No driver, so sixel not available + Assert.True (imageView.CanFocus); + Assert.Equal (1d, imageView.ZoomLevel); + Assert.Equal (64, imageView.MaxSixelPaletteColors); + Assert.True (imageView.AllowSixelUpscaling); + Assert.False (imageView.UseBackgroundRendering); + + imageView.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void Defaults_ConfigurePanAndZoomBindings () + { + ImageView imageView = new (); + + Assert.Equal ([Command.ScrollLeft], imageView.KeyBindings.GetCommands (Key.CursorLeft)); + Assert.Equal ([Command.ScrollRight], imageView.KeyBindings.GetCommands (Key.CursorRight)); + Assert.Equal ([Command.ScrollUp], imageView.KeyBindings.GetCommands (Key.CursorUp)); + Assert.Equal ([Command.ScrollDown], imageView.KeyBindings.GetCommands (Key.CursorDown)); + Assert.Equal ([Command.Home], imageView.KeyBindings.GetCommands (Key.Home)); + Assert.Equal ([Command.ZoomOut], imageView.KeyBindings.GetCommands (Key.PageUp)); + Assert.Equal ([Command.ZoomIn], imageView.KeyBindings.GetCommands (Key.PageDown)); + Assert.Equal ([Command.ZoomIn], imageView.MouseBindings.GetCommands (MouseFlags.WheeledUp)); + Assert.Equal ([Command.ZoomOut], imageView.MouseBindings.GetCommands (MouseFlags.WheeledDown)); + Assert.Equal ([Command.Center], imageView.MouseBindings.GetCommands (MouseFlags.LeftButtonDoubleClicked)); imageView.Dispose (); } #endregion Construction and Defaults + #region Background Rendering + + // Copilot - GPT-5.5 + [Fact] + public void BackgroundRendering_WhenEnabled_ShowsRenderingOverlayOnFirstDraw () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + ImageView imageView = new () + { + Width = 2, + Height = 2, + UseBackgroundRendering = true, + UseSixel = false, + Image = CreateCoordinateImage (4, 4) + }; + runnable.Add (imageView); + + Label overlay = Assert.Single (imageView.SubViews.OfType public Color [,]? Pixels { get; set; } + /// + /// Gets or sets pre-encoded sixel data for . + /// + /// + /// This is used only when the full rectangle is visible. Clipped output still + /// encodes the cropped pixels so the emitted sixel dimensions match the clipped cell region. + /// + public string? EncodedSixel { get; set; } + /// /// Gets or sets the screen cells occupied by . /// diff --git a/Terminal.Gui/Views/ImageView.cs b/Terminal.Gui/Views/ImageView.cs deleted file mode 100644 index 4dde2111e6..0000000000 --- a/Terminal.Gui/Views/ImageView.cs +++ /dev/null @@ -1,1166 +0,0 @@ -namespace Terminal.Gui.Views; - -/// -/// Displays an image represented as a 2D array of pixels. -/// Supports two rendering modes: cell-based (one colored space per pixel, works everywhere) -/// and sixel-based (when the terminal supports it). -/// -/// -/// -/// The image data is provided via the property as a Color[,] array -/// where the first dimension is width (x) and the second is height (y). Image loading and -/// decoding from file formats (PNG, JPEG, etc.) is the caller's responsibility — this view -/// has no dependency on any image library. -/// -/// -/// When sixel is available (detected via ) and -/// is , the view will encode the image as -/// sixel escape sequences and render it through the driver's output buffer. Sixel data -/// is only re-sent to the terminal when is true, -/// avoiding redundant rendering of unchanged images. -/// -/// -/// When sixel is not available, the view falls back to cell-based rendering where each -/// terminal cell is colored with the background color of the corresponding pixel. -/// -/// -public class ImageView : View, IDesignable -{ - private const double MAX_ZOOM_LEVEL = 64d; - private const double MIN_ZOOM_LEVEL = 1d; - private const double ZOOM_FACTOR = 1.25d; - private const int DEFAULT_MAX_SIXEL_PALETTE_COLORS = 64; - - private Color [,]? _image; - private Color [,]? _scaledImage; - private Size? _scaledImageCellSize; - private RenderKey? _scaledImageRenderKey; - private double _centerX = 0.5d; - private double _centerY = 0.5d; - private RenderKey? _backgroundRenderKey; - private bool _backgroundRenderRunning; - private bool _disposed; - private Point? _lastDragPosition; - private int _imageVersion; - private SixelEncoder? _sixelEncoder; - private RenderRequest? _queuedRenderRequest; - private bool _usesDefaultSixelEncoder; - private double _zoomLevel = MIN_ZOOM_LEVEL; - private string RasterImageId => $"ImageView_{GetHashCode ()}"; - - // Cell-based rendering cache - private readonly Dictionary _attributeCache = new (); - private readonly Lock _renderLock = new (); - - private readonly Label _renderingOverlay = new () - { - Text = "Rendering...", - X = Pos.Center (), - Y = Pos.Center (), - CanFocus = false, - Visible = false - }; - - /// - /// Gets or sets the default key bindings for . - /// - public new static Dictionary? DefaultKeyBindings { get; set; } = new () - { - [Command.ScrollLeft] = Bind.All (Key.CursorLeft), - [Command.ScrollRight] = Bind.All (Key.CursorRight), - [Command.ScrollUp] = Bind.All (Key.CursorUp), - [Command.ScrollDown] = Bind.All (Key.CursorDown), - [Command.Home] = Bind.All (Key.Home), - [Command.ZoomOut] = Bind.All (Key.PageUp), - [Command.ZoomIn] = Bind.All (Key.PageDown) - }; - - /// - /// Gets or sets the default mouse bindings for . - /// - public new static Dictionary? DefaultMouseBindings { get; set; } = new () - { - [Command.ZoomIn] = BindMouse.All (MouseFlags.WheeledUp), - [Command.ZoomOut] = BindMouse.All (MouseFlags.WheeledDown), - [Command.Center] = BindMouse.All (MouseFlags.LeftButtonDoubleClicked) - }; - - /// Initializes a new instance of the class. - public ImageView () - { - CanFocus = true; - MousePositionTracking = true; - - AddCommand (Command.ScrollLeft, () => ScrollFromCommand (-1, 0)); - AddCommand (Command.ScrollRight, () => ScrollFromCommand (1, 0)); - AddCommand (Command.ScrollUp, () => ScrollFromCommand (0, -1)); - AddCommand (Command.ScrollDown, () => ScrollFromCommand (0, 1)); - AddCommand (Command.Home, () => ResetView ()); - AddCommand (Command.ZoomIn, context => ZoomFromCommand (context, _zoomLevel * ZOOM_FACTOR)); - AddCommand (Command.ZoomOut, context => ZoomFromCommand (context, _zoomLevel / ZOOM_FACTOR)); - AddCommand (Command.PageDown, context => ZoomFromCommand (context, _zoomLevel * ZOOM_FACTOR)); - AddCommand (Command.PageUp, context => ZoomFromCommand (context, _zoomLevel / ZOOM_FACTOR)); - AddCommand (Command.Center, CenterFromCommand); - - ApplyKeyBindings (DefaultKeyBindings, View.DefaultKeyBindings); - ApplyMouseBindings (DefaultMouseBindings, View.DefaultMouseBindings); - ReplacePanAndZoomBindings (); - Add (_renderingOverlay); - } - - private void ReplacePanAndZoomBindings () - { - KeyBindings.ReplaceCommands (Key.CursorLeft, Command.ScrollLeft); - KeyBindings.ReplaceCommands (Key.CursorRight, Command.ScrollRight); - KeyBindings.ReplaceCommands (Key.CursorUp, Command.ScrollUp); - KeyBindings.ReplaceCommands (Key.CursorDown, Command.ScrollDown); - KeyBindings.ReplaceCommands (Key.Home, Command.Home); - KeyBindings.ReplaceCommands (Key.PageUp, Command.ZoomOut); - KeyBindings.ReplaceCommands (Key.PageDown, Command.ZoomIn); - - MouseBindings.ReplaceCommands (MouseFlags.WheeledUp, Command.ZoomIn); - MouseBindings.ReplaceCommands (MouseFlags.WheeledDown, Command.ZoomOut); - MouseBindings.ReplaceCommands (MouseFlags.LeftButtonDoubleClicked, Command.Center); - } - - /// - /// Gets or sets the pixel data to display. The array is indexed as [x, y] where - /// the first dimension is width and the second is height. - /// - /// - /// Setting this property marks the view as needing redraw. The image will be - /// scaled to fit the current while maintaining - /// aspect ratio using nearest-neighbor interpolation. - /// - public Color [,]? Image - { - get => _image; - set - { - _image = value; - _imageVersion++; - ClampCenter (); - - if (_image is null) - { - InvalidateScaledImage (true); - App?.Driver?.GetOutputBuffer ().RemoveRasterImage (RasterImageId); - - return; - } - - InvalidateScaledImage (); - } - } - - /// - /// Gets or sets whether to prefer sixel rendering when the terminal supports it. - /// Default is . - /// - /// - /// When and the terminal supports sixel - /// (per ), the image is rendered using sixel - /// escape sequences for full-resolution display. When , - /// cell-based rendering is always used. - /// - public bool UseSixel { get; set; } = true; - - /// - /// Gets or sets whether ImageView scales image renders on a background thread. - /// - /// - /// The default is . When enabled, ImageView keeps the last completed render visible while - /// a newer render is being prepared and shows a centered "Rendering..." overlay until the background render is - /// ready. - /// - public bool UseBackgroundRendering - { - get; - set - { - if (field == value) - { - return; - } - - field = value; - - if (!field) - { - SetRenderingOverlayVisible (false); - } - - InvalidateScaledImage (); - } - } - - /// - /// Gets or sets the maximum number of colors to use when encoding ImageView sixel output. - /// - /// - /// The default is 64 colors to keep interactive ImageView redraws responsive. The effective - /// encoder palette is also limited by and by - /// the configured MaxColors. - /// - public int MaxSixelPaletteColors - { - get; - set - { - if (value <= 0) - { - throw new ArgumentOutOfRangeException (nameof (value), @"Maximum sixel palette colors must be greater than zero."); - } - - if (field == value) - { - return; - } - - field = value; - InvalidateScaledImage (); - } - } = DEFAULT_MAX_SIXEL_PALETTE_COLORS; - - /// - /// Gets or sets whether sixel rendering may upscale the visible image region above its source pixel size. - /// - /// - /// The default is so the image fills the Viewport at 1. - /// Set to to avoid encoding more pixels than the source image provides during fit-to-view - /// rendering. - /// - public bool AllowSixelUpscaling - { - get; - set - { - if (field == value) - { - return; - } - - field = value; - InvalidateScaledImage (); - } - } = true; - - /// - /// Gets or sets the zoom level. A value of 1 fits the image in the viewport. - /// - public double ZoomLevel { get => _zoomLevel; set => SetZoomLevel (value, null); } - - /// - /// Gets or sets the used to encode images as sixel data. - /// When , a default encoder is created lazily on first use. - /// - /// - /// Set this to provide a custom encoder with specific quantizer settings, palette building - /// algorithms, or color distance algorithms. The encoder's - /// MaxColors will be clamped during - /// rendering. The default encoder is capped by and the - /// terminal's . Custom encoders are capped - /// only by the terminal's . - /// - public SixelEncoder? SixelEncoder - { - get => _sixelEncoder; - set - { - if (ReferenceEquals (_sixelEncoder, value)) - { - return; - } - - _sixelEncoder = value; - _usesDefaultSixelEncoder = false; - InvalidateScaledImage (); - } - } - - /// - /// Gets whether the current rendering mode is using sixel. - /// - public bool IsUsingSixel => UseSixel && App?.Driver?.SixelSupport is { IsSupported: true }; - - /// - /// Converts the Viewport to screen coordinates in pixels. - /// - /// - /// - /// This method accounts for the terminal's cell resolution and the viewport's - /// size, returning the exact pixel dimensions and position required for - /// fully cover the viewport. - /// - /// - /// The screen coordinates of the Viewport in pixels. - public Rectangle ViewportToScreenInPixels () - { - SixelSupportResult support = App?.Driver?.SixelSupport ?? throw new InvalidOperationException (@"No sixel support available."); - - int pixelsPerCellX = support.Resolution.Width; - int pixelsPerCellY = support.Resolution.Height; - Rectangle boundsRect = ViewportToScreen (); - - // Calculate target size in pixels based on viewport and cell resolution - int targetWidthInPixels = boundsRect.Width * pixelsPerCellX; - int targetHeightInPixels = SixelEncoder?.GetHeightInPixels (boundsRect.Height, pixelsPerCellY) ?? boundsRect.Height * pixelsPerCellY; - - return new Rectangle (boundsRect.X * pixelsPerCellX, boundsRect.Y * pixelsPerCellY, targetWidthInPixels, targetHeightInPixels); - } - - /// - /// Returns the size in cell terms of the given image resized to fit in the viewport. - /// - /// - /// - /// This method accounts for the terminal's cell resolution and the viewport's - /// size, returning the exact pixel dimensions and position required for - /// fully cover the viewport without changing the images aspect ratio. - /// - /// - /// The size of the image in pixels. - /// The largest possible size of the image in cell terms that fits within the viewport. - public Size FitImageInViewportCells (Size imageSizeInPixels) - { - if (imageSizeInPixels.Width == 0 || imageSizeInPixels.Height == 0) - { - return Size.Empty; - } - - // Account for the terminal cell aspect ratio - double cellAspectRatio = App?.Driver?.SixelSupport is { } support ? (double)support.Resolution.Height / support.Resolution.Width : 2.0; - Size imageSize = imageSizeInPixels with { Height = (int)(imageSizeInPixels.Height / cellAspectRatio) }; - - // Calculate aspect-ratio-preserving size - double widthScale = (double)Viewport.Width / imageSize.Width; - double heightScale = (double)Viewport.Height / imageSize.Height; - double scale = Math.Min (widthScale, heightScale); - - int newWidth = Math.Max (1, (int)(imageSize.Width * scale)); - int newHeight = Math.Max (1, (int)(imageSize.Height * scale)); - - return new Size (newWidth, newHeight); - } - - /// - /// Scales an image to fit within the current Viewport while maintaining aspect ratio. - /// - /// - /// - /// This method calculates the largest possible size for the given image that will fit - /// within the current while maintaining its aspect ratio. - /// - /// - /// The calculation is based on the terminal's cell resolution and the - /// size, returning the exact pixel dimensions and position required for the scaled image. - /// - /// - /// The original size of the image to scale. - /// The scaled size of the image that fits within the . - public Size FitImageInViewportInPixels (Size imageSize) - { - Rectangle viewportInPixels = ViewportToScreenInPixels (); - - if (imageSize.Width == 0 || imageSize.Height == 0) - { - return Size.Empty; - } - - // Calculate aspect-ratio-preserving size - double widthScale = (double)viewportInPixels.Width / imageSize.Width; - double heightScale = (double)viewportInPixels.Height / imageSize.Height; - double scale = Math.Min (widthScale, heightScale); - - int newWidth = Math.Max (1, (int)(imageSize.Width * scale)); - int newHeight = Math.Max (1, (int)(imageSize.Height * scale)); - - return new Size (newWidth, newHeight); - } - - /// - protected override bool OnDrawingContent (DrawContext? context) - { - base.OnDrawingContent (context); - - if (_image is null) - { - return true; - } - - if (IsUsingSixel) - { - DrawSixel (); - - if (_scaledImageCellSize is not { } cellSize) - { - return true; - } - Rectangle viewport = ViewportToScreen (); - Rectangle dirtyRect = viewport with { Width = Math.Min (viewport.Width, cellSize.Width), Height = Math.Min (viewport.Height, cellSize.Height) }; - context?.AddDrawnRectangle (dirtyRect); - } - else - { - DrawCellBased (); - - if (_scaledImageCellSize is not { } cellSize) - { - return true; - } - Rectangle viewport = ViewportToScreen (); - Rectangle dirtyRect = viewport with { Width = Math.Min (viewport.Width, cellSize.Width), Height = Math.Min (viewport.Height, cellSize.Height) }; - context?.AddDrawnRectangle (dirtyRect); - } - - return true; - } - - /// - protected override void OnFrameChanged (in Rectangle frame) - { - base.OnFrameChanged (frame); - InvalidateScaledImage (); - } - - /// - /// Renders the image using cell-based rendering where each terminal cell - /// gets the background color of the corresponding pixel. - /// - private void DrawCellBased () - { - RenderRequest? request = CreateRenderRequest (false); - - if (request is null) - { - return; - } - - EnsureScaledImage (request); - - if (_scaledImage is null || _scaledImageCellSize is null) - { - return; - } - - int drawWidth = Math.Min (Viewport.Width, _scaledImage.GetLength (0)); - int drawHeight = Math.Min (Viewport.Height, _scaledImage.GetLength (1)); - - for (var y = 0; y < drawHeight; y++) - { - for (var x = 0; x < drawWidth; x++) - { - Color pixel = _scaledImage [x, y]; - - if (!_attributeCache.TryGetValue (pixel, out Attribute attr)) - { - attr = new Attribute (new Color (), pixel); - _attributeCache.Add (pixel, attr); - } - - SetAttribute (attr); - AddRune (x, y, (Rune)' '); - } - } - } - - /// - /// Renders the image using sixel escape sequences. - /// - private void DrawSixel () - { - RenderRequest? request = CreateRenderRequest (true); - - if (request is null) - { - return; - } - - EnsureScaledImage (request); - - if (_scaledImage is null || _scaledImageCellSize is null || SixelEncoder is null || App?.Driver is not { } driver) - { - return; - } - - Rectangle viewport = ViewportToScreen (); - Size cellSize = _scaledImageCellSize.Value; - Rectangle destinationCells = viewport with { Width = Math.Min (viewport.Width, cellSize.Width), Height = Math.Min (viewport.Height, cellSize.Height) }; - - if (destinationCells.Width <= 0 || destinationCells.Height <= 0) - { - return; - } - - RasterImageCommand command = new () - { - Id = RasterImageId, - Pixels = _scaledImage, - DestinationCells = destinationCells, - Encoder = SixelEncoder, - IsDirty = true - }; - - driver.GetOutputBuffer ().AddRasterImage (command); - } - - private bool? CenterFromCommand (ICommandContext? context) => context?.Binding is MouseBinding { MouseEvent.Position: { } position } && CenterOnViewportPoint (position); - - /// - /// Centers the image on the specified viewport-relative point. - /// - /// The viewport-relative point. - /// if the view was centered; otherwise, . - protected virtual bool CenterOnViewportPoint (Point position) - { - if (!TryMapViewportPointToSourceCenter (position, out double centerX, out double centerY)) - { - return false; - } - - return SetCenter (centerX, centerY); - } - - private void ClampCenter () - { - if (_image is null || _zoomLevel <= MIN_ZOOM_LEVEL) - { - _centerX = 0.5d; - _centerY = 0.5d; - - return; - } - - double halfVisible = 0.5d / _zoomLevel; - _centerX = Math.Clamp (_centerX, halfVisible, 1d - halfVisible); - _centerY = Math.Clamp (_centerY, halfVisible, 1d - halfVisible); - } - - private RectangleF GetVisibleSourceRectangle () - { - if (_image is null) - { - return RectangleF.Empty; - } - - int srcWidth = _image.GetLength (0); - int srcHeight = _image.GetLength (1); - double visibleWidth = srcWidth / _zoomLevel; - double visibleHeight = srcHeight / _zoomLevel; - double x = _centerX * srcWidth - visibleWidth / 2d; - double y = _centerY * srcHeight - visibleHeight / 2d; - x = Math.Clamp (x, 0d, Math.Max (0d, srcWidth - visibleWidth)); - y = Math.Clamp (y, 0d, Math.Max (0d, srcHeight - visibleHeight)); - - return new RectangleF ((float)x, (float)y, (float)visibleWidth, (float)visibleHeight); - } - - private static Size GetSixelCellSize (Size pixelSize, Size resolution) - { - int cellWidth = Math.Max (1, (int)Math.Ceiling ((double)pixelSize.Width / Math.Max (1, resolution.Width))); - int cellHeight = Math.Max (1, (int)Math.Ceiling ((double)pixelSize.Height / Math.Max (1, resolution.Height))); - - return new Size (cellWidth, cellHeight); - } - - private void InvalidateScaledImage (bool clearCurrentRender = false) - { - if (clearCurrentRender || !UseBackgroundRendering || _scaledImage is null) - { - _scaledImage = null; - _scaledImageCellSize = null; - _attributeCache.Clear (); - } - - _scaledImageRenderKey = null; - SetNeedsDraw (); - } - - private bool IsRenderCacheCurrent (RenderKey key) => _scaledImage is { } && _scaledImageCellSize is { } && _scaledImageRenderKey == key; - - private void EnsureScaledImage (RenderRequest request) - { - if (IsRenderCacheCurrent (request.Key)) - { - SetRenderingOverlayVisible (false); - - return; - } - - if (UseBackgroundRendering && App?.Initialized == true) - { - ScheduleBackgroundRender (request); - - return; - } - - ApplyRenderResult (RenderScaledImage (request)); - SetRenderingOverlayVisible (false); - } - - private RenderRequest? CreateRenderRequest (bool useSixel) - { - if (_image is null) - { - return null; - } - - RectangleF visibleSource = GetVisibleSourceRectangle (); - - if (visibleSource.Width <= 0 || visibleSource.Height <= 0) - { - return null; - } - - Size? resolution = null; - int? maxColors = null; - Size targetSize; - var allowUpscale = true; - var preserveAspectRatio = false; - - if (useSixel) - { - if (!IsUsingSixel || App?.Driver?.SixelSupport is not { } support) - { - return null; - } - - SixelEncoder encoder = PrepareSixelEncoder (support); - Rectangle targetRect = ViewportToScreenInPixels (); - targetSize = targetRect.Size; - resolution = support.Resolution; - maxColors = encoder.Quantizer.MaxColors; - allowUpscale = AllowSixelUpscaling || _zoomLevel > MIN_ZOOM_LEVEL; - preserveAspectRatio = true; - } - else - { - targetSize = new Size (Viewport.Width, Viewport.Height); - } - - if (targetSize.Width <= 0 || targetSize.Height <= 0) - { - return null; - } - - RenderKey key = new (_imageVersion, - useSixel, - targetSize, - resolution, - maxColors, - _centerX, - _centerY, - _zoomLevel, - allowUpscale, - preserveAspectRatio); - - return new RenderRequest (key, _image, visibleSource, targetSize, resolution); - } - - private SixelEncoder PrepareSixelEncoder (SixelSupportResult support) - { - SixelEncoder? encoder = SixelEncoder; - - if (encoder is null) - { - encoder = new SixelEncoder (); - _sixelEncoder = encoder; - _usesDefaultSixelEncoder = true; - } - - int maxColors = _usesDefaultSixelEncoder - ? Math.Min (MaxSixelPaletteColors, support.MaxPaletteColors) - : Math.Min (encoder.Quantizer.MaxColors, support.MaxPaletteColors); - encoder.Quantizer.MaxColors = maxColors; - - return encoder; - } - - private void ApplyRenderResult (RenderResult result) - { - _scaledImage = result.ScaledImage; - _scaledImageCellSize = result.CellSize; - _scaledImageRenderKey = result.Key; - _attributeCache.Clear (); - } - - private static RenderResult RenderScaledImage (RenderRequest request) - { - Color [,] scaledImage = ScaleVisibleImage (request.Source, - request.VisibleSource, - request.TargetSize, - request.Key.AllowUpscale, - request.Key.PreserveAspectRatio); - - Size cellSize = request.Key.UseSixel && request.Resolution is { } resolution - ? GetSixelCellSize (new Size (scaledImage.GetLength (0), scaledImage.GetLength (1)), resolution) - : new Size (scaledImage.GetLength (0), scaledImage.GetLength (1)); - - return new RenderResult (request.Key, scaledImage, cellSize); - } - - private void ScheduleBackgroundRender (RenderRequest request) - { - if (_disposed) - { - return; - } - - SetRenderingOverlayVisible (true); - - RenderRequest? requestToStart; - - lock (_renderLock) - { - if (_backgroundRenderRunning) - { - if (_backgroundRenderKey == request.Key || _queuedRenderRequest?.Key == request.Key) - { - return; - } - - _queuedRenderRequest = request; - - return; - } - - _backgroundRenderRunning = true; - requestToStart = request; - } - - StartBackgroundRender (requestToStart); - } - - private void StartBackgroundRender (RenderRequest request) - { - lock (_renderLock) - { - _backgroundRenderKey = request.Key; - } - - Task task = Task.Run (() => RenderScaledImage (request)); - task.ContinueWith (CompleteBackgroundRender, TaskScheduler.Default); - } - - private void CompleteBackgroundRender (Task completed) - { - AggregateException? exception = completed.Exception; - - if (_disposed) - { - _ = exception; - - return; - } - - IApplication? app = App; - - if (app is null || !app.Initialized) - { - _ = exception; - - return; - } - - try - { - if (exception is { }) - { - app.Invoke (() => FailBackgroundRender (exception.GetBaseException ())); - - return; - } - - app.Invoke (() => CompleteBackgroundRenderOnMainThread (completed.Result)); - } - catch (NotInitializedException) - { - _ = exception; - } - catch (ObjectDisposedException) - { - _ = exception; - } - } - - private void CompleteBackgroundRenderOnMainThread (RenderResult result) - { - if (_disposed) - { - return; - } - - RenderRequest? currentRequest = CreateRenderRequest (result.Key.UseSixel); - - if (UseBackgroundRendering && currentRequest?.Key == result.Key) - { - ApplyRenderResult (result); - } - - StartNextQueuedRenderOrFinish (result.Key); - SetNeedsDraw (); - } - - private void FailBackgroundRender (Exception exception) - { - StartNextQueuedRenderOrFinish (_backgroundRenderKey); - - throw new InvalidOperationException ("Background ImageView rendering failed.", exception); - } - - private void StartNextQueuedRenderOrFinish (RenderKey? completedKey) - { - RenderRequest? nextRequest = null; - - lock (_renderLock) - { - if (_queuedRenderRequest is { } queuedRequest && queuedRequest.Key != completedKey) - { - nextRequest = queuedRequest; - _queuedRenderRequest = null; - } - else - { - _queuedRenderRequest = null; - _backgroundRenderKey = null; - _backgroundRenderRunning = false; - } - } - - if (nextRequest is { }) - { - StartBackgroundRender (nextRequest); - - return; - } - - SetRenderingOverlayVisible (false); - } - - private void SetRenderingOverlayVisible (bool visible) - { - if (_renderingOverlay.Visible == visible) - { - return; - } - - _renderingOverlay.Visible = visible; - } - - private bool PanByCells (int deltaX, int deltaY) - { - if (_image is null || _zoomLevel <= MIN_ZOOM_LEVEL || Viewport.Width <= 0 || Viewport.Height <= 0) - { - return false; - } - - RectangleF visibleSource = GetVisibleSourceRectangle (); - double centerX = _centerX + deltaX * visibleSource.Width / Viewport.Width / _image.GetLength (0); - double centerY = _centerY + deltaY * visibleSource.Height / Viewport.Height / _image.GetLength (1); - - return SetCenter (centerX, centerY); - } - - private bool ScrollFromCommand (int deltaX, int deltaY) - { - PanByCells (deltaX, deltaY); - - return true; - } - - private bool ResetView () - { - if (_zoomLevel == MIN_ZOOM_LEVEL && Math.Abs (_centerX - 0.5d) < double.Epsilon && Math.Abs (_centerY - 0.5d) < double.Epsilon) - { - return false; - } - - _zoomLevel = MIN_ZOOM_LEVEL; - _centerX = 0.5d; - _centerY = 0.5d; - InvalidateScaledImage (); - - return true; - } - - private bool SetCenter (double centerX, double centerY) - { - double previousCenterX = _centerX; - double previousCenterY = _centerY; - _centerX = centerX; - _centerY = centerY; - ClampCenter (); - - if (Math.Abs (previousCenterX - _centerX) < double.Epsilon && Math.Abs (previousCenterY - _centerY) < double.Epsilon) - { - return false; - } - - InvalidateScaledImage (); - - return true; - } - - private bool SetZoomLevel (double zoomLevel, Point? anchor) - { - if (double.IsNaN (zoomLevel) || double.IsInfinity (zoomLevel)) - { - throw new ArgumentOutOfRangeException (nameof (zoomLevel), @"Zoom level must be a finite number."); - } - - double previousZoomLevel = _zoomLevel; - double clampedZoomLevel = Math.Clamp (zoomLevel, MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL); - - if (Math.Abs (previousZoomLevel - clampedZoomLevel) < double.Epsilon) - { - return false; - } - - if (anchor is { } position && TryMapViewportPointToSourceCenter (position, out double sourceX, out double sourceY)) - { - _zoomLevel = clampedZoomLevel; - SetCenterForAnchor (position, sourceX, sourceY); - } - else - { - _zoomLevel = clampedZoomLevel; - ClampCenter (); - } - - InvalidateScaledImage (); - - return true; - } - - private void SetCenterForAnchor (Point position, double sourceX, double sourceY) - { - if (_image is null || Viewport.Width <= 0 || Viewport.Height <= 0) - { - return; - } - - double visibleWidth = 1d / _zoomLevel; - double visibleHeight = 1d / _zoomLevel; - _centerX = sourceX - ((position.X + 0.5d) / Viewport.Width - 0.5d) * visibleWidth; - _centerY = sourceY - ((position.Y + 0.5d) / Viewport.Height - 0.5d) * visibleHeight; - ClampCenter (); - } - - private static Color [,] ScaleVisibleImage (Color [,] source, RectangleF visibleSource, Size targetSize, bool allowUpscale, bool preserveAspectRatio) - { - int newWidth; - int newHeight; - - if (preserveAspectRatio) - { - double widthScale = targetSize.Width / visibleSource.Width; - double heightScale = targetSize.Height / visibleSource.Height; - double scale = Math.Min (widthScale, heightScale); - - if (!allowUpscale) - { - scale = Math.Min (scale, 1d); - } - - newWidth = Math.Max (1, (int)(visibleSource.Width * scale)); - newHeight = Math.Max (1, (int)(visibleSource.Height * scale)); - } - else - { - newWidth = targetSize.Width; - newHeight = targetSize.Height; - } - - Color [,] scaledImage = new Color [newWidth, newHeight]; - ScaleVisibleNearestNeighbor (source, scaledImage, visibleSource); - - return scaledImage; - } - - private static void ScaleVisibleNearestNeighbor (Color [,] source, Color [,] destination, RectangleF visibleSource) - { - int srcWidth = source.GetLength (0); - int srcHeight = source.GetLength (1); - int newWidth = destination.GetLength (0); - int newHeight = destination.GetLength (1); - - for (var y = 0; y < newHeight; y++) - { - int srcY = Math.Clamp ((int)(visibleSource.Y + y * visibleSource.Height / newHeight), 0, srcHeight - 1); - - for (var x = 0; x < newWidth; x++) - { - int srcX = Math.Clamp ((int)(visibleSource.X + x * visibleSource.Width / newWidth), 0, srcWidth - 1); - destination [x, y] = source [srcX, srcY]; - } - } - } - - private bool TryMapViewportPointToSourceCenter (Point position, out double centerX, out double centerY) - { - centerX = 0.5d; - centerY = 0.5d; - - if (_image is null || Viewport.Width <= 0 || Viewport.Height <= 0) - { - return false; - } - - if (position.X < 0 || position.Y < 0 || position.X >= Viewport.Width || position.Y >= Viewport.Height) - { - return false; - } - - RectangleF visibleSource = GetVisibleSourceRectangle (); - centerX = (visibleSource.X + (position.X + 0.5d) * visibleSource.Width / Viewport.Width) / _image.GetLength (0); - centerY = (visibleSource.Y + (position.Y + 0.5d) * visibleSource.Height / Viewport.Height) / _image.GetLength (1); - - return true; - } - - private bool ZoomFromCommand (ICommandContext? context, double zoomLevel) - { - Point? anchor = context?.Binding is MouseBinding { MouseEvent.Position: { } position } ? position : null; - - return SetZoomLevel (zoomLevel, anchor); - } - - /// - /// Scales a Color[,] pixel array into a destination array using nearest-neighbor interpolation. - /// - /// The source pixel array indexed as [x, y]. - /// The destination pixel array indexed as [x, y]. - public static void ScaleNearestNeighbor (Color [,] source, Color [,] destination) - { - int srcWidth = source.GetLength (0); - int srcHeight = source.GetLength (1); - int newWidth = destination.GetLength (0); - int newHeight = destination.GetLength (1); - - for (var y = 0; y < newHeight; y++) - { - int srcY = Math.Min (y * srcHeight / newHeight, srcHeight - 1); - - for (var x = 0; x < newWidth; x++) - { - int srcX = Math.Min (x * srcWidth / newWidth, srcWidth - 1); - destination [x, y] = source [srcX, srcY]; - } - } - } - - /// - protected override bool OnMouseEvent (Mouse mouse) - { - if (HandleDrag (mouse)) - { - return true; - } - - return base.OnMouseEvent (mouse); - } - - private bool HandleDrag (Mouse mouse) - { - if (mouse.Position is not { } position) - { - return false; - } - - if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed) && !mouse.Flags.FastHasFlags (MouseFlags.PositionReport)) - { - _lastDragPosition = position; - App?.Mouse.GrabMouse (this); - - return true; - } - - if (mouse.Flags == (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport) && _lastDragPosition is { } lastDragPosition) - { - bool panned = PanByCells (lastDragPosition.X - position.X, lastDragPosition.Y - position.Y); - _lastDragPosition = position; - - return panned; - } - - if (!mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) - { - return false; - } - _lastDragPosition = null; - - if (App is { } && App.Mouse.IsGrabbed (this)) - { - App.Mouse.UngrabMouse (); - } - - return true; - - } - - /// - protected override void Dispose (bool disposing) - { - if (disposing) - { - _disposed = true; - - lock (_renderLock) - { - _queuedRenderRequest = null; - _backgroundRenderKey = null; - _backgroundRenderRunning = false; - } - - App?.Driver?.GetOutputBuffer ().RemoveRasterImage (RasterImageId); - } - - base.Dispose (disposing); - } - - private readonly record struct RenderKey (int ImageVersion, - bool UseSixel, - Size TargetSize, - Size? Resolution, - int? MaxColors, - double CenterX, - double CenterY, - double ZoomLevel, - bool AllowUpscale, - bool PreserveAspectRatio); - - private sealed class RenderRequest (RenderKey key, Color [,] source, RectangleF visibleSource, Size targetSize, Size? resolution) - { - public RenderKey Key { get; } = key; - public Color [,] Source { get; } = source; - public RectangleF VisibleSource { get; } = visibleSource; - public Size TargetSize { get; } = targetSize; - public Size? Resolution { get; } = resolution; - } - - private readonly record struct RenderResult (RenderKey Key, Color [,] ScaledImage, Size CellSize); - - /// - bool IDesignable.EnableForDesign () - { - // Create a simple gradient test image for the designer - var width = 20; - var height = 10; - Color [,] testImage = new Color [width, height]; - - for (var y = 0; y < height; y++) - { - for (var x = 0; x < width; x++) - { - var r = (byte)(x * 255 / Math.Max (1, width - 1)); - var g = (byte)(y * 255 / Math.Max (1, height - 1)); - var b = (byte)128; - testImage [x, y] = new Color (r, g, b); - } - } - - Image = testImage; - - return true; - } -} diff --git a/Terminal.Gui/Views/ImageView/ImageView.Drawing.cs b/Terminal.Gui/Views/ImageView/ImageView.Drawing.cs new file mode 100644 index 0000000000..25026cdd67 --- /dev/null +++ b/Terminal.Gui/Views/ImageView/ImageView.Drawing.cs @@ -0,0 +1,139 @@ +namespace Terminal.Gui.Views; + +public partial class ImageView +{ + private readonly Dictionary _attributeCache = new (); + + /// + protected override bool OnDrawingContent (DrawContext? context) + { + base.OnDrawingContent (context); + + if (_image is null) + { + return true; + } + + if (IsUsingSixel) + { + DrawSixel (); + + if (_scaledImageCellSize is not { } cellSize) + { + return true; + } + Rectangle viewport = ViewportToScreen (); + Rectangle dirtyRect = viewport with { Width = Math.Min (viewport.Width, cellSize.Width), Height = Math.Min (viewport.Height, cellSize.Height) }; + context?.AddDrawnRectangle (dirtyRect); + } + else + { + DrawCellBased (); + + if (_scaledImageCellSize is not { } cellSize) + { + return true; + } + Rectangle viewport = ViewportToScreen (); + Rectangle dirtyRect = viewport with { Width = Math.Min (viewport.Width, cellSize.Width), Height = Math.Min (viewport.Height, cellSize.Height) }; + context?.AddDrawnRectangle (dirtyRect); + } + + return true; + } + + /// + protected override void OnFrameChanged (in Rectangle frame) + { + base.OnFrameChanged (frame); + InvalidateScaledImage (); + } + + /// + /// Renders the image using cell-based rendering where each terminal cell + /// gets the background color of the corresponding pixel. + /// + private void DrawCellBased () + { + RenderRequest? request = CreateRenderRequest (false); + + if (request is null) + { + return; + } + + EnsureScaledImage (request); + + if (_scaledImage is null || _scaledImageCellSize is null) + { + return; + } + + int drawWidth = Math.Min (Viewport.Width, _scaledImage.GetLength (0)); + int drawHeight = Math.Min (Viewport.Height, _scaledImage.GetLength (1)); + Point offset = _zoomLevel < FIT_ZOOM_LEVEL ? GetCenteredRenderOffset (new Size (drawWidth, drawHeight), Viewport.Size) : Point.Empty; + + for (var y = 0; y < drawHeight; y++) + { + for (var x = 0; x < drawWidth; x++) + { + Color pixel = _scaledImage [x, y]; + + if (!_attributeCache.TryGetValue (pixel, out Attribute attr)) + { + attr = new Attribute (new Color (), pixel); + _attributeCache.Add (pixel, attr); + } + + SetAttribute (attr); + AddRune (x + offset.X, y + offset.Y, (Rune)' '); + } + } + } + + /// + /// Renders the image using sixel escape sequences. + /// + private void DrawSixel () + { + RenderRequest? request = CreateRenderRequest (true); + + if (request is null) + { + return; + } + + EnsureScaledImage (request); + + if (_scaledImage is null || _scaledImageCellSize is null || SixelEncoder is null || App?.Driver is not { } driver) + { + return; + } + + Rectangle viewport = ViewportToScreen (); + Size cellSize = _scaledImageCellSize.Value; + Size destinationSize = new (Math.Min (viewport.Width, cellSize.Width), Math.Min (viewport.Height, cellSize.Height)); + Point offset = _zoomLevel < FIT_ZOOM_LEVEL ? GetCenteredRenderOffset (destinationSize, viewport.Size) : Point.Empty; + Rectangle destinationCells = new (viewport.X + offset.X, viewport.Y + offset.Y, destinationSize.Width, destinationSize.Height); + + if (destinationCells.Width <= 0 || destinationCells.Height <= 0) + { + return; + } + + RasterImageCommand command = new () + { + Id = RasterImageId, + Pixels = _scaledImage, + EncodedSixel = _encodedSixel, + DestinationCells = destinationCells, + Encoder = SixelEncoder, + IsDirty = true + }; + + driver.GetOutputBuffer ().AddRasterImage (command); + } + + private bool? CenterFromCommand (ICommandContext? context) => + context?.Binding is MouseBinding { MouseEvent.Position: { } position } && CenterOnViewportPoint (position); +} diff --git a/Terminal.Gui/Views/ImageView/ImageView.Geometry.cs b/Terminal.Gui/Views/ImageView/ImageView.Geometry.cs new file mode 100644 index 0000000000..4ce067d9b1 --- /dev/null +++ b/Terminal.Gui/Views/ImageView/ImageView.Geometry.cs @@ -0,0 +1,109 @@ +namespace Terminal.Gui.Views; + +public partial class ImageView +{ + private double _centerX = 0.5d; + private double _centerY = 0.5d; + + /// + /// Centers the image on the specified viewport-relative point. + /// + /// The viewport-relative point. + /// if the view was centered; otherwise, . + protected virtual bool CenterOnViewportPoint (Point position) + { + if (!TryMapViewportPointToSourceCenter (position, out double centerX, out double centerY)) + { + return false; + } + + return SetCenter (centerX, centerY); + } + + private void ClampCenter () + { + if (_image is null || _zoomLevel <= FIT_ZOOM_LEVEL) + { + _centerX = 0.5d; + _centerY = 0.5d; + + return; + } + + double halfVisible = 0.5d / _zoomLevel; + _centerX = Math.Clamp (_centerX, halfVisible, 1d - halfVisible); + _centerY = Math.Clamp (_centerY, halfVisible, 1d - halfVisible); + } + + private RectangleF GetVisibleSourceRectangle () + { + if (_image is null) + { + return RectangleF.Empty; + } + + int srcWidth = _image.GetLength (0); + int srcHeight = _image.GetLength (1); + double effectiveZoom = Math.Max (FIT_ZOOM_LEVEL, _zoomLevel); + double visibleWidth = srcWidth / effectiveZoom; + double visibleHeight = srcHeight / effectiveZoom; + double x = _centerX * srcWidth - visibleWidth / 2d; + double y = _centerY * srcHeight - visibleHeight / 2d; + x = Math.Clamp (x, 0d, Math.Max (0d, srcWidth - visibleWidth)); + y = Math.Clamp (y, 0d, Math.Max (0d, srcHeight - visibleHeight)); + + return new RectangleF ((float)x, (float)y, (float)visibleWidth, (float)visibleHeight); + } + + private Size ApplyZoomOutToTargetSize (Size targetSize) + { + if (_zoomLevel >= FIT_ZOOM_LEVEL) + { + return targetSize; + } + + int width = Math.Max (1, (int)Math.Round (targetSize.Width * _zoomLevel)); + int height = Math.Max (1, (int)Math.Round (targetSize.Height * _zoomLevel)); + + return new Size (width, height); + } + + private static Point GetCenteredRenderOffset (Size renderSize, Size viewportSize) + { + int offsetX = Math.Max (0, (viewportSize.Width - renderSize.Width) / 2); + int offsetY = Math.Max (0, (viewportSize.Height - renderSize.Height) / 2); + + return new Point (offsetX, offsetY); + } + + private double GetMinimumZoomLevel () + { + Size targetSize = GetBaseRenderTargetSize (); + int maxDimension = Math.Max (targetSize.Width, targetSize.Height); + + if (maxDimension <= 0) + { + return FIT_ZOOM_LEVEL; + } + + return 1d / maxDimension; + } + + private Size GetBaseRenderTargetSize () + { + if (IsUsingSixel && App?.Driver?.SixelSupport is { }) + { + return ViewportToScreenInPixels ().Size; + } + + return Viewport.Size; + } + + private static Size GetSixelCellSize (Size pixelSize, Size resolution) + { + int cellWidth = Math.Max (1, (int)Math.Ceiling ((double)pixelSize.Width / Math.Max (1, resolution.Width))); + int cellHeight = Math.Max (1, (int)Math.Ceiling ((double)pixelSize.Height / Math.Max (1, resolution.Height))); + + return new Size (cellWidth, cellHeight); + } +} diff --git a/Terminal.Gui/Views/ImageView/ImageView.Input.cs b/Terminal.Gui/Views/ImageView/ImageView.Input.cs new file mode 100644 index 0000000000..1e10bc26f5 --- /dev/null +++ b/Terminal.Gui/Views/ImageView/ImageView.Input.cs @@ -0,0 +1,185 @@ +namespace Terminal.Gui.Views; + +public partial class ImageView +{ + private Point? _lastDragPosition; + + private bool PanByCells (int deltaX, int deltaY) + { + if (_image is null || _zoomLevel <= FIT_ZOOM_LEVEL || Viewport.Width <= 0 || Viewport.Height <= 0) + { + return false; + } + + RectangleF visibleSource = GetVisibleSourceRectangle (); + double centerX = _centerX + deltaX * (double)visibleSource.Width / Viewport.Width / _image.GetLength (0); + double centerY = _centerY + deltaY * (double)visibleSource.Height / Viewport.Height / _image.GetLength (1); + + return SetCenter (centerX, centerY); + } + + private bool ScrollFromCommand (int deltaX, int deltaY) + { + PanByCells (deltaX, deltaY); + + return true; + } + + private bool ResetView () + { + if (Math.Abs (_zoomLevel - FIT_ZOOM_LEVEL) < double.Epsilon + && Math.Abs (_centerX - 0.5d) < double.Epsilon + && Math.Abs (_centerY - 0.5d) < double.Epsilon) + { + return false; + } + + _zoomLevel = FIT_ZOOM_LEVEL; + _centerX = 0.5d; + _centerY = 0.5d; + InvalidateScaledImage (); + + return true; + } + + private bool SetCenter (double centerX, double centerY) + { + double previousCenterX = _centerX; + double previousCenterY = _centerY; + _centerX = centerX; + _centerY = centerY; + ClampCenter (); + + if (Math.Abs (previousCenterX - _centerX) < double.Epsilon && Math.Abs (previousCenterY - _centerY) < double.Epsilon) + { + return false; + } + + InvalidateScaledImage (); + + return true; + } + + private bool SetZoomLevel (double zoomLevel, Point? anchor) + { + if (double.IsNaN (zoomLevel) || double.IsInfinity (zoomLevel)) + { + throw new ArgumentOutOfRangeException (nameof (zoomLevel), @"Zoom level must be a finite number."); + } + + double previousZoomLevel = _zoomLevel; + double clampedZoomLevel = Math.Clamp (zoomLevel, GetMinimumZoomLevel (), MAX_ZOOM_LEVEL); + + if (Math.Abs (previousZoomLevel - clampedZoomLevel) < double.Epsilon) + { + return false; + } + + if (anchor is { } position && TryMapViewportPointToSourceCenter (position, out double sourceX, out double sourceY)) + { + _zoomLevel = clampedZoomLevel; + SetCenterForAnchor (position, sourceX, sourceY); + } + else + { + _zoomLevel = clampedZoomLevel; + ClampCenter (); + } + + InvalidateScaledImage (); + + return true; + } + + private void SetCenterForAnchor (Point position, double sourceX, double sourceY) + { + if (_image is null || Viewport.Width <= 0 || Viewport.Height <= 0) + { + return; + } + + double effectiveZoom = Math.Max (FIT_ZOOM_LEVEL, _zoomLevel); + double visibleWidth = 1d / effectiveZoom; + double visibleHeight = 1d / effectiveZoom; + _centerX = sourceX - ((position.X + 0.5d) / Viewport.Width - 0.5d) * visibleWidth; + _centerY = sourceY - ((position.Y + 0.5d) / Viewport.Height - 0.5d) * visibleHeight; + ClampCenter (); + } + + private bool TryMapViewportPointToSourceCenter (Point position, out double centerX, out double centerY) + { + centerX = 0.5d; + centerY = 0.5d; + + if (_image is null || Viewport.Width <= 0 || Viewport.Height <= 0) + { + return false; + } + + if (position.X < 0 || position.Y < 0 || position.X >= Viewport.Width || position.Y >= Viewport.Height) + { + return false; + } + + RectangleF visibleSource = GetVisibleSourceRectangle (); + centerX = (visibleSource.X + (position.X + 0.5d) * visibleSource.Width / Viewport.Width) / _image.GetLength (0); + centerY = (visibleSource.Y + (position.Y + 0.5d) * visibleSource.Height / Viewport.Height) / _image.GetLength (1); + + return true; + } + + private bool ZoomFromCommand (ICommandContext? context, double zoomLevel) + { + Point? anchor = context?.Binding is MouseBinding { MouseEvent.Position: { } position } ? position : null; + + return SetZoomLevel (zoomLevel, anchor); + } + + /// + protected override bool OnMouseEvent (Mouse mouse) + { + if (HandleDrag (mouse)) + { + return true; + } + + return base.OnMouseEvent (mouse); + } + + private bool HandleDrag (Mouse mouse) + { + if (mouse.Position is not { } position) + { + return false; + } + + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed) && !mouse.Flags.FastHasFlags (MouseFlags.PositionReport)) + { + _lastDragPosition = position; + App?.Mouse.GrabMouse (this); + + return true; + } + + if (mouse.Flags == (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport) && _lastDragPosition is { } lastDragPosition) + { + bool panned = PanByCells (lastDragPosition.X - position.X, lastDragPosition.Y - position.Y); + _lastDragPosition = position; + + return panned; + } + + if (!mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) + { + return false; + } + _lastDragPosition = null; + + if (App is { } && App.Mouse.IsGrabbed (this)) + { + App.Mouse.UngrabMouse (); + } + + return true; + } +} diff --git a/Terminal.Gui/Views/ImageView/ImageView.Lifecycle.cs b/Terminal.Gui/Views/ImageView/ImageView.Lifecycle.cs new file mode 100644 index 0000000000..26438034cd --- /dev/null +++ b/Terminal.Gui/Views/ImageView/ImageView.Lifecycle.cs @@ -0,0 +1,47 @@ +namespace Terminal.Gui.Views; + +public partial class ImageView +{ + bool IDesignable.EnableForDesign () + { + // Create a simple gradient test image for the designer + var width = 20; + var height = 10; + Color [,] testImage = new Color [width, height]; + + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var r = (byte)(x * 255 / Math.Max (1, width - 1)); + var g = (byte)(y * 255 / Math.Max (1, height - 1)); + var b = (byte)128; + testImage [x, y] = new Color (r, g, b); + } + } + + Image = testImage; + + return true; + } + + /// + protected override void Dispose (bool disposing) + { + if (disposing) + { + _disposed = true; + + lock (_renderLock) + { + _queuedRenderRequest = null; + _backgroundRenderKey = null; + _backgroundRenderRunning = false; + } + + App?.Driver?.GetOutputBuffer ().RemoveRasterImage (RasterImageId); + } + + base.Dispose (disposing); + } +} diff --git a/Terminal.Gui/Views/ImageView/ImageView.Render.cs b/Terminal.Gui/Views/ImageView/ImageView.Render.cs new file mode 100644 index 0000000000..da1895d1de --- /dev/null +++ b/Terminal.Gui/Views/ImageView/ImageView.Render.cs @@ -0,0 +1,355 @@ +namespace Terminal.Gui.Views; + +public partial class ImageView +{ + private string? _encodedSixel; + private Color [,]? _scaledImage; + private Size? _scaledImageCellSize; + private RenderKey? _scaledImageRenderKey; + + private readonly Lock _renderLock = new (); + + private RenderKey? _backgroundRenderKey; + private bool _backgroundRenderRunning; + private bool _disposed; + private RenderRequest? _queuedRenderRequest; + + private void InvalidateScaledImage (bool clearCurrentRender = false) + { + if (clearCurrentRender || !UseBackgroundRendering || _scaledImage is null) + { + _scaledImage = null; + _scaledImageCellSize = null; + _encodedSixel = null; + _attributeCache.Clear (); + } + + _scaledImageRenderKey = null; + SetRenderingOverlayVisible (UseBackgroundRendering && _image is { }); + SetNeedsDraw (); + } + + private bool IsRenderCacheCurrent (RenderKey key) => _scaledImage is { } && _scaledImageCellSize is { } && _scaledImageRenderKey == key; + + private bool IsCurrentRenderReady () + { + if (_image is null) + { + return true; + } + + RenderRequest? request = CreateRenderRequest (IsUsingSixel); + + return request is null || IsRenderCacheCurrent (request.Key); + } + + private void EnsureScaledImage (RenderRequest request) + { + if (IsRenderCacheCurrent (request.Key)) + { + SetRenderingOverlayVisible (false); + + return; + } + + if (UseBackgroundRendering && App?.Initialized == true) + { + ScheduleBackgroundRender (request); + + return; + } + + ApplyRenderResult (RenderScaledImage (request)); + SetRenderingOverlayVisible (false); + } + + private RenderRequest? CreateRenderRequest (bool useSixel) + { + if (_image is null) + { + return null; + } + + RectangleF visibleSource = GetVisibleSourceRectangle (); + + if (visibleSource.Width <= 0 || visibleSource.Height <= 0) + { + return null; + } + + Size? resolution = null; + SixelEncoder? encoder = null; + int? maxColors = null; + Size targetSize; + var allowUpscale = true; + var preserveAspectRatio = false; + + if (useSixel) + { + if (!IsUsingSixel || App?.Driver?.SixelSupport is not { } support) + { + return null; + } + + encoder = PrepareSixelEncoder (support); + Rectangle targetRect = ViewportToScreenInPixels (); + targetSize = targetRect.Size; + resolution = support.Resolution; + maxColors = encoder.Quantizer.MaxColors; + allowUpscale = AllowSixelUpscaling || _zoomLevel > FIT_ZOOM_LEVEL; + preserveAspectRatio = true; + } + else + { + targetSize = new Size (Viewport.Width, Viewport.Height); + } + + targetSize = ApplyZoomOutToTargetSize (targetSize); + + if (targetSize.Width <= 0 || targetSize.Height <= 0) + { + return null; + } + + RenderKey key = new (_imageVersion, + useSixel, + targetSize, + resolution, + maxColors, + _centerX, + _centerY, + _zoomLevel, + allowUpscale, + preserveAspectRatio); + + return new RenderRequest (key, _image, visibleSource, targetSize, resolution, encoder is { } ? CreateBackgroundEncoder (encoder) : null); + } + + private SixelEncoder PrepareSixelEncoder (SixelSupportResult support) + { + SixelEncoder? encoder = SixelEncoder; + + if (encoder is null) + { + encoder = new SixelEncoder (); + _sixelEncoder = encoder; + _usesDefaultSixelEncoder = true; + } + + int maxColors = _usesDefaultSixelEncoder + ? Math.Min (MaxSixelPaletteColors, support.MaxPaletteColors) + : Math.Min (encoder.Quantizer.MaxColors, support.MaxPaletteColors); + encoder.Quantizer.MaxColors = maxColors; + + return encoder; + } + + private void ApplyRenderResult (RenderResult result) + { + _scaledImage = result.ScaledImage; + _scaledImageCellSize = result.CellSize; + _encodedSixel = result.EncodedSixel; + _scaledImageRenderKey = result.Key; + _attributeCache.Clear (); + } + + private static RenderResult RenderScaledImage (RenderRequest request) + { + Color [,] scaledImage = ScaleVisibleImage (request.Source, + request.VisibleSource, + request.TargetSize, + request.Key.AllowUpscale, + request.Key.PreserveAspectRatio); + + Size cellSize = request.Key.UseSixel && request.Resolution is { } resolution + ? GetSixelCellSize (new Size (scaledImage.GetLength (0), scaledImage.GetLength (1)), resolution) + : new Size (scaledImage.GetLength (0), scaledImage.GetLength (1)); + string? encodedSixel = request.Key.UseSixel ? request.Encoder?.EncodeSixel (scaledImage) : null; + + return new RenderResult (request.Key, scaledImage, cellSize, encodedSixel); + } + + private static SixelEncoder CreateBackgroundEncoder (SixelEncoder encoder) => + new () + { + AvoidBottomScroll = encoder.AvoidBottomScroll, + Quantizer = new ColorQuantizer + { + MaxColors = encoder.Quantizer.MaxColors, + DistanceAlgorithm = encoder.Quantizer.DistanceAlgorithm, + PaletteBuildingAlgorithm = encoder.Quantizer.PaletteBuildingAlgorithm + } + }; + + private void ScheduleBackgroundRender (RenderRequest request) + { + if (_disposed) + { + return; + } + + SetRenderingOverlayVisible (true); + + RenderRequest? requestToStart; + + lock (_renderLock) + { + if (_backgroundRenderRunning) + { + if (_backgroundRenderKey == request.Key || _queuedRenderRequest?.Key == request.Key) + { + return; + } + + _queuedRenderRequest = request; + + return; + } + + _backgroundRenderRunning = true; + requestToStart = request; + } + + StartBackgroundRender (requestToStart); + } + + private void StartBackgroundRender (RenderRequest request) + { + lock (_renderLock) + { + _backgroundRenderKey = request.Key; + } + + Task task = Task.Run (() => RenderScaledImage (request)); + task.ContinueWith (CompleteBackgroundRender, TaskScheduler.Default); + } + + private void CompleteBackgroundRender (Task completed) + { + AggregateException? exception = completed.Exception; + + if (_disposed) + { + _ = exception; + + return; + } + + IApplication? app = App; + + if (app is null || !app.Initialized) + { + _ = exception; + + return; + } + + try + { + if (exception is { }) + { + app.Invoke (() => FailBackgroundRender (exception.GetBaseException ())); + + return; + } + + app.Invoke (() => CompleteBackgroundRenderOnMainThread (completed.Result)); + } + catch (NotInitializedException) + { + _ = exception; + } + catch (ObjectDisposedException) + { + _ = exception; + } + } + + private void CompleteBackgroundRenderOnMainThread (RenderResult result) + { + if (_disposed) + { + return; + } + + RenderRequest? currentRequest = CreateRenderRequest (result.Key.UseSixel); + + if (UseBackgroundRendering && currentRequest?.Key == result.Key) + { + ApplyRenderResult (result); + } + + StartNextQueuedRenderOrFinish (result.Key); + SetNeedsDraw (); + } + + private void FailBackgroundRender (Exception exception) + { + StartNextQueuedRenderOrFinish (_backgroundRenderKey); + + throw new InvalidOperationException ("Background ImageView rendering failed.", exception); + } + + private void StartNextQueuedRenderOrFinish (RenderKey? completedKey) + { + RenderRequest? nextRequest = null; + + lock (_renderLock) + { + if (_queuedRenderRequest is { } queuedRequest && queuedRequest.Key != completedKey) + { + nextRequest = queuedRequest; + _queuedRenderRequest = null; + } + else + { + _queuedRenderRequest = null; + _backgroundRenderKey = null; + _backgroundRenderRunning = false; + } + } + + if (nextRequest is { }) + { + StartBackgroundRender (nextRequest); + + return; + } + + SetRenderingOverlayVisible (false); + } + + private void SetRenderingOverlayVisible (bool visible) + { + if (_renderingOverlay.Visible == visible) + { + return; + } + + _renderingOverlay.AutoSpin = visible; + _renderingOverlay.Visible = visible; + } + + private readonly record struct RenderKey (int ImageVersion, + bool UseSixel, + Size TargetSize, + Size? Resolution, + int? MaxColors, + double CenterX, + double CenterY, + double ZoomLevel, + bool AllowUpscale, + bool PreserveAspectRatio); + + private sealed class RenderRequest (RenderKey key, Color [,] source, RectangleF visibleSource, Size targetSize, Size? resolution, SixelEncoder? encoder) + { + public RenderKey Key { get; } = key; + public Color [,] Source { get; } = source; + public RectangleF VisibleSource { get; } = visibleSource; + public Size TargetSize { get; } = targetSize; + public Size? Resolution { get; } = resolution; + public SixelEncoder? Encoder { get; } = encoder; + } + + private readonly record struct RenderResult (RenderKey Key, Color [,] ScaledImage, Size CellSize, string? EncodedSixel); +} diff --git a/Terminal.Gui/Views/ImageView/ImageView.Scaling.cs b/Terminal.Gui/Views/ImageView/ImageView.Scaling.cs new file mode 100644 index 0000000000..de4dfa5593 --- /dev/null +++ b/Terminal.Gui/Views/ImageView/ImageView.Scaling.cs @@ -0,0 +1,78 @@ +namespace Terminal.Gui.Views; + +public partial class ImageView +{ + private static Color [,] ScaleVisibleImage (Color [,] source, RectangleF visibleSource, Size targetSize, bool allowUpscale, bool preserveAspectRatio) + { + int newWidth; + int newHeight; + + if (preserveAspectRatio) + { + double widthScale = targetSize.Width / (double)visibleSource.Width; + double heightScale = targetSize.Height / (double)visibleSource.Height; + double scale = Math.Min (widthScale, heightScale); + + if (!allowUpscale) + { + scale = Math.Min (scale, 1d); + } + + newWidth = Math.Max (1, (int)(visibleSource.Width * scale)); + newHeight = Math.Max (1, (int)(visibleSource.Height * scale)); + } + else + { + newWidth = targetSize.Width; + newHeight = targetSize.Height; + } + + Color [,] scaledImage = new Color [newWidth, newHeight]; + ScaleVisibleNearestNeighbor (source, scaledImage, visibleSource); + + return scaledImage; + } + + private static void ScaleVisibleNearestNeighbor (Color [,] source, Color [,] destination, RectangleF visibleSource) + { + int srcWidth = source.GetLength (0); + int srcHeight = source.GetLength (1); + int newWidth = destination.GetLength (0); + int newHeight = destination.GetLength (1); + + for (var y = 0; y < newHeight; y++) + { + int srcY = Math.Clamp ((int)(visibleSource.Y + y * (double)visibleSource.Height / newHeight), 0, srcHeight - 1); + + for (var x = 0; x < newWidth; x++) + { + int srcX = Math.Clamp ((int)(visibleSource.X + x * (double)visibleSource.Width / newWidth), 0, srcWidth - 1); + destination [x, y] = source [srcX, srcY]; + } + } + } + + /// + /// Scales a Color[,] pixel array into a destination array using nearest-neighbor interpolation. + /// + /// The source pixel array indexed as [x, y]. + /// The destination pixel array indexed as [x, y]. + public static void ScaleNearestNeighbor (Color [,] source, Color [,] destination) + { + int srcWidth = source.GetLength (0); + int srcHeight = source.GetLength (1); + int newWidth = destination.GetLength (0); + int newHeight = destination.GetLength (1); + + for (var y = 0; y < newHeight; y++) + { + int srcY = Math.Min (y * srcHeight / newHeight, srcHeight - 1); + + for (var x = 0; x < newWidth; x++) + { + int srcX = Math.Min (x * srcWidth / newWidth, srcWidth - 1); + destination [x, y] = source [srcX, srcY]; + } + } + } +} diff --git a/Terminal.Gui/Views/ImageView/ImageView.cs b/Terminal.Gui/Views/ImageView/ImageView.cs new file mode 100644 index 0000000000..cd4a7b9752 --- /dev/null +++ b/Terminal.Gui/Views/ImageView/ImageView.cs @@ -0,0 +1,375 @@ +namespace Terminal.Gui.Views; + +/// +/// Displays an image represented as a 2D array of pixels. +/// Supports two rendering modes: cell-based (one colored space per pixel, works everywhere) +/// and sixel-based (when the terminal supports it). +/// +/// +/// +/// The image data is provided via the property as a Color[,] array +/// where the first dimension is width (x) and the second is height (y). Image loading and +/// decoding from file formats (PNG, JPEG, etc.) is the caller's responsibility — this view +/// has no dependency on any image library. +/// +/// +/// When sixel is available (detected via ) and +/// is , the view will encode the image as +/// sixel escape sequences and render it through the driver's output buffer. Sixel data +/// is only re-sent to the terminal when is true, +/// avoiding redundant rendering of unchanged images. +/// +/// +/// When sixel is not available, the view falls back to cell-based rendering where each +/// terminal cell is colored with the background color of the corresponding pixel. +/// +/// +public partial class ImageView : View, IDesignable +{ + private const double MAX_ZOOM_LEVEL = 64d; + private const double FIT_ZOOM_LEVEL = 1d; + private const double ZOOM_FACTOR = 1.25d; + private const int DEFAULT_MAX_SIXEL_PALETTE_COLORS = 64; + + private readonly SpinnerView _renderingOverlay = new () + { + X = Pos.Center (), + Y = Pos.Center (), + CanFocus = false, + Style = new SpinnerStyle.Aesthetic2 (), + Visible = false + }; + + /// + /// Gets or sets the default key bindings for . + /// + public new static Dictionary? DefaultKeyBindings { get; set; } = new () + { + [Command.ScrollLeft] = Bind.All (Key.CursorLeft), + [Command.ScrollRight] = Bind.All (Key.CursorRight), + [Command.ScrollUp] = Bind.All (Key.CursorUp), + [Command.ScrollDown] = Bind.All (Key.CursorDown), + [Command.Home] = Bind.All (Key.Home), + [Command.ZoomIn] = Bind.All (Key.PageUp), + [Command.ZoomOut] = Bind.All (Key.PageDown) + }; + + /// + /// Gets or sets the default mouse bindings for . + /// + public new static Dictionary? DefaultMouseBindings { get; set; } = new () + { + [Command.ZoomIn] = BindMouse.All (MouseFlags.WheeledUp), + [Command.ZoomOut] = BindMouse.All (MouseFlags.WheeledDown), + [Command.Center] = BindMouse.All (MouseFlags.LeftButtonDoubleClicked) + }; + + /// Initializes a new instance of the class. + public ImageView () + { + CanFocus = true; + MousePositionTracking = true; + + AddCommand (Command.ScrollLeft, () => ScrollFromCommand (-1, 0)); + AddCommand (Command.ScrollRight, () => ScrollFromCommand (1, 0)); + AddCommand (Command.ScrollUp, () => ScrollFromCommand (0, -1)); + AddCommand (Command.ScrollDown, () => ScrollFromCommand (0, 1)); + AddCommand (Command.Home, () => ResetView ()); + AddCommand (Command.ZoomIn, context => ZoomFromCommand (context, _zoomLevel * ZOOM_FACTOR)); + AddCommand (Command.ZoomOut, context => ZoomFromCommand (context, _zoomLevel / ZOOM_FACTOR)); + AddCommand (Command.PageUp, context => ZoomFromCommand (context, _zoomLevel * ZOOM_FACTOR)); + AddCommand (Command.PageDown, context => ZoomFromCommand (context, _zoomLevel / ZOOM_FACTOR)); + AddCommand (Command.Center, CenterFromCommand); + + ApplyKeyBindings (DefaultKeyBindings, View.DefaultKeyBindings); + ApplyMouseBindings (DefaultMouseBindings, View.DefaultMouseBindings); + ReplacePanAndZoomBindings (); + Add (_renderingOverlay); + } + + private void ReplacePanAndZoomBindings () + { + KeyBindings.ReplaceCommands (Key.CursorLeft, Command.ScrollLeft); + KeyBindings.ReplaceCommands (Key.CursorRight, Command.ScrollRight); + KeyBindings.ReplaceCommands (Key.CursorUp, Command.ScrollUp); + KeyBindings.ReplaceCommands (Key.CursorDown, Command.ScrollDown); + KeyBindings.ReplaceCommands (Key.Home, Command.Home); + + MouseBindings.ReplaceCommands (MouseFlags.WheeledUp, Command.ZoomIn); + MouseBindings.ReplaceCommands (MouseFlags.WheeledDown, Command.ZoomOut); + MouseBindings.Remove (MouseFlags.LeftButtonReleased); + MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Activate); + MouseBindings.ReplaceCommands (MouseFlags.LeftButtonDoubleClicked, Command.Center); + } + + private Color [,]? _image; + + private int _imageVersion; + + private string RasterImageId => $"ImageView_{GetHashCode ()}"; + + /// + /// Gets or sets the pixel data to display. The array is indexed as [x, y] where + /// the first dimension is width and the second is height. + /// + /// + /// Setting this property marks the view as needing redraw. The image will be + /// scaled to fit the current while maintaining + /// aspect ratio using nearest-neighbor interpolation. + /// + public Color [,]? Image + { + get => _image; + set + { + _image = value; + _imageVersion++; + ClampCenter (); + + if (_image is null) + { + InvalidateScaledImage (true); + App?.Driver?.GetOutputBuffer ().RemoveRasterImage (RasterImageId); + + return; + } + + InvalidateScaledImage (); + } + } + + /// + /// Gets or sets whether to prefer sixel rendering when the terminal supports it. + /// Default is . + /// + /// + /// When and the terminal supports sixel + /// (per ), the image is rendered using sixel + /// escape sequences for full-resolution display. When , + /// cell-based rendering is always used. + /// + public bool UseSixel { get; set; } = true; + + /// + /// Gets or sets whether ImageView scales image renders on a background thread. + /// + /// + /// The default is . When enabled, ImageView keeps the last completed render visible while + /// a newer render is being prepared and shows a centered spinner until the background render is + /// ready. + /// + public bool UseBackgroundRendering + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + + if (field && _image is { } && !IsCurrentRenderReady ()) + { + SetRenderingOverlayVisible (true); + } + else if (!field) + { + SetRenderingOverlayVisible (false); + } + + InvalidateScaledImage (); + } + } + + /// + /// Gets or sets the maximum number of colors to use when encoding ImageView sixel output. + /// + /// + /// The default is 64 colors to keep interactive ImageView redraws responsive. The effective + /// encoder palette is also limited by and by + /// the configured MaxColors. + /// + public int MaxSixelPaletteColors + { + get; + set + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException (nameof (value), @"Maximum sixel palette colors must be greater than zero."); + } + + if (field == value) + { + return; + } + + field = value; + InvalidateScaledImage (); + } + } = DEFAULT_MAX_SIXEL_PALETTE_COLORS; + + /// + /// Gets or sets whether sixel rendering may upscale the visible image region above its source pixel size. + /// + /// + /// The default is so the image fills the Viewport at 1. + /// Set to to avoid encoding more pixels than the source image provides during fit-to-view + /// rendering. + /// + public bool AllowSixelUpscaling + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + InvalidateScaledImage (); + } + } = true; + + private double _zoomLevel = FIT_ZOOM_LEVEL; + + /// + /// Gets or sets the zoom level. A value of 1 fits the image in the viewport. + /// + public double ZoomLevel { get => _zoomLevel; set => SetZoomLevel (value, null); } + + private SixelEncoder? _sixelEncoder; + + private bool _usesDefaultSixelEncoder; + + /// + /// Gets or sets the used to encode images as sixel data. + /// When , a default encoder is created lazily on first use. + /// + /// + /// Set this to provide a custom encoder with specific quantizer settings, palette building + /// algorithms, or color distance algorithms. The encoder's + /// MaxColors will be clamped during + /// rendering. The default encoder is capped by and the + /// terminal's . Custom encoders are capped + /// only by the terminal's . + /// + public SixelEncoder? SixelEncoder + { + get => _sixelEncoder; + set + { + if (ReferenceEquals (_sixelEncoder, value)) + { + return; + } + + _sixelEncoder = value; + _usesDefaultSixelEncoder = false; + InvalidateScaledImage (); + } + } + + /// + /// Gets whether the current rendering mode is using sixel. + /// + public bool IsUsingSixel => UseSixel && App?.Driver?.SixelSupport is { IsSupported: true }; + + /// + /// Converts the Viewport to screen coordinates in pixels. + /// + /// + /// + /// This method accounts for the terminal's cell resolution and the viewport's + /// size, returning the exact pixel dimensions and position required for + /// fully cover the viewport. + /// + /// + /// The screen coordinates of the Viewport in pixels. + public Rectangle ViewportToScreenInPixels () + { + SixelSupportResult support = App?.Driver?.SixelSupport ?? throw new InvalidOperationException (@"No sixel support available."); + + int pixelsPerCellX = support.Resolution.Width; + int pixelsPerCellY = support.Resolution.Height; + Rectangle boundsRect = ViewportToScreen (); + + // Calculate target size in pixels based on viewport and cell resolution + int targetWidthInPixels = boundsRect.Width * pixelsPerCellX; + int targetHeightInPixels = SixelEncoder?.GetHeightInPixels (boundsRect.Height, pixelsPerCellY) ?? boundsRect.Height * pixelsPerCellY; + + return new Rectangle (boundsRect.X * pixelsPerCellX, boundsRect.Y * pixelsPerCellY, targetWidthInPixels, targetHeightInPixels); + } + + /// + /// Returns the size in cell terms of the given image resized to fit in the viewport. + /// + /// + /// + /// This method accounts for the terminal's cell resolution and the viewport's + /// size, returning the exact pixel dimensions and position required for + /// fully cover the viewport without changing the images aspect ratio. + /// + /// + /// The size of the image in pixels. + /// The largest possible size of the image in cell terms that fits within the viewport. + public Size FitImageInViewportCells (Size imageSizeInPixels) + { + if (imageSizeInPixels.Width == 0 || imageSizeInPixels.Height == 0) + { + return Size.Empty; + } + + // Account for the terminal cell aspect ratio + double cellAspectRatio = App?.Driver?.SixelSupport is { } support ? (double)support.Resolution.Height / support.Resolution.Width : 2.0; + Size imageSize = imageSizeInPixels with { Height = (int)(imageSizeInPixels.Height / cellAspectRatio) }; + + // Calculate aspect-ratio-preserving size + double widthScale = (double)Viewport.Width / imageSize.Width; + double heightScale = (double)Viewport.Height / imageSize.Height; + double scale = Math.Min (widthScale, heightScale); + + int newWidth = Math.Max (1, (int)(imageSize.Width * scale)); + int newHeight = Math.Max (1, (int)(imageSize.Height * scale)); + + return new Size (newWidth, newHeight); + } + + /// + /// Scales an image to fit within the current Viewport while maintaining aspect ratio. + /// + /// + /// + /// This method calculates the largest possible size for the given image that will fit + /// within the current while maintaining its aspect ratio. + /// + /// + /// The calculation is based on the terminal's cell resolution and the + /// size, returning the exact pixel dimensions and position required for the scaled image. + /// + /// + /// The original size of the image to scale. + /// The scaled size of the image that fits within the . + public Size FitImageInViewportInPixels (Size imageSize) + { + Rectangle viewportInPixels = ViewportToScreenInPixels (); + + if (imageSize.Width == 0 || imageSize.Height == 0) + { + return Size.Empty; + } + + // Calculate aspect-ratio-preserving size + double widthScale = (double)viewportInPixels.Width / imageSize.Width; + double heightScale = (double)viewportInPixels.Height / imageSize.Height; + double scale = Math.Min (widthScale, heightScale); + + int newWidth = Math.Max (1, (int)(imageSize.Width * scale)); + int newHeight = Math.Max (1, (int)(imageSize.Height * scale)); + + return new Size (newWidth, newHeight); + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs index 3c55fb29bd..76d853cb84 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs @@ -1061,6 +1061,34 @@ public void ToAnsi_RasterImage_RendersBeforeLaterTextCells () Assert.InRange (resetAfterImageIndex, imageIndex + 1, textIndex - 1); } + // Copilot - GPT-5.5 + [Fact] + public void ToAnsi_RasterImage_UsesEncodedSixelForFullVisibleRectangle () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (2, 2); + string encodedSixel = "\u001bPpre-encoded\u001b\\"; + + RasterImageCommand command = new () + { + Id = "image", + Pixels = CreateSolidImage (2, 2, new Color (255, 0, 0)), + EncodedSixel = encodedSixel, + DestinationCells = new Rectangle (0, 0, 2, 2) + }; + + buffer.AddRasterImage (command); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert + Assert.Contains (encodedSixel, ansi); + Assert.DoesNotContain ("\"1;1;2;2", ansi); + } + // Copilot - GPT-5.5 [Theory] [InlineData (null)] diff --git a/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs b/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs index e760598749..83abda0b57 100644 --- a/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs @@ -36,8 +36,8 @@ public void Defaults_ConfigurePanAndZoomBindings () Assert.Equal ([Command.ScrollUp], imageView.KeyBindings.GetCommands (Key.CursorUp)); Assert.Equal ([Command.ScrollDown], imageView.KeyBindings.GetCommands (Key.CursorDown)); Assert.Equal ([Command.Home], imageView.KeyBindings.GetCommands (Key.Home)); - Assert.Equal ([Command.ZoomOut], imageView.KeyBindings.GetCommands (Key.PageUp)); - Assert.Equal ([Command.ZoomIn], imageView.KeyBindings.GetCommands (Key.PageDown)); + Assert.Equal ([Command.ZoomIn], imageView.KeyBindings.GetCommands (Key.PageUp)); + Assert.Equal ([Command.ZoomOut], imageView.KeyBindings.GetCommands (Key.PageDown)); Assert.Equal ([Command.ZoomIn], imageView.MouseBindings.GetCommands (MouseFlags.WheeledUp)); Assert.Equal ([Command.ZoomOut], imageView.MouseBindings.GetCommands (MouseFlags.WheeledDown)); Assert.Equal ([Command.Center], imageView.MouseBindings.GetCommands (MouseFlags.LeftButtonDoubleClicked)); @@ -51,7 +51,7 @@ public void Defaults_ConfigurePanAndZoomBindings () // Copilot - GPT-5.5 [Fact] - public void BackgroundRendering_WhenEnabled_ShowsRenderingOverlayOnFirstDraw () + public void BackgroundRendering_WhenEnabled_ShowsSpinnerBeforeFirstDraw () { using IApplication app = Application.Create (); app.Init (DriverRegistry.Names.ANSI); @@ -69,13 +69,16 @@ public void BackgroundRendering_WhenEnabled_ShowsRenderingOverlayOnFirstDraw () }; runnable.Add (imageView); - Label overlay = Assert.Single (imageView.SubViews.OfType Edit, - /// Centers the current item or viewport. - Center, - - /// Zooms in. - ZoomIn, - - /// Zooms out. - ZoomOut, - #endregion #region Multi-Caret Commands @@ -399,5 +387,17 @@ public enum Command /// Starts extending a rectangular selection via pointing-device input. StartRectangleSelection, + /// Moves or resets to the home position. + Home, + + /// Centers the current item or viewport. + Center, + + /// Zooms in. + ZoomIn, + + /// Zooms out. + ZoomOut, + #endregion } diff --git a/Terminal.Gui/Views/ProgressBar.cs b/Terminal.Gui/Views/ProgressBar.cs index 161d747d14..46d71f5c05 100644 --- a/Terminal.Gui/Views/ProgressBar.cs +++ b/Terminal.Gui/Views/ProgressBar.cs @@ -89,6 +89,8 @@ public class ProgressBar : View, IDesignable private int []? _activityPos; private int _delta; + private SixelEncoder? _fireEncoder; + private int _fireEncoderMaxColors; private int _fireFrame; private readonly string _fireRasterImageId = $"{nameof (ProgressBar)}.{Guid.NewGuid ():N}.Fire"; private float _fraction; @@ -430,7 +432,7 @@ private bool DrawFireProgress (int filledCells) Id = _fireRasterImageId, Pixels = CreateFirePixels (pixelWidth, pixelHeight, _fireFrame++), DestinationCells = ViewportToScreen (new Rectangle (0, 0, filledCells, Viewport.Height)), - Encoder = CreateFireEncoder (support) + Encoder = GetFireEncoder (support) }; Driver.GetOutputBuffer ().AddRasterImage (command); @@ -438,11 +440,26 @@ private bool DrawFireProgress (int filledCells) return true; } - private static SixelEncoder CreateFireEncoder (SixelSupportResult support) + private SixelEncoder GetFireEncoder (SixelSupportResult support) { - SixelEncoder encoder = new () { Quantizer = { MaxColors = Math.Min (support.MaxPaletteColors, _firePalette.Length) } }; + int maxColors = Math.Min (support.MaxPaletteColors, _firePalette.Length); - return encoder; + if (_fireEncoder is { } encoder && _fireEncoderMaxColors == maxColors) + { + return encoder; + } + + _fireEncoderMaxColors = maxColors; + _fireEncoder = new () + { + Quantizer = + { + MaxColors = maxColors, + PaletteBuildingAlgorithm = new FirePaletteBuilder () + } + }; + + return _fireEncoder; } private static Color [,] CreateFirePixels (int width, int height, int frame) @@ -469,6 +486,13 @@ private static SixelEncoder CreateFireEncoder (SixelSupportResult support) private int GetProgressPercentage () => Math.Clamp ((int)Math.Round (_fraction * 100), 0, 100); + private sealed class FirePaletteBuilder : IStaticPaletteBuilder + { + public List BuildPalette (List colors, int maxColors) => BuildPalette (maxColors); + + public List BuildPalette (int maxColors) => [.. _firePalette.Take (maxColors)]; + } + private void RemoveFireRasterImage () => Driver?.GetOutputBuffer ().RemoveRasterImage (_fireRasterImageId); private void UpdateTerminalProgress () diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index c48e5712be..b6d00b2721 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -572,7 +572,6 @@ True True True - True True True diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs index 76d853cb84..9c2bc3bf50 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs @@ -1178,6 +1178,33 @@ public void RemoveRasterImage_InvalidatesCoveredCells () Assert.True (buffer.DirtyLines [0]); } + // Copilot - GPT-5.5 + [Fact] + public void GetRasterImages_ReturnsReadOnlySnapshot () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (2, 2); + + RasterImageCommand command = new () + { + Id = "image", + Pixels = CreateSolidImage (2, 2, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2) + }; + + buffer.AddRasterImage (command); + + // Act + IReadOnlyList images = buffer.GetRasterImages (); + + // Assert + IList list = Assert.IsAssignableFrom> (images); + Assert.Throws (() => list.Clear ()); + Assert.Single (buffer.GetRasterImages ()); + } + // Copilot - GPT-5.5 [Fact] public void Write_RasterImage_RendersBeforeLaterDirtyCells () diff --git a/Tests/UnitTestsParallelizable/Input/CommandEnumOrderTests.cs b/Tests/UnitTestsParallelizable/Input/CommandEnumOrderTests.cs index 55edbef38a..8bb4edd2ca 100644 --- a/Tests/UnitTestsParallelizable/Input/CommandEnumOrderTests.cs +++ b/Tests/UnitTestsParallelizable/Input/CommandEnumOrderTests.cs @@ -22,6 +22,23 @@ public void StartRectangleSelection_IsAfter_StartSelection () Assert.True ((int)Command.StartRectangleSelection > (int)Command.StartSelection); } + // Copilot - GPT-5.5 + [Fact] + public void ImageViewCommands_AreAppendedAfterExistingCommands () + { + Assert.True ((int)Command.Home > (int)Command.StartRectangleSelection); + Assert.True ((int)Command.Center > (int)Command.Home); + Assert.True ((int)Command.ZoomIn > (int)Command.Center); + Assert.True ((int)Command.ZoomOut > (int)Command.ZoomIn); + } + + // Copilot - GPT-5.5 + [Fact] + public void End_Ordinal_IsUnchanged () + { + Assert.Equal (15, (int)Command.End); + } + [Fact] public void KillWordRight_Ordinal_IsUnchanged () { diff --git a/Tests/UnitTestsParallelizable/Input/CommandInsertCaretEnumTests.cs b/Tests/UnitTestsParallelizable/Input/CommandInsertCaretEnumTests.cs index 0cb551e01e..9dd2fcec54 100644 --- a/Tests/UnitTestsParallelizable/Input/CommandInsertCaretEnumTests.cs +++ b/Tests/UnitTestsParallelizable/Input/CommandInsertCaretEnumTests.cs @@ -53,14 +53,18 @@ public void InsertCaretCommands_AreAppendedAtEnd_NoRenumbering () Assert.True (belowValue > aboveValue); - // All members except the InsertCaret and mouse-selection commands (added later) + // All members except the InsertCaret, mouse-selection, and ImageView commands (added later) // should have lower values than InsertCaretAbove. HashSet tailCommands = [ Command.InsertCaretAbove, Command.InsertCaretBelow, Command.StartSelection, - Command.StartRectangleSelection + Command.StartRectangleSelection, + Command.Home, + Command.Center, + Command.ZoomIn, + Command.ZoomOut ]; Assert.All ( diff --git a/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs b/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs index 7b4f58a216..ef6b40c4fa 100644 --- a/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs @@ -109,10 +109,39 @@ public void FireStyle_SixelSupport_AddsRasterImageForFilledCells () Assert.Equal (new (0, 0, 2, 2), command.DestinationCells); Assert.Equal (4, command.Pixels!.GetLength (0)); Assert.Equal (6, command.Pixels.GetLength (1)); + Assert.IsAssignableFrom (command.Encoder!.Quantizer.PaletteBuildingAlgorithm); Assert.False (driver.Contents! [0, 0].IsDirty); Assert.Equal (" ", driver.Contents [0, 2].Grapheme); } + // Copilot - GPT-5.5 + [Fact] + public void FireStyle_SixelSupport_ReusesEncoderAcrossFrames () + { + DriverImpl driver = (DriverImpl)CreateTestDriver (4, 2); + driver.Clip = new (driver.Screen); + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (2, 3) }); + + ProgressBar pb = new () + { + Driver = driver, + Width = 4, + Height = 2, + Fraction = 0.5F, + ProgressBarStyle = ProgressBarStyle.Fire + }; + pb.BeginInit (); + pb.EndInit (); + pb.LayoutSubViews (); + + pb.Draw (); + SixelEncoder encoder = Assert.Single (driver.GetOutputBuffer ().GetRasterImages ()).Encoder!; + + pb.Draw (); + + Assert.Same (encoder, Assert.Single (driver.GetOutputBuffer ().GetRasterImages ()).Encoder); + } + // Copilot - GPT-5.5 [Fact] public void FireStyle_SixelSupport_WritesPercentageAfterRasterImageOnRepeatedFrames () From 698abbb6b3b704cce8355fe26503c9b5eef7cd9c Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 1 Jun 2026 18:31:16 -0600 Subject: [PATCH 13/13] Keep image commands grouped Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Terminal.Gui/Input/Command.cs | 24 +++++++++---------- .../Input/CommandEnumOrderTests.cs | 17 ------------- .../Input/CommandInsertCaretEnumTests.cs | 8 ++----- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/Terminal.Gui/Input/Command.cs b/Terminal.Gui/Input/Command.cs index edad674740..31e35f016b 100644 --- a/Terminal.Gui/Input/Command.cs +++ b/Terminal.Gui/Input/Command.cs @@ -102,6 +102,9 @@ public enum Command /// Moves to the start (e.g. the top or home). Start, + /// Moves or resets to the home position. + Home, + /// Moves to the end (e.g. the bottom). End, @@ -357,6 +360,15 @@ public enum Command /// Edit, + /// Centers the current item or viewport. + Center, + + /// Zooms in. + ZoomIn, + + /// Zooms out. + ZoomOut, + #endregion #region Multi-Caret Commands @@ -387,17 +399,5 @@ public enum Command /// Starts extending a rectangular selection via pointing-device input. StartRectangleSelection, - /// Moves or resets to the home position. - Home, - - /// Centers the current item or viewport. - Center, - - /// Zooms in. - ZoomIn, - - /// Zooms out. - ZoomOut, - #endregion } diff --git a/Tests/UnitTestsParallelizable/Input/CommandEnumOrderTests.cs b/Tests/UnitTestsParallelizable/Input/CommandEnumOrderTests.cs index 8bb4edd2ca..55edbef38a 100644 --- a/Tests/UnitTestsParallelizable/Input/CommandEnumOrderTests.cs +++ b/Tests/UnitTestsParallelizable/Input/CommandEnumOrderTests.cs @@ -22,23 +22,6 @@ public void StartRectangleSelection_IsAfter_StartSelection () Assert.True ((int)Command.StartRectangleSelection > (int)Command.StartSelection); } - // Copilot - GPT-5.5 - [Fact] - public void ImageViewCommands_AreAppendedAfterExistingCommands () - { - Assert.True ((int)Command.Home > (int)Command.StartRectangleSelection); - Assert.True ((int)Command.Center > (int)Command.Home); - Assert.True ((int)Command.ZoomIn > (int)Command.Center); - Assert.True ((int)Command.ZoomOut > (int)Command.ZoomIn); - } - - // Copilot - GPT-5.5 - [Fact] - public void End_Ordinal_IsUnchanged () - { - Assert.Equal (15, (int)Command.End); - } - [Fact] public void KillWordRight_Ordinal_IsUnchanged () { diff --git a/Tests/UnitTestsParallelizable/Input/CommandInsertCaretEnumTests.cs b/Tests/UnitTestsParallelizable/Input/CommandInsertCaretEnumTests.cs index 9dd2fcec54..0cb551e01e 100644 --- a/Tests/UnitTestsParallelizable/Input/CommandInsertCaretEnumTests.cs +++ b/Tests/UnitTestsParallelizable/Input/CommandInsertCaretEnumTests.cs @@ -53,18 +53,14 @@ public void InsertCaretCommands_AreAppendedAtEnd_NoRenumbering () Assert.True (belowValue > aboveValue); - // All members except the InsertCaret, mouse-selection, and ImageView commands (added later) + // All members except the InsertCaret and mouse-selection commands (added later) // should have lower values than InsertCaretAbove. HashSet tailCommands = [ Command.InsertCaretAbove, Command.InsertCaretBelow, Command.StartSelection, - Command.StartRectangleSelection, - Command.Home, - Command.Center, - Command.ZoomIn, - Command.ZoomOut + Command.StartRectangleSelection ]; Assert.All (