Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## What's new (2026-06-26)

### Live IME State for Safe CJK Entry

Wait for the input method to commit before reading a Japanese/Chinese/Korean field. Full reference: [`docs/source/Eng/doc/new_features/v208_features_doc.rst`](docs/source/Eng/doc/new_features/v208_features_doc.rst).

- **`ime_state` / `is_composing` / `wait_for_composition_commit` / `decode_conversion_mode`** (`AC_ime_state`, `AC_is_composing`, `AC_wait_for_composition_commit`, `AC_decode_conversion_mode`): typing into a CJK field is unsafe while an IME is *composing* — the candidate text isn't committed, so reading the field back returns half-entered glyphs and the next keystroke edits the composition. `text_unicode` (`VK_PACKET`) is blind to this. `ime_state` exposes the focused window's live `{open, composing, composition, conversion}` (Windows IMM32, read-only) through an injectable `reader`; `is_composing` is the boolean gate; `wait_for_composition_commit` blocks until the IME commits (injectable `clock`/`sleep`/`reader`); `decode_conversion_mode` is the pure `IME_CMODE_*` bitmask decoder. All decode/wait logic is unit-tested without an IME. Sixth feature of the ROUND-15 cross-app OS lane. No `PySide6`.

### Lock the Workstation + Wait for Unlock

Lock the box at the end of a run, and block until a human unlocks it before resuming. Full reference: [`docs/source/Eng/doc/new_features/v207_features_doc.rst`](docs/source/Eng/doc/new_features/v207_features_doc.rst).
Expand Down
57 changes: 57 additions & 0 deletions docs/source/Eng/doc/new_features/v208_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
Live IME State for Safe CJK Entry
=================================

Typing into a CJK / Japanese / Korean field is unsafe while an IME (input method
editor) is *composing*: the candidate text has not been committed yet, so
reading the field back returns half-entered glyphs and the next keystroke edits
the composition instead of the field. ``text_unicode`` (``VK_PACKET``) is blind
to this. ``ime_state`` exposes the live composition and conversion state so a
flow can wait for the IME to commit before it reads or acts.

* :func:`ime_state` — ``{open, composing, composition, conversion,
conversion_flags}`` for the focused window's IME, through an injectable
``reader``.
* :func:`is_composing` — ``True`` while the IME has an uncommitted composition.
* :func:`wait_for_composition_commit` — block until composition ends (or a
timeout), with injectable ``clock`` / ``sleep`` / ``reader``.
* :func:`decode_conversion_mode` — pure: the IMM32 ``IME_CMODE_*`` conversion
bitmask to ``{native, katakana, full_shape, roman, char_code}``.

The default ``reader`` queries Windows IMM32 (``ImmGetContext`` /
``ImmGetOpenStatus`` / ``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``)
read-only; all decoding / waiting logic runs through the injectable seam, so it
is fully testable without an IME. Imports no ``PySide6``.

Headless API
------------

.. code-block:: python

from je_auto_control import (
ime_state, is_composing, wait_for_composition_commit,
)

# Before reading a CJK field, make sure the IME has committed
if wait_for_composition_commit(timeout_s=3):
value = read_field()

is_composing() # True while candidate text is still on screen
ime_state() # {'open': True, 'composing': True, 'composition': 'あ', ...}

For tests (or any non-Windows host) pass a ``reader`` — a
``() -> {open, conversion, composition}``:

.. code-block:: python

busy = lambda: {"open": True, "conversion": 0, "composition": "あ"}
is_composing(reader=busy) # True
ime_state(reader=busy)["composition"] # 'あ'

Executor commands
-----------------

``AC_ime_state`` (→ the full state), ``AC_is_composing`` (→ ``{composing}``),
``AC_wait_for_composition_commit`` (``timeout`` / ``interval`` →
``{committed}``) and ``AC_decode_conversion_mode`` (``flags`` → the decoded
modes). They are exposed as the matching read-only ``ac_*`` MCP tools and as
Script Builder commands under **Shell**.
51 changes: 51 additions & 0 deletions docs/source/Zh/doc/new_features/v208_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
即時 IME 狀態以利安全的 CJK 輸入
================================

在 IME(輸入法)*組字中*對 CJK / 日文 / 韓文欄位輸入並不安全:候選字尚未送出,故讀回欄位會得到
半成形的字,而下一個按鍵會編輯組字而非欄位。``text_unicode``(``VK_PACKET``)對此一無所知。
``ime_state`` 暴露即時的組字與轉換狀態,讓流程能在讀取或操作前等待 IME 送出。

* :func:`ime_state` ——聚焦視窗 IME 的 ``{open, composing, composition, conversion,
conversion_flags}``,透過可注入的 ``reader``。
* :func:`is_composing` ——當 IME 有尚未送出的組字時回傳 ``True``。
* :func:`wait_for_composition_commit` ——阻塞直到組字結束(或逾時),``clock`` / ``sleep`` /
``reader`` 皆可注入。
* :func:`decode_conversion_mode` ——純函式:把 IMM32 ``IME_CMODE_*`` 轉換位元遮罩解碼為
``{native, katakana, full_shape, roman, char_code}``。

預設 ``reader`` 以唯讀方式查詢 Windows IMM32(``ImmGetContext`` / ``ImmGetOpenStatus`` /
``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``);所有解碼 / 等待邏輯都透過可注入接縫
執行,故能在沒有 IME 的情況下完整測試。不匯入 ``PySide6``。

無頭 API
--------

.. code-block:: python

from je_auto_control import (
ime_state, is_composing, wait_for_composition_commit,
)

# 讀取 CJK 欄位前,先確認 IME 已送出
if wait_for_composition_commit(timeout_s=3):
value = read_field()

is_composing() # 候選字仍在畫面上時為 True
ime_state() # {'open': True, 'composing': True, 'composition': 'あ', ...}

測試時(或任何非 Windows 主機)可傳入 ``reader`` ——一個
``() -> {open, conversion, composition}``:

.. code-block:: python

busy = lambda: {"open": True, "conversion": 0, "composition": "あ"}
is_composing(reader=busy) # True
ime_state(reader=busy)["composition"] # 'あ'

執行器指令
----------

``AC_ime_state``(→ 完整狀態)、``AC_is_composing``(→ ``{composing}``)、
``AC_wait_for_composition_commit``(``timeout`` / ``interval`` → ``{committed}``)
與 ``AC_decode_conversion_mode``(``flags`` → 解碼後的模式)。皆以對應的唯讀 ``ac_*`` MCP 工具
及 Script Builder 指令(位於 **Shell** 分類下)形式提供。
7 changes: 7 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@
classify_lock_transitions, lock_session, plan_lock_session,
wait_for_lock, wait_for_unlock,
)
# Read the live IME composition / conversion state for safe CJK entry
from je_auto_control.utils.ime_state import (
decode_conversion_mode, ime_state, is_composing,
wait_for_composition_commit,
)
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
from je_auto_control.utils.clipboard_rich_formats import (
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
Expand Down Expand Up @@ -1727,6 +1732,8 @@ def start_autocontrol_gui(*args, **kwargs):
"is_muted", "set_mute", "mute", "unmute", "toggle_mute",
"lock_session", "plan_lock_session", "wait_for_unlock",
"wait_for_lock", "classify_lock_transitions",
"ime_state", "is_composing", "wait_for_composition_commit",
"decode_conversion_mode",
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
"set_clipboard_rtf", "get_clipboard_rtf",
"set_clipboard_csv", "get_clipboard_csv",
Expand Down
28 changes: 28 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4373,6 +4373,34 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
),
description="Reduce lock-state samples to lock / unlock events.",
))
specs.append(CommandSpec(
"AC_ime_state", "Shell", "IME State",
fields=(),
description="Read the focused window's live IME composition state.",
))
specs.append(CommandSpec(
"AC_is_composing", "Shell", "Is IME Composing",
fields=(),
description="True while the IME has an uncommitted composition.",
))
specs.append(CommandSpec(
"AC_wait_for_composition_commit", "Shell", "Wait for IME Commit",
fields=(
FieldSpec("timeout", FieldType.FLOAT, optional=True, default=5.0,
placeholder="timeout seconds"),
FieldSpec("interval", FieldType.FLOAT, optional=True, default=0.1,
placeholder="poll interval seconds"),
),
description="Block until the IME finishes composing or timeout.",
))
specs.append(CommandSpec(
"AC_decode_conversion_mode", "Shell", "Decode IME Conversion Mode",
fields=(
FieldSpec("flags", FieldType.INT, default=0,
placeholder="IMM32 conversion bitmask"),
),
description="Decode an IMM32 conversion bitmask into named flags.",
))
specs.append(CommandSpec(
"AC_normalize_ext", "Shell", "Normalize Extension",
fields=(
Expand Down
31 changes: 31 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2711,6 +2711,33 @@ def _classify_lock_transitions(states: Any) -> Dict[str, Any]:
return {"events": classify_lock_transitions(samples)}


def _ime_state() -> Dict[str, Any]:
"""Adapter: the focused window's live IME composition / conversion state."""
from je_auto_control.utils.ime_state import ime_state
return ime_state()


def _is_composing() -> Dict[str, Any]:
"""Adapter: whether the IME has an uncommitted composition."""
from je_auto_control.utils.ime_state import is_composing
return {"composing": bool(is_composing())}


def _wait_for_composition_commit(timeout: Any = 5.0, interval: Any = 0.1
) -> Dict[str, Any]:
"""Adapter: block until the IME finishes composing or timeout."""
from je_auto_control.utils.ime_state import wait_for_composition_commit
committed = wait_for_composition_commit(timeout_s=float(timeout),
interval_s=float(interval))
return {"committed": bool(committed)}


def _decode_conversion_mode(flags: Any) -> Dict[str, Any]:
"""Adapter: decode an IMM32 conversion bitmask into named flags (pure)."""
from je_auto_control.utils.ime_state import decode_conversion_mode
return decode_conversion_mode(int(flags))


def _normalize_ext(target: str) -> Dict[str, Any]:
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
from je_auto_control.utils.file_assoc import normalize_ext
Expand Down Expand Up @@ -6729,6 +6756,10 @@ def __init__(self):
"AC_plan_lock_session": _plan_lock_session,
"AC_wait_for_unlock": _wait_for_unlock,
"AC_classify_lock_transitions": _classify_lock_transitions,
"AC_ime_state": _ime_state,
"AC_is_composing": _is_composing,
"AC_wait_for_composition_commit": _wait_for_composition_commit,
"AC_decode_conversion_mode": _decode_conversion_mode,
"AC_normalize_ext": _normalize_ext,
"AC_file_association": _file_association,
"AC_get_control_text": _get_control_text,
Expand Down
10 changes: 10 additions & 0 deletions je_auto_control/utils/ime_state/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Read the live IME composition / conversion state for safe CJK entry."""
from je_auto_control.utils.ime_state.ime_state import (
decode_conversion_mode, ime_state, is_composing,
wait_for_composition_commit,
)

__all__ = [
"ime_state", "is_composing", "wait_for_composition_commit",
"decode_conversion_mode",
]
134 changes: 134 additions & 0 deletions je_auto_control/utils/ime_state/ime_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Read the live IME (input method editor) state for safe CJK entry.

Typing into a CJK / Japanese / Korean field is unsafe while an IME is *composing*:
the candidate text has not been committed yet, so reading the field back returns
half-entered glyphs and the next keystroke edits the composition instead of the
field. ``text_unicode`` (``VK_PACKET``) is blind to this. ``ime_state`` exposes
the live composition and conversion state so a flow can wait for the IME to
commit before it reads or acts.

* :func:`ime_state` — ``{open, composing, composition, conversion}`` for the
focused window's IME, through an injectable ``reader``.
* :func:`is_composing` — ``True`` while the IME has an uncommitted composition.
* :func:`wait_for_composition_commit` — block until composition ends (or a
timeout), with injectable ``clock`` / ``sleep`` / ``reader``.
* :func:`decode_conversion_mode` — pure: the IMM32 ``IME_CMODE_*`` conversion
bitmask to ``{native, katakana, full_shape, roman, char_code}``.

The default ``reader`` queries Windows IMM32 (``ImmGetContext`` /
``ImmGetOpenStatus`` / ``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``)
read-only; all decoding / waiting logic runs through the injectable seam, so it
is fully testable without an IME. Imports no ``PySide6``.
"""
import sys
import time
from typing import Any, Callable, Dict, Optional

# IMM32 conversion-mode (IME_CMODE_*) bit flags.
IME_CMODE_NATIVE = 0x0001
IME_CMODE_KATAKANA = 0x0002
IME_CMODE_FULLSHAPE = 0x0008
IME_CMODE_ROMAN = 0x0010
IME_CMODE_CHARCODE = 0x0020

# A reader returns the raw IME state: {open, conversion, composition}.
ImeReader = Callable[[], Dict[str, Any]]


def decode_conversion_mode(flags: int) -> Dict[str, bool]:
"""Decode an IMM32 ``IME_CMODE_*`` bitmask into named booleans (pure)."""
value = int(flags)
return {
"native": bool(value & IME_CMODE_NATIVE),
"katakana": bool(value & IME_CMODE_KATAKANA),
"full_shape": bool(value & IME_CMODE_FULLSHAPE),
"roman": bool(value & IME_CMODE_ROMAN),
"char_code": bool(value & IME_CMODE_CHARCODE),
}


def _normalize(raw: Dict[str, Any]) -> Dict[str, Any]:
"""Turn a raw reader result into the public IME-state dict (pure)."""
composition = str(raw.get("composition") or "")
flags = int(raw.get("conversion") or 0)
return {
"open": bool(raw.get("open")),
"composing": bool(composition),
"composition": composition,
"conversion": decode_conversion_mode(flags),
"conversion_flags": flags,
}


def ime_state(*, reader: Optional[ImeReader] = None) -> Dict[str, Any]:
"""Return the focused window's IME state.

``{open, composing, composition, conversion, conversion_flags}``. Pass
``reader`` (a ``() -> {open, conversion, composition}``) to supply the
reading in tests; the default queries Windows IMM32.
"""
source = reader if reader is not None else _default_reader
return _normalize(source())


def is_composing(*, reader: Optional[ImeReader] = None) -> bool:
"""Return ``True`` while the IME has an uncommitted composition."""
return bool(ime_state(reader=reader)["composing"])


def wait_for_composition_commit(
*, reader: Optional[ImeReader] = None, timeout_s: float = 5.0,
interval_s: float = 0.1, clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep) -> bool:
"""Block until the IME is no longer composing; ``True``, or ``False`` on timeout.

``clock`` / ``sleep`` / ``reader`` are injectable for deterministic tests.
"""
deadline = clock() + float(timeout_s)
while True:
if not is_composing(reader=reader):
return True
if clock() >= deadline:
return False
sleep(float(interval_s))


# IMM32 composition-string flag: read the in-progress composition (GCS_COMPSTR).
_GCS_COMPSTR = 0x0008


def _read_composition(imm32: Any, himc: int) -> str:
"""Read the in-progress composition string from an IME context."""
import ctypes
byte_len = imm32.ImmGetCompositionStringW(himc, _GCS_COMPSTR, None, 0)
if byte_len <= 0:
return ""
buffer = ctypes.create_unicode_buffer(byte_len // 2)
imm32.ImmGetCompositionStringW(himc, _GCS_COMPSTR, buffer, byte_len)
return buffer.value


def _default_reader() -> Dict[str, Any]:
"""Read the focused window's IME state from Windows IMM32 (read-only)."""
if not sys.platform.startswith("win"):
raise RuntimeError(
"IME state has no OS reader on this platform; pass reader=")
import ctypes
user32 = ctypes.windll.user32
imm32 = ctypes.windll.imm32
hwnd = user32.GetForegroundWindow()
himc = imm32.ImmGetContext(hwnd)
if not himc:
return {"open": False, "conversion": 0, "composition": ""}
try:
conversion = ctypes.c_uint(0)
sentence = ctypes.c_uint(0)
imm32.ImmGetConversionStatus(
himc, ctypes.byref(conversion), ctypes.byref(sentence))
return {
"open": bool(imm32.ImmGetOpenStatus(himc)),
"conversion": int(conversion.value),
"composition": _read_composition(imm32, himc),
}
finally:
imm32.ImmReleaseContext(hwnd, himc)
Loading
Loading