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:
@@ -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 手动看效果
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using TinyTUI.Terminal.Images;
|
||||
using TinyTUI.Theme;
|
||||
|
||||
namespace TinyTUI.Components;
|
||||
|
||||
/// <summary>
|
||||
/// 渲染终端内联图像并在不支持时输出文本 fallback
|
||||
/// </summary>
|
||||
public sealed class Image : IComponent
|
||||
{
|
||||
private readonly byte[] _data;
|
||||
private readonly string _base64Data;
|
||||
private readonly ITerminalImageService _imageService;
|
||||
private readonly ITuiTheme _theme;
|
||||
private IReadOnlyList<string>? _cachedLines;
|
||||
private int? _cachedWidth;
|
||||
|
||||
/// <summary>
|
||||
/// 创建终端图像组件
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取 MIME 类型
|
||||
/// </summary>
|
||||
public string MimeType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取图片像素尺寸
|
||||
/// </summary>
|
||||
public ImageDimensions Dimensions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置文件名
|
||||
/// </summary>
|
||||
public string? FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置最大显示列数
|
||||
/// </summary>
|
||||
public int? MaxWidthCells { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置最大显示行数
|
||||
/// </summary>
|
||||
public int? MaxHeightCells { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前 Kitty image id
|
||||
/// </summary>
|
||||
public int? ImageId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 清理渲染缓存
|
||||
/// </summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
_cachedLines = null;
|
||||
_cachedWidth = null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> 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<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按协议输出行并补齐图片占用高度
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 渲染不支持图像协议时的文本 fallback
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> RenderFallback()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(FileName))
|
||||
parts.Add(FileName);
|
||||
|
||||
parts.Add($"[{MimeType}]");
|
||||
parts.Add($"{Dimensions.WidthPx}x{Dimensions.HeightPx}");
|
||||
return [_theme.Dim($"[Image: {string.Join(' ', parts)}]")];
|
||||
}
|
||||
}
|
||||
@@ -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<int> _previousKittyImageIds = new HashSet<int>();
|
||||
|
||||
/// <summary>
|
||||
/// 获取累计全量重绘次数
|
||||
@@ -72,6 +74,8 @@ public sealed class DifferentialRenderer : IRenderer
|
||||
_previousSize = null;
|
||||
_previousViewport = default;
|
||||
_maxLogicalLineCount = 0;
|
||||
_previousKittyImageIds = new HashSet<int>();
|
||||
_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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除变化区域里上一帧已经显示过的 Kitty 图像
|
||||
/// </summary>
|
||||
private void DeleteChangedKittyImages(RenderedFrame previousFrame, RenderedFrame frame)
|
||||
{
|
||||
var maxLineCount = Math.Max(frame.VisibleLines.Count, previousFrame.VisibleLines.Count);
|
||||
var ids = new HashSet<int>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写出 Kitty 图像删除序列
|
||||
/// </summary>
|
||||
private void DeleteKittyImages(IEnumerable<int> ids)
|
||||
{
|
||||
foreach (var id in ids)
|
||||
_output.Write(TerminalImageService.DeleteKittyImage(id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断可见视口是否存在需要写回终端的行差异
|
||||
/// </summary>
|
||||
|
||||
@@ -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
|
||||
/// <inheritdoc />
|
||||
public void Reset()
|
||||
{
|
||||
_output.Write(TerminalImageService.DeleteAllKittyImages());
|
||||
_output.ClearScreen();
|
||||
_output.Flush();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TinyTUI.Terminal.Images;
|
||||
|
||||
/// <summary>
|
||||
/// 表示单个终端 cell 的像素尺寸
|
||||
/// </summary>
|
||||
public readonly record struct CellDimensions(int WidthPx, int HeightPx)
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取默认 cell 尺寸估计值
|
||||
/// </summary>
|
||||
public static CellDimensions Default { get; } = new(9, 18);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace TinyTUI.Terminal.Images;
|
||||
|
||||
/// <summary>
|
||||
/// 提供终端图像能力检测、尺寸解析和协议编码
|
||||
/// </summary>
|
||||
public interface ITerminalImageService
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前终端能力
|
||||
/// </summary>
|
||||
TerminalCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前 cell 像素尺寸
|
||||
/// </summary>
|
||||
CellDimensions CellDimensions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 设置当前 cell 像素尺寸
|
||||
/// </summary>
|
||||
void SetCellDimensions(CellDimensions dimensions);
|
||||
|
||||
/// <summary>
|
||||
/// 尝试从图片数据读取像素尺寸
|
||||
/// </summary>
|
||||
ImageDimensions? TryGetDimensions(ReadOnlySpan<byte> data, string mimeType);
|
||||
|
||||
/// <summary>
|
||||
/// 计算图片在终端中占用的 cell 尺寸
|
||||
/// </summary>
|
||||
ImageCellSize CalculateCellSize(ImageDimensions dimensions, int maxWidthCells, int? maxHeightCells = null);
|
||||
|
||||
/// <summary>
|
||||
/// 渲染终端图像协议序列 不支持图像时返回 null
|
||||
/// </summary>
|
||||
RenderedTerminalImage? RenderImage(string base64Data, ImageDimensions dimensions, ImageRenderOptions? options = null);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace TinyTUI.Terminal.Images;
|
||||
|
||||
/// <summary>
|
||||
/// 表示图片占用的终端 cell 尺寸
|
||||
/// </summary>
|
||||
public readonly record struct ImageCellSize(int Columns, int Rows);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace TinyTUI.Terminal.Images;
|
||||
|
||||
/// <summary>
|
||||
/// 表示图片原始像素尺寸
|
||||
/// </summary>
|
||||
public readonly record struct ImageDimensions(int WidthPx, int HeightPx);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TinyTUI.Terminal.Images;
|
||||
|
||||
/// <summary>
|
||||
/// 表示终端可用的内联图像协议
|
||||
/// </summary>
|
||||
public enum ImageProtocol
|
||||
{
|
||||
/// <summary>
|
||||
/// 不支持终端图像协议
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Kitty graphics protocol
|
||||
/// </summary>
|
||||
Kitty,
|
||||
|
||||
/// <summary>
|
||||
/// iTerm2 inline image protocol
|
||||
/// </summary>
|
||||
ITerm2,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TinyTUI.Terminal.Images;
|
||||
|
||||
/// <summary>
|
||||
/// 表示终端图像渲染选项
|
||||
/// </summary>
|
||||
public sealed class ImageRenderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置最大显示列数
|
||||
/// </summary>
|
||||
public int? MaxWidthCells { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置最大显示行数
|
||||
/// </summary>
|
||||
public int? MaxHeightCells { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否保持宽高比
|
||||
/// </summary>
|
||||
public bool PreserveAspectRatio { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 Kitty image id
|
||||
/// </summary>
|
||||
public int? ImageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置 Kitty 是否按协议默认移动光标
|
||||
/// </summary>
|
||||
public bool MoveCursor { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace TinyTUI.Terminal.Images;
|
||||
|
||||
/// <summary>
|
||||
/// 表示终端图像编码后的输出结果
|
||||
/// </summary>
|
||||
public sealed record RenderedTerminalImage(string Sequence, int Rows, int? ImageId);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TinyTUI.Terminal.Images;
|
||||
|
||||
/// <summary>
|
||||
/// 表示当前终端可安全启用的显示能力
|
||||
/// </summary>
|
||||
public sealed record TerminalCapabilities(
|
||||
ImageProtocol Images,
|
||||
bool TrueColor,
|
||||
bool Hyperlinks)
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取保守的默认能力
|
||||
/// </summary>
|
||||
public static TerminalCapabilities Conservative { get; } = new(ImageProtocol.None, false, false);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace TinyTUI.Terminal.Images;
|
||||
|
||||
/// <summary>
|
||||
/// 默认终端图像服务
|
||||
/// </summary>
|
||||
public sealed class TerminalImageService : ITerminalImageService
|
||||
{
|
||||
private const int KittyChunkSize = 4096;
|
||||
private const string KittyPrefix = "\e_G";
|
||||
private const string ITerm2Prefix = "\e]1337;File=";
|
||||
|
||||
/// <summary>
|
||||
/// 获取默认图像服务实例
|
||||
/// </summary>
|
||||
public static TerminalImageService Default { get; } = new();
|
||||
|
||||
private TerminalCapabilities? _capabilities;
|
||||
private CellDimensions _cellDimensions = CellDimensions.Default;
|
||||
|
||||
/// <inheritdoc />
|
||||
public TerminalCapabilities Capabilities => _capabilities ??= DetectCapabilities();
|
||||
|
||||
/// <inheritdoc />
|
||||
public CellDimensions CellDimensions => _cellDimensions;
|
||||
|
||||
/// <summary>
|
||||
/// 判断行中是否包含 Kitty 或 iTerm2 图像序列
|
||||
/// </summary>
|
||||
public static bool IsImageLine(string line) =>
|
||||
line.Contains(KittyPrefix, StringComparison.Ordinal) ||
|
||||
line.Contains(ITerm2Prefix, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// 分配随机 Kitty image id
|
||||
/// </summary>
|
||||
public static int AllocateImageId() => RandomNumberGenerator.GetInt32(1, int.MaxValue);
|
||||
|
||||
/// <summary>
|
||||
/// 构建删除指定 Kitty 图像的序列
|
||||
/// </summary>
|
||||
public static string DeleteKittyImage(int imageId) => $"\e_Ga=d,d=I,i={imageId},q=2\e\\";
|
||||
|
||||
/// <summary>
|
||||
/// 构建删除所有可见 Kitty 图像的序列
|
||||
/// </summary>
|
||||
public static string DeleteAllKittyImages() => "\e_Ga=d,d=A,q=2\e\\";
|
||||
|
||||
/// <summary>
|
||||
/// 从渲染行中提取 Kitty image id
|
||||
/// </summary>
|
||||
public static IReadOnlySet<int> ExtractKittyImageIds(IEnumerable<string> lines)
|
||||
{
|
||||
var ids = new HashSet<int>();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
foreach (var id in ExtractKittyImageIds(line))
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从单行中提取 Kitty image id
|
||||
/// </summary>
|
||||
public static IEnumerable<int> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注入终端能力 主要供测试和示例固定路径使用
|
||||
/// </summary>
|
||||
public void SetCapabilities(TerminalCapabilities capabilities) => _capabilities = capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// 清理已缓存的终端能力
|
||||
/// </summary>
|
||||
public void ResetCapabilitiesCache() => _capabilities = null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetCellDimensions(CellDimensions dimensions)
|
||||
{
|
||||
_cellDimensions = new CellDimensions(Math.Max(1, dimensions.WidthPx), Math.Max(1, dimensions.HeightPx));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImageDimensions? TryGetDimensions(ReadOnlySpan<byte> 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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检测当前终端能力
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编码 Kitty graphics protocol 序列
|
||||
/// </summary>
|
||||
private static string EncodeKitty(string base64Data, int columns, int rows, int? imageId, bool moveCursor)
|
||||
{
|
||||
var parameters = new List<string> { "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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编码 iTerm2 inline image 序列
|
||||
/// </summary>
|
||||
private static string EncodeITerm2(string base64Data, int width, string height, bool preserveAspectRatio)
|
||||
{
|
||||
var parameters = new List<string> { "inline=1", $"width={width}", $"height={height}" };
|
||||
if (!preserveAspectRatio)
|
||||
parameters.Add("preserveAspectRatio=0");
|
||||
|
||||
return $"\e]1337;File={string.Join(';', parameters)}:{base64Data}\a";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取 PNG 尺寸
|
||||
/// </summary>
|
||||
private static ImageDimensions? TryGetPngDimensions(ReadOnlySpan<byte> 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]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取 JPEG 尺寸
|
||||
/// </summary>
|
||||
private static ImageDimensions? TryGetJpegDimensions(ReadOnlySpan<byte> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取 GIF 尺寸
|
||||
/// </summary>
|
||||
private static ImageDimensions? TryGetGifDimensions(ReadOnlySpan<byte> 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]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取 WebP 尺寸
|
||||
/// </summary>
|
||||
private static ImageDimensions? TryGetWebpDimensions(ReadOnlySpan<byte> 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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取 WebP lossless 尺寸
|
||||
/// </summary>
|
||||
private static ImageDimensions ReadWebpLosslessDimensions(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var bits = BinaryPrimitives.ReadUInt32LittleEndian(data[21..25]);
|
||||
return new ImageDimensions((int)(bits & 0x3fff) + 1, (int)((bits >> 14) & 0x3fff) + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找 Kitty 序列结束位置
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user