You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

780 lines
33 KiB

import sys
import time
import os
from PyQt6.QtWidgets import (QMainWindow, QVBoxLayout, QWidget,
QSystemTrayIcon, QMenu, QApplication, QHBoxLayout, QPushButton)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve, QPoint
from PyQt6.QtGui import QFont, QPainter, QIcon, QAction, QPixmap, QDragEnterEvent, QDropEvent, QMouseEvent
import uiautomation as auto
from ai_worker import AIWorker
from drag_drop_handler import DragDropHandler
from icon_window import IconWindow
from main_interface import MainInterface
import qtawesome as qta
import pyperclip
import keyboard
class TransparentWindow(QMainWindow):
text_to_insert = pyqtSignal(str)
def __init__(self):
super().__init__()
self.is_visible = False # Видимость окна (скрыто/показано)
self.is_expanded = False # Ширина окна (узкое/широкое)
self.hook_manager = None
self.dragging = False
self.drag_offset = QPoint()
self.drag_n_drop_active = False
self.setup_ui()
self.setup_tray()
self.setup_focus_monitor()
self.hide() # Изначально окно скрыто
self.last_focus_state = False
self.last_active_timestamp = time.time()
# Инициализация AI worker
self.ai_worker = AIWorker()
self.ai_worker.text_processed.connect(self.on_text_processed)
self.ai_worker.image_processed.connect(self.on_image_processed)
self.ai_worker.processing_finished.connect(self.on_processing_finished)
self.ai_worker.start()
# Инициализация drag-drop handler
self.drag_drop_handler = DragDropHandler(self)
# Переменные состояния
self.current_complaints_text = ""
self.current_history_text = ""
self.current_image_path = ""
self.is_processing = False
self.pending_text_type = None
# Настройка drag-and-drop
self.setAcceptDrops(True)
def setup_ui(self):
"""Настраивает основной интерфейс окна"""
# Устанавливаем прозрачность и убираем рамку
self.setWindowFlags(Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
# Получаем размеры экрана
screen_geometry = QApplication.primaryScreen().geometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
# Размеры окон
self.collapsed_width = 70
self.expanded_width = 200
self.window_height = 300
# Позиционируем основное окно слева по центру
main_x_position = 10
main_y_position = (screen_height - self.window_height) // 2
# Устанавливаем начальную позицию основного окна (свернутое состояние)
self.setGeometry(main_x_position, main_y_position, self.collapsed_width, self.window_height)
# Создаем центральный виджет
self.central_widget = QWidget()
self.central_widget.setObjectName("CentralWidget")
self.central_widget.setAcceptDrops(True)
self.setCentralWidget(self.central_widget)
self.central_widget.mousePressEvent = self.mouse_press_event
self.central_widget.mouseMoveEvent = self.mouse_move_event
self.central_widget.mouseReleaseEvent = self.mouse_release_event
# Основной layout
self.main_layout = QVBoxLayout(self.central_widget)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.top_layout = QHBoxLayout()
self.top_layout.setContentsMargins(4, 4, 4, 0)
self.top_layout.setSpacing(0)
self.top_layout.addStretch()
icn = qta.icon("ph.gear-six-bold", color=('#FFFFFF', 255))
button_1 = QPushButton(icn, "")
button_1.setStyleSheet("""
QPushButton {
color: #FFFFFF;
background-color: rgba(0, 0, 0, 0);
padding: 4px 4px;
border-radius: 5px;
font-size: 14px;
font-weight: 650;
margin: 0px 3px;
text-align: left;
max-width: 16px;
}
QPushButton:hover {
background-color: rgba(0, 0, 0, 100);
}
QPushButton:pressed {
background-color: rgba(0, 0, 0, 220);
}
QPushButton:disabled {
color: #888888;
}
""")
icn = qta.icon("ph.push-pin-bold", color=('#FFFFFF', 255))
button_2 = QPushButton(icn, "")
button_2.setStyleSheet("""
QPushButton {
color: #FFFFFF;
background-color: rgba(0, 0, 0, 0);
padding: 4px 4px;
border-radius: 5px;
font-size: 14px;
font-weight: 650;
margin: 0px 3px;
text-align: left;
max-width: 16px;
}
QPushButton:hover {
background-color: rgba(0, 0, 0, 100);
}
QPushButton:pressed {
background-color: rgba(0, 0, 0, 220);
}
QPushButton:disabled {
color: #888888;
}
""")
self.top_layout.addWidget(button_1)
self.top_layout.addWidget(button_2)
self.main_layout.addLayout(self.top_layout)
# Создаем и показываем иконку
self.icon_window = IconWindow(self)
self.icon_window.icon_clicked.connect(self.toggle_window_visibility)
self.icon_window.show_icon()
# .addLayout(self.central_widget)
# Создаем основной интерфейс
self.main_interface = MainInterface(self)
self.main_interface.button_clicked.connect(self.on_button_click)
self.main_interface.hide() # Изначально скрыт
self.main_layout.addWidget(self.main_interface)
self.central_widget.setStyleSheet("""
#CentralWidget {
background: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(30, 30, 30, 220),
stop:1 rgba(40, 40, 40, 240)
);
border-radius: 15px;
}
""")
self.text_to_insert.connect(self.insert_text)
def mouse_press_event(self, event: QMouseEvent):
"""Обработчик нажатия мыши на иконку"""
if event.button() == Qt.MouseButton.LeftButton:
self.dragging = True
self.drag_offset = event.position().toPoint()
event.accept()
def mouse_move_event(self, event: QMouseEvent):
"""Обработчик движения мыши для иконки"""
if self.dragging and event.buttons() & Qt.MouseButton.LeftButton:
global_pos = self.central_widget.mapToGlobal(event.position().toPoint())
new_pos = global_pos - self.drag_offset + QPoint(-10,-10)
self.move(new_pos)
event.accept()
def mouse_release_event(self, event: QMouseEvent):
"""Обработчик отпускания мыши для иконки"""
if event.button() == Qt.MouseButton.LeftButton and self.dragging:
# Решение конфликта событий mouse_press_event и clicked
self.dragging = False
event.accept()
def toggle_window_visibility(self):
"""Переключает видимость окна (скрыть/показать)"""
if self.is_visible:
self.hide_window()
else:
self.show_window()
def show_window(self):
"""Показывает окно в узком состоянии"""
if self.is_visible:
return
self.is_visible = True
self.show()
self.raise_()
# Показываем основной интерфейс
self.main_interface.show()
# Устанавливаем узкую ширину и показываем compact drop область
# self.resize(self.collapsed_width, self.window_height)
# self.main_interface.hide_buttons()
# self.main_interface.show_compact_drop_area()
self.expand_for_text_field()
def hide_window(self):
"""Полностью скрывает окно"""
if not self.is_visible:
return
self.is_visible = False
self.hide()
self.main_interface.hide_all_drop_areas()
def expand_for_drag_drop(self, mime_data):
"""Расширяет окно для drag-n-drop"""
if not self.is_visible:
self.show_window()
if not self.is_expanded:
self.animate_window_width(self.expanded_width)
self.is_expanded = True
# Показываем соответствующие drop области (без кнопок)
self.main_interface.hide_buttons()
self.main_interface.hide_all_drop_areas()
if mime_data.hasUrls():
urls = mime_data.urls()
if urls and urls[0].isLocalFile():
file_path = urls[0].toLocalFile()
if self.drag_drop_handler.is_image_file(file_path):
self.main_interface.show_image_drop_interface()
else:
self.main_interface.show_text_drop_interface()
else:
urls = mime_data.urls()
file_path = urls[0]
self.main_interface.show_image_drop_interface()
elif mime_data.hasText():
self.main_interface.show_text_drop_interface()
def expand_for_text_field(self):
"""Расширяет окно для работы с текстовым полем"""
if not self.is_visible:
self.show_window()
if not self.is_expanded:
self.animate_window_width(self.expanded_width)
self.is_expanded = True
# Показываем кнопки (без drop областей)
self.main_interface.show_buttons()
self.main_interface.hide_all_drop_areas()
def collapse_to_compact(self):
"""Сворачивает окно до компактного состояния"""
if not self.is_expanded:
return
self.animate_window_width(self.collapsed_width)
self.is_expanded = False
# Показываем compact drop область
self.main_interface.hide_buttons()
self.main_interface.show_compact_drop_area()
def animate_window_width(self, target_width, callback=None):
"""Анимирует изменение ширины окна"""
# self.resize(target_width, self.window_height)
if callback:
callback()
def show_drag_drop_interface(self, mime_data):
"""Показывает интерфейс для drag-n-drop"""
self.expand_for_drag_drop(mime_data)
def handle_dropped_image(self, image_path):
"""Обрабатывает перетащенное изображение"""
try:
self.current_image_path = image_path
print(" NEW Image. URL:", image_path)
# Запускаем обработку изображения
self.set_processing_state(True)
self.main_interface.buttons[2].setText(" ЛОР-Статус (обработка...)")
self.main_interface.buttons[3].setText(" Заключение ИИ (обработка...)")
self.ai_worker.process_image(image_path)
self.main_interface.show_compact_drop_area()
except Exception as e:
self.show_error(f"Не удалось загрузить изображение: {str(e)}")
self.set_processing_state(False)
self.main_interface.show_compact_drop_area()
def handle_dropped_text(self, text, drop_type, y_position):
"""Обрабатывает перетащенный текст"""
if not text.strip() and drop_type == 'file_drop':
try:
text = "Текст из перетащенного файла"
except:
text = "Не удалось прочитать файл"
if y_position < self.window_height/2:
# Обрабатываем как жалобы
self.start_text_processing(text, 'complaints')
else:
# Обрабатываем как жалобы
self.start_text_processing(text, 'history')
self.main_interface.show_compact_drop_area()
def start_text_processing(self, text, processing_type):
"""Запускает обработку текста"""
self.set_processing_state(True)
if processing_type == 'complaints':
self.main_interface.buttons[0].setText(" Обработка...")
self.pending_text_type = 'complaints'
else:
self.main_interface.buttons[1].setText(" Обработка...")
self.pending_text_type = 'history'
self.ai_worker.process_text(text, processing_type)
def set_processing_state(self, processing):
"""Устанавливает состояние обработки"""
self.is_processing = processing
self.main_interface.set_buttons_enabled(not processing)
self.main_interface.set_processing_state(processing)
def on_text_processed(self, result, processing_type):
"""Обработчик завершения обработки текста"""
if processing_type == 'complaints':
self.current_complaints_text = result
self.main_interface.buttons[0].setText(" Вставить жалобы (ИИ)")
else:
self.current_history_text = result
self.main_interface.buttons[1].setText(" Вставить анамнез (ИИ)")
def on_image_processed(self, result):
"""Обработчик завершения обработки изображения"""
self.current_image_path = result
self.main_interface.buttons[2].setText(" ЛОР-Статус (ИИ)")
self.main_interface.buttons[3].setText(" Заключение ИИ (готово)")
def on_processing_finished(self, processing_type):
"""Обработчик завершения любой обработки"""
self.set_processing_state(False)
# Drag and Drop events
def dragEnterEvent(self, event: QDragEnterEvent):
"""Обработчик события перетаскивания в окно"""
self.drag_drop_handler.handle_drag_enter(event)
self.drag_n_drop_active = True
def dragLeaveEvent(self, event):
"""Обработчик события выхода перетаскивания из окна"""
if self.is_visible and not self.is_processing:
self.collapse_to_compact()
self.drag_n_drop_active = False
def dropEvent(self, event: QDropEvent):
"""Обработчик события отпускания перетаскиваемого объекта"""
self.drag_drop_handler.handle_drop(event)
self.drag_n_drop_active = False
def setup_tray(self):
"""Настраиваем системный трей"""
self.tray_icon = QSystemTrayIcon(self)
pixmap = QPixmap(16, 16)
pixmap.fill(Qt.GlobalColor.blue)
self.tray_icon.setIcon(QIcon(pixmap))
self.tray_icon.setToolTip("Input Helper\nАвтоматическая панель для ввода текста")
tray_menu = QMenu()
show_action = QAction("Показать панель", self)
show_action.triggered.connect(self.show_window)
hide_action = QAction("Скрыть панель", self)
hide_action.triggered.connect(self.hide_window)
quit_action = QAction("Выход", self)
quit_action.triggered.connect(self.quit_application)
tray_menu.addAction(show_action)
tray_menu.addAction(hide_action)
tray_menu.addSeparator()
tray_menu.addAction(quit_action)
self.tray_icon.setContextMenu(tray_menu)
self.tray_icon.activated.connect(self.tray_icon_activated)
self.tray_icon.show()
def is_editable_field(self):
try:
focused = auto.GetFocusedControl()
if not focused:
return False
control_type = focused.ControlType
if control_type in [auto.ControlType.EditControl, auto.ControlType.ComboBoxControl,
auto.ControlType.TextControl]:
return True
return False
except Exception as e:
return False
def setup_focus_monitor(self):
"""Мониторинг фокуса ввода через таймер"""
self.focus_timer = QTimer()
self.focus_timer.timeout.connect(self.check_focused_control)
self.focus_timer.start(200)
def check_focused_control(self):
"""Проверяет, есть ли фокус на редактируемом поле"""
try:
if self.drag_n_drop_active:
return 0
current_focus_state = self.is_editable_field()
if current_focus_state:
self.last_active_timestamp = time.time()
if not self.last_focus_state:
# При фокусе на текстовом поле - расширяем окно и показываем кнопки
self.expand_for_text_field()
else:
if self.geometry().width() < self.expanded_width:
self.animate_window_width(self.expanded_width)
self.is_expanded = True
self.main_interface.show_buttons()
self.main_interface.hide_all_drop_areas()
else:
if time.time() - self.last_active_timestamp > 5:
self.last_active_timestamp = time.time()
if not self.last_focus_state:
# При фокусе на текстовом поле - расширяем окно и показываем кнопки
self.expand_for_text_field()
else:
if self.geometry().width() < self.expanded_width:
self.animate_window_width(self.expanded_width)
self.is_expanded = True
self.main_interface.show_buttons()
self.main_interface.hide_all_drop_areas()
self.last_focus_state = current_focus_state
except Exception:
pass
def on_button_click(self, index):
"""Обработчик клика по кнопке"""
if index == len(self.main_interface.buttons) - 1: # Последняя кнопка - скрыть
self.restart_form()
elif index == 0: # Кнопка жалоб
self.handle_complaints_button()
elif index == 1: # Кнопка анамнеза
self.handle_history_button()
elif index == 2: # ЛОР-Статус
if "(ИИ)" in self.main_interface.buttons[2].text() and self.current_image_path:
self.insert_text(self.current_image_path)
else:
self.insert_text("""Отоскопия (эндоскопия уха):
Правое ухо: Наружный слуховой проход широкий, кожа розовая, чистая. Барабанная перепонка перламутрово-серого цвета, опознавательные знаки четко дифференцируются.
Левое ухо: Визуализируется обтурирующая масса в костном отделе наружного слухового прохода.""")
elif index == 3: # Заключение ИИ
if "(готово)" in self.main_interface.buttons[3].text() and self.current_image_path:
self.insert_text(self.current_image_path)
else:
self.insert_text("H68.0 Воспаление слуховой (евстахиевой) трубы")
def handle_complaints_button(self):
"""Обрабатывает нажатие кнопки жалоб"""
button = self.main_interface.buttons[0]
text = button.text()
if "Копировать" in text:
self.copy_current_text('complaints')
elif "Вставить" in text and self.current_complaints_text:
self.insert_text(self.current_complaints_text)
def handle_history_button(self):
"""Обрабатывает нажатие кнопки анамнеза"""
button = self.main_interface.buttons[1]
text = button.text()
if "Копировать" in text:
self.copy_current_text('history')
elif "Вставить" in text and self.current_history_text:
self.insert_text(self.current_history_text)
def copy_current_text(self, processing_type):
"""Копирует текст из активного поля и запускает обработку"""
try:
self.hide_window()
self.is_visible = False
if processing_type == 'complaints':
self.main_interface.buttons[0].setText(" Обработка...")
else:
self.main_interface.buttons[1].setText(" Обработка...")
self.set_processing_state(True)
QTimer.singleShot(50, lambda: self._do_copy_and_process(processing_type))
except Exception as e:
print(f"Ошибка при копировании: {e}")
self.set_processing_state(False)
# def _do_copy_and_process(self, processing_type):
# """Копирует текст и запускает обработку"""
# try:
#
# original_text = pyperclip.paste()
#
# keyboard.press_and_release('ctrl+a')
# time.sleep(0.3)
# keyboard.press_and_release('ctrl+c')
# time.sleep(0.3)
# keyboard.press_and_release('end')
# time.sleep(0.3)
#
# copied_text = pyperclip.paste()
# # pyperclip.copy(original_text)
#
# print(copied_text)
# if copied_text: #and copied_text != original_text
# self.ai_worker.process_text(copied_text, processing_type)
# else:
# if processing_type == 'complaints':
# self.main_interface.buttons[0].setText(" Копировать жалобы")
# else:
# self.main_interface.buttons[1].setText(" Копировать анамнез")
# print("Не удалось скопировать текст из активного поля")
# self.show_error("Не удалось скопировать текст из активного поля")
# self.set_processing_state(False)
#
# QTimer.singleShot(100, self.show_window)
#
# except Exception as e:
# print(f"Ошибка при работе с pyperclip: {e}")
# self.set_processing_state(False)
# QTimer.singleShot(100, self.show_window)
def _do_copy_and_process(self, processing_type):
"""Копирует выделенный текст из любого окна (включая Chrome) через UI Automation"""
try:
copied_text = None
if auto is not None:
try:
selected_text = self._get_selected_text_uia()
if selected_text:
copied_text = selected_text
except Exception as e:
print(f"UIA сбой: {e}")
copied_text = None
print("Auto:", copied_text)
# Fallback: если UIA недоступен или не сработал — используем SendInput
if not copied_text:
copied_text = self._copy_via_sendinput()
print("SendInput:", copied_text)
# Обработка результата
if copied_text:
self.ai_worker.process_text(copied_text, processing_type)
else:
if processing_type == 'complaints':
self.main_interface.buttons[0].setText(" Копировать жалобы")
else:
self.main_interface.buttons[1].setText(" Копировать анамнез")
print("Не удалось скопировать текст из активного поля")
self.show_error("Не удалось скопировать текст из активного поля")
self.set_processing_state(False)
QTimer.singleShot(100, self.show_window)
except Exception as e:
print(f"Ошибка при работе с pyperclip: {e}")
self.set_processing_state(False)
QTimer.singleShot(100, self.show_window)
def _get_selected_text_uia(self):
"""Получает весь текст из активного редактируемого поля через UI Automation"""
try:
focused = auto.GetFocusedControl()
if not focused:
return None
# Игнорируем известные "пустышки"
if focused.ClassName == "IHWindowClass" and "Input Capture" in (focused.Name or ""):
# RDP: невозможно получить текст локально
return None
# Пытаемся получить TextPattern (даже у ComboBoxControl в Chrome он есть!)
text_pattern = focused.GetTextPattern()
if text_pattern is not None:
try:
full_range = text_pattern.DocumentRange
if full_range:
full_text = full_range.GetText(-1)
if isinstance(full_text, str):
return full_text.strip()
except Exception as e:
print(f"Ошибка при получении DocumentRange: {e}")
# Fallback: если TextPattern недоступен, пробуем Value (осторожно!)
if hasattr(focused, 'LegacyIAccessible'):
acc = focused.LegacyIAccessible
value = getattr(acc, 'Value', None)
if isinstance(value, str) and value.strip():
# Дополнительная проверка: если Name похож на placeholder — не доверяем Value
# Но в большинстве нативных приложений Value = реальный текст
return value.strip()
except Exception as e:
print(f"UIA: ошибка получения текста: {e}")
return None
def _copy_via_sendinput(self):
try:
# Сохраняем оригинальный буфер
original_clipboard = pyperclip.paste()
# Очищаем буфер перед копированием (чтобы точно знать, что скопировалось)
pyperclip.copy("")
time.sleep(0.15)
# Выделяем всё и копируем
keyboard.press_and_release('ctrl+a', do_press=True, do_release=False)
time.sleep(0.2)
keyboard.press_and_release('ctrl+a', do_press=False, do_release=True)
time.sleep(0.2)
keyboard.press_and_release('ctrl+c', do_press=True, do_release=False)
time.sleep(0.2)
keyboard.press_and_release('ctrl+c', do_press=False, do_release=True)
time.sleep(0.4) # Достаточно для большинства случаев
# # Перемещаем курсор в конец (опционально)
# keyboard.press_and_release('end')
time.sleep(0.15)
# Получаем результат
copied_text = pyperclip.paste().strip()
# Восстанавливаем оригинальный буфер
pyperclip.copy(original_clipboard)
if copied_text:
return copied_text
else:
return None
except Exception as e:
print(f"Ошибка при копировании: {e}")
return None
def insert_text(self, text):
"""Вставляет текст в активное поле ввода"""
try:
self.hide_window()
self.is_visible = False
if text:
QTimer.singleShot(50, lambda: self._do_insert_pyperclip(text))
except Exception as e:
print(f"Ошибка при вставке: {e}")
def _do_insert_pyperclip(self, text):
"""Вставка текста с использованием pyperclip"""
try:
import pyperclip
original_text = pyperclip.paste()
pyperclip.copy(text)
QTimer.singleShot(30, lambda: self._paste_and_restore(original_text))
except Exception as e:
print(f"Ошибка при работе с pyperclip: {e}")
QTimer.singleShot(100, self.show_window)
def _paste_and_restore(self, original_text):
"""Вставляет текст и восстанавливает буфер"""
try:
keyboard.press_and_release('ctrl+v')
QTimer.singleShot(100, lambda: pyperclip.copy(original_text))
QTimer.singleShot(100, self.show_window)
except Exception as e:
print(f"Ошибка при вставке: {e}")
QTimer.singleShot(100, self.show_window)
def show_error(self, message):
"""Показывает сообщение об ошибке"""
print(f"Ошибка: {message}")
def tray_icon_activated(self, reason):
"""Обработчик активации иконки в трее"""
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
if self.is_visible:
self.hide_window()
else:
self.show_window()
def quit_application(self):
"""Корректный выход из приложения"""
if self.hook_manager:
try:
self.hook_manager.UnhookMouse()
except:
pass
if self.ai_worker.isRunning():
self.ai_worker.stop()
self.tray_icon.hide()
QApplication.quit()
def paintEvent(self, event):
"""Отрисовывает тень вокруг окна"""
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
shadow_margin = 8
shadow_rect = self.rect().adjusted(
shadow_margin,
shadow_margin,
-shadow_margin,
-shadow_margin
)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(Qt.GlobalColor.black)
painter.setOpacity(0.2)
painter.drawRoundedRect(shadow_rect, 15, 15)
painter.end()
super().paintEvent(event)
def closeEvent(self, event):
"""Обработчик закрытия окна"""
event.ignore()
self.hide_window()
def restart_form(self):
self.main_interface.buttons[0].setText(" Копировать жалобы")
self.main_interface.buttons[1].setText(" Копировать анамнез")
self.main_interface.buttons[2].setText(" ЛОР-Статус")
self.main_interface.buttons[3].setText(" Заключение ИИ")
self.current_complaints_text = ""
self.current_history_text = ""
self.current_image_path = ""