supervertaler 1.9.116__py3-none-any.whl → 1.9.130__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.
Supervertaler.py CHANGED
@@ -3,7 +3,7 @@ Supervertaler
3
3
  =============
4
4
  The Ultimate Translation Workbench.
5
5
  Modern PyQt6 interface with specialised modules to handle any problem.
6
- Version: 1.9.116 (Fix all tab navigation + startup tab)
6
+ Version: 1.9.123 (QuickMenu now supports generic AI tasks)
7
7
  Release Date: January 19, 2026
8
8
  Framework: PyQt6
9
9
 
@@ -34,9 +34,9 @@ License: MIT
34
34
  """
35
35
 
36
36
  # Version Information.
37
- __version__ = "1.9.116"
37
+ __version__ = "1.9.130"
38
38
  __phase__ = "0.9"
39
- __release_date__ = "2026-01-18"
39
+ __release_date__ = "2026-01-19"
40
40
  __edition__ = "Qt"
41
41
 
42
42
  import sys
@@ -123,7 +123,7 @@ try:
123
123
  QButtonGroup, QDialogButtonBox, QTabWidget, QGroupBox, QGridLayout, QCheckBox,
124
124
  QProgressBar, QProgressDialog, QFormLayout, QTabBar, QPlainTextEdit, QAbstractItemDelegate,
125
125
  QFrame, QListWidget, QListWidgetItem, QStackedWidget, QTreeWidget, QTreeWidgetItem,
126
- QScrollArea, QSizePolicy, QSlider, QToolButton
126
+ QScrollArea, QSizePolicy, QSlider, QToolButton, QAbstractItemView
127
127
  )
128
128
  from PyQt6.QtCore import Qt, QSize, QTimer, pyqtSignal, QObject, QUrl
129
129
  from PyQt6.QtGui import QFont, QAction, QKeySequence, QIcon, QTextOption, QColor, QDesktopServices, QTextCharFormat, QTextCursor, QBrush, QSyntaxHighlighter, QPalette, QTextBlockFormat
@@ -170,11 +170,11 @@ def cleanup_ahk_process():
170
170
  try:
171
171
  _ahk_process.terminate()
172
172
  _ahk_process.wait(timeout=1)
173
- print("[Superlookup] AHK process terminated on exit")
173
+ print("[Hotkeys] AHK process terminated on exit")
174
174
  except:
175
175
  try:
176
176
  _ahk_process.kill()
177
- print("[Superlookup] AHK process killed on exit")
177
+ print("[Hotkeys] AHK process killed on exit")
178
178
  except:
179
179
  pass
180
180
 
@@ -910,6 +910,7 @@ class _CtrlReturnEventFilter(QObject):
910
910
 
911
911
  return False
912
912
 
913
+
913
914
  class GridTableEventFilter:
914
915
  """Mixin to pass keyboard shortcuts from editor to table"""
915
916
  pass
@@ -2559,7 +2560,7 @@ class EditableGridTextEditor(QTextEdit):
2559
2560
  menu.addSeparator()
2560
2561
 
2561
2562
  # Add to dictionary action
2562
- add_to_dict_action = QAction(f"📖 Add '{misspelled_word}' to Dictionary", self)
2563
+ add_to_dict_action = QAction(f"📖 Add '{misspelled_word}' to Dictionary (Alt+D)", self)
2563
2564
  add_to_dict_action.triggered.connect(
2564
2565
  lambda checked, w=misspelled_word: self._add_to_dictionary(w)
2565
2566
  )
@@ -5592,6 +5593,11 @@ class SupervertalerQt(QMainWindow):
5592
5593
  self.termview_shortcuts = []
5593
5594
  self._termview_last_key = None # Track last key for double-tap detection
5594
5595
  self._termview_last_time = 0 # Track timing for double-tap
5596
+
5597
+ # Double-tap Shift detection for context menu
5598
+ self._shift_last_press_time = 0
5599
+ self._shift_double_tap_threshold = 0.35 # 350ms window
5600
+
5595
5601
  for i in range(0, 10): # 0-9
5596
5602
  shortcut_id = f"termview_insert_{i}"
5597
5603
  default_key = f"Alt+{i}"
@@ -5635,6 +5641,9 @@ class SupervertalerQt(QMainWindow):
5635
5641
  self._ctrl_return_event_filter = _CtrlReturnEventFilter(self)
5636
5642
  QApplication.instance().installEventFilter(self._ctrl_return_event_filter)
5637
5643
 
5644
+ # Note: Double-tap Shift for context menu is handled by AutoHotkey (double_shift_menu.ahk)
5645
+ # Qt's event system makes reliable double-tap detection difficult in Python
5646
+
5638
5647
  # Ctrl+Shift+Enter - Always confirm all selected segments
5639
5648
  create_shortcut("editor_confirm_selected", "Ctrl+Shift+Return", self.confirm_selected_segments)
5640
5649
 
@@ -5668,6 +5677,24 @@ class SupervertalerQt(QMainWindow):
5668
5677
 
5669
5678
  # Ctrl+Shift+2 - Quick add term with Priority 2
5670
5679
  create_shortcut("editor_quick_add_priority_2", "Ctrl+Shift+2", lambda: self._quick_add_term_with_priority(2))
5680
+
5681
+ # Alt+D - Add word at cursor to dictionary
5682
+ create_shortcut("editor_add_to_dictionary", "Alt+D", self.add_word_to_dictionary_shortcut)
5683
+
5684
+ # Ctrl+N - Focus Segment Note tab
5685
+ create_shortcut("editor_focus_notes", "Ctrl+N", self.focus_segment_notes)
5686
+
5687
+ def focus_segment_notes(self):
5688
+ """Switch to Segment Note tab and focus the notes editor so user can start typing immediately"""
5689
+ if not hasattr(self, 'bottom_tabs'):
5690
+ return
5691
+
5692
+ # Switch to Segment note tab (index 1)
5693
+ self.bottom_tabs.setCurrentIndex(1)
5694
+
5695
+ # Focus the notes editor so user can start typing
5696
+ if hasattr(self, 'bottom_notes_edit'):
5697
+ self.bottom_notes_edit.setFocus()
5671
5698
 
5672
5699
  def refresh_shortcut_enabled_states(self):
5673
5700
  """Refresh enabled/disabled states and key bindings of all global shortcuts from shortcut manager.
@@ -10836,6 +10863,38 @@ class SupervertalerQt(QMainWindow):
10836
10863
  self._play_sound_effect('glossary_term_error')
10837
10864
  self.statusBar().showMessage(f"Error adding term: {e}", 3000)
10838
10865
 
10866
+ def add_word_to_dictionary_shortcut(self):
10867
+ """Add word at cursor position to custom dictionary (Alt+D shortcut)
10868
+
10869
+ Finds the misspelled word at the current cursor position and adds it to the
10870
+ custom dictionary. Works when focus is in a grid target cell.
10871
+ """
10872
+ # Get currently focused widget
10873
+ focused_widget = QApplication.focusWidget()
10874
+
10875
+ # Check if we're in an editable grid cell
10876
+ if not isinstance(focused_widget, EditableGridTextEditor):
10877
+ self.statusBar().showMessage("Alt+D: Place cursor on a misspelled word in the target cell first", 3000)
10878
+ return
10879
+
10880
+ # Get cursor position
10881
+ cursor = focused_widget.textCursor()
10882
+
10883
+ # Try to find misspelled word at cursor
10884
+ word_info = focused_widget._get_misspelled_word_at_cursor(cursor)
10885
+
10886
+ if word_info[0] is None:
10887
+ # No misspelled word found at cursor
10888
+ self.statusBar().showMessage("No misspelled word at cursor position", 3000)
10889
+ return
10890
+
10891
+ word, start_pos, end_pos = word_info
10892
+
10893
+ # Add to dictionary
10894
+ focused_widget._add_to_dictionary(word)
10895
+
10896
+ # Status message already shown by _add_to_dictionary
10897
+
10839
10898
  def add_text_to_non_translatables(self, text: str):
10840
10899
  """Add selected text to active non-translatable list(s)"""
10841
10900
  if not text or not text.strip():
@@ -13956,6 +14015,45 @@ class SupervertalerQt(QMainWindow):
13956
14015
  full_context_cb.stateChanged.connect(lambda: context_slider.setEnabled(full_context_cb.isChecked()))
13957
14016
  update_context_label(context_window_size)
13958
14017
 
14018
+ prefs_layout.addSpacing(10)
14019
+
14020
+ # QuickMenu document context
14021
+ quickmenu_context_label = QLabel("<b>QuickMenu Document Context:</b>")
14022
+ prefs_layout.addWidget(quickmenu_context_label)
14023
+
14024
+ quickmenu_context_percent = general_prefs.get('quickmenu_context_percent', 50)
14025
+ quickmenu_context_layout = QHBoxLayout()
14026
+ quickmenu_context_layout.addWidget(QLabel(" Document context size:"))
14027
+ quickmenu_context_slider = QSlider(Qt.Orientation.Horizontal)
14028
+ quickmenu_context_slider.setMinimum(0)
14029
+ quickmenu_context_slider.setMaximum(100)
14030
+ quickmenu_context_slider.setValue(quickmenu_context_percent)
14031
+ quickmenu_context_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
14032
+ quickmenu_context_slider.setTickInterval(10)
14033
+ quickmenu_context_layout.addWidget(quickmenu_context_slider)
14034
+ quickmenu_context_value_label = QLabel(f"{quickmenu_context_percent}%")
14035
+ quickmenu_context_value_label.setMinimumWidth(60)
14036
+ quickmenu_context_layout.addWidget(quickmenu_context_value_label)
14037
+ quickmenu_context_layout.addStretch()
14038
+ prefs_layout.addLayout(quickmenu_context_layout)
14039
+
14040
+ quickmenu_context_info = QLabel(
14041
+ " ⓘ When using {{SOURCE+TARGET_CONTEXT}} or {{SOURCE_CONTEXT}} placeholders in QuickMenu prompts.\n"
14042
+ " 0% = disabled, 50% = half the document (default), 100% = entire document.\n"
14043
+ " Limit: maximum 100 segments for performance."
14044
+ )
14045
+ quickmenu_context_info.setStyleSheet("font-size: 9pt; color: #666; padding-left: 20px;")
14046
+ quickmenu_context_info.setWordWrap(True)
14047
+ prefs_layout.addWidget(quickmenu_context_info)
14048
+
14049
+ def update_quickmenu_context_label(value):
14050
+ if value == 0:
14051
+ quickmenu_context_value_label.setText("0% (disabled)")
14052
+ else:
14053
+ quickmenu_context_value_label.setText(f"{value}%")
14054
+
14055
+ quickmenu_context_slider.valueChanged.connect(update_quickmenu_context_label)
14056
+
13959
14057
  prefs_layout.addSpacing(5)
13960
14058
 
13961
14059
  # Check TM before API call
@@ -14100,7 +14198,8 @@ class SupervertalerQt(QMainWindow):
14100
14198
  batch_size_spin, surrounding_spin, full_context_cb, context_slider,
14101
14199
  check_tm_cb, auto_propagate_cb, delay_spin,
14102
14200
  ollama_keepwarm_cb,
14103
- llm_matching_cb, auto_markdown_cb, llm_spin
14201
+ llm_matching_cb, auto_markdown_cb, llm_spin,
14202
+ quickmenu_context_slider
14104
14203
  ))
14105
14204
  layout.addWidget(save_btn)
14106
14205
 
@@ -17098,7 +17197,8 @@ class SupervertalerQt(QMainWindow):
17098
17197
  batch_size_spin, surrounding_spin, full_context_cb, context_slider,
17099
17198
  check_tm_cb, auto_propagate_cb, delay_spin,
17100
17199
  ollama_keepwarm_cb,
17101
- llm_matching_cb, auto_markdown_cb, llm_spin):
17200
+ llm_matching_cb, auto_markdown_cb, llm_spin,
17201
+ quickmenu_context_slider):
17102
17202
  """Save all AI settings from the unified AI Settings tab"""
17103
17203
  # Determine selected provider
17104
17204
  if openai_radio.isChecked():
@@ -17157,6 +17257,7 @@ class SupervertalerQt(QMainWindow):
17157
17257
  general_prefs['surrounding_segments'] = surrounding_spin.value()
17158
17258
  general_prefs['use_full_context'] = full_context_cb.isChecked()
17159
17259
  general_prefs['context_window_size'] = context_slider.value()
17260
+ general_prefs['quickmenu_context_percent'] = quickmenu_context_slider.value()
17160
17261
  general_prefs['check_tm_before_api'] = check_tm_cb.isChecked()
17161
17262
  general_prefs['auto_propagate_100'] = auto_propagate_cb.isChecked()
17162
17263
  general_prefs['lookup_delay'] = delay_spin.value()
@@ -28206,6 +28307,20 @@ class SupervertalerQt(QMainWindow):
28206
28307
  # Full processing for non-arrow-key navigation (click, etc.)
28207
28308
  self._on_cell_selected_full(current_row, current_col, previous_row, previous_col)
28208
28309
 
28310
+ def _center_row_in_viewport(self, row: int):
28311
+ """Center the given row vertically in the visible table viewport.
28312
+
28313
+ Uses Qt's built-in scrollTo() with PositionAtCenter hint.
28314
+ """
28315
+ if row < 0 or row >= self.table.rowCount():
28316
+ return
28317
+
28318
+ # Get the model index for any cell in this row (use column 0)
28319
+ index = self.table.model().index(row, 0)
28320
+ if index.isValid():
28321
+ # Use Qt's built-in centering - PositionAtCenter puts the item in the center of the viewport
28322
+ self.table.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter)
28323
+
28209
28324
  def _on_cell_selected_minimal(self, current_row, previous_row):
28210
28325
  """Minimal UI update for fast arrow key navigation - just highlight and scroll"""
28211
28326
  try:
@@ -28227,7 +28342,7 @@ class SupervertalerQt(QMainWindow):
28227
28342
 
28228
28343
  # Auto-center if enabled
28229
28344
  if getattr(self, 'auto_center_active_segment', False) and not getattr(self, 'filtering_active', False):
28230
- self.table.scrollToItem(current_id_item, QTableWidget.ScrollHint.PositionAtCenter)
28345
+ self._center_row_in_viewport(current_row)
28231
28346
  except Exception as e:
28232
28347
  if self.debug_mode_enabled:
28233
28348
  self.log(f"Error in minimal cell selection: {e}")
@@ -28272,7 +28387,7 @@ class SupervertalerQt(QMainWindow):
28272
28387
  current_id_item.setForeground(QColor("white"))
28273
28388
 
28274
28389
  if getattr(self, 'auto_center_active_segment', False) and not getattr(self, 'filtering_active', False):
28275
- self.table.scrollToItem(current_id_item, QTableWidget.ScrollHint.PositionAtCenter)
28390
+ self._center_row_in_viewport(current_row)
28276
28391
 
28277
28392
  if not self.current_project or current_row < 0:
28278
28393
  return
@@ -31042,11 +31157,18 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31042
31157
  normalized_source = clean_source_lower
31043
31158
  for quote_char in '\"\'\u201C\u201D\u201E\u00AB\u00BB\u2018\u2019\u201A\u2039\u203A':
31044
31159
  normalized_source = normalized_source.replace(quote_char, ' ')
31160
+
31161
+ # CRITICAL FIX: Strip trailing punctuation from glossary term before matching
31162
+ # This allows entries like "De huidige uitvinding...problemen." (with period)
31163
+ # to match source text "...problemen." where tokenization strips the period
31164
+ # Strip from both ends to handle quotes and trailing punctuation
31165
+ normalized_term = source_term.lower().rstrip(PUNCT_CHARS).lstrip(PUNCT_CHARS)
31166
+
31045
31167
  # Check if term has punctuation - use different pattern
31046
- if any(char in source_term for char in ['.', '%', ',', '-', '/']):
31047
- pattern = re.compile(r'(?<!\w)' + re.escape(source_term.lower()) + r'(?!\w)')
31168
+ if any(char in normalized_term for char in ['.', '%', ',', '-', '/']):
31169
+ pattern = re.compile(r'(?<!\w)' + re.escape(normalized_term) + r'(?!\w)')
31048
31170
  else:
31049
- pattern = re.compile(r"\b" + re.escape(source_term.lower()) + r"\b")
31171
+ pattern = re.compile(r"\b" + re.escape(normalized_term) + r"\b")
31050
31172
 
31051
31173
  # Try matching on normalized (tag-stripped, quote-stripped) text first,
31052
31174
  # then tag-stripped, then original with tags
@@ -31503,7 +31625,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31503
31625
  self.case_sensitive_cb.setChecked(op.case_sensitive)
31504
31626
 
31505
31627
  def _fr_run_set_batch(self, fr_set: FindReplaceSet):
31506
- """Run all enabled operations in a F&R Set as a batch."""
31628
+ """Run all enabled operations in a F&R Set as a batch (optimized for speed)."""
31507
31629
  enabled_ops = [op for op in fr_set.operations if op.enabled and op.find_text]
31508
31630
 
31509
31631
  if not enabled_ops:
@@ -31522,17 +31644,32 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31522
31644
  if reply != QMessageBox.StandardButton.Yes:
31523
31645
  return
31524
31646
 
31525
- # Run each operation
31526
- total_replaced = 0
31527
- for op in enabled_ops:
31528
- count = self._execute_single_fr_operation(op)
31529
- total_replaced += count
31530
- self.log(f" '{op.find_text}' → '{op.replace_text}': {count} replacement(s)")
31647
+ # OPTIMIZATION: Disable UI updates during batch processing
31648
+ self.table.setUpdatesEnabled(False)
31531
31649
 
31532
- # Refresh grid
31533
- self.load_segments_to_grid()
31534
- self.project_modified = True
31535
- self.update_window_title()
31650
+ try:
31651
+ # Run each operation
31652
+ total_replaced = 0
31653
+ for op in enabled_ops:
31654
+ count = self._execute_single_fr_operation(op)
31655
+ total_replaced += count
31656
+ self.log(f" '{op.find_text}' → '{op.replace_text}': {count} replacement(s)")
31657
+
31658
+ self.project_modified = True
31659
+ self.update_window_title()
31660
+
31661
+ finally:
31662
+ # OPTIMIZATION: Re-enable UI updates and refresh only target column cells
31663
+ self.table.setUpdatesEnabled(True)
31664
+ # Update all target cells in-place (batch operations can affect many segments)
31665
+ for row in range(len(self.current_project.segments)):
31666
+ segment = self.current_project.segments[row]
31667
+ target_widget = self.table.cellWidget(row, 3)
31668
+ if target_widget and hasattr(target_widget, 'setPlainText'):
31669
+ target_widget.blockSignals(True)
31670
+ target_widget.setPlainText(segment.target)
31671
+ target_widget.blockSignals(False)
31672
+ self.table.viewport().update()
31536
31673
 
31537
31674
  QMessageBox.information(
31538
31675
  self.find_replace_dialog,
@@ -31541,12 +31678,30 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31541
31678
  )
31542
31679
 
31543
31680
  def _execute_single_fr_operation(self, op: FindReplaceOperation) -> int:
31544
- """Execute a single F&R operation on all segments. Returns replacement count."""
31681
+ """Execute a single F&R operation on all segments (optimized). Returns replacement count."""
31545
31682
  import re
31546
31683
  count = 0
31547
31684
 
31685
+ # OPTIMIZATION: Pre-filter segments - only check segments that might contain the text
31686
+ # Quick case-insensitive check to skip segments that definitely don't match
31687
+ search_text_lower = op.find_text.lower() if not op.case_sensitive else None
31688
+
31548
31689
  for segment in self.current_project.segments:
31549
31690
  texts_to_check = []
31691
+
31692
+ # Pre-filter: skip segments that can't possibly match
31693
+ if not op.case_sensitive:
31694
+ # Quick check: does the segment contain the search text at all?
31695
+ skip_segment = True
31696
+ if op.search_in in ("source", "both") and self.allow_replace_in_source:
31697
+ if search_text_lower in segment.source.lower():
31698
+ skip_segment = False
31699
+ if op.search_in in ("target", "both"):
31700
+ if search_text_lower in segment.target.lower():
31701
+ skip_segment = False
31702
+ if skip_segment:
31703
+ continue
31704
+
31550
31705
  if op.search_in in ("source", "both") and self.allow_replace_in_source:
31551
31706
  texts_to_check.append(("source", segment.source))
31552
31707
  if op.search_in in ("target", "both"):
@@ -31842,7 +31997,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31842
31997
  self.find_next_match()
31843
31998
 
31844
31999
  def replace_all_matches(self):
31845
- """Replace all matches in target segments"""
32000
+ """Replace all matches in target segments (optimized for speed)"""
31846
32001
  find_text = self.find_input.text()
31847
32002
  replace_text = self.replace_input.text()
31848
32003
 
@@ -31915,52 +32070,67 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31915
32070
  if reply != QMessageBox.StandardButton.Yes:
31916
32071
  return
31917
32072
 
31918
- # Perform replacements
31919
- import re
31920
- replaced_count = 0
32073
+ # OPTIMIZATION: Disable UI updates during batch replacement
32074
+ self.table.setUpdatesEnabled(False)
31921
32075
 
31922
- for row, col in self.find_matches:
31923
- segment = self.current_project.segments[row]
31924
-
31925
- # Get the appropriate field
31926
- if col == 2: # Source
31927
- old_text = segment.source
31928
- else: # col == 3, Target
31929
- old_text = segment.target
32076
+ try:
32077
+ # Perform replacements
32078
+ import re
32079
+ replaced_count = 0
32080
+ updated_rows = set() # Track which rows need UI updates
31930
32081
 
31931
- # Perform replacement
31932
- if match_mode == 2: # Entire segment
31933
- new_text = replace_text
31934
- else:
31935
- if case_sensitive:
31936
- new_text = old_text.replace(find_text, replace_text)
31937
- else:
31938
- pattern = re.escape(find_text)
31939
- new_text = re.sub(pattern, replace_text, old_text, flags=re.IGNORECASE)
31940
-
31941
- if new_text != old_text:
31942
- replaced_count += 1
31943
- # Update the appropriate field
31944
- if col == 2:
31945
- segment.source = new_text
32082
+ for row, col in self.find_matches:
32083
+ segment = self.current_project.segments[row]
32084
+
32085
+ # Get the appropriate field
32086
+ if col == 2: # Source
32087
+ old_text = segment.source
32088
+ else: # col == 3, Target
32089
+ old_text = segment.target
32090
+
32091
+ # Perform replacement
32092
+ if match_mode == 2: # Entire segment
32093
+ new_text = replace_text
31946
32094
  else:
31947
- old_target = segment.target
31948
- old_status = segment.status
31949
- segment.target = new_text
31950
- # Record undo state for find/replace operation
31951
- self.record_undo_state(segment.id, old_target, new_text, old_status, old_status)
32095
+ if case_sensitive:
32096
+ new_text = old_text.replace(find_text, replace_text)
32097
+ else:
32098
+ pattern = re.escape(find_text)
32099
+ new_text = re.sub(pattern, replace_text, old_text, flags=re.IGNORECASE)
31952
32100
 
31953
- # Update table
31954
- item = self.table.item(row, col)
31955
- if item:
31956
- item.setText(new_text)
31957
-
31958
- self.project_modified = True
31959
- self.update_window_title()
31960
-
31961
- # Clear matches and reload
31962
- self.find_matches = []
31963
- self.load_segments_to_grid()
32101
+ if new_text != old_text:
32102
+ replaced_count += 1
32103
+ updated_rows.add(row)
32104
+
32105
+ # Update the appropriate field
32106
+ if col == 2:
32107
+ segment.source = new_text
32108
+ else:
32109
+ old_target = segment.target
32110
+ old_status = segment.status
32111
+ segment.target = new_text
32112
+ # Record undo state for find/replace operation
32113
+ self.record_undo_state(segment.id, old_target, new_text, old_status, old_status)
32114
+
32115
+ # OPTIMIZATION: Update only the affected cell widget in-place
32116
+ cell_widget = self.table.cellWidget(row, col)
32117
+ if cell_widget and hasattr(cell_widget, 'setPlainText'):
32118
+ cell_widget.blockSignals(True)
32119
+ cell_widget.setPlainText(new_text)
32120
+ cell_widget.blockSignals(False)
32121
+
32122
+ self.project_modified = True
32123
+ self.update_window_title()
32124
+
32125
+ # Clear matches
32126
+ self.find_matches = []
32127
+
32128
+ finally:
32129
+ # OPTIMIZATION: Re-enable UI updates without full grid reload
32130
+ self.table.setUpdatesEnabled(True)
32131
+ # Trigger a repaint of updated rows only
32132
+ for row in updated_rows:
32133
+ self.table.viewport().update()
31964
32134
 
31965
32135
  QMessageBox.information(self.find_replace_dialog, "Replace All", f"Replaced {replaced_count} occurrence(s).")
31966
32136
  self.log(f"✓ Replaced {replaced_count} occurrence(s) of '{find_text}'")
@@ -37564,7 +37734,19 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
37564
37734
  return text.strip()
37565
37735
 
37566
37736
  def _quickmenu_build_custom_prompt(self, prompt_relative_path: str, source_text: str, source_lang: str, target_lang: str) -> str:
37567
- """Build a complete translation prompt using the chosen QuickMenu prompt as PRIMARY instructions."""
37737
+ """Build a prompt for QuickMenu using the selected prompt as instructions.
37738
+
37739
+ This is a GENERIC prompt builder (not translation-specific) that allows QuickMenu prompts
37740
+ to do anything: explain, define, search, translate, analyze, etc.
37741
+
37742
+ Supports placeholders:
37743
+ - {{SELECTION}} or {{SOURCE_TEXT}} - The selected text
37744
+ - {{SOURCE_LANGUAGE}} - Source language name
37745
+ - {{TARGET_LANGUAGE}} - Target language name
37746
+ - {{SOURCE+TARGET_CONTEXT}} - Project segments with both source and target (for proofreading)
37747
+ - {{SOURCE_CONTEXT}} - Project segments with source only (for translation questions)
37748
+ - {{TARGET_CONTEXT}} - Project segments with target only (for consistency/style analysis)
37749
+ """
37568
37750
  if not hasattr(self, 'prompt_manager_qt') or not self.prompt_manager_qt:
37569
37751
  raise RuntimeError("Prompt manager not available")
37570
37752
 
@@ -37581,25 +37763,114 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
37581
37763
  if not prompt_content:
37582
37764
  raise RuntimeError("Prompt content is empty")
37583
37765
 
37584
- mode = getattr(pm, 'current_mode', None) or "single"
37585
- system_template = pm.get_system_template(mode)
37586
-
37587
- system_template = system_template.replace("{{SOURCE_LANGUAGE}}", source_lang)
37588
- system_template = system_template.replace("{{TARGET_LANGUAGE}}", target_lang)
37589
- system_template = system_template.replace("{{SOURCE_TEXT}}", source_text)
37590
-
37591
- library_prompts = "\n\n# PRIMARY INSTRUCTIONS\n\n" + prompt_content
37592
-
37593
- # Keep any globally attached prompts as additional instructions
37766
+ # Build document context if requested (three variants)
37767
+ source_target_context = ""
37768
+ source_only_context = ""
37769
+ target_only_context = ""
37770
+
37771
+ has_source_target = "{{SOURCE+TARGET_CONTEXT}}" in prompt_content
37772
+ has_source_only = "{{SOURCE_CONTEXT}}" in prompt_content
37773
+ has_target_only = "{{TARGET_CONTEXT}}" in prompt_content
37774
+
37775
+ if (has_source_target or has_source_only or has_target_only) and hasattr(self, 'current_project') and self.current_project:
37776
+ if has_source_target:
37777
+ source_target_context = self._build_quickmenu_document_context(mode="both")
37778
+ self.log(f"🔍 QuickMenu: Built SOURCE+TARGET context ({len(source_target_context)} chars)")
37779
+ if has_source_only:
37780
+ source_only_context = self._build_quickmenu_document_context(mode="source")
37781
+ self.log(f"🔍 QuickMenu: Built SOURCE_ONLY context ({len(source_only_context)} chars)")
37782
+ if has_target_only:
37783
+ target_only_context = self._build_quickmenu_document_context(mode="target")
37784
+ self.log(f"🔍 QuickMenu: Built TARGET_ONLY context ({len(target_only_context)} chars)")
37785
+ else:
37786
+ if has_source_target:
37787
+ self.log("⚠️ QuickMenu: {{SOURCE+TARGET_CONTEXT}} requested but no project loaded")
37788
+ if has_source_only:
37789
+ self.log("⚠️ QuickMenu: {{SOURCE_CONTEXT}} requested but no project loaded")
37790
+ if has_target_only:
37791
+ self.log("⚠️ QuickMenu: {{TARGET_CONTEXT}} requested but no project loaded")
37792
+
37793
+ # Replace placeholders in the prompt content
37794
+ prompt_content = prompt_content.replace("{{SOURCE_LANGUAGE}}", source_lang)
37795
+ prompt_content = prompt_content.replace("{{TARGET_LANGUAGE}}", target_lang)
37796
+ prompt_content = prompt_content.replace("{{SOURCE_TEXT}}", source_text)
37797
+ prompt_content = prompt_content.replace("{{SELECTION}}", source_text) # Alternative placeholder
37798
+ prompt_content = prompt_content.replace("{{SOURCE+TARGET_CONTEXT}}", source_target_context)
37799
+ prompt_content = prompt_content.replace("{{SOURCE_CONTEXT}}", source_only_context)
37800
+ prompt_content = prompt_content.replace("{{TARGET_CONTEXT}}", target_only_context)
37801
+
37802
+ # Debug: Log the final prompt being sent
37803
+ self.log(f"📝 QuickMenu: Final prompt ({len(prompt_content)} chars):")
37804
+ self.log("─" * 80)
37805
+ self.log(prompt_content[:500] + ("..." if len(prompt_content) > 500 else ""))
37806
+ self.log("─" * 80)
37807
+
37808
+ # If the prompt doesn't contain the selection/text, append it
37809
+ if "{{SOURCE_TEXT}}" not in prompt_data.get('content', '') and "{{SELECTION}}" not in prompt_data.get('content', ''):
37810
+ prompt_content += f"\n\nText:\n{source_text}"
37811
+
37812
+ return prompt_content
37813
+
37814
+ def _build_quickmenu_document_context(self, mode: str = "both") -> str:
37815
+ """Build document context for QuickMenu prompts.
37816
+
37817
+ Args:
37818
+ mode: One of:
37819
+ - "both": Include both source and target text (for proofreading)
37820
+ - "source": Include only source text (for translation/terminology questions)
37821
+ - "target": Include only target text (for consistency/style analysis)
37822
+
37823
+ Returns a formatted string with segments from the project for context.
37824
+ Uses the 'quickmenu_context_segments' setting (default: 50% of total segments).
37825
+ """
37826
+ if not hasattr(self, 'current_project') or not self.current_project or not self.current_project.segments:
37827
+ return "(No project context available)"
37828
+
37594
37829
  try:
37595
- for attached_content in lib.attached_prompts:
37596
- library_prompts += "\n\n# ADDITIONAL INSTRUCTIONS\n\n" + (attached_content or "")
37597
- except Exception:
37598
- pass
37599
-
37600
- final_prompt = system_template + library_prompts
37601
- final_prompt += "\n\n**YOUR TRANSLATION (provide ONLY the translated text, no numbering or labels):**\n"
37602
- return final_prompt
37830
+ # Get settings
37831
+ general_prefs = self.load_general_settings()
37832
+ context_percent = general_prefs.get('quickmenu_context_percent', 50) # Default: 50%
37833
+ max_context_segments = general_prefs.get('quickmenu_context_max', 100) # Safety limit
37834
+
37835
+ # Calculate how many segments to include
37836
+ total_segments = len(self.current_project.segments)
37837
+ num_segments = min(
37838
+ int(total_segments * context_percent / 100),
37839
+ max_context_segments
37840
+ )
37841
+
37842
+ if num_segments == 0:
37843
+ return "(Document context disabled)"
37844
+
37845
+ # Get segments (from start of document)
37846
+ context_segments = self.current_project.segments[:num_segments]
37847
+
37848
+ # Format segments based on mode
37849
+ context_parts = []
37850
+ mode_labels = {"both": "SOURCE + TARGET", "source": "SOURCE ONLY", "target": "TARGET ONLY"}
37851
+ context_parts.append(f"=== DOCUMENT CONTEXT ({mode_labels.get(mode, 'UNKNOWN')}) ===")
37852
+ context_parts.append(f"(Showing {num_segments} of {total_segments} segments - {context_percent}%)")
37853
+ context_parts.append("")
37854
+
37855
+ for seg in context_segments:
37856
+ if mode == "both":
37857
+ # Source + Target
37858
+ context_parts.append(f"[{seg.id}] {seg.source}")
37859
+ if seg.target and seg.target.strip():
37860
+ context_parts.append(f" → {seg.target}")
37861
+ elif mode == "source":
37862
+ # Source only
37863
+ context_parts.append(f"[{seg.id}] {seg.source}")
37864
+ elif mode == "target":
37865
+ # Target only (skip empty targets)
37866
+ if seg.target and seg.target.strip():
37867
+ context_parts.append(f"[{seg.id}] {seg.target}")
37868
+ context_parts.append("") # Blank line between segments
37869
+
37870
+ return "\n".join(context_parts)
37871
+
37872
+ except Exception as e:
37873
+ return f"(Error building document context: {e})"
37603
37874
 
37604
37875
  def _quickmenu_show_result_dialog(self, title: str, output_text: str, apply_callback=None):
37605
37876
  from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox, QPushButton, QApplication
@@ -37679,10 +37950,14 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
37679
37950
  )
37680
37951
 
37681
37952
  client = LLMClient(api_key=api_key, provider=provider, model=model)
37953
+
37954
+ # Use translate() with empty text and custom_prompt for generic AI completion
37955
+ # This allows QuickMenu prompts to do anything (explain, define, search, etc.)
37956
+ # not just translation. Same pattern as AI Assistant.
37682
37957
  output_text = client.translate(
37683
- text=input_text,
37684
- source_lang=source_lang,
37685
- target_lang=target_lang,
37958
+ text="", # Empty - we're using custom_prompt for everything
37959
+ source_lang="en", # Dummy values
37960
+ target_lang="en",
37686
37961
  custom_prompt=custom_prompt
37687
37962
  )
37688
37963
 
@@ -44268,7 +44543,7 @@ class SuperlookupTab(QWidget):
44268
44543
  try:
44269
44544
  # Use multiple methods to ensure cleanup
44270
44545
  # Method 1: Kill by window title
44271
- subprocess.run(['taskkill', '/F', '/FI', 'WINDOWTITLE eq superlookup_hotkey.ahk*'],
44546
+ subprocess.run(['taskkill', '/F', '/FI', 'WINDOWTITLE eq supervertaler_hotkeys.ahk*'],
44272
44547
  capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
44273
44548
 
44274
44549
  # Method 2: Kill AutoHotkey processes more aggressively
@@ -44280,7 +44555,7 @@ class SuperlookupTab(QWidget):
44280
44555
  try:
44281
44556
  for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
44282
44557
  try:
44283
- if 'superlookup_hotkey' in ' '.join(proc.cmdline() or []):
44558
+ if 'supervertaler_hotkeys' in ' '.join(proc.cmdline() or []):
44284
44559
  proc.kill()
44285
44560
  except:
44286
44561
  pass
@@ -44293,8 +44568,8 @@ class SuperlookupTab(QWidget):
44293
44568
  ahk_exe, source = self._find_autohotkey_executable()
44294
44569
 
44295
44570
  if not ahk_exe:
44296
- print("[Superlookup] AutoHotkey not found.")
44297
- print("[Superlookup] Global hotkey (Ctrl+Alt+L) will not be available.")
44571
+ print("[Hotkeys] AutoHotkey not found.")
44572
+ print("[Hotkeys] Global hotkeys (Ctrl+Alt+L, Shift+Shift) will not be available.")
44298
44573
  self.hotkey_registered = False
44299
44574
  # Show setup dialog (deferred to avoid blocking startup) - unless user opted out
44300
44575
  if self.main_window and hasattr(self.main_window, 'general_settings'):
@@ -44304,11 +44579,11 @@ class SuperlookupTab(QWidget):
44304
44579
  QTimer.singleShot(2000, self._show_autohotkey_setup_dialog)
44305
44580
  return
44306
44581
 
44307
- print(f"[Superlookup] Found AutoHotkey at: {ahk_exe} (source: {source})")
44582
+ print(f"[Hotkeys] Found AutoHotkey at: {ahk_exe} (source: {source})")
44308
44583
 
44309
- ahk_script = Path(__file__).parent / "superlookup_hotkey.ahk"
44310
- print(f"[Superlookup] Looking for script at: {ahk_script}")
44311
- print(f"[Superlookup] Script exists: {ahk_script.exists()}")
44584
+ ahk_script = Path(__file__).parent / "supervertaler_hotkeys.ahk"
44585
+ print(f"[Hotkeys] Looking for script at: {ahk_script}")
44586
+ print(f"[Hotkeys] Script exists: {ahk_script.exists()}")
44312
44587
 
44313
44588
  if ahk_script.exists():
44314
44589
  # Start AHK script in background (hidden)
@@ -44316,16 +44591,16 @@ class SuperlookupTab(QWidget):
44316
44591
  creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
44317
44592
  # Store in global variable for atexit cleanup
44318
44593
  _ahk_process = self.ahk_process
44319
- print(f"[Superlookup] AHK hotkey registered: Ctrl+Alt+L")
44594
+ print(f"[Hotkeys] AHK hotkeys registered (Ctrl+Alt+L, Shift+Shift)")
44320
44595
 
44321
44596
  # Start file watcher
44322
44597
  self.start_file_watcher()
44323
44598
  self.hotkey_registered = True
44324
44599
  else:
44325
- print(f"[Superlookup] AHK script not found: {ahk_script}")
44600
+ print(f"[Hotkeys] AHK script not found: {ahk_script}")
44326
44601
  self.hotkey_registered = False
44327
44602
  except Exception as e:
44328
- print(f"[Superlookup] Could not start AHK hotkey: {e}")
44603
+ print(f"[Hotkeys] Could not start AHK hotkeys: {e}")
44329
44604
  self.hotkey_registered = False
44330
44605
 
44331
44606
  def start_file_watcher(self):