supervertaler 1.9.189__py3-none-any.whl → 1.9.196__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of supervertaler might be problematic. Click here for more details.

modules/quicktrans.py ADDED
@@ -0,0 +1,670 @@
1
+ """
2
+ QuickTrans - Instant translation popup (GT4T-style)
3
+
4
+ A popup window that shows translations from all enabled MT engines and LLMs.
5
+ Part of the Supervertaler tool suite. Triggered by Ctrl+M (in-app) or Ctrl+Alt+M (global).
6
+
7
+ Features:
8
+ - Shows source text at the top
9
+ - Displays numbered list of translations from MT engines and LLMs
10
+ - Press number key (1-9) or click to insert translation
11
+ - Escape to dismiss
12
+ - Translations fetched in parallel for speed
13
+ """
14
+
15
+ from PyQt6.QtWidgets import (
16
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QFrame,
17
+ QScrollArea, QWidget, QPushButton, QApplication
18
+ )
19
+ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QSettings
20
+ from PyQt6.QtGui import QKeySequence, QShortcut, QCursor, QFont
21
+ from typing import Dict, List, Optional, Tuple, Any
22
+ from dataclasses import dataclass
23
+ import threading
24
+ from concurrent.futures import ThreadPoolExecutor, as_completed
25
+
26
+
27
+ @dataclass
28
+ class MTSuggestion:
29
+ """A single MT suggestion from a provider"""
30
+ provider_name: str # Full name: "Google Translate", "DeepL", etc.
31
+ provider_code: str # Short code: "GT", "DL", etc.
32
+ translation: str
33
+ is_error: bool = False
34
+
35
+
36
+ class MTFetchWorker(QThread):
37
+ """Background worker to fetch MT translations in parallel"""
38
+
39
+ result_ready = pyqtSignal(str, str, str, bool) # provider_name, provider_code, translation, is_error
40
+ all_complete = pyqtSignal()
41
+
42
+ def __init__(self, source_text: str, source_lang: str, target_lang: str,
43
+ providers: List[Tuple[str, str, callable]], parent=None):
44
+ super().__init__(parent)
45
+ self.source_text = source_text
46
+ self.source_lang = source_lang
47
+ self.target_lang = target_lang
48
+ self.providers = providers # List of (name, code, call_function)
49
+
50
+ def run(self):
51
+ """Fetch translations from all providers in parallel"""
52
+ def fetch_single(provider_info):
53
+ name, code, call_func = provider_info
54
+ try:
55
+ result = call_func(self.source_text, self.source_lang, self.target_lang)
56
+ is_error = result.startswith('[') and 'error' in result.lower()
57
+ return (name, code, result, is_error)
58
+ except Exception as e:
59
+ return (name, code, f"[Error: {str(e)}]", True)
60
+
61
+ # Use ThreadPoolExecutor for parallel execution
62
+ with ThreadPoolExecutor(max_workers=6) as executor:
63
+ futures = {executor.submit(fetch_single, p): p for p in self.providers}
64
+ for future in as_completed(futures):
65
+ try:
66
+ name, code, translation, is_error = future.result()
67
+ self.result_ready.emit(name, code, translation, is_error)
68
+ except Exception as e:
69
+ provider = futures[future]
70
+ self.result_ready.emit(provider[0], provider[1], f"[Error: {str(e)}]", True)
71
+
72
+ self.all_complete.emit()
73
+
74
+
75
+ class MTSuggestionItem(QFrame):
76
+ """A single MT suggestion row in the popup"""
77
+
78
+ clicked = pyqtSignal(str) # Emits the translation text when clicked
79
+
80
+ def __init__(self, number: int, suggestion: MTSuggestion, parent=None):
81
+ super().__init__(parent)
82
+ self.suggestion = suggestion
83
+ self.number = number
84
+ self.is_selected = False
85
+
86
+ self.setFrameStyle(QFrame.Shape.NoFrame)
87
+ self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
88
+
89
+ layout = QHBoxLayout(self)
90
+ layout.setContentsMargins(8, 6, 8, 6)
91
+ layout.setSpacing(10)
92
+
93
+ # Number badge
94
+ num_label = QLabel(str(number))
95
+ num_label.setFixedSize(24, 24)
96
+ num_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
97
+ num_label.setStyleSheet("""
98
+ QLabel {
99
+ background-color: #ff9800;
100
+ color: #333;
101
+ font-weight: bold;
102
+ font-size: 11px;
103
+ border-radius: 4px;
104
+ }
105
+ """)
106
+ layout.addWidget(num_label)
107
+
108
+ # Provider icon/code badge
109
+ provider_label = QLabel(suggestion.provider_code)
110
+ provider_label.setFixedWidth(36)
111
+ provider_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
112
+ provider_label.setToolTip(suggestion.provider_name)
113
+
114
+ # Color-code by provider
115
+ provider_colors = {
116
+ "GT": "#4285F4", # Google blue
117
+ "DL": "#042B48", # DeepL dark blue
118
+ "MS": "#00A4EF", # Microsoft blue
119
+ "AT": "#FF9900", # Amazon orange
120
+ "MMT": "#6B4EE6", # ModernMT purple
121
+ "MM": "#2ECC71", # MyMemory green
122
+ # LLM providers
123
+ "CL": "#D97706", # Claude orange/amber
124
+ "GPT": "#10A37F", # OpenAI green
125
+ "GEM": "#4285F4", # Gemini blue (Google)
126
+ }
127
+ bg_color = provider_colors.get(suggestion.provider_code, "#666")
128
+ provider_label.setStyleSheet(f"""
129
+ QLabel {{
130
+ background-color: {bg_color};
131
+ color: white;
132
+ font-weight: bold;
133
+ font-size: 9px;
134
+ border-radius: 3px;
135
+ padding: 2px 4px;
136
+ }}
137
+ """)
138
+ layout.addWidget(provider_label)
139
+
140
+ # Translation text
141
+ text_label = QLabel(suggestion.translation)
142
+ text_label.setWordWrap(True)
143
+ text_label.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
144
+
145
+ if suggestion.is_error:
146
+ text_label.setStyleSheet("color: #ff6b6b; font-size: 11px;")
147
+ else:
148
+ text_label.setStyleSheet("color: #333; font-size: 11px;")
149
+
150
+ layout.addWidget(text_label, 1)
151
+
152
+ self._update_style()
153
+
154
+ def _update_style(self):
155
+ """Update visual style based on selection state"""
156
+ if self.is_selected:
157
+ self.setStyleSheet("""
158
+ MTSuggestionItem {
159
+ background-color: #e3f2fd;
160
+ border: 1px solid #2196F3;
161
+ border-radius: 4px;
162
+ }
163
+ """)
164
+ else:
165
+ self.setStyleSheet("""
166
+ MTSuggestionItem {
167
+ background-color: white;
168
+ border: 1px solid #e0e0e0;
169
+ border-radius: 4px;
170
+ }
171
+ MTSuggestionItem:hover {
172
+ background-color: #f5f5f5;
173
+ border: 1px solid #bdbdbd;
174
+ }
175
+ """)
176
+
177
+ def select(self):
178
+ """Select this item"""
179
+ self.is_selected = True
180
+ self._update_style()
181
+
182
+ def deselect(self):
183
+ """Deselect this item"""
184
+ self.is_selected = False
185
+ self._update_style()
186
+
187
+ def mousePressEvent(self, event):
188
+ """Handle click to select this translation"""
189
+ if event.button() == Qt.MouseButton.LeftButton and not self.suggestion.is_error:
190
+ self.clicked.emit(self.suggestion.translation)
191
+ super().mousePressEvent(event)
192
+
193
+
194
+ class MTQuickPopup(QDialog):
195
+ """
196
+ GT4T-style popup showing MT suggestions from all enabled providers
197
+
198
+ Usage:
199
+ popup = MTQuickPopup(parent_app, source_text, source_lang, target_lang)
200
+ popup.translation_selected.connect(on_translation_selected)
201
+ popup.show()
202
+ """
203
+
204
+ translation_selected = pyqtSignal(str) # Emitted when user selects a translation
205
+
206
+ def __init__(self, parent_app, source_text: str, source_lang: str = None,
207
+ target_lang: str = None, parent=None):
208
+ super().__init__(parent)
209
+ self.parent_app = parent_app
210
+ self.source_text = source_text
211
+ self.source_lang = source_lang or getattr(parent_app, 'source_language', 'en')
212
+ self.target_lang = target_lang or getattr(parent_app, 'target_language', 'nl')
213
+
214
+ self.suggestions: List[MTSuggestion] = []
215
+ self.suggestion_items: List[MTSuggestionItem] = []
216
+ self.selected_index = -1
217
+ self.worker = None
218
+
219
+ self.setup_ui()
220
+ self.setup_shortcuts()
221
+ self.start_fetching()
222
+
223
+ def setup_ui(self):
224
+ """Setup the popup UI"""
225
+ self.setWindowTitle("⚡ Supervertaler QuickTrans")
226
+ # Use standard dialog with title bar for resize/move support
227
+ self.setWindowFlags(
228
+ Qt.WindowType.Dialog |
229
+ Qt.WindowType.WindowCloseButtonHint |
230
+ Qt.WindowType.WindowStaysOnTopHint
231
+ )
232
+
233
+ # Set size - allow resizing
234
+ self.setMinimumWidth(450)
235
+ self.setMinimumHeight(200)
236
+
237
+ # Restore saved size and position or use defaults
238
+ settings = QSettings("Supervertaler", "MTQuickPopup")
239
+ saved_width = settings.value("width", 650, type=int)
240
+ saved_height = settings.value("height", 400, type=int)
241
+ self.resize(saved_width, saved_height)
242
+
243
+ # Check if we have a saved position
244
+ self._has_saved_position = settings.contains("x") and settings.contains("y")
245
+ if self._has_saved_position:
246
+ saved_x = settings.value("x", 0, type=int)
247
+ saved_y = settings.value("y", 0, type=int)
248
+ self.move(saved_x, saved_y)
249
+
250
+ # Main layout
251
+ main_layout = QVBoxLayout(self)
252
+ main_layout.setContentsMargins(8, 8, 8, 8)
253
+ main_layout.setSpacing(0)
254
+
255
+ # Container with styling
256
+ container = QFrame()
257
+ container.setStyleSheet("""
258
+ QFrame {
259
+ background-color: white;
260
+ border: 1px solid #e0e0e0;
261
+ border-radius: 4px;
262
+ }
263
+ """)
264
+ container_layout = QVBoxLayout(container)
265
+ container_layout.setContentsMargins(12, 12, 12, 12)
266
+ container_layout.setSpacing(8)
267
+
268
+ # Header with title and settings button
269
+ header_layout = QHBoxLayout()
270
+ header_layout.setContentsMargins(0, 0, 0, 4)
271
+
272
+ title_label = QLabel("⚡ Supervertaler QuickTrans")
273
+ title_label.setStyleSheet("font-size: 11px; font-weight: bold; color: #333;")
274
+ header_layout.addWidget(title_label)
275
+
276
+ header_layout.addStretch()
277
+
278
+ # Settings button
279
+ settings_btn = QPushButton("⚙️")
280
+ settings_btn.setFixedSize(24, 24)
281
+ settings_btn.setToolTip("Configure QuickTrans providers")
282
+ settings_btn.setStyleSheet("""
283
+ QPushButton {
284
+ border: none;
285
+ background: transparent;
286
+ font-size: 14px;
287
+ }
288
+ QPushButton:hover {
289
+ background-color: #e0e0e0;
290
+ border-radius: 4px;
291
+ }
292
+ """)
293
+ settings_btn.clicked.connect(self._open_settings)
294
+ header_layout.addWidget(settings_btn)
295
+
296
+ container_layout.addLayout(header_layout)
297
+
298
+ # Source text display
299
+ source_frame = QFrame()
300
+ source_frame.setStyleSheet("""
301
+ QFrame {
302
+ background-color: #f5f5f5;
303
+ border: 1px solid #e0e0e0;
304
+ border-radius: 4px;
305
+ }
306
+ """)
307
+ source_layout = QVBoxLayout(source_frame)
308
+ source_layout.setContentsMargins(8, 6, 8, 6)
309
+
310
+ source_header = QLabel("Source:")
311
+ source_header.setStyleSheet("font-size: 9px; color: #666; font-weight: bold;")
312
+ source_layout.addWidget(source_header)
313
+
314
+ source_text_label = QLabel(self.source_text)
315
+ source_text_label.setWordWrap(True)
316
+ source_text_label.setStyleSheet("font-size: 11px; color: #333;")
317
+ source_text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
318
+ source_layout.addWidget(source_text_label)
319
+
320
+ container_layout.addWidget(source_frame)
321
+
322
+ # Separator
323
+ sep = QFrame()
324
+ sep.setFrameShape(QFrame.Shape.HLine)
325
+ sep.setStyleSheet("background-color: #e0e0e0;")
326
+ sep.setFixedHeight(1)
327
+ container_layout.addWidget(sep)
328
+
329
+ # Suggestions scroll area
330
+ scroll = QScrollArea()
331
+ scroll.setWidgetResizable(True)
332
+ scroll.setStyleSheet("""
333
+ QScrollArea {
334
+ border: none;
335
+ background-color: transparent;
336
+ }
337
+ """)
338
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
339
+
340
+ self.suggestions_container = QWidget()
341
+ self.suggestions_layout = QVBoxLayout(self.suggestions_container)
342
+ self.suggestions_layout.setContentsMargins(0, 0, 0, 0)
343
+ self.suggestions_layout.setSpacing(4)
344
+
345
+ # Loading indicator
346
+ self.loading_label = QLabel("⏳ Fetching translations...")
347
+ self.loading_label.setStyleSheet("color: #666; font-size: 11px; padding: 20px;")
348
+ self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
349
+ self.suggestions_layout.addWidget(self.loading_label)
350
+
351
+ self.suggestions_layout.addStretch()
352
+
353
+ scroll.setWidget(self.suggestions_container)
354
+ container_layout.addWidget(scroll, 1)
355
+
356
+ # Footer with hint
357
+ hint = QLabel("Press 1-9 to insert • ↑↓ to navigate • Enter to insert selected • Esc to close")
358
+ hint.setStyleSheet("font-size: 9px; color: #999; padding-top: 4px;")
359
+ hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
360
+ container_layout.addWidget(hint)
361
+
362
+ main_layout.addWidget(container)
363
+
364
+ # Position popup near cursor
365
+ self._position_near_cursor()
366
+
367
+ def _position_near_cursor(self):
368
+ """Position the popup near the cursor (only if no saved position)"""
369
+ # Skip if we restored a saved position
370
+ if getattr(self, '_has_saved_position', False):
371
+ # Verify saved position is still on a valid screen
372
+ screen = QApplication.screenAt(self.pos())
373
+ if screen:
374
+ return # Saved position is valid, use it
375
+
376
+ # Position near cursor
377
+ cursor_pos = QCursor.pos()
378
+ screen = QApplication.screenAt(cursor_pos)
379
+ if screen:
380
+ screen_geo = screen.availableGeometry()
381
+
382
+ # Try to position popup below and to the right of cursor
383
+ x = cursor_pos.x() + 10
384
+ y = cursor_pos.y() + 10
385
+
386
+ # Ensure popup stays on screen
387
+ if x + self.width() > screen_geo.right():
388
+ x = cursor_pos.x() - self.width() - 10
389
+ if y + self.height() > screen_geo.bottom():
390
+ y = cursor_pos.y() - self.height() - 10
391
+
392
+ # Clamp to screen bounds
393
+ x = max(screen_geo.left(), min(x, screen_geo.right() - self.width()))
394
+ y = max(screen_geo.top(), min(y, screen_geo.bottom() - self.height()))
395
+
396
+ self.move(x, y)
397
+
398
+ def setup_shortcuts(self):
399
+ """Setup keyboard shortcuts"""
400
+ # Number keys 1-9 for quick selection
401
+ for i in range(1, 10):
402
+ shortcut = QShortcut(QKeySequence(str(i)), self)
403
+ shortcut.activated.connect(lambda idx=i: self._select_by_number(idx))
404
+
405
+ # Navigation
406
+ QShortcut(QKeySequence(Qt.Key.Key_Up), self).activated.connect(self._navigate_up)
407
+ QShortcut(QKeySequence(Qt.Key.Key_Down), self).activated.connect(self._navigate_down)
408
+ QShortcut(QKeySequence(Qt.Key.Key_Return), self).activated.connect(self._insert_selected)
409
+ QShortcut(QKeySequence(Qt.Key.Key_Enter), self).activated.connect(self._insert_selected)
410
+ QShortcut(QKeySequence(Qt.Key.Key_Escape), self).activated.connect(self.close)
411
+
412
+ def start_fetching(self):
413
+ """Start fetching translations from all enabled providers"""
414
+ providers = self._get_enabled_providers()
415
+
416
+ if not providers:
417
+ self.loading_label.setText("⚠️ No MT providers configured. Check Settings → MT Settings.")
418
+ return
419
+
420
+ self.worker = MTFetchWorker(
421
+ self.source_text,
422
+ self.source_lang,
423
+ self.target_lang,
424
+ providers,
425
+ self
426
+ )
427
+ self.worker.result_ready.connect(self._on_result_ready)
428
+ self.worker.all_complete.connect(self._on_all_complete)
429
+ self.worker.start()
430
+
431
+ def _get_enabled_providers(self) -> List[Tuple[str, str, callable]]:
432
+ """Get list of enabled MT providers with their call functions"""
433
+ providers = []
434
+
435
+ if not self.parent_app:
436
+ return providers
437
+
438
+ api_keys = {}
439
+ enabled_providers = {}
440
+
441
+ if hasattr(self.parent_app, 'load_api_keys'):
442
+ api_keys = self.parent_app.load_api_keys()
443
+ if hasattr(self.parent_app, 'load_provider_enabled_states'):
444
+ enabled_providers = self.parent_app.load_provider_enabled_states()
445
+
446
+ # Load MT Quick Lookup specific settings
447
+ mt_quick_settings = self._load_mt_quick_settings()
448
+
449
+ # Define MT providers: (display_name, code, enabled_key, api_key_name, call_method_name)
450
+ mt_provider_defs = [
451
+ ("Google Translate", "GT", "mt_google_translate", "google_translate", "call_google_translate"),
452
+ ("DeepL", "DL", "mt_deepl", "deepl", "call_deepl"),
453
+ ("Microsoft Translator", "MS", "mt_microsoft", "microsoft_translate", "call_microsoft_translate"),
454
+ ("Amazon Translate", "AT", "mt_amazon", "amazon_translate", "call_amazon_translate"),
455
+ ("ModernMT", "MMT", "mt_modernmt", "modernmt", "call_modernmt"),
456
+ ("MyMemory", "MM", "mt_mymemory", None, "call_mymemory"), # MyMemory works without key
457
+ ]
458
+
459
+ for name, code, enabled_key, api_key_name, method_name in mt_provider_defs:
460
+ # Check if provider is enabled in MT Quick Lookup settings (default: use MT Settings state)
461
+ quick_lookup_key = f"mtql_{code.lower()}"
462
+ if not mt_quick_settings.get(quick_lookup_key, enabled_providers.get(enabled_key, True)):
463
+ continue
464
+
465
+ # Check if API key is available (MyMemory doesn't require one)
466
+ if api_key_name and not api_keys.get(api_key_name):
467
+ continue
468
+
469
+ # Get the call method
470
+ if hasattr(self.parent_app, method_name):
471
+ call_method = getattr(self.parent_app, method_name)
472
+
473
+ # Create a wrapper that handles the API key
474
+ api_key = api_keys.get(api_key_name) if api_key_name else None
475
+
476
+ def make_caller(m, k):
477
+ return lambda text, src, tgt: m(text, src, tgt, k)
478
+
479
+ providers.append((name, code, make_caller(call_method, api_key)))
480
+
481
+ # Add LLM providers if enabled
482
+ self._add_llm_providers(providers, api_keys, mt_quick_settings)
483
+
484
+ return providers
485
+
486
+ def _load_mt_quick_settings(self) -> Dict[str, Any]:
487
+ """Load MT Quick Lookup specific settings"""
488
+ if hasattr(self.parent_app, 'load_general_settings'):
489
+ settings = self.parent_app.load_general_settings()
490
+ return settings.get('mt_quick_lookup', {})
491
+ return {}
492
+
493
+ def _add_llm_providers(self, providers: List, api_keys: Dict, mt_quick_settings: Dict):
494
+ """Add LLM providers (Claude, OpenAI, Gemini) to the providers list"""
495
+ # LLM provider definitions: (name, code, api_key_name, settings_key)
496
+ llm_defs = [
497
+ ("Claude", "CL", "claude", "mtql_claude"),
498
+ ("OpenAI", "GPT", "openai", "mtql_openai"),
499
+ ("Gemini", "GEM", "gemini", "mtql_gemini"),
500
+ ]
501
+
502
+ for name, code, api_key_name, settings_key in llm_defs:
503
+ # Check if LLM is enabled in MT Quick Lookup settings (default: disabled)
504
+ if not mt_quick_settings.get(settings_key, False):
505
+ continue
506
+
507
+ # Check if API key is available
508
+ if not api_keys.get(api_key_name):
509
+ continue
510
+
511
+ # Get model from settings or use default
512
+ model_key = f"{settings_key}_model"
513
+ model = mt_quick_settings.get(model_key, None)
514
+
515
+ # Create LLM translation caller
516
+ def make_llm_caller(provider_name, provider_key, provider_model):
517
+ def call_llm(text, src_lang, tgt_lang):
518
+ return self._call_llm_translation(provider_key, text, src_lang, tgt_lang, provider_model)
519
+ return call_llm
520
+
521
+ providers.append((name, code, make_llm_caller(name, api_key_name, model)))
522
+
523
+ def _call_llm_translation(self, provider: str, text: str, source_lang: str, target_lang: str, model: str = None) -> str:
524
+ """Call LLM for translation"""
525
+ try:
526
+ from modules.llm_clients import LLMClient, load_api_keys
527
+
528
+ api_keys = load_api_keys()
529
+ api_key = api_keys.get(provider)
530
+
531
+ if not api_key:
532
+ return f"[Error: No API key for {provider}]"
533
+
534
+ client = LLMClient(
535
+ api_key=api_key,
536
+ provider=provider,
537
+ model=model
538
+ )
539
+
540
+ # Use the translate method
541
+ result = client.translate(
542
+ text=text,
543
+ source_lang=source_lang,
544
+ target_lang=target_lang
545
+ )
546
+
547
+ # Clean up result - remove quotes if present
548
+ if result:
549
+ result = result.strip()
550
+ if (result.startswith('"') and result.endswith('"')) or (result.startswith("'") and result.endswith("'")):
551
+ result = result[1:-1]
552
+
553
+ return result or "[No translation returned]"
554
+
555
+ except Exception as e:
556
+ return f"[Error: {str(e)}]"
557
+
558
+ def _open_settings(self):
559
+ """Open MT Quick Lookup settings tab"""
560
+ if self.parent_app and hasattr(self.parent_app, 'open_mt_quick_lookup_settings'):
561
+ self.close() # Close popup first
562
+ self.parent_app.open_mt_quick_lookup_settings()
563
+
564
+ def _on_result_ready(self, provider_name: str, provider_code: str, translation: str, is_error: bool):
565
+ """Handle a single MT result"""
566
+ # Hide loading label on first result
567
+ if self.loading_label.isVisible():
568
+ self.loading_label.hide()
569
+
570
+ # Create suggestion
571
+ suggestion = MTSuggestion(
572
+ provider_name=provider_name,
573
+ provider_code=provider_code,
574
+ translation=translation,
575
+ is_error=is_error
576
+ )
577
+ self.suggestions.append(suggestion)
578
+
579
+ # Create and add item widget
580
+ item = MTSuggestionItem(len(self.suggestions), suggestion)
581
+ item.clicked.connect(self._on_item_clicked)
582
+ self.suggestion_items.append(item)
583
+
584
+ # Insert before the stretch
585
+ self.suggestions_layout.insertWidget(self.suggestions_layout.count() - 1, item)
586
+
587
+ # Auto-select first non-error result
588
+ if self.selected_index == -1 and not is_error:
589
+ self._select_index(len(self.suggestion_items) - 1)
590
+
591
+ def _on_all_complete(self):
592
+ """Handle completion of all MT fetches"""
593
+ if not self.suggestions:
594
+ self.loading_label.setText("⚠️ No translations available.")
595
+ self.loading_label.show()
596
+ # Don't call adjustSize() - it shrinks the window and loses user's preferred size
597
+
598
+ def _on_item_clicked(self, translation: str):
599
+ """Handle click on a suggestion item"""
600
+ self.translation_selected.emit(translation)
601
+ self.close()
602
+
603
+ def _select_by_number(self, number: int):
604
+ """Select suggestion by number (1-based)"""
605
+ idx = number - 1
606
+ if 0 <= idx < len(self.suggestion_items):
607
+ suggestion = self.suggestions[idx]
608
+ if not suggestion.is_error:
609
+ self.translation_selected.emit(suggestion.translation)
610
+ self.close()
611
+
612
+ def _select_index(self, index: int):
613
+ """Select suggestion by index"""
614
+ # Deselect previous
615
+ if 0 <= self.selected_index < len(self.suggestion_items):
616
+ self.suggestion_items[self.selected_index].deselect()
617
+
618
+ # Select new (skip errors)
619
+ if 0 <= index < len(self.suggestion_items):
620
+ self.selected_index = index
621
+ self.suggestion_items[index].select()
622
+ # Ensure visible
623
+ self.suggestion_items[index].setFocus()
624
+
625
+ def _navigate_up(self):
626
+ """Navigate to previous suggestion"""
627
+ if not self.suggestion_items:
628
+ return
629
+
630
+ new_idx = self.selected_index - 1
631
+ while new_idx >= 0:
632
+ if not self.suggestions[new_idx].is_error:
633
+ self._select_index(new_idx)
634
+ return
635
+ new_idx -= 1
636
+
637
+ def _navigate_down(self):
638
+ """Navigate to next suggestion"""
639
+ if not self.suggestion_items:
640
+ return
641
+
642
+ new_idx = self.selected_index + 1
643
+ while new_idx < len(self.suggestions):
644
+ if not self.suggestions[new_idx].is_error:
645
+ self._select_index(new_idx)
646
+ return
647
+ new_idx += 1
648
+
649
+ def _insert_selected(self):
650
+ """Insert the currently selected suggestion"""
651
+ if 0 <= self.selected_index < len(self.suggestions):
652
+ suggestion = self.suggestions[self.selected_index]
653
+ if not suggestion.is_error:
654
+ self.translation_selected.emit(suggestion.translation)
655
+ self.close()
656
+
657
+ def closeEvent(self, event):
658
+ """Clean up worker on close and save window size and position"""
659
+ # Save window size and position for next time
660
+ settings = QSettings("Supervertaler", "MTQuickPopup")
661
+ settings.setValue("width", self.width())
662
+ settings.setValue("height", self.height())
663
+ settings.setValue("x", self.x())
664
+ settings.setValue("y", self.y())
665
+
666
+ # Clean up worker
667
+ if self.worker and self.worker.isRunning():
668
+ self.worker.quit()
669
+ self.worker.wait(1000)
670
+ super().closeEvent(event)