"""File-drop helpers for the md2gost GUI (tkinterdnd2 or Windows WM_DROPFILES).""" from __future__ import annotations import os import sys from collections.abc import Callable from urllib.parse import unquote, urlparse DropCallback = Callable[[list[str]], None] def parse_tkdnd_files(data: str) -> list[str]: """Parse a TkDND file list (`{C:\\a b.md} C:\\c.md`).""" files: list[str] = [] text = (data or "").strip() i = 0 n = len(text) while i < n: if text[i].isspace(): i += 1 continue if text[i] == "{": j = text.find("}", i + 1) if j < 0: files.append(text[i + 1 :]) break files.append(text[i + 1 : j]) i = j + 1 continue j = i while j < n and not text[j].isspace(): j += 1 files.append(text[i:j]) i = j return files def normalize_drop_paths(items: list[str] | str) -> list[str]: if isinstance(items, str): items = parse_tkdnd_files(items) out: list[str] = [] for raw in items: if isinstance(raw, bytes): try: raw = raw.decode("utf-8") except UnicodeDecodeError: raw = raw.decode(sys.getfilesystemencoding() or "utf-8", errors="replace") path = raw.strip().strip('"') if not path: continue if path.lower().startswith("file:"): parsed = urlparse(path) path = unquote(parsed.path) if sys.platform == "win32" and path.startswith("/") and len(path) > 3 and path[2] == ":": path = path[1:] out.append(os.path.normpath(path)) return out def first_markdown(paths: list[str]) -> str | None: for path in paths: if path.lower().endswith(".md") and os.path.isfile(path): return path return None def enable_file_drop(widget, callback: DropCallback) -> str: """Enable dropping files onto widget. Returns backend name: tkdnd | win32 | none.""" def _deliver(items: list[str] | str) -> None: callback(normalize_drop_paths(items)) if _try_tkdnd(widget, _deliver): return "tkdnd" if sys.platform == "win32" and _WinDropHook.attach(widget, _deliver): return "win32" return "none" def _try_tkdnd(widget, deliver: DropCallback) -> bool: try: from tkinterdnd2 import DND_FILES except ImportError: return False register = getattr(widget, "drop_target_register", None) bind = getattr(widget, "dnd_bind", None) if register is None or bind is None: root = widget.winfo_toplevel() register = getattr(root, "drop_target_register", None) bind = getattr(root, "dnd_bind", None) target = root else: target = widget if register is None or bind is None: return False try: register(DND_FILES) bind("<>", lambda event: deliver(getattr(event, "data", "") or "")) bind("<>", lambda event: widget.event_generate("<>")) bind("<>", lambda event: widget.event_generate("<>")) except Exception: return False return True class _WinDropHook: """Subclass a Win32 HWND and accept WM_DROPFILES. Keep a strong ref on the widget.""" _hooks: list[_WinDropHook] = [] def __init__(self, widget, callback: DropCallback): self.widget = widget self.callback = callback self._pending: list[str] = [] self._old_proc = None self._wndproc = None self._hwnd = 0 self._alive = True @classmethod def attach(cls, widget, callback: DropCallback) -> bool: try: toplevel = widget.winfo_toplevel() except Exception: toplevel = widget if getattr(toplevel, "_md2gost_dnd_hooked", False): return True hook = cls(widget, callback) def start(_event=None): if getattr(toplevel, "_md2gost_dnd_hooked", False): return if hook._install(): toplevel._md2gost_dnd_hooked = True toplevel._md2gost_dnd_hook = hook cls._hooks.append(hook) widget.bind("", start, add="+") widget.bind("", lambda e: hook._detach(), add="+") try: if widget.winfo_ismapped(): start() else: widget.after_idle(start) except Exception: widget.after(200, start) return True def _install(self) -> bool: try: api = _win32_drop_api() except Exception: return False hwnd = _toplevel_hwnd(self.widget) if not hwnd: return False WM_DROPFILES = 0x0233 GWLP_WNDPROC = -4 WS_EX_ACCEPTFILES = 0x00000010 GWL_EXSTYLE = -20 def wndproc(hw, msg, wp, lp): if msg == WM_DROPFILES: try: hdrop = int(wp) if wp is not None else 0 if hdrop: self._pending.extend(_query_drop_files(hdrop, api=api)) api.DragFinish(hdrop) except Exception: pass return 0 if self._old_proc: return api.CallWindowProc(self._old_proc, hw, msg, wp, lp) return api.DefWindowProc(hw, msg, wp, lp) self._wndproc = api.WNDPROC(wndproc) try: ex = api.GetWindowLong(hwnd, GWL_EXSTYLE) or 0 api.SetWindowLong(hwnd, GWL_EXSTYLE, int(ex) | WS_EX_ACCEPTFILES) api.DragAcceptFiles(hwnd, True) self._old_proc = api.GetWindowLongPtr(hwnd, GWLP_WNDPROC) api.SetWindowLongPtr(hwnd, GWLP_WNDPROC, api.as_ptr(self._wndproc)) except Exception: return False self._hwnd = hwnd self._poll() return True def _poll(self) -> None: if not self._alive: return if self._pending: files = self._pending[:] self._pending.clear() try: self.callback(files) except Exception: pass try: self.widget.after(120, self._poll) except Exception: self._alive = False def _detach(self) -> None: self._alive = False if not self._hwnd or self._old_proc is None: return try: api = _win32_drop_api() api.SetWindowLongPtr(self._hwnd, -4, self._old_proc) except Exception: pass self._old_proc = None class _Win32DropApi: def __init__(self): import ctypes from ctypes import wintypes self._ctypes = ctypes is64 = ctypes.sizeof(ctypes.c_void_p) == 8 # wintypes.WPARAM/LPARAM historically were 32-bit; force pointer width. WPARAM = ctypes.c_uint64 if is64 else ctypes.c_uint LPARAM = ctypes.c_int64 if is64 else ctypes.c_long HWND = ctypes.c_void_p LRESULT = ctypes.c_int64 if is64 else ctypes.c_long user32 = ctypes.WinDLL("user32", use_last_error=True) shell32 = ctypes.WinDLL("shell32", use_last_error=True) get_ptr = user32.GetWindowLongPtrW if is64 else user32.GetWindowLongW set_ptr = user32.SetWindowLongPtrW if is64 else user32.SetWindowLongW get_ptr.argtypes = [HWND, ctypes.c_int] get_ptr.restype = ctypes.c_void_p set_ptr.argtypes = [HWND, ctypes.c_int, ctypes.c_void_p] set_ptr.restype = ctypes.c_void_p # GWL_EXSTYLE is a 32-bit style mask; Get/SetWindowLongW is enough. get_long = user32.GetWindowLongW set_long = user32.SetWindowLongW get_long.argtypes = [HWND, ctypes.c_int] get_long.restype = ctypes.c_long set_long.argtypes = [HWND, ctypes.c_int, ctypes.c_long] set_long.restype = ctypes.c_long call_proc = user32.CallWindowProcW call_proc.argtypes = [ctypes.c_void_p, HWND, wintypes.UINT, WPARAM, LPARAM] call_proc.restype = LRESULT def_proc = user32.DefWindowProcW def_proc.argtypes = [HWND, wintypes.UINT, WPARAM, LPARAM] def_proc.restype = LRESULT accept = shell32.DragAcceptFiles accept.argtypes = [HWND, wintypes.BOOL] accept.restype = None finish = shell32.DragFinish finish.argtypes = [ctypes.c_void_p] finish.restype = None query = shell32.DragQueryFileW query.argtypes = [ctypes.c_void_p, wintypes.UINT, ctypes.c_wchar_p, wintypes.UINT] query.restype = wintypes.UINT self.shell32 = shell32 self.WNDPROC = ctypes.WINFUNCTYPE(LRESULT, HWND, wintypes.UINT, WPARAM, LPARAM) self.GetWindowLongPtr = get_ptr self.SetWindowLongPtr = set_ptr self.GetWindowLong = get_long self.SetWindowLong = set_long self.CallWindowProc = call_proc self.DefWindowProc = def_proc self.DragAcceptFiles = accept self.DragFinish = finish self.DragQueryFileW = query def as_ptr(self, wndproc): return self._ctypes.cast(wndproc, self._ctypes.c_void_p).value _WIN32_DROP_API = None def _win32_drop_api() -> _Win32DropApi: global _WIN32_DROP_API if _WIN32_DROP_API is None: _WIN32_DROP_API = _Win32DropApi() return _WIN32_DROP_API def _toplevel_hwnd(widget) -> int: try: import ctypes hwnd = int(widget.winfo_id()) GA_ROOT = 2 root = ctypes.windll.user32.GetAncestor(hwnd, GA_ROOT) if root: return int(root) parent = ctypes.windll.user32.GetParent(hwnd) return int(parent or hwnd) except Exception: try: return int(widget.winfo_id()) except Exception: return 0 def _query_drop_files(hdrop: int, api: _Win32DropApi | None = None) -> list[str]: import ctypes query = (api or _win32_drop_api()).DragQueryFileW count = query(hdrop, 0xFFFFFFFF, None, 0) files: list[str] = [] for i in range(count): length = query(hdrop, i, None, 0) buf = ctypes.create_unicode_buffer(length + 1) query(hdrop, i, buf, length + 1) if buf.value: files.append(buf.value) return files