334 lines
10 KiB
Python
334 lines
10 KiB
Python
"""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 first_docx(paths: list[str]) -> str | None:
|
|
for path in paths:
|
|
if path.lower().endswith(".docx") 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("<<Drop>>", lambda event: deliver(getattr(event, "data", "") or ""))
|
|
bind("<<DragEnter>>", lambda event: widget.event_generate("<<Md2GostDragEnter>>"))
|
|
bind("<<DragLeave>>", lambda event: widget.event_generate("<<Md2GostDragLeave>>"))
|
|
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("<Map>", start, add="+")
|
|
widget.bind("<Destroy>", 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
|