@@ -1299,7 +1299,7 @@ fn handle_typing_assist_after_space(
12991299 let correction = [ 2 , 1 ] . into_iter ( ) . find_map ( |word_count| {
13001300 let events = buf. last_completed_words_events ( word_count) ?;
13011301 let original = map_original_events ( & events) ;
1302- let replacement = apply_typing_assist_exact ( & original) ?;
1302+ let replacement = apply_typing_assist ( & original, active_auto_switch_layout ( ) ) ?;
13031303 Some ( ( original, replacement) )
13041304 } ) ;
13051305 let Some ( ( original, replacement) ) = correction else {
@@ -2606,7 +2606,12 @@ fn apply_auto_replace(original: &str, target: &str) -> Option<String> {
26062606 } )
26072607}
26082608
2609+ #[ cfg( test) ]
26092610fn apply_typing_assist_exact ( text : & str ) -> Option < String > {
2611+ apply_typing_assist ( text, false )
2612+ }
2613+
2614+ fn apply_typing_assist ( text : & str , allow_layout_auto : bool ) -> Option < String > {
26102615 let ( leading, core, trailing) = split_edge_whitespace ( text) ;
26112616 if core. is_empty ( ) {
26122617 return None ;
@@ -2623,6 +2628,11 @@ fn apply_typing_assist_exact(text: &str) -> Option<String> {
26232628 replacement_for_token ( word)
26242629 . or_else ( || correct_duplicate_layout_prefix_on_ascii_token ( word) )
26252630 . or_else ( || correct_wrong_layout_ascii_technical_token ( word) )
2631+ . or_else ( || {
2632+ allow_layout_auto
2633+ . then ( || correct_wrong_layout_ascii_word ( word) )
2634+ . flatten ( )
2635+ } )
26262636 . or_else ( || correct_cyrillic_word_case ( word) )
26272637 . or_else ( || correct_hard_sign_typo ( word) )
26282638 . or_else ( || correct_adjacent_transposition ( word) )
@@ -2639,6 +2649,79 @@ fn apply_typing_assist_exact(text: &str) -> Option<String> {
26392649 ( out != text) . then_some ( out)
26402650}
26412651
2652+ fn correct_wrong_layout_ascii_word ( token : & str ) -> Option < String > {
2653+ if !is_plain_ascii_layout_token ( token) || is_protected_ascii_layout_token ( token) {
2654+ return None ;
2655+ }
2656+
2657+ let converted = lay:: dict:: convert ( token, lay:: dict:: Direction :: Us2Ru ) ;
2658+ if converted == token || !is_cyrillic_word ( & converted) {
2659+ return None ;
2660+ }
2661+
2662+ let converted_lower = converted. to_lowercase ( ) ;
2663+ if !is_known_russian_layout_autoswitch_word ( & converted_lower) {
2664+ return None ;
2665+ }
2666+
2667+ match lay:: llm:: choose_token_hybrid ( token, & converted) {
2668+ Ok ( Some ( choice) ) if choice == converted => Some ( converted) ,
2669+ Ok ( Some ( choice) ) if choice == token => allow_short_layout_word ( token, & converted_lower)
2670+ . then ( || apply_word_case ( token, & converted_lower) ) ,
2671+ _ => Some ( apply_word_case ( token, & converted_lower) ) ,
2672+ }
2673+ }
2674+
2675+ fn is_plain_ascii_layout_token ( token : & str ) -> bool {
2676+ token. is_ascii ( )
2677+ && token. chars ( ) . any ( |ch| ch. is_ascii_alphabetic ( ) )
2678+ && !token. chars ( ) . any ( |ch| ch. is_ascii_digit ( ) )
2679+ && token
2680+ . chars ( )
2681+ . all ( |ch| ch. is_ascii_alphabetic ( ) || matches ! ( ch, ',' | ';' | '\'' | '[' | ']' | '`' ) )
2682+ }
2683+
2684+ fn is_protected_ascii_layout_token ( token : & str ) -> bool {
2685+ token. chars ( ) . any ( |ch| ch. is_ascii_alphabetic ( ) )
2686+ && ( is_upper_ascii_layout_acronym ( token) || is_mixed_case_ascii_layout_brand ( token) )
2687+ }
2688+
2689+ fn is_upper_ascii_layout_acronym ( token : & str ) -> bool {
2690+ let letters: Vec < char > = token
2691+ . chars ( )
2692+ . filter ( |ch| ch. is_ascii_alphabetic ( ) )
2693+ . collect ( ) ;
2694+ ( 2 ..=4 ) . contains ( & letters. len ( ) ) && letters. iter ( ) . all ( |ch| ch. is_ascii_uppercase ( ) )
2695+ }
2696+
2697+ fn is_mixed_case_ascii_layout_brand ( token : & str ) -> bool {
2698+ let letters: Vec < char > = token
2699+ . chars ( )
2700+ . filter ( |ch| ch. is_ascii_alphabetic ( ) )
2701+ . collect ( ) ;
2702+ letters. len ( ) >= 4
2703+ && letters. iter ( ) . any ( |ch| ch. is_ascii_lowercase ( ) )
2704+ && letters. iter ( ) . skip ( 1 ) . any ( |ch| ch. is_ascii_uppercase ( ) )
2705+ }
2706+
2707+ fn allow_short_layout_word ( original : & str , converted_lower : & str ) -> bool {
2708+ original
2709+ . chars ( )
2710+ . filter ( |ch| ch. is_ascii_alphabetic ( ) )
2711+ . count ( )
2712+ <= 3
2713+ && russian_tiny_dictionary ( ) . contains ( converted_lower)
2714+ }
2715+
2716+ fn is_known_russian_layout_autoswitch_word ( word : & str ) -> bool {
2717+ let len = word. chars ( ) . filter ( |ch| is_cyrillic_letter ( * ch) ) . count ( ) ;
2718+ if len <= 3 {
2719+ return russian_tiny_dictionary ( ) . contains ( word) ;
2720+ }
2721+
2722+ is_known_russian_word_or_form ( word) || russian_short_dictionary ( ) . contains ( word)
2723+ }
2724+
26422725fn replacement_for_token ( token : & str ) -> Option < String > {
26432726 if let Some ( replacement) = promoted_replacement_for_token ( token) {
26442727 return Some ( replacement) ;
@@ -3690,6 +3773,28 @@ fn russian_short_dictionary() -> &'static HashSet<String> {
36903773 } )
36913774}
36923775
3776+ fn russian_tiny_dictionary ( ) -> & ' static HashSet < String > {
3777+ static WORDS : OnceLock < HashSet < String > > = OnceLock :: new ( ) ;
3778+ WORDS . get_or_init ( || {
3779+ let words =
3780+ load_hunspell_words_min_len ( "/usr/share/hunspell/ru_RU.dic" , 2 ) . unwrap_or_else ( |e| {
3781+ log ( & format ! ( "⚠ tiny ru dictionary load failed: {e}" ) ) ;
3782+ HashSet :: new ( )
3783+ } ) ;
3784+ #[ cfg( test) ]
3785+ {
3786+ let mut words = words;
3787+ words. extend ( test_russian_forms ( ) . into_iter ( ) . map ( str:: to_string) ) ;
3788+ words. insert ( "не" . to_string ( ) ) ;
3789+ words
3790+ }
3791+ #[ cfg( not( test) ) ]
3792+ {
3793+ words
3794+ }
3795+ } )
3796+ }
3797+
36933798fn russian_generated_form_dictionary ( ) -> & ' static HashSet < String > {
36943799 static WORDS : OnceLock < HashSet < String > > = OnceLock :: new ( ) ;
36953800 WORDS . get_or_init ( || {
@@ -6133,6 +6238,34 @@ mod tests {
61336238 assert_eq ! ( apply_typing_assist_exact( "Есть " ) , None ) ;
61346239 }
61356240
6241+ #[ test]
6242+ fn typing_assist_auto_switch_converts_confident_wrong_layout_words ( ) {
6243+ assert_eq ! (
6244+ apply_typing_assist( "njkmrj " , true ) ,
6245+ Some ( "только " . to_string( ) )
6246+ ) ;
6247+ assert_eq ! ( apply_typing_assist( "yt " , true ) , Some ( "не " . to_string( ) ) ) ;
6248+ assert_eq ! (
6249+ apply_typing_assist( "hf,jnftn " , true ) ,
6250+ Some ( "работает " . to_string( ) )
6251+ ) ;
6252+ assert_eq ! (
6253+ apply_typing_assist( "njkmrj " , false ) ,
6254+ None ,
6255+ "auto layout word repair must stay behind the tray checkbox"
6256+ ) ;
6257+ }
6258+
6259+ #[ test]
6260+ fn typing_assist_auto_switch_keeps_english_and_protected_ascii ( ) {
6261+ assert_eq ! ( apply_typing_assist( "hello " , true ) , None ) ;
6262+ assert_eq ! ( apply_typing_assist( "test " , true ) , None ) ;
6263+ assert_eq ! ( apply_typing_assist( "good " , true ) , None ) ;
6264+ assert_eq ! ( apply_typing_assist( "API " , true ) , None ) ;
6265+ assert_eq ! ( apply_typing_assist( "AmoCRM " , true ) , None ) ;
6266+ assert_eq ! ( apply_typing_assist( "wi-fi " , true ) , None ) ;
6267+ }
6268+
61366269 #[ test]
61376270 fn typing_assist_fixes_adjacent_transposition ( ) {
61386271 assert_eq ! (
0 commit comments