supervertaler 1.9.153__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.

Files changed (85) hide show
  1. Supervertaler.py +47886 -0
  2. modules/__init__.py +10 -0
  3. modules/ai_actions.py +964 -0
  4. modules/ai_attachment_manager.py +343 -0
  5. modules/ai_file_viewer_dialog.py +210 -0
  6. modules/autofingers_engine.py +466 -0
  7. modules/cafetran_docx_handler.py +379 -0
  8. modules/config_manager.py +469 -0
  9. modules/database_manager.py +1878 -0
  10. modules/database_migrations.py +417 -0
  11. modules/dejavurtf_handler.py +779 -0
  12. modules/document_analyzer.py +427 -0
  13. modules/docx_handler.py +689 -0
  14. modules/encoding_repair.py +319 -0
  15. modules/encoding_repair_Qt.py +393 -0
  16. modules/encoding_repair_ui.py +481 -0
  17. modules/feature_manager.py +350 -0
  18. modules/figure_context_manager.py +340 -0
  19. modules/file_dialog_helper.py +148 -0
  20. modules/find_replace.py +164 -0
  21. modules/find_replace_qt.py +457 -0
  22. modules/glossary_manager.py +433 -0
  23. modules/image_extractor.py +188 -0
  24. modules/keyboard_shortcuts_widget.py +571 -0
  25. modules/llm_clients.py +1211 -0
  26. modules/llm_leaderboard.py +737 -0
  27. modules/llm_superbench_ui.py +1401 -0
  28. modules/local_llm_setup.py +1104 -0
  29. modules/model_update_dialog.py +381 -0
  30. modules/model_version_checker.py +373 -0
  31. modules/mqxliff_handler.py +638 -0
  32. modules/non_translatables_manager.py +743 -0
  33. modules/pdf_rescue_Qt.py +1822 -0
  34. modules/pdf_rescue_tkinter.py +909 -0
  35. modules/phrase_docx_handler.py +516 -0
  36. modules/project_home_panel.py +209 -0
  37. modules/prompt_assistant.py +357 -0
  38. modules/prompt_library.py +689 -0
  39. modules/prompt_library_migration.py +447 -0
  40. modules/quick_access_sidebar.py +282 -0
  41. modules/ribbon_widget.py +597 -0
  42. modules/sdlppx_handler.py +874 -0
  43. modules/setup_wizard.py +353 -0
  44. modules/shortcut_manager.py +932 -0
  45. modules/simple_segmenter.py +128 -0
  46. modules/spellcheck_manager.py +727 -0
  47. modules/statuses.py +207 -0
  48. modules/style_guide_manager.py +315 -0
  49. modules/superbench_ui.py +1319 -0
  50. modules/superbrowser.py +329 -0
  51. modules/supercleaner.py +600 -0
  52. modules/supercleaner_ui.py +444 -0
  53. modules/superdocs.py +19 -0
  54. modules/superdocs_viewer_qt.py +382 -0
  55. modules/superlookup.py +252 -0
  56. modules/tag_cleaner.py +260 -0
  57. modules/tag_manager.py +333 -0
  58. modules/term_extractor.py +270 -0
  59. modules/termbase_entry_editor.py +842 -0
  60. modules/termbase_import_export.py +488 -0
  61. modules/termbase_manager.py +1060 -0
  62. modules/termview_widget.py +1172 -0
  63. modules/theme_manager.py +499 -0
  64. modules/tm_editor_dialog.py +99 -0
  65. modules/tm_manager_qt.py +1280 -0
  66. modules/tm_metadata_manager.py +545 -0
  67. modules/tmx_editor.py +1461 -0
  68. modules/tmx_editor_qt.py +2784 -0
  69. modules/tmx_generator.py +284 -0
  70. modules/tracked_changes.py +900 -0
  71. modules/trados_docx_handler.py +430 -0
  72. modules/translation_memory.py +715 -0
  73. modules/translation_results_panel.py +2134 -0
  74. modules/translation_services.py +282 -0
  75. modules/unified_prompt_library.py +659 -0
  76. modules/unified_prompt_manager_qt.py +3951 -0
  77. modules/voice_commands.py +920 -0
  78. modules/voice_dictation.py +477 -0
  79. modules/voice_dictation_lite.py +249 -0
  80. supervertaler-1.9.153.dist-info/METADATA +896 -0
  81. supervertaler-1.9.153.dist-info/RECORD +85 -0
  82. supervertaler-1.9.153.dist-info/WHEEL +5 -0
  83. supervertaler-1.9.153.dist-info/entry_points.txt +2 -0
  84. supervertaler-1.9.153.dist-info/licenses/LICENSE +21 -0
  85. supervertaler-1.9.153.dist-info/top_level.txt +2 -0
@@ -0,0 +1,2134 @@
1
+ """
2
+ Translation Results Panel
3
+ Compact memoQ-style right-side panel for displaying translation matches
4
+ Supports stacked match sections, drag/drop, and compare boxes with diff highlighting
5
+
6
+ Keyboard Shortcuts:
7
+ - ↑/↓ arrows: Navigate through matches (cycle through sections)
8
+ - Spacebar/Enter: Insert currently selected match into target cell
9
+ - Ctrl+1-9: Insert specific match directly (by number, global across all sections)
10
+ - Escape: Deselect match (when focus on panel)
11
+
12
+ Compare boxes: Vertical stacked with resizable splitter
13
+ Text display: Supports long segments with text wrapping
14
+ """
15
+
16
+ from PyQt6.QtWidgets import (
17
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
18
+ QFrame, QScrollArea, QTextEdit, QSplitter, QTabWidget
19
+ )
20
+ from PyQt6.QtCore import Qt, pyqtSignal, QMimeData
21
+ from PyQt6.QtGui import QDrag, QCursor, QFont, QColor, QTextCharFormat, QTextCursor
22
+ from dataclasses import dataclass
23
+ from typing import List, Optional, Dict, Any
24
+ import difflib
25
+
26
+
27
+ @dataclass
28
+ class TranslationMatch:
29
+ """Represents a single translation match"""
30
+ source: str
31
+ target: str
32
+ relevance: int # 0-100
33
+ metadata: Dict[str, Any] # Context, domain, timestamp, etc.
34
+ match_type: str # "NT", "MT", "TM", "Termbase", "LLM"
35
+ compare_source: Optional[str] = None # For TM compare boxes
36
+ provider_code: Optional[str] = None # Provider code: "GT", "AT", "MMT", "CL", "GPT", "GEM", etc.
37
+
38
+
39
+ class CompactMatchItem(QFrame):
40
+ """Compact match display (like memoQ) with source and target in separate columns"""
41
+
42
+ match_selected = pyqtSignal(TranslationMatch)
43
+
44
+ # Class variables (can be changed globally)
45
+ font_size_pt = 9
46
+ show_tags = False # When False, HTML/XML tags are hidden
47
+ tag_highlight_color = '#7f0001' # Default memoQ dark red for tag highlighting
48
+ badge_text_color = '#333333' # Dark gray for badge text (readable without being harsh)
49
+ theme_manager = None # Class-level theme manager reference
50
+
51
+ def __init__(self, match: TranslationMatch, match_number: int = 0, parent=None):
52
+ super().__init__(parent)
53
+ self.match = match
54
+ self.match_number = match_number
55
+ self.is_selected = False
56
+ self.num_label_ref = None # Initialize FIRST before update_styling()
57
+ self.source_label = None
58
+ self.target_label = None
59
+
60
+ self.setFrameStyle(QFrame.Shape.NoFrame) # No frame border
61
+ self.setMinimumHeight(20) # Minimum height (can expand)
62
+ self.setMaximumHeight(100) # Allow up to 100px if text wraps
63
+
64
+ # Vertical layout with 2 rows: number+relevance on left, then source and target on right
65
+ main_layout = QHBoxLayout(self)
66
+ main_layout.setContentsMargins(2, 1, 2, 1) # Minimal padding
67
+ main_layout.setSpacing(3)
68
+
69
+ # Left side: Match number box (small colored box)
70
+ if match_number > 0:
71
+ num_label = QLabel(f"{match_number}")
72
+ num_label.setStyleSheet("""
73
+ QLabel {
74
+ font-weight: bold;
75
+ font-size: 9px;
76
+ padding: 1px;
77
+ border-radius: 2px;
78
+ margin: 0px;
79
+ }
80
+ """)
81
+ num_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
82
+ num_label.setFixedWidth(22)
83
+ num_label.setFixedHeight(18)
84
+
85
+ # Add tooltip based on match type
86
+ match_type_tooltips = {
87
+ "LLM": "LLM Translation (AI-generated)",
88
+ "TM": "Translation Memory (Previously approved)",
89
+ "Termbase": "Termbase",
90
+ "MT": "Machine Translation",
91
+ "NT": "New Translation",
92
+ "NonTrans": "🚫 Non-Translatable (do not translate)"
93
+ }
94
+ tooltip_text = match_type_tooltips.get(match.match_type, "Translation Match")
95
+ num_label.setToolTip(tooltip_text)
96
+
97
+ self.num_label_ref = num_label # Set BEFORE calling update_styling()
98
+ main_layout.addWidget(num_label, 0, Qt.AlignmentFlag.AlignTop)
99
+
100
+ # Get theme color for secondary text
101
+ secondary_text_color = "#666"
102
+ if CompactMatchItem.theme_manager:
103
+ secondary_text_color = CompactMatchItem.theme_manager.current_theme.text_disabled
104
+
105
+ # Middle: Relevance % (vertical)
106
+ rel_label = QLabel(f"{match.relevance}%")
107
+ rel_label.setStyleSheet(f"font-size: 7px; color: {secondary_text_color}; padding: 0px; margin: 0px;")
108
+ rel_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
109
+ rel_label.setFixedWidth(32)
110
+ rel_label.setFixedHeight(18)
111
+ main_layout.addWidget(rel_label, 0, Qt.AlignmentFlag.AlignTop)
112
+
113
+ # Right side: Source and Target in a horizontal layout (like spreadsheet columns)
114
+ content_layout = QHBoxLayout()
115
+ content_layout.setContentsMargins(0, 0, 0, 0)
116
+ content_layout.setSpacing(6)
117
+
118
+ # Get theme colors for text
119
+ if CompactMatchItem.theme_manager:
120
+ theme = CompactMatchItem.theme_manager.current_theme
121
+ source_color = theme.text
122
+ # Use slightly dimmer text for target, but not as dim as text_disabled
123
+ # For dark themes, use a color between text and text_disabled for better readability
124
+ is_dark = theme.name == "Dark"
125
+ target_color = "#B0B0B0" if is_dark else "#555"
126
+ else:
127
+ source_color = "#333"
128
+ target_color = "#555"
129
+
130
+ # Source column - NO truncation, allow wrapping
131
+ self.source_label = QLabel(self._format_text(match.source))
132
+ self.source_label.setWordWrap(True) # Allow wrapping
133
+ # Always use RichText when tags are shown (for highlighting), otherwise RichText for rendering
134
+ self.source_label.setTextFormat(Qt.TextFormat.RichText)
135
+ self.source_label.setStyleSheet(f"font-size: {self.font_size_pt}px; color: {source_color}; padding: 0px; margin: 0px;")
136
+ self.source_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
137
+ self.source_label.setMinimumWidth(150) # Much wider minimum
138
+ content_layout.addWidget(self.source_label, 1)
139
+
140
+ # Target column - NO truncation, allow wrapping
141
+ self.target_label = QLabel(self._format_text(match.target))
142
+ self.target_label.setWordWrap(True) # Allow wrapping
143
+ # Always use RichText when tags are shown (for highlighting), otherwise RichText for rendering
144
+ self.target_label.setTextFormat(Qt.TextFormat.RichText)
145
+ self.target_label.setStyleSheet(f"font-size: {self.font_size_pt}px; color: {target_color}; padding: 0px; margin: 0px;")
146
+ self.target_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
147
+ self.target_label.setMinimumWidth(150) # Much wider minimum
148
+ content_layout.addWidget(self.target_label, 1)
149
+
150
+ # Provider code column (tiny, after target text) - always reserve space for alignment
151
+ # Determine provider code text and styling
152
+ provider_code_text = match.provider_code if match.provider_code else ""
153
+
154
+ # Determine if this is a project termbase or project TM
155
+ # For termbases: explicit flag OR ranking #1 = project termbase
156
+ is_project_tb_flag = match.match_type == 'Termbase' and match.metadata.get('is_project_termbase', False)
157
+ is_ranking_1 = match.match_type == 'Termbase' and match.metadata.get('ranking') == 1
158
+ is_project_tb = is_project_tb_flag or is_ranking_1
159
+ is_project_tm = match.match_type == 'TM' and match.metadata.get('is_project_tm', False)
160
+
161
+ provider_label = QLabel(provider_code_text)
162
+
163
+ # Use bold font for project termbases/TMs, normal font for background resources
164
+ font_weight = "bold" if (is_project_tb or is_project_tm) else "normal"
165
+ # Use theme color for text
166
+ provider_text_color = secondary_text_color # Reuse the secondary text color from above
167
+ provider_label.setStyleSheet(f"font-size: 7px; color: {provider_text_color}; padding: 0px; margin: 0px; font-weight: {font_weight};")
168
+ provider_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
169
+ provider_label.setFixedWidth(28) # Tiny column, just wide enough for "GPT", "MMT", etc.
170
+ provider_label.setFixedHeight(18)
171
+ # Add tooltip with full provider name (only if code exists)
172
+ if match.provider_code:
173
+ provider_tooltips = {
174
+ "GT": "Google Translate",
175
+ "AT": "Amazon Translate",
176
+ "MMT": "ModernMT",
177
+ "DL": "DeepL",
178
+ "MS": "Microsoft Translator",
179
+ "MM": "MyMemory",
180
+ "CL": "Claude",
181
+ "GPT": "OpenAI",
182
+ "GEM": "Gemini"
183
+ }
184
+ # Add any custom termbase codes to tooltips (they'll show termbase name from metadata)
185
+ if match.match_type == 'Termbase' and match.metadata.get('termbase_name'):
186
+ provider_tooltips[match.provider_code] = match.metadata.get('termbase_name', match.provider_code)
187
+ full_name = provider_tooltips.get(match.provider_code, match.provider_code)
188
+ provider_label.setToolTip(full_name)
189
+ content_layout.addWidget(provider_label, 0, Qt.AlignmentFlag.AlignTop)
190
+
191
+ main_layout.addLayout(content_layout, 1) # Expand to fill remaining space
192
+
193
+ self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
194
+
195
+ # NOW call update_styling() after num_label_ref is set
196
+ self.update_styling()
197
+
198
+ def _format_text(self, text: str) -> str:
199
+ """Format text based on show_tags setting"""
200
+ if self.show_tags:
201
+ # Show tags with text color
202
+ import re
203
+ # Escape HTML entities first to prevent double-escaping
204
+ text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
205
+ # Now color the escaped tags
206
+ tag_pattern = re.compile(r'&lt;/?[a-zA-Z][a-zA-Z0-9]*/?&gt;')
207
+ text = tag_pattern.sub(lambda m: f'<span style="color: {self.tag_highlight_color};">{m.group()}</span>', text)
208
+ return text
209
+ else:
210
+ # Let QLabel interpret as HTML (tags will be rendered/hidden)
211
+ return text
212
+
213
+ def update_tag_color(self, color: str):
214
+ """Update tag highlight color for this item"""
215
+ self.tag_highlight_color = color
216
+ # Refresh text if tags are shown
217
+ if self.show_tags and self.source_label and self.target_label:
218
+ self.source_label.setText(self._format_text(self.match.source))
219
+ self.target_label.setText(self._format_text(self.match.target))
220
+
221
+ @classmethod
222
+ def set_font_size(cls, size: int):
223
+ """Set the font size for all match items"""
224
+ cls.font_size_pt = size
225
+
226
+ def update_font_size(self):
227
+ """Update font size for this item"""
228
+ if self.source_label:
229
+ self.source_label.setStyleSheet(f"font-size: {self.font_size_pt}px; color: #333; padding: 0px; margin: 0px;")
230
+ if self.target_label:
231
+ self.target_label.setStyleSheet(f"font-size: {self.font_size_pt}px; color: #555; padding: 0px; margin: 0px;")
232
+
233
+ def mousePressEvent(self, event):
234
+ """Emit signal when clicked"""
235
+ if event.button() == Qt.MouseButton.LeftButton:
236
+ self.match_selected.emit(self.match)
237
+ self.select()
238
+ elif event.button() == Qt.MouseButton.RightButton:
239
+ self._show_context_menu(event.globalPosition().toPoint())
240
+
241
+ def _show_context_menu(self, pos):
242
+ """Show context menu for this match item"""
243
+ # Only show edit option for termbase matches
244
+ if self.match.match_type != "Termbase":
245
+ return
246
+
247
+ from PyQt6.QtWidgets import QMenu
248
+ from PyQt6.QtGui import QAction
249
+
250
+ menu = QMenu()
251
+
252
+ # Edit entry action
253
+ edit_action = QAction("✏️ Edit Glossary Entry", menu)
254
+ edit_action.triggered.connect(self._edit_termbase_entry)
255
+ menu.addAction(edit_action)
256
+
257
+ # Delete entry action
258
+ delete_action = QAction("🗑️ Delete Glossary Entry", menu)
259
+ delete_action.triggered.connect(self._delete_termbase_entry)
260
+ menu.addAction(delete_action)
261
+
262
+ menu.exec(pos)
263
+
264
+ def _edit_termbase_entry(self):
265
+ """Open termbase entry editor for this match"""
266
+ if self.match.match_type != "Termbase":
267
+ return
268
+
269
+ # Get term_id and termbase_id from metadata
270
+ term_id = self.match.metadata.get('term_id')
271
+ termbase_id = self.match.metadata.get('termbase_id')
272
+
273
+ if term_id and termbase_id:
274
+ from modules.termbase_entry_editor import TermbaseEntryEditor
275
+
276
+ # Get parent window (main application)
277
+ parent_window = self.window()
278
+
279
+ dialog = TermbaseEntryEditor(
280
+ parent=parent_window,
281
+ db_manager=getattr(parent_window, 'db_manager', None),
282
+ termbase_id=termbase_id,
283
+ term_id=term_id
284
+ )
285
+
286
+ if dialog.exec():
287
+ # Entry was edited, refresh if needed
288
+ # Signal could be emitted here to refresh the translation results panel
289
+ pass
290
+
291
+ def _delete_termbase_entry(self):
292
+ """Delete this termbase entry"""
293
+ from PyQt6.QtWidgets import QMessageBox
294
+
295
+ if self.match.match_type != "Termbase":
296
+ return
297
+
298
+ # Get term_id and termbase_id from metadata
299
+ term_id = self.match.metadata.get('term_id')
300
+ termbase_id = self.match.metadata.get('termbase_id')
301
+ source_term = self.match.source
302
+ target_term = self.match.target
303
+
304
+ if term_id and termbase_id:
305
+ # Confirm deletion
306
+ parent_window = self.window()
307
+ reply = QMessageBox.question(
308
+ parent_window,
309
+ "Confirm Deletion",
310
+ f"Delete glossary entry?\n\nSource: {source_term}\nTarget: {target_term}\n\nThis action cannot be undone.",
311
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
312
+ QMessageBox.StandardButton.No
313
+ )
314
+
315
+ if reply == QMessageBox.StandardButton.Yes:
316
+ db_manager = getattr(parent_window, 'db_manager', None)
317
+ if db_manager:
318
+ try:
319
+ # Log database path for debugging
320
+ if hasattr(parent_window, 'log'):
321
+ db_path = getattr(db_manager, 'db_path', 'unknown')
322
+ parent_window.log(f"🗑️ Deleting term ID {term_id} from database: {db_path}")
323
+
324
+ cursor = db_manager.cursor
325
+ # First verify the term exists
326
+ cursor.execute("SELECT source_term, target_term FROM termbase_terms WHERE id = ?", (term_id,))
327
+ existing = cursor.fetchone()
328
+ if hasattr(parent_window, 'log'):
329
+ if existing:
330
+ parent_window.log(f" Found term to delete: {existing[0]} → {existing[1]}")
331
+ else:
332
+ parent_window.log(f" ⚠️ Term ID {term_id} not found in database!")
333
+
334
+ # Delete the term
335
+ cursor.execute("DELETE FROM termbase_terms WHERE id = ?", (term_id,))
336
+ rows_deleted = cursor.rowcount
337
+ db_manager.connection.commit()
338
+
339
+ if hasattr(parent_window, 'log'):
340
+ parent_window.log(f" ✅ Deleted {rows_deleted} row(s) from database")
341
+
342
+ # Clear termbase cache to force refresh
343
+ if hasattr(parent_window, 'termbase_cache'):
344
+ with parent_window.termbase_cache_lock:
345
+ parent_window.termbase_cache.clear()
346
+ if hasattr(parent_window, 'log'):
347
+ parent_window.log(f" ✅ Cleared termbase cache")
348
+
349
+ # Reset the last selected row to force re-highlighting when returning to this segment
350
+ if hasattr(parent_window, '_last_selected_row'):
351
+ parent_window._last_selected_row = None
352
+
353
+ # Trigger re-highlighting of source text to remove deleted term
354
+ if hasattr(parent_window, 'table') and hasattr(parent_window, 'find_termbase_matches_in_source'):
355
+ current_row = parent_window.table.currentRow()
356
+ if current_row >= 0:
357
+ # Get source text widget
358
+ source_widget = parent_window.table.cellWidget(current_row, 2)
359
+ if source_widget and hasattr(source_widget, 'toPlainText'):
360
+ source_text = source_widget.toPlainText()
361
+ # Re-find matches and re-highlight
362
+ termbase_matches = parent_window.find_termbase_matches_in_source(source_text)
363
+ if hasattr(source_widget, 'highlight_termbase_matches'):
364
+ source_widget.highlight_termbase_matches(termbase_matches)
365
+ # Update the widget's stored matches to reflect the deletion
366
+ if hasattr(source_widget, 'termbase_matches'):
367
+ source_widget.termbase_matches = termbase_matches
368
+
369
+ QMessageBox.information(parent_window, "Success", "Glossary entry deleted")
370
+ # Hide this match card since it's been deleted
371
+ self.hide()
372
+ except Exception as e:
373
+ QMessageBox.critical(parent_window, "Error", f"Failed to delete entry: {e}")
374
+
375
+ def select(self):
376
+ """Select this match"""
377
+ self.is_selected = True
378
+ self.update_styling()
379
+
380
+ def deselect(self):
381
+ """Deselect this match"""
382
+ self.is_selected = False
383
+ self.update_styling()
384
+
385
+ def update_styling(self):
386
+ """Update visual styling based on selection state and match type"""
387
+ # Color code by match type: LLM=purple, TM=red, Termbase=green, MT=orange, NT=gray, NonTrans=yellow
388
+ base_color_map = {
389
+ "LLM": "#9c27b0", # Purple for LLM translations
390
+ "TM": "#ff6b6b", # Red for Translation Memory
391
+ "Termbase": "#4CAF50", # Green for all termbase matches (Material Design Green 500)
392
+ "MT": "#ff9800", # Orange for Machine Translation
393
+ "NT": "#adb5bd", # Gray for New Translation
394
+ "NonTrans": "#E6C200" # Pastel yellow for Non-Translatables
395
+ }
396
+
397
+ base_color = base_color_map.get(self.match.match_type, "#adb5bd")
398
+
399
+ # Special styling for Non-Translatables
400
+ if self.match.match_type == "NonTrans":
401
+ type_color = "#FFFDD0" # Pastel yellow background
402
+ # For termbase matches, apply ranking-based green shading
403
+ elif self.match.match_type == "Termbase":
404
+ is_forbidden = self.match.metadata.get('forbidden', False)
405
+ is_project_termbase_flag = self.match.metadata.get('is_project_termbase', False)
406
+ termbase_ranking = self.match.metadata.get('ranking', None)
407
+
408
+ # EFFECTIVE project termbase = explicit flag OR ranking #1
409
+ is_effective_project = is_project_termbase_flag or (termbase_ranking == 1)
410
+ is_project_termbase = is_effective_project # For later use in background styling
411
+
412
+ if is_forbidden:
413
+ type_color = "#000000" # Forbidden terms: black
414
+ else:
415
+ # Use ranking to determine soft pastel green shade
416
+ # All shades are subtle to stay in the background
417
+ if termbase_ranking is not None:
418
+ # Map ranking to soft pastel green shades:
419
+ # Ranking #1: Soft medium green (Green 200)
420
+ # Ranking #2: Soft light green (Green 100)
421
+ # Ranking #3: Very soft light green (Light Green 100)
422
+ # Ranking #4+: Extremely soft pastel green (Green 50)
423
+ ranking_colors = {
424
+ 1: "#A5D6A7", # Soft medium green (Green 200)
425
+ 2: "#C8E6C9", # Soft light green (Green 100)
426
+ 3: "#DCEDC8", # Very soft light green (Light Green 100)
427
+ }
428
+ type_color = ranking_colors.get(termbase_ranking, "#E8F5E9") # Green 50 for 4+
429
+ else:
430
+ # No ranking - use soft light green
431
+ type_color = "#C8E6C9" # Green 100 (fallback)
432
+ else:
433
+ type_color = base_color
434
+
435
+ # Update styling only for the number label, not the entire item
436
+ if hasattr(self, 'num_label_ref') and self.num_label_ref:
437
+ if self.is_selected:
438
+ # Selected: darker shade of type color with black text and outline
439
+ darker_color = self._darken_color(type_color)
440
+ self.num_label_ref.setStyleSheet(f"""
441
+ QLabel {{
442
+ background-color: {darker_color};
443
+ color: black;
444
+ font-weight: bold;
445
+ font-size: 10px;
446
+ min-width: 22px;
447
+ padding: 2px;
448
+ border-radius: 2px;
449
+ border: 1px solid rgba(255, 255, 255, 0.3);
450
+ }}
451
+ """)
452
+ # Add background to the entire item only when selected
453
+ self.setStyleSheet(f"""
454
+ CompactMatchItem {{
455
+ background-color: {self._lighten_color(type_color, 0.95)};
456
+ border: 1px solid {type_color};
457
+ }}
458
+ """)
459
+ else:
460
+ # Unselected: number badge colored with customizable text color and subtle outline
461
+ self.num_label_ref.setStyleSheet(f"""
462
+ QLabel {{
463
+ background-color: {type_color};
464
+ color: {CompactMatchItem.badge_text_color};
465
+ font-weight: bold;
466
+ font-size: 10px;
467
+ min-width: 22px;
468
+ padding: 1px;
469
+ border-radius: 2px;
470
+ border: 1px solid rgba(255, 255, 255, 0.4);
471
+ }}
472
+ """)
473
+ # Use theme colors for background if available
474
+ if CompactMatchItem.theme_manager:
475
+ theme = CompactMatchItem.theme_manager.current_theme
476
+ bg_color = theme.base
477
+ hover_color = theme.alternate_bg
478
+ else:
479
+ bg_color = "white"
480
+ hover_color = "#f5f5f5"
481
+
482
+ self.setStyleSheet(f"""
483
+ CompactMatchItem {{
484
+ background-color: {bg_color};
485
+ border: none;
486
+ }}
487
+ CompactMatchItem:hover {{
488
+ background-color: {hover_color};
489
+ }}
490
+ """)
491
+
492
+ @staticmethod
493
+ def _lighten_color(hex_color: str, factor: float) -> str:
494
+ """Lighten a hex color"""
495
+ hex_color = hex_color.lstrip('#')
496
+ r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
497
+ r = int(r + (255 - r) * (1 - factor))
498
+ g = int(g + (255 - g) * (1 - factor))
499
+ b = int(b + (255 - b) * (1 - factor))
500
+ return f'#{r:02x}{g:02x}{b:02x}'
501
+
502
+ @staticmethod
503
+ def _darken_color(hex_color: str, factor: float = 0.7) -> str:
504
+ """Darken a hex color"""
505
+ hex_color = hex_color.lstrip('#')
506
+ r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
507
+ r = int(r * factor)
508
+ g = int(g * factor)
509
+ b = int(b * factor)
510
+ return f'#{r:02x}{g:02x}{b:02x}'
511
+
512
+ def mouseMoveEvent(self, event):
513
+ """Support drag/drop"""
514
+ if event.buttons() == Qt.MouseButton.LeftButton:
515
+ drag = QDrag(self)
516
+ mime_data = QMimeData()
517
+ mime_data.setText(self.match.target)
518
+ mime_data.setData("application/x-match", str(self.match.target).encode())
519
+ drag.setMimeData(mime_data)
520
+ drag.exec(Qt.DropAction.CopyAction)
521
+
522
+
523
+ class MatchSection(QWidget):
524
+ """Stacked section for a match type (NT/MT/TM/Termbases)"""
525
+
526
+ match_selected = pyqtSignal(TranslationMatch)
527
+
528
+ def __init__(self, title: str, matches: List[TranslationMatch], parent=None, global_number_start: int = 1):
529
+ super().__init__(parent)
530
+ self.title = title
531
+ self.matches = matches
532
+ self.is_expanded = True
533
+ self.match_items = []
534
+ self.selected_index = -1
535
+ self.global_number_start = global_number_start # For global numbering across sections
536
+
537
+ layout = QVBoxLayout(self)
538
+ layout.setContentsMargins(0, 0, 0, 0)
539
+ layout.setSpacing(2)
540
+
541
+ # Section header (collapsible)
542
+ header = self._create_header()
543
+ layout.addWidget(header)
544
+
545
+ # Matches container
546
+ self.matches_container = QWidget()
547
+ self.matches_layout = QVBoxLayout(self.matches_container)
548
+ self.matches_layout.setContentsMargins(0, 0, 0, 0) # No margins
549
+ self.matches_layout.setSpacing(0) # No spacing between matches
550
+
551
+ # Populate with matches
552
+ self._populate_matches()
553
+
554
+ self.scroll_area = QScrollArea()
555
+ self.scroll_area.setWidget(self.matches_container)
556
+ self.scroll_area.setWidgetResizable(True)
557
+ self.scroll_area.setStyleSheet("QScrollArea { border: none; background-color: white; }")
558
+ layout.addWidget(self.scroll_area)
559
+
560
+ def _create_header(self) -> QWidget:
561
+ """Create collapsible header"""
562
+ header = QWidget()
563
+ header_layout = QHBoxLayout(header)
564
+ header_layout.setContentsMargins(4, 2, 4, 2)
565
+
566
+ # Toggle button
567
+ self.toggle_btn = QPushButton("▼" if self.is_expanded else "▶")
568
+ self.toggle_btn.setMaximumWidth(20)
569
+ self.toggle_btn.setMaximumHeight(20)
570
+ self.toggle_btn.setFlat(True)
571
+ self.toggle_btn.clicked.connect(self._toggle_section)
572
+ header_layout.addWidget(self.toggle_btn)
573
+
574
+ # Title + match count
575
+ title_text = f"{self.title}"
576
+ if self.matches:
577
+ title_text += f" ({len(self.matches)})"
578
+
579
+ title_label = QLabel(title_text)
580
+ title_label.setStyleSheet("font-weight: bold; font-size: 10px; color: #333;")
581
+ header_layout.addWidget(title_label)
582
+
583
+ header_layout.addStretch()
584
+
585
+ header.setStyleSheet("""
586
+ background-color: #f0f0f0;
587
+ border-bottom: 1px solid #ddd;
588
+ padding: 2px;
589
+ """)
590
+
591
+ return header
592
+
593
+ def _populate_matches(self):
594
+ """Populate section with matches using global numbering"""
595
+ for local_idx, match in enumerate(self.matches):
596
+ global_number = self.global_number_start + local_idx
597
+ item = CompactMatchItem(match, match_number=global_number)
598
+ item.match_selected.connect(lambda m, i=local_idx: self._on_match_selected(m, i))
599
+ self.matches_layout.addWidget(item)
600
+ self.match_items.append(item)
601
+
602
+ self.matches_layout.addStretch()
603
+
604
+ def _toggle_section(self):
605
+ """Toggle section expansion"""
606
+ self.is_expanded = not self.is_expanded
607
+ self.toggle_btn.setText("▼" if self.is_expanded else "▶")
608
+ self.scroll_area.setVisible(self.is_expanded)
609
+
610
+ def _on_match_selected(self, match: TranslationMatch, index: int):
611
+ """Handle match selection"""
612
+ # Deselect previous
613
+ if 0 <= self.selected_index < len(self.match_items):
614
+ self.match_items[self.selected_index].deselect()
615
+
616
+ # Select new
617
+ self.selected_index = index
618
+ if 0 <= index < len(self.match_items):
619
+ self.match_items[index].select()
620
+
621
+ self.match_selected.emit(match)
622
+
623
+ def select_by_number(self, number: int):
624
+ """Select match by number (1-based)"""
625
+ if 1 <= number <= len(self.match_items):
626
+ self._on_match_selected(self.matches[number-1], number-1)
627
+ # Scroll to visible
628
+ self.scroll_area.ensureWidgetVisible(self.match_items[number-1])
629
+
630
+ def navigate(self, direction: int):
631
+ """Navigate matches: direction=1 for next, -1 for previous"""
632
+ new_index = self.selected_index + direction
633
+ if 0 <= new_index < len(self.match_items):
634
+ self._on_match_selected(self.matches[new_index], new_index)
635
+ self.scroll_area.ensureWidgetVisible(self.match_items[new_index])
636
+ return True
637
+ return False
638
+
639
+
640
+ class TranslationResultsPanel(QWidget):
641
+ """
642
+ Main translation results panel (right side of editor)
643
+ Compact memoQ-style design with stacked match sections
644
+
645
+ Features:
646
+ - Keyboard navigation: Up/Down arrows to cycle through matches
647
+ - Insert selected match: Press Enter
648
+ - Quick insert by number: Ctrl+1 through Ctrl+9 (1-based index)
649
+ - Vertical compare boxes with resizable splitter
650
+ - Match numbering display
651
+ - Zoom controls for both match list and compare boxes
652
+ """
653
+
654
+ match_selected = pyqtSignal(TranslationMatch)
655
+ match_inserted = pyqtSignal(str) # Emitted when user wants to insert match into target
656
+
657
+ # Class variables for font sizes
658
+ compare_box_font_size = 9
659
+
660
+ def __init__(self, parent=None, parent_app=None):
661
+ super().__init__(parent)
662
+ self.parent_app = parent_app # Reference to main app for settings access
663
+ self.theme_manager = parent_app.theme_manager if parent_app and hasattr(parent_app, 'theme_manager') else None
664
+ self.matches_by_type: Dict[str, List[TranslationMatch]] = {}
665
+ self.current_selection: Optional[TranslationMatch] = None
666
+ self.all_matches: List[TranslationMatch] = []
667
+ self.match_sections: Dict[str, MatchSection] = {}
668
+ self.match_items: List[CompactMatchItem] = []
669
+ self.selected_index = -1
670
+ self.compare_text_edits = [] # Track compare boxes for font size updates
671
+ self.setup_ui()
672
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Ensure widget receives keyboard events
673
+
674
+
675
+ def setup_ui(self):
676
+ """Setup the UI"""
677
+ layout = QVBoxLayout(self)
678
+ layout.setContentsMargins(4, 4, 4, 4)
679
+ layout.setSpacing(2)
680
+
681
+ # Set class-level theme_manager for CompactMatchItem
682
+ CompactMatchItem.theme_manager = self.theme_manager
683
+
684
+ # Header with segment info
685
+ # Get theme colors
686
+ if self.theme_manager:
687
+ theme = self.theme_manager.current_theme
688
+ bg_color = theme.base
689
+ border_color = theme.border
690
+ separator_color = theme.separator
691
+ title_color = theme.text_disabled
692
+ frame_bg = theme.alternate_bg
693
+ else:
694
+ bg_color = "white"
695
+ border_color = "#ddd"
696
+ separator_color = "#e0e0e0"
697
+ title_color = "#666"
698
+ frame_bg = "#f5f5f5"
699
+
700
+ self.segment_label = QLabel("No segment selected")
701
+ self.segment_label.setStyleSheet(f"font-weight: bold; font-size: 10px; color: {title_color};")
702
+ layout.addWidget(self.segment_label)
703
+
704
+ # Use splitter for resizable sections (matches vs compare boxes)
705
+ self.main_splitter = QSplitter(Qt.Orientation.Vertical)
706
+
707
+ self.main_splitter.setStyleSheet(f"QSplitter::handle {{ background-color: {separator_color}; }}")
708
+
709
+ # Matches scroll area
710
+ self.matches_scroll = QScrollArea()
711
+ self.matches_scroll.setWidgetResizable(True)
712
+ self.matches_scroll.setStyleSheet(f"""
713
+ QScrollArea {{
714
+ border: 1px solid {border_color};
715
+ background-color: {bg_color};
716
+ border-radius: 3px;
717
+ }}
718
+ """)
719
+
720
+ self.matches_container = QWidget()
721
+ self.matches_container.setStyleSheet(f"background-color: {bg_color};")
722
+ self.main_layout = QVBoxLayout(self.matches_container)
723
+ self.main_layout.setContentsMargins(0, 0, 0, 0)
724
+ self.main_layout.setSpacing(2)
725
+
726
+ self.matches_scroll.setWidget(self.matches_container)
727
+ self.main_splitter.addWidget(self.matches_scroll)
728
+
729
+ # Compare box (shown when TM match selected) - VERTICAL STACKED LAYOUT
730
+ self.compare_frame = self._create_compare_box()
731
+ self.main_splitter.addWidget(self.compare_frame)
732
+ self.compare_frame.hide() # Hidden by default
733
+
734
+ # Termbase data viewer (shown when termbase match selected)
735
+ self.termbase_frame = self._create_termbase_viewer()
736
+ self.main_splitter.addWidget(self.termbase_frame)
737
+ self.termbase_frame.hide() # Hidden by default
738
+
739
+ # Tabbed widget for TM Info and Notes (always visible, compact)
740
+ self.info_tabs = QTabWidget()
741
+ self.info_tabs.setStyleSheet(f"""
742
+ QTabWidget::pane {{
743
+ border: 1px solid {border_color};
744
+ background-color: {bg_color};
745
+ border-radius: 3px;
746
+ }}
747
+ QTabBar::tab {{
748
+ background-color: {frame_bg};
749
+ border: 1px solid {border_color};
750
+ border-bottom: none;
751
+ padding: 3px 8px;
752
+ font-size: 9px;
753
+ min-width: 60px;
754
+ }}
755
+ QTabBar::tab:selected {{
756
+ background-color: {bg_color};
757
+ font-weight: bold;
758
+ }}
759
+ """)
760
+ self.info_tabs.setMaximumHeight(100)
761
+
762
+ # TM Info tab
763
+ self.tm_info_frame = self._create_tm_info_panel()
764
+ self.info_tabs.addTab(self.tm_info_frame, "💾 TM Info")
765
+
766
+ # Notes tab
767
+ self.notes_widget = QWidget()
768
+ notes_layout = QVBoxLayout(self.notes_widget)
769
+ notes_layout.setContentsMargins(4, 4, 4, 4)
770
+ notes_layout.setSpacing(2)
771
+
772
+ self.notes_edit = QTextEdit()
773
+ self.notes_edit.setPlaceholderText("Add notes about this segment, context, or translation concerns...")
774
+ self.notes_edit.setStyleSheet(f"font-size: 9px; padding: 4px; background-color: {bg_color}; color: {theme.text if self.theme_manager else '#333'}; border: none;")
775
+ notes_layout.addWidget(self.notes_edit)
776
+
777
+ self.info_tabs.addTab(self.notes_widget, "📝 Segment note")
778
+
779
+ self.main_splitter.addWidget(self.info_tabs)
780
+
781
+ # Set splitter proportions for all 4 widgets:
782
+ # [matches_scroll, compare_frame, termbase_frame, info_tabs]
783
+ # Compare/Termbase are hidden by default, give them reasonable starting sizes
784
+ self.main_splitter.setSizes([300, 200, 150, 100])
785
+ self.main_splitter.setCollapsible(0, False) # matches_scroll
786
+ self.main_splitter.setCollapsible(1, False) # compare_frame - don't allow collapsing
787
+ self.main_splitter.setCollapsible(2, True) # termbase_frame
788
+ self.main_splitter.setCollapsible(3, False) # info_tabs
789
+
790
+ layout.addWidget(self.main_splitter)
791
+
792
+ def apply_theme(self):
793
+ """Refresh all theme-dependent colors when theme changes"""
794
+ if not self.theme_manager:
795
+ return
796
+
797
+ theme = self.theme_manager.current_theme
798
+
799
+ bg_color = theme.base
800
+ border_color = theme.border
801
+ separator_color = theme.separator
802
+ frame_bg = theme.alternate_bg
803
+ title_color = theme.text_disabled
804
+ text_color = theme.text
805
+
806
+ # Update class-level theme_manager for CompactMatchItem
807
+ CompactMatchItem.theme_manager = self.theme_manager
808
+
809
+ # Update main scroll area
810
+ if hasattr(self, 'matches_scroll'):
811
+ self.matches_scroll.setStyleSheet(f"""
812
+ QScrollArea {{
813
+ border: 1px solid {border_color};
814
+ background-color: {bg_color};
815
+ border-radius: 3px;
816
+ }}
817
+ """)
818
+
819
+ # Update matches container background
820
+ if hasattr(self, 'matches_container'):
821
+ self.matches_container.setStyleSheet(f"background-color: {bg_color};")
822
+
823
+ # Update compare frame
824
+ if hasattr(self, 'compare_frame') and self.compare_frame:
825
+ self.compare_frame.setStyleSheet(f"""
826
+ QFrame {{
827
+ background-color: {frame_bg};
828
+ border: 1px solid {border_color};
829
+ border-radius: 3px;
830
+ padding: 4px;
831
+ }}
832
+ """)
833
+
834
+ # Update compare text boxes backgrounds using QPalette (more reliable than stylesheet)
835
+ box_colors = [theme.panel_info, theme.panel_warning, theme.panel_neutral]
836
+ for i, text_edit in enumerate(self.compare_text_edits):
837
+ if text_edit and i < len(box_colors):
838
+ bg_color = box_colors[i]
839
+
840
+ # Clear existing stylesheet first, then set new one
841
+ text_edit.setStyleSheet("")
842
+ new_style = f"""
843
+ QTextEdit {{
844
+ font-size: {self.compare_box_font_size}px;
845
+ padding: 3px;
846
+ background-color: {bg_color};
847
+ border: 1px solid {border_color};
848
+ border-radius: 2px;
849
+ color: {text_color};
850
+ }}
851
+ """
852
+ text_edit.setStyleSheet(new_style)
853
+
854
+ # Also set palette for reliability
855
+ palette = text_edit.palette()
856
+ palette.setColor(palette.ColorRole.Base, QColor(bg_color))
857
+ palette.setColor(palette.ColorRole.Text, QColor(text_color))
858
+ text_edit.setPalette(palette)
859
+ text_edit.setAutoFillBackground(True)
860
+
861
+ # Force update
862
+ text_edit.style().unpolish(text_edit)
863
+ text_edit.style().polish(text_edit)
864
+ text_edit.update()
865
+
866
+ # Update segment label
867
+ if hasattr(self, 'segment_label'):
868
+ self.segment_label.setStyleSheet(f"font-weight: bold; font-size: 10px; color: {title_color};")
869
+
870
+ # Update notes section
871
+ if hasattr(self, 'notes_edit'):
872
+ self.notes_edit.setStyleSheet(f"font-size: 9px; padding: 4px; background-color: {bg_color}; color: {text_color}; border: none;")
873
+
874
+ # Update info_tabs (TM Info + Notes tabs)
875
+ if hasattr(self, 'info_tabs'):
876
+ self.info_tabs.setStyleSheet(f"""
877
+ QTabWidget::pane {{
878
+ border: 1px solid {border_color};
879
+ background-color: {bg_color};
880
+ border-radius: 3px;
881
+ }}
882
+ QTabBar::tab {{
883
+ background-color: {frame_bg};
884
+ border: 1px solid {border_color};
885
+ border-bottom: none;
886
+ padding: 3px 8px;
887
+ font-size: 9px;
888
+ min-width: 60px;
889
+ }}
890
+ QTabBar::tab:selected {{
891
+ background-color: {bg_color};
892
+ font-weight: bold;
893
+ }}
894
+ """)
895
+
896
+ # Update TM Info panel (now inside tab)
897
+ if hasattr(self, 'tm_info_frame') and self.tm_info_frame:
898
+ self.tm_info_frame.setStyleSheet(f"""
899
+ QFrame {{
900
+ background-color: {bg_color};
901
+ border: none;
902
+ padding: 2px;
903
+ }}
904
+ """)
905
+
906
+ if hasattr(self, 'tm_info_title'):
907
+ self.tm_info_title.setStyleSheet(f"font-weight: bold; font-size: 9px; color: {title_color}; margin-bottom: 2px;")
908
+
909
+ if hasattr(self, 'tm_name_label'):
910
+ self.tm_name_label.setStyleSheet(f"font-size: 9px; color: {text_color}; font-weight: bold;")
911
+
912
+ if hasattr(self, 'tm_languages_label'):
913
+ self.tm_languages_label.setStyleSheet(f"font-size: 8px; color: {title_color};")
914
+
915
+ if hasattr(self, 'tm_stats_label'):
916
+ self.tm_stats_label.setStyleSheet(f"font-size: 8px; color: {title_color};")
917
+
918
+ if hasattr(self, 'tm_description_label'):
919
+ self.tm_description_label.setStyleSheet(f"""
920
+ QLabel {{
921
+ font-size: 8px;
922
+ color: {title_color};
923
+ background-color: {bg_color};
924
+ padding: 3px;
925
+ border: 1px solid {border_color};
926
+ border-radius: 2px;
927
+ }}
928
+ """)
929
+
930
+ # Update Termbase viewer panel
931
+ source_bg = theme.panel_info
932
+ target_bg = theme.panel_neutral
933
+ metadata_bg = theme.panel_warning
934
+
935
+ if hasattr(self, 'termbase_frame') and self.termbase_frame:
936
+ self.termbase_frame.setStyleSheet(f"""
937
+ QFrame {{
938
+ background-color: {frame_bg};
939
+ border: 1px solid {border_color};
940
+ border-radius: 3px;
941
+ padding: 4px;
942
+ }}
943
+ """)
944
+
945
+ if hasattr(self, 'termbase_title'):
946
+ self.termbase_title.setStyleSheet(f"font-weight: bold; font-size: 9px; color: {title_color};")
947
+
948
+ if hasattr(self, 'termbase_source_label'):
949
+ self.termbase_source_label.setStyleSheet(f"font-weight: bold; font-size: 8px; color: {title_color};")
950
+
951
+ if hasattr(self, 'termbase_source'):
952
+ self.termbase_source.setStyleSheet(f"""
953
+ QLabel {{
954
+ background-color: {source_bg};
955
+ border: 1px solid {border_color};
956
+ border-radius: 2px;
957
+ font-size: 10px;
958
+ padding: 6px;
959
+ margin: 0px;
960
+ color: {text_color};
961
+ }}
962
+ """)
963
+
964
+ if hasattr(self, 'termbase_target_label'):
965
+ self.termbase_target_label.setStyleSheet(f"font-weight: bold; font-size: 8px; color: {title_color};")
966
+
967
+ if hasattr(self, 'termbase_target'):
968
+ self.termbase_target.setStyleSheet(f"""
969
+ QLabel {{
970
+ background-color: {target_bg};
971
+ border: 1px solid {border_color};
972
+ border-radius: 2px;
973
+ font-size: 10px;
974
+ padding: 6px;
975
+ margin: 0px;
976
+ color: {text_color};
977
+ }}
978
+ """)
979
+
980
+ if hasattr(self, 'termbase_metadata_label'):
981
+ self.termbase_metadata_label.setStyleSheet(f"font-weight: bold; font-size: 8px; color: {title_color};")
982
+
983
+ if hasattr(self, 'termbase_metadata'):
984
+ self.termbase_metadata.setStyleSheet(f"""
985
+ QTextBrowser {{
986
+ background-color: {metadata_bg};
987
+ border: 1px solid {border_color};
988
+ border-radius: 2px;
989
+ font-size: {self.compare_box_font_size}px;
990
+ padding: 4px;
991
+ margin: 0px;
992
+ color: {text_color};
993
+ }}
994
+ """)
995
+
996
+ def _apply_compare_box_theme(self):
997
+ """Apply theme colors to compare boxes - called when boxes become visible"""
998
+ if not self.theme_manager:
999
+ return
1000
+
1001
+ theme = self.theme_manager.current_theme
1002
+ border_color = theme.border
1003
+ text_color = theme.text
1004
+ box_colors = [theme.panel_info, theme.panel_warning, theme.panel_neutral]
1005
+
1006
+ for i, text_edit in enumerate(self.compare_text_edits[:3]): # Only the 3 compare boxes
1007
+ if text_edit:
1008
+ bg_color = box_colors[i]
1009
+ # Clear and set stylesheet
1010
+ text_edit.setStyleSheet("")
1011
+ text_edit.setStyleSheet(f"""
1012
+ QTextEdit {{
1013
+ font-size: {self.compare_box_font_size}px;
1014
+ padding: 3px;
1015
+ background-color: {bg_color};
1016
+ border: 1px solid {border_color};
1017
+ border-radius: 2px;
1018
+ color: {text_color};
1019
+ }}
1020
+ """)
1021
+ # Also set palette for reliability
1022
+ palette = text_edit.palette()
1023
+ palette.setColor(palette.ColorRole.Base, QColor(bg_color))
1024
+ palette.setColor(palette.ColorRole.Text, QColor(text_color))
1025
+ text_edit.setPalette(palette)
1026
+ text_edit.setAutoFillBackground(True)
1027
+ # Force visual update
1028
+ text_edit.style().unpolish(text_edit)
1029
+ text_edit.style().polish(text_edit)
1030
+ text_edit.update()
1031
+
1032
+ def _create_compare_box(self) -> QFrame:
1033
+ """Create compare box frame with VERTICAL stacked layout - all boxes resize together"""
1034
+ # Get theme colors - try to get from parent_app if self.theme_manager is not set yet
1035
+ theme_manager = self.theme_manager
1036
+ if not theme_manager and hasattr(self, 'parent_app') and self.parent_app:
1037
+ theme_manager = getattr(self.parent_app, 'theme_manager', None)
1038
+
1039
+ if theme_manager:
1040
+ theme = theme_manager.current_theme
1041
+ frame_bg = theme.alternate_bg
1042
+ border_color = theme.border
1043
+ title_color = theme.text_disabled
1044
+ box1_bg = theme.panel_info
1045
+ box2_bg = theme.panel_warning
1046
+ box3_bg = theme.panel_neutral
1047
+ else:
1048
+ frame_bg = "#fafafa"
1049
+ border_color = "#ddd"
1050
+ title_color = "#666"
1051
+ box1_bg = "#e3f2fd"
1052
+ box2_bg = "#fff3cd"
1053
+ box3_bg = "#d4edda"
1054
+
1055
+ frame = QFrame()
1056
+ frame.setStyleSheet(f"""
1057
+ QFrame {{
1058
+ background-color: {frame_bg};
1059
+ border: 1px solid {border_color};
1060
+ border-radius: 3px;
1061
+ padding: 4px;
1062
+ }}
1063
+ """)
1064
+
1065
+ layout = QVBoxLayout(frame)
1066
+ layout.setContentsMargins(4, 4, 4, 4)
1067
+ layout.setSpacing(2)
1068
+
1069
+ # Title
1070
+ title = QLabel("📊 Compare Box")
1071
+ title.setStyleSheet(f"font-weight: bold; font-size: 9px; color: {title_color};")
1072
+ layout.addWidget(title)
1073
+
1074
+ # Box 1: Current Source
1075
+ box1 = self._create_compare_text_box("Current Source:", box1_bg)
1076
+ self.compare_current = box1[1]
1077
+ self.compare_current_label = box1[2]
1078
+ layout.addWidget(box1[0], 1) # stretch factor 1
1079
+
1080
+ # Box 2: TM Source (with diff highlighting capability)
1081
+ box2 = self._create_compare_text_box("TM Source:", box2_bg)
1082
+ self.compare_tm_source = box2[1]
1083
+ self.compare_source_label = box2[2]
1084
+ self.compare_source_container = box2[0]
1085
+ layout.addWidget(box2[0], 1) # stretch factor 1
1086
+
1087
+ # Box 3: TM Target
1088
+ box3 = self._create_compare_text_box("TM Target:", box3_bg)
1089
+ self.compare_tm_target = box3[1]
1090
+ self.compare_target_label = box3[2]
1091
+ layout.addWidget(box3[0], 1) # stretch factor 1
1092
+
1093
+ return frame
1094
+
1095
+ def _create_compare_text_box(self, label: str, bg_color: str) -> tuple:
1096
+ """Create a single compare text box"""
1097
+ # Get theme colors
1098
+ if self.theme_manager:
1099
+ theme = self.theme_manager.current_theme
1100
+ label_color = theme.text_disabled
1101
+ border_color = theme.border
1102
+ text_color = theme.text
1103
+ else:
1104
+ label_color = "#666"
1105
+ border_color = "#ccc"
1106
+ text_color = "#333"
1107
+
1108
+ container = QWidget()
1109
+ layout = QVBoxLayout(container)
1110
+ layout.setContentsMargins(2, 2, 2, 2)
1111
+ layout.setSpacing(2)
1112
+
1113
+ label_widget = QLabel(label)
1114
+ label_widget.setStyleSheet(f"font-weight: bold; font-size: 8px; color: {label_color};")
1115
+ layout.addWidget(label_widget)
1116
+
1117
+ text_edit = QTextEdit()
1118
+ text_edit.setReadOnly(True)
1119
+ text_edit.setStyleSheet(f"""
1120
+ QTextEdit {{
1121
+ background-color: {bg_color};
1122
+ border: 1px solid {border_color};
1123
+ border-radius: 2px;
1124
+ font-size: {self.compare_box_font_size}px;
1125
+ padding: 4px;
1126
+ margin: 0px;
1127
+ color: {text_color};
1128
+ }}
1129
+ """)
1130
+ layout.addWidget(text_edit)
1131
+
1132
+ # Track this text edit for font size updates
1133
+ self.compare_text_edits.append(text_edit)
1134
+
1135
+ return (container, text_edit, label_widget)
1136
+
1137
+ def _create_termbase_viewer(self) -> QFrame:
1138
+ """Create termbase data viewer frame"""
1139
+ # Get theme colors
1140
+ if self.theme_manager:
1141
+ theme = self.theme_manager.current_theme
1142
+ frame_bg = theme.alternate_bg
1143
+ border_color = theme.border
1144
+ title_color = theme.text_disabled
1145
+ text_color = theme.text
1146
+ source_bg = theme.panel_info
1147
+ target_bg = theme.panel_neutral
1148
+ metadata_bg = theme.panel_warning
1149
+ else:
1150
+ frame_bg = "#fafafa"
1151
+ border_color = "#ddd"
1152
+ title_color = "#666"
1153
+ text_color = "#333"
1154
+ source_bg = "#e3f2fd"
1155
+ target_bg = "#d4edda"
1156
+ metadata_bg = "#fff3cd"
1157
+
1158
+ frame = QFrame()
1159
+ frame.setStyleSheet(f"""
1160
+ QFrame {{
1161
+ background-color: {frame_bg};
1162
+ border: 1px solid {border_color};
1163
+ border-radius: 3px;
1164
+ padding: 4px;
1165
+ }}
1166
+ """)
1167
+
1168
+ layout = QVBoxLayout(frame)
1169
+ layout.setContentsMargins(4, 4, 4, 4)
1170
+ layout.setSpacing(4)
1171
+
1172
+ # Title with termbase name (will be updated dynamically)
1173
+ header_layout = QHBoxLayout()
1174
+ self.termbase_title = QLabel("📖 Term Info")
1175
+ self.termbase_title.setStyleSheet(f"font-weight: bold; font-size: 9px; color: {title_color};")
1176
+ header_layout.addWidget(self.termbase_title)
1177
+ header_layout.addStretch()
1178
+
1179
+ # Refresh button
1180
+ self.termbase_refresh_btn = QPushButton("🔄 Refresh data")
1181
+ self.termbase_refresh_btn.setStyleSheet("""
1182
+ QPushButton {
1183
+ font-size: 8px;
1184
+ padding: 2px 6px;
1185
+ background-color: #2196F3;
1186
+ color: white;
1187
+ border: none;
1188
+ border-radius: 2px;
1189
+ }
1190
+ QPushButton:hover {
1191
+ background-color: #0b7dda;
1192
+ }
1193
+ """)
1194
+ self.termbase_refresh_btn.setFixedHeight(20)
1195
+ self.termbase_refresh_btn.setToolTip("Refresh entry from database")
1196
+ self.termbase_refresh_btn.clicked.connect(self._on_refresh_termbase_entry)
1197
+ header_layout.addWidget(self.termbase_refresh_btn)
1198
+
1199
+ # Edit button
1200
+ self.termbase_edit_btn = QPushButton("✏️ Edit")
1201
+ self.termbase_edit_btn.setStyleSheet("""
1202
+ QPushButton {
1203
+ font-size: 8px;
1204
+ padding: 2px 6px;
1205
+ background-color: #4CAF50;
1206
+ color: white;
1207
+ border: none;
1208
+ border-radius: 2px;
1209
+ }
1210
+ QPushButton:hover {
1211
+ background-color: #45a049;
1212
+ }
1213
+ """)
1214
+ self.termbase_edit_btn.setFixedHeight(20)
1215
+ self.termbase_edit_btn.clicked.connect(self._on_edit_termbase_entry)
1216
+ header_layout.addWidget(self.termbase_edit_btn)
1217
+
1218
+ layout.addLayout(header_layout)
1219
+
1220
+ # Source and Target terms
1221
+ terms_container = QWidget()
1222
+ terms_layout = QVBoxLayout(terms_container)
1223
+ terms_layout.setContentsMargins(2, 2, 2, 2)
1224
+ terms_layout.setSpacing(3)
1225
+
1226
+ # Source term
1227
+ self.termbase_source_label = QLabel("Source Term:")
1228
+ self.termbase_source_label.setStyleSheet(f"font-weight: bold; font-size: 8px; color: {title_color};")
1229
+ terms_layout.addWidget(self.termbase_source_label)
1230
+
1231
+ self.termbase_source = QLabel()
1232
+ self.termbase_source.setStyleSheet(f"""
1233
+ QLabel {{
1234
+ background-color: {source_bg};
1235
+ border: 1px solid {border_color};
1236
+ border-radius: 2px;
1237
+ font-size: 10px;
1238
+ padding: 6px;
1239
+ margin: 0px;
1240
+ color: {text_color};
1241
+ }}
1242
+ """)
1243
+ self.termbase_source.setWordWrap(True)
1244
+ terms_layout.addWidget(self.termbase_source)
1245
+
1246
+ # Target term
1247
+ self.termbase_target_label = QLabel("Target Term:")
1248
+ self.termbase_target_label.setStyleSheet(f"font-weight: bold; font-size: 8px; color: {title_color};")
1249
+ terms_layout.addWidget(self.termbase_target_label)
1250
+
1251
+ self.termbase_target = QLabel()
1252
+ self.termbase_target.setStyleSheet(f"""
1253
+ QLabel {{
1254
+ background-color: {target_bg};
1255
+ border: 1px solid {border_color};
1256
+ border-radius: 2px;
1257
+ font-size: 10px;
1258
+ padding: 6px;
1259
+ margin: 0px;
1260
+ color: {text_color};
1261
+ }}
1262
+ """)
1263
+ self.termbase_target.setWordWrap(True)
1264
+ terms_layout.addWidget(self.termbase_target)
1265
+
1266
+ layout.addWidget(terms_container)
1267
+
1268
+ # Metadata area
1269
+ self.termbase_metadata_label = QLabel("Metadata:")
1270
+ self.termbase_metadata_label.setStyleSheet(f"font-weight: bold; font-size: 8px; color: {title_color};")
1271
+ layout.addWidget(self.termbase_metadata_label)
1272
+
1273
+ from PyQt6.QtWidgets import QTextBrowser
1274
+ self.termbase_metadata = QTextBrowser()
1275
+ self.termbase_metadata.setReadOnly(True)
1276
+ self.termbase_metadata.setMaximumHeight(80)
1277
+ self.termbase_metadata.setStyleSheet(f"""
1278
+ QTextBrowser {{
1279
+ background-color: {metadata_bg};
1280
+ border: 1px solid {border_color};
1281
+ border-radius: 2px;
1282
+ font-size: {self.compare_box_font_size}px;
1283
+ padding: 4px;
1284
+ margin: 0px;
1285
+ color: {text_color};
1286
+ }}
1287
+ """)
1288
+ # Enable clickable links
1289
+ self.termbase_metadata.setOpenExternalLinks(True)
1290
+ layout.addWidget(self.termbase_metadata)
1291
+
1292
+ # Track metadata text edit for font size updates
1293
+ self.compare_text_edits.append(self.termbase_metadata)
1294
+
1295
+ return frame
1296
+
1297
+ def _create_tm_info_panel(self) -> QFrame:
1298
+ """Create TM metadata info panel (memoQ-style) - shown in TM Info tab"""
1299
+ # Get theme colors
1300
+ if self.theme_manager:
1301
+ theme = self.theme_manager.current_theme
1302
+ frame_bg = theme.base
1303
+ border_color = theme.border
1304
+ title_color = theme.text_disabled
1305
+ text_color = theme.text
1306
+ desc_bg = theme.alternate_bg
1307
+ else:
1308
+ frame_bg = "#fff"
1309
+ border_color = "#ddd"
1310
+ title_color = "#666"
1311
+ text_color = "#333"
1312
+ desc_bg = "#f5f5f5"
1313
+
1314
+ frame = QFrame()
1315
+ frame.setStyleSheet(f"""
1316
+ QFrame {{
1317
+ background-color: {frame_bg};
1318
+ border: none;
1319
+ padding: 2px;
1320
+ }}
1321
+ """)
1322
+
1323
+ layout = QVBoxLayout(frame)
1324
+ layout.setContentsMargins(4, 4, 4, 4)
1325
+ layout.setSpacing(2)
1326
+
1327
+ # Info grid (compact layout - no title needed, it's in the tab)
1328
+ info_container = QWidget()
1329
+ info_layout = QVBoxLayout(info_container)
1330
+ info_layout.setContentsMargins(0, 0, 0, 0)
1331
+ info_layout.setSpacing(2)
1332
+
1333
+ # TM Name
1334
+ self.tm_name_label = QLabel()
1335
+ self.tm_name_label.setStyleSheet(f"font-size: 9px; color: {text_color}; font-weight: bold;")
1336
+ self.tm_name_label.setWordWrap(True)
1337
+ info_layout.addWidget(self.tm_name_label)
1338
+
1339
+ # Languages (smaller)
1340
+ self.tm_languages_label = QLabel()
1341
+ self.tm_languages_label.setStyleSheet(f"font-size: 8px; color: {title_color};")
1342
+ info_layout.addWidget(self.tm_languages_label)
1343
+
1344
+ # Entry count and modified date in single line
1345
+ self.tm_stats_label = QLabel()
1346
+ self.tm_stats_label.setStyleSheet(f"font-size: 8px; color: {title_color};")
1347
+ self.tm_stats_label.setWordWrap(True)
1348
+ info_layout.addWidget(self.tm_stats_label)
1349
+
1350
+ # Description (if available)
1351
+ self.tm_description_label = QLabel()
1352
+ self.tm_description_label.setStyleSheet(f"""
1353
+ QLabel {{
1354
+ font-size: 8px;
1355
+ color: {title_color};
1356
+ background-color: {desc_bg};
1357
+ padding: 3px;
1358
+ border: 1px solid {border_color};
1359
+ border-radius: 2px;
1360
+ }}
1361
+ """)
1362
+ self.tm_description_label.setWordWrap(True)
1363
+ self.tm_description_label.hide() # Hidden if no description
1364
+ info_layout.addWidget(self.tm_description_label)
1365
+
1366
+ layout.addWidget(info_container)
1367
+
1368
+ return frame
1369
+
1370
+ def _on_edit_termbase_entry(self):
1371
+ """Handle edit button click - open termbase entry editor dialog"""
1372
+ if not self.current_selection or self.current_selection.match_type != "Termbase":
1373
+ return
1374
+
1375
+ # Get term_id from metadata if available
1376
+ term_id = self.current_selection.metadata.get('term_id')
1377
+ termbase_id = self.current_selection.metadata.get('termbase_id')
1378
+
1379
+ if term_id and termbase_id:
1380
+ # Import and show editor dialog
1381
+ from modules.termbase_entry_editor import TermbaseEntryEditor
1382
+
1383
+ # Get parent window (main application)
1384
+ parent_window = self.window()
1385
+
1386
+ dialog = TermbaseEntryEditor(
1387
+ parent=parent_window,
1388
+ db_manager=getattr(parent_window, 'db_manager', None),
1389
+ termbase_id=termbase_id,
1390
+ term_id=term_id
1391
+ )
1392
+
1393
+ if dialog.exec():
1394
+ # Entry was edited, refresh the display
1395
+ # Get updated term data and refresh the termbase viewer
1396
+ self._refresh_termbase_viewer()
1397
+
1398
+ def _on_refresh_termbase_entry(self):
1399
+ """Handle refresh button click - reload entry from database"""
1400
+ if not self.current_selection or self.current_selection.match_type != "Termbase":
1401
+ return
1402
+
1403
+ # Get term_id from metadata
1404
+ term_id = self.current_selection.metadata.get('term_id')
1405
+ if not term_id:
1406
+ return
1407
+
1408
+ # Get parent window and database manager
1409
+ parent_window = self.window()
1410
+ db_manager = getattr(parent_window, 'db_manager', None)
1411
+
1412
+ if not db_manager:
1413
+ return
1414
+
1415
+ try:
1416
+ # Fetch fresh data from database
1417
+ cursor = db_manager.cursor
1418
+ cursor.execute("""
1419
+ SELECT source_term, target_term, priority, domain, notes,
1420
+ project, client, forbidden, termbase_id
1421
+ FROM termbase_terms
1422
+ WHERE id = ?
1423
+ """, (term_id,))
1424
+
1425
+ row = cursor.fetchone()
1426
+ if row:
1427
+ # Update the current selection metadata with fresh data
1428
+ self.current_selection.source = row[0]
1429
+ self.current_selection.target = row[1]
1430
+ self.current_selection.metadata['priority'] = row[2] or 99
1431
+ self.current_selection.metadata['domain'] = row[3] or ''
1432
+ self.current_selection.metadata['notes'] = row[4] or ''
1433
+ self.current_selection.metadata['project'] = row[5] or ''
1434
+ self.current_selection.metadata['client'] = row[6] or ''
1435
+ self.current_selection.metadata['forbidden'] = row[7] or False
1436
+ self.current_selection.metadata['termbase_id'] = row[8]
1437
+
1438
+ # Re-display with updated data
1439
+ self._display_termbase_data(self.current_selection)
1440
+
1441
+ except Exception as e:
1442
+ print(f"Error refreshing termbase entry: {e}")
1443
+
1444
+ def _refresh_termbase_viewer(self):
1445
+ """Refresh termbase viewer with latest data from database"""
1446
+ if not self.current_selection or self.current_selection.match_type != "Termbase":
1447
+ return
1448
+
1449
+ # Use the refresh handler to fetch and display fresh data
1450
+ self._on_refresh_termbase_entry()
1451
+
1452
+ def _display_termbase_data(self, match: TranslationMatch):
1453
+ """Display termbase entry data in the viewer"""
1454
+ # Keep consistent "Term Info" title
1455
+ self.termbase_title.setText("📖 Term Info")
1456
+
1457
+ # Display source and target terms
1458
+ self.termbase_source.setText(match.source)
1459
+
1460
+ # Include synonyms in target display if available
1461
+ target_synonyms = match.metadata.get('target_synonyms', [])
1462
+ if target_synonyms:
1463
+ # Show main term with synonyms
1464
+ synonyms_text = ", ".join(target_synonyms)
1465
+ self.termbase_target.setText(f"{match.target} | {synonyms_text}")
1466
+ else:
1467
+ self.termbase_target.setText(match.target)
1468
+
1469
+ # Build metadata text
1470
+ metadata_parts = []
1471
+
1472
+ # Termbase name
1473
+ termbase_name = match.metadata.get('termbase_name', 'Unknown')
1474
+ metadata_parts.append(f"<b>Termbase:</b> {termbase_name}")
1475
+
1476
+ # Priority
1477
+ priority = match.metadata.get('priority', 50)
1478
+ metadata_parts.append(f"<b>Priority:</b> {priority}")
1479
+
1480
+ # Domain
1481
+ domain = match.metadata.get('domain', '')
1482
+ if domain:
1483
+ metadata_parts.append(f"<b>Domain:</b> {domain}")
1484
+
1485
+ # Notes
1486
+ notes = match.metadata.get('notes', '')
1487
+ if notes:
1488
+ # Truncate long notes for display
1489
+ if len(notes) > 200:
1490
+ notes = notes[:200] + "..."
1491
+ # Convert URLs to clickable links
1492
+ import re
1493
+ url_pattern = r'(https?://[^\s]+)'
1494
+ notes = re.sub(url_pattern, r'<a href="\1">\1</a>', notes)
1495
+ metadata_parts.append(f"<b>Notes:</b> {notes}")
1496
+
1497
+ # Project
1498
+ project = match.metadata.get('project', '')
1499
+ if project:
1500
+ metadata_parts.append(f"<b>Project:</b> {project}")
1501
+
1502
+ # Client
1503
+ client = match.metadata.get('client', '')
1504
+ if client:
1505
+ metadata_parts.append(f"<b>Client:</b> {client}")
1506
+
1507
+ # Forbidden status
1508
+ forbidden = match.metadata.get('forbidden', False)
1509
+ if forbidden:
1510
+ metadata_parts.append("<b><span style='color: red;'>⚠️ FORBIDDEN TERM</span></b>")
1511
+
1512
+ # Term ID (for debugging)
1513
+ term_id = match.metadata.get('term_id', '')
1514
+ if term_id:
1515
+ metadata_parts.append(f"<span style='color: #888; font-size: 7px;'>Term ID: {term_id}</span>")
1516
+
1517
+ metadata_html = "<br>".join(metadata_parts) if metadata_parts else "<i>No metadata</i>"
1518
+ self.termbase_metadata.setHtml(metadata_html)
1519
+
1520
+ def _display_tm_metadata(self, match: TranslationMatch):
1521
+ """Display TM metadata in the info panel (memoQ-style)"""
1522
+ # Get TM metadata from match
1523
+ tm_name = match.metadata.get('tm_name', 'Unknown TM')
1524
+ tm_id = match.metadata.get('tm_id', '')
1525
+ source_lang = match.metadata.get('source_lang', '')
1526
+ target_lang = match.metadata.get('target_lang', '')
1527
+ entry_count = match.metadata.get('entry_count', 0)
1528
+ modified_date = match.metadata.get('modified_date', '')
1529
+ description = match.metadata.get('description', '')
1530
+
1531
+ # Update TM name
1532
+ self.tm_name_label.setText(f"📝 {tm_name}")
1533
+
1534
+ # Update languages
1535
+ if source_lang and target_lang:
1536
+ self.tm_languages_label.setText(f"🌐 {source_lang} → {target_lang}")
1537
+ else:
1538
+ self.tm_languages_label.setText("")
1539
+
1540
+ # Update stats (entry count + modified date)
1541
+ stats_parts = []
1542
+ if entry_count:
1543
+ stats_parts.append(f"📊 {entry_count:,} entries")
1544
+ if modified_date:
1545
+ # Format date nicely if it's ISO format
1546
+ try:
1547
+ from datetime import datetime
1548
+ dt = datetime.fromisoformat(modified_date)
1549
+ formatted_date = dt.strftime("%Y-%m-%d %H:%M")
1550
+ stats_parts.append(f"🕒 Modified: {formatted_date}")
1551
+ except:
1552
+ stats_parts.append(f"🕒 {modified_date}")
1553
+
1554
+ self.tm_stats_label.setText(" • ".join(stats_parts) if stats_parts else "")
1555
+
1556
+ # Update description (show/hide based on content)
1557
+ if description and description.strip():
1558
+ self.tm_description_label.setText(f"💬 {description}")
1559
+ self.tm_description_label.show()
1560
+ else:
1561
+ self.tm_description_label.hide()
1562
+
1563
+ def add_matches(self, new_matches_dict: Dict[str, List[TranslationMatch]]):
1564
+ """
1565
+ Add new matches to existing matches (for progressive loading)
1566
+ Merges new matches with existing ones and re-renders the display
1567
+ Includes deduplication to prevent showing identical matches
1568
+
1569
+ Args:
1570
+ new_matches_dict: Dict with keys like "NT", "MT", "TM", "Termbases"
1571
+ """
1572
+ # Merge new matches with existing matches_by_type
1573
+ if not hasattr(self, 'matches_by_type') or not self.matches_by_type:
1574
+ # No existing matches, just set them
1575
+ self.set_matches(new_matches_dict)
1576
+ return
1577
+
1578
+ # Merge: Update existing match types with new matches (with deduplication)
1579
+ for match_type, new_matches in new_matches_dict.items():
1580
+ if new_matches: # Only merge non-empty lists
1581
+ if match_type in self.matches_by_type:
1582
+ # Deduplicate: Only add matches that don't already exist
1583
+ existing_targets = {match.target for match in self.matches_by_type[match_type]}
1584
+ unique_new_matches = [m for m in new_matches if m.target not in existing_targets]
1585
+ if unique_new_matches:
1586
+ self.matches_by_type[match_type].extend(unique_new_matches)
1587
+ else:
1588
+ # New match type, add it
1589
+ self.matches_by_type[match_type] = new_matches
1590
+
1591
+ # Re-render with merged matches
1592
+ self.set_matches(self.matches_by_type)
1593
+
1594
+ def _sort_termbase_matches(self, matches: List[TranslationMatch]) -> List[TranslationMatch]:
1595
+ """
1596
+ Sort termbase matches based on user preference.
1597
+
1598
+ Args:
1599
+ matches: List of termbase matches
1600
+
1601
+ Returns:
1602
+ Sorted list of matches
1603
+ """
1604
+ if not self.parent_app:
1605
+ return matches # No sorting if no parent app
1606
+
1607
+ sort_order = getattr(self.parent_app, 'termbase_display_order', 'appearance')
1608
+
1609
+ if sort_order == 'alphabetical':
1610
+ # Sort alphabetically by source term (case-insensitive)
1611
+ return sorted(matches, key=lambda m: m.source.lower())
1612
+ elif sort_order == 'length':
1613
+ # Sort by source term length (longest first)
1614
+ return sorted(matches, key=lambda m: len(m.source), reverse=True)
1615
+ elif sort_order == 'appearance':
1616
+ # Sort by position in source text (if available in metadata)
1617
+ # If position not available, keep original order
1618
+ def get_position(match):
1619
+ pos = match.metadata.get('position_in_source', -1)
1620
+ # If no position, put at end
1621
+ return pos if pos >= 0 else 999999
1622
+ return sorted(matches, key=get_position)
1623
+ else:
1624
+ # Default: keep original order
1625
+ return matches
1626
+
1627
+ def _filter_shorter_matches(self, matches: List[TranslationMatch]) -> List[TranslationMatch]:
1628
+ """
1629
+ Filter out shorter termbase matches that are substrings of longer matches.
1630
+
1631
+ Args:
1632
+ matches: List of termbase matches
1633
+
1634
+ Returns:
1635
+ Filtered list with shorter substring matches removed
1636
+ """
1637
+ if not self.parent_app:
1638
+ return matches # No filtering if no parent app
1639
+
1640
+ hide_shorter = getattr(self.parent_app, 'termbase_hide_shorter_matches', False)
1641
+
1642
+ if not hide_shorter:
1643
+ return matches
1644
+
1645
+ # Create a list to track which matches to keep
1646
+ filtered_matches = []
1647
+
1648
+ for i, match in enumerate(matches):
1649
+ # Check if this match's source is a substring of any other match's source
1650
+ is_substring = False
1651
+ source_lower = match.source.lower()
1652
+
1653
+ for j, other_match in enumerate(matches):
1654
+ if i == j:
1655
+ continue
1656
+ other_source_lower = other_match.source.lower()
1657
+
1658
+ # Check if current match is a substring of the other match
1659
+ # and is shorter than the other match
1660
+ if (source_lower in other_source_lower and
1661
+ len(source_lower) < len(other_source_lower)):
1662
+ is_substring = True
1663
+ break
1664
+
1665
+ # Keep the match if it's not a substring of a longer match
1666
+ if not is_substring:
1667
+ filtered_matches.append(match)
1668
+
1669
+ return filtered_matches
1670
+
1671
+ def set_matches(self, matches_dict: Dict[str, List[TranslationMatch]]):
1672
+ """
1673
+ Set matches from different sources in unified flat list with GLOBAL consecutive numbering
1674
+ (memoQ-style: single grid, color coding only identifies match type)
1675
+
1676
+ Args:
1677
+ matches_dict: Dict with keys like "NT", "MT", "TM", "Termbases"
1678
+ """
1679
+ print(f"🎯 TranslationResultsPanel.set_matches() called with matches_dict keys: {list(matches_dict.keys())}")
1680
+ for match_type, matches in matches_dict.items():
1681
+ print(f" {match_type}: {len(matches)} matches")
1682
+ if match_type == "Termbases" and matches:
1683
+ for i, match in enumerate(matches[:2]): # Show first 2 for debugging
1684
+ print(f" [{i}] {match.source} → {match.target}")
1685
+
1686
+ # Ensure CompactMatchItem has current theme_manager
1687
+ if self.theme_manager:
1688
+ CompactMatchItem.theme_manager = self.theme_manager
1689
+
1690
+ # Store current matches for delayed search access
1691
+ self._current_matches = matches_dict.copy()
1692
+ self.matches_by_type = matches_dict
1693
+ self.all_matches = []
1694
+ self.match_items = [] # Track all match items for navigation
1695
+ self.selected_index = -1
1696
+
1697
+ # Clear existing matches
1698
+ while self.main_layout.count() > 0:
1699
+ item = self.main_layout.takeAt(0)
1700
+ if item and item.widget():
1701
+ item.widget().deleteLater()
1702
+
1703
+ # Apply match limits per type (configurable, defaults provided)
1704
+ match_limits = getattr(self, 'match_limits', {
1705
+ "LLM": 3,
1706
+ "NT": 5,
1707
+ "MT": 3,
1708
+ "TM": 5,
1709
+ "Termbases": 10,
1710
+ "NonTrans": 20 # Non-translatables (show more since they're important)
1711
+ })
1712
+
1713
+ # Build flat list of all matches with global numbering
1714
+ global_number = 1
1715
+ order = ["LLM", "NonTrans", "NT", "MT", "TM", "Termbases"] # LLM first, NonTrans early (important for translator)
1716
+
1717
+ for match_type in order:
1718
+ if match_type in matches_dict and matches_dict[match_type]:
1719
+ # Get matches for this type
1720
+ type_matches = matches_dict[match_type]
1721
+
1722
+ # Apply sorting and filtering for termbase matches
1723
+ if match_type == "Termbases":
1724
+ # First filter out shorter substring matches (if enabled)
1725
+ type_matches = self._filter_shorter_matches(type_matches)
1726
+ # Then sort according to user preference
1727
+ type_matches = self._sort_termbase_matches(type_matches)
1728
+
1729
+ # Apply limit for this match type
1730
+ limit = match_limits.get(match_type, 5)
1731
+ limited_matches = type_matches[:limit]
1732
+
1733
+ for match in limited_matches:
1734
+ self.all_matches.append(match)
1735
+
1736
+ # Create match item with global number
1737
+ item = CompactMatchItem(match, match_number=global_number)
1738
+ item.match_selected.connect(lambda m, idx=len(self.match_items): self._on_match_item_selected(m, idx))
1739
+ self.main_layout.addWidget(item)
1740
+ self.match_items.append(item)
1741
+
1742
+ global_number += 1
1743
+
1744
+ self.main_layout.addStretch()
1745
+
1746
+ def _on_match_item_selected(self, match: TranslationMatch, index: int):
1747
+ """Handle match item selection"""
1748
+ # Deselect previous
1749
+ if 0 <= self.selected_index < len(self.match_items):
1750
+ self.match_items[self.selected_index].deselect()
1751
+
1752
+ # Select new
1753
+ self.selected_index = index
1754
+ if 0 <= index < len(self.match_items):
1755
+ self.match_items[index].select()
1756
+
1757
+ self._on_match_selected(match)
1758
+
1759
+ def _on_match_selected(self, match: TranslationMatch):
1760
+ """Handle match selection"""
1761
+ print(f"🔍 DEBUG: _on_match_selected called with match_type='{match.match_type}'")
1762
+ self.current_selection = match
1763
+ self.match_selected.emit(match)
1764
+
1765
+ # Show appropriate viewer based on match type
1766
+ if match.match_type == "TM" and match.compare_source:
1767
+ # Show TM compare box
1768
+ print("📊 DEBUG: Showing TM compare box")
1769
+ self.compare_frame.show()
1770
+ self.termbase_frame.hide()
1771
+
1772
+ # Switch to TM Info tab
1773
+ if hasattr(self, 'info_tabs'):
1774
+ self.info_tabs.setCurrentIndex(0) # TM Info tab
1775
+
1776
+ # Ensure compare box has reasonable size in splitter
1777
+ if hasattr(self, 'main_splitter'):
1778
+ sizes = self.main_splitter.sizes()
1779
+ # If compare_frame (index 1) has 0 or very small size, redistribute
1780
+ if len(sizes) >= 4 and sizes[1] < 100:
1781
+ # Give compare box 200px, take from matches
1782
+ total = sum(sizes)
1783
+ sizes[1] = 200 # compare_frame
1784
+ sizes[0] = max(100, total - 200 - sizes[2] - sizes[3]) # matches_scroll
1785
+ self.main_splitter.setSizes(sizes)
1786
+
1787
+ # Apply theme colors to compare boxes (needed because they might not apply when hidden)
1788
+ self._apply_compare_box_theme()
1789
+
1790
+ # Update labels for TM
1791
+ self.compare_source_label.setText("TM Source:")
1792
+ self.compare_target_label.setText("TM Target:")
1793
+ self.compare_source_container.show() # Show TM source box
1794
+
1795
+ # Get current source text for diff comparison
1796
+ current_source = self.compare_current.toPlainText()
1797
+ tm_source = match.compare_source
1798
+
1799
+ # Apply diff highlighting between current source and TM source
1800
+ self._apply_diff_highlighting(current_source, tm_source)
1801
+
1802
+ # Set TM target (no diff highlighting needed)
1803
+ self.compare_tm_target.setText(match.target)
1804
+
1805
+ # Populate TM metadata panel
1806
+ self._display_tm_metadata(match)
1807
+
1808
+ elif match.match_type in ("MT", "LLM") and match.compare_source:
1809
+ # Show MT/LLM compare box (simplified - just current source and translation)
1810
+ print(f"🤖 DEBUG: Showing {match.match_type} compare box")
1811
+ self.compare_frame.show()
1812
+ self.termbase_frame.hide()
1813
+
1814
+ # Ensure compare box has reasonable size in splitter
1815
+ if hasattr(self, 'main_splitter'):
1816
+ sizes = self.main_splitter.sizes()
1817
+ # If compare_frame (index 1) has 0 or very small size, redistribute
1818
+ if len(sizes) >= 4 and sizes[1] < 100:
1819
+ total = sum(sizes)
1820
+ sizes[1] = 200 # compare_frame
1821
+ sizes[0] = max(100, total - 200 - sizes[2] - sizes[3]) # matches_scroll
1822
+ self.main_splitter.setSizes(sizes)
1823
+
1824
+ # Apply theme colors to compare boxes (needed because they might not apply when hidden)
1825
+ self._apply_compare_box_theme()
1826
+
1827
+ # Update labels for MT/LLM
1828
+ provider_name = match.metadata.get('provider', match.match_type)
1829
+ self.compare_source_container.hide() # Hide source box for MT/LLM (source = current)
1830
+ self.compare_target_label.setText(f"{match.match_type} Translation ({provider_name}):")
1831
+
1832
+ # Set target text
1833
+ self.compare_tm_target.setText(match.target)
1834
+
1835
+ elif match.match_type == "Termbase":
1836
+ # Show termbase data viewer
1837
+ print("📖 DEBUG: Showing termbase viewer!")
1838
+ self.compare_frame.hide()
1839
+ self.termbase_frame.show()
1840
+ self._display_termbase_data(match)
1841
+ else:
1842
+ # Hide all viewers
1843
+ print(f"❌ DEBUG: Match type '{match.match_type}' - hiding all viewers")
1844
+ self.compare_frame.hide()
1845
+ self.termbase_frame.hide()
1846
+
1847
+ def set_segment_info(self, segment_num: int, source_text: str):
1848
+ """Update segment info display"""
1849
+ self.segment_label.setText(f"Segment {segment_num}: {source_text[:50]}...")
1850
+ self.compare_current.setText(source_text)
1851
+
1852
+ def _apply_diff_highlighting(self, current_source: str, tm_source: str):
1853
+ """
1854
+ Apply diff highlighting between current source and TM source.
1855
+ Shows differences memoQ-style in the TM Source box:
1856
+ - Red strikethrough for text in TM that's not in current segment (will need to be removed)
1857
+ - Red underline for text in current that's not in TM (translator needs to add this)
1858
+
1859
+ The Current Source box shows the plain text without highlighting.
1860
+ This helps translators quickly see what changed between the fuzzy match and the current segment.
1861
+ """
1862
+ # Use difflib's SequenceMatcher to find differences at word level
1863
+ current_words = current_source.split()
1864
+ tm_words = tm_source.split()
1865
+
1866
+ # Get opcodes that describe how to transform tm_source into current_source
1867
+ matcher = difflib.SequenceMatcher(None, tm_words, current_words)
1868
+ opcodes = matcher.get_opcodes()
1869
+
1870
+ # Define formatting styles
1871
+ # Red strikethrough for deletions (text in TM but not in current)
1872
+ delete_format = QTextCharFormat()
1873
+ delete_format.setForeground(QColor("#CC0000")) # Red text
1874
+ delete_format.setFontStrikeOut(True)
1875
+
1876
+ # Red underline for additions (text in current but not in TM)
1877
+ insert_format = QTextCharFormat()
1878
+ insert_format.setForeground(QColor("#CC0000")) # Red text
1879
+ insert_format.setFontUnderline(True)
1880
+
1881
+ # Normal format (for unchanged text)
1882
+ normal_format = QTextCharFormat()
1883
+ if self.theme_manager:
1884
+ normal_format.setForeground(QColor(self.theme_manager.current_theme.text))
1885
+ else:
1886
+ normal_format.setForeground(QColor("#333333"))
1887
+
1888
+ # Current Source box: just show plain text (already set by set_segment_info, but reset to ensure no formatting)
1889
+ self.compare_current.setText(current_source)
1890
+
1891
+ # TM Source box: show with diff highlighting
1892
+ # Red strikethrough = text in TM but not in current (needs to be removed/changed)
1893
+ # Red underline = text in current but not in TM (needs to be added to translation)
1894
+ self.compare_tm_source.clear()
1895
+ tm_cursor = self.compare_tm_source.textCursor()
1896
+
1897
+ first_word = True
1898
+ for tag, i1, i2, j1, j2 in opcodes:
1899
+ if tag == 'equal':
1900
+ # Unchanged words - show in normal format
1901
+ text = ' '.join(tm_words[i1:i2])
1902
+ if not first_word:
1903
+ tm_cursor.insertText(' ', normal_format)
1904
+ tm_cursor.insertText(text, normal_format)
1905
+ first_word = False
1906
+ elif tag == 'replace':
1907
+ # Words were replaced
1908
+ # Show what's in TM (being replaced) as strikethrough
1909
+ # Show what's in current (replacing it) as underlined
1910
+ old_text = ' '.join(tm_words[i1:i2])
1911
+ new_text = ' '.join(current_words[j1:j2])
1912
+ if not first_word:
1913
+ tm_cursor.insertText(' ', normal_format)
1914
+ tm_cursor.insertText(old_text, delete_format)
1915
+ tm_cursor.insertText(' ', normal_format)
1916
+ tm_cursor.insertText(new_text, insert_format)
1917
+ first_word = False
1918
+ elif tag == 'delete':
1919
+ # Words in TM but not in current - strikethrough (will be removed)
1920
+ text = ' '.join(tm_words[i1:i2])
1921
+ if not first_word:
1922
+ tm_cursor.insertText(' ', normal_format)
1923
+ tm_cursor.insertText(text, delete_format)
1924
+ first_word = False
1925
+ elif tag == 'insert':
1926
+ # Words in current but not in TM - underlined (needs to be added)
1927
+ text = ' '.join(current_words[j1:j2])
1928
+ if not first_word:
1929
+ tm_cursor.insertText(' ', normal_format)
1930
+ tm_cursor.insertText(text, insert_format)
1931
+ first_word = False
1932
+
1933
+ def clear(self):
1934
+ """Clear all matches (but NOT notes - those are managed separately)"""
1935
+ self.matches_by_type = {}
1936
+ self.current_selection = None
1937
+ self.all_matches = []
1938
+ self.compare_frame.hide()
1939
+ self.termbase_frame.hide()
1940
+ # NOTE: Do NOT clear notes_edit here - notes are loaded/saved separately
1941
+ # and clear() is called multiple times during segment navigation
1942
+
1943
+ while self.main_layout.count() > 0:
1944
+ item = self.main_layout.takeAt(0)
1945
+ if item and item.widget():
1946
+ item.widget().deleteLater()
1947
+
1948
+ def get_selected_match(self) -> Optional[TranslationMatch]:
1949
+ """Get currently selected match"""
1950
+ return self.current_selection
1951
+
1952
+ def set_font_size(self, size: int):
1953
+ """Set font size for all match items (for zoom control)"""
1954
+ CompactMatchItem.set_font_size(size)
1955
+ # Update all currently displayed items
1956
+ for item in self.match_items:
1957
+ item.update_font_size()
1958
+ item.adjustSize()
1959
+ self.matches_scroll.update()
1960
+
1961
+ def set_compare_box_font_size(self, size: int):
1962
+ """Set font size for compare boxes"""
1963
+ TranslationResultsPanel.compare_box_font_size = size
1964
+ for text_edit in self.compare_text_edits:
1965
+ text_edit.setStyleSheet(f"""
1966
+ QTextEdit {{
1967
+ background-color: {self._get_box_color(text_edit)};
1968
+ border: 1px solid #ccc;
1969
+ border-radius: 2px;
1970
+ font-size: {size}px;
1971
+ padding: 4px;
1972
+ margin: 0px;
1973
+ }}
1974
+ """)
1975
+
1976
+ def set_show_tags(self, show: bool):
1977
+ """Set whether to show HTML/XML tags in matches"""
1978
+ CompactMatchItem.show_tags = show
1979
+ # Refresh all match items
1980
+ for item in self.match_items:
1981
+ if hasattr(item, 'source_label') and hasattr(item, 'target_label'):
1982
+ # Always use RichText (needed for both tag rendering and highlighting)
1983
+ item.source_label.setTextFormat(Qt.TextFormat.RichText)
1984
+ item.target_label.setTextFormat(Qt.TextFormat.RichText)
1985
+ # Refresh text
1986
+ item.source_label.setText(item._format_text(item.match.source))
1987
+ item.target_label.setText(item._format_text(item.match.target))
1988
+
1989
+ def set_tag_color(self, color: str):
1990
+ """Set tag highlight color for all match items"""
1991
+ CompactMatchItem.tag_highlight_color = color
1992
+ # Refresh all match items to apply new color
1993
+ for item in self.match_items:
1994
+ if hasattr(item, 'update_tag_color'):
1995
+ item.update_tag_color(color)
1996
+
1997
+ def _get_box_color(self, text_edit) -> str:
1998
+ """Get background color for a compare box (mapping hack)"""
1999
+ # This is a workaround - in production, store colors with the widgets
2000
+ colors = ["#e3f2fd", "#fff3cd", "#d4edda"]
2001
+ if text_edit in self.compare_text_edits:
2002
+ return colors[self.compare_text_edits.index(text_edit) % len(colors)]
2003
+ return "#fafafa"
2004
+
2005
+ def zoom_in(self):
2006
+ """Increase font size for both match list and compare boxes"""
2007
+ new_size = CompactMatchItem.font_size_pt + 1
2008
+ if new_size <= 16: # Max 16pt
2009
+ self.set_font_size(new_size)
2010
+ # Also increase compare boxes
2011
+ compare_size = TranslationResultsPanel.compare_box_font_size + 1
2012
+ if compare_size <= 14:
2013
+ self.set_compare_box_font_size(compare_size)
2014
+ return new_size
2015
+ return CompactMatchItem.font_size_pt
2016
+
2017
+ def zoom_out(self):
2018
+ """Decrease font size for both match list and compare boxes"""
2019
+ new_size = CompactMatchItem.font_size_pt - 1
2020
+ if new_size >= 7: # Min 7pt
2021
+ self.set_font_size(new_size)
2022
+ # Also decrease compare boxes
2023
+ compare_size = TranslationResultsPanel.compare_box_font_size - 1
2024
+ if compare_size >= 7:
2025
+ self.set_compare_box_font_size(compare_size)
2026
+ return new_size
2027
+ return CompactMatchItem.font_size_pt
2028
+
2029
+ def reset_zoom(self):
2030
+ """Reset font size to defaults"""
2031
+ self.set_font_size(9)
2032
+ self.set_compare_box_font_size(9)
2033
+ return 9
2034
+
2035
+ def select_previous_match(self):
2036
+ """Navigate to previous match (Ctrl+Up from main window)"""
2037
+ if self.selected_index > 0:
2038
+ new_index = self.selected_index - 1
2039
+ self._on_match_item_selected(self.all_matches[new_index], new_index)
2040
+ # Scroll to make it visible
2041
+ if 0 <= new_index < len(self.match_items):
2042
+ self.matches_scroll.ensureWidgetVisible(self.match_items[new_index])
2043
+ elif self.selected_index == -1 and self.all_matches:
2044
+ # No selection, select last match
2045
+ new_index = len(self.all_matches) - 1
2046
+ self._on_match_item_selected(self.all_matches[new_index], new_index)
2047
+ if 0 <= new_index < len(self.match_items):
2048
+ self.matches_scroll.ensureWidgetVisible(self.match_items[new_index])
2049
+
2050
+ def select_next_match(self):
2051
+ """Navigate to next match (Ctrl+Down from main window)"""
2052
+ if self.selected_index < len(self.all_matches) - 1:
2053
+ new_index = self.selected_index + 1
2054
+ self._on_match_item_selected(self.all_matches[new_index], new_index)
2055
+ # Scroll to make it visible
2056
+ if 0 <= new_index < len(self.match_items):
2057
+ self.matches_scroll.ensureWidgetVisible(self.match_items[new_index])
2058
+ elif self.selected_index == -1 and self.all_matches:
2059
+ # No selection, select first match
2060
+ new_index = 0
2061
+ self._on_match_item_selected(self.all_matches[new_index], new_index)
2062
+ if 0 <= new_index < len(self.match_items):
2063
+ self.matches_scroll.ensureWidgetVisible(self.match_items[new_index])
2064
+
2065
+ def insert_match_by_number(self, match_number: int):
2066
+ """Insert match by its number (1-based index) - for Ctrl+1-9 shortcuts"""
2067
+ if 0 < match_number <= len(self.all_matches):
2068
+ match = self.all_matches[match_number - 1]
2069
+ # Select it visually
2070
+ self._on_match_item_selected(match, match_number - 1)
2071
+ # Scroll to it
2072
+ if 0 <= match_number - 1 < len(self.match_items):
2073
+ self.matches_scroll.ensureWidgetVisible(self.match_items[match_number - 1])
2074
+ # Emit insert signal
2075
+ self.match_inserted.emit(match.target)
2076
+ return True
2077
+ return False
2078
+
2079
+ def insert_selected_match(self):
2080
+ """Insert currently selected match (Ctrl+Space)"""
2081
+ if self.current_selection:
2082
+ self.match_inserted.emit(self.current_selection.target)
2083
+ return True
2084
+ return False
2085
+
2086
+ def keyPressEvent(self, event):
2087
+ """
2088
+ Handle keyboard events for navigation and insertion
2089
+
2090
+ Shortcuts:
2091
+ - Up/Down arrows: Navigate matches (plain arrows, no Ctrl)
2092
+ - Spacebar: Insert selected match into target
2093
+ - Return/Enter: Insert selected match into target
2094
+ - Ctrl+Space: Insert selected match (alternative)
2095
+ - Ctrl+1 to Ctrl+9: Insert specific match by number (global)
2096
+
2097
+ Note: Ctrl+Up/Down are handled at main window level for grid navigation
2098
+ """
2099
+ # Ctrl+Space: Insert currently selected match
2100
+ if (event.modifiers() & Qt.KeyboardModifier.ControlModifier and
2101
+ event.key() == Qt.Key.Key_Space):
2102
+ if self.insert_selected_match():
2103
+ event.accept()
2104
+ return
2105
+
2106
+ # Ctrl+1 through Ctrl+9: Insert match by number
2107
+ if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
2108
+ if event.key() >= Qt.Key.Key_1 and event.key() <= Qt.Key.Key_9:
2109
+ match_num = event.key() - Qt.Key.Key_0 # Convert key to number
2110
+ if self.insert_match_by_number(match_num):
2111
+ event.accept()
2112
+ return
2113
+
2114
+ # Up/Down arrows: Navigate matches (plain arrows only, NOT Ctrl+Up/Down)
2115
+ if event.key() == Qt.Key.Key_Up:
2116
+ if not (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
2117
+ self.select_previous_match()
2118
+ event.accept()
2119
+ return
2120
+ elif event.key() == Qt.Key.Key_Down:
2121
+ if not (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
2122
+ self.select_next_match()
2123
+ event.accept()
2124
+ return
2125
+
2126
+ # Spacebar or Return/Enter: Insert selected match
2127
+ elif event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Space):
2128
+ if self.current_selection:
2129
+ self.match_inserted.emit(self.current_selection.target)
2130
+ event.accept()
2131
+ return
2132
+
2133
+ super().keyPressEvent(event)
2134
+