- Добавить GlobalKeyboardService — выделенный поток с блокирующим Console.ReadKey, единственный источник клавишных событий для TUI - Убрать FocusManager из App.razor: перехватывал Tab до компонентов - Удалить @onkeydown с <Select>: RazorConsole не пробрасывает Tab/стрелки через этот механизм - Использовать FocusedValue вместо Value в Select для корректной подсветки - Обновить CLAUDE.md и AGENTS.md: архитектура TUI, RazorConsole gotchas - Добавить docs/tui_log.md: разбор проблемы и справочник по RazorConsole Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
17 KiB
TUI Keyboard Handling — разбор и решение
Проблема
После внедрения RazorConsole TUI не работали два базовых взаимодействия:
- Tab не переключал вкладки (Overview / Logs / Settings)
- Стрелки не навигировали по спискам
Диагностика
Что проверяли
Изучили исходную архитектуру: каждый дочерний компонент (OverviewTab, LogsTab, SettingsTab) рендерил <Select TItem="int"> из RazorConsole.Components с атрибутом @onkeydown="OnKeyDown". OnKeyDown — это EventCallback<KeyboardEventArgs>, пробрасываемый из App.razor.
Корневая причина — RazorConsole 0.5.0
Изучили пакет RazorConsole.Core 0.5.0 (XML-документация, структура NuGet):
-
@onkeydownна<Select>не работает для служебных клавиш.Selectполучает@onkeydownкак частьAdditionalAttributes. Стрелки и Enter компонент обрабатывает внутренне (переключаетFocusedValue), наружу через callback они не выходят. -
Tab перехватывается FocusManager до
<Select>. Внутренняя инфраструктура RazorConsole вызываетFocusManager.FocusNextAsync()при нажатии Tab ещё до того, как событие доходит до компонента. Наш хендлер вApp.razorне вызывался вообще. -
Глобального перехвата клавиш в 0.5.0 нет.
ConsoleAppOptionsсодержит только три поля (AutoClearConsole,EnableTerminalResizing,AfterRenderAsync) — никакихOnKeyPress/IKeyboardHandler. Публичного API для регистрации глобального обработчика не предусмотрено. -
Select.FocusedValuevsSelect.Value.Value/ValueChanged— «зафиксированный» выбор, обновляется только при подтверждении (Enter).FocusedValue/FocusedValueChanged— подсветка при навигации стрелками. Мы подписывались наValueChanged, поэтому даже если стрелки что-то делали внутри Select — наш стейт не обновлялся.
Решение
GlobalKeyboardService
Создали LazyBear.MCP/TUI/GlobalKeyboardService.cs — единственный источник клавишных событий для всего TUI. Реализует IHostedService (не BackgroundService), запускает выделенный фоновый поток с блокирующим Console.ReadKey(intercept: true).
Зарегистрирован как Singleton + IHostedService в Program.cs, чтобы можно было инжектить в компоненты:
services.AddSingleton<GlobalKeyboardService>();
services.AddHostedService(sp => sp.GetRequiredService<GlobalKeyboardService>());
App.razor
- Убраны
FocusManagerиOnAfterRenderAsync(FocusManager мешал: перехватывал Tab для собственногоFocusNextAsync). - Подписка на
KeyboardService.OnKeyPressedвOnInitialized, отписка вDispose. ConvertKey(ConsoleKeyInfo)конвертирует нажатие вKeyboardEventArgsс именами в стиле браузера ("ArrowUp","Tab"," "и т.д.).- Вся логика обработки (
HandleOverviewKey,HandleLogsKey,HandleSettingsKey) осталась без изменений — переехала изasync Taskв синхронныйvoid,StateHasChanged()вызывается один раз в конце черезInvokeAsync.
Дочерние компоненты
Из OverviewTab, LogsTab, SettingsTab:
- Удалён параметр
OnKeyDown - Удалён
@onkeydown="OnKeyDown"с<Select> - Добавлен
FocusedValue="@GetNormalizedIndex()"— чтобы индикатор>отображался рядом с текущим элементом, управляемым извне
Проблема производительности (тормоза)
Первая реализация GlobalKeyboardService использовала polling:
// ❌ Было — 100 вызовов/сек Console.KeyAvailable
while (!stoppingToken.IsCancellationRequested)
{
if (Console.KeyAvailable)
OnKeyPressed?.Invoke(Console.ReadKey(intercept: true));
else
await Task.Delay(10, stoppingToken);
}
Console.KeyAvailable под капотом захватывает консольный mutex каждые 10ms. RazorConsole для рендеринга тоже обращается к консоли — возникала конкуренция, UI тормозил.
Исправление — блокирующий поток без polling:
// ✓ Стало — выделенный поток, спит на уровне ОС между нажатиями
_thread = new Thread(ReadLoop) { IsBackground = true, Name = "KeyboardReader" };
void ReadLoop() {
while (!_stopping)
OnKeyPressed?.Invoke(Console.ReadKey(intercept: true)); // блокирует без mutex-а
}
Между нажатиями поток блокируется на уровне ОС, консольный mutex свободен, рендеринг не конкурирует ни за какой ресурс.
Итоговые изменения
| Файл | Что сделано |
|---|---|
TUI/GlobalKeyboardService.cs |
Создан. Блокирующий ReadKey в выделенном потоке |
Program.cs |
Регистрация GlobalKeyboardService как singleton + hosted service |
TUI/Components/App.razor |
Убран FocusManager. Подписка на GlobalKeyboardService. Sync key handler |
TUI/Components/OverviewTab.razor |
Убран OnKeyDown. Добавлен FocusedValue |
TUI/Components/LogsTab.razor |
Убран OnKeyDown. Добавлен FocusedValue |
TUI/Components/SettingsTab.razor |
Убран OnKeyDown. Добавлен FocusedValue |
TUI/Components/_Imports.razor |
Убран @using RazorConsole.Core.Focus |
InspectRazorConsole/ |
Временный проект для инспекции DLL (можно удалить) |
Вывод по RazorConsole 0.5.0
Фреймворк хорошо подходит для рендеринга (Spectre.Console под капотом), но клавишные события — слабое место. @onkeydown на интерактивных компонентах не даёт полного контроля: служебные клавиши перехватываются внутри. Для любого нетривиального TUI с собственной навигацией нужен независимый читатель консольного ввода — либо свой поток с Console.ReadKey, либо (если появится) публичный API глобального перехвата в будущих версиях фреймворка.
Ключевые моменты при работе с RazorConsole (0.5.0)
Справочник для написания кода и диагностики багов.
Архитектура и рендеринг
-
Рендеринг — Spectre.Console под капотом. Компоненты транслируются через VDOM-слой в
IRenderableобъекты Spectre. Всё, что умеет Spectre.Console (цвета, границы, таблицы, разметка), доступно через Razor-компоненты. -
AutoClearConsole = trueвConsoleAppOptions— обязательно, иначе при перерисовке вывод накапливается и экран «растёт». Устанавливается черезservices.Configure<ConsoleAppOptions>(...)внутриUseRazorConsole. -
EnableTerminalResizing = true— без этого изменение размера окна не триггерит перерисовку. Нужно для корректного поведенияConsole.WindowHeightв вычислениях viewport. -
AfterRenderAsync— хук после каждого рендера. Единственное место, где гарантированно можно записать в stdout после того, как RazorConsole завершил свой вывод. Используется, например, чтобы скрыть курсор (Console.CursorVisible = false+ ANSI\e[?25l). -
StateHasChanged()нужно вызывать черезInvokeAsyncпри вызове из фонового потока или подписчика события. Иначе — исключение о вызове не из диспетчера компонента:// ✓ из любого потока InvokeAsync(() => { /* изменить стейт */ StateHasChanged(); }); -
Частые
StateHasChanged→ видимые тормоза. Каждый вызов — полная перерисовка терминала (очистка + вывод). При высокочастотных событиях (потоковые логи, таймеры) нужна дебаунс-логика или throttle: планировать один рендер на «пакет» событий, а не по одному на каждое.
Компоненты
<Select TItem="T">
-
Value/ValueChanged— зафиксированный выбор. Обновляется только когда пользователь «подтверждает» (Enter). Не подходит для отслеживания позиции курсора в реальном времени. -
FocusedValue/FocusedValueChanged— текущая подсвеченная строка при навигации стрелками. Нужно использовать именно этот параметр для синхронизации позиции курсора с внешним стейтом. -
SelectedIndicator— символ рядом с текущейFocusedValue. Без явной установкиFocusedValueиндикатор может не отображаться или «застывать» на первом элементе. -
@onkeydownна<Select>не работает для служебных клавиш (Tab, Arrow, Enter, Space). Эти клавиши потребляются компонентом или FocusManager до того, как попадают в callback. Не использовать@onkeydownна<Select>для логики навигации. -
Прокрутка:
Selectне принимает параметрViewportRowsявно — он сам управляет отображением. Для явного ограничения viewport нужен<Scrollable<T>>или внешнее ограничение спискаOptions.
<Scrollable<T>>
- Управляет пагинацией и клавишной навигацией (стрелки, PageUp/Down, Home/End) самостоятельно.
ChildContentполучаетScrollContext<T>с видимыми элементами и (по документации) keyboard event handlers. Конкретные названия handler-ов в XML-документации не описаны — нужна инспекция DLL или исходники.PageSize— количество видимых элементов за раз (по умолчанию 1).
<TextInput>
OnInput— срабатывает на каждый символ. Подходит для захвата произвольных нажатий если нужен «ввод текста».OnSubmit— Enter.- Не использовать как скрытый «перехватчик» клавиш: он не перехватывает служебные клавиши (Tab, стрелки).
<TextButton>
OnClick— активируется Enter или пробелом когда кнопка в фокусе.- Нет события
OnFocus/IsFocusedChanged— нельзя реагировать на момент получения фокуса через Tab.
FocusManager
- Не использовать
FocusNextAsync()вOnAfterRenderAsyncбез условия — вызов после каждого рендера перемещает фокус по кругу, сбивая пользовательскую навигацию. - Tab глобально обрабатывается FocusManager —
FocusNextAsync()вызывается фреймворком автоматически. Повторный вызов в коде → двойное смещение фокуса. FocusManager.CurrentFocusKey— ключ текущего элемента в фокусе. Можно использовать для восстановления фокуса: сохранить ключ, после рендера вызватьFocusAsync(savedKey).- Если в интерфейсе нет ни одного фокусируемого компонента (
Select,TextInput,TextButton) — FocusManager не читает клавиши. Это позволяет безопасно взять чтение консоли на себя.
Ввод с клавиатуры (правила)
-
Один читатель консоли.
Console.ReadKeyне мультиплексируется — если FocusManager читает клавиши (есть хотя бы один фокусируемый компонент), свойReadKey-поток конкурирует с ним и теряет нажатия. -
Забрать ввод целиком можно убрав все фокусируемые компоненты и запустив собственный поток с
Console.ReadKey(intercept: true). Тогда FocusManager не активирует свой reader. -
Polling
Console.KeyAvailable— антипаттерн. Под Windows захватывает консольный mutex при каждой проверке. При частом опросе (< 50ms) конкурирует с рендером и вызывает заметные тормоза. Используй блокирующийConsole.ReadKeyв выделенном потоке. -
Нажатие из фонового потока →
InvokeAsync. После чтения клавиши и изменения стейта компонента всегда оборачивай вInvokeAsyncпередStateHasChanged.
Регистрация в DI
// Паттерн: сервис доступен и как синглтон для инжекции, и как IHostedService для запуска
services.AddSingleton<MyService>();
services.AddHostedService(sp => sp.GetRequiredService<MyService>());
Альтернатива AddHostedService<T>() регистрирует transient-экземпляр, который нельзя инжектить — для TUI-сервисов всегда использовать паттерн выше.
Отладка
- Логи не видны в терминале во время работы TUI — RazorConsole владеет stdout. Все
.NET-логи надо направлять вInMemoryLogSinkи отображать во вкладке Logs. - Для инспекции публичного API пакета без исходников: создать временный
net10.0-проект, загрузить DLL черезAssembly.LoadFromи перечислить типы/методы рефлексией. XML-документация пакета (.xmlрядом с.dllв NuGet-кеше) — первая точка поиска. Console.IsInputRedirected— проверять перед любым обращением кConsole.ReadKey/Console.KeyAvailable. В CI или при перенаправлении stdin эти вызовы бросают исключение.