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;