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},
};
use crate::{
app::AppState,
conversion::input::{KeySequence, reselect_last_inserted_text_utf16_units, send_text_unicode},
};
const MAX_SELECTION_CHARS: usize = 512;
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,
}
}
#[allow(dead_code)]
#[tracing::instrument(level = "trace", skip(state))]
pub fn convert_selection_if_any(state: &mut AppState) -> bool {
match convert_selection_outcome(state, MAX_SELECTION_CHARS) {
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) {
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) {
ConvertOutcome::Noop => tracing::trace!("no selection"),
ConvertOutcome::Ok => {}
ConvertOutcome::Err(e) => {
tracing::warn!(user_text = e.user_text(), error = ?e, "selection conversion failed");
}
}
}
#[derive(Debug)]
enum ConvertOutcome {
Noop,
Ok,
Err(ConvertSelectionError),
}
fn convert_selection_outcome(state: &mut AppState, max_chars: usize) -> ConvertOutcome {
let Some(text) = probe_convertible_selection(max_chars) else {
return ConvertOutcome::Noop;
};
match convert_selection_from_text(state, &text) {
Ok(()) => ConvertOutcome::Ok,
Err(e) => ConvertOutcome::Err(e),
}
}
#[derive(Debug)]
enum ConvertSelectionError {
Delete,
InsertConverted,
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",
}
}
}
fn convert_selection_from_text(
state: &mut AppState,
text: &str,
) -> Result<(), ConvertSelectionError> {
let delay_ms = crate::helpers::get_edit_u32(state.edits.delay_ms).unwrap_or(100);
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)));
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(())
}
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)
}
fn foreground_window() -> Option<HWND> {
let fg = unsafe { GetForegroundWindow() };
(!fg.0.is_null()).then_some(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
}
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,
}
}
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)
}
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
}
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)
}
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)
}
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(())
}
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
}
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()
}