use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::ffi::c_void;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use raw_window_handle::RawWindowHandle;
use teksilo_core::AppEventPoster;
use teksilo_core::raw_handle::ParentHandle;
use teksilo_core::text_input_document::TextInputDocument;
use teksilo_core::text_surface::TextSurfaces;
use teksilo_core::window::TeksiloWindowId;
mod tsf;
use super::imm_completion::{self, ImmCompletion, ImmMessage};
use super::imm_requests::{self, ReconvertString};
use super::{FinishedComposition, StoreFocus, TextStoreMode, text_store_mode};
use windows::Win32::Foundation::{
CO_E_NOTINITIALIZED, CloseHandle, HWND, LPARAM, LRESULT, POINT, RECT, S_OK, WPARAM,
};
use windows::Win32::Graphics::Gdi::ClientToScreen;
use windows::Win32::System::Com::{
APTTYPE, APTTYPE_MAINSTA, APTTYPE_MTA, APTTYPE_NA, APTTYPE_STA, APTTYPEQUALIFIER,
CLSCTX_INPROC_SERVER, CoCreateInstance, CoGetApartmentType,
};
use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress};
use windows::Win32::System::Threading::{
GetCurrentProcessId, OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
QueryFullProcessImageNameW,
};
use windows::Win32::System::Variant::{
VARIANT, VT_BOOL, VT_BSTR, VT_EMPTY, VT_I2, VT_I4, VT_INT, VT_UI4, VT_UINT, VT_UNKNOWN,
VariantClear,
};
use windows::Win32::UI::HiDpi::GetDpiForWindow;
use windows::Win32::UI::Input::Ime::{
CPS_CANCEL, CPS_COMPLETE, CS_INSERTCHAR, CS_NOMOVECARET, GCS_COMPATTR, GCS_COMPCLAUSE,
GCS_COMPREADATTR, GCS_COMPREADCLAUSE, GCS_COMPREADSTR, GCS_COMPSTR, GCS_CURSORPOS,
GCS_DELTASTART, GCS_RESULTCLAUSE, GCS_RESULTREADCLAUSE, GCS_RESULTREADSTR, GCS_RESULTSTR, HIMC,
IME_COMPOSITION_STRING, IME_CONVERSION_MODE, IME_SENTENCE_MODE, IMECHARPOSITION,
IMN_CHANGECANDIDATE, IMN_CLOSECANDIDATE, IMN_CLOSESTATUSWINDOW, IMN_GUIDELINE,
IMN_OPENCANDIDATE, IMN_OPENSTATUSWINDOW, IMN_PRIVATE, IMN_SETCANDIDATEPOS,
IMN_SETCOMPOSITIONFONT, IMN_SETCOMPOSITIONWINDOW, IMN_SETCONVERSIONMODE, IMN_SETOPENSTATUS,
IMN_SETSENTENCEMODE, IMN_SETSTATUSWINDOWPOS, IMN_SOFTKBDDESTROYED, IMR_CANDIDATEWINDOW,
IMR_COMPOSITIONFONT, IMR_COMPOSITIONWINDOW, IMR_CONFIRMRECONVERTSTRING, IMR_DOCUMENTFEED,
IMR_QUERYCHARPOSITION, IMR_RECONVERTSTRING, ImmGetCompositionStringW, ImmGetContext,
ImmGetConversionStatus, ImmGetOpenStatus, ImmNotifyIME, ImmReleaseContext, NI_COMPOSITIONSTR,
RECONVERTSTRING,
};
use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyboardLayout, VK_PACKET, VK_PROCESSKEY};
use windows::Win32::UI::Input::{
GetCurrentInputMessageSource, IMDT_KEYBOARD, IMDT_MOUSE, IMDT_PEN, IMDT_TOUCH, IMDT_TOUCHPAD,
IMDT_UNAVAILABLE, IMO_HARDWARE, IMO_INJECTED, IMO_SYSTEM, IMO_UNAVAILABLE,
INPUT_MESSAGE_SOURCE,
};
use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass};
use windows::Win32::UI::TextServices::{
CLSID_TF_InputProcessorProfiles, GUID_COMPARTMENT_CONVERSIONMODEBIAS,
GUID_COMPARTMENT_EMPTYCONTEXT, GUID_COMPARTMENT_ENABLED_PROFILES_UPDATED,
GUID_COMPARTMENT_HANDWRITING_OPENCLOSE, GUID_COMPARTMENT_KEYBOARD_DISABLED,
GUID_COMPARTMENT_KEYBOARD_INPUTMODE, GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION,
GUID_COMPARTMENT_KEYBOARD_INPUTMODE_SENTENCE, GUID_COMPARTMENT_KEYBOARD_OPENCLOSE,
GUID_COMPARTMENT_SAPI_AUDIO, GUID_COMPARTMENT_SPEECH_CFGMENU, GUID_COMPARTMENT_SPEECH_DISABLED,
GUID_COMPARTMENT_SPEECH_GLOBALSTATE, GUID_COMPARTMENT_SPEECH_OPENCLOSE,
GUID_COMPARTMENT_SPEECH_UI_STATUS, GUID_COMPARTMENT_TIPUISTATUS,
GUID_COMPARTMENT_TRANSITORYEXTENSION, GUID_COMPARTMENT_TRANSITORYEXTENSION_DOCUMENTMANAGER,
GUID_COMPARTMENT_TRANSITORYEXTENSION_PARENT, GUID_TFCAT_TIP_KEYBOARD, ITfCompartmentMgr,
ITfContext, ITfContextComposition, ITfInputProcessorProfileMgr, ITfThreadMgr,
TF_INPUTPROCESSORPROFILE, TF_IPP_FLAG_ACTIVE, TF_IPP_FLAG_ENABLED,
TF_IPP_FLAG_SUBSTITUTEDBYINPUTPROCESSOR, TF_PROFILETYPE_INPUTPROCESSOR,
TF_PROFILETYPE_KEYBOARDLAYOUT, TS_SS_DISJOINTSEL, TS_SS_NOHIDDENTEXT, TS_SS_REGIONS,
TS_SS_TKBAUTOCORRECTENABLE, TS_SS_TKBPREDICTIONENABLE, TS_SS_TRANSITORY, TS_SS_UWPCONTROL,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetClassNameW, GetForegroundWindow, GetMessageExtraInfo, GetWindowThreadProcessId,
UNICODE_NOCHAR, WM_CHAR, WM_DEADCHAR, WM_IME_CHAR, WM_IME_COMPOSITION, WM_IME_ENDCOMPOSITION,
WM_IME_KEYDOWN, WM_IME_KEYUP, WM_IME_NOTIFY, WM_IME_REQUEST, WM_IME_SELECT, WM_IME_SETCONTEXT,
WM_IME_STARTCOMPOSITION, WM_INPUTLANGCHANGE, WM_KEYDOWN, WM_KEYUP, WM_KILLFOCUS, WM_NCDESTROY,
WM_SETFOCUS, WM_SYSCHAR, WM_SYSDEADCHAR, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_UNICHAR,
};
use windows::core::{GUID, HRESULT, Interface, PWSTR, s, w};
macro_rules! tap_trace {
($($arg:tt)*) => {
super::trace(format_args!($($arg)*))
};
}
static NEXT_SUBCLASS_ID: AtomicUsize = AtomicUsize::new(0xFE_114_000);
static NEXT_SEQUENCE: AtomicU64 = AtomicU64::new(1);
thread_local! {
static WINDOWS: RefCell<HashMap<TeksiloWindowId, HWND>> = RefCell::new(HashMap::new());
static TAPS: RefCell<HashMap<TeksiloWindowId, Tap>> = RefCell::new(HashMap::new());
static HANDLING: Cell<u32> = const { Cell::new(0) };
static PROBING: Cell<bool> = const { Cell::new(false) };
}
struct TapState {
window_id: TeksiloWindowId,
surfaces: TextSurfaces,
}
impl std::fmt::Debug for TapState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TapState")
.field("window_id", &self.window_id)
.finish_non_exhaustive()
}
}
#[derive(Debug)]
struct Tap {
hwnd: HWND,
subclass_id: usize,
state: Rc<TapState>,
}
impl Tap {
fn ref_data(&self) -> usize {
Rc::as_ptr(&self.state) as usize
}
}
impl Drop for Tap {
fn drop(&mut self) {
unsafe {
let _ = RemoveWindowSubclass(self.hwnd, Some(text_input_proc), self.subclass_id);
}
}
}
pub(super) fn attach(
window_id: TeksiloWindowId,
parent: &ParentHandle,
poster: Arc<dyn AppEventPoster>,
surfaces: TextSurfaces,
) {
let Some(hwnd) = hwnd_of(parent) else {
return;
};
detach(window_id);
WINDOWS.with_borrow_mut(|windows| windows.insert(window_id, hwnd));
install_tap(window_id, hwnd, surfaces.clone());
tsf::attach(window_id, hwnd, poster, surfaces);
}
pub(super) fn set_text_store_focus(parent: &ParentHandle, focus: StoreFocus) {
if let Some(hwnd) = hwnd_of(parent) {
tsf::set_text_store_focus(hwnd, focus);
}
}
pub(super) fn text_store_mode_for(parent: &ParentHandle) -> TextStoreMode {
let mode = text_store_mode();
if mode != TextStoreMode::Off && hwnd_of(parent).is_some_and(tsf::has_store) {
mode
} else {
TextStoreMode::Off
}
}
pub(super) fn legacy_input_method_active() -> bool {
let layout = unsafe { GetKeyboardLayout(0) };
is_legacy_input_method(layout.0 as usize)
}
fn is_legacy_input_method(layout: usize) -> bool {
(layout as u32) >> 28 == 0xE
}
pub(super) fn terminate_text_store_composition(parent: &ParentHandle) -> bool {
hwnd_of(parent).is_some_and(tsf::terminate_composition)
}
pub(super) fn take_key_taken_by_text_store() -> bool {
tsf::take_key_taken()
}
fn hwnd_of(parent: &ParentHandle) -> Option<HWND> {
let RawWindowHandle::Win32(handle) = parent.raw_window_handle() else {
return None;
};
Some(HWND(handle.hwnd.get() as *mut c_void))
}
fn install_tap(window_id: TeksiloWindowId, hwnd: HWND, surfaces: TextSurfaces) {
let tap = Tap {
hwnd,
subclass_id: NEXT_SUBCLASS_ID.fetch_add(1, Ordering::Relaxed),
state: Rc::new(TapState {
window_id,
surfaces,
}),
};
let installed =
unsafe { SetWindowSubclass(hwnd, Some(text_input_proc), tap.subclass_id, tap.ref_data()) };
let seq = next_sequence();
if !installed.as_bool() {
tap_trace!(
"#{seq} {window_id} SetWindowSubclass returned FALSE; no requests answered, not traced"
);
return;
}
tap_trace!(
"#{seq} {window_id} attached hwnd={:#x} subclass={:#x}",
hwnd.0 as usize,
tap.subclass_id
);
if super::trace_enabled() {
if winit_is_handling() {
tap_trace!("#{seq} baseline skipped: winit is handling a message");
} else {
let _probing = Probing::enter();
probe_tsf(seq, Reach::Baseline);
probe_foreground(seq, hwnd);
}
}
TAPS.with_borrow_mut(|taps| taps.insert(window_id, tap));
}
pub(super) fn detach(window_id: TeksiloWindowId) {
if let Some(hwnd) = WINDOWS.with_borrow_mut(|windows| windows.remove(&window_id)) {
tsf::detach(hwnd);
}
let tap = TAPS.with_borrow_mut(|taps| taps.remove(&window_id));
drop(tap);
}
fn next_sequence() -> u64 {
NEXT_SEQUENCE.fetch_add(1, Ordering::Relaxed)
}
unsafe extern "system" fn text_input_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
uid: usize,
dw_ref_data: usize,
) -> LRESULT {
let state: Option<Rc<TapState>> = (dw_ref_data != 0).then(|| {
let state = dw_ref_data as *const TapState;
unsafe {
Rc::increment_strong_count(state);
Rc::from_raw(state)
}
});
let probe_after = state.as_deref().and_then(|state| {
catch_unwind(AssertUnwindSafe(|| log_message(state, msg, wparam, lparam)))
.ok()
.flatten()
});
if let Some(state) = state.as_deref()
&& msg == WM_IME_REQUEST
{
let answer = catch_unwind(AssertUnwindSafe(|| {
answer_request(state, hwnd, wparam.0 as u32, lparam)
}))
.ok()
.flatten();
if let Some(answer) = answer {
return answer;
}
}
if msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN {
tsf::note_window_key_down(wparam.0 as u32);
}
if msg == WM_NCDESTROY {
unsafe {
let _ = RemoveWindowSubclass(hwnd, Some(text_input_proc), uid);
}
}
let result = {
let _handling = Handling::enter();
unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }
};
if let Some(seq) = probe_after {
let _ = catch_unwind(AssertUnwindSafe(|| probe_message(hwnd, msg, seq)));
}
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Arrival {
Direct,
WhileHandling,
DuringProbe,
}
impl Arrival {
fn now() -> Self {
if PROBING.try_with(Cell::get).unwrap_or(true) {
Self::DuringProbe
} else if winit_is_handling() {
Self::WhileHandling
} else {
Self::Direct
}
}
fn mark(self) -> &'static str {
match self {
Self::Direct => "",
Self::WhileHandling => " (sent while winit handled another message)",
Self::DuringProbe => " (sent during a probe)",
}
}
}
fn winit_is_handling() -> bool {
HANDLING.try_with(Cell::get).unwrap_or(0) > 0
}
struct Handling;
impl Handling {
fn enter() -> Self {
let _ = HANDLING.try_with(|count| count.set(count.get() + 1));
Self
}
}
impl Drop for Handling {
fn drop(&mut self) {
let _ = HANDLING.try_with(|count| count.set(count.get().saturating_sub(1)));
}
}
struct Probing {
outer: bool,
}
impl Probing {
fn enter() -> Self {
let outer = PROBING
.try_with(|probing| probing.replace(true))
.unwrap_or(true);
Self { outer }
}
}
impl Drop for Probing {
fn drop(&mut self) {
let _ = PROBING.try_with(|probing| probing.set(self.outer));
}
}
fn log_message(state: &TapState, msg: u32, wparam: WPARAM, lparam: LPARAM) -> Option<u64> {
if !super::trace_enabled() {
return None;
}
let name = message_name(msg)?;
let source = input_source();
let extra = unsafe { GetMessageExtraInfo() }.0;
let seq = next_sequence();
let arrival = Arrival::now();
tap_trace!(
"#{seq} {} {name} {} src={source} extra={extra:#x}{}",
state.window_id,
describe(msg, wparam, lparam),
arrival.mark()
);
let probed = matches!(
msg,
WM_IME_STARTCOMPOSITION | WM_IME_COMPOSITION | WM_IME_ENDCOMPOSITION
) || (msg == WM_IME_NOTIFY
&& matches!(
wparam.0 as u32,
IMN_OPENCANDIDATE | IMN_CLOSECANDIDATE | IMN_SETOPENSTATUS
));
(probed && arrival == Arrival::Direct).then_some(seq)
}
fn probe_message(hwnd: HWND, msg: u32, seq: u64) {
let _probing = Probing::enter();
if matches!(
msg,
WM_IME_COMPOSITION | WM_IME_ENDCOMPOSITION | WM_IME_NOTIFY
) {
probe_imm(hwnd, seq);
}
probe_tsf(seq, Reach::Message);
}
fn message_name(msg: u32) -> Option<&'static str> {
Some(match msg {
WM_KEYDOWN => "WM_KEYDOWN",
WM_KEYUP => "WM_KEYUP",
WM_SYSKEYDOWN => "WM_SYSKEYDOWN",
WM_SYSKEYUP => "WM_SYSKEYUP",
WM_CHAR => "WM_CHAR",
WM_SYSCHAR => "WM_SYSCHAR",
WM_DEADCHAR => "WM_DEADCHAR",
WM_SYSDEADCHAR => "WM_SYSDEADCHAR",
WM_UNICHAR => "WM_UNICHAR",
WM_IME_STARTCOMPOSITION => "WM_IME_STARTCOMPOSITION",
WM_IME_ENDCOMPOSITION => "WM_IME_ENDCOMPOSITION",
WM_IME_COMPOSITION => "WM_IME_COMPOSITION",
WM_IME_NOTIFY => "WM_IME_NOTIFY",
WM_IME_SETCONTEXT => "WM_IME_SETCONTEXT",
WM_IME_CHAR => "WM_IME_CHAR",
WM_IME_REQUEST => "WM_IME_REQUEST",
WM_IME_SELECT => "WM_IME_SELECT",
WM_IME_KEYDOWN => "WM_IME_KEYDOWN",
WM_IME_KEYUP => "WM_IME_KEYUP",
WM_INPUTLANGCHANGE => "WM_INPUTLANGCHANGE",
WM_SETFOCUS => "WM_SETFOCUS",
WM_KILLFOCUS => "WM_KILLFOCUS",
_ => return None,
})
}
fn describe(msg: u32, wparam: WPARAM, lparam: LPARAM) -> String {
let (w, l) = (wparam.0, lparam.0);
match msg {
WM_KEYDOWN | WM_KEYUP | WM_SYSKEYDOWN | WM_SYSKEYUP | WM_IME_KEYDOWN | WM_IME_KEYUP => {
format!("{} {}", virtual_key(w), key_flags(l))
}
WM_CHAR | WM_SYSCHAR | WM_DEADCHAR | WM_SYSDEADCHAR | WM_IME_CHAR => {
format!("{} {}", utf16_unit(w), key_flags(l))
}
WM_UNICHAR if w as u32 == UNICODE_NOCHAR => "probe (UNICODE_NOCHAR)".to_owned(),
WM_UNICHAR => format!("{} {}", code_point(w), key_flags(l)),
WM_IME_COMPOSITION => format!(
"last={} flags={:#x} [{}]",
utf16_unit(w),
l as u32,
flag_names(l as u32, COMPOSITION_FLAGS)
),
WM_IME_NOTIFY => format!("{} lparam={l:#x}", named(w as u32, NOTIFY_COMMANDS)),
WM_IME_SETCONTEXT => format!(
"active={} show={:#x} [{}]",
w != 0,
l as u32,
flag_names(l as u32, SHOW_UI_FLAGS)
),
WM_IME_REQUEST => named(w as u32, REQUEST_COMMANDS),
WM_IME_SELECT => format!("selected={} hkl={l:#x}", w != 0),
WM_INPUTLANGCHANGE => format!("charset={w} hkl={l:#x}"),
_ => String::new(),
}
}
fn virtual_key(wparam: usize) -> String {
let vk = wparam as u16;
let tag = if vk == VK_PACKET.0 {
" VK_PACKET"
} else if vk == VK_PROCESSKEY.0 {
" VK_PROCESSKEY"
} else {
""
};
format!("vk={vk:#04x}{tag}")
}
fn key_flags(lparam: isize) -> String {
let bits = lparam as u32;
format!(
"scan={:#04x} ext={} alt={} prev={} up={} rep={}",
(bits >> 16) & 0xFF,
(bits >> 24) & 1,
(bits >> 29) & 1,
(bits >> 30) & 1,
(bits >> 31) & 1,
bits & 0xFFFF
)
}
fn utf16_unit(wparam: usize) -> String {
let unit = wparam as u32;
match unit {
0xD800..=0xDBFF => format!("unit={unit:#06x} high-surrogate"),
0xDC00..=0xDFFF => format!("unit={unit:#06x} low-surrogate"),
_ => match char::from_u32(unit) {
Some(c) => format!("unit={unit:#06x} {c:?}"),
None => format!("unit={unit:#06x}"),
},
}
}
fn code_point(wparam: usize) -> String {
let value = wparam as u32;
match char::from_u32(value) {
Some(c) => format!("U+{value:04X} {c:?}"),
None => format!("U+{value:04X} (not a scalar value)"),
}
}
fn flag_names(bits: u32, table: &[(u32, &str)]) -> String {
let mut names = Vec::new();
let mut rest = bits;
for &(flag, name) in table {
if flag != 0 && bits & flag == flag {
names.push(name.to_owned());
rest &= !flag;
}
}
if rest != 0 {
names.push(format!("{rest:#x}"));
}
if names.is_empty() {
"none".to_owned()
} else {
names.join("|")
}
}
fn named(value: u32, table: &[(u32, &str)]) -> String {
table
.iter()
.find(|&&(known, _)| known == value)
.map_or_else(|| format!("{value:#x}"), |&(_, name)| name.to_owned())
}
const COMPOSITION_FLAGS: &[(u32, &str)] = &[
(GCS_COMPREADSTR.0, "GCS_COMPREADSTR"),
(GCS_COMPREADATTR.0, "GCS_COMPREADATTR"),
(GCS_COMPREADCLAUSE.0, "GCS_COMPREADCLAUSE"),
(GCS_COMPSTR.0, "GCS_COMPSTR"),
(GCS_COMPATTR.0, "GCS_COMPATTR"),
(GCS_COMPCLAUSE.0, "GCS_COMPCLAUSE"),
(GCS_CURSORPOS.0, "GCS_CURSORPOS"),
(GCS_DELTASTART.0, "GCS_DELTASTART"),
(GCS_RESULTREADSTR.0, "GCS_RESULTREADSTR"),
(GCS_RESULTREADCLAUSE.0, "GCS_RESULTREADCLAUSE"),
(GCS_RESULTSTR.0, "GCS_RESULTSTR"),
(GCS_RESULTCLAUSE.0, "GCS_RESULTCLAUSE"),
(CS_INSERTCHAR, "CS_INSERTCHAR"),
(CS_NOMOVECARET, "CS_NOMOVECARET"),
];
const SHOW_UI_FLAGS: &[(u32, &str)] = &[
(0x8000_0000, "COMPOSITIONWINDOW"),
(0x4000_0000, "GUIDELINE"),
(0x0000_0001, "CANDIDATEWINDOW0"),
(0x0000_0002, "CANDIDATEWINDOW1"),
(0x0000_0004, "CANDIDATEWINDOW2"),
(0x0000_0008, "CANDIDATEWINDOW3"),
];
const NOTIFY_COMMANDS: &[(u32, &str)] = &[
(IMN_CLOSESTATUSWINDOW, "IMN_CLOSESTATUSWINDOW"),
(IMN_OPENSTATUSWINDOW, "IMN_OPENSTATUSWINDOW"),
(IMN_CHANGECANDIDATE, "IMN_CHANGECANDIDATE"),
(IMN_CLOSECANDIDATE, "IMN_CLOSECANDIDATE"),
(IMN_OPENCANDIDATE, "IMN_OPENCANDIDATE"),
(IMN_SETCONVERSIONMODE, "IMN_SETCONVERSIONMODE"),
(IMN_SETSENTENCEMODE, "IMN_SETSENTENCEMODE"),
(IMN_SETOPENSTATUS, "IMN_SETOPENSTATUS"),
(IMN_SETCANDIDATEPOS, "IMN_SETCANDIDATEPOS"),
(IMN_SETCOMPOSITIONFONT, "IMN_SETCOMPOSITIONFONT"),
(IMN_SETCOMPOSITIONWINDOW, "IMN_SETCOMPOSITIONWINDOW"),
(IMN_SETSTATUSWINDOWPOS, "IMN_SETSTATUSWINDOWPOS"),
(IMN_GUIDELINE, "IMN_GUIDELINE"),
(IMN_PRIVATE, "IMN_PRIVATE"),
(IMN_SOFTKBDDESTROYED, "IMN_SOFTKBDDESTROYED"),
];
const REQUEST_COMMANDS: &[(u32, &str)] = &[
(IMR_COMPOSITIONWINDOW, "IMR_COMPOSITIONWINDOW"),
(IMR_CANDIDATEWINDOW, "IMR_CANDIDATEWINDOW"),
(IMR_COMPOSITIONFONT, "IMR_COMPOSITIONFONT"),
(IMR_RECONVERTSTRING, "IMR_RECONVERTSTRING"),
(IMR_CONFIRMRECONVERTSTRING, "IMR_CONFIRMRECONVERTSTRING"),
(IMR_QUERYCHARPOSITION, "IMR_QUERYCHARPOSITION"),
(IMR_DOCUMENTFEED, "IMR_DOCUMENTFEED"),
];
const STATIC_STATUS_FLAGS: &[(u32, &str)] = &[
(TS_SS_DISJOINTSEL, "DISJOINTSEL"),
(TS_SS_REGIONS, "REGIONS"),
(TS_SS_TRANSITORY, "TRANSITORY"),
(TS_SS_NOHIDDENTEXT, "NOHIDDENTEXT"),
(TS_SS_TKBAUTOCORRECTENABLE, "TKBAUTOCORRECTENABLE"),
(TS_SS_TKBPREDICTIONENABLE, "TKBPREDICTIONENABLE"),
(TS_SS_UWPCONTROL, "UWPCONTROL"),
];
const PROFILE_FLAGS: &[(u32, &str)] = &[
(TF_IPP_FLAG_ACTIVE, "ACTIVE"),
(TF_IPP_FLAG_ENABLED, "ENABLED"),
(
TF_IPP_FLAG_SUBSTITUTEDBYINPUTPROCESSOR,
"SUBSTITUTEDBYINPUTPROCESSOR",
),
];
fn input_source() -> String {
let mut source = INPUT_MESSAGE_SOURCE::default();
if let Err(error) = unsafe { GetCurrentInputMessageSource(&mut source) } {
return format!("<{}>", hresult(&error));
}
let device = match source.deviceType {
IMDT_UNAVAILABLE => "unavailable".to_owned(),
IMDT_KEYBOARD => "keyboard".to_owned(),
IMDT_MOUSE => "mouse".to_owned(),
IMDT_TOUCH => "touch".to_owned(),
IMDT_PEN => "pen".to_owned(),
IMDT_TOUCHPAD => "touchpad".to_owned(),
other => format!("{:#x}", other.0),
};
let origin = match source.originId {
IMO_UNAVAILABLE => "unavailable".to_owned(),
IMO_HARDWARE => "hardware".to_owned(),
IMO_INJECTED => "injected".to_owned(),
IMO_SYSTEM => "system".to_owned(),
other => format!("{:#x}", other.0),
};
format!("{device}/{origin}")
}
fn hresult(error: &windows::core::Error) -> String {
let code = error.code();
if code.is_ok() {
return "null".to_owned();
}
format!("{:#010x} {}", code.0 as u32, error.message().trim_end())
}
fn probe_imm(hwnd: HWND, seq: u64) {
let himc = unsafe { ImmGetContext(hwnd) };
if himc.is_invalid() {
tap_trace!("#{seq} imm: the window has no input context");
return;
}
let composition = composition_string(himc, GCS_COMPSTR);
let result = composition_string(himc, GCS_RESULTSTR);
let cursor = unsafe { ImmGetCompositionStringW(himc, GCS_CURSORPOS, None, 0) };
let delta = unsafe { ImmGetCompositionStringW(himc, GCS_DELTASTART, None, 0) };
let open = unsafe { ImmGetOpenStatus(himc) }.as_bool();
let (mut conversion, mut sentence) = (IME_CONVERSION_MODE(0), IME_SENTENCE_MODE(0));
let _ = unsafe { ImmGetConversionStatus(himc, Some(&mut conversion), Some(&mut sentence)) };
unsafe {
let _ = ImmReleaseContext(hwnd, himc);
}
tap_trace!(
"#{seq} imm: comp={composition} result={result} cursor={cursor} delta={delta} open={open} conversion={:#x} sentence={:#x}",
conversion.0,
sentence.0
);
}
fn composition_string(himc: HIMC, which: IME_COMPOSITION_STRING) -> String {
let bytes = unsafe { ImmGetCompositionStringW(himc, which, None, 0) };
if bytes < 0 {
return format!("<error {bytes}>");
}
if bytes == 0 {
return "<none>".to_owned();
}
let mut units = vec![0u16; bytes as usize / 2];
let read = unsafe {
ImmGetCompositionStringW(
himc,
which,
Some(units.as_mut_ptr().cast()),
(units.len() * 2) as u32,
)
};
if read < 0 {
return format!("<error {read}>");
}
units.truncate(read as usize / 2);
let hex: Vec<String> = units.iter().map(|unit| format!("{unit:04x}")).collect();
format!("{:?} [{}]", String::from_utf16_lossy(&units), hex.join(" "))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Reach {
Message,
Baseline,
}
fn probe_tsf(seq: u64, reach: Reach) {
let apartment = match com_apartment() {
Ok(apartment) => apartment,
Err(problem) => {
tap_trace!("#{seq} tsf: {problem}; not probed");
return;
}
};
match existing_thread_manager() {
Ok(manager) => probe_thread_manager(seq, apartment, &manager, reach),
Err(why) => tap_trace!("#{seq} tsf: apartment={apartment}, no thread manager: {why}"),
}
probe_keyboard_profile(seq);
}
fn com_apartment() -> Result<&'static str, String> {
let mut kind = APTTYPE::default();
let mut qualifier = APTTYPEQUALIFIER::default();
match unsafe { CoGetApartmentType(&mut kind, &mut qualifier) } {
Ok(()) => Ok(match kind {
APTTYPE_STA => "STA",
APTTYPE_MAINSTA => "main STA",
APTTYPE_MTA => "MTA",
APTTYPE_NA => "NA",
_ => "unknown",
}),
Err(error) if error.code() == CO_E_NOTINITIALIZED => {
Err("COM is not initialised on this thread".to_owned())
}
Err(error) => Err(format!("CoGetApartmentType failed: {}", hresult(&error))),
}
}
fn existing_thread_manager() -> Result<ITfThreadMgr, String> {
type GetThreadMgr = unsafe extern "system" fn(*mut *mut c_void) -> HRESULT;
let module = unsafe { GetModuleHandleW(w!("msctf.dll")) }
.map_err(|_| "msctf.dll is not loaded".to_owned())?;
let Some(export) = (unsafe { GetProcAddress(module, s!("TF_GetThreadMgr")) }) else {
return Err("msctf.dll exports no TF_GetThreadMgr".to_owned());
};
let get = unsafe {
std::mem::transmute::<unsafe extern "system" fn() -> isize, GetThreadMgr>(export)
};
let mut raw: *mut c_void = std::ptr::null_mut();
let result = unsafe { get(&mut raw) };
if result.is_err() {
return Err(format!(
"TF_GetThreadMgr failed: {}",
hresult(&windows::core::Error::from_hresult(result))
));
}
if raw.is_null() {
return Err("this thread has none".to_owned());
}
Ok(unsafe { ITfThreadMgr::from_raw(raw) })
}
fn probe_thread_manager(seq: u64, apartment: &str, manager: &ITfThreadMgr, reach: Reach) {
let thread_focus = match unsafe { manager.IsThreadFocus() } {
Ok(focus) => focus.as_bool().to_string(),
Err(error) => format!("<{}>", hresult(&error)),
};
let prefix = format!("tsf: apartment={apartment}, thread focus={thread_focus}");
let document = match unsafe { manager.GetFocus() } {
Ok(document) => Some(document),
Err(error) => {
tap_trace!("#{seq} {prefix}, focused document: {}", hresult(&error));
None
}
};
let context = document
.as_ref()
.and_then(|document| match unsafe { document.GetTop() } {
Ok(context) => Some(context),
Err(error) => {
tap_trace!("#{seq} {prefix}, top context: {}", hresult(&error));
None
}
});
if let Some(context) = &context {
match unsafe { context.GetStatus() } {
Ok(status) => tap_trace!(
"#{seq} {prefix}, top context dynamic={:#x} static={:#x} [{}]",
status.dwDynamicFlags,
status.dwStaticFlags,
flag_names(status.dwStaticFlags, STATIC_STATUS_FLAGS)
),
Err(error) => {
tap_trace!("#{seq} {prefix}, top context status: {}", hresult(&error))
}
}
probe_compositions(seq, context);
}
if reach != Reach::Baseline {
return;
}
probe_compartments(seq, "global", unsafe { manager.GetGlobalCompartment() });
probe_compartments(seq, "thread", manager.cast());
if let Some(document) = &document {
probe_compartments(seq, "document", document.cast());
}
if let Some(context) = &context {
probe_compartments(seq, "context", context.cast());
}
}
fn probe_compositions(seq: u64, context: &ITfContext) {
let views = match context
.cast::<ITfContextComposition>()
.and_then(|composition| unsafe { composition.EnumCompositions() })
{
Ok(views) => views,
Err(error) => {
tap_trace!("#{seq} tsf: compositions: {}", hresult(&error));
return;
}
};
const MAX_COMPOSITIONS: usize = 16;
let mut count = 0;
while count < MAX_COMPOSITIONS {
let mut slot = [None];
let mut fetched = 0u32;
if let Err(error) = unsafe { views.Next(&mut slot, &mut fetched) } {
tap_trace!("#{seq} tsf: composition {count}: {}", hresult(&error));
break;
}
let Some(view) = slot[0].take().filter(|_| fetched > 0) else {
break;
};
match unsafe { view.GetOwnerClsid() } {
Ok(owner) => tap_trace!("#{seq} tsf: composition {count} owner={{{owner:?}}}"),
Err(error) => {
tap_trace!(
"#{seq} tsf: composition {count} owner: {}",
hresult(&error)
)
}
}
count += 1;
}
if count == 0 {
tap_trace!("#{seq} tsf: no composition in the top context");
}
}
const WATCHED_COMPARTMENTS: &[(GUID, &str)] = &[
(GUID_COMPARTMENT_SPEECH_OPENCLOSE, "SPEECH_OPENCLOSE"),
(GUID_COMPARTMENT_SPEECH_DISABLED, "SPEECH_DISABLED"),
(GUID_COMPARTMENT_SPEECH_GLOBALSTATE, "SPEECH_GLOBALSTATE"),
(
GUID_COMPARTMENT_HANDWRITING_OPENCLOSE,
"HANDWRITING_OPENCLOSE",
),
(GUID_COMPARTMENT_KEYBOARD_OPENCLOSE, "KEYBOARD_OPENCLOSE"),
(GUID_COMPARTMENT_KEYBOARD_DISABLED, "KEYBOARD_DISABLED"),
];
const OTHER_KNOWN_COMPARTMENTS: &[(GUID, &str)] = &[
(GUID_COMPARTMENT_CONVERSIONMODEBIAS, "CONVERSIONMODEBIAS"),
(GUID_COMPARTMENT_EMPTYCONTEXT, "EMPTYCONTEXT"),
(
GUID_COMPARTMENT_ENABLED_PROFILES_UPDATED,
"ENABLED_PROFILES_UPDATED",
),
(GUID_COMPARTMENT_KEYBOARD_INPUTMODE, "KEYBOARD_INPUTMODE"),
(
GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION,
"KEYBOARD_INPUTMODE_CONVERSION",
),
(
GUID_COMPARTMENT_KEYBOARD_INPUTMODE_SENTENCE,
"KEYBOARD_INPUTMODE_SENTENCE",
),
(GUID_COMPARTMENT_SAPI_AUDIO, "SAPI_AUDIO"),
(GUID_COMPARTMENT_SPEECH_CFGMENU, "SPEECH_CFGMENU"),
(GUID_COMPARTMENT_SPEECH_UI_STATUS, "SPEECH_UI_STATUS"),
(GUID_COMPARTMENT_TIPUISTATUS, "TIPUISTATUS"),
(GUID_COMPARTMENT_TRANSITORYEXTENSION, "TRANSITORYEXTENSION"),
(
GUID_COMPARTMENT_TRANSITORYEXTENSION_DOCUMENTMANAGER,
"TRANSITORYEXTENSION_DOCUMENTMANAGER",
),
(
GUID_COMPARTMENT_TRANSITORYEXTENSION_PARENT,
"TRANSITORYEXTENSION_PARENT",
),
];
const MAX_LISTED_COMPARTMENTS: usize = 64;
fn probe_compartments(seq: u64, level: &str, manager: windows::core::Result<ITfCompartmentMgr>) {
let manager = match manager {
Ok(manager) => manager,
Err(error) => {
tap_trace!("#{seq} tsf: {level} compartments: {}", hresult(&error));
return;
}
};
let (listed, listing) = match listed_compartments(&manager) {
Ok(listed) => {
let listing = format!("{} listed", listed.len());
(listed, listing)
}
Err(error) => (Vec::new(), format!("listing: {}", hresult(&error))),
};
let watched: Vec<String> = WATCHED_COMPARTMENTS
.iter()
.map(|(guid, name)| {
let mark = if listed.contains(guid) {
""
} else {
" (unlisted)"
};
format!("{name}={}{mark}", read_compartment(&manager, guid))
})
.collect();
tap_trace!(
"#{seq} tsf: {level} compartments ({listing}): {}",
watched.join(", ")
);
let others: Vec<String> = listed
.iter()
.filter(|guid| {
!WATCHED_COMPARTMENTS
.iter()
.any(|(watched, _)| watched == *guid)
})
.map(|guid| {
format!(
"{}={}",
compartment_name(guid),
read_compartment(&manager, guid)
)
})
.collect();
if !others.is_empty() {
tap_trace!(
"#{seq} tsf: {level} compartments, others: {}",
others.join(", ")
);
}
}
fn listed_compartments(manager: &ITfCompartmentMgr) -> windows::core::Result<Vec<GUID>> {
let guids = unsafe { manager.EnumCompartments() }?;
let mut listed = Vec::new();
while listed.len() < MAX_LISTED_COMPARTMENTS {
let mut batch = [GUID::zeroed(); 16];
let mut fetched = 0u32;
let result = unsafe { guids.Next(&mut batch, Some(&mut fetched)) };
result.ok()?;
let fetched = (fetched as usize).min(batch.len());
listed.extend_from_slice(&batch[..fetched]);
if result != S_OK || fetched == 0 {
break;
}
}
Ok(listed)
}
fn compartment_name(guid: &GUID) -> String {
WATCHED_COMPARTMENTS
.iter()
.chain(OTHER_KNOWN_COMPARTMENTS)
.find(|(known, _)| known == guid)
.map_or_else(|| format!("{{{guid:?}}}"), |(_, name)| (*name).to_owned())
}
fn read_compartment(manager: &ITfCompartmentMgr, guid: &GUID) -> String {
let compartment = match unsafe { manager.GetCompartment(guid) } {
Ok(compartment) => compartment,
Err(error) => return format!("<{}>", hresult(&error)),
};
match unsafe { compartment.GetValue() } {
Ok(mut value) => describe_variant(&mut value),
Err(error) => format!("<{}>", hresult(&error)),
}
}
fn describe_variant(value: &mut VARIANT) -> String {
let text = unsafe {
let tagged = &value.Anonymous.Anonymous;
let data = &tagged.Anonymous;
match tagged.vt {
VT_EMPTY => "empty".to_owned(),
VT_I4 => format!("I4 {}", data.lVal),
VT_INT => format!("INT {}", data.intVal),
VT_UI4 => format!("UI4 {}", data.ulVal),
VT_UINT => format!("UINT {}", data.uintVal),
VT_I2 => format!("I2 {}", data.iVal),
VT_BOOL => format!("BOOL {}", data.boolVal.0 != 0),
VT_BSTR => format!("BSTR {:?}", (*data.bstrVal).to_string()),
VT_UNKNOWN => {
let set = if data.punkVal.is_some() {
"non-null"
} else {
"null"
};
format!("UNKNOWN {set}")
}
other => format!("vt={:#x}", other.0),
}
};
unsafe {
let _ = VariantClear(value);
}
text
}
fn probe_keyboard_profile(seq: u64) {
let profiles: ITfInputProcessorProfileMgr = match unsafe {
CoCreateInstance(&CLSID_TF_InputProcessorProfiles, None, CLSCTX_INPROC_SERVER)
} {
Ok(profiles) => profiles,
Err(error) => {
tap_trace!("#{seq} tsf: profile manager: {}", hresult(&error));
return;
}
};
let mut profile = TF_INPUTPROCESSORPROFILE::default();
if let Err(error) = unsafe { profiles.GetActiveProfile(&GUID_TFCAT_TIP_KEYBOARD, &mut profile) }
{
tap_trace!("#{seq} tsf: active keyboard profile: {}", hresult(&error));
return;
}
let kind = match profile.dwProfileType {
TF_PROFILETYPE_INPUTPROCESSOR => "TIP".to_owned(),
TF_PROFILETYPE_KEYBOARDLAYOUT => "keyboard layout".to_owned(),
other => format!("type {other:#x}"),
};
tap_trace!(
"#{seq} tsf: active keyboard profile: {kind} langid={:#06x} clsid={{{:?}}} profile={{{:?}}} hkl={:#x} flags={:#x} [{}]",
profile.langid,
profile.clsid,
profile.guidProfile,
profile.hkl.0 as usize,
profile.dwFlags,
flag_names(profile.dwFlags, PROFILE_FLAGS)
);
}
fn probe_foreground(seq: u64, hwnd: HWND) {
let foreground = unsafe { GetForegroundWindow() };
if foreground.is_invalid() {
tap_trace!("#{seq} fg: no foreground window");
return;
}
let mut class = [0u16; 256];
let length = unsafe { GetClassNameW(foreground, &mut class) };
let class = match usize::try_from(length) {
Ok(length) if length > 0 => format!(
"{:?}",
String::from_utf16_lossy(&class[..length.min(class.len())])
),
_ => "<unknown>".to_owned(),
};
let mut pid = 0u32;
let tid = unsafe { GetWindowThreadProcessId(foreground, Some(&mut pid)) };
let this_process = pid == unsafe { GetCurrentProcessId() };
tap_trace!(
"#{seq} fg: hwnd={:#x} class={class} pid={pid} tid={tid} this window={} this process={this_process} image={}",
foreground.0 as usize,
foreground == hwnd,
process_image(pid)
);
}
fn process_image(pid: u32) -> String {
let process = match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } {
Ok(process) => process,
Err(error) => return format!("<{}>", hresult(&error)),
};
let mut path = [0u16; 1024];
let mut length = path.len() as u32;
let result = unsafe {
QueryFullProcessImageNameW(
process,
PROCESS_NAME_WIN32,
PWSTR(path.as_mut_ptr()),
&mut length,
)
};
unsafe {
let _ = CloseHandle(process);
}
match result {
Ok(()) => format!(
"{:?}",
String::from_utf16_lossy(&path[..(length as usize).min(path.len())])
),
Err(error) => format!("<{}>", hresult(&error)),
}
}
const _: () = assert!(imm_requests::RECONVERTSTRING_HEADER == size_of::<RECONVERTSTRING>());
fn answer_request(state: &TapState, hwnd: HWND, command: u32, lparam: LPARAM) -> Option<LRESULT> {
let name = match command {
IMR_QUERYCHARPOSITION => "IMR_QUERYCHARPOSITION",
IMR_DOCUMENTFEED => "IMR_DOCUMENTFEED",
IMR_RECONVERTSTRING | IMR_CONFIRMRECONVERTSTRING => {
if super::trace_enabled() {
tap_trace!(
"#{} {} {}: not answered (reconversion is left for the TSF store); passed on",
next_sequence(),
state.window_id,
named(command, REQUEST_COMMANDS)
);
}
return None;
}
_ => return None,
};
let answer = state.surfaces.with_focused_input_document(|document| {
if !document.is_available() {
return Err(Unavailable);
}
Ok(match command {
IMR_QUERYCHARPOSITION => query_char_position(document, hwnd, lparam),
_ => imm_requests::document_feed(document)
.and_then(|string| write_reconvert_string(&string, lparam)),
})
});
if super::trace_enabled() {
let seq = next_sequence();
let window_id = state.window_id;
match &answer {
Some(Ok(Some(LRESULT(result)))) => {
tap_trace!("#{seq} {window_id} answered {name}: {result:#x}")
}
Some(Ok(None)) => {
tap_trace!("#{seq} {window_id} {name}: nothing to answer; passed on")
}
Some(Err(Unavailable)) => tap_trace!(
"#{seq} {window_id} {name}: the document cannot answer without a change \
(busy, or typing not yet inserted); passed on"
),
None => tap_trace!("#{seq} {window_id} {name}: no focused document; passed on"),
}
}
answer.and_then(Result::ok).flatten()
}
#[derive(Debug)]
struct Unavailable;
fn query_char_position(
document: &dyn TextInputDocument,
hwnd: HWND,
lparam: LPARAM,
) -> Option<LRESULT> {
let request = lparam.0 as *mut IMECHARPOSITION;
if request.is_null() {
return None;
}
let mut position = unsafe { request.read_unaligned() };
if (position.dwSize as usize) < size_of::<IMECHARPOSITION>() {
return None;
}
let answer = imm_requests::char_position(document, position.dwCharPos as usize)?;
let dpi = unsafe { GetDpiForWindow(hwnd) };
if dpi == 0 {
return None;
}
let scale = dpi as f32 / 96.0;
position.pt = to_screen(hwnd, answer.caret.x, answer.caret.y, scale);
position.cLineHeight = (answer.caret.height * scale).round().max(1.0) as u32;
if let Some(visible) = answer.document {
let top_left = to_screen(hwnd, visible.x, visible.y, scale);
let bottom_right = to_screen(hwnd, visible.right(), visible.bottom(), scale);
position.rcDocument = RECT {
left: top_left.x,
top: top_left.y,
right: bottom_right.x,
bottom: bottom_right.y,
};
}
if super::trace_enabled() {
let document = position.rcDocument;
tap_trace!(
" char position {} -> screen ({}, {}) height {}, document ({}, {})..({}, {}); caret window-logical {:?}",
position.dwCharPos,
position.pt.x,
position.pt.y,
position.cLineHeight,
document.left,
document.top,
document.right,
document.bottom,
answer.caret
);
}
unsafe { request.write_unaligned(position) };
Some(LRESULT(1))
}
fn to_screen(hwnd: HWND, x: f32, y: f32, scale: f32) -> POINT {
let mut point = POINT {
x: (x * scale).round() as i32,
y: (y * scale).round() as i32,
};
unsafe {
let _ = ClientToScreen(hwnd, &mut point);
}
point
}
fn write_reconvert_string(string: &ReconvertString, lparam: LPARAM) -> Option<LRESULT> {
let fields = string.fields()?;
let buffer = lparam.0 as *mut u8;
if buffer.is_null() {
return Some(LRESULT(fields.size as isize));
}
unsafe {
let header = buffer.cast::<RECONVERTSTRING>();
let given = header.read_unaligned().dwSize;
if given < fields.size {
return Some(LRESULT(0));
}
header.write_unaligned(RECONVERTSTRING {
dwSize: given,
dwVersion: 0,
dwStrLen: fields.str_len,
dwStrOffset: fields.str_offset,
dwCompStrLen: fields.comp_len,
dwCompStrOffset: fields.comp_offset,
dwTargetStrLen: fields.target_len,
dwTargetStrOffset: fields.target_offset,
});
std::ptr::copy_nonoverlapping(
string.units.as_ptr().cast::<u8>(),
buffer.add(fields.str_offset as usize),
string.units.len() * 2,
);
}
if super::trace_enabled() {
tap_trace!(
" RECONVERTSTRING size={} str_len={} comp={}+{} target={}+{} (byte offset+units), from character {}: {:?}",
fields.size,
fields.str_len,
fields.comp_offset,
fields.comp_len,
fields.target_offset,
fields.target_len,
string.start,
String::from_utf16_lossy(&string.units)
);
}
Some(LRESULT(fields.size as isize))
}
const CAPTURE_SUBCLASS_ID: usize = 0xFE_115_000;
const _: () = assert!(
imm_completion::GCS_COMPSTR == GCS_COMPSTR.0
&& imm_completion::GCS_RESULTSTR == GCS_RESULTSTR.0
);
thread_local! {
static CAPTURE: RefCell<Option<(HWND, ImmCompletion)>> = const { RefCell::new(None) };
}
pub(super) fn finish_composition(window_id: TeksiloWindowId) -> Option<FinishedComposition> {
let hwnd = WINDOWS
.try_with(|windows| windows.borrow().get(&window_id).copied())
.ok()
.flatten();
let seq = next_sequence();
let Some(hwnd) = hwnd else {
tap_trace!("#{seq} {window_id} finish composition: the window is not attached");
return None;
};
if tsf::terminate_composition(hwnd) {
tap_trace!("#{seq} {window_id} finish composition: the store's, terminated");
return Some(FinishedComposition::default());
}
let himc = unsafe { ImmGetContext(hwnd) };
if himc.is_invalid() {
tap_trace!("#{seq} {window_id} finish composition: the window has no input context");
return None;
}
if super::trace_enabled() {
tap_trace!(
"#{seq} {window_id} finish composition: comp={} result={}",
composition_string(himc, GCS_COMPSTR),
composition_string(himc, GCS_RESULTSTR)
);
}
let finished = complete(hwnd, himc, seq);
unsafe {
let _ = ImmReleaseContext(hwnd, himc);
}
Some(finished)
}
fn complete(hwnd: HWND, himc: HIMC, seq: u64) -> FinishedComposition {
let tracing = super::trace_enabled();
let Some(capture) = Capture::install(hwnd) else {
let cancelled = unsafe { ImmNotifyIME(himc, NI_COMPOSITIONSTR, CPS_CANCEL, 0) };
tap_trace!(
"#{seq} no capture subclass: cancelled instead, which returned {}",
cancelled.as_bool()
);
return FinishedComposition::default();
};
let completed = unsafe { ImmNotifyIME(himc, NI_COMPOSITIONSTR, CPS_COMPLETE, 0) };
if tracing {
tap_trace!(
"#{seq} complete returned {}; now comp={} result={}",
completed.as_bool(),
composition_string(himc, GCS_COMPSTR),
composition_string(himc, GCS_RESULTSTR)
);
}
let left = capture.left_composing();
if let Some(left) = &left {
let cancelled = unsafe { ImmNotifyIME(himc, NI_COMPOSITIONSTR, CPS_CANCEL, 0) };
tap_trace!(
"#{seq} still composing {left:?}: cancel returned {}",
cancelled.as_bool()
);
}
let completion = capture.finish();
let finished = FinishedComposition {
committed: completion.committed(left.as_deref()),
late_commits: completion.winit_commits().to_vec(),
};
tap_trace!(
"#{seq} the input method committed {:?}; winit commits {:?} after the handler",
finished.committed,
finished.late_commits
);
finished
}
struct Capture {
hwnd: HWND,
}
impl Capture {
fn install(hwnd: HWND) -> Option<Self> {
let started = CAPTURE
.try_with(|slot| match slot.try_borrow_mut() {
Ok(mut slot) if slot.is_none() => {
*slot = Some((hwnd, ImmCompletion::new()));
true
}
_ => false,
})
.unwrap_or(false);
if !started {
return None;
}
let installed =
unsafe { SetWindowSubclass(hwnd, Some(capture_proc), CAPTURE_SUBCLASS_ID, 0) };
if !installed.as_bool() {
Self::clear();
return None;
}
Some(Self { hwnd })
}
fn left_composing(&self) -> Option<String> {
CAPTURE
.try_with(|slot| {
let slot = slot.try_borrow().ok()?;
let (_, completion) = slot.as_ref()?;
completion.left_composing().map(str::to_owned)
})
.ok()
.flatten()
}
fn finish(self) -> ImmCompletion {
let completion = CAPTURE
.try_with(|slot| {
let mut slot = slot.try_borrow_mut().ok()?;
slot.take().map(|(_, completion)| completion)
})
.ok()
.flatten();
drop(self);
completion.unwrap_or_default()
}
fn clear() {
let _ = CAPTURE.try_with(|slot| {
if let Ok(mut slot) = slot.try_borrow_mut() {
*slot = None;
}
});
}
}
impl Drop for Capture {
fn drop(&mut self) {
unsafe {
let _ = RemoveWindowSubclass(self.hwnd, Some(capture_proc), CAPTURE_SUBCLASS_ID);
}
Self::clear();
}
}
unsafe extern "system" fn capture_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
uid: usize,
_dw_ref_data: usize,
) -> LRESULT {
if matches!(msg, WM_IME_COMPOSITION | WM_IME_ENDCOMPOSITION) {
let _ = catch_unwind(AssertUnwindSafe(|| capture_message(hwnd, msg, lparam)));
}
if msg == WM_NCDESTROY {
unsafe {
let _ = RemoveWindowSubclass(hwnd, Some(capture_proc), uid);
}
}
unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }
}
fn capture_message(hwnd: HWND, msg: u32, lparam: LPARAM) {
let capturing = CAPTURE
.try_with(|slot| {
slot.try_borrow()
.is_ok_and(|slot| slot.as_ref().is_some_and(|(window, _)| *window == hwnd))
})
.unwrap_or(false);
if !capturing {
return;
}
let himc = unsafe { ImmGetContext(hwnd) };
let read = |which| {
(!himc.is_invalid())
.then(|| winit_string(himc, which))
.flatten()
};
let message = if msg == WM_IME_COMPOSITION {
let flags = lparam.0 as u32;
ImmMessage::Composition {
flags,
result: (flags & imm_completion::GCS_RESULTSTR != 0)
.then(|| read(GCS_RESULTSTR))
.flatten(),
composition: (flags & imm_completion::GCS_COMPSTR != 0)
.then(|| read(GCS_COMPSTR))
.flatten(),
}
} else {
ImmMessage::End {
result: read(GCS_RESULTSTR),
}
};
if !himc.is_invalid() {
unsafe {
let _ = ImmReleaseContext(hwnd, himc);
}
}
if super::trace_enabled() {
let seq = next_sequence();
match &message {
ImmMessage::Composition {
flags,
result,
composition,
} => tap_trace!(
"#{seq} capture: WM_IME_COMPOSITION flags={flags:#x} result={result:?} comp={composition:?}"
),
ImmMessage::End { result } => {
tap_trace!("#{seq} capture: WM_IME_ENDCOMPOSITION result={result:?}")
}
}
}
let _ = CAPTURE.try_with(|slot| {
if let Ok(mut slot) = slot.try_borrow_mut()
&& let Some((_, completion)) = slot.as_mut()
{
completion.message(&message);
}
});
}
fn winit_string(himc: HIMC, which: IME_COMPOSITION_STRING) -> Option<String> {
let bytes = unsafe { ImmGetCompositionStringW(himc, which, None, 0) };
if bytes < 0 {
return None;
}
if bytes == 0 {
return Some(String::new());
}
let mut units = vec![0u16; (bytes as usize).div_ceil(2)];
let read = unsafe {
ImmGetCompositionStringW(himc, which, Some(units.as_mut_ptr().cast()), bytes as u32)
};
if read < 0 || read % 2 != 0 {
return None;
}
units.truncate(read as usize / 2);
String::from_utf16(&units).ok()
}
#[cfg(test)]
mod tests {
use std::ops::Range;
use super::*;
use teksilo_canvas::{Point, Rect, SizeProposal};
use teksilo_core::text_input_document::{FirstLineRect, Utf16Index};
use teksilo_core::text_surface::TextSurface;
use teksilo_core::{BuildContext, EventContext, HandlerSet, LayoutContext, LayoutResponse};
use teksilo_core::{Widget, WidgetTree};
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, RegisterClassW, SendMessageW,
WINDOW_EX_STYLE, WNDCLASSW, WS_OVERLAPPED,
};
use windows::core::PCWSTR;
unsafe extern "system" fn plain_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
}
fn hidden_window() -> HWND {
unsafe {
let instance = GetModuleHandleW(PCWSTR::null()).expect("this module");
let class = w!("TeksiloImmCompletionTest");
let wc = WNDCLASSW {
lpfnWndProc: Some(plain_proc),
hInstance: instance.into(),
lpszClassName: class,
..Default::default()
};
RegisterClassW(&wc);
CreateWindowExW(
WINDOW_EX_STYLE(0),
class,
w!("imm completion"),
WS_OVERLAPPED,
0,
0,
0,
0,
None,
None,
Some(instance.into()),
None,
)
.expect("a hidden window")
}
}
fn send(hwnd: HWND, msg: u32, lparam: u32) {
unsafe {
let _ = SendMessageW(hwnd, msg, Some(WPARAM(0)), Some(LPARAM(lparam as isize)));
}
}
#[test]
fn the_capture_sees_the_messages_sent_while_it_is_installed() {
let hwnd = hidden_window();
let capture = Capture::install(hwnd).expect("the capture is installed");
assert!(Capture::install(hwnd).is_none(), "one capture at a time");
assert_eq!(
capture.left_composing().as_deref(),
Some(""),
"nothing sent yet: winit would still compose"
);
send(hwnd, WM_IME_COMPOSITION, GCS_RESULTSTR.0);
send(hwnd, WM_IME_ENDCOMPOSITION, 0);
assert_eq!(capture.left_composing(), None, "the end was seen");
let completion = capture.finish();
let has_context = unsafe {
let himc = ImmGetContext(hwnd);
let valid = !himc.is_invalid();
if valid {
let _ = ImmReleaseContext(hwnd, himc);
}
valid
};
let expected: &[&str] = if has_context { &[""] } else { &[] };
assert_eq!(completion.winit_commits(), expected);
assert_eq!(completion.committed(None), None);
CAPTURE.with_borrow_mut(|slot| *slot = Some((hwnd, ImmCompletion::new())));
send(hwnd, WM_IME_ENDCOMPOSITION, 0);
let heard = CAPTURE.with_borrow_mut(|slot| slot.take());
assert_eq!(
heard.map(|(_, completion)| completion.left_composing().map(str::to_owned)),
Some(Some(String::new())),
"no subclass fed it"
);
unsafe {
let _ = DestroyWindow(hwnd);
}
}
#[test]
fn reconversion_is_passed_on() {
let window_id = TeksiloWindowId::new(0xFE_0008);
let document = line("abc", 1..2);
let (hwnd, _tree) = window_with(document.clone(), window_id);
let mut buffer = reconvert_buffer(64);
for command in [IMR_RECONVERTSTRING, IMR_CONFIRMRECONVERTSTRING] {
assert_eq!(request(hwnd, command, 0), 0);
assert_eq!(request(hwnd, command, buffer.as_mut_ptr() as isize), 0);
}
assert_eq!(document.reads.get(), 0);
assert_eq!(
request(hwnd, IMR_DOCUMENTFEED, 0),
32 + 6,
"the document feed is answered"
);
detach(window_id);
unsafe {
let _ = DestroyWindow(hwnd);
}
}
#[derive(Debug)]
struct Line {
text: String,
selection: Range<usize>,
busy: Cell<bool>,
pending: Cell<bool>,
reads: Cell<usize>,
insertions: Cell<usize>,
}
impl Line {
fn read(&self) -> Option<()> {
if self.busy.get() {
return None;
}
if self.pending.replace(false) {
self.insertions.set(self.insertions.get() + 1);
}
self.reads.set(self.reads.get() + 1);
Some(())
}
fn len(&self) -> usize {
self.text.chars().count()
}
}
impl TextInputDocument for Line {
fn is_available(&self) -> bool {
!self.busy.get() && !self.pending.get()
}
fn char_count(&self) -> Option<usize> {
self.read().map(|()| self.len())
}
fn text_in(&self, range: Range<usize>) -> Option<String> {
self.read()?;
let end = range.end.min(self.len());
let start = range.start.min(end);
Some(self.text.chars().skip(start).take(end - start).collect())
}
fn selection(&self) -> Option<Range<usize>> {
self.read().map(|()| self.selection.clone())
}
fn caret(&self) -> Option<usize> {
self.read().map(|()| self.selection.end)
}
fn composition_range(&self) -> Option<Range<usize>> {
None
}
fn first_line_rect(&self, range: Range<usize>) -> Option<FirstLineRect> {
self.read()?;
let end = range.end.min(self.len());
let start = range.start.min(end);
Some(FirstLineRect {
rect: Rect::new(
5.0 + start as f32 * 10.0,
7.0,
((end - start) as f32 * 10.0).max(1.0),
20.0,
),
covered: start..end,
})
}
fn offset_at_point(&self, _: Point) -> Option<usize> {
None
}
fn visible_text_rect(&self) -> Option<Rect> {
self.read().map(|()| Rect::new(5.0, 7.0, 300.0, 20.0))
}
fn is_editable(&self) -> bool {
!self.busy.get()
}
fn is_secure(&self) -> bool {
self.busy.get()
}
fn writing_tools_allowed(&self) -> bool {
false
}
fn utf16_index(&self) -> Option<Rc<Utf16Index>> {
self.read().map(|()| Rc::new(Utf16Index::new(&self.text)))
}
}
impl TextSurface for Line {
fn can_undo(&self) -> bool {
false
}
fn can_redo(&self) -> bool {
false
}
fn undo(&self) {}
fn redo(&self) {}
fn has_selection(&self) -> bool {
!self.selection.is_empty()
}
fn is_read_only(&self) -> bool {
false
}
fn allows_copy(&self) -> bool {
true
}
fn cut(&self, _: &EventContext<'_>) {}
fn copy(&self, _: &EventContext<'_>) {}
fn paste(&self, _: &EventContext<'_>) {}
fn paste_plain(&self, _: &EventContext<'_>) {}
fn select_all(&self) {}
fn input_document(&self) -> Option<&dyn TextInputDocument> {
Some(self)
}
}
#[derive(Debug)]
struct Field {
line: Rc<Line>,
}
impl Widget for Field {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<teksilo_core::WidgetId> {
ctx.apply_self_handlers(HandlerSet::new().focusable(true));
ctx.register_text_surface(self.line.clone());
vec![]
}
fn layout_response(&self, proposal: SizeProposal, _: &LayoutContext) -> LayoutResponse {
proposal.resolve(300.0, 20.0).into()
}
}
fn window_with(line: Rc<Line>, window_id: TeksiloWindowId) -> (HWND, WidgetTree) {
let hwnd = hidden_window();
let mut tree = WidgetTree::new();
let id = tree.add(Field { line });
tree.layout(SizeProposal::exact(300.0, 20.0));
tree.focus(id);
WINDOWS.with_borrow_mut(|windows| windows.insert(window_id, hwnd));
install_tap(window_id, hwnd, tree.text_surfaces());
(hwnd, tree)
}
fn line(text: &str, selection: Range<usize>) -> Rc<Line> {
Rc::new(Line {
text: text.to_owned(),
selection,
busy: Cell::new(false),
pending: Cell::new(false),
reads: Cell::new(0),
insertions: Cell::new(0),
})
}
fn request(hwnd: HWND, command: u32, lparam: isize) -> isize {
unsafe {
SendMessageW(
hwnd,
WM_IME_REQUEST,
Some(WPARAM(command as usize)),
Some(LPARAM(lparam)),
)
}
.0
}
fn reconvert_buffer(bytes: usize) -> Vec<u32> {
let mut buffer = vec![0u32; bytes.div_ceil(4)];
buffer[0] = bytes as u32;
buffer
}
fn read_back(buffer: &[u32]) -> (String, Range<usize>, Range<usize>) {
let header = unsafe { buffer.as_ptr().cast::<RECONVERTSTRING>().read() };
let units = unsafe {
std::slice::from_raw_parts(
buffer
.as_ptr()
.cast::<u8>()
.add(header.dwStrOffset as usize)
.cast::<u16>(),
header.dwStrLen as usize,
)
};
let comp = header.dwCompStrOffset as usize / 2;
let target = header.dwTargetStrOffset as usize / 2;
(
String::from_utf16(units).expect("UTF-16"),
comp..comp + header.dwCompStrLen as usize,
target..target + header.dwTargetStrLen as usize,
)
}
#[test]
fn the_document_feed_is_written_and_a_buffer_too_small_is_refused() {
let window_id = TeksiloWindowId::new(0xFE_0002);
let (hwnd, _tree) = window_with(line("abc", 1..2), window_id);
assert_eq!(
request(hwnd, IMR_DOCUMENTFEED, 0),
38,
"the size, asked first"
);
let mut small = reconvert_buffer(32 + 2);
assert_eq!(
request(hwnd, IMR_DOCUMENTFEED, small.as_mut_ptr() as isize),
0,
"the string does not fit"
);
let mut buffer = reconvert_buffer(32 + 6);
assert_eq!(
request(hwnd, IMR_DOCUMENTFEED, buffer.as_mut_ptr() as isize),
38
);
assert_eq!(read_back(&buffer), ("abc".to_owned(), 1..1, 1..2));
detach(window_id);
unsafe {
let _ = DestroyWindow(hwnd);
}
}
#[test]
fn a_character_position_is_on_the_screen() {
let window_id = TeksiloWindowId::new(0xFE_0003);
let (hwnd, _tree) = window_with(line("abcdef", 2..2), window_id);
let mut position = IMECHARPOSITION {
dwSize: size_of::<IMECHARPOSITION>() as u32,
dwCharPos: 0,
..Default::default()
};
assert_eq!(
request(
hwnd,
IMR_QUERYCHARPOSITION,
std::ptr::from_mut(&mut position) as isize
),
1
);
let scale = unsafe { GetDpiForWindow(hwnd) } as f32 / 96.0;
let expected = to_screen(hwnd, 25.0, 7.0, scale);
assert_eq!((position.pt.x, position.pt.y), (expected.x, expected.y));
assert_eq!(position.cLineHeight, (20.0 * scale).round() as u32);
let corner = to_screen(hwnd, 5.0, 7.0, scale);
assert_eq!(
(position.rcDocument.left, position.rcDocument.top),
(corner.x, corner.y)
);
position.dwCharPos = 1;
assert_eq!(
request(
hwnd,
IMR_QUERYCHARPOSITION,
std::ptr::from_mut(&mut position) as isize
),
0,
"past the (empty) selection: passed on, and winit answers nothing"
);
detach(window_id);
unsafe {
let _ = DestroyWindow(hwnd);
}
}
#[test]
fn a_busy_document_is_not_read_and_the_request_goes_on() {
let window_id = TeksiloWindowId::new(0xFE_0004);
let document = line("abc", 0..0);
let (hwnd, _tree) = window_with(document.clone(), window_id);
document.busy.set(true);
let mut buffer = reconvert_buffer(64);
for command in [IMR_QUERYCHARPOSITION, IMR_DOCUMENTFEED] {
assert_eq!(request(hwnd, command, 0), 0);
assert_eq!(request(hwnd, command, buffer.as_mut_ptr() as isize), 0);
}
assert_eq!(document.reads.get(), 0, "only the probe was asked");
document.busy.set(false);
assert_eq!(
request(hwnd, IMR_DOCUMENTFEED, 0),
32 + 6,
"an available document answers"
);
detach(window_id);
unsafe {
let _ = DestroyWindow(hwnd);
}
}
#[test]
fn a_request_does_not_insert_typing_not_yet_inserted() {
let window_id = TeksiloWindowId::new(0xFE_0006);
let document = line("abc", 1..1);
let (hwnd, _tree) = window_with(document.clone(), window_id);
document.pending.set(true);
let mut position = IMECHARPOSITION {
dwSize: size_of::<IMECHARPOSITION>() as u32,
..Default::default()
};
let mut buffer = reconvert_buffer(64);
assert_eq!(
request(
hwnd,
IMR_QUERYCHARPOSITION,
std::ptr::from_mut(&mut position) as isize
),
0
);
assert_eq!(request(hwnd, IMR_DOCUMENTFEED, 0), 0);
assert_eq!(
request(hwnd, IMR_DOCUMENTFEED, buffer.as_mut_ptr() as isize),
0
);
assert_eq!(document.insertions.get(), 0, "the typing is not inserted");
assert_eq!(document.reads.get(), 0, "nor anything read");
assert!(document.pending.get());
detach(window_id);
unsafe {
let _ = DestroyWindow(hwnd);
}
}
#[test]
fn a_legacy_input_method_is_told_by_its_layout_handle() {
assert!(is_legacy_input_method(0xE001_0411));
assert!(!is_legacy_input_method(0x0411_0411), "Japanese, a TIP");
assert!(!is_legacy_input_method(0x040C_040C), "French");
assert!(
!is_legacy_input_method(0xF002_0409),
"Dvorak, a layout variant"
);
}
}