Files
Igor20264 510f7e7adf
Python application / build (push) Waiting to run
v0.5.2
Что то сделал
2026-09-08 19:37:54 +03:00

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