Files
aza/AzA march 2026 - Kopie (25)/aza_empfang_smart_pick.py
2026-05-08 22:35:18 +02:00

220 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
Globaler Empfang-Pinsel (Maus-Hook + Clipboard) — gemeinsame Basis fuer
Tkinter-Dialog „An Empfang senden“ und Desktop-WebView-Huelle.
Keine Netz-/Server- oder Log-Ausgabe von Patientendaten.
"""
from __future__ import annotations
import re
import sys
import threading
import time
import tkinter as tk
from typing import Any, Callable
from aza_persistence import _win_clipboard_get
try:
from pynput.keyboard import Controller as KbdController, Key, KeyCode
except ImportError:
KbdController = None # type: ignore[misc, assignment]
Key = None # type: ignore[misc, assignment]
KeyCode = None # type: ignore[misc, assignment]
try:
from pynput.mouse import Button as MouseButton, Listener as MouseListener
_HAS_PYNPUT_MOUSE = True
except ImportError:
MouseButton = None # type: ignore[misc, assignment]
MouseListener = None # type: ignore[misc, assignment]
_HAS_PYNPUT_MOUSE = False
_SMART_LISTENER: list[Any] = [None]
_SMART_RESET_UI: list[Callable[[], None] | None] = [None]
# Set bei Abbruch (Pinsel togglen); verhindert Pick-Erkennung nach dem Stop.
_PICK_SUPERSEEDED: threading.Event = threading.Event()
def compact_and_validate_patient_nr_pick_text(raw: str) -> str:
"""
Extrahiert eine plausible Patienten-ID (212 Ziffern), konservativ.
Ganze Saetze / sehr langer Text werden nicht uebernommen.
"""
if not raw:
return ""
s = str(raw).strip()
if not s or len(s) > 220:
return ""
compact_ws = re.sub(r"\s+", "", s)
norm = re.sub(r"[\s\-\/\u00ad\.\u202f]+", "", compact_ws)
if not norm:
return ""
letters = sum(1 for ch in norm if ch.isalpha())
if letters > 4 and len(norm) > 28:
m = re.search(r"\d{2,12}", norm)
return m.group(0) if m else ""
m = re.search(r"\d{2,12}", norm)
return m.group(0) if m else ""
def _smart_pick_run_ui_reset_only() -> None:
fn = _SMART_RESET_UI[0]
_SMART_RESET_UI[0] = None
if fn:
try:
fn()
except Exception:
pass
def stop_global_patient_nr_pick() -> None:
_PICK_SUPERSEEDED.set()
lis = _SMART_LISTENER[0]
if lis is not None:
try:
lis.stop()
except Exception:
pass
_SMART_LISTENER[0] = None
_smart_pick_run_ui_reset_only()
def _grab_thread_finish() -> None:
"""Nach Clipboard-Polling: Listener ist durch return False schon inactive."""
_SMART_LISTENER[0] = None
if not _PICK_SUPERSEEDED.is_set():
_smart_pick_run_ui_reset_only()
_PICK_SUPERSEEDED.set()
def start_global_patient_nr_pick(
on_clipboard_pick: Callable[[str], None],
*,
reset_pick_ui: Callable[[], None] | None = None,
armed_ui: Callable[[], None] | None = None,
owner_tk=None,
schedule_ui: Callable[[Callable[[], None]], None] | None = None,
) -> str:
"""
Startet einen globalen Pick-Zyklus (ein Freigabe-Event, dann Clipboard).
Returns:
"started" — Hoerer aktiv, auf Markierung/Doppelklick warten.
"cancelled" — Vorheriger Zyklus wurde abgebrochen (UI-Reset ausgefuehrt).
"unavailable" — pynput/Tastatur nicht nutzbar.
"""
if _SMART_LISTENER[0] is not None:
stop_global_patient_nr_pick()
return "cancelled"
if (
not _HAS_PYNPUT_MOUSE
or MouseListener is None
or MouseButton is None
or KbdController is None
or Key is None
or KeyCode is None
):
return "unavailable"
_PICK_SUPERSEEDED.clear()
try:
old_clip = ""
if sys.platform == "win32":
old_clip = (_win_clipboard_get() or "").strip()
if not old_clip and owner_tk is not None:
try:
old_clip = owner_tk.clipboard_get().strip()
except tk.TclError:
old_clip = ""
except Exception:
old_clip = ""
_SMART_RESET_UI[0] = reset_pick_ui
if armed_ui:
try:
armed_ui()
except Exception:
pass
def schedule(fn: Callable[[], None]) -> None:
if schedule_ui is not None:
schedule_ui(fn)
else:
# Nicht im pynput-Listener-Thread ausfuehren (lis.stop() / Clipboard wuerden riskieren).
threading.Thread(target=fn, daemon=True).start()
started_ts = time.time()
mouse_was_pressed = False
press_time = 0.0
def on_click(x: object, y: object, button: object, pressed: bool) -> bool | None:
nonlocal mouse_was_pressed, press_time
assert MouseButton is not None
if button != MouseButton.left:
return None
if pressed:
if time.time() - started_ts > 0.3:
mouse_was_pressed = True
press_time = time.time()
return None
if not mouse_was_pressed:
return None
mouse_was_pressed = False
hold_duration = time.time() - press_time
was_drag = hold_duration > 0.20
time.sleep(0.05)
try:
kbd = KbdController()
if not was_drag:
from pynput.mouse import Controller as MController
mc = MController()
mc.click(MouseButton.left, 2)
time.sleep(0.1)
with kbd.pressed(Key.ctrl):
kbd.tap(KeyCode.from_char("c"))
except Exception:
pass
time.sleep(0.38)
def grab() -> None:
cur = ""
try:
for _attempt in range(8):
cur = ""
if sys.platform == "win32":
cur = (_win_clipboard_get() or "").strip()
if not cur and owner_tk is not None:
try:
cur = owner_tk.clipboard_get().strip()
except tk.TclError:
cur = ""
if cur and cur != old_clip:
break
time.sleep(0.06)
if cur and cur != old_clip:
if not _PICK_SUPERSEEDED.is_set():
try:
on_clipboard_pick(cur)
except Exception:
pass
except Exception:
pass
_grab_thread_finish()
schedule(grab)
return False
assert MouseListener is not None
ml = MouseListener(on_click=on_click)
_SMART_LISTENER[0] = ml
ml.start()
return "started"