Skip to content

Commit 7e1682a

Browse files
feat: auto-switch confident wrong-layout words v0.1.121
1 parent 922fa5c commit 7e1682a

10 files changed

Lines changed: 194 additions & 13 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "lay"
3-
version = "0.1.120"
3+
version = "0.1.121"
44
edition = "2021"
55
description = "Double Shift layout rescue for Linux/GNOME Wayland"
66
license = "MIT"

HOW_IT_WORKS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,11 @@ Learning log включается только опцией `learning_log`. По
224224
### Автопереключение раскладки
225225

226226
Опция `auto_switch_layout` относится к автоматической помощи при наборе после
227-
пробела. Когда helper уверенно заменил текст, daemon может оставить активной
228-
раскладку исправленного текста. Если опция выключена, после автоматической
229-
правки daemon возвращает раскладку, которая была активна до исправления.
227+
пробела. Когда helper видит уверенное слово, набранное в неправильной
228+
раскладке, он заменяет его и может оставить активной раскладку исправленного
229+
текста. Если опция выключена, layout-автоправки не применяются, а после обычной
230+
автоматической правки daemon возвращает раскладку, которая была активна до
231+
исправления.
230232

231233
Ручной double Shift не зависит от этой опции: это явная команда пользователя
232234
переключить выбранный хвост, поэтому раскладка переключается всегда.

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,10 @@ extension/lay@radislabus-star.github.io/
197197
}
198198
```
199199

200-
`auto_switch_layout` управляет только автоматическими правками после пробела:
201-
если опция включена, активная раскладка остаётся той, которая соответствует
202-
исправленному тексту. Ручной double Shift переключает раскладку всегда.
200+
`auto_switch_layout` управляет автоматическими layout-правками после пробела:
201+
если слово уверенно похоже на набор в неправильной раскладке, helper заменит
202+
его и оставит активной раскладку исправленного текста. Ручной double Shift
203+
переключает раскладку всегда.
203204

204205
### Автоподмена
205206

VERSIONING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ number from `git rev-list`.
1313

1414
Current publication branch version:
1515

16-
- `0.1.120`
16+
- `0.1.121`
1717

1818
Do not rely on commit counts. Before publishing or pushing, run the bump script
1919
or verify the version fields manually.

extension/lay@radislabus-star.github.io/lay-impl.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {getInputSourceManager} from 'resource:///org/gnome/shell/ui/status/keybo
1818

1919
const CONFIG_PATH = GLib.get_home_dir() + '/.config/lay/config.json';
2020
const STATS_PATH = GLib.get_home_dir() + '/.local/share/lay/stats.json';
21-
const APP_VERSION = '0.1.120';
21+
const APP_VERSION = '0.1.121';
2222
const APP_DESCRIPTION = 'Double Shift layout rescue for Linux/GNOME Wayland';
2323
const APP_RELEASE_DATE = '2026-05-09';
2424
const APP_LICENSE = 'MIT';

extension/lay@radislabus-star.github.io/metadata.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"uuid": "lay@radislabus-star.github.io",
33
"name": "Lay Layout Switcher",
4-
"version": 120,
5-
"version-name": "0.1.120",
4+
"version": 121,
5+
"version-name": "0.1.121",
66
"description": "GNOME Shell extension для lay-daemon. Показывает EN/RU в трее, переключает раскладку по клику. Минимальный DBus bridge для layout activation и fallback text insert.",
77
"shell-version": ["45", "46", "47", "50"],
88
"url": "https://github.com/radislabus-star/lay-public"

scripts/run_runtime_smoke.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class Case:
4141
"mixed_coke_enter": Case("mixed_coke_enter", "слово кока-колу"),
4242
"mixed_coke_toggle3_enter": Case("mixed_coke_toggle3_enter", "слово кока-колу"),
4343
"n_teper_mixed_enter": Case("n_teper_mixed_enter", "Теперь"),
44+
"auto_switch_words_enter": Case("auto_switch_words_enter", "только не работает"),
4445
"proverka_ntrcn_enter": Case("proverka_ntrcn_enter", "проверка текст"),
4546
"vyvodim_dva_enter": Case("vyvodim_dva_enter", "выводим два"),
4647
"wifi_ye_enter": Case("wifi_ye_enter", "wi-fi ну"),

src/bin/lay_daemon.rs

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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)]
26092610
fn 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+
26422725
fn 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+
36933798
fn 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!(

src/bin/lay_test_input.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
//! lay-test-input good_vshgidu_enter — печатает "good Вщгиду" + двойной Shift + Enter
2121
//! lay-test-input good_text_enter — печатает "пщщв ntrcn" + двойной Shift + Enter
2222
//! lay-test-input wifi_ye_enter — печатает "wi-fi ye" + двойной Shift + Enter
23+
//! lay-test-input auto_switch_words_enter — печатает "njkmrj yt hf,jnftn" через пробелы + Enter
2324
//! lay-test-input vyvodim_dva_enter — печатает "dsdjlbv ldf" + двойной Shift + Enter
2425
//! lay-test-input mixed_coke_enter — печатает "слово кjrf-rjke" + двойной Shift + Enter
2526
//! lay-test-input mixed_coke_toggle3_enter — печатает "слово кjrf-rjke" + двойной Shift × 3 + Enter
@@ -71,6 +72,7 @@ fn main() -> std::io::Result<()> {
7172
KeyCode::KEY_ENTER,
7273
KeyCode::KEY_MINUS,
7374
KeyCode::KEY_EQUAL,
75+
KeyCode::KEY_COMMA,
7476
];
7577
for k in all {
7678
keys.insert(k);
@@ -441,6 +443,48 @@ fn main() -> std::io::Result<()> {
441443
double_shift_enter(&mut dev, 900)?;
442444
eprintln!("[test] сценарий wifi_ye_enter отправлен");
443445
}
446+
"auto_switch_words_enter" => {
447+
activate_layout("us");
448+
sleep(Duration::from_millis(250));
449+
tap_keys(
450+
&mut dev,
451+
&[
452+
KeyCode::KEY_N,
453+
KeyCode::KEY_J,
454+
KeyCode::KEY_K,
455+
KeyCode::KEY_M,
456+
KeyCode::KEY_R,
457+
KeyCode::KEY_J,
458+
KeyCode::KEY_SPACE,
459+
],
460+
35,
461+
)?;
462+
sleep(Duration::from_millis(450));
463+
tap_keys(
464+
&mut dev,
465+
&[KeyCode::KEY_Y, KeyCode::KEY_T, KeyCode::KEY_SPACE],
466+
35,
467+
)?;
468+
sleep(Duration::from_millis(450));
469+
tap_keys(
470+
&mut dev,
471+
&[
472+
KeyCode::KEY_H,
473+
KeyCode::KEY_F,
474+
KeyCode::KEY_COMMA,
475+
KeyCode::KEY_J,
476+
KeyCode::KEY_N,
477+
KeyCode::KEY_F,
478+
KeyCode::KEY_T,
479+
KeyCode::KEY_N,
480+
KeyCode::KEY_SPACE,
481+
],
482+
35,
483+
)?;
484+
sleep(Duration::from_millis(650));
485+
tap(&mut dev, KeyCode::KEY_ENTER.code())?;
486+
eprintln!("[test] сценарий auto_switch_words_enter отправлен");
487+
}
444488
"vyvodim_dva_enter" => {
445489
activate_layout("us");
446490
sleep(Duration::from_millis(250));

0 commit comments

Comments
 (0)