diff --git a/.gitignore b/.gitignore index 47a62c4e68..5bc2282ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ docfx/_site # Test Results UnitTests/TestResults TestResults +*.actual # git merge files *.orig 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..f5f4647f85 --- /dev/null +++ b/Examples/UICatalog/Scenarios/Images/Images.cs @@ -0,0 +1,467 @@ +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, + Title = "ImageView" + }; + + 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 = ["Euclidean", "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) + { + _basicImageView?.UseBackgroundRendering = enabled; + + _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 (); + + 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..6a3187e46b --- /dev/null +++ b/Examples/UICatalog/Scenarios/Images/MedianCutPaletteBuilder.cs @@ -0,0 +1,134 @@ +namespace UICatalog.Scenarios; + +internal class MedianCutPaletteBuilder : IPaletteBuilder +{ + public List BuildPalette (List colors, int maxColors) + { + if (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 new file mode 100644 index 0000000000..c83ca2921e --- /dev/null +++ b/Examples/UICatalog/Scenarios/Mandelbrot.cs @@ -0,0 +1,456 @@ +#nullable enable + +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 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!; + private NumericUpDown _centerY = null!; + private ProgressBar _fireProgress = null!; + private object? _fireProgressTimeout; + private NumericUpDown _iterations = null!; + private MandelbrotImageView _mandelbrotView = null!; + private NumericUpDown _span = null!; + private Label _status = null!; + private Window _window = null!; + private SixelSupportResult _sixelSupportResult = new (); + + public override void Main () + { + using IApplication app = Application.Create ().Init (DriverRegistry.Names.ANSI); + _app = app; + _app.Driver!.SixelSupportChanged += OnSixelSupportChanged; + + _window = new Window { 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 (), CanFocus = true }; + + _status = new Label { X = Pos.Align (Alignment.Start), Y = Pos.Align (Alignment.Start), Width = Dim.Fill (), Height = 1 }; + + _mandelbrotView = new MandelbrotImageView + { + X = Pos.Center (), + Y = Pos.Center (), + 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 (); + _mandelbrotView.ViewportChanged += (_, _) => RenderMandelbrot (); + + display.Add (_status, _mandelbrotView); + _window.Add (settings, display); + + _window.Initialized += (_, _) => + { + UpdateSixelSupport (_app.Driver?.SixelSupport); + RenderMandelbrot (); + StartFireProgress (); + + _app.AddTimeout (TimeSpan.FromMilliseconds (100), + () => + { + _mandelbrotView.SetFocus (); + + return false; + }); + }; + + try + { + app.Run (_window); + } + 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) + { + 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 + { + X = Pos.Right (centerXLabel) + 1, + Y = Pos.Top (centerXLabel), + Width = Dim.Fill (), + Value = -0.5, + Increment = 0.05, + Format = "{0:0.000}" + }; + + _centerY = new NumericUpDown + { + X = Pos.Right (centerYLabel) + 1, + Y = Pos.Top (centerYLabel), + Width = Dim.Fill (), + Value = 0, + Increment = 0.05, + Format = "{0:0.000}" + }; + + _span = new NumericUpDown + { + X = Pos.Right (spanLabel) + 1, + Y = Pos.Top (spanLabel), + Width = Dim.Fill (), + Value = 3, + Increment = 0.1, + Format = "{0:0.000}" + }; + + _iterations = new NumericUpDown + { + X = Pos.Right (iterationsLabel) + 1, + Y = Pos.Top (iterationsLabel), + Width = Dim.Fill (), + Value = 80, + Increment = 10 + }; + + _span.ValueChanging += (_, args) => + { + if (args.NewValue < MINIMUM_SPAN) + { + 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 = Pos.Align (Alignment.Start, groupId: RESET_GROUP_ID), Y = Pos.Bottom (iterationsLabel) + 2, Text = "_Reset" }; + + reset.Accepted += (_, _) => ResetSettings (); + + Button overlay = new () { X = Pos.Right (reset) + 1, Y = Pos.Top (reset), Text = "_Overlay" }; + + overlay.Accepted += (_, _) => ShowOverlay (); + + Label note = new () + { + 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: FIRE_LABEL_GROUP_ID), Y = Pos.AnchorEnd (2), Text = "Fire progress:" }; + + _fireProgress = new ProgressBar + { + X = Pos.Align (Alignment.Start, groupId: FIRE_PROGRESS_GROUP_ID), + 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 () + { + Button zoomOut = new () + { + X = Pos.AnchorEnd (2), + Y = Pos.AnchorEnd (1), + Width = 1, + Height = 1, + Text = "-", + CanFocus = false, + NoDecorations = true, + NoPadding = true, + ShadowStyle = null + }; + + Button zoomIn = new () + { + X = Pos.AnchorEnd (1), + Y = Pos.AnchorEnd (1), + Width = 1, + Height = 1, + Text = "+", + CanFocus = false, + NoDecorations = true, + NoPadding = true, + ShadowStyle = null + }; + + zoomOut.Accepted += (_, _) => + { + Zoom (ZOOM_OUT_FACTOR); + _mandelbrotView.SetFocus (); + }; + + zoomIn.Accepted += (_, _) => + { + Zoom (ZOOM_IN_FACTOR); + _mandelbrotView.SetFocus (); + }; + + _mandelbrotView.Add (zoomOut, zoomIn); + } + + 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 = 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 += ((position.X + 0.5d) / viewport.Width - 0.5d) * span; + _centerY.Value += ((position.Y + 0.5d) / viewport.Height - 0.5d) * spanY; + } + + private void OnSixelSupportChanged (object? sender, ValueChangedEventArgs args) + { + UpdateSixelSupport (args.NewValue); + RenderMandelbrot (); + } + + private void UpdateSixelSupport (SixelSupportResult? support) => _sixelSupportResult = support ?? new SixelSupportResult (); + + private void RenderMandelbrot () + { + SixelSupportResult support = _app.Driver?.SixelSupport ?? _sixelSupportResult; + 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; + double span = _span.Value; + + _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.SetNeedsDraw (); + } + + private void ResetSettings () + { + _centerX.Value = -0.5; + _centerY.Value = 0; + _span.Value = 3; + _iterations.Value = 80; + RenderMandelbrot (); + } + + private void ShowOverlay () + { + _mandelbrotView.SetNeedsDraw (); + + Dialog dialog = new () { Title = "Overlay Runnable", Width = 38, Height = 9 }; + + dialog.Add (new Label + { + 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." + }); + + dialog.AddButton (new Button { Text = "_OK", IsDefault = true }); + _app.Run (dialog); + dialog.Dispose (); + + _mandelbrotView.SetNeedsDraw (); + } + + private void StartFireProgress () => _fireProgressTimeout ??= _app.AddTimeout (TimeSpan.FromMilliseconds (80), AdvanceFireProgress); + + private void StopFireProgress () + { + if (_fireProgressTimeout is null) + { + return; + } + + _app.RemoveTimeout (_fireProgressTimeout); + _fireProgressTimeout = null; + } + + private void Zoom (double spanMultiplier) + { + double nextSpan = Math.Max (MINIMUM_SPAN, _span.Value * spanMultiplier); + + if (Math.Abs (nextSpan - _span.Value) < double.Epsilon) + { + return; + } + + _span.Value = nextSpan; + } + + private sealed class MandelbrotImageView : ImageView + { + 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) } }; + Image = CreateMandelbrotPixels (pixelWidth, pixelHeight, centerX, centerY, span, maxIterations); + } + + protected override bool CenterOnViewportPoint (Point position) + { + CenterRequested?.Invoke (position); + + return true; + } + + 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 (var y = 0; y < height; y++) + { + double cy = yMin + spanY * y / Math.Max (1, height - 1); + + for (var 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 Color GetMandelbrotColor (int iterations, int maxIterations) + { + if (iterations >= maxIterations) + { + return Color.Black; + } + + double t = (double)iterations / maxIterations; + 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); + } + } +} 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/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..93aff06820 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -93,6 +93,12 @@ 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); + } + // Process each row for (int row = top; row < rows; row++) { @@ -496,13 +502,166 @@ 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); 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); + Write (new StringBuilder (GetRasterImageSixelData (command, visibleCells, pixels))); + } + + command.IsDirty = false; + } + } + + 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)); + output.Append (GetRasterImageSixelData (command, visibleCells, pixels)); + wroteRasterImages = true; + } + } + + return wroteRasterImages; + } + + private static string GetRasterImageSixelData (RasterImageCommand command, Rectangle visibleCells, Color [,] pixels) + { + if (command.EncodedSixel is { } encodedSixel && visibleCells == command.DestinationCells) + { + return encodedSixel; + } + + SixelEncoder encoder = command.Encoder ?? new (); + + return 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..5ae51ccf8f 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,111 @@ public void Move (int col, int row) Row = row; } + /// + public void AddRasterImage (RasterImageCommand command) + { + ArgumentNullException.ThrowIfNull (command); + ArgumentException.ThrowIfNullOrEmpty (command.Id); + + 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 (); + 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); + } + } + + /// + public void RemoveRasterImage (string id) + { + ArgumentException.ThrowIfNullOrEmpty (id); + + lock (_contentsLock) + { + for (int i = _rasterImages.Count - 1; i >= 0; i--) + { + if (_rasterImages [i].Id != id) + { + + continue; + } + + MarkRasterImageCellsDirty (_rasterImages [i]); + _rasterImages.RemoveAt (i); + } + } + } + + /// + public IReadOnlyList GetRasterImages () + { + lock (_contentsLock) + { + return _rasterImages.ToArray (); + } + } + + 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) + { + 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 = isDirty; + DirtyLines [row] |= isDirty; + } + } + } + } + /// 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..5ff29347d5 --- /dev/null +++ b/Terminal.Gui/Drivers/Output/RasterImageCommand.cs @@ -0,0 +1,51 @@ +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 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 . + /// + 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/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 deleted file mode 100644 index 352a180b6a..0000000000 --- a/Terminal.Gui/Views/ImageView.cs +++ /dev/null @@ -1,465 +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 sixel pipeline. Sixel data -/// is only re-encoded and 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 Color [,]? _image; - private Color [,]? _scaledImage; - private Size? _scaledImageCellSize; - private SixelToRender? _sixelToRender; - private string? _cachedSixelData; - - // Cell-based rendering cache - private readonly Dictionary _attributeCache = new (); - - /// - /// 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; - _scaledImage = null; - _cachedSixelData = null; - _scaledImageCellSize = null; - _attributeCache.Clear (); - UpdateSixelData (); - SetNeedsDraw (); - } - } - - /// - /// 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 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 to the - /// terminal's during rendering. - /// - public SixelEncoder? SixelEncoder { get; set; } - - /// - /// 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 = new (imageSizeInPixels.Width, (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 { } 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); - - // 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 - { - DrawCellBased (); - - if (_scaledImageCellSize is { } 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; - } - - /// - protected override void OnFrameChanged (in Rectangle frame) - { - base.OnFrameChanged (frame); - UpdateSixelData (); - SetNeedsDraw (); - } - - /// - /// Renders the image using cell-based rendering where each terminal cell - /// gets the background color of the corresponding pixel. - /// - private void DrawCellBased () - { - if (_image is null) - { - return; - } - - if (_scaledImage is null) - { - _scaledImage = GetScaledImage (_image, Viewport.Width, Viewport.Height); - - if (_scaledImage is null) - { - return; - } - - _scaledImageCellSize = new Size (_scaledImage.GetLength (0), _scaledImage.GetLength (1)); - } - - 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 (int 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 () - { - SixelSupportResult? support = App?.Driver?.SixelSupport; - - if (support is null) - { - return; - } - - if (_cachedSixelData is null) - { - UpdateSixelData (); - } - - // Get screen position for this view's viewport - Point screenPos = ViewportToScreen ().Location; - - if (_sixelToRender is null) - { - _sixelToRender = new SixelToRender - { - SixelData = _cachedSixelData, - ScreenPosition = screenPos, - Id = $"ImageView_{GetHashCode ()}", - IsDirty = true - }; - - App?.Driver?.GetOutput ().GetSixels ().Enqueue (_sixelToRender); - } - else - { - _sixelToRender.SixelData = _cachedSixelData; - _sixelToRender.ScreenPosition = screenPos; - _sixelToRender.IsDirty = true; - } - } - - private void UpdateSixelData () - { - if (!IsUsingSixel || App?.Driver?.SixelSupport is not { } support || _image is null) - { - return; - } - - // Use caller-provided encoder or create a default one - SixelEncoder ??= new SixelEncoder (); - - // Clamp MaxColors regardless of whether the encoder was provided - SixelEncoder.Quantizer.MaxColors = Math.Min (SixelEncoder.Quantizer.MaxColors, support.MaxPaletteColors); - - Rectangle targetRect = ViewportToScreenInPixels (); - - // 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))); - - if (_scaledImage is null) - { - return; - } - - // Encode sixel data - _cachedSixelData = SixelEncoder.EncodeSixel (_scaledImage); - } - - /// - /// 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) - { - if (image is null || targetWidth <= 0 || targetHeight <= 0) - { - return null; - } - - int srcWidth = image.GetLength (0); - int srcHeight = image.GetLength (1); - - if (srcWidth == 0 || srcHeight == 0) - { - return null; - } - - // Calculate aspect-ratio-preserving size - double widthScale = (double)targetWidth / srcWidth; - double heightScale = (double)targetHeight / srcHeight; - double scale = Math.Min (widthScale, heightScale); - - int newWidth = Math.Max (1, (int)(srcWidth * scale)); - int newHeight = Math.Max (1, (int)(srcHeight * scale)); - - // We can start with the input image, maybe it's the correct size already - Color [,] scaledImage = image; - - // Nearest-neighbor scale - if (scaledImage.GetLength (0) != newWidth || scaledImage.GetLength (1) != newHeight) - { - scaledImage = new Color [newWidth, newHeight]; - ScaleNearestNeighbor (image, scaledImage); - } - - return scaledImage; - } - - /// - /// 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 (int y = 0; y < newHeight; y++) - { - int srcY = Math.Min (y * srcHeight / newHeight, srcHeight - 1); - - for (int x = 0; x < newWidth; x++) - { - int srcX = Math.Min (x * srcWidth / newWidth, srcWidth - 1); - destination [x, y] = source [srcX, srcY]; - } - } - } - - /// - protected override void Dispose (bool disposing) - { - if (disposing && _sixelToRender is { }) - { - // 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; - } - - base.Dispose (disposing); - } - - /// - bool IDesignable.EnableForDesign () - { - // Create a simple gradient test image for the designer - int width = 20; - int height = 10; - Color [,] testImage = new Color [width, height]; - - for (int y = 0; y < height; y++) - { - for (int 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); - testImage [x, y] = new Color (r, g, b); - } - } - - Image = testImage; - - return true; - } -} \ No newline at end of file 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/Terminal.Gui/Views/ProgressBar.cs b/Terminal.Gui/Views/ProgressBar.cs index af43985352..46d71f5c05 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,58 @@ 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 SixelEncoder? _fireEncoder; + private int _fireEncoderMaxColors; + private int _fireFrame; + private readonly string _fireRasterImageId = $"{nameof (ProgressBar)}.{Guid.NewGuid ():N}.Fire"; private float _fraction; private bool _isActivity; private bool _syncWithTerminal; @@ -118,8 +169,6 @@ public ProgressBarStyle ProgressBarStyle get; set { - field = value; - switch (value) { case ProgressBarStyle.Blocks: @@ -141,8 +190,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; @@ -160,7 +220,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; } @@ -176,6 +236,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 +253,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,11 +285,12 @@ 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) { @@ -230,6 +308,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 +389,112 @@ 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 = GetFireEncoder (support) + }; + + Driver.GetOutputBuffer ().AddRasterImage (command); + + return true; + } + + private SixelEncoder GetFireEncoder (SixelSupportResult support) + { + int maxColors = Math.Min (support.MaxPaletteColors, _firePalette.Length); + + 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) + { + 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 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 () { if (!_syncWithTerminal || Driver?.ProgressIndicator is not { } progressIndicator) @@ -337,12 +527,12 @@ public bool EnableForDesign () { Width = Dim.Fill (); Height = Dim.Auto (DimAutoStyle.Text, 1); + CanFocus = true; Fraction = 0.75f; return true; } /// - 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/Drivers/Output/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs index 5c92009c0f..9c2bc3bf50 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs @@ -948,6 +948,294 @@ 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 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 + [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)] + [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 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 () + { + // 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 +1331,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; + } } diff --git a/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs b/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs index fd33aea010..83abda0b57 100644 --- a/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs @@ -16,12 +16,75 @@ 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.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)); imageView.Dispose (); } #endregion Construction and Defaults + #region Background Rendering + + // Copilot - GPT-5.5 + [Fact] + public void BackgroundRendering_WhenEnabled_ShowsSpinnerBeforeFirstDraw () + { + 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); + + SpinnerView overlay = Assert.Single (imageView.SubViews.OfType ()); + Assert.True (overlay.Visible); + Assert.True (overlay.AutoSpin); + Assert.IsType (overlay.Style); + + app.LayoutAndDraw (); + + Assert.True (overlay.Visible); + Assert.True (overlay.AutoSpin); + Assert.IsType (overlay.Style); + + runnable.Dispose (); + } + + #endregion Background Rendering + #region Image Property [Fact] @@ -136,6 +199,184 @@ public void SixelEncoder_CanBeSet () imageView.Dispose (); } + // Copilot - GPT-5.5 + [Fact] + public void MaxSixelPaletteColors_SetInvalid_Throws () + { + ImageView imageView = new (); + + Assert.Throws (() => imageView.MaxSixelPaletteColors = 0); + + imageView.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void SixelRendering_DefaultEncoder_UsesInteractivePaletteLimit () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (1, 1), MaxPaletteColors = 256 }); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + ImageView imageView = new () { Width = 2, Height = 2, Image = CreateCoordinateImage (4, 4) }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + Assert.NotNull (imageView.SixelEncoder); + Assert.Equal (64, imageView.SixelEncoder!.Quantizer.MaxColors); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void SixelRendering_DefaultEncoder_ClampsPaletteLimitToTerminalSupport () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (1, 1), MaxPaletteColors = 64 }); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + ImageView imageView = new () { Width = 2, Height = 2, Image = CreateCoordinateImage (4, 4) }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + Assert.NotNull (imageView.SixelEncoder); + Assert.Equal (64, imageView.SixelEncoder!.Quantizer.MaxColors); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void SixelRendering_CustomEncoder_KeepsPaletteLimitAboveInteractiveDefault () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (1, 1), MaxPaletteColors = 256 }); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + SixelEncoder encoder = new (); + encoder.Quantizer.MaxColors = 200; + ImageView imageView = new () { Width = 2, Height = 2, Image = CreateCoordinateImage (4, 4), SixelEncoder = encoder }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + Assert.Equal (200, encoder.Quantizer.MaxColors); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void SixelRendering_CanDisableUpscaling_WhenViewportWouldUpscale () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (10, 10), MaxPaletteColors = 256 }); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + ImageView imageView = new () + { + Width = 4, + Height = 4, + AllowSixelUpscaling = false, + Image = CreateCoordinateImage (4, 4) + }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + RasterImageCommand command = Assert.Single (driver.GetOutputBuffer ().GetRasterImages ()); + Assert.NotNull (command.Pixels); + Assert.Equal (4, command.Pixels!.GetLength (0)); + Assert.Equal (4, command.Pixels.GetLength (1)); + Assert.Equal (new Rectangle (0, 0, 1, 1), command.DestinationCells); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void SixelRendering_DefaultUpscaling_ScalesToViewport () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (10, 10), MaxPaletteColors = 256 }); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + ImageView imageView = new () + { + Width = 4, + Height = 4, + Image = CreateCoordinateImage (4, 4) + }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + RasterImageCommand command = Assert.Single (driver.GetOutputBuffer ().GetRasterImages ()); + Assert.NotNull (command.Pixels); + Assert.Equal (40, command.Pixels!.GetLength (0)); + Assert.Equal (40, command.Pixels.GetLength (1)); + Assert.Equal (new Rectangle (0, 0, 4, 4), command.DestinationCells); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void SixelRendering_SupportResolutionChange_RecomputesWithoutResize () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (1, 1), MaxPaletteColors = 256 }); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + ImageView imageView = new () { Width = 2, Height = 2, Image = CreateCoordinateImage (4, 4) }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + RasterImageCommand firstCommand = Assert.Single (driver.GetOutputBuffer ().GetRasterImages ()); + Assert.NotNull (firstCommand.Pixels); + Assert.Equal (2, firstCommand.Pixels!.GetLength (0)); + Assert.Equal (2, firstCommand.Pixels.GetLength (1)); + + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (10, 10), MaxPaletteColors = 256 }); + imageView.SetNeedsDraw (); + app.LayoutAndDraw (); + + RasterImageCommand secondCommand = Assert.Single (driver.GetOutputBuffer ().GetRasterImages ()); + Assert.NotNull (secondCommand.Pixels); + Assert.Equal (20, secondCommand.Pixels!.GetLength (0)); + Assert.Equal (20, secondCommand.Pixels.GetLength (1)); + + runnable.Dispose (); + } + #endregion SixelEncoder Property #region ScaleNearestNeighbor @@ -323,8 +564,312 @@ public void CellBasedRendering_ScalesImageToViewport () runnable.Dispose (); } + // Copilot - GPT-5.5 + [Fact] + public void CellBasedRendering_TargetSizeChange_RecomputesScaledImage () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 10, Height = 10 }; + app.Begin (runnable); + + Color [,] image = CreateCoordinateImage (4, 4); + NonInvalidatingImageView imageView = new () { Width = 2, Height = 2, UseSixel = false, Image = image }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + imageView.Width = 4; + imageView.Height = 4; + app.LayoutAndDraw (); + + AssertCellBackground (app.Driver!, 3, 3, image [3, 3]); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void CellBasedRendering_StretchesImageToViewport () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 10, Height = 10 }; + app.Begin (runnable); + + Color [,] image = CreateCoordinateImage (4, 4); + ImageView imageView = new () { Width = 4, Height = 2, UseSixel = false, Image = image }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + AssertCellBackground (app.Driver!, 0, 0, image [0, 0]); + AssertCellBackground (app.Driver!, 3, 1, image [3, 2]); + + runnable.Dispose (); + } + #endregion Cell-Based Rendering + #region Pan and Zoom + + // Copilot - GPT-5.5 + [Fact] + public void Commands_ZoomInZoomOutAndHome_UpdateZoomLevel () + { + ImageView imageView = new () { Width = 2, Height = 2, Image = CreateCoordinateImage (4, 4) }; + View host = new () { Width = 2, Height = 2 }; + host.Add (imageView); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + Assert.True (imageView.InvokeCommand (Command.ZoomIn)); + Assert.True (imageView.ZoomLevel > 1d); + + Assert.True (imageView.InvokeCommand (Command.ZoomOut)); + Assert.Equal (1d, imageView.ZoomLevel); + + imageView.ZoomLevel = 2d; + Assert.True (imageView.InvokeCommand (Command.Home)); + Assert.Equal (1d, imageView.ZoomLevel); + + host.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void KeyBindings_ZoomScrollAndHome_UpdateView () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + Color [,] image = CreateCoordinateImage (4, 4); + ImageView imageView = new () { Width = 2, Height = 2, UseSixel = false, Image = image }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + Assert.True (imageView.NewKeyDownEvent (Key.PageUp)); + Assert.True (imageView.ZoomLevel > 1d); + + imageView.ZoomLevel = 2d; + app.LayoutAndDraw (); + AssertCellBackground (app.Driver!, 0, 0, image [1, 1]); + + Assert.True (imageView.NewKeyDownEvent (Key.CursorRight)); + app.LayoutAndDraw (); + AssertCellBackground (app.Driver!, 0, 0, image [2, 1]); + + Assert.True (imageView.NewKeyDownEvent (Key.Home)); + Assert.Equal (1d, imageView.ZoomLevel); + app.LayoutAndDraw (); + AssertCellBackground (app.Driver!, 0, 0, image [0, 0]); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void KeyBindings_PageDown_ZoomsOutBelowFit () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + ImageView imageView = new () { Width = 4, Height = 4, UseSixel = false, Image = CreateCoordinateImage (4, 4) }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + for (int i = 0; i < 12; i++) + { + imageView.NewKeyDownEvent (Key.PageDown); + } + + Assert.True (imageView.ZoomLevel < 1d); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void ApplicationKeyDispatch_WhenFocused_ZoomsAndScrolls () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + Color [,] image = CreateCoordinateImage (4, 4); + ImageView imageView = new () { Width = 2, Height = 2, UseSixel = false, Image = image }; + runnable.Add (imageView); + app.LayoutAndDraw (); + imageView.SetFocus (); + + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.PageUp)); + Assert.True (imageView.ZoomLevel > 1d); + + imageView.ZoomLevel = 2d; + app.LayoutAndDraw (); + AssertCellBackground (app.Driver!, 0, 0, image [1, 1]); + + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.CursorRight)); + app.LayoutAndDraw (); + AssertCellBackground (app.Driver!, 0, 0, image [2, 1]); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void ApplicationKeyDispatch_WhenFocused_EatsScrollKeysEvenWithoutPan () + { + 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, UseSixel = false, Image = CreateCoordinateImage (2, 2) }; + runnable.Add (imageView); + app.LayoutAndDraw (); + imageView.SetFocus (); + + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.CursorLeft)); + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.CursorRight)); + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.CursorUp)); + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.CursorDown)); + Assert.Equal (1d, imageView.ZoomLevel); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void CellBasedRendering_ZoomAndScroll_ChangesVisiblePixels () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + Color [,] image = CreateCoordinateImage (4, 4); + ImageView imageView = new () { Width = 2, Height = 2, UseSixel = false, Image = image }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + imageView.ZoomLevel = 2d; + app.LayoutAndDraw (); + AssertCellBackground (app.Driver!, 0, 0, image [1, 1]); + + Assert.True (imageView.InvokeCommand (Command.ScrollRight)); + app.LayoutAndDraw (); + AssertCellBackground (app.Driver!, 0, 0, image [2, 1]); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void MouseWheel_ZoomsInAndOut () + { + ImageView imageView = new () { Width = 2, Height = 2, Image = CreateCoordinateImage (4, 4) }; + View host = new () { Width = 2, Height = 2 }; + host.Add (imageView); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + Assert.True (imageView.NewMouseEvent (new Mouse { Flags = MouseFlags.WheeledUp, Position = new (1, 1) })); + Assert.True (imageView.ZoomLevel > 1d); + + Assert.True (imageView.NewMouseEvent (new Mouse { Flags = MouseFlags.WheeledDown, Position = new (1, 1) })); + Assert.Equal (1d, imageView.ZoomLevel); + + host.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void DoubleClick_CentersOnClickedPoint () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + Color [,] image = CreateCoordinateImage (4, 4); + ImageView imageView = new () { Width = 2, Height = 2, UseSixel = false, Image = image, ZoomLevel = 2d }; + runnable.Add (imageView); + app.LayoutAndDraw (); + AssertCellBackground (app.Driver!, 0, 0, image [1, 1]); + + Assert.True (imageView.NewMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonDoubleClicked, Position = new (0, 0) })); + app.LayoutAndDraw (); + AssertCellBackground (app.Driver!, 0, 0, image [0, 0]); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void Drag_PansImage () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + Color [,] image = CreateCoordinateImage (4, 4); + ImageView imageView = new () { Width = 2, Height = 2, UseSixel = false, Image = image, ZoomLevel = 2d }; + runnable.Add (imageView); + app.LayoutAndDraw (); + AssertCellBackground (app.Driver!, 0, 0, image [1, 1]); + + Assert.True (imageView.NewMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed, Position = new (1, 0) })); + Assert.True (imageView.NewMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, Position = new (0, 0) })); + Assert.True (imageView.NewMouseEvent (new Mouse { Flags = MouseFlags.LeftButtonReleased, Position = new (0, 0) })); + app.LayoutAndDraw (); + AssertCellBackground (app.Driver!, 0, 0, image [2, 1]); + + runnable.Dispose (); + } + + // Copilot - GPT-5.5 + [Fact] + public void SixelRendering_Zoom_UsesVisiblePixels () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new () { IsSupported = true, Resolution = new (1, 1) }); + + Runnable runnable = new () { Width = 4, Height = 4 }; + app.Begin (runnable); + + Color [,] image = CreateCoordinateImage (4, 4); + ImageView imageView = new () { Width = 2, Height = 2, Image = image, ZoomLevel = 2d, SixelEncoder = new () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + RasterImageCommand command = Assert.Single (driver.GetOutputBuffer ().GetRasterImages ()); + Assert.NotNull (command.Pixels); + Assert.Equal (image [1, 1], command.Pixels! [0, 0]); + + runnable.Dispose (); + } + + #endregion Pan and Zoom + #region Dispose [Fact] @@ -932,6 +1477,28 @@ public void FitImageInViewportCells_VerySmallImage_ClampsToMinimumOne () return image; } + private static Color [,] CreateCoordinateImage (int width, int height) + { + Color [,] image = new Color [width, height]; + + for (int x = 0; x < width; x++) + { + for (int y = 0; y < height; y++) + { + image [x, y] = new ((byte)(x * 40 + 10), (byte)(y * 40 + 20), (byte)(x * 10 + y)); + } + } + + return image; + } + + private static void AssertCellBackground (IDriver driver, int col, int row, Color expected) + { + Cell [,]? contents = driver.Contents; + Assert.NotNull (contents); + Assert.Equal (expected, contents! [row, col].Attribute!.Value.Background); + } + /// Creates a gradient image where pixel color varies by position. private static Color [,] CreateGradientImage (int width, int height) { @@ -950,5 +1517,10 @@ public void FitImageInViewportCells_VerySmallImage_ClampsToMinimumOne () return image; } + private sealed class NonInvalidatingImageView : ImageView + { + protected override void OnFrameChanged (in Rectangle frame) { } + } + #endregion Helper Methods } \ No newline at end of file diff --git a/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs b/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs index 8d6d7f3436..ef6b40c4fa 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 Region (driver.Screen); + 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 (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,35 +105,114 @@ 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.IsAssignableFrom (command.Encoder!.Quantizer.PaletteBuildingAlgorithm); + 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 Pulse_FirstCall_DrawsMarkerAtStartPosition () + public void FireStyle_SixelSupport_ReusesEncoderAcrossFrames () { - // Width=5 → _activityPos.Length = Math.Min(5/3=1, 5) = 1, initialised 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); + 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 () + { + 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 = 5, - ProgressBarStyle = ProgressBarStyle.MarqueeBlocks + 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 () + { + 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 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 (driver.Screen); + + ProgressBar pb = new () { Driver = driver, Width = 5, ProgressBarStyle = ProgressBarStyle.MarqueeBlocks }; + pb.BeginInit (); + pb.EndInit (); + pb.LayoutSubViews (); + 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 +224,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 +236,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 +254,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 +266,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 +279,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 +288,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 (); 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 0000000000..f60f0dc9ed Binary files /dev/null and b/docfx/images/Mandelbrot.gif differ