ESPN DeportesEl primer Juego 7 en la historia de la WNBA decidirá el primer título para Dream o ValkyriesESPNNHL Tonight: What to watch in all 10 games on HalloweenInquirerLOOK: Iligan City’s holy, non-spooky HalloweenThe Jerusalem PostThe West Bank is the historic heartland of the Jewish people - opinionColliderTimothée Chalamet's Darkest Crime Thriller Officially Arrives on Free StreamingVarietyAlex Gibney-Produced ‘Mohabat,’ Directed by James Longley, Wins at Ji.hlava: ‘Meticulously Researched and Exquisitely Crafted’ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioRai NewsTG2 Post del 31/10/2026Complete SportsEx-Black Stars Coach Otto Addo Drags Ghana FA To FIFA Over Unpaid Wages, BonusesNOSVooral minderjarigen aangehouden in Den Haag, grote incidenten uitgebleven3DNewsM**a тайно создала прототип человекоподобного робота для дома — помогли китайцыPolsat NewsPolacy znali go z "Króla Lwa". Nie żyje aktor Paweł Tucholski
The Daily Newsstand · Free, Always
Saturday, October 31, 2026

Раскладку нельзя определить. Можно только сделать ошибку обратимой

Translate
Определить ошибку нельзя. Можно гарантировать, что откат стоит одно нажатие

Определить ошибку нельзя. Можно гарантировать, что откат стоит одно нажатие

Каждый русскоязычный разработчик когда-нибудь набирал ghbdtn. Это привет с неверной раскладкой — те же шесть физических клавиш, прочитанные другой таблицей. Задача выглядит на один вечер, и часть про конвертацию действительно такая: собрать таблицу символов, применить, готово.

Интересное — в вопросе, на который нужно ответить до конвертации. И ответа у него нет:

Была ли это ошибка?

Потому что ghbdtn — ещё и совершенно валидный идентификатор. Это может быть плохо названная переменная, префикс хеша, ветка в гите, кусок пароля, колонка в легаси-схеме. Ничто в тексте не отличает «я имел в виду привет, и у меня была не та раскладка» от «я имел в виду ровно эти буквы». Нажатия клавиш идентичны. Нужная информация никогда не лежала в буфере — она была в голове у человека в момент набора и не сохранилась.

Я написал под это плагин для IntelliJ (keyboard-magic), и решение, которое сделало его пригодным к использованию, — то самое, которое я бы экспортировал в другие задачи:

Когда намерение восстановить нельзя — перестань его выводить. Сделай операцию инволюцией, гарантированно обратной самой себе, чтобы неверная догадка стоила ровно одно нажатие.

Уверенность недостижима. Дешёвый откат — вполне.

Почему очевидные решения хуже, чем ничего

Инстинкт — поумнеть в детекции. Я прошёл по вариантам; каждый ломается в одну и ту же сторону.

Словарь. Сконвертировать, проверить, получилось ли настоящее слово, конвертировать только если да. В IDE — а плагин живёт именно там — ломается сразу: большая часть текста не является словами ни на одном языке. ghbdtn вполне может быть переменной. А end превращается в утв, которое не слово, — и словарная проверка откажется конвертировать ровно тот случай, когда ты нажал хоткей осознанно.

Частоты биграмм. Посчитать статистику пар букв для обеих интерпретаций, выбрать вероятнейшую. Хорошо работает на абзаце, бесполезно на фрагменте в 4–6 символов, а это и есть реальная единица. У коротких строк почти нет сигнала, и режим отказа молчаливый: оно просто выбирает одно из двух и иногда ошибается, а ты не можешь понять, когда именно.

Порог уверенности. Конвертировать, только когда детектор уверен. Теперь хоткей иногда работает, а иногда молча не делает ничего — худший возможный дизайн взаимодействия для клавиатурного сокращения. Инструмент, отвечающий на один и тот же жест по-разному, отучает собой пользоваться.

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

Дизайн: определять алфавит, гарантировать инволюцию

Плагин вообще не спрашивает «была ли ошибка?». Он задаёт вопрос, у которого ответ есть — «каким алфавитом написан этот текст?» — и дальше гарантирует обратимость.

/** Converts [text] to the other layout, guessing the source layout from the text itself. */
fun switchLayout(text: String): String = convert(text, detectLayout(text))

А детектор нарочито, почти агрессивно туп:

fun detectLayout(text: String): KeyboardLayout {
    var cyrillic = 0
    var latin = 0
    for (ch in text) {
        when {
            isCyrillic(ch) -> cyrillic++
            isLatin(ch) -> latin++
        }
    }
    return if (cyrillic > latin) KeyboardLayout.RU else KeyboardLayout.EN
}

Посчитать кириллицу, посчитать латиницу, победило большинство, EN при ничьей и на пустом множестве. Это всё.

Два решения здесь несущие.

Пунктуация и цифры игнорируются. Они есть в обеих раскладках, значит не несут информации о том, в каком ты алфавите, — учитывать их означало бы подмешивать шум в единственный имеющийся сигнал. В doc-комментарии сказано прямо: «Punctuation and digits are ignored: they exist in both layouts and would only add noise.»

EN — фолбэк, поэтому функция тотальна. Фрагмент вообще без букв всё равно конвертируется, и конвертируется детерминированно. Ветки «я не знаю» нет, а значит нет и пути, на котором хоткей не делает ничего. Это важнее правильности: предсказуемый инструмент лучше точного, когда цена ошибки — одно нажатие.

Обрати внимание, чего эта функция не делает. Она не определяет ошибку. Она определяет алфавит. Выделишь корректно набранное привет и нажмёшь хоткей — получишь ghbdtn, и это корректное поведение, а не баг: ты сам попросил. У плагина нет мнения о том, стоило ли.

Инволюция — свойство таблицы, а не алгоритма

Чтобы «нажми ещё раз и откатится» было гарантией, а не надеждой, switch(switch(x)) == x должно выполняться для каждого символа. Это нельзя утверждать в месте вызова — это должно быть структурным. Поэтому таблица строится от физической клавиатуры:

private val KEY_ROWS: List<Pair<String, String>> = listOf(
    "`1234567890-="   to "ё1234567890-=",
    "~!@#\$%^&*()_+"  to "Ё!\"№;%:?*()_+",
    "qwertyuiop[]\\"  to "йцукенгшщзхъ\\",
    "QWERTYUIOP{}|"   to "ЙЦУКЕНГШЩЗХЪ/",
    "asdfghjkl;'"     to "фывапролджэ",
    "ASDFGHJKL:\""    to "ФЫВАПРОЛДЖЭ",
    "zxcvbnm,./"      to "ячсмитьбю.",
    "ZXCVBNM<>?"      to "ЯЧСМИТЬБЮ,",
)

Четыре ряда клавиш, каждый выписан дважды — без шифта и с шифтом. Каждая физическая клавиша даёт две пары, и поскольку источник правды — клавиши, а не символы, получающиеся таблицы являются биекцией по построению. Обе стороны собираются одним циклом из одних и тех же рядов, поэтому разъехаться не могут.

Я пересобрал эту таблицу из исходника и проверил: 94 символа в каждую сторону, и каждый round-trip-ится. Без исключений и особых случаев.

И есть охранная строка, которую хочется выделить отдельно, — из тех, что экономят отладочную сессию, от которой никто не получил бы удовольствия:

require(enRow.length == ruRow.length) { "Misaligned layout row: '$enRow' / '$ruRow'" }

Ряды сопоставляются по индексу. Если кто-то добавит клавишу в одну сторону и забудет про другую, каждый символ после этой позиции молча сопоставится не с тем партнёром: таблица останется валидной мапой, код продолжит работать, плагин продолжит «работать» — и тихо портить текст. require превращает это в падение на инициализации класса, с печатью виноватого ряда. Когда корректность структуры данных держится на инварианте, который не выражается типами, проверяй его там, где структура строится.

Та самая штука с пунктуацией, из-за которой будет спор

Самое интересное следствие поклавишного отображения:

"ghbdtn, vbh!"  ->  "приветб мир!"
"ghbdtn? vbh!"  ->  "привет, мир!"
Запятая — не запятая. Это клавиша, и в двух раскладках она значит разное

Запятая — не запятая. Это клавиша, и в двух раскладках она значит разное

, отображается в б, а запятая в русском набирается клавишей, на которой в английском живёт ?. То есть запятая — не запятая. Это клавиша, и в двух раскладках она значит разное.

Из-за чего важна вторая строка — она и показывает, что дизайн верен: когда ты реально набираешь «привет, мир!» с включённой не той раскладкой, запятую ты жмёшь клавишей ?. На экране оказывается ghbdtn? vbh!, и конвертация даёт привет, мир! — вместе с пунктуацией, идеально.

Первая строка тоже не баг. В ghbdtn, vbh! лежит настоящая ,, пришедшая с настоящей клавиши, а эта клавиша — б. Отображение верно клавиатуре, а клавиатура — это то, что на самом деле произошло.

На этом стоит задержаться, потому что именно эту разницу путают, когда пытаются «починить»:

Таблица — биекция над клавишами, а не над смыслами. Запятая одной раскладки не является запятой другой, и сопоставление двух запятых друг другу сломало бы инволюцию — то самое свойство, на котором держится весь дизайн.

Любой частный случай, отображающий , → ,, отнимает гарантию round-trip у ? и б. Ты меняешь свойство, которое выполняется всегда, на интуицию, которая выполняется иногда. Плохой обмен.

Где были настоящие баги: в редакторе, а не в отображении

Таблица заняла вечер. Экшен — существенно больше, и каждая проблема была про редактор, а не про раскладки.

Какой текст конвертировать

При выделении — очевидно, выделение. Без выделения — слово под кареткой, и «слово под кареткой» надо определить, потому что жмёшь ты это прямо посреди набора:

fun findWordRange(text: CharSequence, offset: Int): TextRange? {
    if (text.isEmpty()) return null
    val caret = offset.coerceIn(0, text.length)

    var start = caret
    while (start > 0 && isWordChar(text[start - 1])) start--

    var end = caret
    while (end < text.length && isWordChar(text[end])) end++

    return if (start == end) null else TextRange(start, end)
}

private fun isWordChar(ch: Char): Boolean = ch.isLetterOrDigit() || ch == '_'

Расширение в обе стороны от каретки означает, что каретка, касающаяся любого края слова, это слово выбирает: работает и когда ты только что дописал (каретка после слова), и когда кликнул перед ним. Два разных пользовательских жеста обслужены одним правилом, а альтернатива — требовать каретку строго внутри — ломается ровно в частом случае, то есть в момент, когда ты замечаешь ошибку, — сразу после набора.

Символы слова исключают пунктуацию намеренно, и компромисс записан в самом комментарии кода: end; конвертируется как end и сохраняет точку с запятой, что в исходниках правильно. Цена — ,jkm (это боль) от голой каретки конвертируется только в оль, потому что ведущая запятая в слово не входит. Такое выделяют руками. Дефолт, верный в коде и слегка неверный в прозе, — правильный дефолт для плагина, живущего в IDE.

Две каретки в одном слове гасят друг друга

В IntelliJ есть мультикаретка, и вот это по-настоящему забавный баг. Две каретки внутри одного слова обе разрешаются в один и тот же TextRange. Конвертируем дважды — и, поскольку операция является инволюцией, получаем исходный текст. Экшен как будто не делает ничего, и не делает он ничего ровно по той причине, которая делает дизайн хорошим.

val seen = HashSet<TextRange>()
return carets.mapNotNull { caret ->
    val range = when {
        caret.hasSelection() -> TextRange(caret.selectionStart, caret.selectionEnd)
        else -> WordFinder.findWordRange(text, caret.offset)
    }
    // Two carets inside the same word would otherwise convert it twice and undo themselves.
    if (range == null || range.isEmpty || !seen.add(range)) null
    else Edit(caret, range, caret.hasSelection())
}

Правки применять справа налево

// Right to left, so the offsets of the ranges not processed yet stay valid.
for (edit in edits.sortedByDescending { it.range.startOffset }) {
    val converted = layoutService.switchLayout(document.getText(edit.range))
    document.replaceString(edit.range.startOffset, edit.range.endOffset, converted)
    ...
}

Вот это универсально и стоит украсть независимо от того, что ты пишешь. Любой пакет правок, посчитанный по снимку, применяется с конца документа назад, потому что правка на смещении 100 инвалидирует все смещения после неё. Здесь замены случайно одинаковой длины, так что сработало бы и так, — и именно поэтому делать правильно стоит всё равно: это корректно по построению, а не по совпадению, а совпадение живёт ровно до следующей фичи.

Рядом: диапазоны собираются до входа в write action, потому что каретки надо читать из документа, который под тобой не изменяется.

Один Ctrl+Z, иначе инструмент бесполезен

WriteCommandAction.runWriteCommandAction(e.project, COMMAND_NAME, null, { ... })

Всё происходит внутри одной команды, поэтому Ctrl+Z откатывает целиком за один шаг. Это не деталь. Инструмент-исправлялка, откат которого занимает пять нажатий, — это инструмент, которому ты перестаёшь доверять, причём перестаёшь сразу после первого раза, когда откатил три конвертации из пяти и сидишь выясняешь, в каком ты состоянии.

И последний штрих, благодаря которому «нажми ещё раз» реально работает:

// Re-selecting keeps the fragment under the caret, so the next press converts it back.
if (edit.hadSelection) {
    edit.caret.setSelection(edit.range.startOffset, edit.range.endOffset)
}

Инволюция — свойство функции. Восстановление выделения — то, что делает её свойством взаимодействия. Без него математическая гарантия существует, а пользователь до неё не дотягивается.

Три вещи про платформу IntelliJ, стоившие мне времени

Не про это статья, но такого нигде не написано, и оно само по себе окупает вход.

Версия Kotlin API должна совпадать с той, что везёт IDE, а не с той, что стоит у тебя. IDEA 2022.2 поставляется со стандартной библиотекой Kotlin 1.6, поэтому в сборке стоит apiVersion = 1.6. Без этого компилятор радостно примет вызовы из более новой stdlib, а плагин умрёт в рантайме внутри IDE, где такого метода нет. Пиннинг версии API превращает NoSuchMethodError в чужой IDE в ошибку компиляции на твоей машине — обмен, который нужен всегда.

platformLocalPath падает под C:\Program Files. Указать сборке на установленную IDE вместо скачивания SDK можно, только если каталог установки доступен на запись: gradle-intellij-plugin кладёт рядом с IDE Ivy-дескриптор. Под Program Files это «Could not write to file», и формулировка ошибки на причину не указывает. В репозитории SDK по умолчанию скачивается именно поэтому.

Твой дефолтный хоткей уже занят. Ctrl+Shift+R — это Replace in Files, Cmd+Shift+R на macOS — это Run…. IDEA не отказывает, она показывает диалог выбора — то есть первое впечатление от плагина — модалка с просьбой выбрать. По-настоящему свободных сочетаний в современной IDE нет, поэтому честный ход — задокументировать конфликт и объяснить, как перевесить, что README и делает.

Обобщение

То, к чему я с тех пор постоянно возвращаюсь, — вообще не про клавиатуры:

Когда вход по-настоящему неоднозначен, вариантов два. Угадывать хорошо и прятать это — или угадывать дёшево и сделать исправление бесплатным. Второе почти всегда лучше и почти никогда не строится.

Угадывать хорошо — интересная инженерная задача. Там живут словарь, частотная модель и порог. И получается инструмент, который прав в 95% случаев и молча неправ в 5%, причём пользователь не может предсказать, в каких.

Угадывать дёшево — большинство букв, EN при ничьей — даёт инструмент, который прав чаще всего и тривиально поправим в остальных случаях, потому что поправка — то же самое нажатие, которое всё и вызвало. Пользователю вообще не нужно знать, как сделана догадка. Он просто жмёт ещё раз.

Решение на 95% оптимизирует не ту переменную. Оно оптимизирует, как часто ты прав, — тогда как приятность инструмента определяется тем, сколько стоит быть неправым.

Где я искренне хочу возражений: я объявил детекцию неразрешимой и остановился на подсчёте букв. Кто-нибудь справедливо заметит, что IDE знает сильно больше, чем буфер, — PSI-дерево, находится ли каретка в строковом литерале или в идентификаторе, язык проекта, во что резолвится символ. Исправлялка, конвертирующая внутри строк и комментариев и никогда — внутри идентификаторов, была бы права чаще, и платформа ровно для такого и сделана.

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

Вы бы взяли контекстно-зависимую версию, если бы она была права в 99% и непредсказуема в остальном, — или «всегда делает одно и то же, всегда откатывается одним нажатием» здесь стоит дороже точности?

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.