Files
LazyBearWorks/docs/tui_log.md
Shahovalov MIkhail 9b27cd7dc2 fix: исправить навигацию клавиатуры в TUI через GlobalKeyboardService
- Добавить 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>
2026-04-13 23:31:53 +03:00

17 KiB
Raw Permalink Blame History

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):

  1. @onkeydown на <Select> не работает для служебных клавиш. Select получает @onkeydown как часть AdditionalAttributes. Стрелки и Enter компонент обрабатывает внутренне (переключает FocusedValue), наружу через callback они не выходят.

  2. Tab перехватывается FocusManager до <Select>. Внутренняя инфраструктура RazorConsole вызывает FocusManager.FocusNextAsync() при нажатии Tab ещё до того, как событие доходит до компонента. Наш хендлер в App.razor не вызывался вообще.

  3. Глобального перехвата клавиш в 0.5.0 нет. ConsoleAppOptions содержит только три поля (AutoClearConsole, EnableTerminalResizing, AfterRenderAsync) — никаких OnKeyPress / IKeyboardHandler. Публичного API для регистрации глобального обработчика не предусмотрено.

  4. Select.FocusedValue vs Select.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 глобально обрабатывается FocusManagerFocusNextAsync() вызывается фреймворком автоматически. Повторный вызов в коде → двойное смещение фокуса.
  • FocusManager.CurrentFocusKey — ключ текущего элемента в фокусе. Можно использовать для восстановления фокуса: сохранить ключ, после рендера вызвать FocusAsync(savedKey).
  • Если в интерфейсе нет ни одного фокусируемого компонента (Select, TextInput, TextButton) — FocusManager не читает клавиши. Это позволяет безопасно взять чтение консоли на себя.

Ввод с клавиатуры (правила)

  1. Один читатель консоли. Console.ReadKey не мультиплексируется — если FocusManager читает клавиши (есть хотя бы один фокусируемый компонент), свой ReadKey-поток конкурирует с ним и теряет нажатия.

  2. Забрать ввод целиком можно убрав все фокусируемые компоненты и запустив собственный поток с Console.ReadKey(intercept: true). Тогда FocusManager не активирует свой reader.

  3. Polling Console.KeyAvailable — антипаттерн. Под Windows захватывает консольный mutex при каждой проверке. При частом опросе (< 50ms) конкурирует с рендером и вызывает заметные тормоза. Используй блокирующий Console.ReadKey в выделенном потоке.

  4. Нажатие из фонового потока → 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 эти вызовы бросают исключение.