diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fc7036a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Click'n'Translate is a Windows desktop application for instant screen translation and OCR. Users capture text from any screen area via global hotkeys, then translate or copy it to clipboard. + +**Tech Stack**: Python 3, PyQt5, Windows APIs (winrt, ctypes) + +## Commands + +```bash +# Run from source +pip install -r requirements.txt +python main.py + +# Build executable (creates dist/ClicknTranslate/) +build_release.bat +``` + +## Architecture + +### Core Modules + +- **main.py** - Main window, global hotkey handling, configuration management, history tracking +- **ocr.py** - OCR engine abstraction layer (Windows OCR, Tesseract, RapidOCR) +- **translater.py** - Translation engine abstraction (Google, Argos, MyMemory, Lingva, LibreTranslate) +- **settings_window.py** - Settings UI with bilingual support (Russian/English) + +### Key Patterns + +**Configuration Caching**: All modules cache config file reads with modification time tracking to reduce disk I/O. + +**Hotkey Threading**: Global hotkeys are registered via `ctypes.windll.user32.RegisterHotKey`. Callbacks run in separate threads and use `_HotkeyDispatcher` (PyQt5 signal) to safely dispatch to the Qt main thread. + +**History Storage**: Uses file locking (`msvcrt` on Windows) for thread-safe JSON history writes. + +**Engine Abstraction**: Both OCR and translation support multiple engines with automatic fallbacks. + +### Resource Paths + +- App directory: `sys._MEIPASS` (PyInstaller) or script directory +- Data directory: `{APP_DIR}/data/` (auto-created, contains config.json and history files) +- Icons: `icons/` subdirectory + +### Global Hotkeys (default) + +- `Ctrl+Alt+C` - Quick Copy Mode (OCR + clipboard) +- `Ctrl+Alt+T` - Quick Translate Mode (OCR + translate) + +## Platform Constraints + +- **Windows-only** - Uses winrt APIs, ctypes for keyboard simulation, Windows Registry for autostart +- Windows OCR requires language packs installed via Windows Settings diff --git a/debug_ocr_final.png b/debug_ocr_final.png index 31c91c4..ee11bec 100644 Binary files a/debug_ocr_final.png and b/debug_ocr_final.png differ diff --git a/debug_ocr_original.png b/debug_ocr_original.png index 4133682..edb6599 100644 Binary files a/debug_ocr_original.png and b/debug_ocr_original.png differ diff --git a/main.py b/main.py index 19035c5..a92cc36 100644 --- a/main.py +++ b/main.py @@ -24,7 +24,7 @@ from PyQt5 import QtCore from PyQt5.QtWidgets import (QApplication, QMainWindow, QLabel, QVBoxLayout, QComboBox, - QWidget, QPushButton, QSystemTrayIcon, QMenu, QMessageBox, QLineEdit, QTextEdit, QDialog, QHBoxLayout, QCheckBox, QSpacerItem, QSizePolicy, QProgressDialog) + QWidget, QPushButton, QSystemTrayIcon, QMenu, QMessageBox, QLineEdit, QTextEdit, QTextBrowser, QDialog, QHBoxLayout, QCheckBox, QSpacerItem, QSizePolicy, QProgressDialog) from PyQt5.QtCore import Qt, QTimer, QSize from PyQt5.QtGui import QIcon from settings_window import SettingsWindow @@ -38,6 +38,8 @@ "translation_mode": "English", "copy_hotkey": "Ctrl+Alt+C", "translate_hotkey": "Ctrl+Alt+T", + "live_hotkey": "Ctrl+Alt+L", + "live_translation_interval": 3, "notifications": False, "history": False, "start_minimized": False, @@ -554,6 +556,14 @@ def __init__(self): self.translate_hotkey_thread = HotkeyListenerThread(translate_hotkey, self.launch_translate, hotkey_id=2) self.translate_hotkey_thread.start() + live_hotkey = self.config.get("live_hotkey", DEFAULT_CONFIG.get("live_hotkey", "")) + if live_hotkey: + self.live_hotkey_thread = HotkeyListenerThread(live_hotkey, self.launch_live_translate, hotkey_id=3) + self.live_hotkey_thread.start() + + # Менеджер Live Translation + self.live_manager = None + self.HotkeyListenerThread = HotkeyListenerThread self.setWindowIcon(QIcon(resource_path("icons/icon.ico"))) @@ -774,6 +784,17 @@ def launch_translate(self): if not self.config.get("keep_visible_on_ocr", False): self.hide() + def launch_live_translate(self): + """Запуск режима непрерывного чтения (Live Translation).""" + print("launch_live_translate called") + try: + from ocr import run_screen_capture + if not self.config.get("keep_visible_on_ocr", False): + self.hide() + run_screen_capture(mode="live") + except Exception as e: + print(f"Error launching live translate: {e}") + def restart_hotkey_listener(self): self.hotkey_thread = HotkeyListenerThread(self.config.get("ocr_hotkeys", "Ctrl+O"), self.launch_ocr) self.hotkey_thread.start() @@ -1452,6 +1473,17 @@ def closeEvent(self, event): self.translate_hotkey_thread.join(timeout=0.5) except Exception as e: print(f"Error stopping translate hotkey thread: {e}") + try: + if hasattr(self, "live_hotkey_thread") and self.live_hotkey_thread is not None: + self.live_hotkey_thread.stop() + self.live_hotkey_thread.join(timeout=0.5) + except Exception as e: + print(f"Error stopping live hotkey thread: {e}") + try: + if hasattr(self, "live_manager") and self.live_manager is not None: + self.live_manager.stop() + except Exception as e: + print(f"Error stopping live manager: {e}") self.save_config() self.tray_icon.hide() # Убираем иконку из трея event.accept() @@ -1538,45 +1570,597 @@ def _translate_with_progress(): def minimize_to_tray(self): self.hide() +# --- Оверлейное окно для отображения перевода поверх оригинала --- +class TranslationOverlay(QWidget): + """Окно-оверлей для показа перевода поверх выделенной области.""" + + def __init__(self, translated_text, x, y, width, height, opacity=85, theme='Темная', font_size=14, line_height=1.5): + super().__init__() + self.setWindowFlags( + Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint | Qt.Tool + ) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setGeometry(x, y, width, height) + + # Сохраняем для перерендеринга при изменении шрифта + self.translated_text = translated_text + self.current_font_size = font_size + self.current_line_height = line_height + + # Цвета по теме + if theme == "Темная": + bg_r, bg_g, bg_b = 30, 30, 46 + self.text_color = "#E0E0E0" + scroll_bg, scroll_handle = "#2E2E3E", "#555" + else: + bg_r, bg_g, bg_b = 255, 255, 255 + self.text_color = "#1a1a1a" + scroll_bg, scroll_handle = "#f0f0f0", "#ccc" + + alpha = int(opacity * 2.55) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.text_browser = QTextBrowser() + self.text_browser.setReadOnly(True) + self.text_browser.setOpenExternalLinks(False) + self.text_browser.setStyleSheet(f""" + QTextBrowser {{ + background-color: rgba({bg_r}, {bg_g}, {bg_b}, {alpha}); + border: none; + padding: 8px; + }} + QScrollBar:vertical {{ + background: {scroll_bg}; width: 8px; border-radius: 4px; + }} + QScrollBar::handle:vertical {{ + background: {scroll_handle}; border-radius: 4px; min-height: 20px; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} + """) + + self._update_html() + layout.addWidget(self.text_browser) + + def _update_html(self): + """Обновить HTML с текущим размером шрифта.""" + html_content = format_translation_html( + self.translated_text, + self.text_color, + self.current_font_size, + self.current_line_height + ) + self.text_browser.setHtml(html_content) + + def wheelEvent(self, event): + """Ctrl + колесо = изменить размер шрифта.""" + if event.modifiers() == Qt.ControlModifier: + delta = event.angleDelta().y() + if delta > 0: + self.current_font_size = min(self.current_font_size + 1, 48) + else: + self.current_font_size = max(self.current_font_size - 1, 8) + self._update_html() + event.accept() + else: + super().wheelEvent(event) + + def keyPressEvent(self, event): + if event.key() == Qt.Key_Escape: + self.close() + super().keyPressEvent(event) + + def mousePressEvent(self, event): + self.close() + + +# --- Live Translation (непрерывное чтение) --- +class LiveTranslationOverlay(TranslationOverlay): + """Оверлей с поддержкой Live режима и индикатором.""" + + def __init__(self, translated_text, x, y, width, height, opacity=85, theme='Темная', font_size=14, line_height=1.5, live_manager=None): + super().__init__(translated_text, x, y, width, height, opacity, theme, font_size, line_height) + self.live_manager = live_manager + + # Индикатор Live режима + self.live_indicator = QLabel("● LIVE", self) + self.live_indicator.setStyleSheet(""" + QLabel { + color: #ff4444; + background-color: rgba(0, 0, 0, 150); + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: bold; + } + """) + self.live_indicator.adjustSize() + self.live_indicator.move(5, 5) + self.live_indicator.show() + + # Пульсация индикатора + self.pulse_timer = QTimer(self) + self.pulse_timer.timeout.connect(self._pulse) + self.pulse_timer.start(500) + self.pulse_state = True + + def _pulse(self): + """Пульсация индикатора Live.""" + self.pulse_state = not self.pulse_state + color = "#ff4444" if self.pulse_state else "#aa2222" + self.live_indicator.setStyleSheet(f""" + QLabel {{ + color: {color}; + background-color: rgba(0, 0, 0, 150); + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: bold; + }} + """) + + def update_translation(self, translated_text, font_size, line_height): + """Обновить перевод в оверлее.""" + self.translated_text = translated_text + self.current_font_size = font_size + self.current_line_height = line_height + self._update_html() + + def closeEvent(self, event): + """Остановить Live режим при закрытии.""" + if self.live_manager: + self.live_manager.stop() + self.pulse_timer.stop() + super().closeEvent(event) + + def mousePressEvent(self, event): + """Закрыть по клику и остановить Live.""" + if self.live_manager: + self.live_manager.stop() + self.close() + + +class LiveTranslationManager: + """Менеджер режима непрерывного чтения.""" + + MAX_CACHE_SIZE = 100 + + def __init__(self, parent): + self.parent = parent + self.timer = QTimer() + self.timer.timeout.connect(self._tick) + + self.capture_area = None # (x, y, width, height) + self.overlay = None + self.last_ocr_hash = None + self.last_ocr_text = None + self.translation_cache = {} # hash -> (translated_text, ocr_text) + + self.theme = "Темная" + self.lang = "ru" + self.opacity = 85 + + def start(self, x, y, width, height, initial_ocr_text, initial_translation, interval_sec=3): + """Запускает режим непрерывного чтения.""" + import hashlib + + self.capture_area = (x, y, width, height) + + config = get_cached_config() + self.theme = config.get("theme", "Темная") + self.lang = config.get("interface_language", "ru") + self.opacity = config.get("overlay_opacity", 85) + + # Начальный хеш и кеш + self.last_ocr_text = initial_ocr_text + self.last_ocr_hash = hashlib.md5(initial_ocr_text.encode()).hexdigest() + self.translation_cache[self.last_ocr_hash] = (initial_translation, initial_ocr_text) + + # Вычисляем метрики шрифта + metrics = estimate_font_metrics(initial_ocr_text, initial_translation, height, width) + + # Создаём оверлей + self.overlay = LiveTranslationOverlay( + initial_translation, + x, y, width, height, + opacity=self.opacity, + theme=self.theme, + font_size=metrics['font_size'], + line_height=metrics['line_height'], + live_manager=self + ) + self.overlay.show() + + # Запускаем таймер + self.timer.setInterval(interval_sec * 1000) + self.timer.start() + + def stop(self): + """Останавливает режим.""" + self.timer.stop() + if self.overlay and self.overlay.isVisible(): + self.overlay.close() + self.overlay = None + + def _tick(self): + """Вызывается каждые N секунд.""" + import hashlib + from PyQt5.QtWidgets import QApplication + from PyQt5.QtCore import QThread + + if not self.capture_area or not self.overlay: + return + + x, y, w, h = self.capture_area + + try: + # 1. Прячем оверлей чтобы не захватить его на скриншоте + self.overlay.setWindowOpacity(0) + QApplication.processEvents() + QThread.msleep(50) # Даём ОС время убрать оверлей с экрана + + try: + # 2. Скриншот чистой области (без оверлея) + screen = QApplication.primaryScreen() + screenshot = screen.grabWindow(0, x, y, w, h) + finally: + # 3. Показываем оверлей обратно (даже при ошибке) + self.overlay.setWindowOpacity(1) + + if screenshot.isNull(): + return + + qimage = screenshot.toImage() + + # 2. OCR + ocr_text = self._run_quick_ocr(qimage) + if not ocr_text: + return + + # 3. Игнорируем промежуточные состояния (анимация перелистывания) + if self.last_ocr_text and len(ocr_text.strip()) < len(self.last_ocr_text.strip()) * 0.3: + return + + # 4. Хеш текста + text_hash = hashlib.md5(ocr_text.encode()).hexdigest() + + # 5. Если текст не изменился — пропускаем + if text_hash == self.last_ocr_hash: + return + + self.last_ocr_hash = text_hash + self.last_ocr_text = ocr_text + + # 6. Проверяем кеш переводов + if text_hash in self.translation_cache: + translated, _ = self.translation_cache[text_hash] + else: + # 7. Переводим + from translater import translate_text + config = get_cached_config() + + # Определяем направление перевода по текущему языку OCR + from ocr import get_cached_ocr_config + ocr_config = get_cached_ocr_config() + ocr_lang = ocr_config.get("last_ocr_language", "ru") + + if ocr_lang == "ru": + source_code, target_code = "ru", "en" + else: + source_code, target_code = "en", "ru" + + translated = translate_text(ocr_text, source_code, target_code) + if not translated: + return + + # Кешируем + self.translation_cache[text_hash] = (translated, ocr_text) + + # Ограничиваем размер кеша + if len(self.translation_cache) > self.MAX_CACHE_SIZE: + # Удаляем старейшую запись + oldest_key = next(iter(self.translation_cache)) + del self.translation_cache[oldest_key] + + # 8. Обновляем оверлей + metrics = estimate_font_metrics(ocr_text, translated, h, w) + self.overlay.update_translation(translated, metrics['font_size'], metrics['line_height']) + + except Exception as e: + logging.error(f"Live translation tick error: {e}") + + def _run_quick_ocr(self, qimage): + """Быстрый OCR без тяжёлой предобработки для Live режима.""" + try: + from ocr import qimage_to_softwarebitmap, _get_windows_ocr_engine, run_ocr_with_engine, _get_ocr_event_loop, get_cached_ocr_config + import asyncio + + config = get_cached_ocr_config() + ocr_lang = config.get("last_ocr_language", "ru") + lang_tag = {"en": "en-US", "ru": "ru-RU"}.get(ocr_lang, "ru-RU") + + # Конвертируем в bitmap + bitmap = qimage_to_softwarebitmap(qimage) + if not bitmap: + return "" + + # Получаем движок OCR + engine = _get_windows_ocr_engine(lang_tag) + if not engine: + return "" + + # Запускаем OCR + loop = _get_ocr_event_loop() + asyncio.set_event_loop(loop) + result = loop.run_until_complete(run_ocr_with_engine(bitmap, engine)) + + if not result: + return "" + + # Извлекаем текст + lines_text = [] + try: + for line in result.lines: + try: + words = list(line.words) + if words: + lines_text.append(" ".join(word.text for word in words)) + else: + lines_text.append(line.text) + except: + lines_text.append(line.text) + except: + return "" + + return "\n".join(lines_text) + + except Exception as e: + logging.error(f"Quick OCR error: {e}") + return "" + + +def estimate_font_metrics(ocr_text, translated_text, area_height, area_width): + """Оценивает размер шрифта с учётом длины перевода.""" + lines = [line for line in ocr_text.split('\n') if line.strip()] + line_count = len(lines) + + if line_count == 0: + return {'font_size': 14, 'line_height': 1.5} + + # Высота строки в оригинале + line_height_px = area_height / line_count + + # Базовый размер шрифта — 60% от высоты строки (было 72%, слишком много) + font_size_px = int(line_height_px * 0.60) + + # Компенсация длины перевода: если перевод длиннее оригинала, + # нужно уменьшить шрифт чтобы текст поместился + original_len = len(ocr_text.replace('\n', ' ').strip()) + translated_len = len(translated_text.replace('\n', ' ').strip()) + + if original_len > 0 and translated_len > original_len: + # Коэффициент удлинения, но не меньше 0.7 (не уменьшаем шрифт более чем на 30%) + length_ratio = max(0.7, original_len / translated_len) + font_size_px = int(font_size_px * length_ratio) + + # Ограничения + font_size_px = max(9, min(font_size_px, 40)) + + line_height_ratio = round(line_height_px / max(font_size_px, 1), 2) + line_height_ratio = max(1.2, min(line_height_ratio, 2.0)) + + return { + 'font_size': font_size_px, + 'line_height': line_height_ratio, + } + + +def format_translation_html(text, text_color="#E0E0E0", font_size=15, line_height=1.6): + """Форматирует текст перевода в HTML с абзацами (OCR-эвристика). + + Если строка короче 75% от медианной длины — это конец абзаца. + """ + import html as html_module + normalized_text = text.replace('\r\n', '\n').strip() + lines = normalized_text.split('\n') + + non_empty_lens = [len(line.strip()) for line in lines if line.strip()] + if not non_empty_lens: + return '' + + sorted_lens = sorted(non_empty_lens) + median_len = sorted_lens[len(sorted_lens) // 2] + threshold = median_len * 0.75 + + paragraphs = [] + current_para = [] + + for line in lines: + stripped = line.strip() + if not stripped: + if current_para: + paragraphs.append(' '.join(current_para)) + current_para = [] + continue + + current_para.append(stripped) + if len(stripped) < threshold: + paragraphs.append(' '.join(current_para)) + current_para = [] + + if current_para: + paragraphs.append(' '.join(current_para)) + + html_parts = [] + for p in paragraphs: + if p: + escaped = html_module.escape(p) + html_parts.append(f'

{escaped}

') + + return f''' +
+ {''.join(html_parts)} +
+ ''' + + # --- Универсальный диалог перевода --- -def show_translation_dialog(parent, translated_text, auto_copy=True, lang='ru', theme='Темная'): - if theme == "Темная": - style = ( - "QMessageBox { background-color: #121212; color: #ffffff; } " - "QLabel { color: #ffffff; font-size: 18px; } " - "QPushButton { background-color: #1e1e1e; color: #ffffff; border: 1px solid #550000; padding: 5px; min-width: 80px; } " - "QPushButton:hover { background-color: #333333; }" +def show_translation_dialog(parent, translated_text, auto_copy=True, lang='ru', theme='Темная', coords=None, original_text=None): + config = get_cached_config() + display_mode = config.get("translation_display_mode", "popup") + overlay_opacity = config.get("overlay_opacity", 85) + + # Автокопирование + if auto_copy: + pyperclip.copy(translated_text) + + # Определяем цвет текста по теме + text_color = "#E0E0E0" if theme == "Темная" else "#1a1a1a" + + # --- Режим оверлея --- + if display_mode == "overlay" and coords: + # Вычисляем метрики шрифта по оригинальному тексту OCR и длине перевода + if original_text and coords.get('height'): + metrics = estimate_font_metrics(original_text, translated_text, coords['height'], coords['width']) + font_size = metrics['font_size'] + line_height = metrics['line_height'] + else: + font_size, line_height = 15, 1.5 + + overlay = TranslationOverlay( + translated_text, + coords['x'], coords['y'], coords['width'], coords['height'], + opacity=overlay_opacity, + theme=theme, + font_size=font_size, + line_height=line_height ) + overlay.show() + # Храним ссылку чтобы окно не уничтожилось + if not hasattr(parent, '_overlay_windows'): + parent._overlay_windows = [] + parent._overlay_windows.append(overlay) + return + + # --- Режим popup (дефолтные значения) --- + html_content = format_translation_html(translated_text, text_color) + + # --- Режим popup (по умолчанию) --- + dialog = QDialog(parent) + dialog.setWindowTitle(" ") + dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowTitleHint | Qt.WindowCloseButtonHint) + dialog.setWindowIcon(QIcon(resource_path("icons/icon.png"))) + + screen = QApplication.primaryScreen().geometry() + max_height = int(screen.height() * 0.75) + + if theme == "Темная": + bg_color = "#121212" + btn_bg, btn_border, btn_hover = "#1e1e1e", "#550000", "#333333" + scroll_bg, scroll_handle, scroll_hover = "#1e1e1e", "#555555", "#777777" else: - style = ( - "QMessageBox { background-color: #ffffff; color: #000000; } " - "QLabel { color: #000000; font-size: 18px; } " - "QPushButton { background-color: #f0f0f0; color: #000000; border: 1px solid #cccccc; padding: 5px; min-width: 80px; } " - "QPushButton:hover { background-color: #e0e0e0; }" - ) + bg_color = "#ffffff" + btn_bg, btn_border, btn_hover = "#f0f0f0", "#cccccc", "#e0e0e0" + scroll_bg, scroll_handle, scroll_hover = "#f0f0f0", "#cccccc", "#aaaaaa" + + dialog.setStyleSheet(f"QDialog {{ background-color: {bg_color}; }}") + + layout = QVBoxLayout(dialog) + layout.setContentsMargins(15, 15, 15, 15) + layout.setSpacing(15) + + text_browser = QTextBrowser() + text_browser.setReadOnly(True) + text_browser.setOpenExternalLinks(False) + text_browser.setStyleSheet(f""" + QTextBrowser {{ + background-color: {bg_color}; + border: none; + padding: 4px; + }} + QScrollBar:vertical {{ + background: {scroll_bg}; width: 8px; border-radius: 4px; + }} + QScrollBar::handle:vertical {{ + background: {scroll_handle}; border-radius: 4px; min-height: 20px; + }} + QScrollBar::handle:vertical:hover {{ background: {scroll_hover}; }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: none; }} + """) + text_browser.setHtml(html_content) + layout.addWidget(text_browser) + + # Кнопки + button_style = f""" + QPushButton {{ + background-color: {btn_bg}; color: {text_color}; + border: 1px solid {btn_border}; padding: 8px 16px; min-width: 80px; + }} + QPushButton:hover {{ background-color: {btn_hover}; }} + """ + copy_text = "Copy" if lang == "en" else "Копировать" close_text = "Close" if lang == "en" else "Закрыть" google_text = "Google" if lang == "en" else "Гугл" - msg = QMessageBox(parent) - msg.setWindowTitle(" ") - msg.setText(translated_text) + + button_layout = QHBoxLayout() + button_layout.addStretch() + + copy_button = None if not auto_copy: - copy_button = msg.addButton(copy_text, QMessageBox.ActionRole) - google_button = msg.addButton(google_text, QMessageBox.ActionRole) - close_button = msg.addButton(close_text, QMessageBox.RejectRole) - msg.setStyleSheet(style) - msg.setWindowFlags(msg.windowFlags() | Qt.WindowTitleHint | Qt.WindowCloseButtonHint) - msg.setWindowIcon(QIcon(resource_path("icons/icon.png"))) - msg.setIcon(QMessageBox.NoIcon) + copy_button = QPushButton(copy_text) + copy_button.setStyleSheet(button_style) + button_layout.addWidget(copy_button) + + google_button = QPushButton(google_text) + google_button.setStyleSheet(button_style) + button_layout.addWidget(google_button) + + close_button = QPushButton(close_text) + close_button.setStyleSheet(button_style) + button_layout.addWidget(close_button) + + layout.addLayout(button_layout) + if auto_copy: pyperclip.copy(translated_text) - # save_copy_history вызывается в вызывающем коде + + # Вычисляем размер окна по контенту + text_browser.document().setTextWidth(500) + doc_height = int(text_browser.document().size().height()) + dialog_height = min(doc_height + 100, max_height) # +100 для кнопок и отступов + dialog_height = max(dialog_height, 150) + + dialog.setFixedWidth(550) + dialog.setMinimumHeight(150) + dialog.setMaximumHeight(max_height) + dialog.resize(550, dialog_height) + + # Обработчики кнопок + action = {"google": False} + + def on_copy(): + pyperclip.copy(translated_text) + + def on_google(): + action["google"] = True + dialog.accept() + + if copy_button: + copy_button.clicked.connect(on_copy) + google_button.clicked.connect(on_google) + close_button.clicked.connect(dialog.reject) + while True: - clicked = msg.exec_() - if not auto_copy and msg.clickedButton() == copy_button: - pyperclip.copy(translated_text) - # save_copy_history вызывается в вызывающем коде - elif msg.clickedButton() == google_button: + dialog.exec_() + if action["google"]: url = "https://www.google.com/search?q=" + urllib.parse.quote(translated_text) webbrowser.open(url) break diff --git a/ocr.py b/ocr.py index 084907f..dbf8c77 100644 --- a/ocr.py +++ b/ocr.py @@ -19,8 +19,9 @@ def get_log_path(): _debug_log_path = get_log_path() def debug_log(msg): - """Debug logging disabled for production.""" - pass # Логирование отключено для production + """Debug logging для диагностики.""" + with open(_debug_log_path, "a", encoding="utf-8") as f: + f.write(f"[{datetime.now().isoformat()}] {msg}\n") # Инициализация (логирование отключено) @@ -513,6 +514,7 @@ def __init__(self, mode="ocr", defer_show=False): self.start_point = None self.end_point = None self.last_rect = None + self.selection_coords = None # Координаты для оверлейного режима # Загрузка последнего выбранного языка из конфигурации config = get_cached_ocr_config() self.current_language = config.get("last_ocr_language", "ru") @@ -537,10 +539,11 @@ def __init__(self, mode="ocr", defer_show=False): self.lang_combo.addItem(QtGui.QIcon(resource_path("icons/Russian_flag.png")), "RU", "ru") self.lang_combo.addItem(QtGui.QIcon(resource_path("icons/American_flag.png")), "EN", "en") else: - # В режиме translate показываем направление перевода - self.lang_combo.addItem(QtGui.QIcon(resource_path("icons/Russian_flag.png")), "RU → EN", "ru") - self.lang_combo.addItem(QtGui.QIcon(resource_path("icons/American_flag.png")), "EN → RU", "en") - + # В режиме translate/live показываем направление перевода + prefix = "🔴 " if self.mode == "live" else "" + self.lang_combo.addItem(QtGui.QIcon(resource_path("icons/Russian_flag.png")), f"{prefix}RU → EN", "ru") + self.lang_combo.addItem(QtGui.QIcon(resource_path("icons/American_flag.png")), f"{prefix}EN → RU", "en") + # Устанавливаем индекс на основе self.current_language (сохраненного) if self.mode == "copy": # В режиме copy есть AUTO, RU, EN (индексы 0, 1, 2) @@ -553,7 +556,7 @@ def __init__(self, mode="ocr", defer_show=False): else: default_index = 0 # По умолчанию AUTO else: - # В режиме translate только RU, EN (индексы 0, 1) + # В режиме translate/live только RU, EN (индексы 0, 1) default_index = 0 if self.current_language == "ru" else 1 self.lang_combo.setCurrentIndex(default_index) @@ -619,8 +622,8 @@ def __init__(self, mode="ocr", defer_show=False): background-color: rgba(80, 130, 200, 180); } """) - # Размер зависит от режима (translate имеет более длинный текст) - combo_width = 180 if self.mode == "translate" else 160 + # Размер зависит от режима (translate/live имеет более длинный текст) + combo_width = 180 if self.mode in ("translate", "live") else 160 self.lang_combo.setFixedSize(combo_width, 56) self.lang_combo.move((self.width() - self.lang_combo.width()) // 2, 20) # Показываем комбобокс (в режиме copy есть опция AUTO) @@ -864,7 +867,15 @@ def capture_and_copy(self, rect): global_bottom_right = self.mapToGlobal(rect.bottomRight()) global_rect = QtCore.QRect(global_top_left, global_bottom_right) - + + # Сохраняем координаты для оверлейного режима + self.selection_coords = { + 'x': global_rect.x(), + 'y': global_rect.y(), + 'width': global_rect.width(), + 'height': global_rect.height() + } + logging.info(f"Selected local rect: {rect}") logging.info(f"Mapped global rect: {global_rect}") @@ -1188,9 +1199,12 @@ def handle_ocr_result(self, text): theme = config.get("theme", "Темная") lang = config.get("interface_language", "ru") auto_copy = config.get("copy_translated_text", True) + # Получаем координаты выделения для оверлейного режима + coords = getattr(self, 'selection_coords', None) # Ленивый импорт для избежания циклического импорта from main import show_translation_dialog, save_copy_history - show_translation_dialog(self, translated_text, auto_copy=auto_copy, lang=lang, theme=theme) + # text — оригинальный OCR-текст для расчёта размера шрифта в оверлее + show_translation_dialog(self, translated_text, auto_copy=auto_copy, lang=lang, theme=theme, coords=coords, original_text=text) if auto_copy: pyperclip.copy(translated_text) save_copy_history(translated_text) @@ -1200,6 +1214,61 @@ def handle_ocr_result(self, text): # Сохраняем переводы в историю (исходный текст и перевод) save_translation_history(text, translated_text, target_code) self.close() + elif self.mode == "live": + # Режим непрерывного чтения (Live Translation) + from translater import translate_text + lang_code = self.lang_combo.currentData() or "ru" + if lang_code == "ru": + source_code, target_code = "ru", "en" + else: + source_code, target_code = "en", "ru" + + logging.info(f"🔴 Starting Live Translation mode ({source_code.upper()} → {target_code.upper()})...") + try: + translated_text = translate_text(text, source_code, target_code) + if translated_text: + logging.info(f"✅ Initial translation completed ({len(translated_text)} chars)") + else: + logging.warning("⚠️ Initial translation returned empty result") + self.close() + return + except Exception as e: + logging.error(f"❌ Initial translation error: {e}") + QMessageBox.warning(self, "Ошибка перевода", str(e)) + self.close() + return + + # Получаем координаты выделения + coords = getattr(self, 'selection_coords', None) + if not coords: + logging.error("No selection coords for live mode") + self.close() + return + + # Запускаем LiveTranslationManager + config = get_cached_ocr_config() + interval = config.get("live_translation_interval", 3) + + from main import LiveTranslationManager + # Получаем ссылку на главное окно для хранения менеджера + app = QApplication.instance() + main_window = None + for widget in app.topLevelWidgets(): + if widget.__class__.__name__ == "DarkThemeApp": + main_window = widget + break + + if main_window: + main_window.live_manager = LiveTranslationManager(main_window) + main_window.live_manager.start( + coords['x'], coords['y'], coords['width'], coords['height'], + initial_ocr_text=text, + initial_translation=translated_text, + interval_sec=interval + ) + logging.info(f"🔴 Live Translation started (interval: {interval}s)") + + self.close() else: try: # Ленивый импорт для избежания циклического импорта diff --git a/requirements.txt b/requirements.txt index 188875b..644e36d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ winrt-Windows.Globalization winrt-Windows.Graphics.Imaging winrt-Windows.Storage.Streams winrt-Windows.Foundation +winrt-Windows.Foundation.Collections Pillow pyperclip argostranslate diff --git a/settings_window.py b/settings_window.py index 75d0051..7c7a70e 100644 --- a/settings_window.py +++ b/settings_window.py @@ -48,7 +48,13 @@ def resource_path(relative_path): "copy_history_title": "Copy history", "history_empty": "History is empty.", "history_error": "Error reading history.", - "copy_translated_text": "Copy translated text automatically" + "copy_translated_text": "Copy translated text automatically", + "translation_display": "Translation display:", + "display_popup": "Popup window", + "display_overlay": "Overlay", + "overlay_opacity": "Overlay opacity:", + "live_interval": "Live update interval:", + "live_interval_sec": "sec" }, "ru": { "autostart": "Запускать вместе с ОС", @@ -72,7 +78,13 @@ def resource_path(relative_path): "copy_history_title": "История копирований", "history_empty": "История пуста.", "history_error": "Ошибка чтения истории.", - "copy_translated_text": "Копировать сразу переведённый текст" + "copy_translated_text": "Копировать сразу переведённый текст", + "translation_display": "Отображение перевода:", + "display_popup": "Отдельное окно", + "display_overlay": "Оверлей", + "overlay_opacity": "Прозрачность оверлея:", + "live_interval": "Интервал Live режима:", + "live_interval_sec": "сек" } } @@ -277,8 +289,94 @@ def init_ui(self): tr_label.setToolTip(tr_tooltips.get(lang, tr_tooltips["en"])) self.main_layout.addLayout(row2) - # --- Подготовим кнопку обновления (перенесена в группу кнопок ниже) --- - # Убрали из этой строки + # --- Режим отображения перевода и прозрачность оверлея --- + row_display = QHBoxLayout() + row_display.setContentsMargins(0, 0, 0, 0) + row_display.setSpacing(8) + + # Выбор режима отображения + display_label = QLabel(SETTINGS_TEXT[lang]["translation_display"]) + display_label.setStyleSheet("margin:0; padding:0; padding-top: 2px;") + display_label.setFixedWidth(140) + display_label.setFixedHeight(38) + display_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self.display_mode_combo = QComboBox() + display_modes = [ + ("popup", SETTINGS_TEXT[lang]["display_popup"]), + ("overlay", SETTINGS_TEXT[lang]["display_overlay"]) + ] + for mode_id, mode_label in display_modes: + self.display_mode_combo.addItem(mode_label, mode_id) + current_mode = self.parent.config.get("translation_display_mode", "popup") + for i in range(self.display_mode_combo.count()): + if self.display_mode_combo.itemData(i) == current_mode: + self.display_mode_combo.setCurrentIndex(i) + break + self.display_mode_combo.currentIndexChanged.connect(self._on_display_mode_changed) + self.display_mode_combo.setFixedWidth(130) + self.display_mode_combo.setFixedHeight(32) + + row_display.addWidget(display_label) + row_display.addWidget(self.display_mode_combo, alignment=Qt.AlignVCenter) + row_display.addItem(QSpacerItem(20, 20, QSizePolicy.Fixed, QSizePolicy.Minimum)) + + # Слайдер прозрачности + opacity_label = QLabel(SETTINGS_TEXT[lang]["overlay_opacity"]) + opacity_label.setStyleSheet("margin:0; padding:0; padding-top: 2px;") + opacity_label.setFixedWidth(140) + opacity_label.setFixedHeight(38) + opacity_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + from PyQt5.QtWidgets import QSlider + self.opacity_slider = QSlider(Qt.Horizontal) + self.opacity_slider.setMinimum(20) + self.opacity_slider.setMaximum(100) + self.opacity_slider.setValue(self.parent.config.get("overlay_opacity", 85)) + self.opacity_slider.setFixedWidth(100) + self.opacity_slider.setFixedHeight(24) + self.opacity_slider.valueChanged.connect(lambda val: self.auto_save_setting("overlay_opacity", val)) + + self.opacity_value_label = QLabel(f"{self.opacity_slider.value()}%") + self.opacity_value_label.setFixedWidth(35) + self.opacity_slider.valueChanged.connect(lambda val: self.opacity_value_label.setText(f"{val}%")) + + row_display.addWidget(opacity_label) + row_display.addWidget(self.opacity_slider, alignment=Qt.AlignVCenter) + row_display.addWidget(self.opacity_value_label, alignment=Qt.AlignVCenter) + row_display.addStretch() + + self.main_layout.addLayout(row_display) + + # --- Настройка интервала Live Translation --- + row_live = QHBoxLayout() + row_live.setContentsMargins(0, 0, 0, 0) + row_live.setSpacing(8) + + live_label = QLabel(SETTINGS_TEXT[lang]["live_interval"]) + live_label.setStyleSheet("margin:0; padding:0; padding-top: 2px;") + live_label.setFixedWidth(140) + live_label.setFixedHeight(38) + live_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + from PyQt5.QtWidgets import QSpinBox + self.live_interval_spinbox = QSpinBox() + self.live_interval_spinbox.setMinimum(1) + self.live_interval_spinbox.setMaximum(10) + self.live_interval_spinbox.setValue(self.parent.config.get("live_translation_interval", 3)) + self.live_interval_spinbox.setFixedWidth(60) + self.live_interval_spinbox.setFixedHeight(28) + self.live_interval_spinbox.valueChanged.connect(lambda val: self.auto_save_setting("live_translation_interval", val)) + + live_sec_label = QLabel(SETTINGS_TEXT[lang]["live_interval_sec"]) + live_sec_label.setFixedWidth(30) + + row_live.addWidget(live_label) + row_live.addWidget(self.live_interval_spinbox, alignment=Qt.AlignVCenter) + row_live.addWidget(live_sec_label, alignment=Qt.AlignVCenter) + row_live.addStretch() + + self.main_layout.addLayout(row_live) # --- Остальные чекбоксы (start_minimized уже добавлен выше) --- @@ -860,6 +958,10 @@ def _on_translator_changed(self, idx): value = "argos" self.auto_save_setting("translator_engine", value) + def _on_display_mode_changed(self, idx): + mode = self.display_mode_combo.itemData(idx) + self.auto_save_setting("translation_display_mode", mode) + def start_download_thread(self): """Скачать portable-версию Tesseract и две языковые модели (eng, rus).""" # Определяем архитектуру