diff --git a/src/XTerm.NET.Tests/Graphics/KittyRegistryBudgetTests.cs b/src/XTerm.NET.Tests/Graphics/KittyRegistryBudgetTests.cs
new file mode 100644
index 00000000..54900aec
--- /dev/null
+++ b/src/XTerm.NET.Tests/Graphics/KittyRegistryBudgetTests.cs
@@ -0,0 +1,73 @@
+using XTerm.Options;
+
+namespace XTerm.Tests.Graphics;
+
+///
+/// The kitty image registry keeps at least three screens' worth of pixels whatever
+/// 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.
+///
+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");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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));
+ }
+}
diff --git a/src/XTerm.NET.Tests/KittyPlacementDeleteTests.cs b/src/XTerm.NET.Tests/KittyPlacementDeleteTests.cs
index 206f3979..26e23465 100644
--- a/src/XTerm.NET.Tests/KittyPlacementDeleteTests.cs
+++ b/src/XTerm.NET.Tests/KittyPlacementDeleteTests.cs
@@ -16,14 +16,19 @@ public class KittyPlacementDeleteTests
private const string Esc = "\u001b";
private const string St = Esc + "\\";
- /// An 8x8 RGBA image: 256 bytes, one cell at the 8x16 cells set below.
+ /// A 256x256 RGBA image: 262,144 bytes, placed into one cell by c=1,r=1 below.
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);
}
+ ///
+ /// 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.
+ ///
private static Terminal NewTerminal(long registryBudget) =>
new(new TerminalOptions
{
@@ -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)
@@ -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}");
@@ -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}");
@@ -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");
@@ -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);
@@ -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");
@@ -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);
diff --git a/src/XTerm.NET/InputHandler.KittyGraphics.cs b/src/XTerm.NET/InputHandler.KittyGraphics.cs
index e521468d..fc42b943 100644
--- a/src/XTerm.NET/InputHandler.KittyGraphics.cs
+++ b/src/XTerm.NET/InputHandler.KittyGraphics.cs
@@ -14,6 +14,31 @@ namespace XTerm;
///
public partial class InputHandler
{
+ ///
+ /// What the kitty image registry may hold: ,
+ /// or three screens of pixels when the screen is larger than that allows.
+ ///
+ ///
+ /// 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.
+ ///
+ 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)
@@ -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)
@@ -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;
diff --git a/src/XTerm.NET/Options/TerminalOptions.cs b/src/XTerm.NET/Options/TerminalOptions.cs
index d6b4e1cd..87220fe3 100644
--- a/src/XTerm.NET/Options/TerminalOptions.cs
+++ b/src/XTerm.NET/Options/TerminalOptions.cs
@@ -300,12 +300,15 @@ public int TabStopWidth
public bool PointerShapesEnabled { get; set; } = true;
///
- /// 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.
///
///
/// 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.
///
public long MaxImageRegistryBytes { get; set; } = 32L * 1024 * 1024;