From e2d534170f8b5c1f09c2d403d92dbc635895b16d Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 4 Jun 2026 02:28:58 +0800 Subject: [PATCH] feat: add terminal image foundation - add terminal image service and image component - protect image lines and clean up kitty image ids --- TODO.md | 29 ++ src/TinyTUI/Components/Image.cs | 157 ++++++++ src/TinyTUI/Rendering/DifferentialRenderer.cs | 42 ++ src/TinyTUI/Rendering/FullScreenRenderer.cs | 3 + src/TinyTUI/Rendering/RenderFrameBuilder.cs | 8 + src/TinyTUI/Terminal/Images/CellDimensions.cs | 12 + .../Terminal/Images/ITerminalImageService.cs | 37 ++ src/TinyTUI/Terminal/Images/ImageCellSize.cs | 6 + .../Terminal/Images/ImageDimensions.cs | 6 + src/TinyTUI/Terminal/Images/ImageProtocol.cs | 22 ++ .../Terminal/Images/ImageRenderOptions.cs | 32 ++ .../Terminal/Images/RenderedTerminalImage.cs | 6 + .../Terminal/Images/TerminalCapabilities.cs | 15 + .../Terminal/Images/TerminalImageService.cs | 359 ++++++++++++++++++ test/TinyTUI.ComponentChecks/Program.cs | 78 ++++ 15 files changed, 812 insertions(+) create mode 100644 src/TinyTUI/Components/Image.cs create mode 100644 src/TinyTUI/Terminal/Images/CellDimensions.cs create mode 100644 src/TinyTUI/Terminal/Images/ITerminalImageService.cs create mode 100644 src/TinyTUI/Terminal/Images/ImageCellSize.cs create mode 100644 src/TinyTUI/Terminal/Images/ImageDimensions.cs create mode 100644 src/TinyTUI/Terminal/Images/ImageProtocol.cs create mode 100644 src/TinyTUI/Terminal/Images/ImageRenderOptions.cs create mode 100644 src/TinyTUI/Terminal/Images/RenderedTerminalImage.cs create mode 100644 src/TinyTUI/Terminal/Images/TerminalCapabilities.cs create mode 100644 src/TinyTUI/Terminal/Images/TerminalImageService.cs 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"); +}