feat: add terminal image foundation

- add terminal image service and image component

- protect image lines and clean up kitty image ids
This commit is contained in:
chuan
2026-06-04 02:28:58 +08:00
Unverified
parent 41c0d91d77
commit e2d534170f
15 changed files with 812 additions and 0 deletions
+78
View File
@@ -1,8 +1,12 @@
using TinyTUI;
using TinyTUI.Components;
using TinyTUI.Autocomplete;
using TinyTUI.Input;
using TinyTUI.Rendering;
using TinyTUI.Stdout;
using TinyTUI.Terminal.Images;
using TinyTUI.Text;
using System.Text;
var measurer = new TerminalTextMeasurer();
@@ -65,6 +69,49 @@ autocompleteEditor.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames
AssertFalse(autocompleteEditor.IsAutocompleteActive, "autocomplete cancel");
AssertEqual("/h", autocompleteEditor.Value, "autocomplete cancel keeps text");
var imageService = new TerminalImageService();
imageService.SetCapabilities(new TerminalCapabilities(ImageProtocol.Kitty, true, true));
imageService.SetCellDimensions(new CellDimensions(10, 10));
var cellSize = imageService.CalculateCellSize(new ImageDimensions(20, 20), 2);
AssertEqual(new ImageCellSize(2, 2), cellSize, "image cell size");
var pngData = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAFElEQVR4nGNgYGBgYGBgYGBgAAAABQABJzQnCgAAAABJRU5ErkJggg==");
AssertEqual(new ImageDimensions(2, 3), imageService.TryGetDimensions(pngData, "image/png"), "png dimensions");
var image = new Image([1, 2, 3, 4], "image/png", imageService: imageService, dimensions: new ImageDimensions(20, 20))
{
MaxWidthCells = 2,
};
var imageLines = image.Render(4);
AssertEqual(2, imageLines.Count, "kitty image line count");
AssertTrue(image.ImageId is > 0, "kitty image id allocated");
AssertTrue(imageLines[0].StartsWith("\e_G", StringComparison.Ordinal), "kitty image sequence");
AssertTrue(imageLines[0].Contains(",C=1,", StringComparison.Ordinal), "kitty image no cursor movement");
AssertTrue(TerminalImageService.IsImageLine($"prefix {imageLines[0]} suffix"), "image line detection anywhere");
imageService.SetCapabilities(TerminalCapabilities.Conservative);
var fallback = new Image([1, 2, 3], "image/jpeg", imageService: imageService, dimensions: new ImageDimensions(8, 9))
{
FileName = "photo.jpg",
}.Render(80)[0];
AssertTrue(fallback.Contains("[Image: photo.jpg [image/jpeg] 8x9]", StringComparison.Ordinal), "image fallback");
var fakeOutput = new FakeTerminalOutput();
var renderer = new DifferentialRenderer(
fakeOutput,
measurer,
new RenderPipelineOptions { ThrowOnWidthOverflow = true, UseSynchronizedOutput = false });
var longImageLine = $"Read image file \e]1337;File=inline=1:{new string('A', 200)}\a";
renderer.Render([longImageLine], new TerminalSize(20, 5));
renderer.Render(["plain"], new TerminalSize(20, 5));
fakeOutput.Buffer.Clear();
var kittyLine = "\e_Ga=T,f=100,q=2,C=1,c=1,r=1,i=42;AAAA\e\\";
renderer.Render([kittyLine], new TerminalSize(20, 5));
fakeOutput.Buffer.Clear();
renderer.Render(["changed"], new TerminalSize(20, 5));
AssertTrue(fakeOutput.Buffer.ToString().Contains(TerminalImageService.DeleteKittyImage(42), StringComparison.Ordinal), "kitty image cleanup on diff");
Console.WriteLine("TinyTUI component checks passed");
static void AssertEqual<T>(T expected, T actual, string name)
@@ -80,3 +127,34 @@ static void AssertTrue(bool condition, string name)
}
static void AssertFalse(bool condition, string name) => AssertTrue(!condition, name);
/// <summary>
/// 捕获渲染器写出的终端序列
/// </summary>
internal sealed class FakeTerminalOutput : ITerminalOutput
{
/// <summary>
/// 获取已写出的终端缓冲
/// </summary>
public StringBuilder Buffer { get; } = new();
/// <inheritdoc />
public void Write(string value) => Buffer.Append(value);
/// <inheritdoc />
public void Flush()
{
}
/// <inheritdoc />
public void ClearScreen() => Buffer.Append("\e[2J\e[H");
/// <inheritdoc />
public void HideCursor() => Buffer.Append("\e[?25l");
/// <inheritdoc />
public void ShowCursor() => Buffer.Append("\e[?25h");
/// <inheritdoc />
public void MoveCursorTo(int row, int column) => Buffer.Append($"\e[{row};{column}H");
}