diff --git a/TODO.md b/TODO.md
index 72e22d7..7a45006 100644
--- a/TODO.md
+++ b/TODO.md
@@ -313,6 +313,35 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入
参考:`tmp/tui/src/terminal-image.ts`、`tmp/tui/src/components/image.ts`
+本次推进:
+
+- 新增 `TinyTUI.Terminal.Images` 图像基础设施,提供终端能力检测、Kitty/iTerm2 协议选择、cell 像素尺寸、图片尺寸解析、终端 cell 尺寸换算、Kitty/iTerm2 编码和 fallback 所需的数据结构
+- 新增 `Image` 组件,支持 PNG/JPEG/GIF/WebP 尺寸解析结果或外部尺寸注入,终端支持图像时输出 Kitty/iTerm2 内联图像序列,不支持时输出带文件名、MIME 和尺寸的文本 fallback
+- `RenderFrameBuilder` 识别 Kitty 和 iTerm2 image line,遇到图像序列时跳过普通宽度测量和截断,避免把大段 base64 当作可见文本导致溢出或切坏协议序列
+- `DifferentialRenderer` 和 `FullScreenRenderer` 增加 Kitty image cleanup,清屏、reset 或变化行覆盖旧 Kitty 图像时写出删除序列,降低图像残影
+- 扩展 `TinyTUI.ComponentChecks` 覆盖 cell 尺寸换算、PNG 尺寸解析、Kitty Image 组件输出、fallback、image line 任意位置识别、长 iTerm2 image line 不触发宽度溢出和差分删除旧 Kitty image id
+
+为什么先做:
+
+- `tmp/tui/src/terminal-image.ts` 的图像能力是渲染、组件和终端环境共同依赖的底座;先把 C# 侧服务抽出来,后续真实 cell size 查询、resize 后重新布局和更多组件都能复用同一个入口
+- 参考实现里 `isImageLine` 的回归说明很明确:图像序列可能出现在行中间,且终端不支持图像时仍需要识别,否则宽度校验会处理几百 KB base64 并崩溃;因此本次优先把 image line 保护接到渲染帧构建
+- Kitty 图像不会只靠清行自动消失,差分渲染覆盖旧内容时必须发删除序列;本次只做可见帧中的 id 追踪和变化行清理,避免一次性引入完整滚动区域和硬件光标模型重构
+
+当前更好的点:
+
+- C# 侧 `ITerminalImageService` 是显式可替换服务,组件测试可以直接注入固定能力和 cell 尺寸,不需要改全局环境变量
+- `Image` 组件构造时直接接受 `byte[]` 并缓存 base64,尺寸解析和协议渲染分离,调用方可以用真实图片数据,也可以为测试或远端元数据直接注入尺寸
+- renderer 层的 image line 判断不依赖当前终端是否支持图像,能覆盖“fallback 终端仍收到工具输出图像 escape”的崩溃场景
+
+后续仍需补齐:
+
+- 终端 cell size 目前仍是默认估计或外部手动设置,还没有像 `tmp/tui/src/tui.ts` 那样查询 `CSI 16 t` 并在响应后 invalidate 所有图片组件
+- 能力检测对 tmux hyperlink forwarding 仍保守禁用,没有调用 tmux client_termfeatures,也没有区分更多终端的图像代理能力
+- Kitty cleanup 目前只按可见行变化删除旧 id,没有实现参考实现中的 `expandLastChangedForKittyImages`、滚动区域跨行扩展和 previousKittyImageIds 全量精细同步
+- `Image` 组件还没有真实示例入口、动画/更新复用 id 的公开构造选项,也没有 iTerm2 name、inline、preserveAspectRatio 等完整参数
+- WebP/JPEG/GIF 尺寸解析已有基础路径,但还缺专门测试样本和异常格式覆盖;后续第 9 项测试基础设施应把这些协议和 renderer cleanup 用例迁移到正式测试项目
+- overlay 合成对 image line 仍只是依赖文本切片层不主动切 escape,后续需要在 overlay manager 中像参考实现一样遇到 image line 直接保留或跳过合成,避免浮层覆盖图片区域时产生未定义行为
+
### 9. 测试基础设施
目标:引入可以验证真实终端行为的测试层,而不是只靠 Example 手动看效果
diff --git a/src/TinyTUI/Components/Image.cs b/src/TinyTUI/Components/Image.cs
new file mode 100644
index 0000000..140bbe3
--- /dev/null
+++ b/src/TinyTUI/Components/Image.cs
@@ -0,0 +1,157 @@
+using TinyTUI.Terminal.Images;
+using TinyTUI.Theme;
+
+namespace TinyTUI.Components;
+
+///
+/// 渲染终端内联图像并在不支持时输出文本 fallback
+///
+public sealed class Image : IComponent
+{
+ private readonly byte[] _data;
+ private readonly string _base64Data;
+ private readonly ITerminalImageService _imageService;
+ private readonly ITuiTheme _theme;
+ private IReadOnlyList? _cachedLines;
+ private int? _cachedWidth;
+
+ ///
+ /// 创建终端图像组件
+ ///
+ public Image(
+ byte[] data,
+ string mimeType,
+ ITuiTheme? theme = null,
+ ITerminalImageService? imageService = null,
+ ImageDimensions? dimensions = null)
+ {
+ _data = data;
+ _base64Data = Convert.ToBase64String(data);
+ MimeType = mimeType;
+ _theme = theme ?? TuiTheme.Default;
+ _imageService = imageService ?? TerminalImageService.Default;
+ Dimensions = dimensions ?? _imageService.TryGetDimensions(data, mimeType) ?? new ImageDimensions(800, 600);
+ }
+
+ ///
+ /// 获取 MIME 类型
+ ///
+ public string MimeType { get; }
+
+ ///
+ /// 获取图片像素尺寸
+ ///
+ public ImageDimensions Dimensions { get; }
+
+ ///
+ /// 获取或设置文件名
+ ///
+ public string? FileName { get; init; }
+
+ ///
+ /// 获取或设置最大显示列数
+ ///
+ public int? MaxWidthCells { get; init; }
+
+ ///
+ /// 获取或设置最大显示行数
+ ///
+ public int? MaxHeightCells { get; init; }
+
+ ///
+ /// 获取当前 Kitty image id
+ ///
+ public int? ImageId { get; private set; }
+
+ ///
+ /// 清理渲染缓存
+ ///
+ public void Invalidate()
+ {
+ _cachedLines = null;
+ _cachedWidth = null;
+ }
+
+ ///
+ public IReadOnlyList Render(int width)
+ {
+ if (_cachedLines is not null && _cachedWidth == width)
+ return _cachedLines;
+
+ var safeWidth = Math.Max(1, width);
+ var maxWidth = Math.Max(1, Math.Min(safeWidth - 2, MaxWidthCells ?? 60));
+ var defaultMaxHeight = Math.Max(1, (int)Math.Ceiling((double)(maxWidth * _imageService.CellDimensions.WidthPx) / _imageService.CellDimensions.HeightPx));
+ var maxHeight = MaxHeightCells ?? defaultMaxHeight;
+ var capabilities = _imageService.Capabilities;
+
+ IReadOnlyList lines;
+ if (capabilities.Images != ImageProtocol.None)
+ {
+ if (capabilities.Images == ImageProtocol.Kitty && ImageId is null)
+ ImageId = TerminalImageService.AllocateImageId();
+
+ var rendered = _imageService.RenderImage(
+ _base64Data,
+ Dimensions,
+ new ImageRenderOptions
+ {
+ MaxWidthCells = maxWidth,
+ MaxHeightCells = maxHeight,
+ ImageId = ImageId,
+ MoveCursor = false,
+ });
+
+ lines = rendered is null ? RenderFallback() : RenderProtocolLines(rendered, capabilities.Images);
+ }
+ else
+ {
+ lines = RenderFallback();
+ }
+
+ _cachedLines = lines;
+ _cachedWidth = width;
+ return lines;
+ }
+
+ ///
+ /// 按协议输出行并补齐图片占用高度
+ ///
+ private IReadOnlyList RenderProtocolLines(RenderedTerminalImage rendered, ImageProtocol protocol)
+ {
+ if (rendered.ImageId is { } id)
+ ImageId = id;
+
+ if (protocol == ImageProtocol.Kitty)
+ {
+ var lines = new string[rendered.Rows];
+ lines[0] = rendered.Sequence;
+ for (var index = 1; index < lines.Length; index++)
+ lines[index] = string.Empty;
+
+ return lines;
+ }
+
+ var result = new string[rendered.Rows];
+ for (var index = 0; index < rendered.Rows - 1; index++)
+ result[index] = string.Empty;
+
+ // iTerm2 会按图片自身推进光标 这里把序列放到最后一行以维持 TUI 行数记账
+ var rowOffset = rendered.Rows - 1;
+ result[^1] = rowOffset > 0 ? $"\e[{rowOffset}A{rendered.Sequence}" : rendered.Sequence;
+ return result;
+ }
+
+ ///
+ /// 渲染不支持图像协议时的文本 fallback
+ ///
+ private IReadOnlyList RenderFallback()
+ {
+ var parts = new List();
+ if (!string.IsNullOrWhiteSpace(FileName))
+ parts.Add(FileName);
+
+ parts.Add($"[{MimeType}]");
+ parts.Add($"{Dimensions.WidthPx}x{Dimensions.HeightPx}");
+ return [_theme.Dim($"[Image: {string.Join(' ', parts)}]")];
+ }
+}
diff --git a/src/TinyTUI/Rendering/DifferentialRenderer.cs b/src/TinyTUI/Rendering/DifferentialRenderer.cs
index c632dd6..113b21d 100644
--- a/src/TinyTUI/Rendering/DifferentialRenderer.cs
+++ b/src/TinyTUI/Rendering/DifferentialRenderer.cs
@@ -1,4 +1,5 @@
using TinyTUI.Stdout;
+using TinyTUI.Terminal.Images;
using TinyTUI.Text;
namespace TinyTUI.Rendering;
@@ -16,6 +17,7 @@ public sealed class DifferentialRenderer : IRenderer
private TerminalSize? _previousSize;
private RenderViewportState _previousViewport;
private int _maxLogicalLineCount;
+ private IReadOnlySet _previousKittyImageIds = new HashSet();
///
/// 获取累计全量重绘次数
@@ -72,6 +74,8 @@ public sealed class DifferentialRenderer : IRenderer
_previousSize = null;
_previousViewport = default;
_maxLogicalLineCount = 0;
+ _previousKittyImageIds = new HashSet();
+ _output.Write(TerminalImageService.DeleteAllKittyImages());
_output.ClearScreen();
_output.Flush();
}
@@ -125,7 +129,10 @@ public sealed class DifferentialRenderer : IRenderer
BeginSynchronizedOutput();
if (clearScreen)
+ {
+ DeleteKittyImages(_previousKittyImageIds);
_output.ClearScreen();
+ }
for (var index = 0; index < frame.VisibleLines.Count; index++)
{
@@ -161,6 +168,8 @@ public sealed class DifferentialRenderer : IRenderer
BeginSynchronizedOutput();
+ DeleteChangedKittyImages(previousFrame, frame);
+
for (var index = 0; index < maxLineCount; index++)
{
var previous = index < previousFrame.VisibleLines.Count ? previousFrame.VisibleLines[index] : string.Empty;
@@ -204,11 +213,44 @@ public sealed class DifferentialRenderer : IRenderer
_previousFrame = frame;
_previousSize = size;
_previousViewport = frame.Viewport;
+ _previousKittyImageIds = TerminalImageService.ExtractKittyImageIds(frame.VisibleLines);
_maxLogicalLineCount = resetWorkingArea
? frame.LogicalLines.Count
: Math.Max(_maxLogicalLineCount, frame.LogicalLines.Count);
}
+ ///
+ /// 删除变化区域里上一帧已经显示过的 Kitty 图像
+ ///
+ private void DeleteChangedKittyImages(RenderedFrame previousFrame, RenderedFrame frame)
+ {
+ var maxLineCount = Math.Max(frame.VisibleLines.Count, previousFrame.VisibleLines.Count);
+ var ids = new HashSet();
+
+ for (var index = 0; index < maxLineCount; index++)
+ {
+ var previous = index < previousFrame.VisibleLines.Count ? previousFrame.VisibleLines[index] : string.Empty;
+ var next = index < frame.VisibleLines.Count ? frame.VisibleLines[index] : string.Empty;
+
+ if (previous == next)
+ continue;
+
+ foreach (var id in TerminalImageService.ExtractKittyImageIds(previous))
+ ids.Add(id);
+ }
+
+ DeleteKittyImages(ids);
+ }
+
+ ///
+ /// 写出 Kitty 图像删除序列
+ ///
+ private void DeleteKittyImages(IEnumerable ids)
+ {
+ foreach (var id in ids)
+ _output.Write(TerminalImageService.DeleteKittyImage(id));
+ }
+
///
/// 判断可见视口是否存在需要写回终端的行差异
///
diff --git a/src/TinyTUI/Rendering/FullScreenRenderer.cs b/src/TinyTUI/Rendering/FullScreenRenderer.cs
index 9c10cf2..b63d976 100644
--- a/src/TinyTUI/Rendering/FullScreenRenderer.cs
+++ b/src/TinyTUI/Rendering/FullScreenRenderer.cs
@@ -1,4 +1,5 @@
using TinyTUI.Stdout;
+using TinyTUI.Terminal.Images;
using TinyTUI.Text;
namespace TinyTUI.Rendering;
@@ -31,6 +32,7 @@ public sealed class FullScreenRenderer : IRenderer
var frame = _frameBuilder.Build(lines, size);
BeginSynchronizedOutput();
+ _output.Write(TerminalImageService.DeleteAllKittyImages());
_output.ClearScreen();
for (var index = 0; index < frame.VisibleLines.Count; index++)
@@ -49,6 +51,7 @@ public sealed class FullScreenRenderer : IRenderer
///
public void Reset()
{
+ _output.Write(TerminalImageService.DeleteAllKittyImages());
_output.ClearScreen();
_output.Flush();
}
diff --git a/src/TinyTUI/Rendering/RenderFrameBuilder.cs b/src/TinyTUI/Rendering/RenderFrameBuilder.cs
index e1b903d..165116e 100644
--- a/src/TinyTUI/Rendering/RenderFrameBuilder.cs
+++ b/src/TinyTUI/Rendering/RenderFrameBuilder.cs
@@ -1,4 +1,5 @@
using System.Text;
+using TinyTUI.Terminal.Images;
using TinyTUI.Text;
namespace TinyTUI.Rendering;
@@ -39,6 +40,13 @@ internal sealed class RenderFrameBuilder(ITextMeasurer textMeasurer, RenderPipel
for (var index = 0; index < lines.Count; index++)
{
var line = lines[index];
+ if (TerminalImageService.IsImageLine(line))
+ {
+ // 图像序列可能包含大量 base64 数据 不能按普通文本测宽或截断
+ normalized.Add(line);
+ continue;
+ }
+
var visibleWidth = textMeasurer.GetWidth(line);
if (visibleWidth > width)
diff --git a/src/TinyTUI/Terminal/Images/CellDimensions.cs b/src/TinyTUI/Terminal/Images/CellDimensions.cs
new file mode 100644
index 0000000..2541da3
--- /dev/null
+++ b/src/TinyTUI/Terminal/Images/CellDimensions.cs
@@ -0,0 +1,12 @@
+namespace TinyTUI.Terminal.Images;
+
+///
+/// 表示单个终端 cell 的像素尺寸
+///
+public readonly record struct CellDimensions(int WidthPx, int HeightPx)
+{
+ ///
+ /// 获取默认 cell 尺寸估计值
+ ///
+ public static CellDimensions Default { get; } = new(9, 18);
+}
diff --git a/src/TinyTUI/Terminal/Images/ITerminalImageService.cs b/src/TinyTUI/Terminal/Images/ITerminalImageService.cs
new file mode 100644
index 0000000..2847af0
--- /dev/null
+++ b/src/TinyTUI/Terminal/Images/ITerminalImageService.cs
@@ -0,0 +1,37 @@
+namespace TinyTUI.Terminal.Images;
+
+///
+/// 提供终端图像能力检测、尺寸解析和协议编码
+///
+public interface ITerminalImageService
+{
+ ///
+ /// 获取当前终端能力
+ ///
+ TerminalCapabilities Capabilities { get; }
+
+ ///
+ /// 获取当前 cell 像素尺寸
+ ///
+ CellDimensions CellDimensions { get; }
+
+ ///
+ /// 设置当前 cell 像素尺寸
+ ///
+ void SetCellDimensions(CellDimensions dimensions);
+
+ ///
+ /// 尝试从图片数据读取像素尺寸
+ ///
+ ImageDimensions? TryGetDimensions(ReadOnlySpan data, string mimeType);
+
+ ///
+ /// 计算图片在终端中占用的 cell 尺寸
+ ///
+ ImageCellSize CalculateCellSize(ImageDimensions dimensions, int maxWidthCells, int? maxHeightCells = null);
+
+ ///
+ /// 渲染终端图像协议序列 不支持图像时返回 null
+ ///
+ RenderedTerminalImage? RenderImage(string base64Data, ImageDimensions dimensions, ImageRenderOptions? options = null);
+}
diff --git a/src/TinyTUI/Terminal/Images/ImageCellSize.cs b/src/TinyTUI/Terminal/Images/ImageCellSize.cs
new file mode 100644
index 0000000..00b844d
--- /dev/null
+++ b/src/TinyTUI/Terminal/Images/ImageCellSize.cs
@@ -0,0 +1,6 @@
+namespace TinyTUI.Terminal.Images;
+
+///
+/// 表示图片占用的终端 cell 尺寸
+///
+public readonly record struct ImageCellSize(int Columns, int Rows);
diff --git a/src/TinyTUI/Terminal/Images/ImageDimensions.cs b/src/TinyTUI/Terminal/Images/ImageDimensions.cs
new file mode 100644
index 0000000..d13bd52
--- /dev/null
+++ b/src/TinyTUI/Terminal/Images/ImageDimensions.cs
@@ -0,0 +1,6 @@
+namespace TinyTUI.Terminal.Images;
+
+///
+/// 表示图片原始像素尺寸
+///
+public readonly record struct ImageDimensions(int WidthPx, int HeightPx);
diff --git a/src/TinyTUI/Terminal/Images/ImageProtocol.cs b/src/TinyTUI/Terminal/Images/ImageProtocol.cs
new file mode 100644
index 0000000..7ba740d
--- /dev/null
+++ b/src/TinyTUI/Terminal/Images/ImageProtocol.cs
@@ -0,0 +1,22 @@
+namespace TinyTUI.Terminal.Images;
+
+///
+/// 表示终端可用的内联图像协议
+///
+public enum ImageProtocol
+{
+ ///
+ /// 不支持终端图像协议
+ ///
+ None,
+
+ ///
+ /// Kitty graphics protocol
+ ///
+ Kitty,
+
+ ///
+ /// iTerm2 inline image protocol
+ ///
+ ITerm2,
+}
diff --git a/src/TinyTUI/Terminal/Images/ImageRenderOptions.cs b/src/TinyTUI/Terminal/Images/ImageRenderOptions.cs
new file mode 100644
index 0000000..558f667
--- /dev/null
+++ b/src/TinyTUI/Terminal/Images/ImageRenderOptions.cs
@@ -0,0 +1,32 @@
+namespace TinyTUI.Terminal.Images;
+
+///
+/// 表示终端图像渲染选项
+///
+public sealed class ImageRenderOptions
+{
+ ///
+ /// 获取或设置最大显示列数
+ ///
+ public int? MaxWidthCells { get; init; }
+
+ ///
+ /// 获取或设置最大显示行数
+ ///
+ public int? MaxHeightCells { get; init; }
+
+ ///
+ /// 获取或设置是否保持宽高比
+ ///
+ public bool PreserveAspectRatio { get; init; } = true;
+
+ ///
+ /// 获取或设置 Kitty image id
+ ///
+ public int? ImageId { get; init; }
+
+ ///
+ /// 获取或设置 Kitty 是否按协议默认移动光标
+ ///
+ public bool MoveCursor { get; init; } = true;
+}
diff --git a/src/TinyTUI/Terminal/Images/RenderedTerminalImage.cs b/src/TinyTUI/Terminal/Images/RenderedTerminalImage.cs
new file mode 100644
index 0000000..65afece
--- /dev/null
+++ b/src/TinyTUI/Terminal/Images/RenderedTerminalImage.cs
@@ -0,0 +1,6 @@
+namespace TinyTUI.Terminal.Images;
+
+///
+/// 表示终端图像编码后的输出结果
+///
+public sealed record RenderedTerminalImage(string Sequence, int Rows, int? ImageId);
diff --git a/src/TinyTUI/Terminal/Images/TerminalCapabilities.cs b/src/TinyTUI/Terminal/Images/TerminalCapabilities.cs
new file mode 100644
index 0000000..15d96ff
--- /dev/null
+++ b/src/TinyTUI/Terminal/Images/TerminalCapabilities.cs
@@ -0,0 +1,15 @@
+namespace TinyTUI.Terminal.Images;
+
+///
+/// 表示当前终端可安全启用的显示能力
+///
+public sealed record TerminalCapabilities(
+ ImageProtocol Images,
+ bool TrueColor,
+ bool Hyperlinks)
+{
+ ///
+ /// 获取保守的默认能力
+ ///
+ public static TerminalCapabilities Conservative { get; } = new(ImageProtocol.None, false, false);
+}
diff --git a/src/TinyTUI/Terminal/Images/TerminalImageService.cs b/src/TinyTUI/Terminal/Images/TerminalImageService.cs
new file mode 100644
index 0000000..5948656
--- /dev/null
+++ b/src/TinyTUI/Terminal/Images/TerminalImageService.cs
@@ -0,0 +1,359 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace TinyTUI.Terminal.Images;
+
+///
+/// 默认终端图像服务
+///
+public sealed class TerminalImageService : ITerminalImageService
+{
+ private const int KittyChunkSize = 4096;
+ private const string KittyPrefix = "\e_G";
+ private const string ITerm2Prefix = "\e]1337;File=";
+
+ ///
+ /// 获取默认图像服务实例
+ ///
+ public static TerminalImageService Default { get; } = new();
+
+ private TerminalCapabilities? _capabilities;
+ private CellDimensions _cellDimensions = CellDimensions.Default;
+
+ ///
+ public TerminalCapabilities Capabilities => _capabilities ??= DetectCapabilities();
+
+ ///
+ public CellDimensions CellDimensions => _cellDimensions;
+
+ ///
+ /// 判断行中是否包含 Kitty 或 iTerm2 图像序列
+ ///
+ public static bool IsImageLine(string line) =>
+ line.Contains(KittyPrefix, StringComparison.Ordinal) ||
+ line.Contains(ITerm2Prefix, StringComparison.Ordinal);
+
+ ///
+ /// 分配随机 Kitty image id
+ ///
+ public static int AllocateImageId() => RandomNumberGenerator.GetInt32(1, int.MaxValue);
+
+ ///
+ /// 构建删除指定 Kitty 图像的序列
+ ///
+ public static string DeleteKittyImage(int imageId) => $"\e_Ga=d,d=I,i={imageId},q=2\e\\";
+
+ ///
+ /// 构建删除所有可见 Kitty 图像的序列
+ ///
+ public static string DeleteAllKittyImages() => "\e_Ga=d,d=A,q=2\e\\";
+
+ ///
+ /// 从渲染行中提取 Kitty image id
+ ///
+ public static IReadOnlySet ExtractKittyImageIds(IEnumerable lines)
+ {
+ var ids = new HashSet();
+
+ foreach (var line in lines)
+ {
+ foreach (var id in ExtractKittyImageIds(line))
+ ids.Add(id);
+ }
+
+ return ids;
+ }
+
+ ///
+ /// 从单行中提取 Kitty image id
+ ///
+ public static IEnumerable ExtractKittyImageIds(string line)
+ {
+ const string marker = ",i=";
+
+ for (var index = line.IndexOf(KittyPrefix, StringComparison.Ordinal);
+ index >= 0;
+ index = line.IndexOf(KittyPrefix, index + KittyPrefix.Length, StringComparison.Ordinal))
+ {
+ var end = FindKittySequenceEnd(line, index);
+ var sequence = line[index..end];
+ var markerIndex = sequence.IndexOf(marker, StringComparison.Ordinal);
+ if (markerIndex < 0)
+ continue;
+
+ var start = markerIndex + marker.Length;
+ var length = 0;
+ while (start + length < sequence.Length && char.IsDigit(sequence[start + length]))
+ length++;
+
+ if (length > 0 && int.TryParse(sequence.AsSpan(start, length), CultureInfo.InvariantCulture, out var id))
+ yield return id;
+ }
+ }
+
+ ///
+ /// 注入终端能力 主要供测试和示例固定路径使用
+ ///
+ public void SetCapabilities(TerminalCapabilities capabilities) => _capabilities = capabilities;
+
+ ///
+ /// 清理已缓存的终端能力
+ ///
+ public void ResetCapabilitiesCache() => _capabilities = null;
+
+ ///
+ public void SetCellDimensions(CellDimensions dimensions)
+ {
+ _cellDimensions = new CellDimensions(Math.Max(1, dimensions.WidthPx), Math.Max(1, dimensions.HeightPx));
+ }
+
+ ///
+ public ImageDimensions? TryGetDimensions(ReadOnlySpan data, string mimeType)
+ {
+ var normalized = mimeType.ToLowerInvariant();
+ return normalized switch
+ {
+ "image/png" => TryGetPngDimensions(data),
+ "image/jpeg" or "image/jpg" => TryGetJpegDimensions(data),
+ "image/gif" => TryGetGifDimensions(data),
+ "image/webp" => TryGetWebpDimensions(data),
+ _ => null,
+ };
+ }
+
+ ///
+ public ImageCellSize CalculateCellSize(ImageDimensions dimensions, int maxWidthCells, int? maxHeightCells = null)
+ {
+ var maxWidth = Math.Max(1, maxWidthCells);
+ var maxHeight = maxHeightCells is null ? (int?)null : Math.Max(1, maxHeightCells.Value);
+ var imageWidth = Math.Max(1, dimensions.WidthPx);
+ var imageHeight = Math.Max(1, dimensions.HeightPx);
+
+ var widthScale = (double)(maxWidth * _cellDimensions.WidthPx) / imageWidth;
+ var heightScale = maxHeight is null
+ ? widthScale
+ : (double)(maxHeight.Value * _cellDimensions.HeightPx) / imageHeight;
+ var scale = Math.Min(widthScale, heightScale);
+
+ var columns = (int)Math.Ceiling(imageWidth * scale / _cellDimensions.WidthPx);
+ var rows = (int)Math.Ceiling(imageHeight * scale / _cellDimensions.HeightPx);
+
+ return new ImageCellSize(
+ Math.Max(1, Math.Min(maxWidth, columns)),
+ Math.Max(1, maxHeight is null ? rows : Math.Min(maxHeight.Value, rows)));
+ }
+
+ ///
+ public RenderedTerminalImage? RenderImage(string base64Data, ImageDimensions dimensions, ImageRenderOptions? options = null)
+ {
+ options ??= new ImageRenderOptions();
+ if (Capabilities.Images == ImageProtocol.None)
+ return null;
+
+ var maxWidth = options.MaxWidthCells ?? 80;
+ var size = CalculateCellSize(dimensions, maxWidth, options.MaxHeightCells);
+
+ return Capabilities.Images switch
+ {
+ ImageProtocol.Kitty => new RenderedTerminalImage(
+ EncodeKitty(base64Data, size.Columns, size.Rows, options.ImageId, options.MoveCursor),
+ size.Rows,
+ options.ImageId),
+ ImageProtocol.ITerm2 => new RenderedTerminalImage(
+ EncodeITerm2(base64Data, size.Columns, "auto", options.PreserveAspectRatio),
+ size.Rows,
+ null),
+ _ => null,
+ };
+ }
+
+ ///
+ /// 检测当前终端能力
+ ///
+ private static TerminalCapabilities DetectCapabilities()
+ {
+ var termProgram = Environment.GetEnvironmentVariable("TERM_PROGRAM")?.ToLowerInvariant() ?? string.Empty;
+ var terminalEmulator = Environment.GetEnvironmentVariable("TERMINAL_EMULATOR")?.ToLowerInvariant() ?? string.Empty;
+ var term = Environment.GetEnvironmentVariable("TERM")?.ToLowerInvariant() ?? string.Empty;
+ var colorTerm = Environment.GetEnvironmentVariable("COLORTERM")?.ToLowerInvariant() ?? string.Empty;
+ var trueColorHint = colorTerm is "truecolor" or "24bit";
+
+ // tmux/screen 下图像协议和 OSC 8 转发都不稳定 先保守禁用图像
+ if (Environment.GetEnvironmentVariable("TMUX") is not null || term.StartsWith("tmux", StringComparison.Ordinal))
+ return new TerminalCapabilities(ImageProtocol.None, trueColorHint, false);
+
+ if (term.StartsWith("screen", StringComparison.Ordinal))
+ return new TerminalCapabilities(ImageProtocol.None, trueColorHint, false);
+
+ if (Environment.GetEnvironmentVariable("KITTY_WINDOW_ID") is not null || termProgram == "kitty")
+ return new TerminalCapabilities(ImageProtocol.Kitty, true, true);
+
+ if (termProgram == "ghostty" || term.Contains("ghostty", StringComparison.Ordinal) || Environment.GetEnvironmentVariable("GHOSTTY_RESOURCES_DIR") is not null)
+ return new TerminalCapabilities(ImageProtocol.Kitty, true, true);
+
+ if (Environment.GetEnvironmentVariable("WEZTERM_PANE") is not null || termProgram == "wezterm")
+ return new TerminalCapabilities(ImageProtocol.Kitty, true, true);
+
+ if (Environment.GetEnvironmentVariable("ITERM_SESSION_ID") is not null || termProgram == "iterm.app")
+ return new TerminalCapabilities(ImageProtocol.ITerm2, true, true);
+
+ if (Environment.GetEnvironmentVariable("WT_SESSION") is not null || termProgram is "vscode" or "alacritty")
+ return new TerminalCapabilities(ImageProtocol.None, true, true);
+
+ if (terminalEmulator == "jetbrains-jediterm")
+ return new TerminalCapabilities(ImageProtocol.None, true, false);
+
+ return new TerminalCapabilities(ImageProtocol.None, trueColorHint, false);
+ }
+
+ ///
+ /// 编码 Kitty graphics protocol 序列
+ ///
+ private static string EncodeKitty(string base64Data, int columns, int rows, int? imageId, bool moveCursor)
+ {
+ var parameters = new List { "a=T", "f=100", "q=2" };
+ if (!moveCursor)
+ parameters.Add("C=1");
+ parameters.Add($"c={columns}");
+ parameters.Add($"r={rows}");
+ if (imageId is { } id)
+ parameters.Add($"i={id}");
+
+ if (base64Data.Length <= KittyChunkSize)
+ return $"\e_G{string.Join(',', parameters)};{base64Data}\e\\";
+
+ var builder = new StringBuilder();
+ for (var offset = 0; offset < base64Data.Length; offset += KittyChunkSize)
+ {
+ var chunk = base64Data.Substring(offset, Math.Min(KittyChunkSize, base64Data.Length - offset));
+ var isFirst = offset == 0;
+ var isLast = offset + KittyChunkSize >= base64Data.Length;
+ var prefix = isFirst
+ ? $"{string.Join(',', parameters)},m=1"
+ : isLast ? "m=0" : "m=1";
+
+ builder.Append($"\e_G{prefix};{chunk}\e\\");
+ }
+
+ return builder.ToString();
+ }
+
+ ///
+ /// 编码 iTerm2 inline image 序列
+ ///
+ private static string EncodeITerm2(string base64Data, int width, string height, bool preserveAspectRatio)
+ {
+ var parameters = new List { "inline=1", $"width={width}", $"height={height}" };
+ if (!preserveAspectRatio)
+ parameters.Add("preserveAspectRatio=0");
+
+ return $"\e]1337;File={string.Join(';', parameters)}:{base64Data}\a";
+ }
+
+ ///
+ /// 读取 PNG 尺寸
+ ///
+ private static ImageDimensions? TryGetPngDimensions(ReadOnlySpan data)
+ {
+ if (data.Length < 24 || data[0] != 0x89 || data[1] != 0x50 || data[2] != 0x4e || data[3] != 0x47)
+ return null;
+
+ return new ImageDimensions(
+ BinaryPrimitives.ReadInt32BigEndian(data[16..20]),
+ BinaryPrimitives.ReadInt32BigEndian(data[20..24]));
+ }
+
+ ///
+ /// 读取 JPEG 尺寸
+ ///
+ private static ImageDimensions? TryGetJpegDimensions(ReadOnlySpan data)
+ {
+ if (data.Length < 2 || data[0] != 0xff || data[1] != 0xd8)
+ return null;
+
+ var offset = 2;
+ while (offset < data.Length - 9)
+ {
+ if (data[offset] != 0xff)
+ {
+ offset++;
+ continue;
+ }
+
+ var marker = data[offset + 1];
+ if (marker is >= 0xc0 and <= 0xc2)
+ {
+ var height = BinaryPrimitives.ReadUInt16BigEndian(data[(offset + 5)..(offset + 7)]);
+ var width = BinaryPrimitives.ReadUInt16BigEndian(data[(offset + 7)..(offset + 9)]);
+ return new ImageDimensions(width, height);
+ }
+
+ if (offset + 3 >= data.Length)
+ return null;
+
+ var length = BinaryPrimitives.ReadUInt16BigEndian(data[(offset + 2)..(offset + 4)]);
+ if (length < 2)
+ return null;
+
+ offset += 2 + length;
+ }
+
+ return null;
+ }
+
+ ///
+ /// 读取 GIF 尺寸
+ ///
+ private static ImageDimensions? TryGetGifDimensions(ReadOnlySpan data)
+ {
+ if (data.Length < 10)
+ return null;
+
+ var signature = Encoding.ASCII.GetString(data[..6]);
+ if (signature is not ("GIF87a" or "GIF89a"))
+ return null;
+
+ return new ImageDimensions(
+ BinaryPrimitives.ReadUInt16LittleEndian(data[6..8]),
+ BinaryPrimitives.ReadUInt16LittleEndian(data[8..10]));
+ }
+
+ ///
+ /// 读取 WebP 尺寸
+ ///
+ private static ImageDimensions? TryGetWebpDimensions(ReadOnlySpan data)
+ {
+ if (data.Length < 30 || Encoding.ASCII.GetString(data[..4]) != "RIFF" || Encoding.ASCII.GetString(data[8..12]) != "WEBP")
+ return null;
+
+ return Encoding.ASCII.GetString(data[12..16]) switch
+ {
+ "VP8 " => new ImageDimensions(data[26] | ((data[27] & 0x3f) << 8), data[28] | ((data[29] & 0x3f) << 8)),
+ "VP8L" when data.Length >= 25 => ReadWebpLosslessDimensions(data),
+ "VP8X" => new ImageDimensions(
+ (data[24] | (data[25] << 8) | (data[26] << 16)) + 1,
+ (data[27] | (data[28] << 8) | (data[29] << 16)) + 1),
+ _ => null,
+ };
+ }
+
+ ///
+ /// 读取 WebP lossless 尺寸
+ ///
+ private static ImageDimensions ReadWebpLosslessDimensions(ReadOnlySpan data)
+ {
+ var bits = BinaryPrimitives.ReadUInt32LittleEndian(data[21..25]);
+ return new ImageDimensions((int)(bits & 0x3fff) + 1, (int)((bits >> 14) & 0x3fff) + 1);
+ }
+
+ ///
+ /// 查找 Kitty 序列结束位置
+ ///
+ private static int FindKittySequenceEnd(string line, int start)
+ {
+ var end = line.IndexOf("\e\\", start + KittyPrefix.Length, StringComparison.Ordinal);
+ return end < 0 ? line.Length : end + 2;
+ }
+}
diff --git a/test/TinyTUI.ComponentChecks/Program.cs b/test/TinyTUI.ComponentChecks/Program.cs
index e2e48da..5c0a2ad 100644
--- a/test/TinyTUI.ComponentChecks/Program.cs
+++ b/test/TinyTUI.ComponentChecks/Program.cs
@@ -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 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);
+
+///
+/// 捕获渲染器写出的终端序列
+///
+internal sealed class FakeTerminalOutput : ITerminalOutput
+{
+ ///
+ /// 获取已写出的终端缓冲
+ ///
+ public StringBuilder Buffer { get; } = new();
+
+ ///
+ public void Write(string value) => Buffer.Append(value);
+
+ ///
+ public void Flush()
+ {
+ }
+
+ ///
+ public void ClearScreen() => Buffer.Append("\e[2J\e[H");
+
+ ///
+ public void HideCursor() => Buffer.Append("\e[?25l");
+
+ ///
+ public void ShowCursor() => Buffer.Append("\e[?25h");
+
+ ///
+ public void MoveCursorTo(int row, int column) => Buffer.Append($"\e[{row};{column}H");
+}