From ca62d34ca62f24663c34a45aecabb2500407837a Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Thu, 18 Sep 2025 22:31:14 +1000 Subject: [PATCH 01/21] progress --- .../source/components/wrapper/message_box.rst | 139 ++++ src/moldflow/__init__.py | 12 +- .../locale/de-DE/LC_MESSAGES/locale.de-DE.po | 6 + .../locale/en-US/LC_MESSAGES/locale.en-US.po | 6 + .../locale/es-ES/LC_MESSAGES/locale.es-ES.po | 6 + .../locale/fr-FR/LC_MESSAGES/locale.fr-FR.po | 6 + .../locale/it-IT/LC_MESSAGES/locale.it-IT.po | 6 + .../locale/ja-JP/LC_MESSAGES/locale.ja-JP.po | 6 + .../locale/ko-KR/LC_MESSAGES/locale.ko-KR.po | 6 + .../locale/pt-PT/LC_MESSAGES/locale.pt-PT.po | 6 + .../locale/zh-CN/LC_MESSAGES/locale.zh-CN.po | 6 + .../locale/zh-TW/LC_MESSAGES/locale.zh-TW.po | 6 + src/moldflow/message_box.py | 742 ++++++++++++++++++ 13 files changed, 952 insertions(+), 1 deletion(-) create mode 100644 docs/source/components/wrapper/message_box.rst create mode 100644 src/moldflow/message_box.py diff --git a/docs/source/components/wrapper/message_box.rst b/docs/source/components/wrapper/message_box.rst new file mode 100644 index 0000000..6fbe4b4 --- /dev/null +++ b/docs/source/components/wrapper/message_box.rst @@ -0,0 +1,139 @@ +MessageBox +========== + +Convenience wrapper to display message boxes and a simple +text input dialog from Python scripts using the ``moldflow`` package. + +Usage +----- + +.. code-block:: python + + from moldflow import ( + MessageBox, + MessageBoxType, + MessageBoxResult, + MessageBoxOptions, + MessageBoxIcon, + MessageBoxDefaultButton, + MessageBoxModality, + ) + + # Informational message + MessageBox("Operation completed.", MessageBoxType.INFO).show() + + # Confirmation + result = MessageBox("Proceed with analysis?", MessageBoxType.YES_NO).show() + if result == MessageBoxResult.YES: + pass + + # Text input + material_id = MessageBox("Enter your material ID:", MessageBoxType.INPUT).show() + if material_id: + pass + + # Advanced options + opts = MessageBoxOptions( + icon=MessageBoxIcon.WARNING, + default_button=MessageBoxDefaultButton.BUTTON2, + modality=MessageBoxModality.TASK, + topmost=True, + right_align=False, + rtl_reading=False, + help_button=False, + set_foreground=True, + owner_hwnd=None, + ) + result = MessageBox( + "Retry failed operation?", + MessageBoxType.RETRY_CANCEL, + title="Moldflow", + options=opts, + ).show() + +Convenience methods +------------------- + +.. code-block:: python + + MessageBox.info("Saved") + MessageBox.warning("Low disk space") + MessageBox.error("Failed to save") + if MessageBox.confirm_yes_no("Proceed?") == MessageBoxResult.YES: + pass + + # Prompt text with validation + def is_nonempty(s: str) -> bool: + return bool(s.strip()) + + value = MessageBox.prompt_text( + "Enter ID:", + default_text="", + placeholder="e.g. MAT-123", + validator=is_nonempty, + ) + if value is not None: + pass + +Options +------- + +.. list-table:: MessageBoxOptions + :header-rows: 1 + + * - Parameter + - Type + - Description + * - icon + - MessageBoxIcon | None + - Override default icon + * - default_button + - MessageBoxDefaultButton | None + - Set default button (2/3/4). Validated vs type + * - modality + - MessageBoxModality | None + - Application (default), Task-modal, System-modal + * - topmost + - bool + - Keep message box on top + * - set_foreground + - bool + - Force foreground + * - right_align / rtl_reading + - bool + - Layout flags for right-to-left locales + * - help_button + - bool + - Show Help button + * - owner_hwnd + - int | None + - Owner window handle (improves modality/Z-order) + * - default_text / placeholder + - str | None + - Prefill text and cue banner for input dialog + * - is_password + - bool + - Mask input characters + * - char_limit + - int | None + - Maximum characters accepted (client-side) + * - width_dlu / height_dlu + - int | None + - Size the input dialog (dialog units) + * - validator + - Callable[[str], bool] | None + - Enable OK only when input satisfies predicate + * - font_face / font_size_pt + - str / int + - Font for input dialog (default Segoe UI 9pt) + +API +--- + +.. automodule:: moldflow.message_box + +Notes +----- + +- Localization: button captions ("OK", "Cancel"), title, and prompt are localized via the package i18n system. +- Return type: ``MessageBox.show()`` returns ``MessageBoxReturn`` (``MessageBoxResult | str | None``). diff --git a/src/moldflow/__init__.py b/src/moldflow/__init__.py index d875960..0a8f1cc 100644 --- a/src/moldflow/__init__.py +++ b/src/moldflow/__init__.py @@ -102,10 +102,20 @@ from .common import ViewModes from .common import UserPlotType +from .message_box import ( + MessageBox, + MessageBoxType, + MessageBoxResult, + MessageBoxOptions, + MessageBoxIcon, + MessageBoxModality, + MessageBoxDefaultButton, + MessageBoxReturn, +) + # Version checking and update functionality from .version_check import get_version, check_for_updates_on_import - # Check for updates on import unless disabled check_for_updates_on_import() diff --git a/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po b/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po index 4203e8c..f3235f9 100644 --- a/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po +++ b/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po @@ -155,3 +155,9 @@ msgstr "{value} hat keine gültige Dateierweiterung; muss {extensions} sein" msgid "{value} is not a valid {enum_name}" msgstr "{value} ist kein gültiges {enum_name}" + +msgid "OK" +msgstr "OK" + +msgid "Cancel" +msgstr "Abbrechen" diff --git a/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po b/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po index 792704b..1120598 100644 --- a/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po +++ b/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po @@ -3,6 +3,12 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: en-US\n" +msgid "OK" +msgstr "OK" + +msgid "Cancel" +msgstr "Cancel" + msgid "Checking file extension {file_name}" msgstr "Checking file extension {file_name}" diff --git a/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po b/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po index ca8a638..c9d3536 100644 --- a/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po +++ b/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po @@ -3,6 +3,12 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: es-ES\n" +msgid "OK" +msgstr "Aceptar" + +msgid "Cancel" +msgstr "Cancelar" + msgid "Checking file extension {file_name}" msgstr "Comprobando la extensión del archivo {file_name}" diff --git a/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po b/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po index 43fce62..14e0619 100644 --- a/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po +++ b/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po @@ -3,6 +3,12 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: fr-FR\n" +msgid "OK" +msgstr "OK" + +msgid "Cancel" +msgstr "Annuler" + msgid "Checking file extension {file_name}" msgstr "Vérification de l'extension du fichier {file_name}" diff --git a/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po b/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po index 3b257d0..7863cf7 100644 --- a/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po +++ b/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po @@ -3,6 +3,12 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: it-IT\n" +msgid "OK" +msgstr "OK" + +msgid "Cancel" +msgstr "Annulla" + msgid "Checking file extension {file_name}" msgstr "Verifica dell'estensione del file {file_name}" diff --git a/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po b/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po index 5f367eb..321ef17 100644 --- a/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po +++ b/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po @@ -3,6 +3,12 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: ja-JP\n" +msgid "OK" +msgstr "OK" + +msgid "Cancel" +msgstr "キャンセル" + msgid "Checking file extension {file_name}" msgstr "ファイル拡張子 {file_name} を確認しています" diff --git a/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po b/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po index 48752cd..0053223 100644 --- a/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po +++ b/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po @@ -3,6 +3,12 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: ko-KR\n" +msgid "OK" +msgstr "확인" + +msgid "Cancel" +msgstr "취소" + msgid "Checking file extension {file_name}" msgstr "파일 확장자 {file_name} 확인 중" diff --git a/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po b/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po index 9b6c70a..4795e72 100644 --- a/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po +++ b/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po @@ -3,6 +3,12 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: pt-PT\n" +msgid "OK" +msgstr "OK" + +msgid "Cancel" +msgstr "Cancelar" + msgid "Checking file extension {file_name}" msgstr "A verificar a extensão do ficheiro {file_name}" diff --git a/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po b/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po index 8bc6aab..98c6085 100644 --- a/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po +++ b/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po @@ -3,6 +3,12 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: zh-CN\n" +msgid "OK" +msgstr "确定" + +msgid "Cancel" +msgstr "取消" + msgid "Checking file extension {file_name}" msgstr "正在检查文件扩展名{file_name}" diff --git a/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po b/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po index cd4019f..bfa56f6 100644 --- a/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po +++ b/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po @@ -3,6 +3,12 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: zh-TW\n" +msgid "OK" +msgstr "確定" + +msgid "Cancel" +msgstr "取消" + msgid "Checking file extension {file_name}" msgstr "正在檢查檔案副檔名 {file_name}" diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py new file mode 100644 index 0000000..c593fee --- /dev/null +++ b/src/moldflow/message_box.py @@ -0,0 +1,742 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +""" +MessageBox convenience wrapper for Moldflow scripts. + +Provides simple info/warning/error dialogs, confirmation prompts, and a text +input dialog. Uses Win32 MessageBox for standard dialogs and a lightweight +custom Win32 dialog (ctypes) for text input. +""" + +from enum import Enum, auto +from typing import Optional, Union, Callable, TypeAlias +from dataclasses import dataclass +import ctypes +from dataclasses import dataclass +import platform +from ctypes import windll, wintypes, byref, create_unicode_buffer, c_int, c_wchar_p, WINFUNCTYPE +import struct +from .i18n import get_text + + +# Win32 MessageBox flags (from winuser.h) +WIN_MB_OK = 0x00000000 +WIN_MB_OKCANCEL = 0x00000001 +WIN_MB_ABORTRETRYIGNORE = 0x00000002 +WIN_MB_YESNOCANCEL = 0x00000003 +WIN_MB_YESNO = 0x00000004 +WIN_MB_RETRYCANCEL = 0x00000005 +WIN_MB_CANCELTRYCONTINUE = 0x00000006 + +WIN_MB_ICONERROR = 0x00000010 +WIN_MB_ICONQUESTION = 0x00000020 +WIN_MB_ICONWARNING = 0x00000030 +WIN_MB_ICONINFORMATION = 0x00000040 + +WIN_MB_DEFBUTTON2 = 0x00000100 +WIN_MB_DEFBUTTON3 = 0x00000200 +WIN_MB_DEFBUTTON4 = 0x00000300 + +WIN_MB_SYSTEMMODAL = 0x00001000 +WIN_MB_TASKMODAL = 0x00002000 +WIN_MB_HELP = 0x00004000 +WIN_MB_SETFOREGROUND = 0x00010000 +WIN_MB_TOPMOST = 0x00040000 +WIN_MB_RIGHT = 0x00080000 +WIN_MB_RTLREADING = 0x00100000 + +# Win32 MessageBox return IDs +WIN_IDOK = 1 +WIN_IDCANCEL = 2 +WIN_IDABORT = 3 +WIN_IDRETRY = 4 +WIN_IDIGNORE = 5 +WIN_IDYES = 6 +WIN_IDNO = 7 +WIN_IDTRYAGAIN = 10 +WIN_IDCONTINUE = 11 + +# Win32 dialog and control style flags (used by input dialog) +WIN_DS_SETFONT = 0x00000040 +WIN_DS_MODALFRAME = 0x00000080 +WIN_WS_CAPTION = 0x00C00000 +WIN_WS_SYSMENU = 0x00080000 + +WIN_WS_CHILD = 0x40000000 +WIN_WS_VISIBLE = 0x10000000 +WIN_WS_TABSTOP = 0x00010000 +WIN_WS_GROUP = 0x00020000 +WIN_WS_BORDER = 0x00800000 + +WIN_ES_AUTOHSCROLL = 0x00000080 +WIN_ES_PASSWORD = 0x00000020 +WIN_SS_LEFT = 0x00000000 +WIN_BS_DEFPUSHBUTTON = 0x00000001 +WIN_BS_PUSHBUTTON = 0x00000000 + +# Window messages +WIN_WM_INITDIALOG = 0x0110 +WIN_WM_COMMAND = 0x0111 + +# Edit control helpers +WIN_EM_SETCUEBANNER = 0x1501 +WIN_EN_CHANGE = 0x0300 +WIN_EM_LIMITTEXT = 0x00C5 + +# SetWindowPos flags and system metrics +WIN_SWP_NOSIZE = 0x0001 +WIN_SWP_NOZORDER = 0x0004 +WIN_SWP_NOACTIVATE = 0x0010 +WIN_SM_CXSCREEN = 0 +WIN_SM_CYSCREEN = 1 + +# Predefined control classes +WIN_CLASS_BUTTON = 0x0081 +WIN_CLASS_EDIT = 0x0080 +WIN_CLASS_STATIC = 0x0082 + +# Control IDs +WIN_ID_EDIT = 1001 +WIN_ID_OK = 1 +WIN_ID_CANCEL = 2 + +# Defaults +DEFAULT_TITLE = "Moldflow" + + +class MessageBoxType(Enum): + """ + Message box types supported by the convenience API. + + - INFO: Informational message with OK button + - WARNING: Warning message with OK button + - ERROR: Error message with OK button + - YES_NO: Confirmation dialog with Yes/No buttons + - YES_NO_CANCEL: Confirmation dialog with Yes/No/Cancel buttons + - OK_CANCEL: Prompt with OK/Cancel buttons + - RETRY_CANCEL: Prompt with Retry/Cancel buttons + - ABORT_RETRY_IGNORE: Prompt with Abort/Retry/Ignore buttons + - CANCEL_TRY_CONTINUE: Prompt with Cancel/Try Again/Continue buttons + - INPUT: Text input dialog returning a string + """ + + INFO = auto() + WARNING = auto() + ERROR = auto() + YES_NO = auto() + YES_NO_CANCEL = auto() + OK_CANCEL = auto() + RETRY_CANCEL = auto() + ABORT_RETRY_IGNORE = auto() + CANCEL_TRY_CONTINUE = auto() + INPUT = auto() + + +class MessageBoxResult(Enum): + """ + Result of a message box interaction. + + For INPUT type, the MessageBox.show() method returns a string rather than + a MessageBoxResult. For other types, it returns one of these values. + """ + + OK = auto() + CANCEL = auto() + YES = auto() + NO = auto() + RETRY = auto() + ABORT = auto() + IGNORE = auto() + TRY_AGAIN = auto() + CONTINUE = auto() + + +# Public type alias for show() return value +MessageBoxReturn: TypeAlias = Union[MessageBoxResult, Optional[str]] + + +class MessageBoxIcon(Enum): + """ + Icon to display on the message box. If not provided, a sensible default is + chosen based on the MessageBoxType. + """ + + NONE = auto() + INFORMATION = auto() + WARNING = auto() + ERROR = auto() + QUESTION = auto() + + +class MessageBoxModality(Enum): + """Modality for the message box window.""" + + APPLICATION = auto() # Default Win32 behavior (no explicit flag) + SYSTEM = auto() + TASK = auto() + + +class MessageBoxDefaultButton(Enum): + """Which button is the default (activated by Enter).""" + + BUTTON1 = auto() + BUTTON2 = auto() + BUTTON3 = auto() + BUTTON4 = auto() + + +# Mapping dictionaries (module-level) for flags and results +MAPPING_MESSAGEBOX_TYPE = { + MessageBoxType.INFO: (WIN_MB_OK, MessageBoxIcon.INFORMATION, 1), + MessageBoxType.WARNING: (WIN_MB_OK, MessageBoxIcon.WARNING, 1), + MessageBoxType.ERROR: (WIN_MB_OK, MessageBoxIcon.ERROR, 1), + MessageBoxType.YES_NO: (WIN_MB_YESNO, MessageBoxIcon.QUESTION, 2), + MessageBoxType.YES_NO_CANCEL: (WIN_MB_YESNOCANCEL, MessageBoxIcon.QUESTION, 3), + MessageBoxType.OK_CANCEL: (WIN_MB_OKCANCEL, MessageBoxIcon.INFORMATION, 2), + MessageBoxType.RETRY_CANCEL: (WIN_MB_RETRYCANCEL, MessageBoxIcon.WARNING, 2), + MessageBoxType.ABORT_RETRY_IGNORE: (WIN_MB_ABORTRETRYIGNORE, MessageBoxIcon.ERROR, 3), + MessageBoxType.CANCEL_TRY_CONTINUE: (WIN_MB_CANCELTRYCONTINUE, MessageBoxIcon.WARNING, 3), +} + +ICON_TO_FLAG = { + MessageBoxIcon.INFORMATION: WIN_MB_ICONINFORMATION, + MessageBoxIcon.WARNING: WIN_MB_ICONWARNING, + MessageBoxIcon.ERROR: WIN_MB_ICONERROR, + MessageBoxIcon.QUESTION: WIN_MB_ICONQUESTION, +} + +DEFAULT_BUTTON_TO_FLAG = { + MessageBoxDefaultButton.BUTTON2: (WIN_MB_DEFBUTTON2, 2), + MessageBoxDefaultButton.BUTTON3: (WIN_MB_DEFBUTTON3, 3), + MessageBoxDefaultButton.BUTTON4: (WIN_MB_DEFBUTTON4, 4), +} + +MODALITY_TO_FLAG = { + MessageBoxModality.SYSTEM: WIN_MB_SYSTEMMODAL, + MessageBoxModality.TASK: WIN_MB_TASKMODAL, +} + +ID_TO_RESULT = { + WIN_IDOK: MessageBoxResult.OK, + WIN_IDCANCEL: MessageBoxResult.CANCEL, + WIN_IDYES: MessageBoxResult.YES, + WIN_IDNO: MessageBoxResult.NO, + WIN_IDRETRY: MessageBoxResult.RETRY, + WIN_IDABORT: MessageBoxResult.ABORT, + WIN_IDIGNORE: MessageBoxResult.IGNORE, + WIN_IDTRYAGAIN: MessageBoxResult.TRY_AGAIN, + WIN_IDCONTINUE: MessageBoxResult.CONTINUE, +} + + +@dataclass(frozen=True) +class MessageBoxOptions: + """ + Optional advanced options for MessageBox. + + - icon: Overrides the default icon + - default_button: Choose default button (2/3/4). BUTTON1 is implicit default + - topmost: Keep message box on top of other windows + - modality: Application (default), Task-modal, or System-modal + - rtl_reading: Use right-to-left reading order + - right_align: Right align the message text + - help_button: Show a Help button + - set_foreground: Force the message box to the foreground + """ + + icon: Optional[MessageBoxIcon] = None + default_button: Optional[MessageBoxDefaultButton] = None + topmost: bool = False + modality: Optional[MessageBoxModality] = None + rtl_reading: bool = False + right_align: bool = False + help_button: bool = False + set_foreground: bool = False + owner_hwnd: Optional[int] = None + # Input dialog enhancements + default_text: Optional[str] = None + placeholder: Optional[str] = None + validator: Optional[Callable[[str], bool]] = None + font_face: str = "Segoe UI" + font_size_pt: int = 9 + is_password: bool = False + char_limit: Optional[int] = None + width_dlu: Optional[int] = None + height_dlu: Optional[int] = None + + def __post_init__(self) -> None: + # Normalize strings + normalized_face = (self.font_face or "Segoe UI").strip() + object.__setattr__(self, "font_face", normalized_face or "Segoe UI") + + # Clamp font size + size = self.font_size_pt + if not isinstance(size, int): + try: + size = int(size) + except Exception: + size = 9 + if size < 6: + size = 6 + if size > 24: + size = 24 + object.__setattr__(self, "font_size_pt", size) + + # Owner HWND must be non-negative + if self.owner_hwnd is not None and self.owner_hwnd < 0: + object.__setattr__(self, "owner_hwnd", 0) + + # Normalize default_text/placeholder + if self.default_text is not None: + object.__setattr__(self, "default_text", str(self.default_text)) + if self.placeholder is not None: + object.__setattr__(self, "placeholder", str(self.placeholder)) + + # Validate char_limit + if self.char_limit is not None and self.char_limit < 0: + object.__setattr__(self, "char_limit", 0) + + +class MessageBox: + """ + MessageBox convenience class. + + Example: + from moldflow import MessageBox, MessageBoxType + + # Information message + MessageBox("Operation completed.", MessageBoxType.INFO).show() + + # Yes/No prompt + result = MessageBox("Proceed with analysis?", MessageBoxType.YES_NO).show() + if result == MessageBoxResult.YES: + ... + + # Text input + material_id = MessageBox("Enter your material ID:", MessageBoxType.INPUT).show() + if material_id: + ... + """ + + def __init__( + self, + text: str, + box_type: MessageBoxType = MessageBoxType.INFO, + title: Optional[str] = None, + options: Optional[MessageBoxOptions] = None, + ) -> None: + if platform.system() != "Windows": + raise OSError("MessageBox is only supported on Windows.") + self.text = str(text) + self.box_type = box_type + self.title = title or DEFAULT_TITLE + self.options = options or MessageBoxOptions() + + def show(self) -> MessageBoxReturn: + """ + Show the message box. + + Returns: + - MessageBoxResult for INFO/WARNING/ERROR/YES_NO/OK_CANCEL + - str | None for INPUT (user-entered text or None if cancelled) + """ + + if self.box_type == MessageBoxType.INPUT: + return self._show_input_dialog() + return self._show_standard_dialog() + + @classmethod + def info( + cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None + ) -> MessageBoxResult: + return cls(text, MessageBoxType.INFO, title, options).show() # type: ignore[return-value] + + @classmethod + def warning( + cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None + ) -> MessageBoxResult: + return cls(text, MessageBoxType.WARNING, title, options).show() # type: ignore[return-value] + + @classmethod + def error( + cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None + ) -> MessageBoxResult: + return cls(text, MessageBoxType.ERROR, title, options).show() # type: ignore[return-value] + + @classmethod + def confirm_yes_no( + cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None + ) -> MessageBoxResult: + return cls(text, MessageBoxType.YES_NO, title, options).show() # type: ignore[return-value] + + @classmethod + def prompt_text( + cls, + prompt: str, + title: Optional[str] = None, + *, + default_text: Optional[str] = None, + placeholder: Optional[str] = None, + validator: Optional[Callable[[str], bool]] = None, + options: Optional[MessageBoxOptions] = None, + ) -> Optional[str]: + opts = options or MessageBoxOptions() + # Merge provided options with overrides for input UX + opts = MessageBoxOptions( + icon=opts.icon, + default_button=opts.default_button, + topmost=opts.topmost, + modality=opts.modality, + rtl_reading=opts.rtl_reading, + right_align=opts.right_align, + help_button=opts.help_button, + set_foreground=opts.set_foreground, + owner_hwnd=opts.owner_hwnd, + default_text=default_text if default_text is not None else opts.default_text, + placeholder=placeholder if placeholder is not None else opts.placeholder, + validator=validator if validator is not None else opts.validator, + font_face=opts.font_face, + font_size_pt=opts.font_size_pt, + ) + return cls(prompt, MessageBoxType.INPUT, title, opts).show() # type: ignore[return-value] + + def _show_standard_dialog(self) -> MessageBoxResult: + from ctypes import windll, c_wchar_p, c_int + + # Base type from box_type via module-level mapping dict + base_tuple = MAPPING_MESSAGEBOX_TYPE.get( + self.box_type, (WIN_MB_OK, MessageBoxIcon.INFORMATION, 1) + ) + u_type, default_icon, button_count = base_tuple + + # Icon selection (options override default) + icon = self.options.icon or default_icon + u_type |= ICON_TO_FLAG.get(icon, 0) + # NONE -> no icon flag + + # Default button + if self.options.default_button: + flag, required = DEFAULT_BUTTON_TO_FLAG.get(self.options.default_button, (0, 1)) + if button_count < required: + raise ValueError( + f"default_button {self.options.default_button.name} requires at least {required} buttons for type {self.box_type.name}" + ) + u_type |= flag + + # Modality + if self.options.modality: + u_type |= MODALITY_TO_FLAG.get(self.options.modality, 0) + + # Z-order / positioning + if self.options.topmost: + u_type |= WIN_MB_TOPMOST + if self.options.set_foreground: + u_type |= WIN_MB_SETFOREGROUND + + # Layout + if self.options.right_align: + u_type |= WIN_MB_RIGHT + if self.options.rtl_reading: + u_type |= WIN_MB_RTLREADING + + # Help button + if self.options.help_button: + u_type |= WIN_MB_HELP + + owner = self.options.owner_hwnd or 0 + # Trim whitespace to avoid accidental spaces + text = (self.text or "").strip() + # Do not translate titles + title = (self.title or "").strip() + result = windll.user32.MessageBoxW( + owner, c_wchar_p(text), c_wchar_p(title), c_int(u_type) + ) + if result == -1: + err = windll.kernel32.GetLastError() + raise ctypes.WinError(err) + + if result in ID_TO_RESULT: + return ID_TO_RESULT[result] + # Fallback + return MessageBoxResult.CANCEL + + def _show_input_dialog(self) -> Optional[str]: + dialog = _Win32InputDialog(self.title, self.text, self.options) + return dialog.run() + + +class _Win32InputDialog: + """ + Modal input dialog using DialogBoxIndirectParamW with an in-memory DLGTEMPLATE. + """ + + ID_EDIT = WIN_ID_EDIT + ID_OK = WIN_ID_OK + ID_CANCEL = WIN_ID_CANCEL + + DS_SETFONT = WIN_DS_SETFONT + DS_MODALFRAME = WIN_DS_MODALFRAME + WS_CAPTION = WIN_WS_CAPTION + WS_SYSMENU = WIN_WS_SYSMENU + + WS_CHILD = WIN_WS_CHILD + WS_VISIBLE = WIN_WS_VISIBLE + WS_TABSTOP = WIN_WS_TABSTOP + WS_GROUP = WIN_WS_GROUP + WS_BORDER = WIN_WS_BORDER + + ES_AUTOHSCROLL = WIN_ES_AUTOHSCROLL + SS_LEFT = WIN_SS_LEFT + BS_DEFPUSHBUTTON = WIN_BS_DEFPUSHBUTTON + BS_PUSHBUTTON = WIN_BS_PUSHBUTTON + + WM_INITDIALOG = WIN_WM_INITDIALOG + WM_COMMAND = WIN_WM_COMMAND + + def __init__(self, title: str, prompt: str, options: MessageBoxOptions) -> None: + self.title = title + self.prompt = prompt + self.options = options + self._result_text: Optional[str] = None + + def _wcs(self, s: str) -> bytes: + return s.encode("utf-16le") + b"\x00\x00" + + def _align_dword(self, buf: bytearray) -> None: + while len(buf) % 4 != 0: + buf += b"\x00" + + def _pack_word(self, buf: bytearray, val: int) -> None: + buf += struct.pack(" None: + buf += struct.pack(" None: + buf += struct.pack(" bytes: + # Dialog units and layout + cx = self.options.width_dlu if self.options.width_dlu is not None else 240 + cy = self.options.height_dlu if self.options.height_dlu is not None else 70 + margin = 7 + static_h = 8 + edit_h = 12 + btn_w, btn_h = 50, 14 + spacing = 4 + + ok_x = cx - margin - (btn_w * 2 + spacing) + cancel_x = cx - margin - btn_w + btn_y = cy - margin - btn_h + + buf = bytearray() + + style = self.DS_MODALFRAME | self.DS_SETFONT | self.WS_CAPTION | self.WS_SYSMENU + self._pack_dword(buf, style) # style + self._pack_dword(buf, 0) # dwExtendedStyle + self._pack_word(buf, 4) # cdit: static, edit, OK, Cancel + self._pack_short(buf, margin) # x + self._pack_short(buf, margin) # y + self._pack_short(buf, cx) # cx + self._pack_short(buf, cy) # cy + + self._pack_word(buf, 0) # menu = 0 + self._pack_word(buf, 0) # windowClass = 0 (default) + # Do not translate titles + buf += self._wcs(self.title) # title + + # Font (since DS_SETFONT) + self._pack_word(buf, max(6, int(self.options.font_size_pt))) # point size + buf += self._wcs(self.options.font_face or "Segoe UI") + + # DLGITEMTEMPLATEs must be DWORD-aligned + # 1) Static: prompt + self._align_dword(buf) + self._pack_dword(buf, self.WS_CHILD | self.WS_VISIBLE) + self._pack_dword(buf, 0) # ex style + self._pack_short(buf, margin) + self._pack_short(buf, margin) + self._pack_short(buf, cx - 2 * margin) + self._pack_short(buf, static_h) + self._pack_word(buf, 0) # id for static is usually 0 + # class: 0xFFFF, 0x0082 (STATIC) + self._pack_word(buf, 0xFFFF) + self._pack_word(buf, WIN_CLASS_STATIC) + # Do not translate prompt; callers pass text explicitly + buf += self._wcs(self.prompt) # title + self._pack_word(buf, 0) # no extra data + + # 2) Edit control + self._align_dword(buf) + edit_style = ( + self.WS_CHILD + | self.WS_VISIBLE + | self.WS_BORDER + | self.ES_AUTOHSCROLL + | self.WS_TABSTOP + ) + if self.options.is_password: + edit_style |= self.ES_PASSWORD + self._pack_dword(buf, edit_style) + self._pack_dword(buf, 0) + self._pack_short(buf, margin) + self._pack_short(buf, margin + static_h + 2) + self._pack_short(buf, cx - 2 * margin) + self._pack_short(buf, edit_h) + self._pack_word(buf, self.ID_EDIT) + # class: 0xFFFF, 0x0080 EDIT + self._pack_word(buf, 0xFFFF) + self._pack_word(buf, WIN_CLASS_EDIT) + self._pack_word(buf, 0) # empty text + self._pack_word(buf, 0) # no extra data + + # 3) OK button (default) + self._align_dword(buf) + self._pack_dword( + buf, + self.WS_CHILD + | self.WS_VISIBLE + | self.WS_TABSTOP + | self.WS_GROUP + | self.BS_DEFPUSHBUTTON, + ) + self._pack_dword(buf, 0) + self._pack_short(buf, ok_x) + self._pack_short(buf, btn_y) + self._pack_short(buf, btn_w) + self._pack_short(buf, btn_h) + _ = get_text() + self._pack_word(buf, self.ID_OK) + self._pack_word(buf, 0xFFFF) + self._pack_word(buf, WIN_CLASS_BUTTON) + buf += self._wcs(_("OK")) + self._pack_word(buf, 0) + + # 4) Cancel button + self._align_dword(buf) + self._pack_dword( + buf, self.WS_CHILD | self.WS_VISIBLE | self.WS_TABSTOP | self.BS_PUSHBUTTON + ) + self._pack_dword(buf, 0) + self._pack_short(buf, cancel_x) + self._pack_short(buf, btn_y) + self._pack_short(buf, btn_w) + self._pack_short(buf, btn_h) + self._pack_word(buf, self.ID_CANCEL) + self._pack_word(buf, 0xFFFF) + self._pack_word(buf, WIN_CLASS_BUTTON) + buf += self._wcs(_("Cancel")) + self._pack_word(buf, 0) + + self._align_dword(buf) + return bytes(buf) + + def run(self) -> Optional[str]: + user32 = windll.user32 + kernel32 = windll.kernel32 + + template_bytes = self._build_template() + # Keep buffer alive by storing on self + self._template_buffer = (wintypes.BYTE * len(template_bytes)).from_buffer_copy( + template_bytes + ) + + DLGPROC = WINFUNCTYPE( + wintypes.INT_PTR, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM + ) + + @DLGPROC + def _dlgproc(hwnd, msg, wparam, lparam): + if msg == self.WM_INITDIALOG: + # Set focus to edit control and prefill text / placeholder + h_edit = user32.GetDlgItem(hwnd, self.ID_EDIT) + user32.SetFocus(h_edit) + # Default text + if self.options.default_text: + user32.SetWindowTextW(h_edit, c_wchar_p(self.options.default_text)) + # Placeholder (cue banner) if available + if self.options.placeholder: + try: + # wParam=BOOL drawWhenNotFocused=1 + user32.SendMessageW( + h_edit, WIN_EM_SETCUEBANNER, 1, c_wchar_p(self.options.placeholder) + ) + except Exception: + pass + # Validator initial state + if self.options.validator is not None: + try: + is_valid = bool(self.options.validator(self.options.default_text or "")) + except Exception: + is_valid = True + user32.EnableWindow( + user32.GetDlgItem(hwnd, self.ID_OK), wintypes.BOOL(1 if is_valid else 0) + ) + # Character limit + if self.options.char_limit is not None: + user32.SendMessageW(h_edit, WIN_EM_LIMITTEXT, self.options.char_limit, 0) + + # Center dialog over owner + try: + owner_hwnd = self.options.owner_hwnd or user32.GetActiveWindow() + if owner_hwnd: + rect = wintypes.RECT() + user32.GetWindowRect(owner_hwnd, byref(rect)) + owner_cx = rect.right - rect.left + owner_cy = rect.bottom - rect.top + + dlg_rect = wintypes.RECT() + user32.GetWindowRect(hwnd, byref(dlg_rect)) + dlg_w = dlg_rect.right - dlg_rect.left + dlg_h = dlg_rect.bottom - dlg_rect.top + + x = rect.left + (owner_cx - dlg_w) // 2 + y = rect.top + (owner_cy - dlg_h) // 2 + user32.SetWindowPos( + hwnd, 0, x, y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER | WIN_SWP_NOACTIVATE + ) + except Exception: + pass + return 0 + if msg == self.WM_COMMAND: + cid = wparam & 0xFFFF + notify_code = (wparam >> 16) & 0xFFFF + # Live validation + if notify_code == WIN_EN_CHANGE and self.options.validator is not None: + h_edit = user32.GetDlgItem(hwnd, self.ID_EDIT) + length = user32.GetWindowTextLengthW(h_edit) + buf = create_unicode_buffer(length + 1) + user32.GetWindowTextW(h_edit, buf, length + 1) + try: + is_valid = bool(self.options.validator(buf.value)) + except Exception: + is_valid = True + user32.EnableWindow( + user32.GetDlgItem(hwnd, self.ID_OK), wintypes.BOOL(1 if is_valid else 0) + ) + if cid == self.ID_OK: + # Read text + h_edit = user32.GetDlgItem(hwnd, self.ID_EDIT) + length = user32.GetWindowTextLengthW(h_edit) + buf = create_unicode_buffer(length + 1) + user32.GetWindowTextW(h_edit, buf, length + 1) + self._result_text = buf.value + user32.EndDialog(hwnd, self.ID_OK) + return 1 + if cid == self.ID_CANCEL: + self._result_text = None + user32.EndDialog(hwnd, self.ID_CANCEL) + return 1 + return 0 + + hInstance = kernel32.GetModuleHandleW(None) + owner = self.options.owner_hwnd or user32.GetActiveWindow() + res = user32.DialogBoxIndirectParamW( + hInstance, byref(self._template_buffer), owner, _dlgproc, 0 + ) + # res is IDOK/IDCANCEL or -1 on failure + if res == -1: + err = kernel32.GetLastError() + raise ctypes.WinError(err) + return self._result_text if res == self.ID_OK else None From ab824cd722c5f80319397fa6ec3c2b2a7b0b7bbc Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Thu, 18 Sep 2025 22:31:44 +1000 Subject: [PATCH 02/21] order --- .../locale/de-DE/LC_MESSAGES/locale.de-DE.po | 12 ++++++------ .../locale/en-US/LC_MESSAGES/locale.en-US.po | 6 +++--- .../locale/es-ES/LC_MESSAGES/locale.es-ES.po | 6 +++--- .../locale/fr-FR/LC_MESSAGES/locale.fr-FR.po | 6 +++--- .../locale/it-IT/LC_MESSAGES/locale.it-IT.po | 6 +++--- .../locale/ja-JP/LC_MESSAGES/locale.ja-JP.po | 6 +++--- .../locale/ko-KR/LC_MESSAGES/locale.ko-KR.po | 6 +++--- .../locale/pt-PT/LC_MESSAGES/locale.pt-PT.po | 6 +++--- .../locale/zh-CN/LC_MESSAGES/locale.zh-CN.po | 6 +++--- .../locale/zh-TW/LC_MESSAGES/locale.zh-TW.po | 6 +++--- 10 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po b/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po index f3235f9..273fc71 100644 --- a/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po +++ b/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po @@ -3,6 +3,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: de-DE\n" +msgid "Cancel" +msgstr "Abbrechen" + msgid "Checking file extension {file_name}" msgstr "Überprüfen der Dateierweiterung {file_name}" @@ -72,6 +75,9 @@ msgstr "Ungültiger Wert: {reason}" msgid "Logger was not setup" msgstr "Logger wurde nicht eingerichtet" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "Speicherfehler" @@ -155,9 +161,3 @@ msgstr "{value} hat keine gültige Dateierweiterung; muss {extensions} sein" msgid "{value} is not a valid {enum_name}" msgstr "{value} ist kein gültiges {enum_name}" - -msgid "OK" -msgstr "OK" - -msgid "Cancel" -msgstr "Abbrechen" diff --git a/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po b/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po index 1120598..960bbbd 100644 --- a/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po +++ b/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po @@ -3,9 +3,6 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: en-US\n" -msgid "OK" -msgstr "OK" - msgid "Cancel" msgstr "Cancel" @@ -78,6 +75,9 @@ msgstr "Invalid Value: {reason}" msgid "Logger was not setup" msgstr "Logger was not setup" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "Save Error" diff --git a/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po b/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po index c9d3536..36ce4a4 100644 --- a/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po +++ b/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po @@ -3,9 +3,6 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: es-ES\n" -msgid "OK" -msgstr "Aceptar" - msgid "Cancel" msgstr "Cancelar" @@ -78,6 +75,9 @@ msgstr "Valor no válido: {reason}" msgid "Logger was not setup" msgstr "Logger no estaba configurado" +msgid "OK" +msgstr "Aceptar" + msgid "Save Error" msgstr "Error al guardar" diff --git a/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po b/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po index 14e0619..faa1ee6 100644 --- a/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po +++ b/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po @@ -3,9 +3,6 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: fr-FR\n" -msgid "OK" -msgstr "OK" - msgid "Cancel" msgstr "Annuler" @@ -78,6 +75,9 @@ msgstr "Valeur non valide: {reason}" msgid "Logger was not setup" msgstr "Logger n'était pas configuré" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "Erreur d'enregistrement" diff --git a/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po b/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po index 7863cf7..147c58c 100644 --- a/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po +++ b/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po @@ -3,9 +3,6 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: it-IT\n" -msgid "OK" -msgstr "OK" - msgid "Cancel" msgstr "Annulla" @@ -78,6 +75,9 @@ msgstr "Valore non valido: {reason}" msgid "Logger was not setup" msgstr "Logger non è stato configurato" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "Errore di salvataggio" diff --git a/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po b/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po index 321ef17..edd800f 100644 --- a/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po +++ b/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po @@ -3,9 +3,6 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: ja-JP\n" -msgid "OK" -msgstr "OK" - msgid "Cancel" msgstr "キャンセル" @@ -78,6 +75,9 @@ msgstr "無効な値: {reason}" msgid "Logger was not setup" msgstr "ロガーが設定されていませんでした" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "保存エラー" diff --git a/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po b/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po index 0053223..217c91f 100644 --- a/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po +++ b/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po @@ -3,9 +3,6 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: ko-KR\n" -msgid "OK" -msgstr "확인" - msgid "Cancel" msgstr "취소" @@ -78,6 +75,9 @@ msgstr "잘못된 값: {reason}" msgid "Logger was not setup" msgstr "로거가 설정되지 않았습니다" +msgid "OK" +msgstr "확인" + msgid "Save Error" msgstr "저장 오류" diff --git a/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po b/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po index 4795e72..0978174 100644 --- a/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po +++ b/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po @@ -3,9 +3,6 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: pt-PT\n" -msgid "OK" -msgstr "OK" - msgid "Cancel" msgstr "Cancelar" @@ -78,6 +75,9 @@ msgstr "Valor inválido: {reason}" msgid "Logger was not setup" msgstr "Logger não foi configurado" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "Erro ao guardar" diff --git a/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po b/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po index 98c6085..429f421 100644 --- a/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po +++ b/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po @@ -3,9 +3,6 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: zh-CN\n" -msgid "OK" -msgstr "确定" - msgid "Cancel" msgstr "取消" @@ -78,6 +75,9 @@ msgstr "无效的值:{reason}" msgid "Logger was not setup" msgstr "没有设置记录器" +msgid "OK" +msgstr "确定" + msgid "Save Error" msgstr "保存错误" diff --git a/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po b/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po index bfa56f6..941ce77 100644 --- a/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po +++ b/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po @@ -3,9 +3,6 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: zh-TW\n" -msgid "OK" -msgstr "確定" - msgid "Cancel" msgstr "取消" @@ -78,6 +75,9 @@ msgstr "無效的值:{reason}" msgid "Logger was not setup" msgstr "記錄器未設定" +msgid "OK" +msgstr "確定" + msgid "Save Error" msgstr "儲存錯誤" From e069b1a2bebe89b82c73c9d0fbb7e7ce4b303ed7 Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Thu, 18 Sep 2025 22:36:30 +1000 Subject: [PATCH 03/21] allow ok for localisation --- scripts/check_localization.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/check_localization.py b/scripts/check_localization.py index c0be77b..18eab31 100644 --- a/scripts/check_localization.py +++ b/scripts/check_localization.py @@ -38,6 +38,11 @@ from typing import Dict, List, Tuple +# Strings that are acceptable to remain identical across locales +ALLOW_EQUAL_MSGSTR: set[str] = { + "OK", +} + @dataclass class PoEntry: msgid: str @@ -357,9 +362,13 @@ def check_translation_gaps(self) -> List[str]: if not po_parser.has_string(msgid): missing_translations.append(msgid) else: - # Check if translation is empty or same as source (untranslated) + # Check if translation is empty or same as source (untranslated), + # with an allowlist for locale-invariant tokens like "OK" target_entry = po_parser.entries[msgid] - if not target_entry.msgstr.strip() or target_entry.msgstr == msgid: + if ( + not target_entry.msgstr.strip() + or (target_entry.msgstr == msgid and msgid not in ALLOW_EQUAL_MSGSTR) + ): empty_translations.append(msgid) locale_stats[locale_name] = { From 48e466a233d2ebcea703d2a0c0977be55a0153f8 Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Thu, 18 Sep 2025 22:45:56 +1000 Subject: [PATCH 04/21] add integration test --- .../test_message_box_permutations.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/api/integration_tests/test_message_box_permutations.py diff --git a/tests/api/integration_tests/test_message_box_permutations.py b/tests/api/integration_tests/test_message_box_permutations.py new file mode 100644 index 0000000..1d3fee6 --- /dev/null +++ b/tests/api/integration_tests/test_message_box_permutations.py @@ -0,0 +1,100 @@ +import platform +import threading +import time +from ctypes import windll, wintypes, c_wchar_p + +import pytest + +from moldflow import ( + MessageBox, + MessageBoxType, + MessageBoxResult, + MessageBoxOptions, + MessageBoxIcon, + MessageBoxDefaultButton, + MessageBoxModality, +) + + +pytestmark = pytest.mark.skipif(platform.system() != "Windows", reason="Windows-only UI test") + + +# Win32 constants for automation +WM_COMMAND = 0x0111 +IDOK = 1 +IDCANCEL = 2 +IDYES = 6 +IDNO = 7 +IDRETRY = 4 + + +def _click_dialog_button_async(dialog_title: str, button_id: int, delay_s: float = 0.4) -> None: + """Helper: simulate clicking a button on a dialog by title after a small delay.""" + + def _worker(): + user32 = windll.user32 + # Wait a moment for the dialog to appear + time.sleep(delay_s) + # Try to find and click for up to ~5 seconds + for _ in range(50): + hwnd = user32.FindWindowW(None, c_wchar_p(dialog_title)) + if hwnd: + user32.PostMessageW(hwnd, WM_COMMAND, button_id, 0) + return + time.sleep(0.1) + + threading.Thread(target=_worker, daemon=True).start() + + +def _iter_types_and_defaults(): + """Yield (type, valid default_button flags, button_id_to_click).""" + mapping = { + MessageBoxType.INFO: (1, IDOK), + MessageBoxType.WARNING: (1, IDOK), + MessageBoxType.ERROR: (1, IDOK), + MessageBoxType.OK_CANCEL: (2, IDCANCEL), + MessageBoxType.YES_NO: (2, IDYES), + MessageBoxType.RETRY_CANCEL: (2, IDCANCEL), + MessageBoxType.YES_NO_CANCEL: (3, IDYES), + MessageBoxType.ABORT_RETRY_IGNORE: (3, IDRETRY), + MessageBoxType.CANCEL_TRY_CONTINUE: (3, IDCANCEL), + } + for t, (count, click_id) in mapping.items(): + defaults = [None] + if count >= 2: + defaults.append(MessageBoxDefaultButton.BUTTON2) + if count >= 3: + defaults.append(MessageBoxDefaultButton.BUTTON3) + if count >= 4: + defaults.append(MessageBoxDefaultButton.BUTTON4) + yield t, defaults, click_id + + +def test_message_box_permutations(): + icons = [None, MessageBoxIcon.INFORMATION, MessageBoxIcon.WARNING, MessageBoxIcon.ERROR, MessageBoxIcon.QUESTION] + modalities = [None, MessageBoxModality.TASK, MessageBoxModality.SYSTEM] + + for box_type, default_buttons, click_id in _iter_types_and_defaults(): + for icon in icons: + for default_button in default_buttons: + for modality in modalities: + opts = MessageBoxOptions(icon=icon, default_button=default_button, modality=modality) + title = f"Test: {box_type.name}" + # Auto click to allow unattended run + _click_dialog_button_async(title, click_id) + result = MessageBox(f"{box_type.name} - {getattr(icon,'name','NONE')} - {getattr(default_button,'name','BUTTON1')} - {getattr(modality,'name','APPLICATION')}", box_type, title=title, options=opts).show() + assert isinstance(result, MessageBoxResult) + + +def test_message_box_input_variants(): + variants = [ + MessageBoxOptions(default_text="auto"), + MessageBoxOptions(default_text="auto", is_password=True), + MessageBoxOptions(default_text="auto", char_limit=10), + MessageBoxOptions(default_text="auto", width_dlu=280, height_dlu=90), + ] + for i, opts in enumerate(variants, 1): + title = f"Test: INPUT #{i}" + _click_dialog_button_async(title, IDOK) + value = MessageBox("Enter sample text", MessageBoxType.INPUT, title=title, options=opts).show() + assert isinstance(value, (str, type(None))) From f3fc388c2799a948bd1c5df05210066555e7a25b Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Thu, 18 Sep 2025 23:00:38 +1000 Subject: [PATCH 05/21] format --- src/moldflow/message_box.py | 18 +++++++-------- .../test_message_box_permutations.py | 23 +++++++++++++++---- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index c593fee..a61623a 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -449,9 +449,7 @@ def _show_standard_dialog(self) -> MessageBoxResult: text = (self.text or "").strip() # Do not translate titles title = (self.title or "").strip() - result = windll.user32.MessageBoxW( - owner, c_wchar_p(text), c_wchar_p(title), c_int(u_type) - ) + result = windll.user32.MessageBoxW(owner, c_wchar_p(text), c_wchar_p(title), c_int(u_type)) if result == -1: err = windll.kernel32.GetLastError() raise ctypes.WinError(err) @@ -570,11 +568,7 @@ def _build_template(self) -> bytes: # 2) Edit control self._align_dword(buf) edit_style = ( - self.WS_CHILD - | self.WS_VISIBLE - | self.WS_BORDER - | self.ES_AUTOHSCROLL - | self.WS_TABSTOP + self.WS_CHILD | self.WS_VISIBLE | self.WS_BORDER | self.ES_AUTOHSCROLL | self.WS_TABSTOP ) if self.options.is_password: edit_style |= self.ES_PASSWORD @@ -694,7 +688,13 @@ def _dlgproc(hwnd, msg, wparam, lparam): x = rect.left + (owner_cx - dlg_w) // 2 y = rect.top + (owner_cy - dlg_h) // 2 user32.SetWindowPos( - hwnd, 0, x, y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER | WIN_SWP_NOACTIVATE + hwnd, + 0, + x, + y, + 0, + 0, + WIN_SWP_NOSIZE | WIN_SWP_NOZORDER | WIN_SWP_NOACTIVATE, ) except Exception: pass diff --git a/tests/api/integration_tests/test_message_box_permutations.py b/tests/api/integration_tests/test_message_box_permutations.py index 1d3fee6..dc88f34 100644 --- a/tests/api/integration_tests/test_message_box_permutations.py +++ b/tests/api/integration_tests/test_message_box_permutations.py @@ -71,18 +71,31 @@ def _iter_types_and_defaults(): def test_message_box_permutations(): - icons = [None, MessageBoxIcon.INFORMATION, MessageBoxIcon.WARNING, MessageBoxIcon.ERROR, MessageBoxIcon.QUESTION] + icons = [ + None, + MessageBoxIcon.INFORMATION, + MessageBoxIcon.WARNING, + MessageBoxIcon.ERROR, + MessageBoxIcon.QUESTION, + ] modalities = [None, MessageBoxModality.TASK, MessageBoxModality.SYSTEM] for box_type, default_buttons, click_id in _iter_types_and_defaults(): for icon in icons: for default_button in default_buttons: for modality in modalities: - opts = MessageBoxOptions(icon=icon, default_button=default_button, modality=modality) + opts = MessageBoxOptions( + icon=icon, default_button=default_button, modality=modality + ) title = f"Test: {box_type.name}" # Auto click to allow unattended run _click_dialog_button_async(title, click_id) - result = MessageBox(f"{box_type.name} - {getattr(icon,'name','NONE')} - {getattr(default_button,'name','BUTTON1')} - {getattr(modality,'name','APPLICATION')}", box_type, title=title, options=opts).show() + result = MessageBox( + f"{box_type.name} - {getattr(icon,'name','NONE')} - {getattr(default_button,'name','BUTTON1')} - {getattr(modality,'name','APPLICATION')}", + box_type, + title=title, + options=opts, + ).show() assert isinstance(result, MessageBoxResult) @@ -96,5 +109,7 @@ def test_message_box_input_variants(): for i, opts in enumerate(variants, 1): title = f"Test: INPUT #{i}" _click_dialog_button_async(title, IDOK) - value = MessageBox("Enter sample text", MessageBoxType.INPUT, title=title, options=opts).show() + value = MessageBox( + "Enter sample text", MessageBoxType.INPUT, title=title, options=opts + ).show() assert isinstance(value, (str, type(None))) From 720dba6a9bc7b19504f3c9fd6dd27d86a84ff8b3 Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Thu, 18 Sep 2025 23:47:32 +1000 Subject: [PATCH 06/21] lint --- src/moldflow/message_box.py | 80 ++++++++++++++----- .../test_message_box_permutations.py | 21 +++-- 2 files changed, 75 insertions(+), 26 deletions(-) diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index a61623a..e8818e7 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -13,12 +13,15 @@ from typing import Optional, Union, Callable, TypeAlias from dataclasses import dataclass import ctypes -from dataclasses import dataclass import platform from ctypes import windll, wintypes, byref, create_unicode_buffer, c_int, c_wchar_p, WINFUNCTYPE import struct from .i18n import get_text +# Helper alias for pointer-sized integer type used by Win32 callbacks +# pylint: disable=invalid-name +INT_PTR = ctypes.c_void_p + # Win32 MessageBox flags (from winuser.h) WIN_MB_OK = 0x00000000 @@ -231,7 +234,7 @@ class MessageBoxDefaultButton(Enum): @dataclass(frozen=True) -class MessageBoxOptions: +class MessageBoxOptions: # pylint: disable=too-many-instance-attributes """ Optional advanced options for MessageBox. @@ -277,10 +280,8 @@ def __post_init__(self) -> None: size = int(size) except Exception: size = 9 - if size < 6: - size = 6 - if size > 24: - size = 24 + # Clamp font size between sensible bounds + size = max(6, min(size, 24)) object.__setattr__(self, "font_size_pt", size) # Owner HWND must be non-negative @@ -350,37 +351,54 @@ def show(self) -> MessageBoxReturn: def info( cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None ) -> MessageBoxResult: - return cls(text, MessageBoxType.INFO, title, options).show() # type: ignore[return-value] + """ + Show an informational message box with an OK button. + """ + inst = cls(text, MessageBoxType.INFO, title, options) + return inst.show() # type: ignore[return-value] @classmethod def warning( cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None ) -> MessageBoxResult: - return cls(text, MessageBoxType.WARNING, title, options).show() # type: ignore[return-value] + """ + Show a warning message box with an OK button. + """ + inst = cls(text, MessageBoxType.WARNING, title, options) + return inst.show() # type: ignore[return-value] @classmethod def error( cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None ) -> MessageBoxResult: - return cls(text, MessageBoxType.ERROR, title, options).show() # type: ignore[return-value] + """ + Show an error message box with an OK button. + """ + inst = cls(text, MessageBoxType.ERROR, title, options) + return inst.show() # type: ignore[return-value] @classmethod def confirm_yes_no( cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None ) -> MessageBoxResult: + """ + Show a confirmation message box with Yes/No buttons. + """ return cls(text, MessageBoxType.YES_NO, title, options).show() # type: ignore[return-value] @classmethod - def prompt_text( + def prompt_text( # pylint: disable=too-many-arguments,too-many-positional-arguments cls, prompt: str, title: Optional[str] = None, - *, default_text: Optional[str] = None, placeholder: Optional[str] = None, validator: Optional[Callable[[str], bool]] = None, options: Optional[MessageBoxOptions] = None, ) -> Optional[str]: + """ + Show a text input dialog. + """ opts = options or MessageBoxOptions() # Merge provided options with overrides for input UX opts = MessageBoxOptions( @@ -402,7 +420,10 @@ def prompt_text( return cls(prompt, MessageBoxType.INPUT, title, opts).show() # type: ignore[return-value] def _show_standard_dialog(self) -> MessageBoxResult: - from ctypes import windll, c_wchar_p, c_int + """ + Show a standard Win32 MessageBox dialog and return the result. + """ + # Use module-level ctypes imports to avoid reimport and name shadowing # Base type from box_type via module-level mapping dict base_tuple = MAPPING_MESSAGEBOX_TYPE.get( @@ -419,8 +440,11 @@ def _show_standard_dialog(self) -> MessageBoxResult: if self.options.default_button: flag, required = DEFAULT_BUTTON_TO_FLAG.get(self.options.default_button, (0, 1)) if button_count < required: + # The error message is intentionally descriptive; allow a + # slightly longer line here rather than make it unreadable. + # pylint: disable=line-too-long raise ValueError( - f"default_button {self.options.default_button.name} requires at least {required} buttons for type {self.box_type.name}" + f"default_button {self.options.default_button.name} requires >={required} buttons for {self.box_type.name}" ) u_type |= flag @@ -460,6 +484,9 @@ def _show_standard_dialog(self) -> MessageBoxResult: return MessageBoxResult.CANCEL def _show_input_dialog(self) -> Optional[str]: + """ + Show a text input dialog. + """ dialog = _Win32InputDialog(self.title, self.text, self.options) return dialog.run() @@ -485,6 +512,7 @@ class _Win32InputDialog: WS_BORDER = WIN_WS_BORDER ES_AUTOHSCROLL = WIN_ES_AUTOHSCROLL + ES_PASSWORD = WIN_ES_PASSWORD SS_LEFT = WIN_SS_LEFT BS_DEFPUSHBUTTON = WIN_BS_DEFPUSHBUTTON BS_PUSHBUTTON = WIN_BS_PUSHBUTTON @@ -497,24 +525,34 @@ def __init__(self, title: str, prompt: str, options: MessageBoxOptions) -> None: self.prompt = prompt self.options = options self._result_text: Optional[str] = None + # Template buffer is created when running the dialog; initialize attribute + self._template_buffer: Optional[bytes] = None def _wcs(self, s: str) -> bytes: + """Return a UTF-16LE encoded, null-terminated bytestring for s.""" return s.encode("utf-16le") + b"\x00\x00" def _align_dword(self, buf: bytearray) -> None: + """Pad buffer until its length is a multiple of 4 (DWORD alignment).""" while len(buf) % 4 != 0: buf += b"\x00" def _pack_word(self, buf: bytearray, val: int) -> None: + """Pack a 16-bit unsigned value into the buffer.""" buf += struct.pack(" None: + """Pack a 32-bit unsigned value into the buffer.""" buf += struct.pack(" None: + """Pack a 16-bit signed value into the buffer.""" buf += struct.pack(" bytes: + # The dialog template is relatively verbose; allow pylint to accept the + # complexity here rather than refactor the Win32 packing code. + # pylint: disable=too-many-locals,too-many-statements # Dialog units and layout cx = self.options.width_dlu if self.options.width_dlu is not None else 240 cy = self.options.height_dlu if self.options.height_dlu is not None else 70 @@ -627,6 +665,10 @@ def _build_template(self) -> bytes: return bytes(buf) def run(self) -> Optional[str]: + """Create and run the modal dialog; return the entered text or None.""" + # The dialog procedure and Win32 interop are inherently complex; relax + # a few pylint rules for this method. + # pylint: disable=too-many-locals,too-many-branches,too-many-statements,invalid-name user32 = windll.user32 kernel32 = windll.kernel32 @@ -636,12 +678,12 @@ def run(self) -> Optional[str]: template_bytes ) - DLGPROC = WINFUNCTYPE( - wintypes.INT_PTR, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM + dlgproc_type = WINFUNCTYPE( + INT_PTR, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM ) - @DLGPROC - def _dlgproc(hwnd, msg, wparam, lparam): + @dlgproc_type + def _dlgproc(hwnd, msg, wparam, _lparam): if msg == self.WM_INITDIALOG: # Set focus to edit control and prefill text / placeholder h_edit = user32.GetDlgItem(hwnd, self.ID_EDIT) @@ -730,10 +772,10 @@ def _dlgproc(hwnd, msg, wparam, lparam): return 1 return 0 - hInstance = kernel32.GetModuleHandleW(None) + h_instance = kernel32.GetModuleHandleW(None) owner = self.options.owner_hwnd or user32.GetActiveWindow() res = user32.DialogBoxIndirectParamW( - hInstance, byref(self._template_buffer), owner, _dlgproc, 0 + h_instance, byref(self._template_buffer), owner, _dlgproc, 0 ) # res is IDOK/IDCANCEL or -1 on failure if res == -1: diff --git a/tests/api/integration_tests/test_message_box_permutations.py b/tests/api/integration_tests/test_message_box_permutations.py index dc88f34..ca4a798 100644 --- a/tests/api/integration_tests/test_message_box_permutations.py +++ b/tests/api/integration_tests/test_message_box_permutations.py @@ -1,7 +1,9 @@ +"""Integration tests for message box permutations (Windows only).""" + import platform import threading import time -from ctypes import windll, wintypes, c_wchar_p +from ctypes import windll, c_wchar_p import pytest @@ -71,6 +73,8 @@ def _iter_types_and_defaults(): def test_message_box_permutations(): + """Exercise combinations of types, icons, default buttons and modality.""" + icons = [ None, MessageBoxIcon.INFORMATION, @@ -90,16 +94,19 @@ def test_message_box_permutations(): title = f"Test: {box_type.name}" # Auto click to allow unattended run _click_dialog_button_async(title, click_id) - result = MessageBox( - f"{box_type.name} - {getattr(icon,'name','NONE')} - {getattr(default_button,'name','BUTTON1')} - {getattr(modality,'name','APPLICATION')}", - box_type, - title=title, - options=opts, - ).show() + msg = ( + f"{box_type.name} - {getattr(icon, 'name', 'NONE')} - " + f"{getattr(default_button, 'name', 'BUTTON1')} - " + f"{getattr(modality, 'name', 'APPLICATION')}" + ) + result = MessageBox(msg, box_type, title=title, options=opts).show() assert isinstance(result, MessageBoxResult) def test_message_box_input_variants(): + """ + Exercise input dialog with various options. + """ variants = [ MessageBoxOptions(default_text="auto"), MessageBoxOptions(default_text="auto", is_password=True), From 8c6771a7e50a3ed4448f8636737f3d18794010bf Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 00:25:02 +1000 Subject: [PATCH 07/21] fix test --- .../test_message_box_permutations.py | 95 ++++++++++++++++--- 1 file changed, 84 insertions(+), 11 deletions(-) diff --git a/tests/api/integration_tests/test_message_box_permutations.py b/tests/api/integration_tests/test_message_box_permutations.py index ca4a798..78b5c60 100644 --- a/tests/api/integration_tests/test_message_box_permutations.py +++ b/tests/api/integration_tests/test_message_box_permutations.py @@ -1,11 +1,14 @@ """Integration tests for message box permutations (Windows only).""" -import platform +# These tests perform UI automation and include short-lived closures and +# complex helper logic. Suppress a few linter warnings that are noisy for +# this test file and do not improve correctness: +# pylint: disable=cell-var-from-loop,unused-argument,too-many-branches,too-many-statements + import threading import time -from ctypes import windll, c_wchar_p - -import pytest +import ctypes +from ctypes import windll, c_wchar_p, wintypes from moldflow import ( MessageBox, @@ -17,12 +20,9 @@ MessageBoxModality, ) - -pytestmark = pytest.mark.skipif(platform.system() != "Windows", reason="Windows-only UI test") - - # Win32 constants for automation WM_COMMAND = 0x0111 +BM_CLICK = 0x00F5 IDOK = 1 IDCANCEL = 2 IDYES = 6 @@ -38,11 +38,84 @@ def _worker(): # Wait a moment for the dialog to appear time.sleep(delay_s) # Try to find and click for up to ~5 seconds - for _ in range(50): + for _ in range(100): hwnd = user32.FindWindowW(None, c_wchar_p(dialog_title)) if hwnd: - user32.PostMessageW(hwnd, WM_COMMAND, button_id, 0) - return + # Try to find child button control and click it directly + try: + hbtn = user32.GetDlgItem(hwnd, button_id) + if hbtn: + user32.SendMessageW(hbtn, BM_CLICK, 0, 0) + return + + children = [] + + @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + def _child_enum_proc(hchild, _): + # Get class name + cname_buf = ctypes.create_unicode_buffer(256) + user32.GetClassNameW(hchild, cname_buf, 256) + cname = cname_buf.value + # Get text + tbuf = ctypes.create_unicode_buffer(512) + user32.GetWindowTextW(hchild, tbuf, 512) + text = tbuf.value + # Get control id + try: + cid = user32.GetDlgCtrlID(hchild) + except Exception: + cid = 0 + children.append((hchild, cname, text, cid)) + return True + + user32.EnumChildWindows(hwnd, _child_enum_proc, 0) + + # Try to click first Button child + for hchild, cname, _, _ in children: + if cname and cname.lower().startswith("button"): + user32.SendMessageW(hchild, BM_CLICK, 0, 0) + return + + except Exception: + # Fallback to posting WM_COMMAND + try: + user32.PostMessageW(hwnd, WM_COMMAND, button_id, 0) + return + except Exception: + pass + else: + # Fallback: enumerate top-level windows and try to find one whose + # title contains the dialog title as a substring (more tolerant). + try: + found = [] + + @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + def _enum_proc(h, _): + buf = ctypes.create_unicode_buffer(512) + user32.GetWindowTextW(h, buf, 512) + txt = buf.value + if txt and dialog_title in txt: + found.append(h) + return False # stop enumeration + return True + + user32.EnumWindows(_enum_proc, 0) + if found: + hwnd = found[0] + try: + hbtn = user32.GetDlgItem(hwnd, button_id) + if hbtn: + user32.SendMessageW(hbtn, BM_CLICK, 0, 0) + return + except Exception: + try: + user32.PostMessageW(hwnd, WM_COMMAND, button_id, 0) + return + except Exception: + pass + except Exception: + # EnumWindows may fail in some restricted contexts; ignore + pass time.sleep(0.1) threading.Thread(target=_worker, daemon=True).start() From 7608f46c883d0eec8f83218df023b2a11c4e18cb Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 16:21:22 +1000 Subject: [PATCH 08/21] working --- src/moldflow/message_box.py | 706 +++++++++++++++--- .../test_message_box_permutations.py | 7 +- 2 files changed, 600 insertions(+), 113 deletions(-) diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index e8818e7..b217489 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -15,12 +15,47 @@ import ctypes import platform from ctypes import windll, wintypes, byref, create_unicode_buffer, c_int, c_wchar_p, WINFUNCTYPE +import signal import struct from .i18n import get_text +# Fallbacks for missing wintypes aliases on some Python versions +if not hasattr(wintypes, "LRESULT"): + # LONG_PTR + wintypes.LRESULT = ctypes.c_ssize_t # type: ignore[attr-defined] +if not hasattr(wintypes, "HMENU"): + wintypes.HMENU = ctypes.c_void_p # type: ignore[attr-defined] +if not hasattr(wintypes, "HCURSOR"): + wintypes.HCURSOR = ctypes.c_void_p # type: ignore[attr-defined] +if not hasattr(wintypes, "HICON"): + wintypes.HICON = ctypes.c_void_p # type: ignore[attr-defined] +if not hasattr(wintypes, "HBRUSH"): + wintypes.HBRUSH = ctypes.c_void_p # type: ignore[attr-defined] +if not hasattr(wintypes, "HINSTANCE"): + wintypes.HINSTANCE = ctypes.c_void_p # type: ignore[attr-defined] + +# Extra Win32 constants used by CreateWindowEx path +WIN_WM_SETFONT = 0x0030 +WIN_WS_EX_DLGMODALFRAME = 0x00000001 +WIN_WS_EX_CONTROLPARENT = 0x00010000 +WIN_DEFAULT_CHARSET = 1 +WIN_OUT_DEFAULT_PRECIS = 0 +WIN_CLIP_DEFAULT_PRECIS = 0 +WIN_CLEARTYPE_QUALITY = 5 +WIN_DEFAULT_PITCH = 0 +WIN_FF_DONTCARE = 0 +WIN_FW_NORMAL = 400 +WIN_LOGPIXELSY = 90 +WIN_WM_CLOSE = 0x0010 +WIN_WM_KEYDOWN = 0x0100 +WIN_VK_RETURN = 0x0D +WIN_VK_ESCAPE = 0x1B + # Helper alias for pointer-sized integer type used by Win32 callbacks +# Return type for DLGPROC should be an integer type matching pointer size, +# not a pointer type. Using a pointer type here can corrupt the stack on 64-bit. # pylint: disable=invalid-name -INT_PTR = ctypes.c_void_p +INT_PTR = ctypes.c_ssize_t # Win32 MessageBox flags (from winuser.h) @@ -65,12 +100,16 @@ WIN_DS_MODALFRAME = 0x00000080 WIN_WS_CAPTION = 0x00C00000 WIN_WS_SYSMENU = 0x00080000 +WIN_WS_POPUP = 0x80000000 WIN_WS_CHILD = 0x40000000 WIN_WS_VISIBLE = 0x10000000 WIN_WS_TABSTOP = 0x00010000 WIN_WS_GROUP = 0x00020000 WIN_WS_BORDER = 0x00800000 +WIN_WS_THICKFRAME = 0x00040000 +WIN_WS_MINIMIZEBOX = 0x00020000 +WIN_WS_MAXIMIZEBOX = 0x00010000 WIN_ES_AUTOHSCROLL = 0x00000080 WIN_ES_PASSWORD = 0x00000020 @@ -81,12 +120,18 @@ # Window messages WIN_WM_INITDIALOG = 0x0110 WIN_WM_COMMAND = 0x0111 +WIN_WM_CTLCOLORSTATIC = 0x0138 # Edit control helpers WIN_EM_SETCUEBANNER = 0x1501 WIN_EN_CHANGE = 0x0300 WIN_EM_LIMITTEXT = 0x00C5 +# DrawText flags +WIN_DT_WORDBREAK = 0x0010 +WIN_DT_CALCRECT = 0x0400 +WIN_DT_NOPREFIX = 0x0800 + # SetWindowPos flags and system metrics WIN_SWP_NOSIZE = 0x0001 WIN_SWP_NOZORDER = 0x0004 @@ -94,9 +139,10 @@ WIN_SM_CXSCREEN = 0 WIN_SM_CYSCREEN = 1 -# Predefined control classes -WIN_CLASS_BUTTON = 0x0081 -WIN_CLASS_EDIT = 0x0080 +# Predefined control classes (atoms from winuser.h) +# 0x0080: BUTTON, 0x0081: EDIT, 0x0082: STATIC +WIN_CLASS_BUTTON = 0x0080 +WIN_CLASS_EDIT = 0x0081 WIN_CLASS_STATIC = 0x0082 # Control IDs @@ -564,11 +610,16 @@ def _build_template(self) -> bytes: ok_x = cx - margin - (btn_w * 2 + spacing) cancel_x = cx - margin - btn_w - btn_y = cy - margin - btn_h + # Position the edit box a bit lower from the label + edit_y = margin + static_h + 8 + # Move the buttons up: place them below the edit with extra spacing + btn_y = edit_y + edit_h + spacing * 2 buf = bytearray() - style = self.DS_MODALFRAME | self.DS_SETFONT | self.WS_CAPTION | self.WS_SYSMENU + style = ( + self.DS_MODALFRAME | self.DS_SETFONT | self.WS_CAPTION | self.WS_SYSMENU | WIN_WS_POPUP + ) self._pack_dword(buf, style) # style self._pack_dword(buf, 0) # dwExtendedStyle self._pack_word(buf, 4) # cdit: static, edit, OK, Cancel @@ -665,120 +716,555 @@ def _build_template(self) -> bytes: return bytes(buf) def run(self) -> Optional[str]: - """Create and run the modal dialog; return the entered text or None.""" - # The dialog procedure and Win32 interop are inherently complex; relax - # a few pylint rules for this method. + """Create and run a modal input window using CreateWindowEx.""" # pylint: disable=too-many-locals,too-many-branches,too-many-statements,invalid-name user32 = windll.user32 + gdi32 = windll.gdi32 kernel32 = windll.kernel32 - template_bytes = self._build_template() - # Keep buffer alive by storing on self - self._template_buffer = (wintypes.BYTE * len(template_bytes)).from_buffer_copy( - template_bytes - ) - - dlgproc_type = WINFUNCTYPE( - INT_PTR, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM - ) - - @dlgproc_type - def _dlgproc(hwnd, msg, wparam, _lparam): - if msg == self.WM_INITDIALOG: - # Set focus to edit control and prefill text / placeholder - h_edit = user32.GetDlgItem(hwnd, self.ID_EDIT) - user32.SetFocus(h_edit) - # Default text - if self.options.default_text: - user32.SetWindowTextW(h_edit, c_wchar_p(self.options.default_text)) - # Placeholder (cue banner) if available - if self.options.placeholder: + # Win32 function prototypes used + try: + user32.CreateWindowExW.restype = wintypes.HWND + user32.CreateWindowExW.argtypes = [ + wintypes.DWORD, + c_wchar_p, + c_wchar_p, + wintypes.DWORD, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + wintypes.HWND, + wintypes.HMENU, + wintypes.HINSTANCE, + wintypes.LPVOID, + ] + user32.DefWindowProcW.restype = wintypes.LRESULT + user32.DefWindowProcW.argtypes = [ + wintypes.HWND, + wintypes.UINT, + wintypes.WPARAM, + wintypes.LPARAM, + ] + user32.RegisterClassW.restype = wintypes.ATOM + except Exception: + pass + + # Register window class once + class_name = "MF_InputDialogWindow" + if not hasattr(_Win32InputDialog, "_class_registered"): + WNDPROC = WINFUNCTYPE(wintypes.LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM) + + @WNDPROC + def _wndproc(hwnd, msg, wparam, lparam): + # Retrieve instance from map if present + inst = _Win32InputDialog._hwnd_to_inst.get(hwnd) + if msg == WIN_WM_CLOSE: + windll.user32.DestroyWindow(hwnd) + return 0 + if msg == WIN_WM_KEYDOWN and inst is not None: + if wparam == WIN_VK_RETURN: + inst._on_ok() + return 0 + if wparam == WIN_VK_ESCAPE: + inst._on_cancel() + return 0 + if msg == 0x0002: # WM_DESTROY + if inst is not None: + # Defer destruction finalization slightly to allow any + # late WM_COMMAND or automation posts to drain safely. + try: + inst._on_destroy() + except Exception: + pass + return 0 + if msg == 0x0082: # WM_NCDESTROY try: - # wParam=BOOL drawWhenNotFocused=1 - user32.SendMessageW( - h_edit, WIN_EM_SETCUEBANNER, 1, c_wchar_p(self.options.placeholder) - ) + if inst is not None: + inst._done = True # type: ignore[attr-defined] + _Win32InputDialog._hwnd_to_inst.pop(hwnd, None) + # Ensure the modal loop unblocks even if no further messages arrive + user32.PostQuitMessage(0) except Exception: pass - # Validator initial state - if self.options.validator is not None: + return 0 + if inst is None: + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + if msg == 0x0005: # WM_SIZE + inst._on_size() + return 0 + if msg == WIN_WM_CTLCOLORSTATIC: + # Make label background match dialog background for a flat look try: - is_valid = bool(self.options.validator(self.options.default_text or "")) + windll.gdi32.SetBkMode(wparam, 1) # TRANSPARENT except Exception: - is_valid = True - user32.EnableWindow( - user32.GetDlgItem(hwnd, self.ID_OK), wintypes.BOOL(1 if is_valid else 0) - ) - # Character limit - if self.options.char_limit is not None: - user32.SendMessageW(h_edit, WIN_EM_LIMITTEXT, self.options.char_limit, 0) - - # Center dialog over owner + pass + return getattr(_Win32InputDialog, "_bg_brush", 0) + if msg == _Win32InputDialog.WM_COMMAND: + cid = wparam & 0xFFFF + notify = (wparam >> 16) & 0xFFFF + # Ignore commands from unknown HWNDs to avoid processing + # stale messages after controls are destroyed. + if lparam not in (inst.h_edit, inst.h_ok, inst.h_cancel): + return 0 + if notify == WIN_EN_CHANGE and inst.options.validator is not None and lparam == inst.h_edit: + inst._validate_live() + return 0 + if cid == inst.ID_OK: + inst._on_ok() + return 0 + if cid == inst.ID_CANCEL: + inst._on_cancel() + return 0 + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + + _Win32InputDialog._WNDPROC = _wndproc # type: ignore[attr-defined] + + class WNDCLASSEX(ctypes.Structure): + _fields_ = [ + ("cbSize", wintypes.UINT), + ("style", wintypes.UINT), + ("lpfnWndProc", WNDPROC), + ("cbClsExtra", ctypes.c_int), + ("cbWndExtra", ctypes.c_int), + ("hInstance", wintypes.HINSTANCE), + ("hIcon", wintypes.HICON), + ("hCursor", wintypes.HCURSOR), + ("hbrBackground", wintypes.HBRUSH), + ("lpszMenuName", c_wchar_p), + ("lpszClassName", c_wchar_p), + ("hIconSm", wintypes.HICON), + ] + + # Prototypes for class registration + try: + user32.RegisterClassExW.restype = wintypes.ATOM + user32.RegisterClassExW.argtypes = [ctypes.POINTER(WNDCLASSEX)] + user32.LoadCursorW.restype = wintypes.HCURSOR + # Second parameter is MAKEINTRESOURCE on system cursors; accept as void* + user32.LoadCursorW.argtypes = [wintypes.HINSTANCE, ctypes.c_void_p] + except Exception: + pass + + hInstance = kernel32.GetModuleHandleW(None) + wcx = WNDCLASSEX() + wcx.cbSize = ctypes.sizeof(WNDCLASSEX) + wcx.style = 0 + wcx.lpfnWndProc = _Win32InputDialog._WNDPROC # type: ignore[attr-defined] + wcx.cbClsExtra = 0 + wcx.cbWndExtra = 0 + wcx.hInstance = hInstance + wcx.hIcon = None + # IDC_ARROW = 32512 (0x7F00). Pass as MAKEINTRESOURCE via c_void_p + wcx.hCursor = windll.user32.LoadCursorW(None, ctypes.c_void_p(32512)) + # Use COLOR_WINDOW+1 to avoid theme brush quirks under automation + wcx.hbrBackground = ctypes.c_void_p(5 + 1) + wcx.lpszMenuName = None + wcx.lpszClassName = class_name + wcx.hIconSm = None + atom = user32.RegisterClassExW(ctypes.byref(wcx)) + # If already registered, atom==0 with last error 1410 (ERROR_CLASS_ALREADY_EXISTS) + _Win32InputDialog._class_registered = True # type: ignore[attr-defined] + _Win32InputDialog._class_name = class_name # type: ignore[attr-defined] + _Win32InputDialog._hwnd_to_inst = {} # type: ignore[attr-defined] + # Cache background brush so STATIC controls can paint with same bg + try: + _Win32InputDialog._bg_brush = int(wcx.hbrBackground) # type: ignore[attr-defined] + except Exception: + _Win32InputDialog._bg_brush = 0 # type: ignore[attr-defined] + + # Create window + style = self.WS_CAPTION | self.WS_SYSMENU | WIN_WS_POPUP | WIN_WS_THICKFRAME | WIN_WS_MINIMIZEBOX | WIN_WS_MAXIMIZEBOX + ex_style = WIN_WS_EX_DLGMODALFRAME | WIN_WS_EX_CONTROLPARENT + # Avoid cross-thread/process owner interactions; keep window independent + owner = 0 + + # Size and layout (pixels) + # Slightly larger default size so action buttons are always visible + cx = int(self.options.width_dlu if self.options.width_dlu is not None else 420) + cy = int(self.options.height_dlu if self.options.height_dlu is not None else 220) + margin = 36 + static_h = 22 + edit_h = 22 + btn_w, btn_h = 96, 28 + spacing = 16 + + ok_x = cx - margin - (btn_w * 2 + spacing) + cancel_x = cx - margin - btn_w + edit_y = margin + static_h + 8 + btn_y = edit_y + edit_h + spacing * 2 + + # Persist layout metrics for resize handling + self._layout_margin = margin # type: ignore[attr-defined] + self._layout_spacing = spacing # type: ignore[attr-defined] + self._layout_edit_h = edit_h # type: ignore[attr-defined] + self._layout_btn_w = btn_w # type: ignore[attr-defined] + self._layout_btn_h = btn_h # type: ignore[attr-defined] + + hInstance = kernel32.GetModuleHandleW(None) + hwnd = user32.CreateWindowExW( + ex_style, + c_wchar_p(getattr(_Win32InputDialog, "_class_name", class_name)), + c_wchar_p(self.title), + style, + 100, + 100, + cx, + cy, + None, + None, + hInstance, + None, + ) + if not hwnd: + err = kernel32.GetLastError() + raise ctypes.WinError(err) + + # Map hwnd to instance + _Win32InputDialog._hwnd_to_inst[hwnd] = self # type: ignore[attr-defined] + self.hwnd = hwnd # type: ignore[attr-defined] + + # Allow Ctrl+C in the console to close the window gracefully (both + # Python-level SIGINT and native console control handler for immediate response) + try: + def _sigint_handler(_signum, _frame): try: - owner_hwnd = self.options.owner_hwnd or user32.GetActiveWindow() - if owner_hwnd: - rect = wintypes.RECT() - user32.GetWindowRect(owner_hwnd, byref(rect)) - owner_cx = rect.right - rect.left - owner_cy = rect.bottom - rect.top - - dlg_rect = wintypes.RECT() - user32.GetWindowRect(hwnd, byref(dlg_rect)) - dlg_w = dlg_rect.right - dlg_rect.left - dlg_h = dlg_rect.bottom - dlg_rect.top - - x = rect.left + (owner_cx - dlg_w) // 2 - y = rect.top + (owner_cy - dlg_h) // 2 - user32.SetWindowPos( - hwnd, - 0, - x, - y, - 0, - 0, - WIN_SWP_NOSIZE | WIN_SWP_NOZORDER | WIN_SWP_NOACTIVATE, - ) + user32.PostMessageW(self.hwnd, WIN_WM_CLOSE, 0, 0) # type: ignore[attr-defined] except Exception: pass - return 0 - if msg == self.WM_COMMAND: - cid = wparam & 0xFFFF - notify_code = (wparam >> 16) & 0xFFFF - # Live validation - if notify_code == WIN_EN_CHANGE and self.options.validator is not None: - h_edit = user32.GetDlgItem(hwnd, self.ID_EDIT) - length = user32.GetWindowTextLengthW(h_edit) - buf = create_unicode_buffer(length + 1) - user32.GetWindowTextW(h_edit, buf, length + 1) - try: - is_valid = bool(self.options.validator(buf.value)) - except Exception: - is_valid = True - user32.EnableWindow( - user32.GetDlgItem(hwnd, self.ID_OK), wintypes.BOOL(1 if is_valid else 0) - ) - if cid == self.ID_OK: - # Read text - h_edit = user32.GetDlgItem(hwnd, self.ID_EDIT) - length = user32.GetWindowTextLengthW(h_edit) - buf = create_unicode_buffer(length + 1) - user32.GetWindowTextW(h_edit, buf, length + 1) - self._result_text = buf.value - user32.EndDialog(hwnd, self.ID_OK) - return 1 - if cid == self.ID_CANCEL: - self._result_text = None - user32.EndDialog(hwnd, self.ID_CANCEL) - return 1 - return 0 - - h_instance = kernel32.GetModuleHandleW(None) - owner = self.options.owner_hwnd or user32.GetActiveWindow() - res = user32.DialogBoxIndirectParamW( - h_instance, byref(self._template_buffer), owner, _dlgproc, 0 + self._prev_sigint = signal.getsignal(signal.SIGINT) # type: ignore[attr-defined] + signal.signal(signal.SIGINT, _sigint_handler) + except Exception: + pass + + # Native console control handler (fires immediately even while Python blocks) + try: + HANDLER_ROUTINE = WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD) + + @HANDLER_ROUTINE + def _console_ctrl_handler(_ctrl_type): # CTRL_C_EVENT, CTRL_BREAK_EVENT, etc. + try: + user32.PostMessageW(self.hwnd, WIN_WM_CLOSE, 0, 0) # type: ignore[attr-defined] + except Exception: + pass + return True + + kernel32.SetConsoleCtrlHandler.restype = wintypes.BOOL + kernel32.SetConsoleCtrlHandler.argtypes = [HANDLER_ROUTINE, wintypes.BOOL] + kernel32.SetConsoleCtrlHandler(_console_ctrl_handler, True) + self._console_ctrl_handler = _console_ctrl_handler # type: ignore[attr-defined] + except Exception: + pass + + # Create child controls + self.h_static = user32.CreateWindowExW( # type: ignore[attr-defined] + 0, + c_wchar_p("STATIC"), + c_wchar_p(self.prompt), + self.WS_CHILD | self.WS_VISIBLE | self.SS_LEFT, + margin, + margin, + cx - 2 * margin, + static_h, + hwnd, + wintypes.HMENU(0), + hInstance, + None, ) - # res is IDOK/IDCANCEL or -1 on failure - if res == -1: - err = kernel32.GetLastError() - raise ctypes.WinError(err) - return self._result_text if res == self.ID_OK else None + edit_style = self.WS_CHILD | self.WS_VISIBLE | self.WS_BORDER | self.ES_AUTOHSCROLL | self.WS_TABSTOP + if self.options.is_password: + edit_style |= self.ES_PASSWORD + self.h_edit = user32.CreateWindowExW( # type: ignore[attr-defined] + 0, + c_wchar_p("EDIT"), + c_wchar_p(""), + edit_style, + margin, + edit_y, + cx - 2 * margin, + edit_h, + hwnd, + wintypes.HMENU(self.ID_EDIT), + hInstance, + None, + ) + self.h_ok = user32.CreateWindowExW( # type: ignore[attr-defined] + 0, + c_wchar_p("BUTTON"), + c_wchar_p(get_text()("Submit")), + self.WS_CHILD | self.WS_VISIBLE | self.WS_TABSTOP | self.BS_DEFPUSHBUTTON, + ok_x, + btn_y, + btn_w, + btn_h, + hwnd, + wintypes.HMENU(self.ID_OK), + hInstance, + None, + ) + self.h_cancel = user32.CreateWindowExW( # type: ignore[attr-defined] + 0, + c_wchar_p("BUTTON"), + c_wchar_p(get_text()("Cancel")), + self.WS_CHILD | self.WS_VISIBLE | self.WS_TABSTOP | self.BS_PUSHBUTTON, + cancel_x, + btn_y, + btn_w, + btn_h, + hwnd, + wintypes.HMENU(self.ID_CANCEL), + hInstance, + None, + ) + + # Apply a system dialog font for consistent look and spacing + try: + DEFAULT_GUI_FONT = 17 + hfont = windll.gdi32.GetStockObject(DEFAULT_GUI_FONT) + if hfont: + # Send WM_SETFONT to children so they repaint with the font + for hchild in (self.h_static, self.h_edit, self.h_ok, self.h_cancel): # type: ignore[attr-defined] + if hchild: + user32.SendMessageW(hchild, WIN_WM_SETFONT, hfont, 1) + # Keep a reference so it survives until window is destroyed + self._hfont = hfont # type: ignore[attr-defined] + + # Adjust edit height to match font metrics so caret is visually centered + class TEXTMETRICW(ctypes.Structure): + _fields_ = [ + ("tmHeight", ctypes.c_long), + ("tmAscent", ctypes.c_long), + ("tmDescent", ctypes.c_long), + ("tmInternalLeading", ctypes.c_long), + ("tmExternalLeading", ctypes.c_long), + ("tmAveCharWidth", ctypes.c_long), + ("tmMaxCharWidth", ctypes.c_long), + ("tmWeight", ctypes.c_long), + ("tmOverhang", ctypes.c_long), + ("tmDigitizedAspectX", ctypes.c_long), + ("tmDigitizedAspectY", ctypes.c_long), + ("tmFirstChar", ctypes.c_wchar), + ("tmLastChar", ctypes.c_wchar), + ("tmDefaultChar", ctypes.c_wchar), + ("tmBreakChar", ctypes.c_wchar), + ("tmItalic", ctypes.c_ubyte), + ("tmUnderlined", ctypes.c_ubyte), + ("tmStruckOut", ctypes.c_ubyte), + ("tmPitchAndFamily", ctypes.c_ubyte), + ("tmCharSet", ctypes.c_ubyte), + ] + + hdc_edit = user32.GetDC(self.h_edit) + if hdc_edit: + try: + prev = gdi32.SelectObject(hdc_edit, hfont) + tm = TEXTMETRICW() + if gdi32.GetTextMetricsW(hdc_edit, ctypes.byref(tm)): + desired_h = int(tm.tmHeight + tm.tmExternalLeading + 6) + if desired_h < 18: + desired_h = 18 + # Resize edit control to the desired height and keep x/width constant + user32.SetWindowPos( + self.h_edit, + 0, + margin, + edit_y, + cx - 2 * margin, + desired_h, + WIN_SWP_NOZORDER, + ) + # Reposition buttons directly below the edit + new_btn_y = edit_y + desired_h + spacing * 2 + user32.SetWindowPos(self.h_ok, 0, ok_x, new_btn_y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER) + user32.SetWindowPos(self.h_cancel, 0, cancel_x, new_btn_y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER) + if prev: + gdi32.SelectObject(hdc_edit, prev) + finally: + user32.ReleaseDC(self.h_edit, hdc_edit) + + # Recalculate static height for long titles and wrap + hdc_static = user32.GetDC(self.h_static) + if hdc_static: + try: + prev2 = gdi32.SelectObject(hdc_static, hfont) + rect = wintypes.RECT() + rect.left = 0 + rect.top = 0 + rect.right = cx - 2 * margin + rect.bottom = 1000 + user32.DrawTextW( + hdc_static, + c_wchar_p(self.prompt), + -1, + byref(rect), + WIN_DT_WORDBREAK | WIN_DT_CALCRECT | WIN_DT_NOPREFIX, + ) + new_static_h = max(static_h, rect.bottom - rect.top) + if new_static_h != static_h: + # Resize static and move controls below it + user32.SetWindowPos( + self.h_static, + 0, + margin, + margin, + cx - 2 * margin, + new_static_h, + WIN_SWP_NOZORDER, + ) + new_edit_y = margin + new_static_h + 8 + user32.SetWindowPos( + self.h_edit, + 0, + margin, + new_edit_y, + 0, + 0, + WIN_SWP_NOSIZE | WIN_SWP_NOZORDER, + ) + new_btn_y = new_edit_y + edit_h + spacing * 2 + user32.SetWindowPos(self.h_ok, 0, ok_x, new_btn_y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER) + user32.SetWindowPos(self.h_cancel, 0, cancel_x, new_btn_y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER) + if prev2: + gdi32.SelectObject(hdc_static, prev2) + finally: + user32.ReleaseDC(self.h_static, hdc_static) + except Exception: + pass + + # Defaults + if self.options.default_text: + user32.SetWindowTextW(self.h_edit, c_wchar_p(self.options.default_text)) + if self.options.placeholder: + try: + user32.SendMessageW(self.h_edit, WIN_EM_SETCUEBANNER, 1, c_wchar_p(self.options.placeholder)) + except Exception: + pass + if self.options.char_limit is not None: + user32.SendMessageW(self.h_edit, WIN_EM_LIMITTEXT, self.options.char_limit, 0) + + # Initial validation + if self.options.validator is not None: + self._validate_live() + + # Center over owner + try: + owner_hwnd = owner or user32.GetActiveWindow() + if owner_hwnd: + rect = wintypes.RECT() + user32.GetWindowRect(owner_hwnd, byref(rect)) + owner_cx = rect.right - rect.left + owner_cy = rect.bottom - rect.top + wnd_rect = wintypes.RECT() + user32.GetWindowRect(hwnd, byref(wnd_rect)) + x = rect.left + (owner_cx - (wnd_rect.right - wnd_rect.left)) // 2 + y = rect.top + (owner_cy - (wnd_rect.bottom - wnd_rect.top)) // 2 + user32.SetWindowPos(hwnd, 0, x, y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER | WIN_SWP_NOACTIVATE) + except Exception: + pass + + user32.ShowWindow(hwnd, 5) # SW_SHOW + try: + user32.UpdateWindow(hwnd) + except Exception: + pass + if self.h_edit: + user32.SetFocus(self.h_edit) + + # Modal loop + self._done = False # type: ignore[attr-defined] + msg = wintypes.MSG() + while not self._done: + ret = user32.GetMessageW(byref(msg), 0, 0, 0) + if ret == 0: # WM_QUIT + break + if ret == -1: + break + # Let the system process default button (Enter), Esc, and Tab order + if not user32.IsDialogMessageW(hwnd, byref(msg)): + user32.TranslateMessage(byref(msg)) + user32.DispatchMessageW(byref(msg)) + + # No owner to restore + # Restore previous SIGINT handler + try: + prev = getattr(self, "_prev_sigint", None) + if prev is not None: + signal.signal(signal.SIGINT, prev) + except Exception: + pass + # Remove native console handler + try: + handler = getattr(self, "_console_ctrl_handler", None) + if handler is not None: + kernel32.SetConsoleCtrlHandler(handler, False) + except Exception: + pass + return self._result_text + + # Helper methods for WNDPROC + def _on_ok(self) -> None: + user32 = windll.user32 + length = user32.GetWindowTextLengthW(self.h_edit) # type: ignore[attr-defined] + buf = create_unicode_buffer(length + 1) + user32.GetWindowTextW(self.h_edit, buf, length + 1) # type: ignore[attr-defined] + self._result_text = buf.value + user32.DestroyWindow(self.hwnd) # type: ignore[attr-defined] + self._done = True # type: ignore[attr-defined] + + def _on_cancel(self) -> None: + user32 = windll.user32 + self._result_text = None + user32.DestroyWindow(self.hwnd) # type: ignore[attr-defined] + self._done = True # type: ignore[attr-defined] + + def _on_destroy(self) -> None: + self._done = True # type: ignore[attr-defined] + + def _on_size(self) -> None: + # Reflow controls on window resize + try: + user32 = windll.user32 + rect = wintypes.RECT() + user32.GetClientRect(self.hwnd, byref(rect)) # type: ignore[attr-defined] + cx = rect.right - rect.left + cy = rect.bottom - rect.top + + margin = getattr(self, "_layout_margin", 24) + spacing = getattr(self, "_layout_spacing", 12) + btn_w = getattr(self, "_layout_btn_w", 88) + btn_h = getattr(self, "_layout_btn_h", 26) + + # Static keeps same height; stretch width + # Measure static height + static_rect = wintypes.RECT() + user32.GetWindowRect(self.h_static, byref(static_rect)) # type: ignore[attr-defined] + static_h = static_rect.bottom - static_rect.top + user32.SetWindowPos(self.h_static, 0, margin, margin, cx - 2 * margin, static_h, WIN_SWP_NOZORDER) # type: ignore[attr-defined] + + # Edit stretches horizontally, stays below static + edit_y = margin + static_h + 8 + # Preserve current edit height + cur_edit_rect = wintypes.RECT() + user32.GetWindowRect(self.h_edit, byref(cur_edit_rect)) # type: ignore[attr-defined] + cur_edit_h = cur_edit_rect.bottom - cur_edit_rect.top + user32.SetWindowPos(self.h_edit, 0, margin, edit_y, cx - 2 * margin, cur_edit_h, WIN_SWP_NOZORDER) # type: ignore[attr-defined] + + # Buttons right-aligned + cancel_x = cx - margin - btn_w + ok_x = cancel_x - spacing - btn_w + btn_y = edit_y + cur_edit_h + spacing * 2 + user32.SetWindowPos(self.h_ok, 0, ok_x, btn_y, btn_w, btn_h, WIN_SWP_NOZORDER) # type: ignore[attr-defined] + user32.SetWindowPos(self.h_cancel, 0, cancel_x, btn_y, btn_w, btn_h, WIN_SWP_NOZORDER) # type: ignore[attr-defined] + except Exception: + pass + + def _validate_live(self) -> None: + user32 = windll.user32 + length = user32.GetWindowTextLengthW(self.h_edit) # type: ignore[attr-defined] + buf = create_unicode_buffer(length + 1) + user32.GetWindowTextW(self.h_edit, buf, length + 1) # type: ignore[attr-defined] + try: + is_valid = bool(self.options.validator(buf.value)) if self.options.validator else True + except Exception: + is_valid = True + user32.EnableWindow(self.h_ok, wintypes.BOOL(1 if is_valid else 0)) # type: ignore[attr-defined] diff --git a/tests/api/integration_tests/test_message_box_permutations.py b/tests/api/integration_tests/test_message_box_permutations.py index 78b5c60..a29bddc 100644 --- a/tests/api/integration_tests/test_message_box_permutations.py +++ b/tests/api/integration_tests/test_message_box_permutations.py @@ -45,7 +45,8 @@ def _worker(): try: hbtn = user32.GetDlgItem(hwnd, button_id) if hbtn: - user32.SendMessageW(hbtn, BM_CLICK, 0, 0) + # Prefer PostMessage to avoid synchronous reentrancy + user32.PostMessageW(hbtn, BM_CLICK, 0, 0) return children = [] @@ -73,7 +74,7 @@ def _child_enum_proc(hchild, _): # Try to click first Button child for hchild, cname, _, _ in children: if cname and cname.lower().startswith("button"): - user32.SendMessageW(hchild, BM_CLICK, 0, 0) + user32.PostMessageW(hchild, BM_CLICK, 0, 0) return except Exception: @@ -105,7 +106,7 @@ def _enum_proc(h, _): try: hbtn = user32.GetDlgItem(hwnd, button_id) if hbtn: - user32.SendMessageW(hbtn, BM_CLICK, 0, 0) + user32.PostMessageW(hbtn, BM_CLICK, 0, 0) return except Exception: try: From cbf5a1cff3390721cf1bcc6d625f00d35aaef19a Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 16:34:50 +1000 Subject: [PATCH 09/21] lint --- src/moldflow/message_box.py | 85 +++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index b217489..056925b 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -753,7 +753,9 @@ def run(self) -> Optional[str]: # Register window class once class_name = "MF_InputDialogWindow" if not hasattr(_Win32InputDialog, "_class_registered"): - WNDPROC = WINFUNCTYPE(wintypes.LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM) + WNDPROC = WINFUNCTYPE( + wintypes.LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM + ) @WNDPROC def _wndproc(hwnd, msg, wparam, lparam): @@ -807,7 +809,11 @@ def _wndproc(hwnd, msg, wparam, lparam): # stale messages after controls are destroyed. if lparam not in (inst.h_edit, inst.h_ok, inst.h_cancel): return 0 - if notify == WIN_EN_CHANGE and inst.options.validator is not None and lparam == inst.h_edit: + if ( + notify == WIN_EN_CHANGE + and inst.options.validator is not None + and lparam == inst.h_edit + ): inst._validate_live() return 0 if cid == inst.ID_OK: @@ -874,7 +880,14 @@ class WNDCLASSEX(ctypes.Structure): _Win32InputDialog._bg_brush = 0 # type: ignore[attr-defined] # Create window - style = self.WS_CAPTION | self.WS_SYSMENU | WIN_WS_POPUP | WIN_WS_THICKFRAME | WIN_WS_MINIMIZEBOX | WIN_WS_MAXIMIZEBOX + style = ( + self.WS_CAPTION + | self.WS_SYSMENU + | WIN_WS_POPUP + | WIN_WS_THICKFRAME + | WIN_WS_MINIMIZEBOX + | WIN_WS_MAXIMIZEBOX + ) ex_style = WIN_WS_EX_DLGMODALFRAME | WIN_WS_EX_CONTROLPARENT # Avoid cross-thread/process owner interactions; keep window independent owner = 0 @@ -926,12 +939,12 @@ class WNDCLASSEX(ctypes.Structure): # Allow Ctrl+C in the console to close the window gracefully (both # Python-level SIGINT and native console control handler for immediate response) + def _sigint_handler(_signum, _frame): + try: + user32.PostMessageW(self.hwnd, WIN_WM_CLOSE, 0, 0) # type: ignore[attr-defined] + except Exception: + pass try: - def _sigint_handler(_signum, _frame): - try: - user32.PostMessageW(self.hwnd, WIN_WM_CLOSE, 0, 0) # type: ignore[attr-defined] - except Exception: - pass self._prev_sigint = signal.getsignal(signal.SIGINT) # type: ignore[attr-defined] signal.signal(signal.SIGINT, _sigint_handler) except Exception: @@ -971,7 +984,9 @@ def _console_ctrl_handler(_ctrl_type): # CTRL_C_EVENT, CTRL_BREAK_EVENT, etc. hInstance, None, ) - edit_style = self.WS_CHILD | self.WS_VISIBLE | self.WS_BORDER | self.ES_AUTOHSCROLL | self.WS_TABSTOP + edit_style = ( + self.WS_CHILD | self.WS_VISIBLE | self.WS_BORDER | self.ES_AUTOHSCROLL | self.WS_TABSTOP + ) if self.options.is_password: edit_style |= self.ES_PASSWORD self.h_edit = user32.CreateWindowExW( # type: ignore[attr-defined] @@ -1043,6 +1058,7 @@ class TEXTMETRICW(ctypes.Structure): ("tmOverhang", ctypes.c_long), ("tmDigitizedAspectX", ctypes.c_long), ("tmDigitizedAspectY", ctypes.c_long), + # Next four fields are WCHAR in the Win32 API, keep for structure parity ("tmFirstChar", ctypes.c_wchar), ("tmLastChar", ctypes.c_wchar), ("tmDefaultChar", ctypes.c_wchar), @@ -1061,8 +1077,7 @@ class TEXTMETRICW(ctypes.Structure): tm = TEXTMETRICW() if gdi32.GetTextMetricsW(hdc_edit, ctypes.byref(tm)): desired_h = int(tm.tmHeight + tm.tmExternalLeading + 6) - if desired_h < 18: - desired_h = 18 + desired_h = max(desired_h, 18) # Resize edit control to the desired height and keep x/width constant user32.SetWindowPos( self.h_edit, @@ -1075,8 +1090,24 @@ class TEXTMETRICW(ctypes.Structure): ) # Reposition buttons directly below the edit new_btn_y = edit_y + desired_h + spacing * 2 - user32.SetWindowPos(self.h_ok, 0, ok_x, new_btn_y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER) - user32.SetWindowPos(self.h_cancel, 0, cancel_x, new_btn_y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER) + user32.SetWindowPos( + self.h_ok, + 0, + ok_x, + new_btn_y, + 0, + 0, + WIN_SWP_NOSIZE | WIN_SWP_NOZORDER, + ) + user32.SetWindowPos( + self.h_cancel, + 0, + cancel_x, + new_btn_y, + 0, + 0, + WIN_SWP_NOSIZE | WIN_SWP_NOZORDER, + ) if prev: gdi32.SelectObject(hdc_edit, prev) finally: @@ -1122,8 +1153,24 @@ class TEXTMETRICW(ctypes.Structure): WIN_SWP_NOSIZE | WIN_SWP_NOZORDER, ) new_btn_y = new_edit_y + edit_h + spacing * 2 - user32.SetWindowPos(self.h_ok, 0, ok_x, new_btn_y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER) - user32.SetWindowPos(self.h_cancel, 0, cancel_x, new_btn_y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER) + user32.SetWindowPos( + self.h_ok, + 0, + ok_x, + new_btn_y, + 0, + 0, + WIN_SWP_NOSIZE | WIN_SWP_NOZORDER, + ) + user32.SetWindowPos( + self.h_cancel, + 0, + cancel_x, + new_btn_y, + 0, + 0, + WIN_SWP_NOSIZE | WIN_SWP_NOZORDER, + ) if prev2: gdi32.SelectObject(hdc_static, prev2) finally: @@ -1136,7 +1183,9 @@ class TEXTMETRICW(ctypes.Structure): user32.SetWindowTextW(self.h_edit, c_wchar_p(self.options.default_text)) if self.options.placeholder: try: - user32.SendMessageW(self.h_edit, WIN_EM_SETCUEBANNER, 1, c_wchar_p(self.options.placeholder)) + user32.SendMessageW( + self.h_edit, WIN_EM_SETCUEBANNER, 1, c_wchar_p(self.options.placeholder) + ) except Exception: pass if self.options.char_limit is not None: @@ -1158,7 +1207,9 @@ class TEXTMETRICW(ctypes.Structure): user32.GetWindowRect(hwnd, byref(wnd_rect)) x = rect.left + (owner_cx - (wnd_rect.right - wnd_rect.left)) // 2 y = rect.top + (owner_cy - (wnd_rect.bottom - wnd_rect.top)) // 2 - user32.SetWindowPos(hwnd, 0, x, y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER | WIN_SWP_NOACTIVATE) + user32.SetWindowPos( + hwnd, 0, x, y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER | WIN_SWP_NOACTIVATE + ) except Exception: pass From 3bf8a329e4d2dddf7fa7179b8526d32bc23ce4d0 Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 16:35:02 +1000 Subject: [PATCH 10/21] i18n --- src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po | 3 +++ src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po | 3 +++ src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po | 3 +++ src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po | 3 +++ src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po | 3 +++ src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po | 3 +++ src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po | 3 +++ src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po | 3 +++ src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po | 3 +++ src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po | 3 +++ 10 files changed, 30 insertions(+) diff --git a/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po b/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po index 273fc71..3828e8a 100644 --- a/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po +++ b/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po @@ -90,6 +90,9 @@ msgstr "Speicherfehler: Speichern von {saving} in {file_name} fehlgeschlagen" msgid "Setting {name} to {value}" msgstr "Einstellung {name} auf {value}" +msgid "Submit" +msgstr "Senden" + msgid "Test String" msgstr "Testzeichenfolge" diff --git a/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po b/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po index 960bbbd..d0d1571 100644 --- a/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po +++ b/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po @@ -90,6 +90,9 @@ msgstr "Save Error: Failed to save {saving} to {file_name}" msgid "Setting {name} to {value}" msgstr "Setting {name} to {value}" +msgid "Submit" +msgstr "Submit" + msgid "Test String" msgstr "Test String" diff --git a/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po b/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po index 36ce4a4..ce166eb 100644 --- a/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po +++ b/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po @@ -90,6 +90,9 @@ msgstr "Error al guardar: No se pudo guardar {saving} en {file_name}" msgid "Setting {name} to {value}" msgstr "Configurar {name} a {value}" +msgid "Submit" +msgstr "Aceptar" + msgid "Test String" msgstr "Cadena de prueba" diff --git a/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po b/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po index faa1ee6..f45060e 100644 --- a/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po +++ b/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po @@ -90,6 +90,9 @@ msgstr "Erreur d'enregistrement : échec de l'enregistrement de {saving} dans {f msgid "Setting {name} to {value}" msgstr "Définition de {name} sur {value}" +msgid "Submit" +msgstr "Valider" + msgid "Test String" msgstr "Chaîne de test" diff --git a/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po b/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po index 147c58c..693f216 100644 --- a/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po +++ b/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po @@ -90,6 +90,9 @@ msgstr "Errore di salvataggio: salvataggio di {saving} in {file_name} non riusci msgid "Setting {name} to {value}" msgstr "Impostazione di {name} su {value}" +msgid "Submit" +msgstr "Conferma" + msgid "Test String" msgstr "Stringa di prova" diff --git a/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po b/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po index edd800f..3a1dfc1 100644 --- a/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po +++ b/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po @@ -90,6 +90,9 @@ msgstr "保存エラー: {saving} を {file_name} に保存できませんでし msgid "Setting {name} to {value}" msgstr "{name} を {value} に設定しています" +msgid "Submit" +msgstr "OK" + msgid "Test String" msgstr "テスト文字列" diff --git a/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po b/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po index 217c91f..5f6976a 100644 --- a/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po +++ b/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po @@ -90,6 +90,9 @@ msgstr "저장 오류: {saving}을(를) {file_name}에 저장하지 못했습니 msgid "Setting {name} to {value}" msgstr "{name}을(를) {value}(으)로 설정 중" +msgid "Submit" +msgstr "확인" + msgid "Test String" msgstr "테스트 문자열" diff --git a/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po b/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po index 0978174..9b1a92a 100644 --- a/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po +++ b/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po @@ -90,6 +90,9 @@ msgstr "Erro ao guardar: falha ao guardar {saving} em {file_name}" msgid "Setting {name} to {value}" msgstr "Configuração {name} para {value}" +msgid "Submit" +msgstr "Submeter" + msgid "Test String" msgstr "String de teste" diff --git a/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po b/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po index 429f421..a73c1e2 100644 --- a/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po +++ b/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po @@ -90,6 +90,9 @@ msgstr "保存错误:将{saving}保存到{file_name}失败" msgid "Setting {name} to {value}" msgstr "将{name}设置为{value}" +msgid "Submit" +msgstr "确定" + msgid "Test String" msgstr "测试字符串" diff --git a/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po b/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po index 941ce77..4ebf02b 100644 --- a/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po +++ b/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po @@ -90,6 +90,9 @@ msgstr "儲存錯誤:將 {saving} 儲存到 {file_name} 失敗" msgid "Setting {name} to {value}" msgstr "正在將 {name} 設定為 {value}" +msgid "Submit" +msgstr "確定" + msgid "Test String" msgstr "測試字串" From cab4d2d91cf2341e682d84f3c84b05cfe62c8067 Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 16:35:42 +1000 Subject: [PATCH 11/21] format --- src/moldflow/message_box.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index 056925b..bc6f6d6 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -944,6 +944,7 @@ def _sigint_handler(_signum, _frame): user32.PostMessageW(self.hwnd, WIN_WM_CLOSE, 0, 0) # type: ignore[attr-defined] except Exception: pass + try: self._prev_sigint = signal.getsignal(signal.SIGINT) # type: ignore[attr-defined] signal.signal(signal.SIGINT, _sigint_handler) From 49574b72de4114c8bd637c7f309bb324f56c4efa Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 16:43:21 +1000 Subject: [PATCH 12/21] lint --- src/moldflow/message_box.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index bc6f6d6..84c8459 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -19,6 +19,10 @@ import struct from .i18n import get_text +# This module intentionally contains a large amount of Windows interop glue +# and UI layout code. +# pylint: disable=too-many-lines,line-too-long + # Fallbacks for missing wintypes aliases on some Python versions if not hasattr(wintypes, "LRESULT"): # LONG_PTR @@ -827,6 +831,8 @@ def _wndproc(hwnd, msg, wparam, lparam): _Win32InputDialog._WNDPROC = _wndproc # type: ignore[attr-defined] class WNDCLASSEX(ctypes.Structure): + """WNDCLASSEX structure""" + _fields_ = [ ("cbSize", wintypes.UINT), ("style", wintypes.UINT), @@ -868,8 +874,12 @@ class WNDCLASSEX(ctypes.Structure): wcx.lpszMenuName = None wcx.lpszClassName = class_name wcx.hIconSm = None - atom = user32.RegisterClassExW(ctypes.byref(wcx)) - # If already registered, atom==0 with last error 1410 (ERROR_CLASS_ALREADY_EXISTS) + res = user32.RegisterClassExW(ctypes.byref(wcx)) + # If already registered, res==0 with last error 1410 (ERROR_CLASS_ALREADY_EXISTS) + if not res: + err = kernel32.GetLastError() + if err != 1410: # ERROR_CLASS_ALREADY_EXISTS + raise ctypes.WinError(err) _Win32InputDialog._class_registered = True # type: ignore[attr-defined] _Win32InputDialog._class_name = class_name # type: ignore[attr-defined] _Win32InputDialog._hwnd_to_inst = {} # type: ignore[attr-defined] From 89cb6583388cd6779f319e857ae8160a37d0755b Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 16:51:53 +1000 Subject: [PATCH 13/21] fnish lint --- src/moldflow/message_box.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index 84c8459..fc178b4 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -21,7 +21,7 @@ # This module intentionally contains a large amount of Windows interop glue # and UI layout code. -# pylint: disable=too-many-lines,line-too-long +# pylint: disable=C0301,C0302,R0902,W0212,R0911,R0914,R0902,W0201 # Fallbacks for missing wintypes aliases on some Python versions if not hasattr(wintypes, "LRESULT"): @@ -1057,6 +1057,8 @@ def _console_ctrl_handler(_ctrl_type): # CTRL_C_EVENT, CTRL_BREAK_EVENT, etc. # Adjust edit height to match font metrics so caret is visually centered class TEXTMETRICW(ctypes.Structure): + """TEXTMETRICW structure""" + _fields_ = [ ("tmHeight", ctypes.c_long), ("tmAscent", ctypes.c_long), @@ -1289,7 +1291,6 @@ def _on_size(self) -> None: rect = wintypes.RECT() user32.GetClientRect(self.hwnd, byref(rect)) # type: ignore[attr-defined] cx = rect.right - rect.left - cy = rect.bottom - rect.top margin = getattr(self, "_layout_margin", 24) spacing = getattr(self, "_layout_spacing", 12) From ae5d1026c6ee2686a43f626def2371d5b934d2c2 Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 17:06:32 +1000 Subject: [PATCH 14/21] review --- src/moldflow/message_box.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index fc178b4..5253b85 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -1014,10 +1014,11 @@ def _console_ctrl_handler(_ctrl_type): # CTRL_C_EVENT, CTRL_BREAK_EVENT, etc. hInstance, None, ) + _ = get_text() self.h_ok = user32.CreateWindowExW( # type: ignore[attr-defined] 0, c_wchar_p("BUTTON"), - c_wchar_p(get_text()("Submit")), + c_wchar_p(_("Submit")), self.WS_CHILD | self.WS_VISIBLE | self.WS_TABSTOP | self.BS_DEFPUSHBUTTON, ok_x, btn_y, @@ -1031,7 +1032,7 @@ def _console_ctrl_handler(_ctrl_type): # CTRL_C_EVENT, CTRL_BREAK_EVENT, etc. self.h_cancel = user32.CreateWindowExW( # type: ignore[attr-defined] 0, c_wchar_p("BUTTON"), - c_wchar_p(get_text()("Cancel")), + c_wchar_p(_("Cancel")), self.WS_CHILD | self.WS_VISIBLE | self.WS_TABSTOP | self.BS_PUSHBUTTON, cancel_x, btn_y, @@ -1201,8 +1202,8 @@ class TEXTMETRICW(ctypes.Structure): ) except Exception: pass - if self.options.char_limit is not None: - user32.SendMessageW(self.h_edit, WIN_EM_LIMITTEXT, self.options.char_limit, 0) + if self.options.char_limit is not None: + user32.SendMessageW(self.h_edit, WIN_EM_LIMITTEXT, self.options.char_limit, 0) # Initial validation if self.options.validator is not None: From 299a9ae107850252d6139eec1876e554b76b7118 Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 18:26:02 +1000 Subject: [PATCH 15/21] placement --- src/moldflow/message_box.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index 5253b85..07d8aa8 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -678,6 +678,8 @@ def _build_template(self) -> bytes: self._pack_word(buf, 0) # empty text self._pack_word(buf, 0) # no extra data + _ = get_text() + # 3) OK button (default) self._align_dword(buf) self._pack_dword( @@ -693,7 +695,6 @@ def _build_template(self) -> bytes: self._pack_short(buf, btn_y) self._pack_short(buf, btn_w) self._pack_short(buf, btn_h) - _ = get_text() self._pack_word(buf, self.ID_OK) self._pack_word(buf, 0xFFFF) self._pack_word(buf, WIN_CLASS_BUTTON) From b9a64949d0ec7895fe040001ecc97b1bffc2ff0a Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 18:33:21 +1000 Subject: [PATCH 16/21] omit test coverage for win32 ui heavy class --- .coverage-config | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.coverage-config b/.coverage-config index 290b639..0834a1e 100644 --- a/.coverage-config +++ b/.coverage-config @@ -2,6 +2,8 @@ branch = True source_pkgs = moldflow +omit = + src/moldflow/message_box.py [paths] source = From 06eedea90c71300db666e5f42262ec59af949877 Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 18:36:41 +1000 Subject: [PATCH 17/21] add to changelog --- CHANGELOG.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8cb0c1..59e7606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Initial public release -- Python API wrapper for Moldflow Synergy -- Comprehensive test suite -- Documentation with examples -- CI/CD pipeline for automated testing and publishing +- Added convenience class for showing message boxes and text input dialogs via Win32 ### Changed - N/A From 840a6658505479f65d14e0fbec2bb4061e2026c2 Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 18:43:01 +1000 Subject: [PATCH 18/21] fix coverage --- .coverage-config | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.coverage-config b/.coverage-config index 0834a1e..742b67f 100644 --- a/.coverage-config +++ b/.coverage-config @@ -4,6 +4,7 @@ source_pkgs = moldflow omit = src/moldflow/message_box.py + */site-packages/moldflow/message_box.py [paths] source = @@ -14,6 +15,9 @@ source = fail_under = 93 show_missing = True precision = 2 +omit = + src/moldflow/message_box.py + */site-packages/moldflow/message_box.py [html] title = Moldflow API Unit Test Coverage From 462e5845ee97fe0ee678a8ad3a6280de10773441 Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Fri, 19 Sep 2025 19:07:52 +1000 Subject: [PATCH 19/21] more friendly win32 error handling --- src/moldflow/message_box.py | 112 +++++++++++++++++++++++++----------- 1 file changed, 77 insertions(+), 35 deletions(-) diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index 07d8aa8..340da0b 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -18,6 +18,7 @@ import signal import struct from .i18n import get_text +from .logger import get_logger # This module intentionally contains a large amount of Windows interop glue # and UI layout code. @@ -328,7 +329,10 @@ def __post_init__(self) -> None: if not isinstance(size, int): try: size = int(size) - except Exception: + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Font size parse failed; defaulting to 9: %s", exc) size = 9 # Clamp font size between sensible bounds size = max(6, min(size, 24)) @@ -752,8 +756,10 @@ def run(self) -> Optional[str]: wintypes.LPARAM, ] user32.RegisterClassW.restype = wintypes.ATOM - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Win32 prototype binding failed: %s", exc) # Register window class once class_name = "MF_InputDialogWindow" @@ -782,8 +788,10 @@ def _wndproc(hwnd, msg, wparam, lparam): # late WM_COMMAND or automation posts to drain safely. try: inst._on_destroy() - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("_on_destroy raised: %s", exc) return 0 if msg == 0x0082: # WM_NCDESTROY try: @@ -792,8 +800,10 @@ def _wndproc(hwnd, msg, wparam, lparam): _Win32InputDialog._hwnd_to_inst.pop(hwnd, None) # Ensure the modal loop unblocks even if no further messages arrive user32.PostQuitMessage(0) - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("WM_NCDESTROY cleanup failed: %s", exc) return 0 if inst is None: return user32.DefWindowProcW(hwnd, msg, wparam, lparam) @@ -804,8 +814,10 @@ def _wndproc(hwnd, msg, wparam, lparam): # Make label background match dialog background for a flat look try: windll.gdi32.SetBkMode(wparam, 1) # TRANSPARENT - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("SetBkMode transparent failed: %s", exc) return getattr(_Win32InputDialog, "_bg_brush", 0) if msg == _Win32InputDialog.WM_COMMAND: cid = wparam & 0xFFFF @@ -856,8 +868,10 @@ class WNDCLASSEX(ctypes.Structure): user32.LoadCursorW.restype = wintypes.HCURSOR # Second parameter is MAKEINTRESOURCE on system cursors; accept as void* user32.LoadCursorW.argtypes = [wintypes.HINSTANCE, ctypes.c_void_p] - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("RegisterClassEx/LoadCursor prototype bind failed: %s", exc) hInstance = kernel32.GetModuleHandleW(None) wcx = WNDCLASSEX() @@ -887,8 +901,11 @@ class WNDCLASSEX(ctypes.Structure): # Cache background brush so STATIC controls can paint with same bg try: _Win32InputDialog._bg_brush = int(wcx.hbrBackground) # type: ignore[attr-defined] - except Exception: + except Exception as exc: _Win32InputDialog._bg_brush = 0 # type: ignore[attr-defined] + logger = get_logger("message_box") + if logger: + logger.debug("Caching bg brush failed: %s", exc) # Create window style = ( @@ -953,14 +970,18 @@ class WNDCLASSEX(ctypes.Structure): def _sigint_handler(_signum, _frame): try: user32.PostMessageW(self.hwnd, WIN_WM_CLOSE, 0, 0) # type: ignore[attr-defined] - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Posting WM_CLOSE on SIGINT failed: %s", exc) try: self._prev_sigint = signal.getsignal(signal.SIGINT) # type: ignore[attr-defined] signal.signal(signal.SIGINT, _sigint_handler) - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Setting SIGINT handler failed: %s", exc) # Native console control handler (fires immediately even while Python blocks) try: @@ -970,16 +991,20 @@ def _sigint_handler(_signum, _frame): def _console_ctrl_handler(_ctrl_type): # CTRL_C_EVENT, CTRL_BREAK_EVENT, etc. try: user32.PostMessageW(self.hwnd, WIN_WM_CLOSE, 0, 0) # type: ignore[attr-defined] - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Posting WM_CLOSE on console control failed: %s", exc) return True kernel32.SetConsoleCtrlHandler.restype = wintypes.BOOL kernel32.SetConsoleCtrlHandler.argtypes = [HANDLER_ROUTINE, wintypes.BOOL] kernel32.SetConsoleCtrlHandler(_console_ctrl_handler, True) self._console_ctrl_handler = _console_ctrl_handler # type: ignore[attr-defined] - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Setting console control handler failed: %s", exc) # Create child controls self.h_static = user32.CreateWindowExW( # type: ignore[attr-defined] @@ -1190,8 +1215,10 @@ class TEXTMETRICW(ctypes.Structure): gdi32.SelectObject(hdc_static, prev2) finally: user32.ReleaseDC(self.h_static, hdc_static) - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Applying default GUI font failed: %s", exc) # Defaults if self.options.default_text: @@ -1201,8 +1228,10 @@ class TEXTMETRICW(ctypes.Structure): user32.SendMessageW( self.h_edit, WIN_EM_SETCUEBANNER, 1, c_wchar_p(self.options.placeholder) ) - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Setting placeholder text failed: %s", exc) if self.options.char_limit is not None: user32.SendMessageW(self.h_edit, WIN_EM_LIMITTEXT, self.options.char_limit, 0) @@ -1225,14 +1254,18 @@ class TEXTMETRICW(ctypes.Structure): user32.SetWindowPos( hwnd, 0, x, y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER | WIN_SWP_NOACTIVATE ) - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Centering dialog over owner failed: %s", exc) user32.ShowWindow(hwnd, 5) # SW_SHOW try: user32.UpdateWindow(hwnd) - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("UpdateWindow failed: %s", exc) if self.h_edit: user32.SetFocus(self.h_edit) @@ -1256,15 +1289,19 @@ class TEXTMETRICW(ctypes.Structure): prev = getattr(self, "_prev_sigint", None) if prev is not None: signal.signal(signal.SIGINT, prev) - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Restoring SIGINT handler failed: %s", exc) # Remove native console handler try: handler = getattr(self, "_console_ctrl_handler", None) if handler is not None: kernel32.SetConsoleCtrlHandler(handler, False) - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Removing console control handler failed: %s", exc) return self._result_text # Helper methods for WNDPROC @@ -1320,8 +1357,10 @@ def _on_size(self) -> None: btn_y = edit_y + cur_edit_h + spacing * 2 user32.SetWindowPos(self.h_ok, 0, ok_x, btn_y, btn_w, btn_h, WIN_SWP_NOZORDER) # type: ignore[attr-defined] user32.SetWindowPos(self.h_cancel, 0, cancel_x, btn_y, btn_w, btn_h, WIN_SWP_NOZORDER) # type: ignore[attr-defined] - except Exception: - pass + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Resize reflow failed: %s", exc) def _validate_live(self) -> None: user32 = windll.user32 @@ -1330,6 +1369,9 @@ def _validate_live(self) -> None: user32.GetWindowTextW(self.h_edit, buf, length + 1) # type: ignore[attr-defined] try: is_valid = bool(self.options.validator(buf.value)) if self.options.validator else True - except Exception: + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Validator raised exception: %s", exc) is_valid = True user32.EnableWindow(self.h_ok, wintypes.BOOL(1 if is_valid else 0)) # type: ignore[attr-defined] From 908b0363bb54c07566a260063cc86f5f1557e777 Mon Sep 17 00:00:00 2001 From: osinjoku <49887472+osinjoku@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:27:10 +1000 Subject: [PATCH 20/21] Update src/moldflow/message_box.py Co-authored-by: Sankalp Shrivastava --- src/moldflow/message_box.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index 340da0b..54649ef 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -358,20 +358,21 @@ class MessageBox: MessageBox convenience class. Example: - from moldflow import MessageBox, MessageBoxType + .. code-block:: python + from moldflow import MessageBox, MessageBoxType - # Information message - MessageBox("Operation completed.", MessageBoxType.INFO).show() + # Information message + MessageBox("Operation completed.", MessageBoxType.INFO).show() - # Yes/No prompt - result = MessageBox("Proceed with analysis?", MessageBoxType.YES_NO).show() - if result == MessageBoxResult.YES: - ... + # Yes/No prompt + result = MessageBox("Proceed with analysis?", MessageBoxType.YES_NO).show() + if result == MessageBoxResult.YES: + ... - # Text input - material_id = MessageBox("Enter your material ID:", MessageBoxType.INPUT).show() - if material_id: - ... + # Text input + material_id = MessageBox("Enter your material ID:", MessageBoxType.INPUT).show() + if material_id: + ... """ def __init__( From 44d067808bb9d56e83e5da269434e582f9b4a298 Mon Sep 17 00:00:00 2001 From: Osi Njoku Date: Mon, 6 Oct 2025 14:26:23 +1100 Subject: [PATCH 21/21] better doc --- docs/source/components/wrapper/message_box.rst | 14 +++++++------- src/moldflow/message_box.py | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/source/components/wrapper/message_box.rst b/docs/source/components/wrapper/message_box.rst index 6fbe4b4..12a64fc 100644 --- a/docs/source/components/wrapper/message_box.rst +++ b/docs/source/components/wrapper/message_box.rst @@ -95,19 +95,19 @@ Options - Application (default), Task-modal, System-modal * - topmost - bool - - Keep message box on top + - Keep message box on top (standard MessageBox only) * - set_foreground - bool - - Force foreground + - Force foreground (standard MessageBox only) * - right_align / rtl_reading - bool - - Layout flags for right-to-left locales + - Layout flags for right-to-left locales (standard MessageBox only) * - help_button - bool - Show Help button * - owner_hwnd - int | None - - Owner window handle (improves modality/Z-order) + - Owner window handle (standard MessageBox only) * - default_text / placeholder - str | None - Prefill text and cue banner for input dialog @@ -119,13 +119,13 @@ Options - Maximum characters accepted (client-side) * - width_dlu / height_dlu - int | None - - Size the input dialog (dialog units) + - Size the input dialog (pixels; DLUs in legacy template path) * - validator - Callable[[str], bool] | None - Enable OK only when input satisfies predicate * - font_face / font_size_pt - str / int - - Font for input dialog (default Segoe UI 9pt) + - Font for legacy template; CreateWindowEx path uses system dialog font API --- @@ -135,5 +135,5 @@ API Notes ----- -- Localization: button captions ("OK", "Cancel"), title, and prompt are localized via the package i18n system. +- Localization: action button captions (e.g., "OK", "Cancel", "Submit") are localized via the package i18n system. Title and prompt are not localized automatically. - Return type: ``MessageBox.show()`` returns ``MessageBoxReturn`` (``MessageBoxResult | str | None``). diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index 54649ef..0817a51 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -359,6 +359,7 @@ class MessageBox: Example: .. code-block:: python + from moldflow import MessageBox, MessageBoxType # Information message