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

193 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`, чтобы можно было инжектить в компоненты:
```csharp
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:
```csharp
// ❌ Было — 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:
```csharp
// ✓ Стало — выделенный поток, спит на уровне ОС между нажатиями
_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`** при вызове из фонового потока или подписчика события. Иначе — исключение о вызове не из диспетчера компонента:
```csharp
// ✓ из любого потока
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 не читает клавиши. Это позволяет безопасно взять чтение консоли на себя.
### Ввод с клавиатуры (правила)
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
```csharp
// Паттерн: сервис доступен и как синглтон для инжекции, и как 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 эти вызовы бросают исключение.