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)."""
# Определяем архитектуру