From 9a7506619a9594dd5f26bbb673b891e130b68add Mon Sep 17 00:00:00 2001 From: Alexey <4681325+qqrm@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:44:43 +0300 Subject: [PATCH] Document required checks in agent guide --- AGENTS.md | 11 ++ src/domain/text/convert.rs | 49 ++++++- src/domain/text/last_word.rs | 35 +++-- src/domain/text/mapping.rs | 202 +++++++++++++++----------- src/tests/mapping_invariants_tests.rs | 16 +- 5 files changed, 211 insertions(+), 102 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ec3428..46d1a54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,3 +120,14 @@ cargo +nightly test test_name -- --nocapture ``` Test modules cover: config I/O, config validation, hotkey formatting, keyboard sequences, character mapping invariants, ring buffer. + +## Agent Workflow Requirements + +When making changes in this repository, always: + +- Run formatting, linting, build, and tests before reporting results: + - `cargo +nightly fmt --check` + - `cargo +nightly clippy --all-targets --all-features -- -D warnings` + - `cargo +nightly build --features debug-tracing` + - `cargo +nightly test --locked` +- Address and fix any findings from these checks before finalizing work. diff --git a/src/domain/text/convert.rs b/src/domain/text/convert.rs index 95d889c..aed6656 100644 --- a/src/domain/text/convert.rs +++ b/src/domain/text/convert.rs @@ -1,6 +1,6 @@ use std::{ptr::null_mut, thread, time::Duration}; -use mapping::convert_ru_en_bidirectional; +use mapping::{ConversionDirection, conversion_direction_for_text, convert_ru_en_with_direction}; use windows::Win32::{ Foundation::{HWND, LPARAM, WPARAM}, System::DataExchange::GetClipboardSequenceNumber, @@ -159,7 +159,10 @@ fn convert_selection_from_text( ) -> Result<(), ConvertSelectionError> { let delay_ms = crate::helpers::get_edit_u32(state.edits.delay_ms).unwrap_or(100); - let converted = convert_ru_en_bidirectional(text); + let direction = conversion_direction_for_text(text) + .or_else(expected_direction_for_foreground_window) + .unwrap_or(ConversionDirection::RuToEn); + let converted = convert_ru_en_with_direction(text, direction); let converted_units = converted.encode_utf16().count(); thread::sleep(Duration::from_millis(u64::from(delay_ms))); @@ -222,6 +225,48 @@ fn current_layout_for_window(fg: HWND) -> HKL { } } +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum LayoutLanguage { + English, + Russian, +} + +const LANG_ID_PRIMARY_MASK: u16 = 0x03ff; +const LANG_ENGLISH: u16 = 0x0009; +const LANG_RUSSIAN: u16 = 0x0019; + +fn lang_id_from_hkl(hkl: HKL) -> u16 { + (hkl.0 as usize & 0xffff) as u16 +} + +fn primary_lang_id(lang_id: u16) -> u16 { + lang_id & LANG_ID_PRIMARY_MASK +} + +/// Returns the active language of the layout (based on LANGID in HKL). +fn active_layout_language(hkl: HKL) -> Option { + match primary_lang_id(lang_id_from_hkl(hkl)) { + LANG_RUSSIAN => Some(LayoutLanguage::Russian), + LANG_ENGLISH => Some(LayoutLanguage::English), + _ => None, + } +} + +/// Returns the expected conversion direction for a given keyboard layout. +fn expected_direction_for_layout(hkl: HKL) -> Option { + match active_layout_language(hkl) { + Some(LayoutLanguage::Russian) => Some(ConversionDirection::RuToEn), + Some(LayoutLanguage::English) => Some(ConversionDirection::EnToRu), + None => None, + } +} + +pub(crate) fn expected_direction_for_foreground_window() -> Option { + let fg = foreground_window()?; + let layout = current_layout_for_window(fg); + expected_direction_for_layout(layout) +} + /// Enumerates installed keyboard layouts for the current desktop. /// /// Returns an empty vector when enumeration fails or yields no results. diff --git a/src/domain/text/last_word.rs b/src/domain/text/last_word.rs index 2e552d1..f20c621 100644 --- a/src/domain/text/last_word.rs +++ b/src/domain/text/last_word.rs @@ -13,7 +13,11 @@ use windows::Win32::UI::{ Input::KeyboardAndMouse::VIRTUAL_KEY, WindowsAndMessaging::GetForegroundWindow, }; -use super::{mapping::convert_ru_en_bidirectional, switch_keyboard_layout, wait_shift_released}; +use super::{ + convert::expected_direction_for_foreground_window, + mapping::{ConversionDirection, conversion_direction_for_text, convert_ru_en_with_direction}, + switch_keyboard_layout, wait_shift_released, +}; use crate::{ app::AppState, conversion::input::{KeySequence, send_text_unicode}, @@ -29,6 +33,13 @@ const MIN_CONFIDENCE_GAIN: f64 = 0.25; static AUTOCONVERT_IN_PROGRESS: AtomicBool = AtomicBool::new(false); +fn convert_with_layout_fallback(text: &str) -> String { + let direction = conversion_direction_for_text(text) + .or_else(expected_direction_for_foreground_window) + .unwrap_or(ConversionDirection::RuToEn); + convert_ru_en_with_direction(text, direction) +} + pub fn convert_last_word(state: &mut AppState) { convert_last_word_impl(state, true); } @@ -277,7 +288,7 @@ fn autoconvert_candidate(p: &LastWordPayload) -> Result { let (word_core, word_punct) = split_trailing_convertible_punct(&p.word); - let converted_core = convert_ru_en_bidirectional(word_core); + let converted_core = convert_with_layout_fallback(word_core); let mut converted = String::with_capacity(converted_core.len() + word_punct.len()); converted.push_str(&converted_core); @@ -505,7 +516,7 @@ fn convert_last_word_impl(state: &mut AppState, switch_layout: bool) { return; } - let converted = convert_ru_en_bidirectional(&payload.word); + let converted = convert_with_layout_fallback(&payload.word); tracing::trace!(%converted, "converted"); if let Err(err) = apply_last_word_replacement(&payload, &converted) { @@ -677,7 +688,7 @@ mod tests { let detector = detector_ru_en(); let word = "привет"; - let converted = convert_ru_en_bidirectional(word); + let converted = convert_with_layout_fallback(word); assert_eq!(converted, "ghbdtn"); let decision = should_autoconvert_word(&detector, word, &converted); @@ -692,7 +703,7 @@ mod tests { let detector = detector_ru_en(); let word = "ghbdtn"; - let converted = convert_ru_en_bidirectional(word); + let converted = convert_with_layout_fallback(word); assert_eq!(converted, "привет"); match should_autoconvert_word(&detector, word, &converted) { @@ -708,7 +719,7 @@ mod tests { let detector = detector_ru_en(); let word = "world"; - let converted = convert_ru_en_bidirectional(word); + let converted = convert_with_layout_fallback(word); assert_ne!(converted, word); let decision = should_autoconvert_word(&detector, word, &converted); @@ -723,7 +734,7 @@ mod tests { let detector = detector_ru_en(); let word = "rfr"; // "как" - let converted = convert_ru_en_bidirectional(word); + let converted = convert_with_layout_fallback(word); assert_eq!(converted, "как"); let decision = should_autoconvert_word(&detector, word, &converted); @@ -738,7 +749,7 @@ mod tests { let detector = detector_ru_en(); let word = ";tklf"; // starts with punctuation, should fail script heuristics - let converted = convert_ru_en_bidirectional(word); + let converted = convert_with_layout_fallback(word); assert_ne!(converted, word); let decision = should_autoconvert_word(&detector, word, &converted); @@ -774,7 +785,7 @@ mod tests { let detector = detector_ru_en(); let word = "hellp"; - let converted = convert_ru_en_bidirectional(word); + let converted = convert_with_layout_fallback(word); assert_ne!(converted, word); let decision = should_autoconvert_word(&detector, word, &converted); @@ -786,7 +797,7 @@ mod tests { let detector = detector_ru_en(); let word = "hjyxnmyuj"; - let converted = convert_ru_en_bidirectional(word); + let converted = convert_with_layout_fallback(word); assert_eq!(converted, "рончтьнго"); match should_autoconvert_word(&detector, word, &converted) { @@ -821,7 +832,7 @@ mod tests { ]; for (word, should_convert) in cases { - let converted = convert_ru_en_bidirectional(word); + let converted = convert_with_layout_fallback(word); let decision = should_autoconvert_word(&detector, word, &converted); match (should_convert, decision) { @@ -847,7 +858,7 @@ mod tests { assert_eq!(p.word.as_str(), "ghbdtn,"); assert_eq!(p.suffix.as_str(), " \t"); - let converted = convert_ru_en_bidirectional(&p.word); + let converted = convert_with_layout_fallback(&p.word); let decision = should_autoconvert_word(&detector, &p.word, &converted); assert!( diff --git a/src/domain/text/mapping.rs b/src/domain/text/mapping.rs index 99e32b0..0d0a0d7 100644 --- a/src/domain/text/mapping.rs +++ b/src/domain/text/mapping.rs @@ -1,112 +1,140 @@ -/// Converts text between English QWERTY and Russian ЙЦУКЕН keyboard layouts in both directions. -/// -/// Behavior: -/// - For each input Unicode scalar value (`char`), tries to map it as EN -> RU. -/// - If EN -> RU mapping is not found, tries RU -> EN. -/// - If neither mapping exists, the character is copied unchanged. -/// -/// Mapping coverage: -/// - Letters a-z and A-Z. -/// - Punctuation on the same physical keys: brackets, semicolon, quote, comma, dot, backtick. -/// - Russian letters include ё and Ё. -/// - Curly braces `{}` and quotes `"` are produced from shifted bracket/quote keys, matching typical layouts. -/// -/// Complexity: -/// - O(n) over Unicode scalar values of the input string. -/// -/// Notes: -/// - This is a layout conversion, not a transliteration. -/// - Non ASCII and non Russian letters are preserved as is. -pub fn convert_ru_en_bidirectional(text: &str) -> String { - fn is_cyrillic(ch: char) -> bool { - matches!(ch, 'а'..='я' | 'А'..='Я' | 'ё' | 'Ё') - } +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ConversionDirection { + RuToEn, + EnToRu, +} - #[rustfmt::skip] - fn is_latin(ch: char) -> bool { - ch.is_ascii_alphabetic() - || matches!( - ch, - - '[' | ']' | ';' | '\'' | ',' | '.' | '`' | '{' | '}' | ':' | '"' | '<' | '>' | '~' - | '?' | '/' - ) - } +fn is_cyrillic_letter(ch: char) -> bool { + matches!(ch, 'а'..='я' | 'А'..='Я' | 'ё' | 'Ё') +} - fn map_ru_to_en(ch: char) -> char { - #[rustfmt::skip] - match ch { - 'й' => 'q', 'ц' => 'w', 'у' => 'e', 'к' => 'r', 'е' => 't', 'н' => 'y', 'г' => 'u', 'ш' => 'i', 'щ' => 'o', 'з' => 'p', - 'х' => '[', 'ъ' => ']', - 'ф' => 'a', 'ы' => 's', 'в' => 'd', 'а' => 'f', 'п' => 'g', 'р' => 'h', 'о' => 'j', 'л' => 'k', 'д' => 'l', - 'ж' => ';', 'э' => '\'', - 'я' => 'z', 'ч' => 'x', 'с' => 'c', 'м' => 'v', 'и' => 'b', 'т' => 'n', 'ь' => 'm', - 'б' => ',', 'ю' => '.', - 'ё' => '`', +fn is_latin_letter(ch: char) -> bool { + ch.is_ascii_alphabetic() +} - // punctuation rules you requested - ',' => '?', - '.' => '/', +fn map_ru_to_en(ch: char) -> char { + #[rustfmt::skip] + match ch { + 'й' => 'q', 'ц' => 'w', 'у' => 'e', 'к' => 'r', 'е' => 't', 'н' => 'y', 'г' => 'u', 'ш' => 'i', 'щ' => 'o', 'з' => 'p', + 'х' => '[', 'ъ' => ']', + 'ф' => 'a', 'ы' => 's', 'в' => 'd', 'а' => 'f', 'п' => 'g', 'р' => 'h', 'о' => 'j', 'л' => 'k', 'д' => 'l', + 'ж' => ';', 'э' => '\'', + 'я' => 'z', 'ч' => 'x', 'с' => 'c', 'м' => 'v', 'и' => 'b', 'т' => 'n', 'ь' => 'm', + 'б' => ',', 'ю' => '.', + 'ё' => '`', - 'Й' => 'Q', 'Ц' => 'W', 'У' => 'E', 'К' => 'R', 'Е' => 'T', 'Н' => 'Y', 'Г' => 'U', 'Ш' => 'I', 'Щ' => 'O', 'З' => 'P', - 'Х' => '{', 'Ъ' => '}', - 'Ф' => 'A', 'Ы' => 'S', 'В' => 'D', 'А' => 'F', 'П' => 'G', 'Р' => 'H', 'О' => 'J', 'Л' => 'K', 'Д' => 'L', - 'Ж' => ':', 'Э' => '"', - 'Я' => 'Z', 'Ч' => 'X', 'С' => 'C', 'М' => 'V', 'И' => 'B', 'Т' => 'N', 'Ь' => 'M', - 'Б' => '<', 'Ю' => '>', - 'Ё' => '~', - _ => ch, - } + // punctuation rules you requested + ',' => '?', + '.' => '/', + '?' => '&', + + 'Й' => 'Q', 'Ц' => 'W', 'У' => 'E', 'К' => 'R', 'Е' => 'T', 'Н' => 'Y', 'Г' => 'U', 'Ш' => 'I', 'Щ' => 'O', 'З' => 'P', + 'Х' => '{', 'Ъ' => '}', + 'Ф' => 'A', 'Ы' => 'S', 'В' => 'D', 'А' => 'F', 'П' => 'G', 'Р' => 'H', 'О' => 'J', 'Л' => 'K', 'Д' => 'L', + 'Ж' => ':', 'Э' => '"', + 'Я' => 'Z', 'Ч' => 'X', 'С' => 'C', 'М' => 'V', 'И' => 'B', 'Т' => 'N', 'Ь' => 'M', + 'Б' => '<', 'Ю' => '>', + 'Ё' => '~', + _ => ch, } +} - fn map_en_to_ru(ch: char) -> char { - #[rustfmt::skip] - match ch { - 'q' => 'й', 'w' => 'ц', 'e' => 'у', 'r' => 'к', 't' => 'е', 'y' => 'н', 'u' => 'г', 'i' => 'ш', 'o' => 'щ', 'p' => 'з', - '[' => 'х', ']' => 'ъ', - 'a' => 'ф', 's' => 'ы', 'd' => 'в', 'f' => 'а', 'g' => 'п', 'h' => 'р', 'j' => 'о', 'k' => 'л', 'l' => 'д', - ';' => 'ж', '\'' => 'э', - 'z' => 'я', 'x' => 'ч', 'c' => 'с', 'v' => 'м', 'b' => 'и', 'n' => 'т', 'm' => 'ь', - ',' => 'б', '.' => 'ю', - '`' => 'ё', +fn map_en_to_ru(ch: char) -> char { + #[rustfmt::skip] + match ch { + 'q' => 'й', 'w' => 'ц', 'e' => 'у', 'r' => 'к', 't' => 'е', 'y' => 'н', 'u' => 'г', 'i' => 'ш', 'o' => 'щ', 'p' => 'з', + '[' => 'х', ']' => 'ъ', + 'a' => 'ф', 's' => 'ы', 'd' => 'в', 'f' => 'а', 'g' => 'п', 'h' => 'р', 'j' => 'о', 'k' => 'л', 'l' => 'д', + ';' => 'ж', '\'' => 'э', + 'z' => 'я', 'x' => 'ч', 'c' => 'с', 'v' => 'м', 'b' => 'и', 'n' => 'т', 'm' => 'ь', + ',' => 'б', '.' => 'ю', + '`' => 'ё', - // punctuation rules you requested - '?' => ',', - '/' => '.', + // punctuation rules you requested + '?' => ',', + '/' => '.', + '&' => '?', - 'Q' => 'Й', 'W' => 'Ц', 'E' => 'У', 'R' => 'К', 'T' => 'Е', 'Y' => 'Н', 'U' => 'Г', 'I' => 'Ш', 'O' => 'Щ', 'P' => 'З', - '{' => 'Х', '}' => 'Ъ', - 'A' => 'Ф', 'S' => 'Ы', 'D' => 'В', 'F' => 'А', 'G' => 'П', 'H' => 'Р', 'J' => 'О', 'K' => 'Л', 'L' => 'Д', - ':' => 'Ж', '"' => 'Э', - 'Z' => 'Я', 'X' => 'Ч', 'C' => 'С', 'V' => 'М', 'B' => 'И', 'N' => 'Т', 'M' => 'Ь', - '<' => 'Б', '>' => 'Ю', - '~' => 'Ё', - _ => ch, - } + 'Q' => 'Й', 'W' => 'Ц', 'E' => 'У', 'R' => 'К', 'T' => 'Е', 'Y' => 'Н', 'U' => 'Г', 'I' => 'Ш', 'O' => 'Щ', 'P' => 'З', + '{' => 'Х', '}' => 'Ъ', + 'A' => 'Ф', 'S' => 'Ы', 'D' => 'В', 'F' => 'А', 'G' => 'П', 'H' => 'Р', 'J' => 'О', 'K' => 'Л', 'L' => 'Д', + ':' => 'Ж', '"' => 'Э', + 'Z' => 'Я', 'X' => 'Ч', 'C' => 'С', 'V' => 'М', 'B' => 'И', 'N' => 'Т', 'M' => 'Ь', + '<' => 'Б', '>' => 'Ю', + '~' => 'Ё', + _ => ch, } +} +fn letter_counts(text: &str) -> (usize, usize) { let mut cyr = 0usize; let mut lat = 0usize; for ch in text.chars() { - if is_cyrillic(ch) { + if is_cyrillic_letter(ch) { cyr += 1; - } else if is_latin(ch) { + } else if is_latin_letter(ch) { lat += 1; } } + (cyr, lat) +} - let ru_to_en = cyr >= lat; +/// Returns a conversion direction based on letter balance. +/// +/// If the counts are tied (including zero letters), returns `None`. +pub fn conversion_direction_for_text(text: &str) -> Option { + let (cyr, lat) = letter_counts(text); + match cyr.cmp(&lat) { + std::cmp::Ordering::Greater => Some(ConversionDirection::RuToEn), + std::cmp::Ordering::Less => Some(ConversionDirection::EnToRu), + std::cmp::Ordering::Equal => None, + } +} +/// Converts text between English QWERTY and Russian ЙЦУКЕН keyboard layouts in the given direction. +/// +/// Complexity: +/// - O(n) over Unicode scalar values of the input string. +/// +/// Notes: +/// - This is a layout conversion, not a transliteration. +/// - Non ASCII and non Russian letters are preserved as is. +pub fn convert_ru_en_with_direction(text: &str, direction: ConversionDirection) -> String { let mut out = String::with_capacity(text.len()); - if ru_to_en { - for ch in text.chars() { - out.push(map_ru_to_en(ch)); + match direction { + ConversionDirection::RuToEn => { + for ch in text.chars() { + out.push(map_ru_to_en(ch)); + } } - } else { - for ch in text.chars() { - out.push(map_en_to_ru(ch)); + ConversionDirection::EnToRu => { + for ch in text.chars() { + out.push(map_en_to_ru(ch)); + } } } - out } + +/// Converts text between English QWERTY and Russian ЙЦУКЕН keyboard layouts in both directions. +/// +/// Behavior: +/// - Counts Latin vs Cyrillic letters to choose a conversion direction. +/// - If the counts are equal, defaults to RU -> EN. +/// +/// Mapping coverage: +/// - Letters a-z and A-Z. +/// - Punctuation on the same physical keys: brackets, semicolon, quote, comma, dot, backtick. +/// - Russian letters include ё and Ё. +/// - Curly braces `{}` and quotes `"` are produced from shifted bracket/quote keys, matching typical layouts. +/// +/// Complexity: +/// - O(n) over Unicode scalar values of the input string. +/// +/// Notes: +/// - This is a layout conversion, not a transliteration. +/// - Non ASCII and non Russian letters are preserved as is. +pub fn convert_ru_en_bidirectional(text: &str) -> String { + let direction = conversion_direction_for_text(text).unwrap_or(ConversionDirection::RuToEn); + convert_ru_en_with_direction(text, direction) +} diff --git a/src/tests/mapping_invariants_tests.rs b/src/tests/mapping_invariants_tests.rs index 1068034..b8ba012 100644 --- a/src/tests/mapping_invariants_tests.rs +++ b/src/tests/mapping_invariants_tests.rs @@ -1,4 +1,6 @@ -use crate::domain::text::mapping::convert_ru_en_bidirectional; +use crate::domain::text::mapping::{ + ConversionDirection, convert_ru_en_bidirectional, convert_ru_en_with_direction, +}; const LATIN_BIJECTIVE: &str = "qwertyuiop[]asdfghjkl;'zxcvbnm,.`QWERTYUIOP{}ASDFGHJKL:\"ZXCVBNM<>~"; @@ -59,3 +61,15 @@ fn punctuation_rules_apply_only_in_en_to_ru_mode() { // Cyr dominates: ru_to_en, so '/' is not remapped by en_to_ru punctuation rule assert_eq!(convert_ru_en_bidirectional("/а"), "/f"); } + +#[test] +fn punctuation_only_respects_explicit_direction() { + assert_eq!( + convert_ru_en_with_direction("?", ConversionDirection::RuToEn), + "&" + ); + assert_eq!( + convert_ru_en_with_direction("?", ConversionDirection::EnToRu), + "," + ); +}