From 0d9a36b6a676e2de9e9186a8eeec422369ca93e6 Mon Sep 17 00:00:00 2001 From: chuan Date: Wed, 3 Jun 2026 21:48:00 +0800 Subject: [PATCH] feat: add console terminal io - implement console-backed terminal input and resize polling - implement console-backed terminal output helpers - add example program for manual IO verification --- src/Example/Program.cs | 74 ++++++++- src/TinyTUI/Stdio/ConsoleTerminalInput.cs | 158 ++++++++++++++++++++ src/TinyTUI/Stdout/ConsoleTerminalOutput.cs | 64 ++++++++ 3 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 src/TinyTUI/Stdio/ConsoleTerminalInput.cs create mode 100644 src/TinyTUI/Stdout/ConsoleTerminalOutput.cs diff --git a/src/Example/Program.cs b/src/Example/Program.cs index 3751555..f8a1e76 100644 --- a/src/Example/Program.cs +++ b/src/Example/Program.cs @@ -1,2 +1,72 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); +using TinyTUI; +using TinyTUI.Stdio; +using TinyTUI.Stdout; + +using var input = new ConsoleTerminalInput(); +var output = new ConsoleTerminalOutput(); +var done = new ManualResetEventSlim(); +var latestSize = input.CurrentSize; + +output.ClearScreen(); +output.HideCursor(); +Render(output, latestSize, "等待输入"); + +input.DataReceived += (_, data) => +{ + if (data == "\x1b") + { + done.Set(); + return; + } + + Render(output, latestSize, $"按键: {FormatInput(data)}"); +}; + +input.Resized += (_, size) => +{ + latestSize = size; + Render(output, latestSize, "窗口尺寸已变化"); +}; + +try +{ + input.Start(); + done.Wait(); +} +finally +{ + input.Stop(); + output.ShowCursor(); + output.Write("\r\n"); + output.Flush(); +} + +static void Render(ConsoleTerminalOutput output, TerminalSize size, string message) +{ + output.ClearScreen(); + output.Write("TinyTUI IO 示例\r\n"); + output.Write("================\r\n"); + output.Write($"尺寸: {size.Columns} x {size.Rows}\r\n"); + output.Write($"{message}\r\n"); + output.Write("\r\n"); + output.Write("测试方式:\r\n"); + output.Write("- 输入普通字符 观察按键内容\r\n"); + output.Write("- 按方向键 Home End Delete F1-F12 观察原始序列\r\n"); + output.Write("- 改变终端窗口大小 观察尺寸刷新\r\n"); + output.Write("- 按 Esc 退出\r\n"); + output.Flush(); +} + +static string FormatInput(string data) +{ + return string.Join(' ', data.Select(static c => c switch + { + '\r' => "\\r", + '\n' => "\\n", + '\t' => "\\t", + '\b' => "\\b", + '\x1b' => "\\x1b", + _ when char.IsControl(c) => $"\\x{(int)c:x2}", + _ => c.ToString(), + })); +} diff --git a/src/TinyTUI/Stdio/ConsoleTerminalInput.cs b/src/TinyTUI/Stdio/ConsoleTerminalInput.cs new file mode 100644 index 0000000..d1acc72 --- /dev/null +++ b/src/TinyTUI/Stdio/ConsoleTerminalInput.cs @@ -0,0 +1,158 @@ +namespace TinyTUI.Stdio; + +/// +/// 基于 System.Console 的终端输入实现 +/// +public sealed class ConsoleTerminalInput : ITerminalInput +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(16); + + private CancellationTokenSource? _cancellation; + private Task? _inputTask; + private Task? _resizeTask; + private bool _previousTreatControlCAsInput; + + /// + public TerminalSize CurrentSize => ReadCurrentSize(); + + /// + public event EventHandler? DataReceived; + + /// + public event EventHandler? Resized; + + /// + public void Start() + { + if (_cancellation is not null) + { + return; + } + + _previousTreatControlCAsInput = Console.TreatControlCAsInput; + Console.TreatControlCAsInput = true; + + _cancellation = new CancellationTokenSource(); + _inputTask = Task.Run(() => ReadInputLoop(_cancellation.Token)); + _resizeTask = Task.Run(() => WatchResizeLoop(_cancellation.Token)); + } + + /// + public void Stop() + { + var cancellation = _cancellation; + if (cancellation is null) + { + return; + } + + cancellation.Cancel(); + + var tasks = new[] { _inputTask, _resizeTask }.OfType().ToArray(); + + try + { + Task.WaitAll(tasks, TimeSpan.FromMilliseconds(250)); + } + catch + { + // 停止阶段不向调用方传播后台轮询任务异常 + } + + _inputTask = null; + _resizeTask = null; + _cancellation = null; + + cancellation.Dispose(); + Console.TreatControlCAsInput = _previousTreatControlCAsInput; + } + + /// + public void Dispose() + { + Stop(); + } + + private void ReadInputLoop(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + if (!Console.KeyAvailable) + { + Thread.Sleep(PollInterval); + continue; + } + + var key = Console.ReadKey(intercept: true); + var data = EncodeKey(key); + + if (data.Length > 0) + { + DataReceived?.Invoke(this, data); + } + } + } + + private void WatchResizeLoop(CancellationToken cancellationToken) + { + var previous = CurrentSize; + + while (!cancellationToken.IsCancellationRequested) + { + Thread.Sleep(PollInterval); + + var current = CurrentSize; + if (current == previous) + { + continue; + } + + previous = current; + Resized?.Invoke(this, current); + } + } + + private static TerminalSize ReadCurrentSize() + { + try + { + return new TerminalSize(Console.WindowWidth, Console.WindowHeight); + } + catch + { + return new TerminalSize(80, 24); + } + } + + private static string EncodeKey(ConsoleKeyInfo key) => key.Key switch + { + ConsoleKey.Enter => "\r", + ConsoleKey.Backspace => "\b", + ConsoleKey.Tab => "\t", + ConsoleKey.Escape => "\e", + ConsoleKey.UpArrow => "\e[A", + ConsoleKey.DownArrow => "\e[B", + ConsoleKey.RightArrow => "\e[C", + ConsoleKey.LeftArrow => "\e[D", + ConsoleKey.Home => "\e[H", + ConsoleKey.End => "\e[F", + ConsoleKey.Insert => "\e[2~", + ConsoleKey.Delete => "\e[3~", + ConsoleKey.PageUp => "\e[5~", + ConsoleKey.PageDown => "\e[6~", + ConsoleKey.F1 => "\eOP", + ConsoleKey.F2 => "\eOQ", + ConsoleKey.F3 => "\eOR", + ConsoleKey.F4 => "\eOS", + ConsoleKey.F5 => "\e[15~", + ConsoleKey.F6 => "\e[17~", + ConsoleKey.F7 => "\e[18~", + ConsoleKey.F8 => "\e[19~", + ConsoleKey.F9 => "\e[20~", + ConsoleKey.F10 => "\e[21~", + ConsoleKey.F11 => "\e[23~", + ConsoleKey.F12 => "\e[24~", + _ when key.KeyChar != '\0' => key.KeyChar.ToString(), + _ => string.Empty, + }; +} diff --git a/src/TinyTUI/Stdout/ConsoleTerminalOutput.cs b/src/TinyTUI/Stdout/ConsoleTerminalOutput.cs new file mode 100644 index 0000000..905123d --- /dev/null +++ b/src/TinyTUI/Stdout/ConsoleTerminalOutput.cs @@ -0,0 +1,64 @@ +using System.Text; + +namespace TinyTUI.Stdout; + +/// +/// 基于 System.Console 的终端输出实现 +/// +public sealed class ConsoleTerminalOutput : ITerminalOutput +{ + private readonly TextWriter _writer; + + /// + /// 创建使用 Console.Out 的终端输出实现 + /// + public ConsoleTerminalOutput() : this(CreateConsoleWriter()) { } + + /// + /// 创建使用指定 TextWriter 的终端输出实现 + /// + public ConsoleTerminalOutput(TextWriter writer) + { + _writer = writer; + } + + /// + public void Write(string value) + { + _writer.Write(value); + } + + /// + public void Flush() + { + _writer.Flush(); + } + + /// + public void ClearScreen() + { + Write("\x1b[2J\x1b[H"); + } + + /// + public void HideCursor() + { + Write("\x1b[?25l"); + } + + /// + public void ShowCursor() + { + Write("\x1b[?25h"); + } + + private static TextWriter CreateConsoleWriter() + { + if (!Console.IsOutputRedirected) + { + Console.OutputEncoding = Encoding.UTF8; + } + + return Console.Out; + } +}