diff --git a/Examples/UICatalog/Scenarios/Images/Images.cs b/Examples/UICatalog/Scenarios/Images/Images.cs index f5f4647f85..18428d1450 100644 --- a/Examples/UICatalog/Scenarios/Images/Images.cs +++ b/Examples/UICatalog/Scenarios/Images/Images.cs @@ -1,49 +1,47 @@ +#nullable enable + 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.")] +[ScenarioMetadata ("Images", "Demonstration of cell and raster image rendering with runtime capability detection.")] [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 const string FIRE_RASTER_IMAGE_ID = "UICatalog.Images.Fire"; + private const int RASTER_PROTOCOL_AUTO = 0; + private const int RASTER_PROTOCOL_KITTY = 1; + private const int RASTER_PROTOCOL_SIXEL = 2; + + private IApplication _app = null!; + private ImageView _cellImageView = null!; + private CheckBox _cbUseRasterGraphics = null!; + private Label _cellStatus = null!; + private Label _driverStatus = null!; + private DoomFire? _fire; + private SixelEncoder _fireEncoder = new (); private int _fireFrameCounter; + private Image? _fullResImage; 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 Label _kittyStatus = null!; + private KittyGraphicsSupportResult? _kittyGraphicsSupportResult; + private OptionSelector _osDistanceAlgorithm = null!; + private OptionSelector _osPaletteBuilder = null!; + private OptionSelector _osRasterProtocol = null!; + private NumericUpDown _popularityThreshold = null!; + private NumericUpDown _pxX = null!; + private NumericUpDown _pxY = null!; + private ImageView _rasterImageView = null!; + private View _rasterSettings = null!; + private Label _selectedStatus = null!; + private Label _sixelStatus = null!; + private SixelSupportResult? _sixelSupportResult; + private View _tabRaster = null!; + private Window _win = null!; private Size _winSize; public override void Main () @@ -51,74 +49,85 @@ public override void Main () ConfigurationManager.Enable (ConfigLocations.All); using IApplication app = Application.Create (); app.Init (); + IDriver driver = app.Driver ?? throw new InvalidOperationException ("The application driver was not initialized."); _app = app; _win = new Window { Title = $"{Application.GetDefaultKey (Command.Quit)} to Quit - Scenario: {GetName ()}" }; - bool canTrueColor = app.Driver?.SupportsTrueColor ?? false; + View tabCell = new () { Title = "_Cell renderer", Width = Dim.Fill (), Height = Dim.Fill () }; + _tabRaster = new View { Title = "_Raster graphics", Width = Dim.Fill (), Height = Dim.Fill () }; - View tabBasic = new () { Title = "_Cell-based", Width = Dim.Fill (), Height = Dim.Fill () }; - _tabSixel = new View { Title = "_Sixel-based", Width = Dim.Fill (), Height = Dim.Fill () }; + FrameView capabilityMatrix = BuildCapabilityMatrix (); + _win.Add (capabilityMatrix); - CheckBox cbSupportsTrueColor = new () - { - Y = 0, Value = canTrueColor ? CheckState.Checked : CheckState.UnChecked, CanFocus = false, Text = "Driver supports true color" - }; - _win.Add (cbSupportsTrueColor); + _cbUseRasterGraphics = new CheckBox { Y = Pos.Bottom (capabilityMatrix), Value = CheckState.Checked, Text = "Use raster ImageView" }; + _cbUseRasterGraphics.ValueChanging += (_, evt) => SetUseRasterGraphics (evt.NewValue == CheckState.Checked); + _win.Add (_cbUseRasterGraphics); - _cbSupportsSixel = new CheckBox { X = Pos.Right (cbSupportsTrueColor) + 2, Y = 1, Value = CheckState.UnChecked, Text = "Supports Sixel" }; + bool canTrueColor = driver.SupportsTrueColor; - _cbSupportsSixel.ValueChanging += (_, e) => - { - _sixelSupportResult.IsSupported = e.NewValue == CheckState.Checked; - _tabSixel.Enabled = _sixelSupportResult.IsSupported; - }; + CheckBox cbUseTrueColor = new () + { + X = Pos.Right (_cbUseRasterGraphics) + 2, + Y = Pos.Top (_cbUseRasterGraphics), + Value = !Driver.Force16Colors ? CheckState.Checked : CheckState.UnChecked, + Enabled = canTrueColor, + Text = "Use true color" + }; - _win.Add (_cbSupportsSixel); + cbUseTrueColor.ValueChanging += (_, evt) => + { + Driver.Force16Colors = evt.NewValue == CheckState.UnChecked; + UpdateRasterCapabilityMatrix (); + }; + _win.Add (cbUseTrueColor); CheckBox cbUseBackgroundRendering = new () { - X = Pos.Right (_cbSupportsSixel) + 2, - Y = Pos.Top (_cbSupportsSixel), + X = Pos.Right (cbUseTrueColor) + 2, + Y = Pos.Top (cbUseTrueColor), Value = CheckState.Checked, Text = "Async rendering" }; cbUseBackgroundRendering.ValueChanging += (_, evt) => SetBackgroundRendering (evt.NewValue == CheckState.Checked); _win.Add (cbUseBackgroundRendering); - CheckBox cbUseTrueColor = new () + Label protocolLabel = new () { Y = Pos.Bottom (_cbUseRasterGraphics), Text = "Raster protocol:" }; + + _osRasterProtocol = new OptionSelector { - X = Pos.Right (cbSupportsTrueColor) + 2, - Y = 0, - Value = !Driver.Force16Colors ? CheckState.Checked : CheckState.UnChecked, - Enabled = canTrueColor, - Text = "Use true color" + X = Pos.Right (protocolLabel) + 1, + Y = Pos.Top (protocolLabel), + Orientation = Orientation.Horizontal, + Labels = ["Auto", "Kitty", "Sixel"], + Values = [RASTER_PROTOCOL_AUTO, RASTER_PROTOCOL_KITTY, RASTER_PROTOCOL_SIXEL], + Value = RASTER_PROTOCOL_AUTO }; - cbUseTrueColor.ValueChanging += (_, evt) => Driver.Force16Colors = evt.NewValue == CheckState.UnChecked; - _win.Add (cbUseTrueColor); + _osRasterProtocol.ValueChanged += (_, _) => ApplyRasterProtocolSelection (); + _win.Add (protocolLabel, _osRasterProtocol); - Button btnOpenImage = new () { Y = Pos.Bottom (cbUseTrueColor), Text = "_Open Image" }; + Button btnOpenImage = new () { Y = Pos.Bottom (_osRasterProtocol), Text = "_Open Image" }; _win.Add (btnOpenImage); - Button btnStartFire = new () { X = Pos.Right (btnOpenImage), Y = Pos.Bottom (cbUseTrueColor), Text = "Start _Fire" }; + Button btnStartFire = new () { X = Pos.Right (btnOpenImage), Y = Pos.Top (btnOpenImage), 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); + BuildCellTab (tabCell); + BuildRasterTab (_tabRaster); + tabs.Add (tabCell, _tabRaster); 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; + _win.Initialized += (_, _) => UpdateRasterSupportState (driver.SixelSupport, driver.KittyGraphicsSupport); + driver.SixelSupportChanged += Driver_SixelSupportChanged; + driver.KittyGraphicsSupportChanged += Driver_KittyGraphicsSupportChanged; try { @@ -126,7 +135,9 @@ public override void Main () } finally { - app.Driver!.SixelSupportChanged -= Driver_SixelSupportChanged; + driver.SixelSupportChanged -= Driver_SixelSupportChanged; + driver.KittyGraphicsSupportChanged -= Driver_KittyGraphicsSupportChanged; + driver.GetOutputBuffer ().RemoveRasterImage (FIRE_RASTER_IMAGE_ID); _win.Dispose (); } } @@ -136,52 +147,104 @@ protected override void Dispose (bool disposing) { base.Dispose (disposing); _fullResImage?.Dispose (); + _app.Driver?.GetOutputBuffer ().RemoveRasterImage (FIRE_RASTER_IMAGE_ID); _isDisposed = true; } private bool AdvanceFireTimerCallback () { - _fire.AdvanceFrame (); + if (_isDisposed) + { + return false; + } + + if (_fire is not { } fire) + { + return false; + } + + fire.AdvanceFrame (); _fireFrameCounter++; // Control frame rate by adjusting this. Lower number means more FPS. - if (_fireFrameCounter % 2 != 0 || _isDisposed) + if (_fireFrameCounter % 2 != 0) { - return !_isDisposed; + return true; } - Color [,] bmp = _fire.GetFirePixels (); - string sixelFireData = _fireEncoder.EncodeSixel (bmp); + Color [,] bmp = fire.GetFirePixels (); - if (_fireSixel == null) + RasterImageCommand command = new () { - _fireSixel = new SixelToRender { SixelData = sixelFireData, ScreenPosition = GetFireScreenPosition (), Id = "fireSixel", AlwaysRender = true }; + Id = FIRE_RASTER_IMAGE_ID, + Pixels = bmp, + DestinationCells = GetFireDestinationCells (), + Encoder = IsSixelRasterPath () ? _fireEncoder : null, + AlwaysRender = true, + RenderAfterText = true + }; + + _win.SetNeedsDraw (); + _app.Driver?.GetOutputBuffer ().AddRasterImage (command); + + return true; + } - _app.Driver?.GetOutput ().GetSixels ().Enqueue (_fireSixel); + private void ApplyRasterProtocolSelection () + { + if (_app.Driver?.GetOutput () is { } output) + { + output.UseKittyGraphics = ShouldUseKittyGraphics (); } - else + + if (_rasterImageView.Image is { } image) { - _fireSixel.SixelData = sixelFireData; - _fireSixel.ScreenPosition = GetFireScreenPosition (); + UpdateRasterImage (image); } - _win.SetNeedsDraw (); + if (_fire is not null) + { + GenerateRasterFire (false); + } - return !_isDisposed; + UpdateRasterCapabilityMatrix (); } - private void BtnStartFireOnAccept (object sender, CommandEventArgs e) + private FrameView BuildCapabilityMatrix () { - if (_fire != null) + FrameView matrix = new () { Title = "Raster Capability Matrix", Width = Dim.Fill (), Height = 7, CanFocus = false }; + + _driverStatus = new Label { Y = 0, Width = Dim.Fill () }; + Label header = new () { Y = 1, Width = Dim.Fill (), Text = "Renderer Available Resolution Notes" }; + _cellStatus = new Label { Y = 2, Width = Dim.Fill () }; + _kittyStatus = new Label { Y = 3, Width = Dim.Fill () }; + _sixelStatus = new Label { Y = 4, Width = Dim.Fill () }; + _selectedStatus = new Label { Y = 5, Width = Dim.Fill () }; + + matrix.Add (_driverStatus, header, _cellStatus, _kittyStatus, _sixelStatus, _selectedStatus); + + return matrix; + } + + private void BtnStartFireOnAccept (object? sender, CommandEventArgs e) + { + if (_fire is not null) { return; } - if (!_sixelSupportResult.SupportsTransparency) + if (!IsRasterAvailable ()) { - if (MessageBox.Query (_app!, + MessageBox.ErrorQuery (_app, "Raster Graphics Not Available", "This terminal did not report Kitty graphics or Sixel support.", "Ok"); + + return; + } + + if (IsSixelRasterPath () && _sixelSupportResult is not { SupportsTransparency: true }) + { + if (MessageBox.Query (_app, "Transparency Not Supported", - "It looks like your terminal does not support transparent sixel backgrounds. Do you want to try anyway?", + "It looks like your terminal does not support transparent Sixel backgrounds. Do you want to try anyway?", "Yes", "No") != 0) @@ -192,95 +255,98 @@ private void BtnStartFireOnAccept (object sender, CommandEventArgs e) _winSize = _win.Viewport.Size; - GenerateSixelFire (true); + GenerateRasterFire (true); } - private void BuildBasicTab (View tabBasic) + private void BuildCellTab (View tabCell) { - _basicImageView = new ImageView + _cellImageView = new ImageView { BorderStyle = LineStyle.Dotted, Width = 30, Height = 10, CanFocus = true, Arrangement = ViewArrangement.Resizable, - UseSixel = false, - UseBackgroundRendering = true + UseRasterGraphics = false, + UseBackgroundRendering = true, + Title = "Cell ImageView" }; - tabBasic.Add (_basicImageView); + tabCell.Add (_cellImageView); } - private void BuildSixelTab (View tabSixel) + private void BuildRasterTab (View tabRaster) { - _sixelSettings = new View { X = Pos.AnchorEnd (), Width = Dim.Auto (), Height = Dim.Auto (), CanFocus = true }; + _rasterSettings = new View { X = Pos.AnchorEnd (), Width = Dim.Auto (), Height = Dim.Auto (), CanFocus = true }; - _sixelImageView = new ImageView + _rasterImageView = new ImageView { CanFocus = true, Width = 30, Height = 10, BorderStyle = LineStyle.Dotted, Arrangement = ViewArrangement.Resizable, - UseSixel = true, + UseRasterGraphics = true, UseBackgroundRendering = true, - Title = "ImageView" + Title = "Raster ImageView" }; - Label lblPxX = new () { Y = 0, Text = "Pixels per Col:" }; + Label lblPxX = new () { Y = 0, Text = "Fire pixels/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:" }; + Label lblPxY = new () { X = lblPxX.X, Y = Pos.Bottom (_pxX), Text = "Fire pixels/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 }; + Label paletteLabel = new () { Text = "Sixel Palette Builder", 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 }; + Label distanceLabel = new () { Text = "Sixel Color Distance", 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); + _rasterSettings.Add (lblPxX); + _rasterSettings.Add (_pxX); + _rasterSettings.Add (lblPxY); + _rasterSettings.Add (_pxY); + _rasterSettings.Add (paletteLabel); + _rasterSettings.Add (_osPaletteBuilder); + _rasterSettings.Add (distanceLabel); + _rasterSettings.Add (_osDistanceAlgorithm); + _rasterSettings.Add (_popularityThreshold); + _rasterSettings.Add (lblPopThreshold); + + tabRaster.Add (_rasterImageView, _rasterSettings); } - private void SetBackgroundRendering (bool enabled) - { - _basicImageView?.UseBackgroundRendering = enabled; - - _sixelImageView?.UseBackgroundRendering = enabled; - } + private void Driver_KittyGraphicsSupportChanged (object? sender, ValueChangedEventArgs e) => + UpdateRasterSupportState (_app.Driver?.SixelSupport, e.NewValue); - private void Driver_SixelSupportChanged (object sender, ValueChangedEventArgs e) => UpdateSixelSupportState (e.NewValue); + private void Driver_SixelSupportChanged (object? sender, ValueChangedEventArgs e) => + UpdateRasterSupportState (e.NewValue, _app.Driver?.KittyGraphicsSupport); - private void GenerateSixelFire (bool addTimeout) + private void GenerateRasterFire (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 = new SixelEncoder { AvoidBottomScroll = IsSixelRasterPath () }; + + if (_sixelSupportResult is { } sixel) + { + _fireEncoder.Quantizer.MaxColors = Math.Min (_fireEncoder.Quantizer.MaxColors, sixel.MaxPaletteColors); + } + _fireEncoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (_fire.Palette); _fireFrameCounter = 0; if (addTimeout) { - _app?.AddTimeout (TimeSpan.FromMilliseconds (30), AdvanceFireTimerCallback); + _app.AddTimeout (TimeSpan.FromMilliseconds (30), AdvanceFireTimerCallback); } } @@ -296,26 +362,30 @@ 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); + Size resolution = GetPreferredRasterResolution (); int pixelWidth = fireCellSize.Width * pixelsPerColumn; - int pixelHeight = Math.Max (1, fireCellSize.Height * pixelsPerRow - 6); + int pixelHeight = fireCellSize.Height * pixelsPerRow; - return new Size (Math.Max (1, (pixelWidth + resolutionWidth - 1) / resolutionWidth), - Math.Max (1, (pixelHeight + resolutionHeight - 1) / resolutionHeight)); + if (IsSixelRasterPath ()) + { + pixelHeight = Math.Max (1, pixelHeight - 6); + } + + return new Size (Math.Max (1, (pixelWidth + resolution.Width - 1) / resolution.Width), + Math.Max (1, (pixelHeight + resolution.Height - 1) / resolution.Height)); } - private Point GetFireScreenPosition () + private Rectangle GetFireDestinationCells () { Rectangle frameScreen = _win.FrameToScreen (); Size fireCellSize = GetFireRenderedCellSize (); - return new Point (frameScreen.X, Math.Max (frameScreen.Y, frameScreen.Bottom - fireCellSize.Height)); + return new Rectangle (frameScreen.X, Math.Max (frameScreen.Y, frameScreen.Bottom - fireCellSize.Height), fireCellSize.Width, fireCellSize.Height); } - private int GetDefaultFirePixelsPerColumn () => Math.Max (1, _sixelSupportResult.Resolution.Width); + private int GetDefaultFirePixelsPerColumn () => Math.Max (1, GetPreferredRasterResolution ().Width); - private int GetDefaultFirePixelsPerRow () => Math.Max (1, _sixelSupportResult.Resolution.Height); + private int GetDefaultFirePixelsPerRow () => Math.Max (1, GetPreferredRasterResolution ().Height); private IColorDistance GetDistanceAlgorithm () { @@ -341,11 +411,44 @@ private IPaletteBuilder GetPaletteBuilder () } } + private Size GetPreferredRasterResolution () + { + if (ShouldUseKittyGraphics () && _kittyGraphicsSupportResult is { IsSupported: true } kitty) + { + return kitty.Resolution; + } + + if (_sixelSupportResult is { IsSupported: true } sixel) + { + return sixel.Resolution; + } + + if (_kittyGraphicsSupportResult is { } detectedKitty) + { + return detectedKitty.Resolution; + } + + if (_sixelSupportResult is { } detectedSixel) + { + return detectedSixel.Resolution; + } + + return new Size (10, 20); + } + + private bool IsImageViewKittyActive (bool rasterEnabled) => rasterEnabled && ShouldUseKittyGraphics (); + + private bool IsRasterAvailable () => _kittyGraphicsSupportResult is { IsSupported: true } || _sixelSupportResult is { IsSupported: true }; + + private bool IsImageViewSixelActive (bool rasterEnabled) => rasterEnabled && !ShouldUseKittyGraphics () && _sixelSupportResult is { IsSupported: true }; + + private bool IsSixelRasterPath () => !ShouldUseKittyGraphics () && _sixelSupportResult is { IsSupported: true }; + private void LoadDefaultImage () { Color [,] image = ImagesTestCard.Create (ImagesTestCard.DEFAULT_WIDTH, ImagesTestCard.DEFAULT_HEIGHT); - _basicImageView.Image = image; - UpdateSixelImage (image); + _cellImageView.Image = image; + UpdateRasterImage (image); } private void LoadImage (string path, bool showError) @@ -360,7 +463,7 @@ private void LoadImage (string path, bool showError) { if (showError) { - MessageBox.ErrorQuery (_app!, "Could not open file", ex.Message, "Ok"); + MessageBox.ErrorQuery (_app, "Could not open file", ex.Message, "Ok"); } return; @@ -369,8 +472,8 @@ private void LoadImage (string path, bool showError) _fullResImage?.Dispose (); _fullResImage = img; Color [,] image = ConvertToColorArray (img); - _basicImageView.Image = image; - UpdateSixelImage (image); + _cellImageView.Image = image; + UpdateRasterImage (image); } public static Color [,] ConvertToColorArray (Image image) @@ -391,12 +494,10 @@ private void LoadImage (string path, bool showError) return colors; } - private void OpenImage (object sender, CommandEventArgs e) + 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)!)); + _app.Run (ofd); if (ofd.Canceled) { @@ -419,36 +520,155 @@ private void OpenImage (object sender, CommandEventArgs e) return; } + string? directoryName = Path.GetDirectoryName (path); + + if (!string.IsNullOrEmpty (directoryName)) + { + Directory.SetCurrentDirectory (Path.GetFullPath (directoryName)); + } + LoadImage (path, true); - _app?.LayoutAndDraw (); + _app.LayoutAndDraw (); } - private void UpdateSixelImage (Color [,] image) + private static string ProtocolState (bool? isSupported) => + isSupported switch + { + true => "yes", + false => "no", + _ => "detecting" + }; + + private static string Row (string renderer, string available, string resolution, string notes) => + $"{renderer,-14} {available,-11} {resolution,-16} {notes}"; + + private void SetBackgroundRendering (bool enabled) + { + _cellImageView.UseBackgroundRendering = enabled; + _rasterImageView.UseBackgroundRendering = enabled; + } + + private void SetUseRasterGraphics (bool enabled) + { + _rasterImageView.UseRasterGraphics = enabled; + _rasterImageView.SetNeedsDraw (); + + UpdateRasterCapabilityMatrix (enabled); + } + + private static string SizeText (Size size) => $"{size.Width}x{size.Height} px/cell"; + + private void UpdateRasterCapabilityMatrix (bool? rasterEnabledOverride = null) + { + bool rasterEnabled = rasterEnabledOverride ?? _cbUseRasterGraphics.Value != CheckState.UnChecked; + bool kittyActive = IsImageViewKittyActive (rasterEnabled); + bool sixelActive = IsImageViewSixelActive (rasterEnabled); + bool cellActive = !rasterEnabled || (!kittyActive && !sixelActive); + string driverName = _app.Driver?.GetName () ?? "unknown"; + bool trueColor = _app.Driver?.SupportsTrueColor == true && !Driver.Force16Colors; + bool legacy = _app.Driver?.IsLegacyConsole == true; + + _driverStatus.Text = $"Driver: {driverName}; true color: {YesNo (trueColor)}; legacy console: {YesNo (legacy)}"; + _cellStatus.Text = Row ("Cell colors", "yes", "1 cell", cellActive ? "active" : "fallback"); + + bool? kittySupported = _kittyGraphicsSupportResult is null ? null : _kittyGraphicsSupportResult.IsSupported; + string kittyResolution = _kittyGraphicsSupportResult is { } kitty ? SizeText (kitty.Resolution) : "-"; + _kittyStatus.Text = Row ("Kitty", ProtocolState (kittySupported), kittyResolution, kittyActive ? "active" : "auto-preferred"); + + bool? sixelSupported = _sixelSupportResult is null ? null : _sixelSupportResult.IsSupported; + string sixelResolution = _sixelSupportResult is { } sixel ? SizeText (sixel.Resolution) : "-"; + + string sixelNotes = _sixelSupportResult is { IsSupported: true } supportedSixel + ? $"{supportedSixel.MaxPaletteColors} colors; alpha {YesNo (supportedSixel.SupportsTransparency)}" + : "fallback after Kitty"; + _sixelStatus.Text = Row ("Sixel", ProtocolState (sixelSupported), sixelResolution, sixelActive ? $"active; {sixelNotes}" : sixelNotes); + + UpdateRasterProtocolSelector (); + _selectedStatus.Text = $"Selected raster path: {GetSelectedRendererName (rasterEnabled, kittyActive, sixelActive)}"; + _win.SetNeedsDraw (); + } + + private void UpdateRasterImage (Color [,] image) { SixelEncoder encoder = new (); - encoder.Quantizer.MaxColors = Math.Min (encoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); + + if (_sixelSupportResult is { } sixel) + { + encoder.Quantizer.MaxColors = Math.Min (encoder.Quantizer.MaxColors, sixel.MaxPaletteColors); + } + encoder.Quantizer.PaletteBuildingAlgorithm = GetPaletteBuilder (); encoder.Quantizer.DistanceAlgorithm = GetDistanceAlgorithm (); - _sixelImageView.SixelEncoder = encoder; - _sixelImageView.Image = image; + _rasterImageView.SixelEncoder = encoder; + _rasterImageView.Image = image; } - private void UpdateSixelSupportState (SixelSupportResult newResult) + private void UpdateRasterSupportState (SixelSupportResult? sixelResult, KittyGraphicsSupportResult? kittyResult) { - newResult ??= new SixelSupportResult (); - _sixelSupportResult = newResult; + _sixelSupportResult = sixelResult; + _kittyGraphicsSupportResult = kittyResult; + ApplyRasterProtocolSelection (); - _cbSupportsSixel.Value = newResult.IsSupported ? CheckState.Checked : CheckState.UnChecked; _pxX.Value = GetDefaultFirePixelsPerColumn (); _pxY.Value = GetDefaultFirePixelsPerRow (); - if (_sixelImageView?.Image is { } image) + if (_rasterImageView.Image is { } image) + { + UpdateRasterImage (image); + } + + UpdateRasterCapabilityMatrix (); + } + + private void UpdateRasterProtocolSelector () + { + bool bothSupported = _kittyGraphicsSupportResult is { IsSupported: true } && _sixelSupportResult is { IsSupported: true }; + _osRasterProtocol.Enabled = bothSupported; + + if (!bothSupported && _osRasterProtocol.Value != RASTER_PROTOCOL_AUTO) + { + _osRasterProtocol.Value = RASTER_PROTOCOL_AUTO; + } + } + + private string GetSelectedRendererName (bool rasterEnabled, bool kittyActive, bool sixelActive) + { + if (!rasterEnabled) + { + return "Cell renderer"; + } + + if (kittyActive) + { + return "Kitty graphics"; + } + + if (sixelActive) + { + return "Sixel"; + } + + return "Cell renderer fallback"; + } + + private bool ShouldUseKittyGraphics () + { + if (_kittyGraphicsSupportResult is not { IsSupported: true }) + { + return false; + } + + if (_osRasterProtocol.Value == RASTER_PROTOCOL_SIXEL && _sixelSupportResult is { IsSupported: true }) { - UpdateSixelImage (image); + return false; } + + return true; } - private void Win_SubViewsLaidOut (object sender, LayoutEventArgs e) + private static string YesNo (bool value) => value ? "yes" : "no"; + + private void Win_SubViewsLaidOut (object? sender, LayoutEventArgs e) { Size currentSize = _win.Viewport.Size; @@ -459,9 +679,9 @@ private void Win_SubViewsLaidOut (object sender, LayoutEventArgs e) _winSize = currentSize; - if (_fireSixel is { }) + if (_fire is not null) { - GenerateSixelFire (false); + GenerateRasterFire (false); } } } diff --git a/Examples/UICatalog/Scenarios/Mandelbrot.cs b/Examples/UICatalog/Scenarios/Mandelbrot.cs index c83ca2921e..d409ce9ef6 100644 --- a/Examples/UICatalog/Scenarios/Mandelbrot.cs +++ b/Examples/UICatalog/Scenarios/Mandelbrot.cs @@ -2,14 +2,11 @@ namespace UICatalog.Scenarios; -[ScenarioMetadata ("Mandelbrot", "Displays a sixel-rendered Mandelbrot set with live settings and an overlay dialog.")] +[ScenarioMetadata ("Mandelbrot", "Demonstrates Sixel/Kitty Graphics Support 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; @@ -19,8 +16,9 @@ public class Mandelbrot : Scenario 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 const int RASTER_PROTOCOL_AUTO = 0; + private const int RASTER_PROTOCOL_KITTY = 1; + private const int RASTER_PROTOCOL_SIXEL = 2; private IApplication _app = null!; private NumericUpDown _centerX = null!; @@ -33,46 +31,67 @@ public class Mandelbrot : Scenario private Label _status = null!; private Window _window = null!; private SixelSupportResult _sixelSupportResult = new (); + private KittyGraphicsSupportResult? _kittyGraphicsSupportResult; + private OptionSelector _osRasterProtocol = null!; + private Label _driverStatus = null!; + private Label _cellStatus = null!; + private Label _kittyStatus = null!; + private Label _sixelStatus = null!; + private Label _selectedStatus = null!; public override void Main () { using IApplication app = Application.Create ().Init (DriverRegistry.Names.ANSI); _app = app; _app.Driver!.SixelSupportChanged += OnSixelSupportChanged; + _app.Driver.KittyGraphicsSupportChanged += OnKittyGraphicsSupportChanged; _window = new Window { Title = $"{Application.GetDefaultKey (Command.Quit)} to Quit - Scenario: {GetName ()}" }; - FrameView settings = new () { Title = "Settings", Width = 34, Height = Dim.Fill () }; + FrameView capabilityMatrix = BuildCapabilityMatrix (); - View display = new () { X = Pos.Right (settings), Width = Dim.Fill (), Height = Dim.Fill (), CanFocus = true }; + Label protocolLabel = new () { Y = Pos.Bottom (capabilityMatrix), Text = "Raster protocol:" }; + + _osRasterProtocol = new OptionSelector + { + X = Pos.Right (protocolLabel) + 1, + Y = Pos.Top (protocolLabel), + Orientation = Orientation.Horizontal, + Labels = ["Auto", "Kitty", "Sixel"], + Values = [RASTER_PROTOCOL_AUTO, RASTER_PROTOCOL_KITTY, RASTER_PROTOCOL_SIXEL], + Value = RASTER_PROTOCOL_AUTO + }; + _osRasterProtocol.ValueChanged += (_, _) => ApplyRasterProtocolSelection (); + + FrameView settings = new () { Title = "Settings", Y = Pos.Bottom (protocolLabel), Width = 34, Height = Dim.Fill () }; + + View display = new () + { + X = Pos.Right (settings), + Y = Pos.Bottom (protocolLabel), + 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 + Y = Pos.Center () }; - _mandelbrotView.CenterRequested += CenterMandelbrot; + _mandelbrotView.ImageRendered += (_, _) => UpdateMandelbrotStatus (); + _mandelbrotView.MandelbrotSettingsChanged += (_, _) => UpdateSettingsFromMandelbrotView (); BuildSettings (settings); - BuildZoomButtons (); - _mandelbrotView.ViewportChanged += (_, _) => RenderMandelbrot (); display.Add (_status, _mandelbrotView); - _window.Add (settings, display); + _window.Add (capabilityMatrix, protocolLabel, _osRasterProtocol, settings, display); _window.Initialized += (_, _) => { - UpdateSixelSupport (_app.Driver?.SixelSupport); - RenderMandelbrot (); + UpdateRasterSupportState (_app.Driver?.SixelSupport, _app.Driver?.KittyGraphicsSupport); StartFireProgress (); _app.AddTimeout (TimeSpan.FromMilliseconds (100), @@ -92,6 +111,7 @@ public override void Main () { StopFireProgress (); _app.Driver!.SixelSupportChanged -= OnSixelSupportChanged; + _app.Driver.KittyGraphicsSupportChanged -= OnKittyGraphicsSupportChanged; _window.Dispose (); } } @@ -157,7 +177,7 @@ private void BuildSettings (View settings) _span.ValueChanging += (_, args) => { - if (args.NewValue < MINIMUM_SPAN) + if (args.NewValue < MandelbrotImageView.MinimumSpan) { args.Handled = true; } @@ -165,16 +185,16 @@ private void BuildSettings (View settings) _iterations.ValueChanging += (_, args) => { - if (args.NewValue < 8) + if (args.NewValue < MandelbrotImageView.MinimumIterations) { args.Handled = true; } }; - _centerX.ValueChanged += (_, _) => RenderMandelbrot (); - _centerY.ValueChanged += (_, _) => RenderMandelbrot (); - _span.ValueChanged += (_, _) => RenderMandelbrot (); - _iterations.ValueChanged += (_, _) => RenderMandelbrot (); + _centerX.ValueChanged += (_, _) => UpdateMandelbrotSettings (); + _centerY.ValueChanged += (_, _) => UpdateMandelbrotSettings (); + _span.ValueChanged += (_, _) => UpdateMandelbrotSettings (); + _iterations.ValueChanged += (_, _) => UpdateMandelbrotSettings (); Button reset = new () { X = Pos.Align (Alignment.Start, groupId: RESET_GROUP_ID), Y = Pos.Bottom (iterationsLabel) + 2, Text = "_Reset" }; @@ -190,7 +210,7 @@ private void BuildSettings (View settings) 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." + Text = "The image supports\nImageView input: mouse\nwheel or +/- zooms,\narrow keys pan, and\nHome or 0 resets." }; Label fireLabel = new () { X = Pos.Align (Alignment.Start, groupId: FIRE_LABEL_GROUP_ID), Y = Pos.AnchorEnd (2), Text = "Fire progress:" }; @@ -220,49 +240,6 @@ private void BuildSettings (View settings) _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 () { @@ -273,66 +250,134 @@ private static Label CreateSettingLabel (string text, int groupId, View? previou Text = text }; - private void CenterMandelbrot (Point position) + private void OnSixelSupportChanged (object? sender, ValueChangedEventArgs args) + { + UpdateRasterSupportState (args.NewValue, _app.Driver?.KittyGraphicsSupport); + } + + private void OnKittyGraphicsSupportChanged (object? sender, ValueChangedEventArgs args) + { + UpdateRasterSupportState (_app.Driver?.SixelSupport, args.NewValue); + } + + private void UpdateRasterSupportState (SixelSupportResult? sixelResult, KittyGraphicsSupportResult? kittyResult) { - Rectangle viewport = _mandelbrotView.Viewport; + _sixelSupportResult = sixelResult ?? new SixelSupportResult (); + _kittyGraphicsSupportResult = kittyResult; + ApplyRasterProtocolSelection (); + } - if (viewport.Width <= 0 || viewport.Height <= 0) + private void ApplyRasterProtocolSelection () + { + if (_app.Driver?.GetOutput () is { } output) { - return; + output.UseKittyGraphics = ShouldUseKittyGraphics (); } - if (position.X < 0 || position.Y < 0 || position.X >= viewport.Width || position.Y >= viewport.Height) + _mandelbrotView.UpdateRasterSupport (_sixelSupportResult, _kittyGraphicsSupportResult); + UpdateRasterCapabilityMatrix (); + } + + private bool ShouldUseKittyGraphics () + { + if (_kittyGraphicsSupportResult is not { IsSupported: true }) { - return; + return false; } - 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; + return _osRasterProtocol.Value != RASTER_PROTOCOL_SIXEL || _sixelSupportResult is not { IsSupported: true }; } - private void OnSixelSupportChanged (object? sender, ValueChangedEventArgs args) + private FrameView BuildCapabilityMatrix () { - UpdateSixelSupport (args.NewValue); - RenderMandelbrot (); + FrameView matrix = new () { Title = "Raster Capability Matrix", Width = Dim.Fill (), Height = 7, CanFocus = false }; + + _driverStatus = new Label { Y = 0, Width = Dim.Fill () }; + Label header = new () { Y = 1, Width = Dim.Fill (), Text = "Renderer Available Resolution Notes" }; + _cellStatus = new Label { Y = 2, Width = Dim.Fill () }; + _kittyStatus = new Label { Y = 3, Width = Dim.Fill () }; + _sixelStatus = new Label { Y = 4, Width = Dim.Fill () }; + _selectedStatus = new Label { Y = 5, Width = Dim.Fill () }; + + matrix.Add (_driverStatus, header, _cellStatus, _kittyStatus, _sixelStatus, _selectedStatus); + + return matrix; } - private void UpdateSixelSupport (SixelSupportResult? support) => _sixelSupportResult = support ?? new SixelSupportResult (); + private void UpdateRasterCapabilityMatrix () + { + bool kittyActive = _mandelbrotView.IsKittyRasterActive; + bool sixelActive = _mandelbrotView.IsSixelRasterActive; + bool cellActive = _mandelbrotView.IsCellRenderActive; + string driverName = _app.Driver?.GetName () ?? "unknown"; + bool trueColor = _app.Driver?.SupportsTrueColor == true; + bool legacy = _app.Driver?.IsLegacyConsole == true; + + _driverStatus.Text = $"Driver: {driverName}; true color: {YesNo (trueColor)}; legacy console: {YesNo (legacy)}"; + _cellStatus.Text = Row ("Cell colors", "yes", "1 cell", cellActive ? "active" : "fallback"); + + bool? kittySupported = _kittyGraphicsSupportResult?.IsSupported; + string kittyResolution = _kittyGraphicsSupportResult is { } kitty ? SizeText (kitty.Resolution) : "-"; + _kittyStatus.Text = Row ("Kitty", ProtocolState (kittySupported), kittyResolution, kittyActive ? "active" : "auto-preferred"); + + bool? sixelSupported = _sixelSupportResult is { IsSupported: true }; + string sixelResolution = _sixelSupportResult is { IsSupported: true } ? SizeText (_sixelSupportResult.Resolution) : "-"; + + string sixelNotes = _sixelSupportResult is { IsSupported: true } + ? $"{_sixelSupportResult.MaxPaletteColors} colors; alpha {YesNo (_sixelSupportResult.SupportsTransparency)}" + : "fallback after Kitty"; + _sixelStatus.Text = Row ("Sixel", ProtocolState (sixelSupported), sixelResolution, sixelActive ? $"active; {sixelNotes}" : sixelNotes); + + UpdateRasterProtocolSelector (); + _selectedStatus.Text = $"Selected raster path: {GetSelectedRendererName (kittyActive, sixelActive)}"; + _window.SetNeedsDraw (); + } - private void RenderMandelbrot () + private void UpdateRasterProtocolSelector () { - SixelSupportResult support = _app.Driver?.SixelSupport ?? _sixelSupportResult; - Rectangle viewport = _mandelbrotView.Viewport; - int imageColumns = Math.Max (0, viewport.Width); - int imageRows = Math.Max (0, viewport.Height); + bool bothSupported = _kittyGraphicsSupportResult is { IsSupported: true } && _sixelSupportResult is { IsSupported: true }; + _osRasterProtocol.Enabled = bothSupported; - if (imageColumns == 0 || imageRows == 0) + if (!bothSupported && _osRasterProtocol.Value != RASTER_PROTOCOL_AUTO) { - return; + _osRasterProtocol.Value = RASTER_PROTOCOL_AUTO; } + } - 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; + private static string GetSelectedRendererName (bool kittyActive, bool sixelActive) + { + if (kittyActive) + { + return "Kitty graphics"; + } - _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 (); + if (sixelActive) + { + return "Sixel"; + } + + return "Cell renderer fallback"; } + private static string ProtocolState (bool? isSupported) => + isSupported switch + { + true => "yes", + false => "no", + _ => "detecting" + }; + + private static string Row (string renderer, string available, string resolution, string notes) => + $"{renderer,-14} {available,-11} {resolution,-16} {notes}"; + + private static string SizeText (Size size) => $"{size.Width}x{size.Height} px/cell"; + + private static string YesNo (bool value) => value ? "yes" : "no"; + private void ResetSettings () { - _centerX.Value = -0.5; - _centerY.Value = 0; - _span.Value = 3; - _iterations.Value = 80; - RenderMandelbrot (); + _mandelbrotView.ResetMandelbrot (); + UpdateSettingsFromMandelbrotView (); } private void ShowOverlay () @@ -370,87 +415,24 @@ private void StopFireProgress () _fireProgressTimeout = null; } - private void Zoom (double spanMultiplier) + private void UpdateMandelbrotSettings () { - double nextSpan = Math.Max (MINIMUM_SPAN, _span.Value * spanMultiplier); - - if (Math.Abs (nextSpan - _span.Value) < double.Epsilon) - { - return; - } - - _span.Value = nextSpan; + _mandelbrotView.SetMandelbrot (_centerX.Value, _centerY.Value, _span.Value, _iterations.Value); } - private sealed class MandelbrotImageView : ImageView + private void UpdateMandelbrotStatus () { - 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); + _status.Text = + $"{_mandelbrotView.ActiveRenderMode}: {_mandelbrotView.LastImageColumns} x {_mandelbrotView.LastImageRows} cells, {_mandelbrotView.LastPixelWidth} x {_mandelbrotView.LastPixelHeight}px"; + _status.SetNeedsDraw (); + UpdateRasterCapabilityMatrix (); + } - return new Color (red, green, blue); - } + private void UpdateSettingsFromMandelbrotView () + { + _centerX.Value = _mandelbrotView.CenterX; + _centerY.Value = _mandelbrotView.CenterY; + _span.Value = _mandelbrotView.Span; + _iterations.Value = _mandelbrotView.MaxIterations; } } diff --git a/Examples/UICatalog/Scenarios/MandelbrotImageView.cs b/Examples/UICatalog/Scenarios/MandelbrotImageView.cs new file mode 100644 index 0000000000..64275a0202 --- /dev/null +++ b/Examples/UICatalog/Scenarios/MandelbrotImageView.cs @@ -0,0 +1,383 @@ +#nullable enable + +namespace UICatalog.Scenarios; + +internal sealed class MandelbrotImageView : ImageView +{ + internal const int DefaultColumns = 30; + internal const int DefaultRows = 20; + internal const double MinimumSpan = double.Epsilon; + internal const int MinimumIterations = 8; + + private const double DEFAULT_CENTER_X = -0.5; + private const double DEFAULT_CENTER_Y = 0; + private const double DEFAULT_SPAN = 3; + private const int DEFAULT_ITERATIONS = 80; + private const double PAN_FACTOR = 0.1; + private const double ZOOM_IN_FACTOR = 0.8; + private const double ZOOM_OUT_FACTOR = 1.25; + + private Point? _lastDragPosition; + + private double _centerX = DEFAULT_CENTER_X; + public double CenterX => _centerX; + + private double _centerY = DEFAULT_CENTER_Y; + public double CenterY => _centerY; + + public bool IsCellRenderActive => !IsKittyRasterActive && !IsSixelRasterActive; + + public bool IsKittyRasterActive => IsUsingRasterGraphics && ShouldUseKittyGraphics (); + + public bool IsSixelRasterActive => IsUsingRasterGraphics + && !ShouldUseKittyGraphics () + && _sixelSupportResult is { IsSupported: true }; + + public int LastImageColumns { get; private set; } + + public int LastImageRows { get; private set; } + + public int LastPixelHeight { get; private set; } + + public int LastPixelWidth { get; private set; } + + private int _maxIterations = DEFAULT_ITERATIONS; + public int MaxIterations => _maxIterations; + + private double _span = DEFAULT_SPAN; + public double Span => _span; + + public string ActiveRenderMode + { + get + { + if (!IsUsingRasterGraphics) + { + return "Cell fallback"; + } + + return ShouldUseKittyGraphics () ? "Kitty raster" : "Sixel raster"; + } + } + + private SixelSupportResult _sixelSupportResult = new (); + + private KittyGraphicsSupportResult? _kittyGraphicsSupportResult; + + public event EventHandler? ImageRendered; + + public event EventHandler? MandelbrotSettingsChanged; + + public MandelbrotImageView () + { + Width = DefaultColumns; + Height = DefaultRows; + BorderStyle = LineStyle.Double; + CanFocus = true; + TabStop = TabBehavior.TabStop; + Arrangement = ViewArrangement.Resizable; + UseRasterGraphics = true; + + ReplacePanAndZoomCommands (); + ViewportChanged += (_, _) => RenderMandelbrot (); + } + + public void RefreshImage () => RenderMandelbrot (); + + public void ResetMandelbrot () + { + SetMandelbrot (DEFAULT_CENTER_X, DEFAULT_CENTER_Y, DEFAULT_SPAN, DEFAULT_ITERATIONS, true); + } + + public void SetMandelbrot (double centerX, double centerY, double span, int maxIterations) + { + SetMandelbrot (centerX, centerY, span, maxIterations, false); + } + + public void UpdateRasterSupport (SixelSupportResult? sixelResult, KittyGraphicsSupportResult? kittyResult) + { + _sixelSupportResult = sixelResult ?? new SixelSupportResult (); + _kittyGraphicsSupportResult = kittyResult; + RenderMandelbrot (); + } + + protected override bool CenterOnViewportPoint (Point position) + { + Rectangle viewport = Viewport; + + if (viewport.Width <= 0 || viewport.Height <= 0) + { + return true; + } + + if (position.X < 0 || position.Y < 0 || position.X >= viewport.Width || position.Y >= viewport.Height) + { + return true; + } + + double spanY = _span * viewport.Height / viewport.Width; + double centerX = _centerX + ((position.X + 0.5d) / viewport.Width - 0.5d) * _span; + double centerY = _centerY + ((position.Y + 0.5d) / viewport.Height - 0.5d) * spanY; + SetMandelbrot (centerX, centerY, _span, _maxIterations, true); + + return true; + } + + protected override bool OnMouseEvent (Mouse mouse) + { + if (HandleFractalDrag (mouse)) + { + return true; + } + + return base.OnMouseEvent (mouse); + } + + 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); + } + + private Size GetPreferredRasterResolution () + { + if (ShouldUseKittyGraphics () && _kittyGraphicsSupportResult is { IsSupported: true } kitty) + { + return kitty.Resolution; + } + + if (_sixelSupportResult is { IsSupported: true } sixel) + { + return sixel.Resolution; + } + + if (_kittyGraphicsSupportResult is { } detectedKitty) + { + return detectedKitty.Resolution; + } + + return _sixelSupportResult.Resolution; + } + + private bool HandleFractalDrag (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 = PanMandelbrot (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; + } + + private bool PanMandelbrot (int deltaX, int deltaY) + { + Rectangle viewport = Viewport; + + if (viewport.Width <= 0 || viewport.Height <= 0) + { + return false; + } + + double spanY = _span * viewport.Height / viewport.Width; + double centerX = _centerX + deltaX * _span * PAN_FACTOR; + double centerY = _centerY + deltaY * spanY * PAN_FACTOR; + SetMandelbrot (centerX, centerY, _span, _maxIterations, true); + + return true; + } + + private void ReplacePanAndZoomCommands () + { + AddCommand (Command.ScrollLeft, () => PanMandelbrot (-1, 0)); + AddCommand (Command.ScrollRight, () => PanMandelbrot (1, 0)); + AddCommand (Command.ScrollUp, () => PanMandelbrot (0, -1)); + AddCommand (Command.ScrollDown, () => PanMandelbrot (0, 1)); + AddCommand (Command.Home, + () => + { + ResetMandelbrot (); + + return true; + }); + AddCommand (Command.ZoomIn, context => ZoomMandelbrot (context, ZOOM_IN_FACTOR)); + AddCommand (Command.ZoomOut, context => ZoomMandelbrot (context, ZOOM_OUT_FACTOR)); + AddCommand (Command.PageUp, context => ZoomMandelbrot (context, ZOOM_IN_FACTOR)); + AddCommand (Command.PageDown, context => ZoomMandelbrot (context, ZOOM_OUT_FACTOR)); + } + + private void RenderMandelbrot () + { + Rectangle viewport = Viewport; + int imageColumns = Math.Max (0, viewport.Width); + int imageRows = Math.Max (0, viewport.Height); + + if (imageColumns == 0 || imageRows == 0) + { + return; + } + + Size resolution = GetPreferredRasterResolution (); + LastImageColumns = imageColumns; + LastImageRows = imageRows; + LastPixelWidth = imageColumns * Math.Max (1, resolution.Width); + LastPixelHeight = imageRows * Math.Max (1, resolution.Height); + Image = CreateMandelbrotPixels (LastPixelWidth, LastPixelHeight, _centerX, _centerY, _span, _maxIterations); + ImageRendered?.Invoke (this, EventArgs.Empty); + } + + private void SetMandelbrot (double centerX, double centerY, double span, int maxIterations, bool notifySettingsChanged) + { + if (span < MinimumSpan) + { + throw new ArgumentOutOfRangeException (nameof (span), @"Mandelbrot span must be greater than or equal to the minimum span."); + } + + if (maxIterations < MinimumIterations) + { + throw new ArgumentOutOfRangeException (nameof (maxIterations), @"Mandelbrot iterations must be greater than or equal to the minimum iterations."); + } + + if (_centerX == centerX && _centerY == centerY && _span == span && _maxIterations == maxIterations) + { + return; + } + + _centerX = centerX; + _centerY = centerY; + _span = span; + _maxIterations = maxIterations; + RenderMandelbrot (); + + if (notifySettingsChanged) + { + MandelbrotSettingsChanged?.Invoke (this, EventArgs.Empty); + } + } + + private bool ShouldUseKittyGraphics () => + _kittyGraphicsSupportResult is { IsSupported: true } + && App?.Driver?.GetOutput ().UseKittyGraphics == true; + + private bool ZoomMandelbrot (ICommandContext? context, double spanMultiplier) + { + Point? anchor = context?.Binding is MouseBinding { MouseEvent.Position: { } mousePosition } ? mousePosition : null; + double nextSpan = Math.Max (MinimumSpan, _span * spanMultiplier); + + if (Math.Abs (nextSpan - _span) < double.Epsilon) + { + return false; + } + + if (anchor is { } anchorPosition && TryGetMandelbrotPoint (anchorPosition, _span, out double anchorX, out double anchorY)) + { + double centerX = anchorX - (anchorX - _centerX) * spanMultiplier; + double centerY = anchorY - (anchorY - _centerY) * spanMultiplier; + SetMandelbrot (centerX, centerY, nextSpan, _maxIterations, true); + + return true; + } + + SetMandelbrot (_centerX, _centerY, nextSpan, _maxIterations, true); + + return true; + } + + private bool TryGetMandelbrotPoint (Point position, double span, out double x, out double y) + { + x = _centerX; + y = _centerY; + Rectangle viewport = Viewport; + + if (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; + } + + double spanY = span * viewport.Height / viewport.Width; + x = _centerX + ((position.X + 0.5d) / viewport.Width - 0.5d) * span; + y = _centerY + ((position.Y + 0.5d) / viewport.Height - 0.5d) * spanY; + + return true; + } +} diff --git a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs index 2c0a0a2cc4..42819ecbd9 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs @@ -276,6 +276,21 @@ private void BuildDriverIfPossible (IApplication? app) { Logging.Warning ($"Sixel support detection failed: {ex.Message}"); } + + try + { + KittyGraphicsSupportDetector kittyDetector = new (_driver); + + kittyDetector.Detect (result => + { + _driver.SetKittyGraphicsSupport (result); + Logging.Trace ($"app: Kitty graphics support: {result.IsSupported}, Resolution: {result.Resolution}"); + }); + } + catch (Exception ex) + { + Logging.Warning ($"Kitty graphics support detection failed: {ex.Message}"); + } } diff --git a/Terminal.Gui/Drawing/Kitty/KittyGraphicsEncoder.cs b/Terminal.Gui/Drawing/Kitty/KittyGraphicsEncoder.cs new file mode 100644 index 0000000000..dbe2a64d70 --- /dev/null +++ b/Terminal.Gui/Drawing/Kitty/KittyGraphicsEncoder.cs @@ -0,0 +1,167 @@ +namespace Terminal.Gui.Drawing; + +/// +/// Encodes a pixel array into the Kitty terminal graphics protocol APC escape sequence stream. +/// +/// +/// +/// The Kitty graphics protocol transmits images via APC sequences of the form: +/// ESC_G <key=value,...>;<base64-data>ESC\. +/// +/// +/// Images are encoded as raw 32-bit RGBA pixel data (no palette, no quantization), split +/// into chunks of at most base64 characters. Multi-chunk +/// transmissions set m=1 on all chunks except the last (m=0). +/// +/// +/// See for the full spec. +/// +/// +public class KittyGraphicsEncoder +{ + /// + /// Maximum number of base64-encoded characters per APC chunk. + /// The Kitty protocol recommends ≤4096 bytes of payload per chunk. + /// + public const int MaxChunkSize = 4096; + + // APC = ESC _ ... ESC \ + private const string APC_START = "\x1b_G"; + private const string APC_END = "\x1b\\"; + + /// + /// Encodes the provided pixel array into a Kitty graphics protocol APC sequence string + /// that, when written to the terminal, renders the image at the current cursor position + /// occupying columns and rows. + /// + /// + /// The pixel array to encode, indexed as [x, y] where the first dimension is width + /// and the second is height. + /// + /// The number of terminal columns the image should occupy. + /// The number of terminal rows the image should occupy. + /// + /// An optional stable image identifier (the Kitty i key). When supplied, placements can be + /// deleted and replaced by id via , which is required to erase + /// a previous placement when the image is resized or moved. Use to derive + /// a stable id from a string. When the terminal assigns its own id and the + /// placement cannot be targeted for deletion. + /// + /// The complete Kitty APC escape sequence string. + public string EncodeKitty (Color [,] pixels, int destCols, int destRows, int? imageId = null) + { + int width = pixels.GetLength (0); + int height = pixels.GetLength (1); + + byte [] rgba = PixelsToRgba (pixels, width, height); + string base64 = Convert.ToBase64String (rgba); + + return BuildApcSequence (base64, width, height, destCols, destRows, imageId); + } + + /// + /// Builds a Kitty graphics protocol APC sequence that deletes all placements of the image with the + /// specified , leaving the transmitted image data intact so it can be + /// re-displayed. + /// + /// + /// Kitty image placements persist on screen until explicitly deleted — unlike Sixel, drawing text + /// over their cells does not erase them. Emit this before re-placing a resized or moved image, and + /// when an image is removed, so a stale placement is not left behind. + /// + /// The image id (the Kitty i key) whose placements should be deleted. + /// The complete Kitty APC delete escape sequence string. + public static string EncodeDeletePlacements (int imageId) => $"{APC_START}a=d,d=i,i={imageId}{APC_END}"; + + /// + /// Derives a stable, positive, non-zero Kitty image id from the given string identifier. + /// + /// + /// The same always maps to the same image id, so a view's placement can be + /// consistently replaced or deleted across renders. + /// + /// The string identifier (e.g. a ). + /// A positive, non-zero image id suitable for the Kitty i key. + public static int GetImageId (string id) + { + ArgumentException.ThrowIfNullOrEmpty (id); + + // FNV-1a 32-bit hash, clamped to a positive, non-zero value. + var hash = 2166136261u; + + foreach (char c in id) + { + hash = (hash ^ c) * 16777619u; + } + + var result = (int)(hash & 0x7FFFFFFF); + + return result == 0 ? 1 : result; + } + + private static byte [] PixelsToRgba (Color [,] pixels, int width, int height) + { + byte [] rgba = new byte [width * height * 4]; + int i = 0; + + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + Color c = pixels [x, y]; + rgba [i++] = c.R; + rgba [i++] = c.G; + rgba [i++] = c.B; + rgba [i++] = c.A; + } + } + + return rgba; + } + + private static string BuildApcSequence (string base64, int pixelWidth, int pixelHeight, int destCols, int destRows, int? imageId) + { + var sb = new StringBuilder (); + int totalLength = base64.Length; + int offset = 0; + + // First chunk carries the image metadata. + string firstChunk = offset + MaxChunkSize < totalLength + ? base64.Substring (offset, MaxChunkSize) + : base64.Substring (offset); + bool isLastChunk = offset + firstChunk.Length >= totalLength; + + // i= tags the placement with a stable image id so a prior placement can be deleted by + // id (see EncodeDeletePlacements) when the image is resized or moved. + string idField = imageId.HasValue ? $"i={imageId.Value}," : string.Empty; + + // C=1 suppresses cursor movement after the image is displayed. Without it the terminal + // advances the cursor past the bottom-right of the image, and an image near the bottom of + // the screen scrolls the whole display up one row on every repaint (animated images march + // up the screen instead of repainting in place). + sb.Append (APC_START); + sb.Append ($"a=T,f=32,{idField}s={pixelWidth},v={pixelHeight},c={destCols},r={destRows},z=-1,C=1,q=2,m={( isLastChunk ? 0 : 1 )}"); + sb.Append (';'); + sb.Append (firstChunk); + sb.Append (APC_END); + offset += firstChunk.Length; + + // Continuation chunks carry only data. + while (offset < totalLength) + { + string chunk = offset + MaxChunkSize < totalLength + ? base64.Substring (offset, MaxChunkSize) + : base64.Substring (offset); + bool last = offset + chunk.Length >= totalLength; + + sb.Append (APC_START); + sb.Append ($"m={( last ? 0 : 1 )}"); + sb.Append (';'); + sb.Append (chunk); + sb.Append (APC_END); + offset += chunk.Length; + } + + return sb.ToString (); + } +} diff --git a/Terminal.Gui/Drawing/Kitty/KittyGraphicsSupportDetector.cs b/Terminal.Gui/Drawing/Kitty/KittyGraphicsSupportDetector.cs new file mode 100644 index 0000000000..16ede899fe --- /dev/null +++ b/Terminal.Gui/Drawing/Kitty/KittyGraphicsSupportDetector.cs @@ -0,0 +1,144 @@ +namespace Terminal.Gui.Drawing; + +/// +/// Detects whether the active terminal supports the Kitty graphics protocol by inspecting +/// well-known environment variables set by Kitty-compatible terminals. +/// +/// +/// +/// Detection checks the following environment variables in order: +/// +/// KITTY_WINDOW_ID — set by the Kitty terminal emulator. +/// TERM_PROGRAM equal to kitty or ghostty. +/// +/// +/// +/// When support is confirmed, the detector attempts to derive the pixel-per-cell +/// via ANSI window-size queries +/// (the same fallback used by ). +/// +/// +public class KittyGraphicsSupportDetector +{ + private readonly IDriver? _driver; + + /// + /// Creates a new instance of the class. + /// + public KittyGraphicsSupportDetector () { } + + /// + /// Creates a new instance of the class + /// bound to the specified driver (used for resolution queries). + /// + /// The driver to send ANSI requests through. + public KittyGraphicsSupportDetector (IDriver driver) + { + ArgumentNullException.ThrowIfNull (driver); + _driver = driver; + } + + /// + /// Detects Kitty graphics protocol support and invokes + /// with the result. When support is detected the detector also attempts to resolve + /// via ANSI queries; if those are + /// unavailable the default 10×20 resolution is used. + /// + /// Called when detection is complete. + public void Detect (Action resultCallback) + { + ArgumentNullException.ThrowIfNull (resultCallback); + + KittyGraphicsSupportResult result = new () + { + IsSupported = IsKittySupportedByEnvironment () + }; + + if (!result.IsSupported || _driver is null) + { + resultCallback (result); + + return; + } + + TryComputeResolution (result, resultCallback); + } + + private static bool IsKittySupportedByEnvironment () + { + string? kittyWindowId = Environment.GetEnvironmentVariable ("KITTY_WINDOW_ID"); + + if (!string.IsNullOrWhiteSpace (kittyWindowId)) + { + return true; + } + + string? termProgram = Environment.GetEnvironmentVariable ("TERM_PROGRAM"); + + return string.Equals (termProgram, "kitty", StringComparison.OrdinalIgnoreCase) + || string.Equals (termProgram, "ghostty", StringComparison.OrdinalIgnoreCase); + } + + private void TryComputeResolution (KittyGraphicsSupportResult result, Action resultCallback) + { + string? consoleSize; + + QueueRequest (EscSeqUtils.CSI_RequestWindowSizeInPixels, + r1 => + { + consoleSize = r1; + + QueueRequest (EscSeqUtils.CSI_ReportWindowSizeInChars, + r2 => + { + if (consoleSize is { }) + { + ComputeResolution (result, consoleSize, r2); + } + + resultCallback (result); + }, + () => resultCallback (result)); + }, + () => resultCallback (result)); + } + + private static void ComputeResolution (KittyGraphicsSupportResult result, string consoleSize, string sizeInChars) + { + System.Text.RegularExpressions.Match pixelMatch = + System.Text.RegularExpressions.Regex.Match (consoleSize, @"\[\d+;(\d+);(\d+)t$"); + + System.Text.RegularExpressions.Match charMatch = + System.Text.RegularExpressions.Regex.Match (sizeInChars, @"\[\d+;(\d+);(\d+)t$"); + + if (!pixelMatch.Success + || !charMatch.Success + || !int.TryParse (pixelMatch.Groups [1].Value, out int pixelHeight) + || !int.TryParse (pixelMatch.Groups [2].Value, out int pixelWidth) + || !int.TryParse (charMatch.Groups [1].Value, out int charHeight) + || !int.TryParse (charMatch.Groups [2].Value, out int charWidth) + || charWidth == 0 + || charHeight == 0) + { + return; + } + + result.Resolution = new Size ( + (int)Math.Round ((double)pixelWidth / charWidth), + (int)Math.Round ((double)pixelHeight / charHeight)); + } + + private void QueueRequest (AnsiEscapeSequence req, Action responseCallback, Action abandoned) + { + AnsiEscapeSequenceRequest newRequest = new () + { + Request = req.Request, + Value = req.Value, + Terminator = req.Terminator, + ResponseReceived = responseCallback!, + Abandoned = abandoned + }; + + _driver?.QueueAnsiRequest (newRequest); + } +} diff --git a/Terminal.Gui/Drawing/Kitty/KittyGraphicsSupportResult.cs b/Terminal.Gui/Drawing/Kitty/KittyGraphicsSupportResult.cs new file mode 100644 index 0000000000..5b608243fe --- /dev/null +++ b/Terminal.Gui/Drawing/Kitty/KittyGraphicsSupportResult.cs @@ -0,0 +1,20 @@ +namespace Terminal.Gui.Drawing; + +/// +/// Describes the discovered state of Kitty graphics protocol support and ancillary information +/// such as . Use to populate this. +/// +public class KittyGraphicsSupportResult +{ + /// + /// Whether the terminal supports the Kitty graphics protocol. + /// Defaults to . + /// + public bool IsSupported { get; set; } + + /// + /// The number of pixels that corresponds to each column () + /// and each row (). Defaults to 10×20. + /// + public Size Resolution { get; set; } = new (10, 20); +} diff --git a/Terminal.Gui/Drawing/Sixel/SixelEncoder.cs b/Terminal.Gui/Drawing/Sixel/SixelEncoder.cs index 7ad94fa8fe..a8f4e0150f 100644 --- a/Terminal.Gui/Drawing/Sixel/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/Sixel/SixelEncoder.cs @@ -184,7 +184,17 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi for (var j = 0; j < usedColorIdx.Count; ++j) { - result.Append ($"#{usedColorIdx [j]}{string.Join ("", targets [j])}$"); + result.Append ($"#{usedColorIdx [j]}{string.Join ("", targets [j])}"); + + // Emit '$' (Graphics Carriage Return) between color layers so + // the next layer redraws the same band. Do NOT emit after the + // last layer — a trailing '$' before '-' or before the sequence + // terminator is redundant and causes rendering corruption in + // iTerm2 (see #5490). + if (j < usedColorIdx.Count - 1) + { + result.Append ('$'); + } } return result.ToString (); diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 9f8cac5e1c..35e19af39c 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -311,16 +311,33 @@ private void OnSizeMonitorOnSizeChanged (object? _, SizeChangedEventArgs e) => /// public event EventHandler>? SixelSupportChanged; - /// - /// Sets the terminal's sixel support result (detected during initialization). - /// - internal void SetSixelSupport (SixelSupportResult result) + /// + public void SetSixelSupport (SixelSupportResult result) { SixelSupportResult? old = SixelSupport; SixelSupport = result; SixelSupportChanged?.Invoke (this, new ValueChangedEventArgs (old, result)); } + /// + public KittyGraphicsSupportResult? KittyGraphicsSupport { get; private set; } + + /// + public event EventHandler>? KittyGraphicsSupportChanged; + + /// + public void SetKittyGraphicsSupport (KittyGraphicsSupportResult result) + { + KittyGraphicsSupportResult? old = KittyGraphicsSupport; + KittyGraphicsSupport = result; + + // Kitty takes priority when supported. Sixel is the fallback for terminals that lack + // Kitty support (e.g. Windows Terminal). + _output.UseKittyGraphics = result.IsSupported; + + KittyGraphicsSupportChanged?.Invoke (this, new ValueChangedEventArgs (old, result)); + } + /// public bool SupportsTrueColor => !IsLegacyConsole; diff --git a/Terminal.Gui/Drivers/IDriver.cs b/Terminal.Gui/Drivers/IDriver.cs index 4558cb4405..05a07f0df1 100644 --- a/Terminal.Gui/Drivers/IDriver.cs +++ b/Terminal.Gui/Drivers/IDriver.cs @@ -141,6 +141,40 @@ public interface IDriver : IDisposable /// event EventHandler>? SixelSupportChanged; + /// + /// Sets the terminal's result and raises . + /// + /// + /// Normally populated automatically during driver initialization. Call this to override detection — for + /// example, to force Sixel output on a headless or PTY driver that skips the terminal handshake. + /// + /// The sixel support capabilities to apply. + void SetSixelSupport (SixelSupportResult result); + + /// + /// Gets the terminal's Kitty graphics protocol support capabilities, detected during driver initialization. + /// + /// + /// if detection has not been performed. + /// + KittyGraphicsSupportResult? KittyGraphicsSupport { get; } + + /// + /// Raised when changes (e.g. after terminal detection completes). + /// + event EventHandler>? KittyGraphicsSupportChanged; + + /// + /// Sets the terminal's result and raises + /// . + /// + /// + /// Normally populated automatically during driver initialization. Call this to override detection — for + /// example, to force Kitty graphics output on a headless or PTY driver that skips the terminal handshake. + /// + /// The Kitty graphics protocol support capabilities to apply. + void SetKittyGraphicsSupport (KittyGraphicsSupportResult result); + /// Gets whether the supports TrueColor output. bool SupportsTrueColor { get; } diff --git a/Terminal.Gui/Drivers/Output/IOutput.cs b/Terminal.Gui/Drivers/Output/IOutput.cs index df092d6f46..305690df50 100644 --- a/Terminal.Gui/Drivers/Output/IOutput.cs +++ b/Terminal.Gui/Drivers/Output/IOutput.cs @@ -14,6 +14,12 @@ public interface IOutput : IDisposable /// bool IsLegacyConsole { get; set; } + /// + /// Gets or sets whether raster images should be emitted using the Kitty graphics protocol + /// instead of Sixel. Set to when the terminal reports Kitty support. + /// + bool UseKittyGraphics { get; set; } + /// ConcurrentQueue GetSixels (); diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 93aff06820..b061640cc8 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -47,6 +47,9 @@ public bool IsLegacyConsole } } + /// + public bool UseKittyGraphics { get; set; } + private readonly ConcurrentQueue _sixels = []; /// @@ -74,6 +77,14 @@ public bool IsLegacyConsole private readonly StringBuilder _lastOutputStringBuilder = new (); private bool _clearLastOutputPending; + // Kitty image ids placed on the previous Write, keyed by RasterImageCommand.Id. A single image + // can occupy more than one placement when its visible region is fragmented by clipping (e.g. a + // SubView punches a hole), and each fragment needs its own image id — sharing one id makes each + // a=T overwrite the previous fragment's data. Kitty placements persist until explicitly deleted + // (unlike Sixel, which is erased by redrawing cells), so every id placed last frame must be + // deleted when the image is resized, re-fragmented, or removed. + private readonly Dictionary> _placedKittyImageIds = []; + /// /// Writes dirty cells from the buffer to the console. Iterates rows/cols, skips clean cells, /// batches dirty cells into ANSI sequences, emits OSC 8 hyperlink start/close around URL cells, @@ -92,11 +103,20 @@ public virtual void Write (IOutputBuffer buffer) int lastCol = -1; InvalidateRowsWithUrlsIfStale (buffer, rows, cols); + IReadOnlyList rasterCellRectangles = GetRasterCellRectangles (buffer); + + // Erase Kitty placements for images that were present last frame but are gone now. Kitty + // placements persist until explicitly deleted, so removed images would otherwise linger. + if (!IsLegacyConsole && UseKittyGraphics) + { + EmitVanishedKittyDeletes (buffer); + ClearKittyRasterBlankCells (buffer); + } // Raster images must be written before dirty cells so later text draws above them. if (!IsLegacyConsole) { - RenderRasterImages (buffer); + RenderRasterImages (buffer, renderAfterText: false); } // Process each row @@ -140,9 +160,18 @@ public virtual void Write (IOutputBuffer buffer) // Batch consecutive dirty cells for (; col < cols; col++) { - // Skip clean cells - position cursor and continue - if (!buffer.Contents! [row, col].IsDirty) + // Skip clean cells, plus blank cells owned by raster images. Raster-owned + // blanks must not be emitted because normal text output would erase Sixel + // images and is unnecessary over Kitty's transparent-cleared placement. + bool skipRasterBlank = IsRasterCoveredBlankCell (buffer, row, col, rasterCellRectangles); + + if (!buffer.Contents! [row, col].IsDirty || skipRasterBlank) { + if (skipRasterBlank) + { + buffer.Contents [row, col].IsDirty = false; + } + if (outputStringBuilder.Length > 0) { // This clears outputStringBuilder @@ -255,6 +284,8 @@ public virtual void Write (IOutputBuffer buffer) return; } + RenderRasterImages (buffer, renderAfterText: true); + // Render queued sixel images foreach (SixelToRender s in GetSixels ()) { @@ -502,7 +533,8 @@ public string ToAnsi (IOutputBuffer buffer) } StringBuilder ansiOutput = new (); - bool wroteRasterImages = AppendRasterImageAnsi (buffer, ansiOutput); + IReadOnlyList rasterCellRectangles = GetRasterCellRectangles (buffer); + bool wroteRasterImages = AppendRasterImageAnsi (buffer, ansiOutput, renderAfterText: false); if (wroteRasterImages) { @@ -510,20 +542,272 @@ public string ToAnsi (IOutputBuffer buffer) } Attribute? lastAttr = null; - BuildAnsiForRegion (buffer, 0, buffer.Rows, 0, buffer.Cols, ansiOutput, ref lastAttr); + + if (rasterCellRectangles.Count > 0) + { + BuildAnsiForRegionSkippingRasterCoveredBlanks (buffer, + 0, + buffer.Rows, + 0, + buffer.Cols, + ansiOutput, + ref lastAttr, + rasterCellRectangles); + } + else + { + BuildAnsiForRegion (buffer, 0, buffer.Rows, 0, buffer.Cols, ansiOutput, ref lastAttr); + } + + AppendRasterImageAnsi (buffer, ansiOutput, renderAfterText: true); return ansiOutput.ToString (); } - private void RenderRasterImages (IOutputBuffer buffer) + private void BuildAnsiForRegionSkippingRasterCoveredBlanks (IOutputBuffer buffer, + int startRow, + int endRow, + int startCol, + int endCol, + StringBuilder output, + ref Attribute? lastAttr, + IReadOnlyList rasterCellRectangles) + { + var redrawTextStyle = TextStyle.None; + string? lastUrl = null; + + for (int row = startRow; row < endRow; row++) + { + for (int col = startCol; col < endCol; col++) + { + if (IsRasterCoveredBlankCell (buffer, row, col, rasterCellRectangles)) + { + if (lastUrl is { }) + { + output.Append (EscSeqUtils.OSC_EndHyperlink ()); + lastUrl = null; + } + + continue; + } + + output.Append (EscSeqUtils.CSI_SetCursorPosition (row + 1, col + 1)); + lastAttr = null; + redrawTextStyle = TextStyle.None; + + for (; col < endCol; col++) + { + if (IsRasterCoveredBlankCell (buffer, row, col, rasterCellRectangles)) + { + col--; + + break; + } + + string? cellUrl = buffer.GetCellUrl (col, row); + + if (cellUrl != lastUrl) + { + if (lastUrl is { }) + { + output.Append (EscSeqUtils.OSC_EndHyperlink ()); + } + + if (!string.IsNullOrEmpty (cellUrl)) + { + output.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); + } + + lastUrl = cellUrl; + } + + Cell cell = buffer.Contents! [row, col]; + int outputWidth = -1; + AppendCellAnsi (cell, output, ref lastAttr, ref redrawTextStyle, endCol, ref col, ref outputWidth); + } + } + + if (lastUrl is { }) + { + output.Append (EscSeqUtils.OSC_EndHyperlink ()); + lastUrl = null; + } + } + } + + private void ClearKittyRasterBlankCells (IOutputBuffer buffer) + { + if (buffer.Contents is null) + { + return; + } + + foreach (RasterImageCommand command in buffer.GetRasterImages ()) + { + bool clearAllCoveredBlanks = command.IsDirty || command.AlwaysRender || command.NeedsTransparentCellClear; + + foreach (Rectangle visibleCells in GetVisibleRasterCellRectangles (command)) + { + Rectangle visible = Rectangle.Intersect (visibleCells, new Rectangle (0, 0, buffer.Cols, buffer.Rows)); + + for (int row = visible.Y; row < visible.Bottom; row++) + { + for (int col = visible.X; col < visible.Right; col++) + { + Cell cell = buffer.Contents [row, col]; + + if (!IsBlankCell (cell) || (!clearAllCoveredBlanks && !cell.IsDirty)) + { + continue; + } + + if (!SetCursorPositionImpl (col, row)) + { + return; + } + + WriteTransparentBlankCell (); + buffer.Contents [row, col].IsDirty = false; + } + } + } + + command.NeedsTransparentCellClear = false; + } + } + + private void WriteTransparentBlankCell () + { + StringBuilder output = new (); + EscSeqUtils.CSI_AppendResetForegroundColor (output); + EscSeqUtils.CSI_AppendResetBackgroundColor (output); + output.Append (' '); + Write (output); + } + + private static IReadOnlyList GetRasterCellRectangles (IOutputBuffer buffer) { + List rectangles = []; + Rectangle screen = new (0, 0, buffer.Cols, buffer.Rows); + foreach (RasterImageCommand command in buffer.GetRasterImages ()) { + foreach (Rectangle visibleCells in GetVisibleRasterCellRectangles (command)) + { + Rectangle visible = Rectangle.Intersect (visibleCells, screen); + + if (visible.Width > 0 && visible.Height > 0) + { + rectangles.Add (visible); + } + } + } + + return rectangles; + } + + private static bool IsRasterCoveredBlankCell (IOutputBuffer buffer, + int row, + int col, + IReadOnlyList rasterCellRectangles) + { + if (rasterCellRectangles.Count == 0 || buffer.Contents is null || !IsBlankCell (buffer.Contents [row, col])) + { + return false; + } + + foreach (Rectangle rectangle in rasterCellRectangles) + { + if (rectangle.Contains (col, row)) + { + return true; + } + } + + return false; + } + + private static bool IsBlankCell (Cell cell) => string.IsNullOrEmpty (cell.Grapheme) || cell.Grapheme == " "; + + // Emits Kitty delete sequences for images that were placed on the previous Write but are no + // longer in the buffer, dropping their tracking entries. Entries for images still present are + // left untouched — their placements persist on screen and are managed by RenderRasterImages. + private void EmitVanishedKittyDeletes (IOutputBuffer buffer) + { + HashSet currentIds = []; + + foreach (RasterImageCommand command in buffer.GetRasterImages ()) + { + if (string.IsNullOrEmpty (command.Id)) + { + continue; + } + + currentIds.Add (command.Id); + } + + List vanished = []; + + foreach ((string id, List placedImageIds) in _placedKittyImageIds) + { + if (currentIds.Contains (id)) + { + continue; + } + + DeleteKittyPlacements (placedImageIds); + vanished.Add (id); + } + + foreach (string id in vanished) + { + _placedKittyImageIds.Remove (id); + } + } + + private void DeleteKittyPlacements (List imageIds) + { + foreach (int imageId in imageIds) + { + Write (new StringBuilder (KittyGraphicsEncoder.EncodeDeletePlacements (imageId))); + } + } + + // Derives the Kitty image id for the rectIndex-th visible fragment of a command. Fragment 0 uses + // the command's base id (matching ImageView's pre-encoded payload); later fragments get distinct + // ids so their data does not overwrite earlier fragments under a shared id. + private static int GetKittyImageId (string commandId, int rectIndex) => + rectIndex == 0 + ? KittyGraphicsEncoder.GetImageId (commandId) + : KittyGraphicsEncoder.GetImageId ($"{commandId}#{rectIndex}"); + + private void RenderRasterImages (IOutputBuffer buffer, bool renderAfterText) + { + foreach (RasterImageCommand command in buffer.GetRasterImages ()) + { + if (command.RenderAfterText != renderAfterText) + { + continue; + } + if (!command.IsDirty && !command.AlwaysRender) { continue; } + bool trackKitty = UseKittyGraphics && !string.IsNullOrEmpty (command.Id); + + // Erase every prior placement of this image before re-placing it, so a resized, moved, or + // re-fragmented Kitty image does not leave stale placements on screen. Sixel needs no such + // delete — redrawing the cells overwrites it. + if (trackKitty && _placedKittyImageIds.TryGetValue (command.Id!, out List? previous)) + { + DeleteKittyPlacements (previous); + } + + List placedImageIds = []; + var rectIndex = 0; + foreach (Rectangle visibleCells in GetVisibleRasterCellRectangles (command)) { if (!TryCropRasterImagePixels (command.Pixels!, command.DestinationCells, visibleCells, out Color [,] pixels)) @@ -531,37 +815,85 @@ private void RenderRasterImages (IOutputBuffer buffer) continue; } + int imageId = trackKitty ? GetKittyImageId (command.Id!, rectIndex) : 0; SetCursorPositionImpl (visibleCells.X, visibleCells.Y); - Write (new StringBuilder (GetRasterImageSixelData (command, visibleCells, pixels))); + Write (new StringBuilder (GetRasterImageData (command, visibleCells, pixels, imageId))); + + if (trackKitty) + { + placedImageIds.Add (imageId); + } + + rectIndex++; + } + + if (trackKitty) + { + _placedKittyImageIds [command.Id!] = placedImageIds; } command.IsDirty = false; } } - private static bool AppendRasterImageAnsi (IOutputBuffer buffer, StringBuilder output) + private bool AppendRasterImageAnsi (IOutputBuffer buffer, StringBuilder output, bool renderAfterText) { bool wroteRasterImages = false; foreach (RasterImageCommand command in buffer.GetRasterImages ()) { + if (command.RenderAfterText != renderAfterText) + { + continue; + } + + bool useKittyIds = UseKittyGraphics && !string.IsNullOrEmpty (command.Id); + var rectIndex = 0; + foreach (Rectangle visibleCells in GetVisibleRasterCellRectangles (command)) { if (!TryCropRasterImagePixels (command.Pixels!, command.DestinationCells, visibleCells, out Color [,] pixels)) { - continue; } + int imageId = useKittyIds ? GetKittyImageId (command.Id!, rectIndex) : 0; output.Append (EscSeqUtils.CSI_SetCursorPosition (visibleCells.Y + 1, visibleCells.X + 1)); - output.Append (GetRasterImageSixelData (command, visibleCells, pixels)); + output.Append (GetRasterImageData (command, visibleCells, pixels, imageId)); wroteRasterImages = true; + rectIndex++; } } return wroteRasterImages; } + private string GetRasterImageData (RasterImageCommand command, Rectangle visibleCells, Color [,] pixels, int imageId) + { + if (UseKittyGraphics) + { + return GetRasterImageKittyData (command, visibleCells, pixels, imageId); + } + + return GetRasterImageSixelData (command, visibleCells, pixels); + } + + private static string GetRasterImageKittyData (RasterImageCommand command, Rectangle visibleCells, Color [,] pixels, int imageId) + { + // The pre-encoded payload carries the base image id and covers the whole destination, so it + // only applies to fragment 0 rendered un-clipped. + if (command.EncodedKitty is { } encodedKitty + && visibleCells == command.DestinationCells + && imageId == KittyGraphicsEncoder.GetImageId (command.Id!)) + { + return encodedKitty; + } + + KittyGraphicsEncoder encoder = new (); + + return encoder.EncodeKitty (pixels, visibleCells.Width, visibleCells.Height, imageId); + } + private static string GetRasterImageSixelData (RasterImageCommand command, Rectangle visibleCells, Color [,] pixels) { if (command.EncodedSixel is { } encodedSixel && visibleCells == command.DestinationCells) diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs index 5ae51ccf8f..7ce1264314 100644 --- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs @@ -319,12 +319,14 @@ public void AddRasterImage (RasterImageCommand command) { MarkRasterImageCellsDirty (_rasterImages [index]); MarkRasterImageCellsClean (command); + command.NeedsTransparentCellClear = ClearCellsUnderRasterImage (command); _rasterImages [index] = command; return; } MarkRasterImageCellsClean (command); + command.NeedsTransparentCellClear = ClearCellsUnderRasterImage (command); _rasterImages.Add (command); } } @@ -359,6 +361,50 @@ public IReadOnlyList GetRasterImages () } } + // A raster image owns the blank cells in its visible destination. Keep those cells clean so + // Sixel output is not erased by normal cell rendering; OutputBase decides when protocol-specific + // transparent blanks must be emitted for Kitty's below-text placement model. + private bool ClearCellsUnderRasterImage (RasterImageCommand command) + { + if (Contents is null) + { + return false; + } + + Attribute transparent = new (Color.None, Color.None); + Region clip = command.Clip ?? new (Screen); + var needsTerminalClear = false; + + 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++) + { + string grapheme = Contents [row, col].Grapheme; + + if (!string.IsNullOrEmpty (grapheme) && grapheme != " ") + { + needsTerminalClear = true; + } + + Contents [row, col].Grapheme = " "; + Contents [row, col].Attribute = transparent; + Contents [row, col].IsDirty = false; + } + } + } + + return needsTerminalClear; + } + private void MarkRasterImageCellsClean (RasterImageCommand command) { SetRasterImageCellsDirtyState (command, false); diff --git a/Terminal.Gui/Drivers/Output/RasterImageCommand.cs b/Terminal.Gui/Drivers/Output/RasterImageCommand.cs index 5ff29347d5..5ecfe19b04 100644 --- a/Terminal.Gui/Drivers/Output/RasterImageCommand.cs +++ b/Terminal.Gui/Drivers/Output/RasterImageCommand.cs @@ -24,6 +24,15 @@ public class RasterImageCommand /// public string? EncodedSixel { get; set; } + /// + /// Gets or sets pre-encoded Kitty graphics protocol data for . + /// + /// + /// This is used only when the full rectangle is visible. Clipped output still + /// re-encodes the cropped pixels so the emitted dimensions match the clipped cell region. + /// + public string? EncodedKitty { get; set; } + /// /// Gets or sets the screen cells occupied by . /// @@ -48,4 +57,19 @@ public class RasterImageCommand /// Gets or sets whether the image should be emitted on every driver write. /// public bool AlwaysRender { get; set; } + + /// + /// Gets or sets whether the image should be emitted after dirty text cells rather than before them. + /// + /// + /// The default is so text drawn later can appear above a raster image. Set this to + /// for animated Sixel overlays that need to repaint after the normal cell pass to + /// avoid visible flicker. + /// + public bool RenderAfterText { get; set; } + + /// + /// Gets or sets whether covered blank cells need to be emitted as transparent blanks before rendering. + /// + internal bool NeedsTransparentCellClear { get; set; } } diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs index 313f3f19f1..e223eb9da8 100644 --- a/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs @@ -304,9 +304,7 @@ public static bool TryWriteStdout (byte [] buffer) return false; } - int written = write (fd, buffer, buffer.Length); - - return written >= 0; + return TryWriteAll (fd, buffer, write); } catch { @@ -314,6 +312,29 @@ public static bool TryWriteStdout (byte [] buffer) } } + internal static bool TryWriteAll (int fd, byte [] buffer, Func writeFunc) + { + int offset = 0; + int remaining = buffer.Length; + + while (remaining > 0) + { + // P/Invoke always writes from index 0, so slice when offset > 0. + byte [] slice = offset == 0 ? buffer : buffer [offset..]; + int written = writeFunc (fd, slice, remaining); + + if (written <= 0) + { + return false; + } + + offset += written; + remaining -= written; + } + + return true; + } + /// /// Writes a UTF-8 string to stdout. /// diff --git a/Terminal.Gui/Views/ImageView/ImageView.Drawing.cs b/Terminal.Gui/Views/ImageView/ImageView.Drawing.cs index 25026cdd67..b8774507c7 100644 --- a/Terminal.Gui/Views/ImageView/ImageView.Drawing.cs +++ b/Terminal.Gui/Views/ImageView/ImageView.Drawing.cs @@ -14,14 +14,15 @@ protected override bool OnDrawingContent (DrawContext? context) return true; } - if (IsUsingSixel) + if (IsUsingRasterGraphics) { - DrawSixel (); + DrawRasterImage (); 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); @@ -34,6 +35,7 @@ protected override bool OnDrawingContent (DrawContext? context) { 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); @@ -92,9 +94,9 @@ private void DrawCellBased () } /// - /// Renders the image using sixel escape sequences. + /// Renders the image using a raster graphics protocol (Kitty or Sixel). /// - private void DrawSixel () + private void DrawRasterImage () { RenderRequest? request = CreateRenderRequest (true); @@ -105,7 +107,7 @@ private void DrawSixel () EnsureScaledImage (request); - if (_scaledImage is null || _scaledImageCellSize is null || SixelEncoder is null || App?.Driver is not { } driver) + if (_scaledImage is null || _scaledImageCellSize is null || App?.Driver is not { } driver) { return; } @@ -126,6 +128,7 @@ private void DrawSixel () Id = RasterImageId, Pixels = _scaledImage, EncodedSixel = _encodedSixel, + EncodedKitty = _encodedKitty, DestinationCells = destinationCells, Encoder = SixelEncoder, IsDirty = true diff --git a/Terminal.Gui/Views/ImageView/ImageView.Geometry.cs b/Terminal.Gui/Views/ImageView/ImageView.Geometry.cs index 4ce067d9b1..cac8a6a1c9 100644 --- a/Terminal.Gui/Views/ImageView/ImageView.Geometry.cs +++ b/Terminal.Gui/Views/ImageView/ImageView.Geometry.cs @@ -91,7 +91,7 @@ private double GetMinimumZoomLevel () private Size GetBaseRenderTargetSize () { - if (IsUsingSixel && App?.Driver?.SixelSupport is { }) + if (IsUsingRasterGraphics && App?.Driver is { }) { return ViewportToScreenInPixels ().Size; } diff --git a/Terminal.Gui/Views/ImageView/ImageView.Input.cs b/Terminal.Gui/Views/ImageView/ImageView.Input.cs index 1e10bc26f5..d6c0f9e1fe 100644 --- a/Terminal.Gui/Views/ImageView/ImageView.Input.cs +++ b/Terminal.Gui/Views/ImageView/ImageView.Input.cs @@ -138,12 +138,7 @@ private bool ZoomFromCommand (ICommandContext? context, double zoomLevel) /// protected override bool OnMouseEvent (Mouse mouse) { - if (HandleDrag (mouse)) - { - return true; - } - - return base.OnMouseEvent (mouse); + return HandleDrag (mouse) || base.OnMouseEvent (mouse); } private bool HandleDrag (Mouse mouse) diff --git a/Terminal.Gui/Views/ImageView/ImageView.Lifecycle.cs b/Terminal.Gui/Views/ImageView/ImageView.Lifecycle.cs index 26438034cd..f526778650 100644 --- a/Terminal.Gui/Views/ImageView/ImageView.Lifecycle.cs +++ b/Terminal.Gui/Views/ImageView/ImageView.Lifecycle.cs @@ -5,16 +5,16 @@ 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]; + const int WIDTH = 20; + const int HEIGHT = 10; + Color [,] testImage = new Color [WIDTH, HEIGHT]; - for (var y = 0; y < height; y++) + for (var y = 0; y < HEIGHT; y++) { - for (var x = 0; x < width; x++) + 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 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); } diff --git a/Terminal.Gui/Views/ImageView/ImageView.Render.cs b/Terminal.Gui/Views/ImageView/ImageView.Render.cs index da1895d1de..33c1db2e34 100644 --- a/Terminal.Gui/Views/ImageView/ImageView.Render.cs +++ b/Terminal.Gui/Views/ImageView/ImageView.Render.cs @@ -3,6 +3,7 @@ namespace Terminal.Gui.Views; public partial class ImageView { private string? _encodedSixel; + private string? _encodedKitty; private Color [,]? _scaledImage; private Size? _scaledImageCellSize; private RenderKey? _scaledImageRenderKey; @@ -21,6 +22,7 @@ private void InvalidateScaledImage (bool clearCurrentRender = false) _scaledImage = null; _scaledImageCellSize = null; _encodedSixel = null; + _encodedKitty = null; _attributeCache.Clear (); } @@ -38,7 +40,7 @@ private bool IsCurrentRenderReady () return true; } - RenderRequest? request = CreateRenderRequest (IsUsingSixel); + RenderRequest? request = CreateRenderRequest (IsUsingRasterGraphics); return request is null || IsRenderCacheCurrent (request.Key); } @@ -63,7 +65,7 @@ private void EnsureScaledImage (RenderRequest request) SetRenderingOverlayVisible (false); } - private RenderRequest? CreateRenderRequest (bool useSixel) + private RenderRequest? CreateRenderRequest (bool useRasterGraphics) { if (_image is null) { @@ -78,24 +80,42 @@ private void EnsureScaledImage (RenderRequest request) } Size? resolution = null; - SixelEncoder? encoder = null; + SixelEncoder? sixelEncoder = null; int? maxColors = null; Size targetSize; var allowUpscale = true; var preserveAspectRatio = false; + var useKitty = false; - if (useSixel) + if (useRasterGraphics) { - if (!IsUsingSixel || App?.Driver?.SixelSupport is not { } support) + if (!IsUsingRasterGraphics) { return null; } - encoder = PrepareSixelEncoder (support); + resolution = GetActiveResolution (); + + if (resolution is null) + { + return null; + } + + useKitty = IsKittyGraphicsActive (); + + if (!useKitty) + { + if (App?.Driver?.SixelSupport is not { } support) + { + return null; + } + + sixelEncoder = PrepareSixelEncoder (support); + maxColors = sixelEncoder.Quantizer.MaxColors; + } + Rectangle targetRect = ViewportToScreenInPixels (); targetSize = targetRect.Size; - resolution = support.Resolution; - maxColors = encoder.Quantizer.MaxColors; allowUpscale = AllowSixelUpscaling || _zoomLevel > FIT_ZOOM_LEVEL; preserveAspectRatio = true; } @@ -112,7 +132,8 @@ private void EnsureScaledImage (RenderRequest request) } RenderKey key = new (_imageVersion, - useSixel, + useRasterGraphics, + useKitty, targetSize, resolution, maxColors, @@ -122,7 +143,13 @@ private void EnsureScaledImage (RenderRequest request) allowUpscale, preserveAspectRatio); - return new RenderRequest (key, _image, visibleSource, targetSize, resolution, encoder is { } ? CreateBackgroundEncoder (encoder) : null); + return new RenderRequest (key, + _image, + visibleSource, + targetSize, + resolution, + sixelEncoder is { } ? CreateBackgroundEncoder (sixelEncoder) : null, + KittyGraphicsEncoder.GetImageId (RasterImageId)); } private SixelEncoder PrepareSixelEncoder (SixelSupportResult support) @@ -149,6 +176,7 @@ private void ApplyRenderResult (RenderResult result) _scaledImage = result.ScaledImage; _scaledImageCellSize = result.CellSize; _encodedSixel = result.EncodedSixel; + _encodedKitty = result.EncodedKitty; _scaledImageRenderKey = result.Key; _attributeCache.Clear (); } @@ -161,12 +189,28 @@ private static RenderResult RenderScaledImage (RenderRequest request) request.Key.AllowUpscale, request.Key.PreserveAspectRatio); - Size cellSize = request.Key.UseSixel && request.Resolution is { } resolution + Size cellSize = request.Key.UseRasterGraphics && 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); + string? encodedSixel = null; + string? encodedKitty = null; + + if (!request.Key.UseRasterGraphics) + { + return new RenderResult (request.Key, scaledImage, cellSize, encodedSixel, encodedKitty); + } + + if (request.Key.UseKitty) + { + encodedKitty = new KittyGraphicsEncoder ().EncodeKitty (scaledImage, cellSize.Width, cellSize.Height, request.KittyImageId); + } + else + { + encodedSixel = request.Encoder?.EncodeSixel (scaledImage); + } + + return new RenderResult (request.Key, scaledImage, cellSize, encodedSixel, encodedKitty); } private static SixelEncoder CreateBackgroundEncoder (SixelEncoder encoder) => @@ -272,7 +316,7 @@ private void CompleteBackgroundRenderOnMainThread (RenderResult result) return; } - RenderRequest? currentRequest = CreateRenderRequest (result.Key.UseSixel); + RenderRequest? currentRequest = CreateRenderRequest (result.Key.UseRasterGraphics); if (UseBackgroundRendering && currentRequest?.Key == result.Key) { @@ -331,7 +375,8 @@ private void SetRenderingOverlayVisible (bool visible) } private readonly record struct RenderKey (int ImageVersion, - bool UseSixel, + bool UseRasterGraphics, + bool UseKitty, Size TargetSize, Size? Resolution, int? MaxColors, @@ -341,7 +386,13 @@ private readonly record struct RenderKey (int ImageVersion, bool AllowUpscale, bool PreserveAspectRatio); - private sealed class RenderRequest (RenderKey key, Color [,] source, RectangleF visibleSource, Size targetSize, Size? resolution, SixelEncoder? encoder) + private sealed class RenderRequest (RenderKey key, + Color [,] source, + RectangleF visibleSource, + Size targetSize, + Size? resolution, + SixelEncoder? encoder, + int kittyImageId) { public RenderKey Key { get; } = key; public Color [,] Source { get; } = source; @@ -349,7 +400,8 @@ private sealed class RenderRequest (RenderKey key, Color [,] source, RectangleF public Size TargetSize { get; } = targetSize; public Size? Resolution { get; } = resolution; public SixelEncoder? Encoder { get; } = encoder; + public int KittyImageId { get; } = kittyImageId; } - private readonly record struct RenderResult (RenderKey Key, Color [,] ScaledImage, Size CellSize, string? EncodedSixel); + private readonly record struct RenderResult (RenderKey Key, Color [,] ScaledImage, Size CellSize, string? EncodedSixel, string? EncodedKitty); } diff --git a/Terminal.Gui/Views/ImageView/ImageView.cs b/Terminal.Gui/Views/ImageView/ImageView.cs index cd4a7b9752..7a307cb418 100644 --- a/Terminal.Gui/Views/ImageView/ImageView.cs +++ b/Terminal.Gui/Views/ImageView/ImageView.cs @@ -3,7 +3,7 @@ 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). +/// and raster-based (for terminals that support either the Sixel or Kitty graphics protocols). /// /// /// @@ -13,15 +13,14 @@ namespace Terminal.Gui.Views; /// 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, +/// When is , the view renders through +/// the best raster protocol available to the driver: Kitty graphics first, then Sixel. +/// Raster 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. +/// When no raster protocol is 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 @@ -49,9 +48,12 @@ public partial class ImageView : View, IDesignable [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) + [Command.Home] = Bind.All (Key.Home, Key.D0), + [Command.ZoomIn] = Bind.All (new Key ('+'), + new Key ('+').WithShift, + new Key ('='), + new Key ('=').WithShift), + [Command.ZoomOut] = Bind.All (new Key ('-')) }; /// @@ -139,16 +141,33 @@ private void ReplacePanAndZoomBindings () } /// - /// Gets or sets whether to prefer sixel rendering when the terminal supports it. + /// Gets or sets whether to prefer raster-graphics rendering (Kitty or Sixel protocol) + /// when the terminal supports it. Default is . + /// + /// + /// When the view selects the best available protocol: + /// Kitty graphics (if the driver reports ), + /// then Sixel (if the driver reports ), + /// then cell-based rendering as a fallback. + /// Set to to always use cell-based rendering. + /// + public bool UseRasterGraphics { get; set; } = true; + + /// + /// Gets whether to prefer raster 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. + /// This obsolete compatibility property delegates to . + /// When , Kitty graphics is preferred and Sixel is used as the + /// fallback. When , cell-based rendering is always used. /// - public bool UseSixel { get; set; } = true; + [Obsolete ("Use UseRasterGraphics instead. UseSixel will be removed in a future version.")] + public bool UseSixel + { + get => UseRasterGraphics; + set => UseRasterGraphics = value; + } /// /// Gets or sets whether ImageView scales image renders on a background thread. @@ -273,10 +292,18 @@ public SixelEncoder? SixelEncoder } } + /// + /// Gets whether the current rendering mode is using a raster graphics protocol (Kitty or Sixel). + /// + public bool IsUsingRasterGraphics => UseRasterGraphics + && (IsKittyGraphicsActive () + || App?.Driver?.SixelSupport is { IsSupported: true }); + /// /// Gets whether the current rendering mode is using sixel. /// - public bool IsUsingSixel => UseSixel && App?.Driver?.SixelSupport is { IsSupported: true }; + [Obsolete ("Use IsUsingRasterGraphics instead. IsUsingSixel will be removed in a future version.")] + public bool IsUsingSixel => UseRasterGraphics && !IsKittyGraphicsActive () && App?.Driver?.SixelSupport is { IsSupported: true }; /// /// Converts the Viewport to screen coordinates in pixels. @@ -291,19 +318,42 @@ public SixelEncoder? SixelEncoder /// The screen coordinates of the Viewport in pixels. public Rectangle ViewportToScreenInPixels () { - SixelSupportResult support = App?.Driver?.SixelSupport ?? throw new InvalidOperationException (@"No sixel support available."); + Size resolution = GetActiveResolution () ?? throw new InvalidOperationException (@"No raster graphics support available."); - int pixelsPerCellX = support.Resolution.Width; - int pixelsPerCellY = support.Resolution.Height; + int pixelsPerCellX = resolution.Width; + int pixelsPerCellY = 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; + int targetHeightInPixels = IsKittyGraphicsActive () + ? boundsRect.Height * pixelsPerCellY + : SixelEncoder?.GetHeightInPixels (boundsRect.Height, pixelsPerCellY) ?? boundsRect.Height * pixelsPerCellY; return new Rectangle (boundsRect.X * pixelsPerCellX, boundsRect.Y * pixelsPerCellY, targetWidthInPixels, targetHeightInPixels); } + /// + /// Returns the resolution (pixels per cell) for the active raster graphics protocol, + /// preferring Kitty over Sixel. Returns when neither is available. + /// + private Size? GetActiveResolution () + { + if (IsKittyGraphicsActive () && App?.Driver?.KittyGraphicsSupport is { IsSupported: true } kitty) + { + return kitty.Resolution; + } + + if (App?.Driver?.SixelSupport is { IsSupported: true } sixel) + { + return sixel.Resolution; + } + + return null; + } + + private bool IsKittyGraphicsActive () => + App?.Driver is { KittyGraphicsSupport.IsSupported: true } driver && driver.GetOutput ().UseKittyGraphics; + /// /// Returns the size in cell terms of the given image resized to fit in the viewport. /// @@ -324,7 +374,8 @@ public Size FitImageInViewportCells (Size imageSizeInPixels) } // Account for the terminal cell aspect ratio - double cellAspectRatio = App?.Driver?.SixelSupport is { } support ? (double)support.Resolution.Height / support.Resolution.Width : 2.0; + Size? activeResolution = GetActiveResolution (); + double cellAspectRatio = activeResolution is { } res ? (double)res.Height / res.Width : 2.0; Size imageSize = imageSizeInPixels with { Height = (int)(imageSizeInPixels.Height / cellAspectRatio) }; // Calculate aspect-ratio-preserving size diff --git a/Terminal.Gui/Views/ProgressBar.cs b/Terminal.Gui/Views/ProgressBar.cs index 46d71f5c05..b6ac03a968 100644 --- a/Terminal.Gui/Views/ProgressBar.cs +++ b/Terminal.Gui/Views/ProgressBar.cs @@ -417,13 +417,25 @@ 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) + if (filledCells <= 0 || Viewport.Height <= 0 || Driver is not { } driver) { return false; } - int cellWidthPixels = Math.Max (1, support.Resolution.Width); - int cellHeightPixels = Math.Max (1, support.Resolution.Height); + // The fire is a raster image, so it works under either raster protocol. Prefer Kitty when it + // is the active output (e.g. Kitty/Ghostty report no Sixel support), otherwise fall back to + // Sixel. Without this the fire silently disappears on Kitty-only terminals. + bool kittyActive = driver.KittyGraphicsSupport is { IsSupported: true } && driver.GetOutput ().UseKittyGraphics; + SixelSupportResult? sixel = driver.SixelSupport; + + if (!kittyActive && sixel is not { IsSupported: true }) + { + return false; + } + + Size resolution = kittyActive ? driver.KittyGraphicsSupport!.Resolution : sixel!.Resolution; + int cellWidthPixels = Math.Max (1, resolution.Width); + int cellHeightPixels = Math.Max (1, resolution.Height); int pixelWidth = Math.Max (1, filledCells * cellWidthPixels); int pixelHeight = Math.Max (1, Viewport.Height * cellHeightPixels); @@ -432,10 +444,12 @@ private bool DrawFireProgress (int filledCells) Id = _fireRasterImageId, Pixels = CreateFirePixels (pixelWidth, pixelHeight, _fireFrame++), DestinationCells = ViewportToScreen (new Rectangle (0, 0, filledCells, Viewport.Height)), - Encoder = GetFireEncoder (support) + + // The Sixel encoder is ignored when Kitty output is active; only build it for the Sixel path. + Encoder = kittyActive ? null : GetFireEncoder (sixel!) }; - Driver.GetOutputBuffer ().AddRasterImage (command); + driver.GetOutputBuffer ().AddRasterImage (command); return true; } diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index b6d00b2721..1fadd1187e 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -577,6 +577,7 @@ True True + True True True True diff --git a/Tests/UnitTestsParallelizable/Drawing/Kitty/KittyGraphicsEncoderTests.cs b/Tests/UnitTestsParallelizable/Drawing/Kitty/KittyGraphicsEncoderTests.cs new file mode 100644 index 0000000000..8b02fa960a --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drawing/Kitty/KittyGraphicsEncoderTests.cs @@ -0,0 +1,221 @@ +// Copilot - Claude Sonnet 4.6 + +namespace DrawingTests; + +public class KittyGraphicsEncoderTests +{ + private static Color [,] CreateSolidPixels (int width, int height, Color color) + { + Color [,] pixels = new Color [width, height]; + + for (var x = 0; x < width; x++) + { + for (var y = 0; y < height; y++) + { + pixels [x, y] = color; + } + } + + return pixels; + } + + [Fact] + public void EncodeKitty_RedSquare_StartsWithApcAndEndsWithStringTerminator () + { + KittyGraphicsEncoder encoder = new (); + Color [,] pixels = CreateSolidPixels (2, 2, new Color (255, 0, 0)); + + string result = encoder.EncodeKitty (pixels, 2, 1); + + Assert.StartsWith ("\x1b_G", result); + Assert.EndsWith ("\x1b\\", result); + } + + [Fact] + public void EncodeKitty_RedSquare_ContainsRequiredMetadataFields () + { + KittyGraphicsEncoder encoder = new (); + Color [,] pixels = CreateSolidPixels (2, 2, new Color (255, 0, 0)); + + string result = encoder.EncodeKitty (pixels, 2, 1); + + Assert.Contains ("a=T", result); + Assert.Contains ("f=32", result); + Assert.Contains ("s=2", result); + Assert.Contains ("v=2", result); + Assert.Contains ("c=2", result); + Assert.Contains ("r=1", result); + Assert.Contains ("q=2", result); + } + + [Fact] + public void EncodeKitty_DoesNotMoveCursor () + { + // Claude - Opus 4.8 + // The Kitty protocol moves the cursor to just after the image by default (C=0). + // When an image is placed near the bottom of the screen, the resulting cursor move + // scrolls the terminal up one row on every frame. C=1 suppresses cursor movement so + // animated images repaint in place instead of marching up the screen. + KittyGraphicsEncoder encoder = new (); + Color [,] pixels = CreateSolidPixels (2, 2, new Color (255, 0, 0)); + + string result = encoder.EncodeKitty (pixels, 2, 1); + + Assert.Contains ("C=1", result); + } + + [Fact] + public void EncodeKitty_WithImageId_EmitsImageIdField () + { + // Claude - Opus 4.8 + // The i= field tags the placement so a prior placement can be deleted/replaced by id. + // Without it, a resized or moved image leaves its old placement on screen. + KittyGraphicsEncoder encoder = new (); + Color [,] pixels = CreateSolidPixels (2, 2, new Color (255, 0, 0)); + + string result = encoder.EncodeKitty (pixels, 2, 1, 12345); + + Assert.Contains ("i=12345", result); + } + + [Fact] + public void EncodeKitty_WithoutImageId_OmitsImageIdField () + { + // Claude - Opus 4.8 + KittyGraphicsEncoder encoder = new (); + Color [,] pixels = CreateSolidPixels (2, 2, new Color (255, 0, 0)); + + string result = encoder.EncodeKitty (pixels, 2, 1); + + Assert.DoesNotContain ("i=", result); + } + + [Fact] + public void GetImageId_IsStableForSameString () + { + // Claude - Opus 4.8 + Assert.Equal (KittyGraphicsEncoder.GetImageId ("ImageView_42"), KittyGraphicsEncoder.GetImageId ("ImageView_42")); + } + + [Fact] + public void GetImageId_IsPositiveAndNonZero () + { + // Claude - Opus 4.8 + Assert.True (KittyGraphicsEncoder.GetImageId ("ImageView_42") > 0); + Assert.True (KittyGraphicsEncoder.GetImageId ("anything") > 0); + } + + [Fact] + public void EncodeDeletePlacements_TargetsImageIdAndKeepsData () + { + // Claude - Opus 4.8 + // a=d deletes; d=i targets placements of image id i, leaving the transmitted data intact. + string result = KittyGraphicsEncoder.EncodeDeletePlacements (777); + + Assert.StartsWith ("\x1b_G", result); + Assert.EndsWith ("\x1b\\", result); + Assert.Contains ("a=d", result); + Assert.Contains ("d=i", result); + Assert.Contains ("i=777", result); + } + + [Fact] + public void EncodeKitty_SmallImage_HasSingleChunkWithMoreEqualZero () + { + KittyGraphicsEncoder encoder = new (); + Color [,] pixels = CreateSolidPixels (1, 1, new Color (0, 255, 0)); + + string result = encoder.EncodeKitty (pixels, 1, 1); + + // Single chunk: m=0 (last/only chunk) + Assert.Contains ("m=0", result); + // Should NOT have m=1 (no continuation chunks) + Assert.DoesNotContain ("m=1", result); + } + + [Fact] + public void EncodeKitty_LargeImage_EmitsMultipleChunksWithContinuationMarker () + { + // 32×32 pixels = 32*32*4 = 4096 raw bytes = ~5460 base64 chars — exceeds MaxChunkSize + KittyGraphicsEncoder encoder = new (); + Color [,] pixels = CreateSolidPixels (32, 32, new Color (0, 0, 255)); + + string result = encoder.EncodeKitty (pixels, 4, 2); + + // m=1 on the first chunk means more data follows + Assert.Contains ("m=1", result); + // The final chunk must have m=0 + Assert.Contains ("m=0", result); + } + + [Fact] + public void EncodeKitty_TransparentPixels_IncludesAlphaChannelInData () + { + KittyGraphicsEncoder encoder = new (); + Color [,] pixels = new Color [1, 1]; + + // Fully transparent red + pixels [0, 0] = new Color (255, 0, 0, 0); + + string result = encoder.EncodeKitty (pixels, 1, 1); + + // Extract the base64 payload from the APC sequence + // Format: ESC_G ; ESC\ + int semicolonIdx = result.IndexOf (';'); + int endIdx = result.LastIndexOf ('\x1b'); + + Assert.True (semicolonIdx > 0, "No semicolon found in APC sequence"); + + string base64 = result.Substring (semicolonIdx + 1, endIdx - semicolonIdx - 1); + byte [] decoded = Convert.FromBase64String (base64); + + // RGBA: R=255, G=0, B=0, A=0 + Assert.Equal (4, decoded.Length); + Assert.Equal (255, decoded [0]); + Assert.Equal (0, decoded [1]); + Assert.Equal (0, decoded [2]); + Assert.Equal (0, decoded [3]); + } + + [Fact] + public void EncodeKitty_OpaquePixel_HasAlpha255 () + { + KittyGraphicsEncoder encoder = new (); + Color [,] pixels = new Color [1, 1]; + + pixels [0, 0] = new Color (10, 20, 30, 255); + + string result = encoder.EncodeKitty (pixels, 1, 1); + + int semicolonIdx = result.IndexOf (';'); + int endIdx = result.LastIndexOf ('\x1b'); + string base64 = result.Substring (semicolonIdx + 1, endIdx - semicolonIdx - 1); + byte [] decoded = Convert.FromBase64String (base64); + + Assert.Equal (4, decoded.Length); + Assert.Equal (10, decoded [0]); + Assert.Equal (20, decoded [1]); + Assert.Equal (30, decoded [2]); + Assert.Equal (255, decoded [3]); + } + + [Fact] + public void EncodeKitty_EmitsValidBase64Payload () + { + KittyGraphicsEncoder encoder = new (); + Color [,] pixels = CreateSolidPixels (2, 2, new Color (128, 64, 32)); + + string result = encoder.EncodeKitty (pixels, 2, 1); + + // Extract the base64 data from first chunk + int semicolonIdx = result.IndexOf (';'); + int endIdx = result.IndexOf ('\x1b', semicolonIdx); + string base64 = result.Substring (semicolonIdx + 1, endIdx - semicolonIdx - 1); + + // Should not throw + byte [] decoded = Convert.FromBase64String (base64); + + // 2×2 pixels × 4 bytes = 16 bytes raw, but may be chunked — at least first chunk data + Assert.True (decoded.Length > 0); + } +} diff --git a/Tests/UnitTestsParallelizable/Drawing/Kitty/KittyGraphicsSupportDetectorTests.cs b/Tests/UnitTestsParallelizable/Drawing/Kitty/KittyGraphicsSupportDetectorTests.cs new file mode 100644 index 0000000000..59931aa6ad --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drawing/Kitty/KittyGraphicsSupportDetectorTests.cs @@ -0,0 +1,239 @@ +// Copilot - Claude Sonnet 4.6 + +using System.Reflection; + +namespace DrawingTests; + +[Collection ("Environment Variable Tests")] +public class KittyGraphicsSupportDetectorTests +{ + [Fact] + public void Constructor_DriverParameter_IsNonNullableContract () + { + NullabilityInfoContext context = new (); + + ConstructorInfo constructor = typeof (KittyGraphicsSupportDetector).GetConstructor ([typeof (IDriver)]) + ?? throw new InvalidOperationException ("Driver constructor was not found."); + + ParameterInfo parameter = Assert.Single (constructor.GetParameters ()); + + Assert.Equal (NullabilityState.NotNull, context.Create (parameter).ReadState); + Assert.Throws (() => new KittyGraphicsSupportDetector (null!)); + } + + [Fact] + public void Detect_WhenKittyWindowIdSet_ReturnsSupported () + { + string? previous = Environment.GetEnvironmentVariable ("KITTY_WINDOW_ID"); + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", "1"); + string? previousTermProgram = Environment.GetEnvironmentVariable ("TERM_PROGRAM"); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", null); + + try + { + KittyGraphicsSupportDetector detector = new (); + KittyGraphicsSupportResult? result = null; + + detector.Detect (r => result = r); + + Assert.NotNull (result); + Assert.True (result.IsSupported); + } + finally + { + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", previous); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", previousTermProgram); + } + } + + [Fact] + public void Detect_WhenTermProgramIsKitty_ReturnsSupported () + { + string? previous = Environment.GetEnvironmentVariable ("KITTY_WINDOW_ID"); + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", null); + string? previousTermProgram = Environment.GetEnvironmentVariable ("TERM_PROGRAM"); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", "kitty"); + + try + { + KittyGraphicsSupportDetector detector = new (); + KittyGraphicsSupportResult? result = null; + + detector.Detect (r => result = r); + + Assert.NotNull (result); + Assert.True (result.IsSupported); + } + finally + { + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", previous); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", previousTermProgram); + } + } + + [Fact] + public void Detect_WhenTermProgramIsGhostty_ReturnsSupported () + { + string? previous = Environment.GetEnvironmentVariable ("KITTY_WINDOW_ID"); + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", null); + string? previousTermProgram = Environment.GetEnvironmentVariable ("TERM_PROGRAM"); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", "ghostty"); + + try + { + KittyGraphicsSupportDetector detector = new (); + KittyGraphicsSupportResult? result = null; + + detector.Detect (r => result = r); + + Assert.NotNull (result); + Assert.True (result.IsSupported); + } + finally + { + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", previous); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", previousTermProgram); + } + } + + [Fact] + public void Detect_WhenTermProgramIsGhostty_CaseInsensitive () + { + string? previous = Environment.GetEnvironmentVariable ("KITTY_WINDOW_ID"); + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", null); + string? previousTermProgram = Environment.GetEnvironmentVariable ("TERM_PROGRAM"); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", "Ghostty"); + + try + { + KittyGraphicsSupportDetector detector = new (); + KittyGraphicsSupportResult? result = null; + + detector.Detect (r => result = r); + + Assert.NotNull (result); + Assert.True (result.IsSupported); + } + finally + { + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", previous); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", previousTermProgram); + } + } + + [Fact] + public void Detect_WhenNoEnvVarsSet_ReturnsNotSupported () + { + string? previousWindowId = Environment.GetEnvironmentVariable ("KITTY_WINDOW_ID"); + string? previousTermProgram = Environment.GetEnvironmentVariable ("TERM_PROGRAM"); + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", null); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", "xterm"); + + try + { + KittyGraphicsSupportDetector detector = new (); + KittyGraphicsSupportResult? result = null; + + detector.Detect (r => result = r); + + Assert.NotNull (result); + Assert.False (result.IsSupported); + } + finally + { + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", previousWindowId); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", previousTermProgram); + } + } + + [Fact] + public void Detect_NotSupported_HasDefaultResolution () + { + string? previousWindowId = Environment.GetEnvironmentVariable ("KITTY_WINDOW_ID"); + string? previousTermProgram = Environment.GetEnvironmentVariable ("TERM_PROGRAM"); + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", null); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", null); + + try + { + KittyGraphicsSupportDetector detector = new (); + KittyGraphicsSupportResult? result = null; + + detector.Detect (r => result = r); + + Assert.NotNull (result); + Assert.Equal (new Size (10, 20), result.Resolution); + } + finally + { + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", previousWindowId); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", previousTermProgram); + } + } + + [Fact] + public void Detect_Supported_NoDriver_HasDefaultResolution () + { + string? previousWindowId = Environment.GetEnvironmentVariable ("KITTY_WINDOW_ID"); + string? previousTermProgram = Environment.GetEnvironmentVariable ("TERM_PROGRAM"); + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", "1"); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", null); + + try + { + // Detector without a driver cannot query for resolution — defaults to 10×20 + KittyGraphicsSupportDetector detector = new (); + KittyGraphicsSupportResult? result = null; + + detector.Detect (r => result = r); + + Assert.NotNull (result); + Assert.True (result.IsSupported); + Assert.Equal (new Size (10, 20), result.Resolution); + } + finally + { + Environment.SetEnvironmentVariable ("KITTY_WINDOW_ID", previousWindowId); + Environment.SetEnvironmentVariable ("TERM_PROGRAM", previousTermProgram); + } + } +} + +[CollectionDefinition ("Environment Variable Tests", DisableParallelization = true)] +public class EnvironmentVariableTestCollection +{ } + +public class KittyGraphicsSupportDetectorCollectionTests +{ + [Fact] + public void DetectorTests_RunInNonParallelEnvironmentCollection () + { + object? collectionAttribute = typeof (KittyGraphicsSupportDetectorTests).GetCustomAttributes (false) + .SingleOrDefault (attribute => attribute.GetType ().Name + == "CollectionAttribute"); + + Assert.NotNull (collectionAttribute); + + var collectionName = collectionAttribute.GetType ().GetProperty ("Name")?.GetValue (collectionAttribute) as string; + + Assert.Equal ("Environment Variable Tests", collectionName); + + Type? collectionDefinitionType = typeof (KittyGraphicsSupportDetectorTests).Assembly.GetType ("DrawingTests.EnvironmentVariableTestCollection"); + + Assert.NotNull (collectionDefinitionType); + + object? collectionDefinitionAttribute = collectionDefinitionType.GetCustomAttributes (false) + .SingleOrDefault (attribute => attribute.GetType ().Name + == "CollectionDefinitionAttribute"); + + Assert.NotNull (collectionDefinitionAttribute); + + var disableParallelization = (bool)(collectionDefinitionAttribute + .GetType () + .GetProperty ("DisableParallelization") + ?.GetValue (collectionDefinitionAttribute) + ?? false); + + Assert.True (disableParallelization); + } +} diff --git a/Tests/UnitTestsParallelizable/Drawing/Kitty/KittyGraphicsSupportResultTests.cs b/Tests/UnitTestsParallelizable/Drawing/Kitty/KittyGraphicsSupportResultTests.cs new file mode 100644 index 0000000000..9497c9630f --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drawing/Kitty/KittyGraphicsSupportResultTests.cs @@ -0,0 +1,31 @@ +// Copilot - Claude Sonnet 4.6 + +namespace DrawingTests; + +public class KittyGraphicsSupportResultTests +{ + [Fact] + public void DefaultValues_AreCorrect () + { + KittyGraphicsSupportResult result = new (); + + Assert.False (result.IsSupported); + Assert.Equal (new Size (10, 20), result.Resolution); + } + + [Fact] + public void IsSupported_CanBeSetToTrue () + { + KittyGraphicsSupportResult result = new () { IsSupported = true }; + + Assert.True (result.IsSupported); + } + + [Fact] + public void Resolution_CanBeCustomized () + { + KittyGraphicsSupportResult result = new () { Resolution = new Size (8, 16) }; + + Assert.Equal (new Size (8, 16), result.Resolution); + } +} diff --git a/Tests/UnitTestsParallelizable/Drawing/Sixel/SixelEncoderTests.cs b/Tests/UnitTestsParallelizable/Drawing/Sixel/SixelEncoderTests.cs index 6d315f7d2c..dc50378642 100644 --- a/Tests/UnitTestsParallelizable/Drawing/Sixel/SixelEncoderTests.cs +++ b/Tests/UnitTestsParallelizable/Drawing/Sixel/SixelEncoderTests.cs @@ -26,8 +26,8 @@ public void EncodeSixel_RedSquare12x12_ReturnsExpectedSixel () * $ (return to start of line) * - (move down to next line) */ - + "#0!12~$-" - + "#0!12~$" // Next 6 rows of red pixels + + "#0!12~-" + + "#0!12~" // Next 6 rows of red pixels (no trailing $ — last color layer) + "\u001b\\"; // End sixel sequence // Arrange: Create a 12x12 bitmap filled with red @@ -108,10 +108,10 @@ public void EncodeSixel_12x12GridPattern3x3_ReturnsExpectedSixel () * #1 (Select white) */ + "#0FFFwwwFFFwww$" // First pass of top band (Filling black) - + "#1wwwFFFwwwFFF$-" // Second pass of top band (Filling white) - // Sequence repeats exactly the same because top band is actually identical pixels to bottom band + + "#1wwwFFFwwwFFF-" // Second pass of top band (Filling white) — no trailing $ on last color layer + // Sequence repeats exactly the same because top band is actually identical pixels to bottom band + "#0FFFwwwFFFwww$" // First pass of bottom band (Filling black) - + "#1wwwFFFwwwFFF$" // Second pass of bottom band (Filling white) + + "#1wwwFFFwwwFFF" // Second pass of bottom band (Filling white) — no trailing $ on last color layer + "\u001b\\"; // End sixel sequence // Arrange: Create a 12x12 bitmap with a 3x3 checkerboard pattern @@ -202,8 +202,8 @@ public void EncodeSixel_VerticalMix_TransparentAndColor_ReturnsExpectedSixel (bo * Since we have 12 pixels horizontally, we'll see this pattern repeat across the row so we see * the 'sequence repeat' 12 times i.e. !12 (do the next letter 'T' 12 times). */ - + "#0!12T$-" // First band of alternating red and transparent pixels - + "#0!12T$" // Second band, same alternating red and transparent pixels + + "#0!12T-" // First band of alternating red and transparent pixels (no trailing $ — single color layer) + + "#0!12T" // Second band, same alternating red and transparent pixels (no trailing $) + "\u001b\\"; // End sixel sequence // Arrange: Create a 12x12 bitmap with alternating transparent and red pixels in a vertical band @@ -258,7 +258,7 @@ public void EncodeSixel_OnePixel_ReturnsExpectedSequence () + "q" + "\"1;1;1;1" // no-scaling + width;height + "#0;2;100;0;0" // palette - + "#0@$" // single column, single row -> code 1 -> char(1+63) = '@', then $ terminator + + "#0@" // single column, single row -> code 1 -> char(1+63) = '@' (no trailing $ — last color layer) + "\u001b\\"; Assert.Equal (expected, result); @@ -425,4 +425,33 @@ public void GetHeightInPixels_HeightBelowPixelHigh_WhenAvoidBottomScrollIsTrue ( Assert.Equal (expected, result); } + + [Fact] // Copilot + public void EncodeSixel_MultiBandMultiColor_NoTrailingDollarOnLastColorLayer () + { + // Arrange: 4x12 image — two bands of 6 rows each, two colors (red left half, blue right half) + Color [,] pixels = new Color [4, 12]; + + for (var y = 0; y < 12; y++) + { + for (var x = 0; x < 4; x++) + { + pixels [x, y] = x < 2 ? new Color (255, 0) : new Color (0, 0, 255); + } + } + + var encoder = new SixelEncoder (); + + // Act + string result = encoder.EncodeSixel (pixels); + + // Assert: No color layer data should end with '$' immediately before '-' or before the terminator. + // Between color layers '$' IS expected. + // Band structure: #data$#data- (for non-last band) + // #data$#data (for last band — no trailing $) + Assert.DoesNotContain ("$-", result); // No trailing $ before band separator + Assert.DoesNotContain ("$\u001b\\", result); // No trailing $ before sequence terminator + Assert.Contains ("-", result); // Has band separator (two bands) + Assert.Contains ("$", result); // Has $ between color layers within a band + } } diff --git a/Tests/UnitTestsParallelizable/Drivers/GraphicsSupportSetterTests.cs b/Tests/UnitTestsParallelizable/Drivers/GraphicsSupportSetterTests.cs new file mode 100644 index 0000000000..936e8da4f1 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/GraphicsSupportSetterTests.cs @@ -0,0 +1,62 @@ +namespace DriverTests; + +/// +/// Verifies the public / +/// setters. These let a host override capability detection — for example, to force inline-image output on +/// a headless or PTY driver that never runs the terminal handshake. +/// +[Collection ("Driver Tests")] +public class GraphicsSupportSetterTests +{ + private static DriverImpl NewDriver (out AnsiOutput output) + { + output = new (); + + return new ( + new AnsiComponentFactory (), + new AnsiInputProcessor (null!), + new OutputBufferImpl (), + output, + new (new AnsiResponseParser (new SystemTimeProvider ())), + new SizeMonitorImpl (output)); + } + + [Fact] + public void SetSixelSupport_UpdatesProperty_AndRaisesChanged () + { + IDriver driver = NewDriver (out _); + SixelSupportResult? raised = null; + driver.SixelSupportChanged += (_, e) => raised = e.NewValue; + + var result = new SixelSupportResult { IsSupported = true, MaxPaletteColors = 256, SupportsTransparency = true }; + driver.SetSixelSupport (result); + + Assert.Same (result, driver.SixelSupport); + Assert.Same (result, raised); + } + + [Fact] + public void SetKittyGraphicsSupport_WhenSupported_EnablesOutput_AndRaisesChanged () + { + IDriver driver = NewDriver (out AnsiOutput output); + KittyGraphicsSupportResult? raised = null; + driver.KittyGraphicsSupportChanged += (_, e) => raised = e.NewValue; + + var result = new KittyGraphicsSupportResult { IsSupported = true }; + driver.SetKittyGraphicsSupport (result); + + Assert.Same (result, driver.KittyGraphicsSupport); + Assert.Same (result, raised); + Assert.True (output.UseKittyGraphics); + } + + [Fact] + public void SetKittyGraphicsSupport_WhenUnsupported_LeavesOutputDisabled () + { + IDriver driver = NewDriver (out AnsiOutput output); + + driver.SetKittyGraphicsSupport (new KittyGraphicsSupportResult { IsSupported = false }); + + Assert.False (output.UseKittyGraphics); + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs index 9c2bc3bf50..70fadb954d 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs @@ -1236,6 +1236,143 @@ public void Write_RasterImage_RendersBeforeLaterDirtyCells () Assert.InRange (imageIndex, 0, textIndex - 1); } + [Fact] + public void Write_RasterImage_RenderAfterText_RendersAfterDirtyCells () + { + 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 = "animated-overlay", + Pixels = CreateSolidImage (2, 2, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2), + RenderAfterText = true + }; + + buffer.AddRasterImage (command); + buffer.Move (0, 0); + buffer.AddStr ("\u03a9"); + + output.Write (buffer); + string rendered = output.GetLastOutput (); + + int textIndex = rendered.IndexOf ("\u03a9", StringComparison.Ordinal); + int imageIndex = rendered.IndexOf ("\u001bP", StringComparison.Ordinal); + Assert.InRange (textIndex, 0, imageIndex - 1); + } + + [Fact] + public void Write_SixelRasterImage_SkipsDirtyBlankCellsCoveredByRaster () + { + AnsiOutput output = new () { UseKittyGraphics = false }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (1, 1); + + const string encodedSixel = "\u001bPIMG\u001b\\"; + + buffer.AddRasterImage (new RasterImageCommand + { + Id = "image", + Pixels = CreateSolidImage (1, 1, new Color (255, 0, 0)), + EncodedSixel = encodedSixel, + DestinationCells = new Rectangle (0, 0, 1, 1) + }); + + buffer.Move (0, 0); + buffer.AddStr (" "); + + output.Write (buffer); + string rendered = output.GetLastOutput (); + + int imageIndex = rendered.IndexOf (encodedSixel, StringComparison.Ordinal); + Assert.True (imageIndex >= 0); + Assert.DoesNotContain (" ", rendered [(imageIndex + encodedSixel.Length)..]); + } + + [Fact] + public void Write_SixelRasterImage_RenderAfterText_SkipsDirtyBlankCellsBeforeRaster () + { + AnsiOutput output = new () { UseKittyGraphics = false }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (1, 1); + + const string encodedSixel = "\u001bPIMG\u001b\\"; + + buffer.AddRasterImage (new RasterImageCommand + { + Id = "image", + Pixels = CreateSolidImage (1, 1, new Color (255, 0, 0)), + EncodedSixel = encodedSixel, + DestinationCells = new Rectangle (0, 0, 1, 1), + RenderAfterText = true + }); + + buffer.Move (0, 0); + buffer.AddStr (" "); + + output.Write (buffer); + string rendered = output.GetLastOutput (); + + int imageIndex = rendered.IndexOf (encodedSixel, StringComparison.Ordinal); + Assert.True (imageIndex >= 0); + Assert.DoesNotContain (" ", rendered [..imageIndex]); + } + + [Fact] + public void ToAnsi_SixelRasterImage_SkipsBlankCellsCoveredByRaster () + { + AnsiOutput output = new () { UseKittyGraphics = false }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (1, 1); + + const string encodedSixel = "\u001bPIMG\u001b\\"; + + buffer.AddRasterImage (new RasterImageCommand + { + Id = "image", + Pixels = CreateSolidImage (1, 1, new Color (255, 0, 0)), + EncodedSixel = encodedSixel, + DestinationCells = new Rectangle (0, 0, 1, 1) + }); + + buffer.Move (0, 0); + buffer.AddStr (" "); + + string ansi = output.ToAnsi (buffer); + + int imageIndex = ansi.IndexOf (encodedSixel, StringComparison.Ordinal); + Assert.True (imageIndex >= 0); + Assert.DoesNotContain (" ", ansi [(imageIndex + encodedSixel.Length)..]); + } + + [Fact] + public void Write_KittyRasterImage_OverPreviousCleanGlyph_EmitsTransparentBlankClear () + { + AnsiOutput output = new () { UseKittyGraphics = true }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (1, 1); + + buffer.AddStr ("X"); + output.Write (buffer); + + buffer.AddRasterImage (new RasterImageCommand + { + Id = "image", + Pixels = CreateSolidImage (1, 1, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 1, 1) + }); + + output.Write (buffer); + string rendered = output.GetLastOutput (); + + Assert.Contains ("\x1b_G", rendered); + Assert.DoesNotContain ("X", rendered); + Assert.Contains (" ", rendered); + } + [Fact] public void DriverImpl_SixelSupport_DefaultsToNull () { @@ -1332,6 +1469,280 @@ public void DriverImpl_SetSixelSupport_StoresResult () driver.Dispose (); } + // Copilot - Claude Sonnet 4.6 + [Fact] + public void ToAnsi_RasterImage_EmitsKittyApcWhenKittyEnabled () + { + // Arrange + AnsiOutput output = new () { UseKittyGraphics = true }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (2, 2); + buffer.Clip = new Region (new Rectangle (0, 0, 2, 2)); + + string kittyPayload = new KittyGraphicsEncoder ().EncodeKitty ( + CreateSolidImage (20, 40, new Color (255, 0, 0)), + 2, + 2); + + RasterImageCommand command = new () + { + Id = "kitty-test", + Pixels = CreateSolidImage (20, 40, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2), + EncodedKitty = kittyPayload + }; + + buffer.AddRasterImage (command); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert: Kitty APC escape sequence emitted + Assert.Contains ("\x1b_G", ansi); + Assert.Contains ("a=T", ansi); + Assert.Contains ("f=32", ansi); + } + + // Copilot - Claude Sonnet 4.6 + [Fact] + public void ToAnsi_RasterImage_EmitsSixelWhenKittyNotEnabled () + { + // Arrange + AnsiOutput output = new () { UseKittyGraphics = false }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (2, 2); + buffer.Clip = new Region (new Rectangle (0, 0, 2, 2)); + + RasterImageCommand command = new () + { + Id = "sixel-test", + Pixels = CreateSolidImage (20, 40, new Color (0, 0, 255)), + DestinationCells = new Rectangle (0, 0, 2, 2) + }; + + buffer.AddRasterImage (command); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert: Sixel DCS sequence emitted (no Kitty APC) + Assert.Contains ("\x1bP", ansi); + Assert.DoesNotContain ("\x1b_G", ansi); + } + + // Copilot - Claude Sonnet 4.6 + [Fact] + public void ToAnsi_RasterImage_KittyUsesEncodedKittyWhenCellsMatch () + { + // When EncodedKitty is pre-computed and cells match the clip, it is used verbatim. + AnsiOutput output = new () { UseKittyGraphics = true }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (2, 2); + buffer.Clip = new Region (new Rectangle (0, 0, 2, 2)); + + string precomputed = "\x1b_Ga=T,f=32,s=2,v=2,c=2,r=2,q=2,m=0;AAAAAA==\x1b\\"; + + RasterImageCommand command = new () + { + Id = "precomputed", + Pixels = CreateSolidImage (2, 2, new Color (0, 255, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2), + EncodedKitty = precomputed + }; + + buffer.AddRasterImage (command); + + string ansi = output.ToAnsi (buffer); + + Assert.Contains (precomputed, ansi); + } + + // Claude - Opus 4.8 + // Kitty images draw with z=-1 (below text), so any glyph left in a covered cell renders ON TOP + // of the image. When an ImageView grows, cells that used to be its border become interior cells + // under the image; if their old glyph (e.g. ║) is not cleared, it shows as a stale line over the + // image. Adding a raster image over a cell that holds a glyph must clear that glyph. + [Fact] + public void ToAnsi_KittyRasterImage_OverCellWithGlyph_DoesNotRenderStaleGlyph () + { + AnsiOutput output = new () { UseKittyGraphics = true }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (4, 1); + + // Stale border-like glyphs sitting where the image will be placed. + buffer.AddStr ("║║║║"); + + buffer.AddRasterImage (new RasterImageCommand + { + Id = "image", + Pixels = CreateSolidImage (8, 8, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 4, 1) + }); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert: the image is emitted, and no stale glyph is rendered over it. + Assert.Contains ("\x1b_G", ansi); + Assert.DoesNotContain ("║", ansi); + } + + [Fact] + public void ToAnsi_RasterImage_KittyOutput_UsesNegativeZIndex () + { + AnsiOutput output = new () { UseKittyGraphics = true }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (2, 2); + buffer.Clip = new Region (new Rectangle (0, 0, 2, 2)); + + RasterImageCommand command = new () + { + Id = "kitty-z-index", + Pixels = CreateSolidImage (20, 40, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2) + }; + + buffer.AddRasterImage (command); + + string ansi = output.ToAnsi (buffer); + + Assert.Contains ("z=-1", ansi); + } + + // Claude - Opus 4.8 + // Kitty placements persist on screen until explicitly deleted. When a Kitty image is resized + // (re-added with a smaller destination), the previous, larger placement must be deleted before + // the new one is placed — otherwise it lingers outside the new frame. + [Fact] + public void Write_KittyRasterImage_Resized_EmitsDeleteBeforeReplacement () + { + AnsiOutput output = new () { UseKittyGraphics = true }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (4, 4); + buffer.Clip = new Region (new Rectangle (0, 0, 4, 4)); + + buffer.AddRasterImage (new RasterImageCommand + { + Id = "image", + Pixels = CreateSolidImage (8, 8, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 4, 4) + }); + output.Write (buffer); + + // Resize smaller — re-add with a smaller destination. + buffer.AddRasterImage (new RasterImageCommand + { + Id = "image", + Pixels = CreateSolidImage (4, 4, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 2, 2) + }); + + // Act + output.Write (buffer); + string result = output.GetLastOutput (); + + // Assert: the delete-by-image-id sequence precedes the new placement. + string delete = KittyGraphicsEncoder.EncodeDeletePlacements (KittyGraphicsEncoder.GetImageId ("image")); + int deleteIdx = result.IndexOf (delete, StringComparison.Ordinal); + int placeIdx = result.IndexOf ("a=T", StringComparison.Ordinal); + + Assert.True (deleteIdx >= 0, "Resize must emit a Kitty delete for the prior placement"); + Assert.True (placeIdx > deleteIdx, "Delete must precede the replacement placement"); + } + + // Claude - Opus 4.8 + // When a Kitty image is removed from the buffer, the next Write must delete its placement so it + // does not linger on screen (Sixel needs no such delete — redrawing the cells erases it). + [Fact] + public void Write_KittyRasterImage_Removed_EmitsDelete () + { + AnsiOutput output = new () { UseKittyGraphics = true }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (4, 4); + buffer.Clip = new Region (new Rectangle (0, 0, 4, 4)); + + buffer.AddRasterImage (new RasterImageCommand + { + Id = "image", + Pixels = CreateSolidImage (8, 8, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 4, 4) + }); + output.Write (buffer); + + // Act: remove the image and render the next frame. + buffer.RemoveRasterImage ("image"); + buffer.AddStr ("x"); + output.Write (buffer); + string result = output.GetLastOutput (); + + // Assert + string delete = KittyGraphicsEncoder.EncodeDeletePlacements (KittyGraphicsEncoder.GetImageId ("image")); + Assert.Contains (delete, result); + } + + // Claude - Opus 4.8 + // The removal delete is Kitty-specific: with Sixel output no APC delete must be emitted. + [Fact] + public void Write_SixelRasterImage_Removed_DoesNotEmitKittyDelete () + { + AnsiOutput output = new () { UseKittyGraphics = false }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (4, 4); + buffer.Clip = new Region (new Rectangle (0, 0, 4, 4)); + + buffer.AddRasterImage (new RasterImageCommand + { + Id = "image", + Pixels = CreateSolidImage (8, 8, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 4, 4) + }); + output.Write (buffer); + + // Act + buffer.RemoveRasterImage ("image"); + buffer.AddStr ("x"); + output.Write (buffer); + string result = output.GetLastOutput (); + + // Assert: no Kitty APC of any kind. + Assert.DoesNotContain ("\x1b_G", result); + } + + // Claude - Opus 4.8 + // When a Kitty image's visible region is fragmented into multiple rectangles by clipping (e.g. + // a SubView punches a hole in it), each fragment must use a DISTINCT Kitty image id. Sharing one + // id makes each a=T overwrite the previous fragment's transmitted data, corrupting the image. + [Fact] + public void Write_KittyRasterImage_FragmentedClip_UsesDistinctImageIdsPerFragment () + { + AnsiOutput output = new () { UseKittyGraphics = true }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (4, 4); + + // Two disjoint horizontal strips → GetVisibleRasterCellRectangles yields two rectangles. + Region clip = new (new Rectangle (0, 0, 4, 1)); + clip.Combine (new Rectangle (0, 3, 4, 1), RegionOp.Union); + buffer.Clip = clip; + + buffer.AddRasterImage (new RasterImageCommand + { + Id = "image", + Pixels = CreateSolidImage (8, 8, new Color (255, 0, 0)), + DestinationCells = new Rectangle (0, 0, 4, 4) + }); + + // Act + output.Write (buffer); + string result = output.GetLastOutput (); + + // Assert: both fragment ids appear, and they differ. + int id0 = KittyGraphicsEncoder.GetImageId ("image"); + int id1 = KittyGraphicsEncoder.GetImageId ("image#1"); + Assert.NotEqual (id0, id1); + Assert.Contains ($"i={id0}", result); + Assert.Contains ($"i={id1}", result); + } + private static Color [,] CreateSolidImage (int width, int height, Color color) { Color [,] image = new Color [width, height]; @@ -1346,4 +1757,54 @@ public void DriverImpl_SetSixelSupport_StoresResult () return image; } + + // Copilot - Claude Sonnet 4.6 + // When Kitty is supported, UseKittyGraphics must be enabled — even if Sixel is also available, + // because Kitty is the preferred protocol. + [Fact] + public void SetKittyGraphicsSupport_WhenSixelAlsoSupported_EnablesKittyOutput () + { + // Arrange + AnsiOutput output = new (); + + DriverImpl driver = new ( + new AnsiComponentFactory (), + new AnsiInputProcessor (null!), + new OutputBufferImpl (), + output, + new (new AnsiResponseParser (new SystemTimeProvider ())), + new SizeMonitorImpl (new AnsiOutput ())); + + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + driver.SetKittyGraphicsSupport (new KittyGraphicsSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // Assert: Kitty has priority — Kitty output MUST be enabled even when Sixel is also available + Assert.True (output.UseKittyGraphics); + + driver.Dispose (); + } + + // Copilot - Claude Sonnet 4.6 + // When Sixel is NOT available, UseKittyGraphics should be enabled. + [Fact] + public void SetKittyGraphicsSupport_WhenSixelNotSupported_EnablesKittyOutput () + { + // Arrange + AnsiOutput output = new (); + + DriverImpl driver = new ( + new AnsiComponentFactory (), + new AnsiInputProcessor (null!), + new OutputBufferImpl (), + output, + new (new AnsiResponseParser (new SystemTimeProvider ())), + new SizeMonitorImpl (new AnsiOutput ())); + + driver.SetKittyGraphicsSupport (new KittyGraphicsSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + // Assert: no Sixel support → Kitty output must be enabled + Assert.True (output.UseKittyGraphics); + + driver.Dispose (); + } } diff --git a/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs b/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs index e3d91b0900..120f0ab0d2 100644 --- a/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/UnixIOHelperTests.cs @@ -15,6 +15,47 @@ public class UnixIOHelperTests [DllImport ("libc", SetLastError = true)] private static extern int close (int fd); + [Fact] + public void TryWriteAll_ReturnsFalse_WhenWriteReturnsZero () + { + var calls = 0; + byte [] payload = [0x41, 0x42]; + + bool result = UnixIOHelper.TryWriteAll (1, + payload, + (_, _, _) => + { + calls++; + + return 0; + }); + + Assert.False (result); + Assert.Equal (1, calls); + } + + [Fact] + public void TryWriteAll_RetriesUntilAllBytesWritten () + { + List counts = []; + List firstBytes = []; + byte [] payload = [0x41, 0x42, 0x43]; + + bool result = UnixIOHelper.TryWriteAll (1, + payload, + (_, buffer, count) => + { + counts.Add (count); + firstBytes.Add (buffer [0]); + + return 1; + }); + + Assert.True (result); + Assert.Equal ([3, 2, 1], counts); + Assert.Equal ([0x41, 0x42, 0x43], firstBytes); + } + [Fact] // Copilot public void IsInputAvailable_ReturnsTrue_WhenPollReportsReadableData () diff --git a/Tests/UnitTestsParallelizable/Scenarios/ImagesScenarioNullabilityTests.cs b/Tests/UnitTestsParallelizable/Scenarios/ImagesScenarioNullabilityTests.cs new file mode 100644 index 0000000000..cfd819cad1 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Scenarios/ImagesScenarioNullabilityTests.cs @@ -0,0 +1,52 @@ +// Copilot - GPT-5.5 + +using System.Reflection; +using UICatalog.Scenarios; + +namespace UICatalogTests.Scenarios; + +public class ImagesScenarioNullabilityTests +{ + [Fact] + public void RasterSupportState_AllowsDetectingNullState () + { + NullabilityInfoContext context = new (); + Type imagesType = typeof (Images); + + AssertFieldIsNullable (context, imagesType, "_sixelSupportResult"); + AssertFieldIsNullable (context, imagesType, "_kittyGraphicsSupportResult"); + AssertParameterIsNullable (context, imagesType, "UpdateRasterSupportState", "sixelResult"); + AssertParameterIsNullable (context, imagesType, "UpdateRasterSupportState", "kittyResult"); + AssertEventArgsValueIsNullable (context, imagesType, "Driver_SixelSupportChanged", "e"); + AssertEventArgsValueIsNullable (context, imagesType, "Driver_KittyGraphicsSupportChanged", "e"); + } + + private static void AssertEventArgsValueIsNullable (NullabilityInfoContext context, Type type, string methodName, string parameterName) + { + MethodInfo method = GetNonPublicInstanceMethod (type, methodName); + ParameterInfo parameter = method.GetParameters ().Single (p => p.Name == parameterName); + NullabilityInfo nullability = context.Create (parameter); + + Assert.Equal (NullabilityState.Nullable, Assert.Single (nullability.GenericTypeArguments).ReadState); + } + + private static void AssertFieldIsNullable (NullabilityInfoContext context, Type type, string fieldName) + { + FieldInfo field = type.GetField (fieldName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException ($"Field {fieldName} was not found."); + + Assert.Equal (NullabilityState.Nullable, context.Create (field).ReadState); + } + + private static void AssertParameterIsNullable (NullabilityInfoContext context, Type type, string methodName, string parameterName) + { + MethodInfo method = GetNonPublicInstanceMethod (type, methodName); + ParameterInfo parameter = method.GetParameters ().Single (p => p.Name == parameterName); + + Assert.Equal (NullabilityState.Nullable, context.Create (parameter).ReadState); + } + + private static MethodInfo GetNonPublicInstanceMethod (Type type, string methodName) => + type.GetMethod (methodName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException ($"Method {methodName} was not found."); +} diff --git a/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs b/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs index 83abda0b57..eb8ef8c23b 100644 --- a/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ImageViewTests.cs @@ -36,8 +36,14 @@ public void Defaults_ConfigurePanAndZoomBindings () Assert.Equal ([Command.ScrollUp], imageView.KeyBindings.GetCommands (Key.CursorUp)); Assert.Equal ([Command.ScrollDown], imageView.KeyBindings.GetCommands (Key.CursorDown)); Assert.Equal ([Command.Home], imageView.KeyBindings.GetCommands (Key.Home)); - Assert.Equal ([Command.ZoomIn], imageView.KeyBindings.GetCommands (Key.PageUp)); - Assert.Equal ([Command.ZoomOut], imageView.KeyBindings.GetCommands (Key.PageDown)); + Assert.Equal ([Command.Home], imageView.KeyBindings.GetCommands (Key.D0)); + Assert.Equal ([Command.ZoomIn], imageView.KeyBindings.GetCommands (new Key ('+'))); + Assert.Equal ([Command.ZoomIn], imageView.KeyBindings.GetCommands (new Key ('+').WithShift)); + Assert.Equal ([Command.ZoomIn], imageView.KeyBindings.GetCommands (new Key ('='))); + Assert.Equal ([Command.ZoomIn], imageView.KeyBindings.GetCommands (new Key ('=').WithShift)); + Assert.Equal ([Command.ZoomOut], imageView.KeyBindings.GetCommands (new Key ('-'))); + Assert.Equal ([Command.PageUp], imageView.KeyBindings.GetCommands (Key.PageUp)); + Assert.Equal ([Command.PageDown], 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)); @@ -652,7 +658,7 @@ public void KeyBindings_ZoomScrollAndHome_UpdateView () runnable.Add (imageView); app.LayoutAndDraw (); - Assert.True (imageView.NewKeyDownEvent (Key.PageUp)); + Assert.True (imageView.NewKeyDownEvent (new Key ('+'))); Assert.True (imageView.ZoomLevel > 1d); imageView.ZoomLevel = 2d; @@ -711,7 +717,7 @@ public void ApplicationKeyDispatch_WhenFocused_ZoomsAndScrolls () app.LayoutAndDraw (); imageView.SetFocus (); - Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.PageUp)); + Assert.True (app.Keyboard.RaiseKeyDownEvent (new Key ('+'))); Assert.True (imageView.ZoomLevel > 1d); imageView.ZoomLevel = 2d; @@ -868,6 +874,127 @@ public void SixelRendering_Zoom_UsesVisiblePixels () runnable.Dispose (); } + // Copilot + [Fact] + public void KeyBindings_PlusKey_ZoomsIn () + { + 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.NewKeyDownEvent (new Key ('+'))); + Assert.True (imageView.ZoomLevel > 1d); + + host.Dispose (); + } + + [Fact] + public void KeyBindings_ShiftedPlusKey_ZoomsIn () + { + 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.NewKeyDownEvent (new Key ('+').WithShift)); + Assert.True (imageView.ZoomLevel > 1d); + + host.Dispose (); + } + + // Copilot + [Fact] + public void KeyBindings_EqualsKey_ZoomsIn () + { + 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.NewKeyDownEvent (new Key ('='))); + Assert.True (imageView.ZoomLevel > 1d); + + host.Dispose (); + } + + // Copilot + [Fact] + public void KeyBindings_MinusKey_ZoomsOut () + { + 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 (); + + imageView.ZoomLevel = 2d; + Assert.True (imageView.NewKeyDownEvent (new Key ('-'))); + Assert.True (imageView.ZoomLevel < 2d); + + host.Dispose (); + } + + // Copilot + [Fact] + public void KeyBindings_ZeroKey_ResetsZoom () + { + 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 (); + + imageView.ZoomLevel = 2d; + Assert.True (imageView.NewKeyDownEvent (Key.D0)); + Assert.Equal (1d, imageView.ZoomLevel); + + host.Dispose (); + } + + // Copilot + [Fact] + public void KeyBindings_PageUpStillZoomsIn () + { + 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.NewKeyDownEvent (Key.PageUp)); + Assert.True (imageView.ZoomLevel > 1d); + + host.Dispose (); + } + + // Copilot + [Fact] + public void KeyBindings_PageDownStillZoomsOut () + { + 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 (); + + imageView.ZoomLevel = 2d; + Assert.True (imageView.NewKeyDownEvent (Key.PageDown)); + Assert.True (imageView.ZoomLevel < 2d); + + host.Dispose (); + } + #endregion Pan and Zoom #region Dispose @@ -1459,6 +1586,168 @@ public void FitImageInViewportCells_VerySmallImage_ClampsToMinimumOne () #endregion FitImageInViewportCells + #region Resolution Selection Consistency + + // Copilot - Claude Sonnet 4.6 + // When both Sixel and Kitty are available, ViewportToScreenInPixels must use the Kitty + // resolution (Kitty is the preferred protocol). Using the Sixel resolution for sizing + // while Kitty does the actual rendering would cause mis-sized images. + [Fact] + public void ViewportToScreenInPixels_WhenBothSixelAndKittyAvailable_UsesKittyResolution () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 10, Height = 10 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + + // Sixel: 10 px/cell. Kitty: 20 px/cell — deliberately different so we can tell which wins. + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 10) }); + driver.SetKittyGraphicsSupport (new KittyGraphicsSupportResult { IsSupported = true, Resolution = new Size (20, 20) }); + + ImageView imageView = new () { Width = 2, Height = 2, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // With Kitty resolution (20 px/cell) and a 2×2 cell viewport the pixel rect is 40×40. + // With Sixel resolution (10 px/cell) it would be 20×20 — wrong when Kitty is preferred. + Rectangle pixelRect = imageView.ViewportToScreenInPixels (); + + Assert.Equal (40, pixelRect.Width); + Assert.Equal (40, pixelRect.Height); + + runnable.Dispose (); + } + + [Fact] + public void ViewportToScreenInPixels_WhenKittyActive_IgnoresSixelAvoidBottomScroll () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 10, Height = 10 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 10) }); + driver.SetKittyGraphicsSupport (new KittyGraphicsSupportResult { IsSupported = true, Resolution = new Size (10, 10) }); + + ImageView imageView = new () + { + Width = 2, + Height = 4, + SixelEncoder = new SixelEncoder { AvoidBottomScroll = true } + }; + + runnable.Add (imageView); + app.LayoutAndDraw (); + + Rectangle pixelRect = imageView.ViewportToScreenInPixels (); + + Assert.Equal (20, pixelRect.Width); + Assert.Equal (40, pixelRect.Height); + + runnable.Dispose (); + } + + [Fact] + public void ViewportToScreenInPixels_WhenKittyOutputDisabled_UsesSixelResolutionAndAvoidBottomScroll () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 10, Height = 10 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (10, 10) }); + driver.SetKittyGraphicsSupport (new KittyGraphicsSupportResult { IsSupported = true, Resolution = new Size (20, 20) }); + driver.GetOutput ().UseKittyGraphics = false; + + ImageView imageView = new () + { + Width = 2, + Height = 4, + SixelEncoder = new SixelEncoder { AvoidBottomScroll = true } + }; + + runnable.Add (imageView); + app.LayoutAndDraw (); + + Rectangle pixelRect = imageView.ViewportToScreenInPixels (); + + Assert.Equal (20, pixelRect.Width); + Assert.Equal (36, pixelRect.Height); + Assert.True (imageView.IsUsingSixel); + + runnable.Dispose (); + } + + // Copilot - Claude Sonnet 4.6 + // FitImageInViewportCells must use Kitty resolution when both protocols are available, + // because Kitty is the preferred protocol. + [Fact] + public void FitImageInViewportCells_WhenBothSixelAndKittyAvailable_UsesKittyResolution () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 20, Height = 10 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + + // Sixel: 8×16 px/cell (cell aspect ratio 2.0). Kitty: 16×8 px/cell (aspect ratio 0.5). + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (8, 16) }); + driver.SetKittyGraphicsSupport (new KittyGraphicsSupportResult { IsSupported = true, Resolution = new Size (16, 8) }); + + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // Kitty cell aspect ratio = 8/16 = 0.5. 80×80 px image → + // adjusted height = 80 / 0.5 = 160 cells → constrained by height → (2, 5). + // Sixel cell aspect ratio = 16/8 = 2.0. 80×80 px image → + // adjusted height = 80 / 2.0 = 40 cells → fits as (10, 5). + Size result = imageView.FitImageInViewportCells (new Size (80, 80)); + + // Must match the Kitty result, not the Sixel result. + Assert.Equal (2, result.Width); + Assert.Equal (5, result.Height); + + runnable.Dispose (); + } + + [Fact] + public void FitImageInViewportCells_WhenKittyOutputDisabled_UsesSixelResolution () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 20, Height = 10 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetSixelSupport (new SixelSupportResult { IsSupported = true, Resolution = new Size (8, 16) }); + driver.SetKittyGraphicsSupport (new KittyGraphicsSupportResult { IsSupported = true, Resolution = new Size (16, 8) }); + driver.GetOutput ().UseKittyGraphics = false; + + ImageView imageView = new () { Width = 10, Height = 5, SixelEncoder = new SixelEncoder () }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + Size result = imageView.FitImageInViewportCells (new Size (80, 80)); + + Assert.Equal (10, result.Width); + Assert.Equal (5, result.Height); + + runnable.Dispose (); + } + + #endregion Resolution Selection Consistency + #region Helper Methods /// Creates a solid-color image of the specified dimensions. @@ -1499,6 +1788,43 @@ private static void AssertCellBackground (IDriver driver, int col, int row, Colo Assert.Equal (expected, contents! [row, col].Attribute!.Value.Background); } + // Claude - Opus 4.8 + // A Kitty image draws below text (z=-1). When an ImageView grows, the framework can leave a glyph + // in a now-interior viewport cell (e.g. the old border column) that resize invalidation never + // clears — it would render as a stale line over the image. The raster draw must blank every + // viewport cell so no stale glyph survives. + [Fact] + public void Draw_KittyRaster_ClearsStaleGlyphInViewport () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new () { Width = 20, Height = 20 }; + app.Begin (runnable); + + DriverImpl driver = (DriverImpl)app.Driver!; + driver.SetKittyGraphicsSupport (new KittyGraphicsSupportResult { IsSupported = true, Resolution = new Size (10, 10) }); + + ImageView imageView = new () { X = 0, Y = 0, Width = 6, Height = 6, Image = CreateGradientImage (12, 12) }; + runnable.Add (imageView); + app.LayoutAndDraw (); + + // Plant a stale glyph in a cell inside the image's viewport (as resize leaves an old border). + Rectangle viewport = imageView.ViewportToScreen (); + int staleX = viewport.X + 1; + int staleY = viewport.Y + 1; + driver.GetOutputBuffer ().Contents! [staleY, staleX].Grapheme = "║"; + + // Act — redraw the image. + imageView.SetNeedsDraw (); + app.LayoutAndDraw (); + + // Assert — the stale glyph was blanked so it cannot render over the z=-1 image. + Assert.Equal (" ", driver.GetOutputBuffer ().Contents! [staleY, staleX].Grapheme); + + runnable.Dispose (); + } + /// Creates a gradient image where pixel color varies by position. private static Color [,] CreateGradientImage (int width, int height) { @@ -1523,4 +1849,4 @@ 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 ef6b40c4fa..c5f2514b83 100644 --- a/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ProgressBarTests.cs @@ -343,4 +343,44 @@ public void Dispose_Without_SyncWithTerminal_Does_Not_Write_Clear_Progress () Assert.DoesNotContain (EscSeqUtils.OSC_ClearProgress (), driver.GetOutput ().GetLastOutput (), StringComparison.Ordinal); } + + // Claude - Opus 4.8 + // The Fire progress style renders via a raster image, so it must work under Kitty graphics — + // not just Sixel. On Kitty/Ghostty (which report no Sixel support) the fire silently vanished + // because DrawFireProgress gated solely on SixelSupport. + [Fact] + public void Fire_WithKittyGraphicsActive_AddsRasterImage () + { + IDriver driver = CreateTestDriver (8, 1); + driver.Clip = new (driver.Screen); + driver.SetKittyGraphicsSupport (new KittyGraphicsSupportResult { IsSupported = true, Resolution = new Size (10, 20) }); + + Assert.True (driver.GetOutput ().UseKittyGraphics); + + ProgressBar pb = new () { Driver = driver, Width = 8, Height = 1, Fraction = 1F, ProgressBarStyle = ProgressBarStyle.Fire }; + pb.BeginInit (); + pb.EndInit (); + pb.LayoutSubViews (); + + pb.Draw (); + + Assert.Single (driver.GetOutputBuffer ().GetRasterImages ()); + } + + // Claude - Opus 4.8 + [Fact] + public void Fire_WithNoRasterSupport_AddsNoRasterImage () + { + IDriver driver = CreateTestDriver (8, 1); + driver.Clip = new (driver.Screen); + + ProgressBar pb = new () { Driver = driver, Width = 8, Height = 1, Fraction = 1F, ProgressBarStyle = ProgressBarStyle.Fire }; + pb.BeginInit (); + pb.EndInit (); + pb.LayoutSubViews (); + + pb.Draw (); + + Assert.Empty (driver.GetOutputBuffer ().GetRasterImages ()); + } } diff --git a/docfx/apispec/namespace-drawing.md b/docfx/apispec/namespace-drawing.md index e066cebf08..632674f1f4 100644 --- a/docfx/apispec/namespace-drawing.md +++ b/docfx/apispec/namespace-drawing.md @@ -9,7 +9,12 @@ The `Drawing` namespace provides visual styling, color management, and drawing p - **Attribute** - Foreground/background color and text style combination - **Color** - Terminal colors including TrueColor (24-bit) support +- **KittyGraphicsEncoder** - Encodes `Color[,]` pixels as Kitty graphics APC sequences +- **KittyGraphicsSupportDetector** - Detects Kitty-compatible terminals and their cell pixel resolution +- **KittyGraphicsSupportResult** - Reports Kitty graphics availability and resolution - **Scheme** - Maps semantic visual roles to attributes +- **SixelEncoder** - Encodes `Color[,]` pixels as Sixel DCS sequences +- **SixelSupportDetector** / **SixelSupportResult** - Detect Sixel availability, palette limits, transparency support, and resolution - **LineCanvas** - Line drawing with automatic glyph joining - **Thickness** - Border and spacing dimensions - **Glyphs** - Standard drawing characters for UI elements @@ -27,6 +32,10 @@ Color custom = new (128, 64, 255); Attribute attr = new (Color.White, Color.Blue); ``` +## Raster Graphics + +`ImageView` and the driver output layer use Drawing encoders and support detectors to render raster images. Kitty graphics is preferred when available; Sixel is the fallback for terminals that support Sixel but not Kitty. Terminals that support neither protocol render `ImageView` content with colored cells. + ## Scheme System Schemes map semantic roles to visual attributes: diff --git a/docfx/apispec/namespace-drivers.md b/docfx/apispec/namespace-drivers.md index 1bc84a23ac..8e19e2039d 100644 --- a/docfx/apispec/namespace-drivers.md +++ b/docfx/apispec/namespace-drivers.md @@ -13,6 +13,7 @@ The `Drivers` namespace provides the platform abstraction layer enabling Termina - **AnsiKeyboardParser** / **AnsiMouseParser** - Keyboard and mouse input parsing - **EscSeqUtils** - ANSI escape sequence constants and utilities - **Cursor** / **CursorStyle** - Terminal cursor management +- **IOutput** / **IOutputBuffer** - Output surfaces, including raster image dispatch ## Available Drivers @@ -37,6 +38,10 @@ app.ForceDriver = DriverRegistry.Names.UNIX; app.Init (); ``` +## Raster Graphics Capabilities + +Drivers expose raster protocol detection through `IDriver.KittyGraphicsSupport` and `IDriver.SixelSupport`, plus matching change events. Kitty graphics is the preferred raster protocol when supported. Sixel remains the fallback for terminals that do not support Kitty graphics. `IOutput.UseKittyGraphics` selects the Kitty output path; when it is false and Sixel is supported, raster commands are emitted as Sixel. + ## See Also - [Drivers Deep Dive](~/docs/drivers.md) diff --git a/docfx/apispec/namespace-views.md b/docfx/apispec/namespace-views.md index c3ffc6364c..60bfe033b5 100644 --- a/docfx/apispec/namespace-views.md +++ b/docfx/apispec/namespace-views.md @@ -29,7 +29,7 @@ The `Views` namespace contains the complete collection of built-in UI controls d - Prompt, MessageBox, FileDialog, OpenDialog, SaveDialog, Wizard **Specialized** -- CharMap, HexView, GraphView, LineView, ScrollBarView +- CharMap, HexView, GraphView, ImageView, LineView, ScrollBarView All views inherit: - Adornments (Margin, Border, Padding) @@ -38,8 +38,11 @@ All views inherit: - Keyboard/mouse bindings - User arrangement (Movable, Resizable) +## Raster Image Views + +`ImageView` displays `Color[,]` pixel buffers. With `UseRasterGraphics` enabled, it prefers Kitty graphics, falls back to Sixel, and uses cell rendering when no raster protocol is available. Use `UseRasterGraphics` and `IsUsingRasterGraphics` for new code; `UseSixel` and `IsUsingSixel` remain obsolete compatibility shims. + ## See Also - [Views Overview](~/docs/views.md) - Complete list with examples - [View Deep Dive](~/docs/View.md) - Base view architecture - diff --git a/docfx/docs/drawing.md b/docfx/docs/drawing.md index e59b9437f2..854efe500c 100644 --- a/docfx/docs/drawing.md +++ b/docfx/docs/drawing.md @@ -91,9 +91,15 @@ 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 +## Raster Images (Kitty and Sixel) -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. +Terminal.Gui can render raster images through the same deferred drawing pipeline as text and line art. accepts a `Color[,]` pixel buffer. When `ImageView.UseRasterGraphics` is `true`, the driver uses the best available terminal raster protocol: + +1. Kitty graphics, when reports support and the output path has Kitty enabled. +2. Sixel, when Kitty is not active and reports support. +3. Cell rendering, when no raster protocol is available. + +Kitty is preferred because it supports full RGBA pixels and works well in modern macOS terminals such as Kitty, Ghostty, and WezTerm. Sixel remains the fallback for terminals such as Windows Terminal and xterm. The obsolete `ImageView.UseSixel` and `ImageView.IsUsingSixel` members still work as compatibility shims; use `UseRasterGraphics` and `IsUsingRasterGraphics` in new code. Raster image commands participate in normal composition: @@ -105,19 +111,21 @@ Raster image commands participate in normal composition: To render an image, assign the pixel buffer and mark the view for drawing: ```cs +Color [,] pixels = new Color [1, 1]; + ImageView imageView = new () { Width = 30, Height = 20, - UseSixel = true + UseRasterGraphics = 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. +To customize Sixel encoding, assign `ImageView.SixelEncoder` before setting `Image`. Kitty output is encoded by the driver/output layer and does not use `SixelEncoder`. -The UICatalog Mandelbrot scenario demonstrates a resizable `ImageView` with a double-line border and a runnable dialog drawn over the raster image. +The UICatalog Images scenario demonstrates runtime protocol selection, and the Mandelbrot scenario demonstrates a custom `ImageView` that re-renders fractal pixels while zooming and panning. ![Mandelbrot sixel raster demo](../images/Mandelbrot.gif) diff --git a/docfx/docs/drivers.md b/docfx/docs/drivers.md index bfb4987426..199ceead81 100644 --- a/docfx/docs/drivers.md +++ b/docfx/docs/drivers.md @@ -233,7 +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 +- Emitting raster image commands through Kitty graphics or Sixel during the normal output pass - Cursor positioning and visibility control - Querying terminal window size - Managing the active screen buffer @@ -348,7 +348,10 @@ 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. +- `KittyGraphicsSupport` - The detected Kitty graphics capability, including cell pixel resolution. `ImageView` and `IOutput` prefer this path when it is supported. +- `KittyGraphicsSupportChanged` - Raised when Kitty graphics detection completes or changes. +- `SixelSupport` - The detected Sixel capability, including cell pixel resolution, palette size, and transparency support. `ImageView` uses this as the raster fallback when Kitty is not active. +- `SixelSupportChanged` - Raised when Sixel detection completes or changes. - `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 @@ -402,6 +405,18 @@ When `AppModel == Inline`, the ANSI driver changes behavior in several ways: - `WriteRaw()`, `GetSixels()` - Legacy raw output and graphics hooks - `Refresh()`, `ToString()`, `ToAnsi()` - Output rendering +#### Raster Graphics Protocol Selection + +Raster images are protocol-neutral in the output buffer. Views add a `RasterImageCommand`; the output layer emits Kitty APC or Sixel DCS data when it flushes the buffer. + +Selection is Kitty-first: + +1. When `IOutput.UseKittyGraphics` is `true` and `IDriver.KittyGraphicsSupport.IsSupported` is `true`, output emits Kitty graphics. +2. Otherwise, when `IDriver.SixelSupport.IsSupported` is `true`, output emits Sixel. +3. Otherwise, views such as `ImageView` use their cell-based fallback. + +`DriverImpl.SetKittyGraphicsSupport()` enables `IOutput.UseKittyGraphics` when Kitty support is detected. Apps that need to force Sixel can set the output preference to `false` and keep Sixel support enabled. + #### Cursor Drivers implement cursor control through `IDriver` which delegates to `IOutput`: diff --git a/docfx/images/Mandelbrot.gif b/docfx/images/Mandelbrot.gif index 218e129ac1..383f96f572 100644 Binary files a/docfx/images/Mandelbrot.gif and b/docfx/images/Mandelbrot.gif differ