rust-switcher 1.0.12

Windows keyboard layout switcher and text conversion utility
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
use std::{ptr::null_mut, thread, time::Duration};

use mapping::{ConversionDirection, conversion_direction_for_text, convert_ru_en_with_direction};
use windows::Win32::{
    Foundation::{HWND, LPARAM, WPARAM},
    UI::{
        Input::KeyboardAndMouse::{
            GetAsyncKeyState, GetKeyboardLayout, GetKeyboardLayoutList, HKL, VIRTUAL_KEY,
            VK_LSHIFT, VK_RSHIFT,
        },
        WindowsAndMessaging::{
            GetForegroundWindow, GetWindowThreadProcessId, PostMessageW, WM_INPUTLANGCHANGEREQUEST,
        },
    },
};

use super::{
    mapping,
    selection_probe::{SelectionProbe, probe_selection_uia, probe_selection_win32},
    smart,
};
use crate::{
    app::AppState,
    conversion::input::{KeySequence, reselect_last_inserted_text_utf16_units, send_text_unicode},
};

const MAX_SELECTION_CHARS: usize = 512;

/// Virtual key code for the Delete key.
///
/// Used to remove the current selection before inserting converted text.
const VK_DELETE_KEY: VIRTUAL_KEY = VIRTUAL_KEY(0x2E);

fn probe_convertible_selection(max_chars: usize) -> Option<String> {
    match probe_selection_uia(max_chars) {
        SelectionProbe::SelectionText(text) => {
            tracing::trace!(len = text.chars().count(), "selection detected through UIA");
            return Some(text);
        }
        SelectionProbe::SelectionPresentButUnreadable(err) => {
            tracing::trace!(error = ?err, "UIA selection present but unreadable");
        }
        SelectionProbe::SelectionPresentButIneligible => {
            tracing::trace!("UIA selection present but ineligible");
        }
        SelectionProbe::NoSelection | SelectionProbe::Unsupported => {}
    }

    match probe_selection_win32(max_chars) {
        SelectionProbe::SelectionText(text) => {
            tracing::trace!(
                len = text.chars().count(),
                "selection detected through Win32"
            );
            Some(text)
        }
        SelectionProbe::SelectionPresentButUnreadable(err) => {
            tracing::trace!(error = ?err, "Win32 selection present but unreadable");
            None
        }
        SelectionProbe::SelectionPresentButIneligible => {
            tracing::trace!("Win32 selection present but ineligible");
            None
        }
        SelectionProbe::NoSelection | SelectionProbe::Unsupported => None,
    }
}

/// Converts the currently selected text, if there is any selection.
///
/// Returns `true` if a non empty eligible selection was found (conversion attempted),
/// otherwise `false`.
#[allow(dead_code)]
#[tracing::instrument(level = "trace", skip(state))]
pub fn convert_selection_if_any(state: &mut AppState) -> bool {
    convert_selection_if_any_impl(state, SelectionDirectionMode::TextThenForeground)
}

pub fn smart_convert_selection_if_any(state: &mut AppState) -> bool {
    convert_selection_if_any_impl(state, SelectionDirectionMode::TextOnly)
}

fn convert_selection_if_any_impl(state: &mut AppState, mode: SelectionDirectionMode) -> bool {
    match convert_selection_outcome(state, MAX_SELECTION_CHARS, mode) {
        ConvertOutcome::Noop => false,
        ConvertOutcome::Ok => true,
        ConvertOutcome::Err(e) => {
            tracing::warn!(user_text = e.user_text(), error = ?e, "selection conversion failed");
            true
        }
    }
}

pub fn convert_selection(state: &mut AppState) {
    convert_selection_impl(state, SelectionDirectionMode::TextThenForeground);
}

pub fn smart_convert_selection(state: &mut AppState) {
    convert_selection_impl(state, SelectionDirectionMode::TextOnly);
}

fn convert_selection_impl(state: &mut AppState, mode: SelectionDirectionMode) {
    tracing::trace!("convert_selection called");
    let fg = unsafe { GetForegroundWindow() };
    if fg.0.is_null() {
        tracing::warn!("foreground window is null");
        return;
    }

    if !wait_shift_released(150) {
        tracing::info!("wait_shift_released returned false");
        return;
    }

    match convert_selection_outcome(state, MAX_SELECTION_CHARS, mode) {
        ConvertOutcome::Noop => tracing::trace!("no selection"),
        ConvertOutcome::Ok => {}
        ConvertOutcome::Err(e) => {
            tracing::warn!(user_text = e.user_text(), error = ?e, "selection conversion failed");
        }
    }
}

#[derive(Copy, Clone, Debug)]
enum SelectionDirectionMode {
    TextThenForeground,
    TextOnly,
}

/// High level outcome of a conversion attempt.
///
/// This is designed for UI boundary code to decide whether to notify the user.
#[derive(Debug)]
enum ConvertOutcome {
    Noop,
    Ok,
    Err(ConvertSelectionError),
}

/// Attempts to convert selection and returns a high level outcome.
///
/// This function does not perform UI safety checks.
fn convert_selection_outcome(
    state: &mut AppState,
    max_chars: usize,
    mode: SelectionDirectionMode,
) -> ConvertOutcome {
    let Some(text) = probe_convertible_selection(max_chars) else {
        return ConvertOutcome::Noop;
    };

    match convert_selection_from_text(state, &text, mode) {
        Ok(()) => ConvertOutcome::Ok,
        Err(e) => ConvertOutcome::Err(e),
    }
}

/// Errors that can occur while replacing the current selection with converted text.
#[derive(Debug)]
enum ConvertSelectionError {
    /// Failed to send Delete to remove the current selection.
    Delete,
    /// Failed to inject Unicode text via `SendInput`.
    InsertConverted,
    /// Failed to reselect the inserted text within the retry budget.
    Reselect,
}

impl ConvertSelectionError {
    fn user_text(&self) -> &'static str {
        match self {
            Self::Delete => "Failed to delete selection",
            Self::InsertConverted => "Failed to insert converted text",
            Self::Reselect => "Failed to reselect inserted text",
        }
    }
}

/// Replaces currently selected text with layout converted text.
///
/// Returns `Ok(())` when:
/// - Delete tap succeeded
/// - Unicode injection succeeded
/// - reselect succeeded within retry budget
///
/// Keyboard layout switching is best effort and does not affect the result.
fn convert_selection_from_text(
    state: &mut AppState,
    text: &str,
    mode: SelectionDirectionMode,
) -> Result<(), ConvertSelectionError> {
    let delay_ms = crate::helpers::get_edit_u32(state.edits.delay_ms).unwrap_or(100);

    if matches!(mode, SelectionDirectionMode::TextOnly) && smart::text_looks_correct(text) {
        tracing::trace!(%text, "smart selection convert skipped: text already looks correct");
        return Ok(());
    }

    let direction = match mode {
        SelectionDirectionMode::TextThenForeground => conversion_direction_for_text(text)
            .or_else(expected_direction_for_foreground_window)
            .unwrap_or(ConversionDirection::RuToEn),
        SelectionDirectionMode::TextOnly => {
            conversion_direction_for_text(text).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)));

    let _seq = KeySequence::new();

    KeySequence::tap(VK_DELETE_KEY)
        .then_some(())
        .ok_or(ConvertSelectionError::Delete)?;

    send_text_unicode(&converted)
        .then_some(())
        .ok_or(ConvertSelectionError::InsertConverted)?;

    reselect_with_retry(
        converted_units,
        Duration::from_millis(120),
        Duration::from_millis(5),
    )
    .then_some(())
    .ok_or(ConvertSelectionError::Reselect)?;

    if let Err(e) = switch_keyboard_layout() {
        tracing::trace!(error = ?e, "layout switch failed");
    }

    Ok(())
}

/// Attempts to reselect the last inserted text using bounded retries.
///
/// Some target applications apply caret and selection updates asynchronously relative to `SendInput`.
/// This helper retries for a short time budget to reduce flakiness without adding a long fixed delay.
///
/// Returns `true` if reselect succeeds within `budget`, otherwise `false`.
fn reselect_with_retry(units: usize, budget: Duration, step_sleep: Duration) -> bool {
    let deadline = std::time::Instant::now() + budget;

    std::iter::repeat_with(|| reselect_last_inserted_text_utf16_units(units))
        .take_while(|_| std::time::Instant::now() < deadline)
        .inspect(|ok| {
            if !ok {
                thread::sleep(step_sleep);
            }
        })
        .any(|ok| ok)
}

/// Returns the current foreground window, or `None` if it is null.
fn foreground_window() -> Option<HWND> {
    let fg = unsafe { GetForegroundWindow() };
    (!fg.0.is_null()).then_some(fg)
}

/// Returns the current keyboard layout for the thread owning `fg`.
fn current_layout_for_window(fg: HWND) -> HKL {
    unsafe {
        let tid = GetWindowThreadProcessId(fg, None);
        GetKeyboardLayout(tid)
    }
}

#[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<LayoutLanguage> {
    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<ConversionDirection> {
    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<ConversionDirection> {
    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.
fn installed_layouts() -> Vec<HKL> {
    let n = unsafe { GetKeyboardLayoutList(None) };
    let Ok(layout_count) = usize::try_from(n) else {
        return Vec::new();
    };
    if layout_count == 0 {
        return Vec::new();
    }

    let mut layouts = vec![HKL(null_mut()); layout_count];

    let n2 = unsafe { GetKeyboardLayoutList(Some(layouts.as_mut_slice())) };
    let Ok(filled) = usize::try_from(n2) else {
        return Vec::new();
    };
    if filled == 0 {
        return Vec::new();
    }

    layouts.truncate(filled);
    layouts
}

/// Switches the keyboard layout for the current foreground window to the next installed layout.
///
/// Algorithm:
/// - obtains the foreground window
/// - reads the current layout for that window thread
/// - enumerates installed layouts
/// - selects the next one cyclically
/// - posts `WM_INPUTLANGCHANGEREQUEST` to the window
///
/// Returns `Ok(())` when the operation is completed or skipped:
/// - no foreground window
/// - no layouts available
///
/// Returns `Err` only if posting the request fails.
pub fn switch_keyboard_layout() -> windows::core::Result<()> {
    let Some(fg) = foreground_window() else {
        return Ok(());
    };

    let cur = current_layout_for_window(fg);
    let layouts = installed_layouts();
    if layouts.is_empty() {
        return Ok(());
    }

    let next = next_layout(&layouts, cur);
    post_layout_change(fg, next)
}

/// Returns the next layout in `layouts` after `cur`, cycling back to the first.
///
/// If `cur` is not found, returns `cur`.
fn next_layout(layouts: &[HKL], cur: HKL) -> HKL {
    layouts
        .iter()
        .position(|&h| h == cur)
        .and_then(|i| layouts.get((i + 1) % layouts.len()).copied())
        .unwrap_or(cur)
}

/// Posts a layout change request message to the foreground window.
///
/// Uses `WM_INPUTLANGCHANGEREQUEST`. The `hkl` is passed through `LPARAM`.
fn post_layout_change(fg: HWND, hkl: HKL) -> windows::core::Result<()> {
    unsafe {
        PostMessageW(
            Some(fg),
            WM_INPUTLANGCHANGEREQUEST,
            WPARAM(0),
            LPARAM(hkl.0 as isize),
        )?;
    }
    Ok(())
}

/// Waits until both left and right Shift keys are released or the timeout elapses.
///
/// Returns `true` as soon as neither Shift key is currently pressed.
pub fn wait_shift_released(timeout_ms: u64) -> bool {
    let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);

    while std::time::Instant::now() < deadline {
        let l = unsafe { GetAsyncKeyState(i32::from(VK_LSHIFT.0)) }.cast_unsigned();
        let r = unsafe { GetAsyncKeyState(i32::from(VK_RSHIFT.0)) }.cast_unsigned();

        let released = (l & 0x8000) == 0 && (r & 0x8000) == 0;
        if released {
            return true;
        }

        thread::sleep(Duration::from_millis(1));
    }

    false
}

/// Checks whether text is eligible for selection conversion.
///
/// Optimization:
/// `s.chars().nth(max_chars).is_none()` stops early for long strings, unlike `chars().count()`.
pub(super) fn is_convertible_selection(s: &str, max_chars: usize) -> bool {
    !s.is_empty() && !s.contains('\n') && !s.contains('\r') && s.chars().nth(max_chars).is_none()
}