Win32 — F07: IME / text input
Findings from ./examples/f07-text-input/app.d, the Win32 implementation of the F07 spec: an editable line with a caret, an inline underlined pre-edit run, caret-anchored ImmSetCandidateWindow re-sent on every caret move, and the full WM_IME_STARTCOMPOSITION → WM_IME_COMPOSITION → WM_IME_ENDCOMPOSITION choreography — plus a minimal TSF COM bring-up whose outcome is the spec-mandated TSF-vs-IMM32 decision record. The headline Wine finding: headless Wine answers the entire IMM32 protocol with no real input method installed — an ImmSetCompositionStringW(SCS_SETSTR) self-injection echoes back as a genuine composition (start → pre-edit → commit-on-CPS_COMPLETE → end), so the app-side choreography is fully verifiable under Tier A[wine]; only the candidate-list UX and real-keystroke interleaving need the Tier-C CJK script below.
Last reviewed: June 11, 2026
IMPORTANT
Everything observed below is A[wine] — measured under Wine 10.0 (wine64, headless) with the exe cross-compiled by LDC 1.41.0 (-mtriple=x86_64-pc-windows-msvc). Two runs: the default winewayland driver (live wayland-0 socket) and a winex11 cross-check under xvfb-run — byte-identical summary lines, so the IME plumbing is driver-independent in Wine. No real IME is installed in either run; what answered is Wine's built-in imm32/default-IME-window machinery, which is a reimplementation — every claim about what a real IME does is cited and queued for the manual-run queue.
The demo
One bounded run per driver (WSI_AUTO_EXIT=1, ~1.2 s, exit 0). The script (one SetTimer tick per step):
| Step | What it does | Proves |
|---|---|---|
probe_imm | ImmGetContext, ImmGetDefaultIMEWnd, open/conversion status, description | what headless Wine's imm32 provides |
type_ascii | SendInput scancodes for a, b | caret movement re-anchors the candidate window |
set_composition_string | ImmSetCompositionStringW(SCS_SETSTR, "nihao") | does imm32 echo WM_IME_COMPOSITION back? (yes) |
notify_complete | ImmNotifyIME(NI_COMPOSITIONSTR, CPS_COMPLETE) | the commit path (GCS_RESULTSTR) |
notify_cancel | SCS_SETSTR "x" + ImmNotifyIME(…, CPS_CANCEL) | the cancel path |
associate_null | ImmAssociateContext(hwnd, null), type c | IME-disable probe: plain WM_CHAR still arrives |
associate_restore | ImmAssociateContext(hwnd, saved) | the context survives detachment |
The default run's summary:
1221232 summary text=abnihaoc caret=8 start=2 comp=3 end=2 setctx=5 notify=18
imechar=0 char=3 commit=1abnihaoc is exactly: typed ab, committed composition nihao, canceled x (absent), IME-less c. imechar=0 — see below.
The TSF-vs-IMM32 decision record
The F07 spec mandates trying TSF first and documenting precisely why if IMM32 is used instead. The demo's tsfProbe is that record, run before the editor starts. druntime's core.sys.windows has no projection of msctf.h at all, so the demo hand-declares the two vtbls (ITfThreadMgr, ITfDocumentMgr) and the CLSID/IID it needs. The bring-up itself is shockingly painless — every step returned S_OK under Wine:
12476 tsf step=CoInitializeEx hr=0x00000000
13619 tsf step=CoCreateInstance clsid=TF_ThreadMgr hr=0x00000000 ptr=1
14015 tsf step=Activate hr=0x00000000 client_id=0x1
14316 tsf step=CreateDocumentMgr hr=0x00000000 ptr=1
14626 tsf step=CreateContext punk=null hr=0x00000000 cookie=0x3
14971 tsf step=Push hr=0x00000000
15205 tsf step=AssociateFocus hr=0x00000000 prev=0
15501 tsf_verdict reached=context_pushed_and_focused note=no_ITextStoreACP_so_no_editable_storeSo COM from plain D is not the wall — interface declarations with the right vtbl order and extern (Windows) calling convention are all it takes, and Wine implements enough of msctf to activate a thread manager and push a context. The wall is what comes next: ITfDocumentMgr::CreateContext with punk=null is legal but produces a context without a text store — an IME can neither read nor edit the document through it. A functioning TSF integration requires the app to implement ITextStoreACP (a ~30-method document-access interface with lock/notification semantics) plus advise-sink plumbing, in a framework that — per Microsoft's own positioning — "is designed for use by Component Object Model (COM) programmers using the C/C++ programming languages" (Text Services Framework).
Decision: the editor speaks IMM32, whose entire app-side surface is the handful of WM_IME_* messages and Imm* calls exercised below. On modern Windows an IMM32-only window is serviced through the system's IMM-over-TSF compatibility layer (the Input Method Manager docs position IMM as the legacy surface TSF wraps), so nothing is lost for a research demo; the cost is forgoing TSF-only features (in-place candidate integration, reconversion-quality document access, modern handwriting/speech sources). A production binding targeting first-class CJK should budget ITextStoreACP as its own work item — the COM mechanics are cheap, the document-store semantics are the project.
druntime footnote, a finding in its own right: core.sys.windows.imm declares alias DWORD HIMC — but HIMC is a pointer-sized DECLARE_HANDLE, so on Win64 druntime's own prototypes truncate the handle (and none are nothrow, which a WndProc needs). The demo redeclares the dozen functions it uses with a correct alias HIMC = HANDLE; the constants and structs (WM_IME_*, GCS_*, CFS_*, CANDIDATEFORM) are fine.
The IMM32 choreography — A[wine]
The composition echo: what SCS_SETSTR proves headless
The probe step asks the IME to set the composition string; Wine's imm32 answered with the full message choreography a real IME keystroke would produce:
471830 msg name=WM_IME_STARTCOMPOSITION
473069 msg name=WM_IME_COMPOSITION flags=0x1b8
473378 preedit text=nihao units=5 cursor=5 attr=00000
474581 imm set_composition_string scs=SCS_SETSTR ok=1 err=0flags=0x1b8 decodes to GCS_COMPSTR | GCS_COMPATTR | GCS_COMPCLAUSE | GCS_CURSORPOS | GCS_DELTASTART — string, attributes, clause info, cursor, delta, all marked dirty in one message, read back with ImmGetCompositionStringW (attr=00000 = five ATTR_INPUT bytes, one per wchar). Note the ordering: WM_IME_STARTCOMPOSITION and WM_IME_COMPOSITION are delivered synchronously inside the ImmSetCompositionStringW call (the ok=1 log line trails them). Commit, via ImmNotifyIME(NI_COMPOSITIONSTR, CPS_COMPLETE):
621951 msg name=WM_IME_COMPOSITION flags=0x1800
622240 commit text=nihao units=5
624246 msg name=WM_IME_ENDCOMPOSITION
624496 imm notify_ime action=CPS_COMPLETE ok=1flags=0x1800 = GCS_RESULTSTR | GCS_RESULTCLAUSE: the pre-edit is replaced by result text in the same message type, distinguished only by the flag bits — exactly the documented shape ("Sent to an application when the IME changes composition status as a result of a keystroke" — WM_IME_COMPOSITION). The demo inserts the result at the caret and clears the pre-edit run.
Cancel: no zero-flag message under Wine
773587 msg name=WM_IME_COMPOSITION flags=0x1b8
773866 preedit text=x units=1 cursor=1 attr=0
774997 msg name=WM_IME_ENDCOMPOSITION
775245 imm notify_ime action=CPS_CANCEL ok=1CPS_CANCEL produced only WM_IME_ENDCOMPOSITION — no WM_IME_COMPOSITION with cleared flags (lParam=0) preceded it, which is the other commonly-described cancel shape. An app that clears its pre-edit only in a lParam==0 composition handler would render a ghost pre-edit forever under Wine; the demo clears it in both places (WM_IME_ENDCOMPOSITION is the authoritative end-of-life). Which shape real Windows IMEs produce per cancel route (Esc vs focus loss) is a Tier-C question in the script below.
Caret-anchored candidate positioning — and its feedback loop
Every caret move (typing, arrows, pre-edit cursor changes) re-sends a CFS_EXCLUDECANDIDATEFORM (and a CFS_POINT COMPOSITIONFORM for IMEs that ignore the former) at the freshly measured caret x:
322688 char utf16=0x0061
323156 msg name=WM_IME_NOTIFY cmd=0x9
323551 msg name=WM_IME_NOTIFY cmd=0xb
323893 candidate_anchor x=19 y=25 style=CFS_EXCLUDE ok=1 comp_ok=1
…
474226 candidate_anchor x=55 y=25 style=CFS_EXCLUDE ok=1 comp_ok=1 (pre-edit grew)
623019 candidate_anchor x=85 y=25 style=CFS_EXCLUDE ok=1 comp_ok=1 (after commit)The anchor x walks 12 → 19 → 25 with typed text, jumps to 55 while the 5-unit pre-edit is live and to 85 after the commit — the re-report-on-caret-move contract working. The cmd=0x9/cmd=0xb lines preceding each candidate_anchor log are IMN_SETCANDIDATEPOS / IMN_SETCOMPOSITIONWINDOW WM_IME_NOTIFY notifications — synchronous echoes of the app's own ImmSetCandidateWindow / ImmSetCompositionWindow calls, delivered re-entrantly before the setter returns (A[wine]). A binding that re-anchors in response to WM_IME_NOTIFY position messages recurses; route them to DefWindowProcW and anchor only on actual caret movement.
Units finding (feeds F08): CANDIDATEFORM/COMPOSITIONFORM coordinates are client-area pixels — under Per-Monitor-v2 awareness those are physical pixels at the window's current DPI, so a DPI change invalidates every cached anchor rectangle.
Owning the rendering: what the demo consumes
The demo renders the pre-edit inline (underlined, distinct from committed text), so it:
- answers
WM_IME_SETCONTEXTwithlParam & ~ISC_SHOWUICOMPOSITIONWINDOW(observedshow=0xc000000f= composition window + guideline + all four candidate windows) — suppressing the IME's own composition UI while keeping the IME-drawn candidate list; - returns
0fromWM_IME_STARTCOMPOSITION/WM_IME_COMPOSITIONinstead of callingDefWindowProcW— which is whyimechar=0: result characters reachWM_IME_CHARonly when the default window processing converts an unconsumedGCS_RESULTSTR, so a zero count proves the demo consumed the commit at the composition level (no double insertion).
The IME-disable probe
921638 msg name=WM_IME_SETCONTEXT active=0 show=0xc000000f
921966 msg name=WM_IME_SETCONTEXT active=1 show=0xc000000f
922314 imm associate_context new=0 old=0x20052 now=0x0
922950 char utf16=0x0063
923145 candidate_anchor skipped=no_himcImmAssociateContext(hwnd, null) ("Associates the specified input context with the specified window" — ImmAssociateContext) detaches the context: ImmGetContext then returns 0, candidate anchoring becomes a no-op, and plain keys still arrive as WM_CHAR (c landed) — the standard way to make a password/hotkey field IME-proof. Re-associating the saved handle restored now=0x20052. Wine quirk: the detach fired a WM_IME_SETCONTEXT active=0 and an immediate active=1 pair (A[wine]).
What headless Wine's imm32 provides — probe summary
171513 imm context himc=0x20052
171727 imm default_ime_wnd hwnd=0x2d0064
171975 imm open_status open=0
172181 imm conversion_status ok=1 conversion=0x0 sentence=0x0
172565 imm description len=0 note=not_an_ime_layoutA real input context and a live default IME window exist with no IME installed; status queries answer (closed, native conversion); ImmGetDescriptionW returns empty because the active HKL is a plain keyboard layout, not an IME — consistent with F06's finding that Wine's headless layout machinery is the US table wearing different HKLs.
Key-event interleaving
With no IME in the loop, typed ASCII rides the ordinary F06 chain (WM_KEYDOWN → app-pumped TranslateMessage → WM_CHAR); nothing is swallowed. The documented real-IME shape — keystrokes consumed by the IME surface as VK_PROCESSKEY key-downs while text emerges from WM_IME_* — cannot be observed under headless Wine and is the first thing the Tier-C script checks. Note that the WM_IME_* messages here arrived without TranslateMessage's involvement (they are posted by imm32, not synthesized in the pump), so the F06 rule "no TranslateMessage, no text" has an IME-side exception path a binding must keep working.
Surprises
- Headless Wine speaks fluent IMM32.
SCS_SETSTR/NI_COMPOSITIONSTRround-trips produce the genuineWM_IME_*choreography with zero input methods installed — the whole app-side state machine is CI-testable underA[wine], something none of the other platforms' IME stacks offer headless (A[wine]). - TSF's COM bring-up is the easy 10%. Hand-declared vtbls from plain D reached
AssociateFocuswith all-S_OK; the missing 90% isITextStoreACP's document-store semantics, which is a design commitment, not a binding chore. - Cancel ≠ zero-flag message under Wine:
CPS_CANCELyields a bareWM_IME_ENDCOMPOSITION. Clear the pre-edit there, not (only) in alParam==0handler. - Your own
ImmSet*Windowcalls come back asWM_IME_NOTIFYre-entrantly — an anchor-on-notify feedback loop waiting to happen. - druntime's
immmodule is unusable as-is on Win64 (HIMC=DWORDtruncation, nonothrow); the constants are fine, the prototypes are not. WM_IME_SETCONTEXT active=1arrived before the firstWM_SETFOCUS-driven activity (17.4 ms, during window creation) and the associate-null probe produced anactive=0/active=1pair —WM_IME_SETCONTEXTcounts (5 here) track context plumbing, not focus changes, under Wine (A[wine]).
Tier-C manual script — real Windows + Microsoft Pinyin
The same binary and dub.sdl, no code changes; run without WSI_AUTO_EXIT (the scripted probe still runs, then the window stays open and logs live typing). Setup: Windows 11, add the Chinese (Simplified) language pack, select the built-in Microsoft Pinyin IME, Win+Space to it with the demo window focused.
- Type
nihao— record: are the fiveWM_KEYDOWNs swallowed (nochar utf16=lines)? ExpectWM_IME_STARTCOMPOSITIONaftern, then onepreedit text=… cursor=… attr=…line per keystroke with growing pinyin (n,ni,nih, …) andattrbytes flipping as the IME segments; the candidate list must open beside the demo-drawn underlined pre-edit, not at the window corner (that isImmSetCandidateWindowworking). - Press Space then
1(or Enter) — expectcommit text=你好 units=2(GCS_RESULTSTR),WM_IME_ENDCOMPOSITION,imechar=0still (no double insert), and the committed glyphs rendered un-underlined. - Cancel: type
nihao, press Esc — record whether aWM_IME_COMPOSITIONwithflags=0x0(orGCS_COMPSTRwith an empty string) precedesWM_IME_ENDCOMPOSITION, vs Wine's bare-end shape above. - Focus loss mid-composition: type
niha, Alt+Tab away — record theWM_IME_SETCONTEXT active=0line and whether the pre-edit is committed, canceled, or restored on Alt+Tab back. This is the spec's focus-lifecycle question; Win32 gives the IME the choice. - Caret-anchor proof at both ends: commit a long line, press Home (caret index 0 via Left-arrow holds), type
nihao— the candidate list must open at the line start; End, repeat — at the line end. Thecandidate_anchor x=log lines give the expected x. - IME-proof field check: wait for the script's
associate_nullstep (or re-run), then try composing — the IME must not activate, and plain Latin keys must insert.
Expected-vs-observed deltas (especially items 3 and 4, and any WM_IME_NOTIFY commands beyond 0x9/0xb, e.g. IMN_OPENCANDIDATE/IMN_CHANGECANDIDATE) get recorded back into this doc with B-tier labels.
Build & run — A[wine]
The scaffold's verified pipeline, run in docs/research/window-system-integration/os-apis/win32/examples/f07-text-input/:
nix develop .#win32 -c win32-ldc2 app.d instrument.d -of=build/f07-text-input.exe
WINEPREFIX=$(mktemp -d) WINEDEBUG=-all WSI_AUTO_EXIT=1 \
nix develop .#win32 -c wine64 ./build/f07-text-input.exe # winewayland (wayland-0)
WINEPREFIX=$(mktemp -d) WINEDEBUG=-all WSI_AUTO_EXIT=1 XDG_RUNTIME_DIR=$(mktemp -d) \
env -u WAYLAND_DISPLAY xvfb-run -a \
nix develop .#win32 -c wine64 ./build/f07-text-input.exe # winex11 cross-checkBoth exit 0 in ~1.2 s with identical summaries (start=2 comp=3 end=2 … commit=1). Without WSI_AUTO_EXIT=1 the window stays open for live (Tier-C) IME typing. The package's dub.sdl (platforms "windows") exists for the Windows CI runner; locally dub is not part of the pipeline.
Sources
./examples/f07-text-input/app.d— the demo (all log excerpts above)- F07 spec — requirements implemented here
- Win32 scaffold findings, F06 keyboard findings — baseline pump, text-input chain
WM_IME_STARTCOMPOSITION,WM_IME_COMPOSITION,WM_IME_ENDCOMPOSITION,WM_IME_SETCONTEXT,WM_IME_NOTIFY,WM_IME_CHAR,ImmGetContext,ImmGetCompositionStringW,ImmSetCompositionStringW,ImmSetCandidateWindow,CANDIDATEFORM,ImmNotifyIME,ImmAssociateContext,ImmGetDefaultIMEWnd, Input Method Manager, Text Services Framework,ITfThreadMgr,ITfDocumentMgr::CreateContext,ITextStoreACP— Microsoft Learn (Wayback-pinned where the archive has a snapshot)