- add replaceable terminal session abstraction - manage terminal modes on start and stop - route runtime and example through terminal session
346 lines
11 KiB
C#
346 lines
11 KiB
C#
using TinyTUI;
|
|
using TinyTUI.Components;
|
|
using TinyTUI.Input;
|
|
using TinyTUI.Overlay;
|
|
using TinyTUI.Rendering;
|
|
using TinyTUI.Runtime;
|
|
using TinyTUI.Terminal;
|
|
using TinyTUI.Text;
|
|
|
|
using var terminal = new ConsoleTerminalSession();
|
|
var parser = new DefaultInputParser();
|
|
var textMeasurer = new TerminalTextMeasurer();
|
|
var renderer = new DifferentialRenderer(terminal, textMeasurer);
|
|
using var runtime = new TuiRuntime(terminal, parser, renderer);
|
|
|
|
var done = new ManualResetEventSlim();
|
|
var eventsText = new Text("最近事件:\n- 无");
|
|
var statusText = new Text();
|
|
var editorHistory = new List<string> { "history: first command", "history: second command" };
|
|
var loader = new Loader(textMeasurer) { Message = "运行中" };
|
|
using var loaderCancellation = new CancellationTokenSource();
|
|
var input = new TinyTUI.Components.Input(textMeasurer) { Prompt = "请输入: " };
|
|
IOverlayHandle? tipHandle = null;
|
|
|
|
input.OnChanged = value => UpdateStatus(statusText, value, textMeasurer, renderer);
|
|
input.OnSubmitted = value =>
|
|
{
|
|
switch (value.Trim())
|
|
{
|
|
case "/help":
|
|
AddEvent(eventsText, "Overlay: help opened");
|
|
runtime.ShowOverlay(
|
|
new HelpOverlay(runtime, textMeasurer),
|
|
new OverlayOptions
|
|
{
|
|
Width = OverlaySize.Percent(45),
|
|
MinWidth = 42,
|
|
Anchor = OverlayAnchor.TopRight,
|
|
Margin = new OverlayMargin(1),
|
|
});
|
|
break;
|
|
case "/tip":
|
|
tipHandle?.Hide();
|
|
tipHandle = runtime.ShowOverlay(
|
|
new TipOverlay(textMeasurer),
|
|
OverlayOptions.NonCapturingTooltip());
|
|
AddEvent(eventsText, "Overlay: non-capturing tip opened");
|
|
break;
|
|
case "/select":
|
|
AddEvent(eventsText, "Overlay: select opened");
|
|
runtime.ShowOverlay(
|
|
new SelectOverlay(runtime, message => AddEvent(eventsText, message), textMeasurer),
|
|
new OverlayOptions
|
|
{
|
|
Width = OverlaySize.Percent(62),
|
|
MinWidth = 56,
|
|
Anchor = OverlayAnchor.LeftCenter,
|
|
Margin = new OverlayMargin(1),
|
|
MaxHeight = 12,
|
|
});
|
|
break;
|
|
case "/edit":
|
|
AddEvent(eventsText, "Overlay: editor opened");
|
|
runtime.ShowOverlay(
|
|
new EditorOverlay(runtime, editorHistory, message => AddEvent(eventsText, message), textMeasurer),
|
|
new OverlayOptions
|
|
{
|
|
Width = OverlaySize.Percent(70),
|
|
MinWidth = 52,
|
|
Anchor = OverlayAnchor.BottomCenter,
|
|
Margin = new OverlayMargin(1),
|
|
});
|
|
break;
|
|
case "":
|
|
AddEvent(eventsText, "Submitted empty input");
|
|
break;
|
|
default:
|
|
AddEvent(eventsText, $"Submitted: {value}");
|
|
break;
|
|
}
|
|
|
|
input.Clear();
|
|
};
|
|
input.OnCanceled = () => done.Set();
|
|
|
|
UpdateStatus(statusText, input.Value, textMeasurer, renderer);
|
|
|
|
var page = new Container();
|
|
AddAll(
|
|
page,
|
|
new Text("TinyTUI 基础组件示例"),
|
|
new Text("======================"),
|
|
new Text(
|
|
"操作说明\n" +
|
|
"- 输入文本后 Enter 提交\n" +
|
|
"- /help 打开帮助弹层\n" +
|
|
"- /tip 打开不抢焦点的右上角提示\n" +
|
|
"- /select 打开可交互选择列表\n" +
|
|
"- /edit 打开需要焦点的多行编辑器\n" +
|
|
"- Esc 退出"),
|
|
new Text(),
|
|
new Box(statusText, textMeasurer) { Title = "状态" },
|
|
new Text(),
|
|
new Box(new Container { Children = { loader, input } }, textMeasurer) { Title = "Input" },
|
|
new Text(),
|
|
new Box(eventsText, textMeasurer) { Title = "事件" });
|
|
|
|
runtime.Add(page);
|
|
runtime.SetFocus(input);
|
|
|
|
var loaderTask = RunLoaderAsync(loader, runtime, loaderCancellation.Token);
|
|
|
|
try
|
|
{
|
|
runtime.Start();
|
|
done.Wait();
|
|
}
|
|
finally
|
|
{
|
|
StopLoader(loaderCancellation, loaderTask);
|
|
runtime.Stop();
|
|
terminal.Write("\r\n");
|
|
terminal.Flush();
|
|
}
|
|
|
|
static async Task RunLoaderAsync(Loader loader, ITuiRuntime runtime, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
await Task.Delay(180, cancellationToken).ConfigureAwait(false);
|
|
loader.Tick();
|
|
runtime.RequestRender();
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
}
|
|
}
|
|
|
|
static void StopLoader(CancellationTokenSource cancellation, Task loaderTask)
|
|
{
|
|
cancellation.Cancel();
|
|
loaderTask.Wait(TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
static void UpdateStatus(
|
|
Text statusText,
|
|
string inputValue,
|
|
ITextMeasurer textMeasurer,
|
|
DifferentialRenderer renderer)
|
|
{
|
|
statusText.Value = string.Join(
|
|
'\n',
|
|
$"当前输入 = \"{inputValue}\"",
|
|
$"当前输入宽度 = {textMeasurer.GetWidth(inputValue)}",
|
|
$"字符串 \"abc中文😀xyz\" 宽度 = {textMeasurer.GetWidth("abc中文😀xyz")}",
|
|
$"截断到 8 列 = \"{textMeasurer.Truncate("abc中文😀xyz", 8)}\"",
|
|
$"全量重绘次数 = {renderer.FullRedrawCount}",
|
|
$"差分重绘次数 = {renderer.DifferentialRedrawCount}");
|
|
}
|
|
|
|
static void AddEvent(Text eventsText, string message)
|
|
{
|
|
var lines = eventsText.Value.Split('\n').Skip(1).Append($"- {message}").TakeLast(8);
|
|
eventsText.Value = "最近事件:\n" + string.Join('\n', lines);
|
|
}
|
|
|
|
static void AddAll(Container container, params IComponent[] components)
|
|
{
|
|
foreach (var component in components) container.Add(component);
|
|
}
|
|
|
|
file sealed class HelpOverlay(ITuiRuntime runtime, ITextMeasurer textMeasurer) : IInputComponent
|
|
{
|
|
private readonly Box _box = new(
|
|
new Text(
|
|
"Overlay 示例\n" +
|
|
"\n" +
|
|
"- 这个弹层由 Runtime 显示\n" +
|
|
"- OverlayManager 会把它合成到基础页面上\n" +
|
|
"- 显示时焦点会切到弹层\n" +
|
|
"- 关闭后焦点会恢复到输入框\n" +
|
|
"\n" +
|
|
"按 Enter 或 Esc 关闭"),
|
|
textMeasurer)
|
|
{
|
|
Title = "Help",
|
|
};
|
|
|
|
public IReadOnlyList<string> Render(int width) => _box.Render(width);
|
|
|
|
public void HandleInput(TuiInputEvent input)
|
|
{
|
|
if (input is { Kind: TuiInputEventKind.Key, Value: KeyNames.Enter or KeyNames.Escape })
|
|
{
|
|
runtime.HideOverlay();
|
|
}
|
|
}
|
|
}
|
|
|
|
file sealed class TipOverlay(ITextMeasurer textMeasurer) : IComponent
|
|
{
|
|
private readonly Box _box = new(
|
|
new Text(
|
|
"这是 non-capturing overlay\n" +
|
|
"打开后输入框仍然接收键盘输入\n" +
|
|
"再次输入 /tip 会替换当前提示"),
|
|
textMeasurer)
|
|
{
|
|
Title = "Tip",
|
|
};
|
|
|
|
public IReadOnlyList<string> Render(int width) => _box.Render(width);
|
|
}
|
|
|
|
file sealed class SelectOverlay : IInputComponent
|
|
{
|
|
private readonly ITuiRuntime _runtime;
|
|
private readonly Action<string> _addEvent;
|
|
private readonly SelectList _selectList;
|
|
private readonly Box _box;
|
|
|
|
public SelectOverlay(ITuiRuntime runtime, Action<string> addEvent, ITextMeasurer textMeasurer)
|
|
{
|
|
_runtime = runtime;
|
|
_addEvent = addEvent;
|
|
_selectList = new SelectList(textMeasurer)
|
|
{
|
|
Height = 6,
|
|
EmptyText = "没有匹配项",
|
|
};
|
|
_selectList.SetItems(
|
|
[
|
|
new SelectItem("text", "Text", "渲染纯文本和多行内容"),
|
|
new SelectItem("input", "Input", "单行输入 提交 取消和光标定位"),
|
|
new SelectItem("editor", "Editor", "多行编辑 历史记录 词级移动和撤销"),
|
|
new SelectItem("select-list", "SelectList", "过滤 描述列 循环选择和提交"),
|
|
new SelectItem("markdown", "Markdown", "轻量 Markdown 文本展示"),
|
|
new SelectItem("loader", "Loader", "自动 tick 的加载动画"),
|
|
new SelectItem("overlay", "Overlay", "定位 尺寸 焦点和 non-capturing 提示"),
|
|
]);
|
|
_selectList.OnSelectionChanged = (item, _) => _addEvent($"Selection: {item.Label}");
|
|
_selectList.OnItemSubmitted = (item, _) =>
|
|
{
|
|
_addEvent($"Selected: {item.Label} ({item.Value})");
|
|
_runtime.HideOverlay();
|
|
};
|
|
_selectList.OnCanceled = _runtime.HideOverlay;
|
|
_box = new Box(
|
|
new Container
|
|
{
|
|
Children =
|
|
{
|
|
new Text("输入文本过滤 Backspace 删除过滤 Up/Down 循环选择 Enter 提交"),
|
|
new Text(),
|
|
_selectList,
|
|
},
|
|
},
|
|
textMeasurer)
|
|
{
|
|
Title = "SelectList",
|
|
};
|
|
}
|
|
|
|
public IReadOnlyList<string> Render(int width) => _box.Render(width);
|
|
|
|
public void HandleInput(TuiInputEvent input) => _selectList.HandleInput(input);
|
|
}
|
|
|
|
file sealed class EditorOverlay : IInputComponent
|
|
{
|
|
private readonly ITuiRuntime _runtime;
|
|
private readonly List<string> _history;
|
|
private readonly Action<string> _addEvent;
|
|
private readonly Editor _editor;
|
|
private readonly Box _box;
|
|
|
|
public EditorOverlay(ITuiRuntime runtime, List<string> history, Action<string> addEvent, ITextMeasurer textMeasurer)
|
|
{
|
|
_runtime = runtime;
|
|
_history = history;
|
|
_addEvent = addEvent;
|
|
_editor = new Editor(textMeasurer)
|
|
{
|
|
Height = 6,
|
|
Placeholder = "在这里输入内容",
|
|
OnSubmitted = SubmitAndClose,
|
|
OnCanceled = CancelAndClose,
|
|
};
|
|
_editor.SetHistory(_history);
|
|
_box = new Box(
|
|
new Container
|
|
{
|
|
Children =
|
|
{
|
|
new Text(
|
|
"Home/End 行首行尾 Ctrl+Left/Right 按词移动\n" +
|
|
"Ctrl+Backspace/Delete 按词删除 Ctrl+Z/Y 撤销重做\n" +
|
|
"Ctrl+C 清空当前内容但保留历史记录\n" +
|
|
"Enter 换行 Ctrl+Enter 或 Esc 提交并关闭\n" +
|
|
"长行会按弹层宽度软换行 Up/Down 按视觉行移动"),
|
|
new Text(),
|
|
_editor,
|
|
},
|
|
},
|
|
textMeasurer)
|
|
{
|
|
Title = "Editor",
|
|
};
|
|
}
|
|
|
|
public IReadOnlyList<string> Render(int width) => _box.Render(width);
|
|
|
|
public void HandleInput(TuiInputEvent input)
|
|
{
|
|
if (input is { Kind: TuiInputEventKind.Key, Value: KeyNames.Escape })
|
|
{
|
|
_editor.Submit();
|
|
return;
|
|
}
|
|
|
|
_editor.HandleInput(input);
|
|
}
|
|
|
|
private void SubmitAndClose(string text)
|
|
{
|
|
var value = text.Trim();
|
|
if (value.Length > 0)
|
|
{
|
|
_history.Remove(value);
|
|
_history.Insert(0, value);
|
|
}
|
|
|
|
_addEvent($"Editor submitted {Math.Max(1, text.Split('\n').Length)} line(s)");
|
|
_runtime.HideOverlay();
|
|
}
|
|
|
|
private void CancelAndClose()
|
|
{
|
|
_addEvent("Editor canceled");
|
|
_runtime.HideOverlay();
|
|
}
|
|
}
|