feat: add component infrastructure

- add focusable runtime state and invalidate entry

- add theme primitives and reusable components

- add component checks for focus and cancellation
This commit is contained in:
chuan
2026-06-04 02:17:07 +08:00
parent 0be34a9c16
commit 83967fa691
19 changed files with 450 additions and 15 deletions
+32
View File
@@ -225,6 +225,38 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入
参考:`tmp/tui/src/components/*`
本次推进:
- 新增 `IFocusableComponent`,把焦点状态从组件内部自管改成由 `TuiRuntime.SetFocus` 统一写入
- `Input``SelectList``Editor` 改为只在 `Focused` 为 true 时输出 `CursorMarker`,避免多个可输入组件同时影响硬件光标定位
- `TuiRuntime` 在添加、移除、焦点切换、resize 和显式 `Invalidate()` 时递归清理组件缓存,为主题变化和复杂组件缓存失效提供框架入口
- `RenderPipelineOptions` 新增 `ShowHardwareCursor`,renderer 始终能把硬件光标定位到 marker,是否显示光标由配置或 `TINYTUI_HARDWARE_CURSOR` 控制
- 新增 `ITuiTheme` 和默认 `TuiTheme`,先提供 hint、dim、输入光标、设置项标签和值、选择 cursor 等跨组件样式入口
- 新增通用组件 `Spacer``TruncatedText``CancellableLoader`,覆盖空行间隔、ANSI 感知单行截断和 Escape 取消加载流程
- 新增 `TinyTUI.ComponentChecks` 最小检查项目,验证 focusable marker、通用组件渲染和 cancellable loader 取消行为
为什么先做:
- `tmp/tui/src/tui.ts``Focusable.focused` 是组件基础设施的关键分界:运行时决定谁有焦点,组件只根据状态决定是否发出硬件光标 marker;先补这一层能减少后续 overlay、autocomplete、settings 子菜单抢焦点时的耦合
- 主题和通用组件先提供薄接口和默认实现,不直接重写所有现有组件样式,避免一次提交同时改变大量视觉输出
- `Invalidate()` 先接入 runtime 生命周期和 resize,不提前实现完整缓存系统,但给 Markdown、SettingsList、Autocomplete 这类后续复杂组件留下统一失效入口
当前更好的点:
- C# 侧通过 `IFocusableComponent` 明确焦点状态契约,运行时切换焦点时会同时 invalidate 前后组件,后续带缓存组件不需要自己猜测焦点变化
- 硬件光标显示配置放在渲染管线选项里,组件只负责 marker,避免组件直接依赖终端输出细节
- `CancellableLoader` 暴露标准 `CancellationToken`,比只暴露回调更容易接入 C# async 工作流
- `TruncatedText` 直接复用 `ITextMeasurer`,能继承第 5 项已经补过的 ANSI、OSC 和 grapheme 宽度处理
后续仍需补齐:
- `SettingsList` 还没有移植,参考实现里的搜索、描述换行、值循环、submenu 委托和主题分区仍需单独推进
- `ITuiTheme` 当前只是轻量样式接口,还没有 runtime 级主题持有、主题切换事件和组件缓存 theme version
- `Input` 目前仍是尾部输入模型,还没有参考实现里的水平滚动、按 grapheme 移动、kill ring 和 undo;这些应在输入/编辑能力后续增量中处理
- 组件缓存规范还只具备 invalidate 入口,没有统一的 width/content/theme cache key,也没有 Markdown AST 或复杂布局缓存基类
- 焦点恢复策略仍比 `tmp/tui/src/tui.ts` 简化,overlay 被临时隐藏、非 overlay 组件短暂抢焦点和多层 submenu 的 blocked restore 还需要继续补强
- 硬件光标定位已可配置显示隐藏,但还没有维护 renderer 级 `hardwareCursorRow` 相对移动模型,IME 在滚动和 resize 场景仍需要真实终端验证
### 7. 自动补全和命令交互基础
目标:把自动补全从 Editor 局部功能升级为可复用的交互能力
@@ -0,0 +1,72 @@
using TinyTUI.Input;
namespace TinyTUI.Components;
/// <summary>
/// 支持 Escape 取消并暴露 CancellationToken 的加载组件
/// </summary>
public sealed class CancellableLoader : IInputComponent, IDisposable
{
private readonly KeybindingRegistry _keybindings;
private readonly CancellationTokenSource _cancellation = new();
private readonly Loader _loader;
/// <summary>
/// 创建可取消加载组件
/// </summary>
public CancellableLoader(Loader? loader = null, KeybindingRegistry? keybindings = null)
{
_loader = loader ?? new Loader();
_keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
}
/// <summary>
/// 获取底层加载组件
/// </summary>
public Loader Loader => _loader;
/// <summary>
/// 获取用于传递给异步操作的取消令牌
/// </summary>
public CancellationToken Token => _cancellation.Token;
/// <summary>
/// 获取是否已经取消
/// </summary>
public bool IsCanceled => _cancellation.IsCancellationRequested;
/// <summary>
/// 在用户取消加载时触发
/// </summary>
public Action? OnCanceled { get; set; }
/// <inheritdoc />
public IReadOnlyList<string> Render(int width) => _loader.Render(width);
/// <inheritdoc />
public void Invalidate() => ((IComponent)_loader).Invalidate();
/// <inheritdoc />
public void HandleInput(TuiInputEvent input)
{
if (input.Kind != TuiInputEventKind.Key || !_keybindings.Matches(input, TuiKeybindings.SelectCancel))
return;
Cancel();
}
/// <summary>
/// 手动取消加载流程
/// </summary>
public void Cancel()
{
if (IsCanceled)
return;
_cancellation.Cancel();
OnCanceled?.Invoke();
}
/// <inheritdoc />
public void Dispose() => _cancellation.Dispose();
}
@@ -0,0 +1,12 @@
namespace TinyTUI.Components;
/// <summary>
/// 定义可由运行时统一设置焦点状态的组件
/// </summary>
public interface IFocusableComponent : IComponent
{
/// <summary>
/// 获取或设置组件当前是否拥有输入焦点
/// </summary>
bool Focused { get; set; }
}
@@ -101,13 +101,16 @@ public sealed partial class Editor
private string RenderVisualRow(EditorVisualRow row, bool isLastRow)
{
if (IsEmpty && row.LogicalRow == _cursorRow && Placeholder.Length > 0)
return CursorMarker.Marker + _textMeasurer.Truncate(Placeholder, _lastRenderWidth);
{
var marker = Focused ? CursorMarker.Marker : string.Empty;
return marker + _textMeasurer.Truncate(Placeholder, _lastRenderWidth);
}
if (row.LogicalRow != _cursorRow || !IsCursorInVisualRow(row, isLastRow))
return row.Text;
// Renderer 会提取这个 marker 并移动硬件光标 所以这里不渲染可见光标字符
return InsertCursorMarker(row.Text, _cursorColumn - row.StartColumn);
return Focused ? InsertCursorMarker(row.Text, _cursorColumn - row.StartColumn) : row.Text;
}
/// <summary>
+4 -1
View File
@@ -6,7 +6,7 @@ namespace TinyTUI.Components;
/// <summary>
/// 支持多行文本编辑的基础编辑器组件
/// </summary>
public sealed partial class Editor(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) : IInputComponent
public sealed partial class Editor(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) : IInputComponent, IFocusableComponent
{
private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
private readonly KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
@@ -38,6 +38,9 @@ public sealed partial class Editor(ITextMeasurer? textMeasurer = null, Keybindin
/// </summary>
public string Placeholder { get; set; } = string.Empty;
/// <inheritdoc />
public bool Focused { get; set; }
/// <summary>
/// 获取当前编辑器文本
/// </summary>
+13 -3
View File
@@ -2,16 +2,21 @@ using System.Text;
using TinyTUI.Input;
using TinyTUI.Rendering;
using TinyTUI.Text;
using TinyTUI.Theme;
namespace TinyTUI.Components;
/// <summary>
/// 支持文本输入 提交和取消的单行输入组件
/// </summary>
public sealed class Input(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) : IInputComponent
public sealed class Input(
ITextMeasurer? textMeasurer = null,
KeybindingRegistry? keybindings = null,
ITuiTheme? theme = null) : IInputComponent, IFocusableComponent
{
private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
private readonly KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
private readonly ITuiTheme _theme = theme ?? TuiTheme.Default;
/// <summary>
/// 获取或设置输入框前缀
@@ -23,6 +28,9 @@ public sealed class Input(ITextMeasurer? textMeasurer = null, KeybindingRegistry
/// </summary>
public string Value { get; private set; } = string.Empty;
/// <inheritdoc />
public bool Focused { get; set; }
/// <summary>
/// 在输入值变化时触发
/// </summary>
@@ -41,8 +49,10 @@ public sealed class Input(ITextMeasurer? textMeasurer = null, KeybindingRegistry
/// <inheritdoc />
public IReadOnlyList<string> Render(int width)
{
// 光标不渲染成可见字符 由 renderer 提取 marker 后移动真实终端光标
var visible = $"{Prompt}{Value}{CursorMarker.Marker}";
// 只有运行时确认获得焦点后才发出 marker 避免多个输入组件争抢硬件光标
var marker = Focused ? CursorMarker.Marker : string.Empty;
var cursor = Focused ? _theme.InputCursor(" ") : string.Empty;
var visible = $"{Prompt}{Value}{marker}{cursor}";
return [_textMeasurer.Truncate(visible, width)];
}
+12 -3
View File
@@ -1,19 +1,24 @@
using TinyTUI.Input;
using TinyTUI.Rendering;
using TinyTUI.Text;
using TinyTUI.Theme;
namespace TinyTUI.Components;
/// <summary>
/// 支持方向键选择和回车确认的列表组件
/// </summary>
public sealed class SelectList(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) : IInputComponent
public sealed class SelectList(
ITextMeasurer? textMeasurer = null,
KeybindingRegistry? keybindings = null,
ITuiTheme? theme = null) : IInputComponent, IFocusableComponent
{
private const int PrimaryColumnGap = 2;
private const int MinDescriptionWidth = 10;
private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
private readonly KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
private readonly ITuiTheme _theme = theme ?? TuiTheme.Default;
private readonly List<SelectItem> _items = [];
private readonly List<SelectItem> _filteredItems = [];
@@ -29,6 +34,9 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null, KeybindingReg
/// </summary>
public int SelectedIndex { get; private set; }
/// <inheritdoc />
public bool Focused { get; set; }
/// <summary>
/// 获取当前选中项文本
/// </summary>
@@ -114,8 +122,9 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null, KeybindingReg
public IReadOnlyList<string> Render(int width)
{
var lines = new List<string>();
var marker = Focused ? CursorMarker.Marker : string.Empty;
lines.Add(_textMeasurer.Truncate($"Filter: {_filter}{CursorMarker.Marker}", width));
lines.Add(_textMeasurer.Truncate($"Filter: {_filter}{marker}", width));
if (_filteredItems.Count == 0)
{
@@ -244,7 +253,7 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null, KeybindingReg
/// </summary>
private string RenderItem(SelectItem item, bool selected, int width, int primaryWidth)
{
var prefix = selected ? "> " : " ";
var prefix = selected ? _theme.Cursor : " ";
var label = NormalizeSingleLine(item.Label.Length == 0 ? item.Value : item.Label);
var description = NormalizeSingleLine(item.Description ?? string.Empty);
var prefixWidth = _textMeasurer.GetWidth(prefix);
+19
View File
@@ -0,0 +1,19 @@
namespace TinyTUI.Components;
/// <summary>
/// 渲染固定数量空行的间隔组件
/// </summary>
public sealed class Spacer(int lines = 1) : IComponent
{
/// <summary>
/// 获取或设置空行数量
/// </summary>
public int Lines { get; set; } = Math.Max(0, lines);
/// <inheritdoc />
public IReadOnlyList<string> Render(int width)
{
var count = Math.Max(0, Lines);
return count == 0 ? [] : Enumerable.Repeat(string.Empty, count).ToArray();
}
}
+68
View File
@@ -0,0 +1,68 @@
using TinyTUI.Text;
namespace TinyTUI.Components;
/// <summary>
/// 渲染单行并按终端宽度截断和填充的文本组件
/// </summary>
public sealed class TruncatedText(string value = "", ITextMeasurer? textMeasurer = null) : IComponent
{
private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
/// <summary>
/// 获取或设置要显示的文本
/// </summary>
public string Value { get; set; } = value;
/// <summary>
/// 获取或设置左右内边距
/// </summary>
public int PaddingX { get; set; }
/// <summary>
/// 获取或设置上下空行内边距
/// </summary>
public int PaddingY { get; set; }
/// <summary>
/// 获取或设置是否补齐到完整宽度
/// </summary>
public bool PadToWidth { get; set; } = true;
/// <inheritdoc />
public IReadOnlyList<string> Render(int width)
{
var safeWidth = Math.Max(0, width);
var lines = new List<string>();
var emptyLine = PadToWidth ? new string(' ', safeWidth) : string.Empty;
var paddingY = Math.Max(0, PaddingY);
for (var index = 0; index < paddingY; index++)
lines.Add(emptyLine);
lines.Add(RenderContentLine(safeWidth));
for (var index = 0; index < paddingY; index++)
lines.Add(emptyLine);
return lines;
}
/// <summary>
/// 渲染单行内容并按可用宽度处理左右内边距
/// </summary>
private string RenderContentLine(int width)
{
var paddingX = Math.Max(0, PaddingX);
var availableWidth = Math.Max(0, width - paddingX * 2);
var singleLine = Value.Replace("\r\n", "\n").Split('\n')[0];
var content = availableWidth == 0 ? string.Empty : _textMeasurer.Truncate(singleLine, availableWidth);
var rendered = $"{new string(' ', paddingX)}{content}{new string(' ', paddingX)}";
if (!PadToWidth)
return rendered;
var padding = Math.Max(0, width - _textMeasurer.GetWidth(rendered));
return rendered + new string(' ', padding);
}
}
+11 -1
View File
@@ -252,14 +252,24 @@ public sealed class DifferentialRenderer : IRenderer
private void MoveCursor(RenderedFrame frame)
{
if (frame.Cursor is not { } position)
{
_output.HideCursor();
return;
}
var visibleRow = position.Row - frame.Viewport.Top;
if (visibleRow is < 1 || visibleRow > frame.Viewport.Height)
{
_output.HideCursor();
return;
}
_output.ShowCursor();
_output.MoveCursorTo(visibleRow, position.Column);
if (_options.ShowHardwareCursor)
_output.ShowCursor();
else
_output.HideCursor();
}
/// <summary>
+11 -1
View File
@@ -77,13 +77,23 @@ public sealed class FullScreenRenderer : IRenderer
private void MoveCursor(RenderedFrame frame)
{
if (frame.Cursor is not { } position)
{
_output.HideCursor();
return;
}
var visibleRow = position.Row - frame.Viewport.Top;
if (visibleRow is < 1 || visibleRow > frame.Viewport.Height)
{
_output.HideCursor();
return;
}
_output.ShowCursor();
_output.MoveCursorTo(visibleRow, position.Column);
if (_options.ShowHardwareCursor)
_output.ShowCursor();
else
_output.HideCursor();
}
}
@@ -30,6 +30,11 @@ public sealed class RenderPipelineOptions
/// </summary>
public bool ThrowOnWidthOverflow { get; init; }
/// <summary>
/// 获取或设置定位到光标标记后是否显示硬件光标
/// </summary>
public bool ShowHardwareCursor { get; init; } = Environment.GetEnvironmentVariable("TINYTUI_HARDWARE_CURSOR") == "1";
/// <summary>
/// 获取或设置是否写出每次差分渲染调试日志
/// </summary>
+5
View File
@@ -23,6 +23,11 @@ public interface ITuiRuntime : IDisposable
/// </summary>
void SetFocus(IComponent? component);
/// <summary>
/// 递归清除组件缓存并请求重新渲染
/// </summary>
void Invalidate();
/// <summary>
/// 请求一次渲染
/// </summary>
+41 -4
View File
@@ -46,6 +46,7 @@ public sealed class TuiRuntime : ITuiRuntime
public void Add(IComponent component)
{
_root.Add(component);
component.Invalidate();
RequestRender();
}
@@ -54,13 +55,37 @@ public sealed class TuiRuntime : ITuiRuntime
{
_root.Remove(component);
if (_focusedComponent == component) _focusedComponent = null;
if (_focusedComponent == component)
SetFocus(null);
else
component.Invalidate();
RequestRender();
}
/// <inheritdoc />
public void SetFocus(IComponent? component) => _focusedComponent = component;
public void SetFocus(IComponent? component)
{
if (ReferenceEquals(_focusedComponent, component))
return;
SetFocusableState(_focusedComponent, focused: false);
_focusedComponent?.Invalidate();
_focusedComponent = component;
SetFocusableState(_focusedComponent, focused: true);
_focusedComponent?.Invalidate();
RequestRender();
}
/// <inheritdoc />
public void Invalidate()
{
_root.Invalidate();
RequestRender();
}
/// <inheritdoc />
public void RequestRender()
@@ -142,7 +167,10 @@ public sealed class TuiRuntime : ITuiRuntime
}
private void OnResized(object? sender, TerminalSize size)
=> Dispatch(new TuiInputEvent(TuiInputEventKind.Resize, $"{size.Columns}x{size.Rows}"));
{
_root.Invalidate();
Dispatch(new TuiInputEvent(TuiInputEventKind.Resize, $"{size.Columns}x{size.Rows}"));
}
/// <summary>
/// 将输入事件发送给当前焦点组件并请求重渲染
@@ -200,7 +228,16 @@ public sealed class TuiRuntime : ITuiRuntime
_overlayFocusRestore.TryGetValue(component, out restoreFocus);
if (_focusedComponent == component)
_focusedComponent = _overlayManager.TopFocusableComponent ?? restoreFocus;
SetFocus(_overlayManager.TopFocusableComponent ?? restoreFocus);
}
/// <summary>
/// 统一写入组件焦点状态 组件自身只根据状态决定是否输出光标 marker
/// </summary>
private static void SetFocusableState(IComponent? component, bool focused)
{
if (component is IFocusableComponent focusable)
focusable.Focused = focused;
}
/// <summary>
+37
View File
@@ -0,0 +1,37 @@
namespace TinyTUI.Theme;
/// <summary>
/// 定义组件渲染时可复用的终端样式入口
/// </summary>
public interface ITuiTheme
{
/// <summary>
/// 获取普通提示文本样式
/// </summary>
string Hint(string value);
/// <summary>
/// 获取弱化文本样式
/// </summary>
string Dim(string value);
/// <summary>
/// 获取选中行前缀
/// </summary>
string Cursor { get; }
/// <summary>
/// 获取输入光标所在字符样式
/// </summary>
string InputCursor(string value);
/// <summary>
/// 获取设置项标签样式
/// </summary>
string SettingLabel(string value, bool selected);
/// <summary>
/// 获取设置项值样式
/// </summary>
string SettingValue(string value, bool selected);
}
+30
View File
@@ -0,0 +1,30 @@
namespace TinyTUI.Theme;
/// <summary>
/// 提供 TinyTUI 默认终端主题
/// </summary>
public sealed class TuiTheme : ITuiTheme
{
/// <summary>
/// 获取默认主题实例
/// </summary>
public static TuiTheme Default { get; } = new();
/// <inheritdoc />
public string Cursor { get; init; } = "> ";
/// <inheritdoc />
public string Hint(string value) => $"\e[2m{value}\e[22m";
/// <inheritdoc />
public string Dim(string value) => $"\e[2m{value}\e[22m";
/// <inheritdoc />
public string InputCursor(string value) => $"\e[7m{value}\e[27m";
/// <inheritdoc />
public string SettingLabel(string value, bool selected) => selected ? $"\e[1m{value}\e[22m" : value;
/// <inheritdoc />
public string SettingValue(string value, bool selected) => selected ? $"\e[36m{value}\e[39m" : value;
}
+58
View File
@@ -0,0 +1,58 @@
using TinyTUI.Components;
using TinyTUI.Input;
using TinyTUI.Rendering;
using TinyTUI.Text;
var measurer = new TerminalTextMeasurer();
var input = new Input(measurer) { Prompt = "> " };
AssertFalse(input.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "unfocused input marker");
input.Focused = true;
AssertTrue(input.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "focused input marker");
var editor = new Editor(measurer) { Placeholder = "type here" };
AssertFalse(editor.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "unfocused editor marker");
editor.Focused = true;
AssertTrue(editor.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "focused editor marker");
var list = new SelectList(measurer);
list.SetItems(["alpha", "beta"]);
AssertFalse(list.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "unfocused select marker");
list.Focused = true;
AssertTrue(list.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "focused select marker");
AssertEqual(3, new Spacer(3).Render(10).Count, "spacer line count");
AssertEqual(0, new Spacer(-1).Render(10).Count, "spacer clamps negative lines");
var truncated = new TruncatedText("abcdef\nignored", measurer)
{
PaddingX = 1,
PaddingY = 1,
};
var truncatedLines = truncated.Render(6);
AssertEqual(3, truncatedLines.Count, "truncated text vertical padding");
AssertEqual(6, measurer.GetWidth(truncatedLines[1]), "truncated text padded width");
AssertTrue(truncatedLines[1].Contains("abc", StringComparison.Ordinal), "truncated text first line only");
var loader = new CancellableLoader(keybindings: KeybindingRegistry.CreateDefault());
var canceled = false;
loader.OnCanceled = () => canceled = true;
loader.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Escape));
AssertTrue(loader.IsCanceled, "cancellable loader token");
AssertTrue(canceled, "cancellable loader callback");
Console.WriteLine("TinyTUI component checks passed");
static void AssertEqual<T>(T expected, T actual, string name)
{
if (!EqualityComparer<T>.Default.Equals(expected, actual))
throw new InvalidOperationException($"{name}: expected '{expected}', actual '{actual}'");
}
static void AssertTrue(bool condition, string name)
{
if (!condition)
throw new InvalidOperationException($"{name}: assertion failed");
}
static void AssertFalse(bool condition, string name) => AssertTrue(!condition, name);
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\src\TinyTUI\TinyTUI.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+1
View File
@@ -9,5 +9,6 @@
</Folder>
<Project Path="src/Example/Example.csproj" Id="6656c1ec-b220-4e7a-8875-5a97a85afbc1" />
<Project Path="src/TinyTUI/TinyTUI.csproj" Id="f39199aa-6947-4951-b952-a1588045505e" />
<Project Path="test/TinyTUI.ComponentChecks/TinyTUI.ComponentChecks.csproj" Id="95c72f97-61f0-4806-8a90-28d70a89cd87" />
<Project Path="test/TinyTUI.TextChecks/TinyTUI.TextChecks.csproj" Id="d827983c-78a1-4f04-85f3-874ccfe2e735" />
</Solution>