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>
This commit is contained in:
192
docs/tui_log.md
Normal file
192
docs/tui_log.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# 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 эти вызовы бросают исключение.
|
||||
Reference in New Issue
Block a user