Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/XTerm.NET.Tests/Graphics/KittyRegistryBudgetTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using XTerm.Options;

namespace XTerm.Tests.Graphics;

/// <summary>
/// The kitty image registry keeps at least three screens' worth of pixels whatever
/// <see cref="TerminalOptions.MaxImageRegistryBytes"/> says: a client that tiles full-screen pictures
/// keeps that many tiles in the terminal to show again, and one it could not hold would place as
/// nothing.
/// </summary>
public class KittyRegistryBudgetTests
{
private const string Esc = "\u001b";
private const string St = Esc + "\\";

// 30x12 cells of 2x3 pixels: one screen is 8,640 bytes, three are 25,920
private static Terminal Fresh(long registryBytes) => new(new TerminalOptions
{
Cols = 30,
Rows = 12,
CellWidthPixels = 2,
CellHeightPixels = 3,
MaxImageRegistryBytes = registryBytes
});

private static string Apc(string control, string payload = "")
=> payload.Length == 0 ? $"{Esc}_G{control}{St}" : $"{Esc}_G{control};{payload}{St}";

private static string SolidRgba(int width, int height)
=> Convert.ToBase64String(new byte[width * height * 4]);

[Fact]
public void An_image_within_three_screens_is_kept_however_small_the_configured_budget()
{
var terminal = Fresh(registryBytes: 1);

terminal.Write(Apc("a=t,i=7,f=32,s=4,v=6,q=2", SolidRgba(4, 6))); // 96 bytes
terminal.Write(Apc("a=p,i=7,q=2"));

Assert.True(ImageAssertions.IsImageAt(terminal, 0, 0), "the image was still in the registry to place");
}

/// <summary>
/// The floor is still a budget. The registry never trims the image just stored or one on screen,
/// so what goes past three screens is an older image nothing shows.
/// </summary>
[Fact]
public void An_older_unshown_image_is_trimmed_once_three_screens_are_exceeded()
{
var terminal = Fresh(registryBytes: 1);

terminal.Write(Apc("a=t,i=7,f=32,s=4,v=6,q=2", SolidRgba(4, 6))); // 96 bytes, never placed
terminal.Write(Apc("a=t,i=8,f=32,s=100,v=100,q=2", SolidRgba(100, 100))); // 40,000 bytes: over 25,920

terminal.Write(Apc("a=p,i=7,q=2"));
Assert.False(ImageAssertions.IsImageAt(terminal, 0, 0), "three screens are 25,920 bytes; the older image nothing showed went");

terminal.Write($"{Esc}[1;20H");
terminal.Write(Apc("a=p,i=8,q=2"));
Assert.True(ImageAssertions.IsImageAt(terminal, 19, 0), "the image just stored is never the one trimmed");
}

[Fact]
public void A_configured_budget_larger_than_three_screens_still_applies()
{
var terminal = Fresh(registryBytes: 1024 * 1024);

terminal.Write(Apc("a=t,i=9,f=32,s=100,v=100,q=2", SolidRgba(100, 100))); // 40,000 bytes
terminal.Write(Apc("a=p,i=9,q=2"));

Assert.True(ImageAssertions.IsImageAt(terminal, 0, 0));
}
}
47 changes: 26 additions & 21 deletions src/XTerm.NET.Tests/KittyPlacementDeleteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@ public class KittyPlacementDeleteTests
private const string Esc = "\u001b";
private const string St = Esc + "\\";

/// <summary>An 8x8 RGBA image: 256 bytes, one cell at the 8x16 cells set below.</summary>
/// <summary>A 256x256 RGBA image: 262,144 bytes, placed into one cell by c=1,r=1 below.</summary>
private static string Pixels(byte value)
{
var bytes = new byte[8 * 8 * 4];
var bytes = new byte[256 * 256 * 4];
Array.Fill(bytes, value);
return Convert.ToBase64String(bytes);
}

/// <summary>
/// 20x5 cells of 8x16 pixels: one screen is 51,200 bytes. The registry holds at least three
/// screens (153,600 bytes) whatever the option says, so the budgets below are set above that to
/// be the ones in force, and the images are sized so a few of them fill one.
/// </summary>
private static Terminal NewTerminal(long registryBudget) =>
new(new TerminalOptions
{
Expand All @@ -37,7 +42,7 @@ private static Terminal NewTerminal(long registryBudget) =>
private static void PlaceAt(Terminal terminal, uint image, uint placement, int col, int row)
{
terminal.Write($"{Esc}[{row + 1};{col + 1}H");
terminal.Write($"{Esc}_Ga=p,i={image},p={placement},C=1,q=2{St}");
terminal.Write($"{Esc}_Ga=p,i={image},p={placement},c=1,r=1,C=1,q=2{St}");
}

private static bool ShowsImage(Terminal terminal, int col, int row)
Expand All @@ -46,13 +51,13 @@ private static bool ShowsImage(Terminal terminal, int col, int row)
[Fact]
public void A_placement_is_deleted_by_its_ids_after_the_registry_trimmed_its_image()
{
// room for one 256-byte image: the second transmit trims the first
var terminal = NewTerminal(registryBudget: 300);
terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=1,q=2;{Pixels(1)}{St}");
// room for one 262,144-byte image: the second transmit trims the first
var terminal = NewTerminal(registryBudget: 300_000);
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=1,q=2;{Pixels(1)}{St}");
PlaceAt(terminal, image: 1, placement: 1, col: 2, row: 1);
Assert.True(ShowsImage(terminal, 2, 1), "sanity: the placement is on screen");

terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=2,q=2;{Pixels(2)}{St}");
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=2,q=2;{Pixels(2)}{St}");
Assert.True(ShowsImage(terminal, 2, 1), "trimming the image from the registry leaves its appearance");

terminal.Write($"{Esc}_Ga=d,d=i,i=1,p=1,q=2{St}");
Expand All @@ -63,11 +68,11 @@ public void A_placement_is_deleted_by_its_ids_after_the_registry_trimmed_its_ima
[Fact]
public void Deleting_a_trimmed_image_drops_its_placements()
{
var terminal = NewTerminal(registryBudget: 300);
terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=1,q=2;{Pixels(1)}{St}");
var terminal = NewTerminal(registryBudget: 300_000);
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=1,q=2;{Pixels(1)}{St}");
PlaceAt(terminal, image: 1, placement: 1, col: 0, row: 0);
PlaceAt(terminal, image: 1, placement: 2, col: 5, row: 2);
terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=2,q=2;{Pixels(2)}{St}");
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=2,q=2;{Pixels(2)}{St}");

terminal.Write($"{Esc}_Ga=d,d=I,i=1,q=2{St}");

Expand All @@ -83,11 +88,11 @@ public void Deleting_a_trimmed_image_drops_its_placements()
[Fact]
public void Trimming_leaves_an_image_on_screen_alone()
{
var terminal = NewTerminal(registryBudget: 300);
terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=1,q=2;{Pixels(1)}{St}");
var terminal = NewTerminal(registryBudget: 300_000);
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=1,q=2;{Pixels(1)}{St}");
PlaceAt(terminal, image: 1, placement: 1, col: 2, row: 1);
terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=2,q=2;{Pixels(2)}{St}"); // unshown
terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=3,q=2;{Pixels(3)}{St}"); // over budget: 2 goes, 1 stays
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=2,q=2;{Pixels(2)}{St}"); // unshown
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=3,q=2;{Pixels(3)}{St}"); // over budget: 2 goes, 1 stays

PlaceAt(terminal, image: 1, placement: 2, col: 4, row: 1);
Assert.True(ShowsImage(terminal, 4, 1), "the image on screen can still be placed again");
Expand All @@ -103,12 +108,12 @@ public void Trimming_leaves_an_image_on_screen_alone()
[Fact]
public void Trimming_takes_the_least_recently_used_image()
{
var terminal = NewTerminal(registryBudget: 600);
terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=1,q=2;{Pixels(1)}{St}");
terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=2,q=2;{Pixels(2)}{St}");
var terminal = NewTerminal(registryBudget: 600_000);
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=1,q=2;{Pixels(1)}{St}");
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=2,q=2;{Pixels(2)}{St}");
PlaceAt(terminal, image: 1, placement: 1, col: 0, row: 0);
terminal.Write($"{Esc}_Ga=d,d=i,i=1,p=1,q=2{St}"); // 1 was used, nothing shows it now
terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=3,q=2;{Pixels(3)}{St}"); // over budget: 2, untouched since stored, goes
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=3,q=2;{Pixels(3)}{St}"); // over budget: 2, untouched since stored, goes

PlaceAt(terminal, image: 1, placement: 2, col: 2, row: 0);
PlaceAt(terminal, image: 2, placement: 3, col: 4, row: 0);
Expand All @@ -126,11 +131,11 @@ public void Trimming_takes_the_least_recently_used_image()
public void Retransmitting_an_id_drops_the_placements_of_the_image_it_replaces()
{
var terminal = NewTerminal(registryBudget: 0);
terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=1,q=2;{Pixels(1)}{St}");
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=1,q=2;{Pixels(1)}{St}");
PlaceAt(terminal, image: 1, placement: 1, col: 0, row: 0);
PlaceAt(terminal, image: 1, placement: 2, col: 4, row: 0);

terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=1,q=2;{Pixels(2)}{St}");
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=1,q=2;{Pixels(2)}{St}");
PlaceAt(terminal, image: 1, placement: 1, col: 0, row: 0);

Assert.True(ShowsImage(terminal, 0, 0), "the placement made after the retransmit shows");
Expand All @@ -141,7 +146,7 @@ public void Retransmitting_an_id_drops_the_placements_of_the_image_it_replaces()
public void A_placement_of_an_image_still_in_the_registry_is_deleted_as_before()
{
var terminal = NewTerminal(registryBudget: 0);
terminal.Write($"{Esc}_Ga=t,f=32,s=8,v=8,i=1,q=2;{Pixels(1)}{St}");
terminal.Write($"{Esc}_Ga=t,f=32,s=256,v=256,i=1,q=2;{Pixels(1)}{St}");
PlaceAt(terminal, image: 1, placement: 1, col: 3, row: 3);
PlaceAt(terminal, image: 1, placement: 2, col: 6, row: 3);

Expand Down
29 changes: 27 additions & 2 deletions src/XTerm.NET/InputHandler.KittyGraphics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,31 @@ namespace XTerm;
/// </summary>
public partial class InputHandler
{
/// <summary>
/// What the kitty image registry may hold: <see cref="TerminalOptions.MaxImageRegistryBytes"/>,
/// or three screens of pixels when the screen is larger than that allows.
/// </summary>
/// <remarks>
/// A client that tiles a full-screen picture (Consolonia does) keeps a few screens' worth of
/// tiles in the terminal, by id, to show them again without resending. A registry too small for
/// that drops tiles the client still counts on, and placing one again then shows nothing. The
/// option stays the floor, so a configured budget larger than three screens still applies; zero
/// or less still means no limit.
/// </remarks>
private long KittyRegistryBudget
{
get
{
long configured = _terminal.Options.MaxImageRegistryBytes;
if (configured <= 0)
return configured;

long screen = 4L * _terminal.Cols * _terminal.Options.CellWidthPixels
* _terminal.Rows * _terminal.Options.CellHeightPixels;
return Math.Max(configured, 3 * screen);
}
}

private void NoteAnimated(Graphics.TerminalImage image)
{
foreach (var known in AnimatedImages)
Expand Down Expand Up @@ -416,7 +441,7 @@ private void CompleteKittyTransmission(Graphics.KittyTransmission transmission)
if (command.ImageId != 0)
_terminal.DropPlacements(p => IsKittyPlacementOf(p, id));

_kittyImages.Store(id, image, _terminal.Options.MaxImageRegistryBytes, command.ImageNumber,
_kittyImages.Store(id, image, KittyRegistryBudget, command.ImageNumber,
_terminal.ShowsImage);

if (command.Action == Graphics.KittyAction.TransmitAndDisplay)
Expand Down Expand Up @@ -492,7 +517,7 @@ private void AddKittyFrame(Graphics.KittyCommand command, byte[] pixels, int wid
// Charged to the REGISTRY, not measured against this animation alone: several
// animations could otherwise each grow to the whole budget, and the registry's counter
// would still hold the size each image was first stored at.
if (!_kittyImages.TryCharge(frameBytes, _terminal.Options.MaxImageRegistryBytes))
if (!_kittyImages.TryCharge(frameBytes, KittyRegistryBudget))
{
ReplyToKitty(command, Graphics.KittyError.TooLarge);
return;
Expand Down
7 changes: 5 additions & 2 deletions src/XTerm.NET/Options/TerminalOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -300,12 +300,15 @@ public int TabStopWidth
public bool PointerShapesEnabled { get; set; } = true;

/// <summary>
/// Budget for images held by client id but not currently on screen, in bytes.
/// Budget for images held by client id but not currently on screen, in bytes. A floor: the
/// terminal always holds at least three screens' worth of pixels, so a program that tiles a
/// full-screen picture can keep a few screens of tiles to show again without resending them.
/// </summary>
/// <remarks>
/// Kitty transmits a picture once and may show it later, so an image can be live with no cell
/// referencing it. Cell references cannot account for those, and without a ceiling a program
/// could transmit without ever placing and never be collected. Oldest goes first.
/// could transmit without ever placing and never be collected. Oldest goes first. Zero or less
/// means no limit.
/// </remarks>
public long MaxImageRegistryBytes { get; set; } = 32L * 1024 * 1024;

Expand Down
Loading