v0.8.9 Responsive Keyboard & Layout Engine 6 мин 30 сентября 2026, 08:20 — 08:45

Акт XI: Тайна проглоченной буквы — как переключение раскладки съедало первый символ и как мы научили терминал слышать с полуслова

Инженерный детектив о пропаже первой буквы при смене языка (Alt+Shift, Ctrl+Shift, Win+Space): как Win32 меню SC_KEYMENU поглощало нажатия, почему Flutter сохранял фантомный Alt в HardwareKeyboard, приоритет печатных символов над залипшими флагами и мгновенный отклик терминала.

BUG-045 #keyboard #win32 #flutter #xterm #sc-keymenu #ghost-modifiers #layout-switching #v0.8.9
Стенограмма расследования: Тайна проглоченной буквы и двойного нажатия
30 сентября 2026, 07:54 — 08:17
Развернуть
Архитектор
скажи мне вот что, при переключении языка при работе в терминале, всегда теряется первый символ при наборе текста, то есть первую букву надо нажать два раза, чтобы после переключения языка она отобразилась в терминале. В чем причина. Пока просто проведи анализ и дай отчет
Antigravity
Диагностика вскрыла тройную причину: 1) Win32 DefWindowProc переводит окно в режим меню по Alt (SC_KEYMENU), поглощая следующее нажатие; 2) При потреблении хоткея переключения языка Windows не отправляет KeyUp для Alt/Ctrl, и HardwareKeyboard сохраняет фантомный флаг модификатора; 3) Терминальный обработчик преждевременно отбрасывал печатные символы из-за проверки isCtrl || isAlt.
Архитектор
/plan
Архитектор
внедряй
Архитектор
прекрасно! работает.
BUG-045 BUG-045: Устранение потери первого символа — блокировка SC_KEYMENU и приоритет печатных рун
Анатомия инцидента безопасности
Поглощение символа (Было)
// win32_window.cpp: Alt активирует меню
// SC_KEYMENU не перехватывался -> поглощение клавиши!
return DefWindowProc(window_handle_, message, wparam, lparam);

// terminal_screen.dart:
if (isCtrl || isAlt) {
  return KeyEventResult.ignored; // Фантомный Alt блокирует печать!
}
Мгновенный отклик (Стало)
// win32_window.cpp: Блокировка модального меню Alt
case WM_SYSCOMMAND:
  if (wparam == SC_KEYMENU) return 0;
  break;

// terminal_screen.dart: Приоритет печатных символов
if (char != null && isPrintable) {
  _terminal.textInput(char); // Мгновенный вывод первой буквы!
  return KeyEventResult.handled;
}
Вердикт аудита: Блокировка SC_KEYMENU в оконной процедуре защищает от перехвата клавиши самой Windows, а приоритизация печатных Unicode-рун над фантомными модификаторами гарантирует моментальную доставку первой буквы в сессию терминала.
Контекст акта: Хроническая эргономическая проблема эмулятора терминала на Windows: при каждом переключении языка ввода комбинацией клавиш (Alt+Shift, Ctrl+Shift, Win+Space) первая нажатая буква бесследно пропадала, заставляя пользователя нажимать её дважды. Расследование вскрыло двойной заговор платформы: переход окна в системное меню Win32 по отпусканию Alt (SC_KEYMENU) и фантомное залипание флагов модификаторов в HardwareKeyboard Flutter из-за глобального перехвата хоткеев ядром ОС. Проблема решена на обоих уровнях, вернув терминалу мгновенную отзывчивость.

Запись 53. Тайна проглоченной буквы: как переключение раскладки съедало первый символ и как мы научили терминал слышать с полуслова

⏱️ Время: 30 сентября 2026, 08:20 — 08:45 (~25 минут)

1. Повод: когда первую букву приходится нажимать дважды

Каждый, кто регулярно работает в консоли и переключается между русским и английским языками, знает эту раздражающую микро-паузу: нажал Alt + Shift, хочешь набрать команду ls или написать комментарий на русском — жмёшь первую букву, а в терминале… тишина. Жмёшь её второй раз — и только тогда она нехотя появляется на экране.

Поначалу на это можно махнуть рукой: показалось, не дожал клавишу, случайная задержка. Но когда дефект воспроизводится абсолютно стабильно при каждом переключении раскладки, ощущение рабочего комфорта испаряется. В эмуляторе терминала отзывчивость обязана быть образцовой: если палец коснулся клавиши, символ должен оказаться в буфере без каких-либо заминок.

Мы решили докопаться до фундаментальной причины: почему теряется именно первый символ и почему для его ввода обязательно требуется ровно два нажатия?


2. Подводные камни: три заговора против первого нажатия

Когда мы разложили цепочку обработки клавиш на составляющие, наружу выплыл целый комплекс скрытых механизмов Win32 API и внутреннего состояния Flutter Engine:

Коварство Win32 и меню по Alt (SC_KEYMENU)

В классической архитектуре Windows одиночное нажатие и отпускание клавиши Alt переводит окно в режим системного меню (WM_SYSCOMMAND с параметром SC_KEYMENU). В этом режиме операционная система считает, что следующее нажатие клавиши — это выбор пункта меню (мнемоника).

Когда пользователь переключал раскладку по Alt + Shift, Windows успевала активировать этот режим. Первое последующее нажатие любой буквы операционная система перехватывала на уровне оконной процедуры DefWindowProc, пыталась найти соответствующий пункт меню, не находила его, молча закрывала режим меню и проглатывала нажатие. До приложения символ даже не долетал!

Фантомный Alt в памяти Flutter (HardwareKeyboard)

Поскольку системные комбинации переключения языка (Alt + Shift, Ctrl + Shift, Win + Space) перехватываются операционной системой глобально на уровне ядра ввода, Windows очень часто не отправляет сообщение WM_KEYUP для клавиши-модификатора в очередь конкретного окна.

В итоге в оперативной памяти Flutter подсистема HardwareKeyboard продолжала считать, что Alt (или Ctrl) всё ещё физически зажат (isAltPressed == true).

Преждевременная маршрутизация шорткатов

В нашем терминальном экране стоял предохранительный фильтр: если зажат Ctrl или Alt, событие отдавалось встроенным шорткатам xterm (KeyEventResult.ignored), чтобы не мешать комбинациям вроде Ctrl+C или Alt+F.

Видя залипший флаг isAlt == true, наш обработчик ошибочно решал, что пользователь нажал комбинацию Alt + [Буква], и полностью блокировал прямой ввод текста!

Почему же срабатывало второе нажатие?

Во время первого «холостого» нажатия происходили два события: Windows выходила из режима системного меню, а Flutter Engine сверял битовую маску реального состояния клавиатуры и сбрасывал фантомный флаг модификатора. Ко второму нажатию система возвращалась в чистое состояние — и символ наконец-то попадал в терминал.


3. Исправление и стабилизация (BUG-045)

Для надежного устранения проблемы мы применили двухфакторную защиту: на уровне C++ раннера Windows и на уровне логики терминального экрана в Dart:

1. Блокировка SC_KEYMENU в нативном окне (win32_window.cpp)

В оконной процедуре Win32Window::MessageHandler мы перехватили системное сообщение WM_SYSCOMMAND:

case WM_SYSCOMMAND:
// Блокируем вход в модальный цикл системного меню по Alt (SC_KEYMENU),
// чтобы Windows не перехватывала последующий ввод символов
if (wparam == SC_KEYMENU) {
return 0;
}
break;

Возврат 0 сообщает Windows, что системное меню обрабатывать не нужно. Это стандартный отраслевой прием для профессиональных эмуляторов терминалов (Windows Terminal, Alacritty) и игр.

2. Приоритет печатных символов над фантомными модификаторами (terminal_screen.dart)

В обработчике _handleTerminalKeyEvent мы выстроили строгую иерархию:

  1. Специальные навигационные клавиши (Enter, Tab, Backspace, стрелки, Delete) безусловно передаются в движок xterm.
  2. Если событие содержит валидный печатный символ (руны $\ge 32$ и $\ne 127$), мы отдаем безусловный приоритет печати текста:
    • Любой не-ASCII символ (например, русские буквы п, ф после смены языка) гарантированно является текстом, так как в терминалах в принципе не существует кириллических управляющих кодов.
    • Если активен Alt без Ctrl — в Windows Alt в одиночку никогда не производит печатных символов. Наличие символа однозначно указывает на то, что Alt уже отпущен, а флаг в памяти — фантом.
    • При этом классические сочетания (Ctrl+C, Ctrl+D, Ctrl+Z, Ctrl+L) и европейский AltGr (@, €, ~) продолжают работать штатно.

4. Регрессионные тесты и верификация

В тестовый набор terminal_screen_test.dart добавлены юнит-тесты, моделирующие залипание модификаторов altLeft и controlLeft при смене языка. Тесты подтверждают, что и латиница ('a'), и кириллица ('п', 'ф') передаются в сессию терминала с самого первого нажатия.

  • Зарегистрирован и проверен дефект BUG-045 (P1 / VERIFIED).
  • Все 128 из 128 тестов пакета terminal_ui успешно пройдены.
  • Статический анализ flutter analyze подтвердил 0 замечаний.

Теперь переключение раскладки в Shellit работает абсолютно незаметно: терминал откликается на первое же касание клавиши, на каком бы языке вы ни начали набирать команду.

📖 Типографика статьи выровнена по алгоритму Кнута — Пласса с помощью mcp-typography-server на базе Justif.
Knuth-Plass • TeX Style