supervertaler 1.9.116__py3-none-any.whl → 1.9.131__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.131"
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,10 +123,10 @@ 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
- from PyQt6.QtGui import QFont, QAction, QKeySequence, QIcon, QTextOption, QColor, QDesktopServices, QTextCharFormat, QTextCursor, QBrush, QSyntaxHighlighter, QPalette, QTextBlockFormat
129
+ from PyQt6.QtGui import QFont, QAction, QKeySequence, QIcon, QTextOption, QColor, QDesktopServices, QTextCharFormat, QTextCursor, QBrush, QSyntaxHighlighter, QPalette, QTextBlockFormat, QCursor
130
130
  from PyQt6.QtWidgets import QStyleOptionViewItem, QStyle
131
131
  from PyQt6.QtCore import QRectF
132
132
  from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
@@ -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,84 @@ 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
+ # Alt+K - Open QuickMenu directly
5688
+ create_shortcut("editor_open_quickmenu", "Alt+K", self.open_quickmenu)
5689
+
5690
+ def focus_segment_notes(self):
5691
+ """Switch to Segment Note tab and focus the notes editor so user can start typing immediately"""
5692
+ if not hasattr(self, 'bottom_tabs'):
5693
+ return
5694
+
5695
+ # Switch to Segment note tab (index 1)
5696
+ self.bottom_tabs.setCurrentIndex(1)
5697
+
5698
+ # Focus the notes editor so user can start typing
5699
+ if hasattr(self, 'bottom_notes_edit'):
5700
+ self.bottom_notes_edit.setFocus()
5701
+
5702
+ def open_quickmenu(self):
5703
+ """Open QuickMenu popup at current cursor position for quick AI prompt selection.
5704
+
5705
+ User can navigate with arrow keys and press Enter to select a prompt.
5706
+ """
5707
+ try:
5708
+ # Get QuickMenu items from prompt library
5709
+ quickmenu_items = []
5710
+ if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
5711
+ lib = getattr(self.prompt_manager_qt, 'library', None)
5712
+ if lib and hasattr(lib, 'get_quickmenu_grid_prompts'):
5713
+ quickmenu_items = lib.get_quickmenu_grid_prompts() or []
5714
+
5715
+ if not quickmenu_items:
5716
+ self.log("⚠️ No QuickMenu prompts available. Add prompts with 'Show in Supervertaler QuickMenu' enabled.")
5717
+ return
5718
+
5719
+ # Find the currently focused widget (source or target cell)
5720
+ focus_widget = QApplication.focusWidget()
5721
+
5722
+ # Build the menu
5723
+ menu = QMenu(self)
5724
+ menu.setTitle("⚡ QuickMenu")
5725
+
5726
+ for rel_path, label in sorted(quickmenu_items, key=lambda x: (x[1] or x[0]).lower()):
5727
+ prompt_menu = menu.addMenu(label or rel_path)
5728
+
5729
+ run_show = QAction("▶ Run (show response)…", self)
5730
+ run_show.triggered.connect(
5731
+ lambda checked=False, p=rel_path, w=focus_widget: self.run_grid_quickmenu_prompt(p, origin_widget=w, behavior="show")
5732
+ )
5733
+ prompt_menu.addAction(run_show)
5734
+
5735
+ run_replace = QAction("↺ Run and replace target selection", self)
5736
+ run_replace.triggered.connect(
5737
+ lambda checked=False, p=rel_path, w=focus_widget: self.run_grid_quickmenu_prompt(p, origin_widget=w, behavior="replace")
5738
+ )
5739
+ prompt_menu.addAction(run_replace)
5740
+
5741
+ # Show menu at cursor position (or center of focused widget)
5742
+ if focus_widget:
5743
+ # Get cursor rectangle if it's a text editor
5744
+ if hasattr(focus_widget, 'cursorRect'):
5745
+ cursor_rect = focus_widget.cursorRect()
5746
+ pos = focus_widget.mapToGlobal(cursor_rect.bottomLeft())
5747
+ else:
5748
+ # Fallback to center of widget
5749
+ pos = focus_widget.mapToGlobal(focus_widget.rect().center())
5750
+ else:
5751
+ # Fallback to mouse cursor position
5752
+ pos = QCursor.pos()
5753
+
5754
+ menu.exec(pos)
5755
+
5756
+ except Exception as e:
5757
+ self.log(f"❌ Error opening QuickMenu: {e}")
5671
5758
 
5672
5759
  def refresh_shortcut_enabled_states(self):
5673
5760
  """Refresh enabled/disabled states and key bindings of all global shortcuts from shortcut manager.
@@ -10836,6 +10923,38 @@ class SupervertalerQt(QMainWindow):
10836
10923
  self._play_sound_effect('glossary_term_error')
10837
10924
  self.statusBar().showMessage(f"Error adding term: {e}", 3000)
10838
10925
 
10926
+ def add_word_to_dictionary_shortcut(self):
10927
+ """Add word at cursor position to custom dictionary (Alt+D shortcut)
10928
+
10929
+ Finds the misspelled word at the current cursor position and adds it to the
10930
+ custom dictionary. Works when focus is in a grid target cell.
10931
+ """
10932
+ # Get currently focused widget
10933
+ focused_widget = QApplication.focusWidget()
10934
+
10935
+ # Check if we're in an editable grid cell
10936
+ if not isinstance(focused_widget, EditableGridTextEditor):
10937
+ self.statusBar().showMessage("Alt+D: Place cursor on a misspelled word in the target cell first", 3000)
10938
+ return
10939
+
10940
+ # Get cursor position
10941
+ cursor = focused_widget.textCursor()
10942
+
10943
+ # Try to find misspelled word at cursor
10944
+ word_info = focused_widget._get_misspelled_word_at_cursor(cursor)
10945
+
10946
+ if word_info[0] is None:
10947
+ # No misspelled word found at cursor
10948
+ self.statusBar().showMessage("No misspelled word at cursor position", 3000)
10949
+ return
10950
+
10951
+ word, start_pos, end_pos = word_info
10952
+
10953
+ # Add to dictionary
10954
+ focused_widget._add_to_dictionary(word)
10955
+
10956
+ # Status message already shown by _add_to_dictionary
10957
+
10839
10958
  def add_text_to_non_translatables(self, text: str):
10840
10959
  """Add selected text to active non-translatable list(s)"""
10841
10960
  if not text or not text.strip():
@@ -13956,6 +14075,45 @@ class SupervertalerQt(QMainWindow):
13956
14075
  full_context_cb.stateChanged.connect(lambda: context_slider.setEnabled(full_context_cb.isChecked()))
13957
14076
  update_context_label(context_window_size)
13958
14077
 
14078
+ prefs_layout.addSpacing(10)
14079
+
14080
+ # QuickMenu document context
14081
+ quickmenu_context_label = QLabel("<b>QuickMenu Document Context:</b>")
14082
+ prefs_layout.addWidget(quickmenu_context_label)
14083
+
14084
+ quickmenu_context_percent = general_prefs.get('quickmenu_context_percent', 50)
14085
+ quickmenu_context_layout = QHBoxLayout()
14086
+ quickmenu_context_layout.addWidget(QLabel(" Document context size:"))
14087
+ quickmenu_context_slider = QSlider(Qt.Orientation.Horizontal)
14088
+ quickmenu_context_slider.setMinimum(0)
14089
+ quickmenu_context_slider.setMaximum(100)
14090
+ quickmenu_context_slider.setValue(quickmenu_context_percent)
14091
+ quickmenu_context_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
14092
+ quickmenu_context_slider.setTickInterval(10)
14093
+ quickmenu_context_layout.addWidget(quickmenu_context_slider)
14094
+ quickmenu_context_value_label = QLabel(f"{quickmenu_context_percent}%")
14095
+ quickmenu_context_value_label.setMinimumWidth(60)
14096
+ quickmenu_context_layout.addWidget(quickmenu_context_value_label)
14097
+ quickmenu_context_layout.addStretch()
14098
+ prefs_layout.addLayout(quickmenu_context_layout)
14099
+
14100
+ quickmenu_context_info = QLabel(
14101
+ " ⓘ When using {{SOURCE+TARGET_CONTEXT}} or {{SOURCE_CONTEXT}} placeholders in QuickMenu prompts.\n"
14102
+ " 0% = disabled, 50% = half the document (default), 100% = entire document.\n"
14103
+ " Limit: maximum 100 segments for performance."
14104
+ )
14105
+ quickmenu_context_info.setStyleSheet("font-size: 9pt; color: #666; padding-left: 20px;")
14106
+ quickmenu_context_info.setWordWrap(True)
14107
+ prefs_layout.addWidget(quickmenu_context_info)
14108
+
14109
+ def update_quickmenu_context_label(value):
14110
+ if value == 0:
14111
+ quickmenu_context_value_label.setText("0% (disabled)")
14112
+ else:
14113
+ quickmenu_context_value_label.setText(f"{value}%")
14114
+
14115
+ quickmenu_context_slider.valueChanged.connect(update_quickmenu_context_label)
14116
+
13959
14117
  prefs_layout.addSpacing(5)
13960
14118
 
13961
14119
  # Check TM before API call
@@ -14100,7 +14258,8 @@ class SupervertalerQt(QMainWindow):
14100
14258
  batch_size_spin, surrounding_spin, full_context_cb, context_slider,
14101
14259
  check_tm_cb, auto_propagate_cb, delay_spin,
14102
14260
  ollama_keepwarm_cb,
14103
- llm_matching_cb, auto_markdown_cb, llm_spin
14261
+ llm_matching_cb, auto_markdown_cb, llm_spin,
14262
+ quickmenu_context_slider
14104
14263
  ))
14105
14264
  layout.addWidget(save_btn)
14106
14265
 
@@ -17098,7 +17257,8 @@ class SupervertalerQt(QMainWindow):
17098
17257
  batch_size_spin, surrounding_spin, full_context_cb, context_slider,
17099
17258
  check_tm_cb, auto_propagate_cb, delay_spin,
17100
17259
  ollama_keepwarm_cb,
17101
- llm_matching_cb, auto_markdown_cb, llm_spin):
17260
+ llm_matching_cb, auto_markdown_cb, llm_spin,
17261
+ quickmenu_context_slider):
17102
17262
  """Save all AI settings from the unified AI Settings tab"""
17103
17263
  # Determine selected provider
17104
17264
  if openai_radio.isChecked():
@@ -17157,6 +17317,7 @@ class SupervertalerQt(QMainWindow):
17157
17317
  general_prefs['surrounding_segments'] = surrounding_spin.value()
17158
17318
  general_prefs['use_full_context'] = full_context_cb.isChecked()
17159
17319
  general_prefs['context_window_size'] = context_slider.value()
17320
+ general_prefs['quickmenu_context_percent'] = quickmenu_context_slider.value()
17160
17321
  general_prefs['check_tm_before_api'] = check_tm_cb.isChecked()
17161
17322
  general_prefs['auto_propagate_100'] = auto_propagate_cb.isChecked()
17162
17323
  general_prefs['lookup_delay'] = delay_spin.value()
@@ -28206,6 +28367,20 @@ class SupervertalerQt(QMainWindow):
28206
28367
  # Full processing for non-arrow-key navigation (click, etc.)
28207
28368
  self._on_cell_selected_full(current_row, current_col, previous_row, previous_col)
28208
28369
 
28370
+ def _center_row_in_viewport(self, row: int):
28371
+ """Center the given row vertically in the visible table viewport.
28372
+
28373
+ Uses Qt's built-in scrollTo() with PositionAtCenter hint.
28374
+ """
28375
+ if row < 0 or row >= self.table.rowCount():
28376
+ return
28377
+
28378
+ # Get the model index for any cell in this row (use column 0)
28379
+ index = self.table.model().index(row, 0)
28380
+ if index.isValid():
28381
+ # Use Qt's built-in centering - PositionAtCenter puts the item in the center of the viewport
28382
+ self.table.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter)
28383
+
28209
28384
  def _on_cell_selected_minimal(self, current_row, previous_row):
28210
28385
  """Minimal UI update for fast arrow key navigation - just highlight and scroll"""
28211
28386
  try:
@@ -28227,7 +28402,7 @@ class SupervertalerQt(QMainWindow):
28227
28402
 
28228
28403
  # Auto-center if enabled
28229
28404
  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)
28405
+ self._center_row_in_viewport(current_row)
28231
28406
  except Exception as e:
28232
28407
  if self.debug_mode_enabled:
28233
28408
  self.log(f"Error in minimal cell selection: {e}")
@@ -28272,7 +28447,7 @@ class SupervertalerQt(QMainWindow):
28272
28447
  current_id_item.setForeground(QColor("white"))
28273
28448
 
28274
28449
  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)
28450
+ self._center_row_in_viewport(current_row)
28276
28451
 
28277
28452
  if not self.current_project or current_row < 0:
28278
28453
  return
@@ -31042,11 +31217,18 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31042
31217
  normalized_source = clean_source_lower
31043
31218
  for quote_char in '\"\'\u201C\u201D\u201E\u00AB\u00BB\u2018\u2019\u201A\u2039\u203A':
31044
31219
  normalized_source = normalized_source.replace(quote_char, ' ')
31220
+
31221
+ # CRITICAL FIX: Strip trailing punctuation from glossary term before matching
31222
+ # This allows entries like "De huidige uitvinding...problemen." (with period)
31223
+ # to match source text "...problemen." where tokenization strips the period
31224
+ # Strip from both ends to handle quotes and trailing punctuation
31225
+ normalized_term = source_term.lower().rstrip(PUNCT_CHARS).lstrip(PUNCT_CHARS)
31226
+
31045
31227
  # 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)')
31228
+ if any(char in normalized_term for char in ['.', '%', ',', '-', '/']):
31229
+ pattern = re.compile(r'(?<!\w)' + re.escape(normalized_term) + r'(?!\w)')
31048
31230
  else:
31049
- pattern = re.compile(r"\b" + re.escape(source_term.lower()) + r"\b")
31231
+ pattern = re.compile(r"\b" + re.escape(normalized_term) + r"\b")
31050
31232
 
31051
31233
  # Try matching on normalized (tag-stripped, quote-stripped) text first,
31052
31234
  # then tag-stripped, then original with tags
@@ -31503,7 +31685,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31503
31685
  self.case_sensitive_cb.setChecked(op.case_sensitive)
31504
31686
 
31505
31687
  def _fr_run_set_batch(self, fr_set: FindReplaceSet):
31506
- """Run all enabled operations in a F&R Set as a batch."""
31688
+ """Run all enabled operations in a F&R Set as a batch (optimized for speed)."""
31507
31689
  enabled_ops = [op for op in fr_set.operations if op.enabled and op.find_text]
31508
31690
 
31509
31691
  if not enabled_ops:
@@ -31522,17 +31704,32 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31522
31704
  if reply != QMessageBox.StandardButton.Yes:
31523
31705
  return
31524
31706
 
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)")
31707
+ # OPTIMIZATION: Disable UI updates during batch processing
31708
+ self.table.setUpdatesEnabled(False)
31531
31709
 
31532
- # Refresh grid
31533
- self.load_segments_to_grid()
31534
- self.project_modified = True
31535
- self.update_window_title()
31710
+ try:
31711
+ # Run each operation
31712
+ total_replaced = 0
31713
+ for op in enabled_ops:
31714
+ count = self._execute_single_fr_operation(op)
31715
+ total_replaced += count
31716
+ self.log(f" '{op.find_text}' → '{op.replace_text}': {count} replacement(s)")
31717
+
31718
+ self.project_modified = True
31719
+ self.update_window_title()
31720
+
31721
+ finally:
31722
+ # OPTIMIZATION: Re-enable UI updates and refresh only target column cells
31723
+ self.table.setUpdatesEnabled(True)
31724
+ # Update all target cells in-place (batch operations can affect many segments)
31725
+ for row in range(len(self.current_project.segments)):
31726
+ segment = self.current_project.segments[row]
31727
+ target_widget = self.table.cellWidget(row, 3)
31728
+ if target_widget and hasattr(target_widget, 'setPlainText'):
31729
+ target_widget.blockSignals(True)
31730
+ target_widget.setPlainText(segment.target)
31731
+ target_widget.blockSignals(False)
31732
+ self.table.viewport().update()
31536
31733
 
31537
31734
  QMessageBox.information(
31538
31735
  self.find_replace_dialog,
@@ -31541,12 +31738,30 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31541
31738
  )
31542
31739
 
31543
31740
  def _execute_single_fr_operation(self, op: FindReplaceOperation) -> int:
31544
- """Execute a single F&R operation on all segments. Returns replacement count."""
31741
+ """Execute a single F&R operation on all segments (optimized). Returns replacement count."""
31545
31742
  import re
31546
31743
  count = 0
31547
31744
 
31745
+ # OPTIMIZATION: Pre-filter segments - only check segments that might contain the text
31746
+ # Quick case-insensitive check to skip segments that definitely don't match
31747
+ search_text_lower = op.find_text.lower() if not op.case_sensitive else None
31748
+
31548
31749
  for segment in self.current_project.segments:
31549
31750
  texts_to_check = []
31751
+
31752
+ # Pre-filter: skip segments that can't possibly match
31753
+ if not op.case_sensitive:
31754
+ # Quick check: does the segment contain the search text at all?
31755
+ skip_segment = True
31756
+ if op.search_in in ("source", "both") and self.allow_replace_in_source:
31757
+ if search_text_lower in segment.source.lower():
31758
+ skip_segment = False
31759
+ if op.search_in in ("target", "both"):
31760
+ if search_text_lower in segment.target.lower():
31761
+ skip_segment = False
31762
+ if skip_segment:
31763
+ continue
31764
+
31550
31765
  if op.search_in in ("source", "both") and self.allow_replace_in_source:
31551
31766
  texts_to_check.append(("source", segment.source))
31552
31767
  if op.search_in in ("target", "both"):
@@ -31842,7 +32057,7 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31842
32057
  self.find_next_match()
31843
32058
 
31844
32059
  def replace_all_matches(self):
31845
- """Replace all matches in target segments"""
32060
+ """Replace all matches in target segments (optimized for speed)"""
31846
32061
  find_text = self.find_input.text()
31847
32062
  replace_text = self.replace_input.text()
31848
32063
 
@@ -31915,52 +32130,67 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
31915
32130
  if reply != QMessageBox.StandardButton.Yes:
31916
32131
  return
31917
32132
 
31918
- # Perform replacements
31919
- import re
31920
- replaced_count = 0
32133
+ # OPTIMIZATION: Disable UI updates during batch replacement
32134
+ self.table.setUpdatesEnabled(False)
31921
32135
 
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
32136
+ try:
32137
+ # Perform replacements
32138
+ import re
32139
+ replaced_count = 0
32140
+ updated_rows = set() # Track which rows need UI updates
31930
32141
 
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
32142
+ for row, col in self.find_matches:
32143
+ segment = self.current_project.segments[row]
32144
+
32145
+ # Get the appropriate field
32146
+ if col == 2: # Source
32147
+ old_text = segment.source
32148
+ else: # col == 3, Target
32149
+ old_text = segment.target
32150
+
32151
+ # Perform replacement
32152
+ if match_mode == 2: # Entire segment
32153
+ new_text = replace_text
31946
32154
  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)
32155
+ if case_sensitive:
32156
+ new_text = old_text.replace(find_text, replace_text)
32157
+ else:
32158
+ pattern = re.escape(find_text)
32159
+ new_text = re.sub(pattern, replace_text, old_text, flags=re.IGNORECASE)
31952
32160
 
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()
32161
+ if new_text != old_text:
32162
+ replaced_count += 1
32163
+ updated_rows.add(row)
32164
+
32165
+ # Update the appropriate field
32166
+ if col == 2:
32167
+ segment.source = new_text
32168
+ else:
32169
+ old_target = segment.target
32170
+ old_status = segment.status
32171
+ segment.target = new_text
32172
+ # Record undo state for find/replace operation
32173
+ self.record_undo_state(segment.id, old_target, new_text, old_status, old_status)
32174
+
32175
+ # OPTIMIZATION: Update only the affected cell widget in-place
32176
+ cell_widget = self.table.cellWidget(row, col)
32177
+ if cell_widget and hasattr(cell_widget, 'setPlainText'):
32178
+ cell_widget.blockSignals(True)
32179
+ cell_widget.setPlainText(new_text)
32180
+ cell_widget.blockSignals(False)
32181
+
32182
+ self.project_modified = True
32183
+ self.update_window_title()
32184
+
32185
+ # Clear matches
32186
+ self.find_matches = []
32187
+
32188
+ finally:
32189
+ # OPTIMIZATION: Re-enable UI updates without full grid reload
32190
+ self.table.setUpdatesEnabled(True)
32191
+ # Trigger a repaint of updated rows only
32192
+ for row in updated_rows:
32193
+ self.table.viewport().update()
31964
32194
 
31965
32195
  QMessageBox.information(self.find_replace_dialog, "Replace All", f"Replaced {replaced_count} occurrence(s).")
31966
32196
  self.log(f"✓ Replaced {replaced_count} occurrence(s) of '{find_text}'")
@@ -37564,7 +37794,19 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
37564
37794
  return text.strip()
37565
37795
 
37566
37796
  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."""
37797
+ """Build a prompt for QuickMenu using the selected prompt as instructions.
37798
+
37799
+ This is a GENERIC prompt builder (not translation-specific) that allows QuickMenu prompts
37800
+ to do anything: explain, define, search, translate, analyze, etc.
37801
+
37802
+ Supports placeholders:
37803
+ - {{SELECTION}} or {{SOURCE_TEXT}} - The selected text
37804
+ - {{SOURCE_LANGUAGE}} - Source language name
37805
+ - {{TARGET_LANGUAGE}} - Target language name
37806
+ - {{SOURCE+TARGET_CONTEXT}} - Project segments with both source and target (for proofreading)
37807
+ - {{SOURCE_CONTEXT}} - Project segments with source only (for translation questions)
37808
+ - {{TARGET_CONTEXT}} - Project segments with target only (for consistency/style analysis)
37809
+ """
37568
37810
  if not hasattr(self, 'prompt_manager_qt') or not self.prompt_manager_qt:
37569
37811
  raise RuntimeError("Prompt manager not available")
37570
37812
 
@@ -37581,25 +37823,114 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
37581
37823
  if not prompt_content:
37582
37824
  raise RuntimeError("Prompt content is empty")
37583
37825
 
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
37826
+ # Build document context if requested (three variants)
37827
+ source_target_context = ""
37828
+ source_only_context = ""
37829
+ target_only_context = ""
37830
+
37831
+ has_source_target = "{{SOURCE+TARGET_CONTEXT}}" in prompt_content
37832
+ has_source_only = "{{SOURCE_CONTEXT}}" in prompt_content
37833
+ has_target_only = "{{TARGET_CONTEXT}}" in prompt_content
37834
+
37835
+ if (has_source_target or has_source_only or has_target_only) and hasattr(self, 'current_project') and self.current_project:
37836
+ if has_source_target:
37837
+ source_target_context = self._build_quickmenu_document_context(mode="both")
37838
+ self.log(f"🔍 QuickMenu: Built SOURCE+TARGET context ({len(source_target_context)} chars)")
37839
+ if has_source_only:
37840
+ source_only_context = self._build_quickmenu_document_context(mode="source")
37841
+ self.log(f"🔍 QuickMenu: Built SOURCE_ONLY context ({len(source_only_context)} chars)")
37842
+ if has_target_only:
37843
+ target_only_context = self._build_quickmenu_document_context(mode="target")
37844
+ self.log(f"🔍 QuickMenu: Built TARGET_ONLY context ({len(target_only_context)} chars)")
37845
+ else:
37846
+ if has_source_target:
37847
+ self.log("⚠️ QuickMenu: {{SOURCE+TARGET_CONTEXT}} requested but no project loaded")
37848
+ if has_source_only:
37849
+ self.log("⚠️ QuickMenu: {{SOURCE_CONTEXT}} requested but no project loaded")
37850
+ if has_target_only:
37851
+ self.log("⚠️ QuickMenu: {{TARGET_CONTEXT}} requested but no project loaded")
37852
+
37853
+ # Replace placeholders in the prompt content
37854
+ prompt_content = prompt_content.replace("{{SOURCE_LANGUAGE}}", source_lang)
37855
+ prompt_content = prompt_content.replace("{{TARGET_LANGUAGE}}", target_lang)
37856
+ prompt_content = prompt_content.replace("{{SOURCE_TEXT}}", source_text)
37857
+ prompt_content = prompt_content.replace("{{SELECTION}}", source_text) # Alternative placeholder
37858
+ prompt_content = prompt_content.replace("{{SOURCE+TARGET_CONTEXT}}", source_target_context)
37859
+ prompt_content = prompt_content.replace("{{SOURCE_CONTEXT}}", source_only_context)
37860
+ prompt_content = prompt_content.replace("{{TARGET_CONTEXT}}", target_only_context)
37861
+
37862
+ # Debug: Log the final prompt being sent
37863
+ self.log(f"📝 QuickMenu: Final prompt ({len(prompt_content)} chars):")
37864
+ self.log("─" * 80)
37865
+ self.log(prompt_content[:500] + ("..." if len(prompt_content) > 500 else ""))
37866
+ self.log("─" * 80)
37867
+
37868
+ # If the prompt doesn't contain the selection/text, append it
37869
+ if "{{SOURCE_TEXT}}" not in prompt_data.get('content', '') and "{{SELECTION}}" not in prompt_data.get('content', ''):
37870
+ prompt_content += f"\n\nText:\n{source_text}"
37871
+
37872
+ return prompt_content
37873
+
37874
+ def _build_quickmenu_document_context(self, mode: str = "both") -> str:
37875
+ """Build document context for QuickMenu prompts.
37876
+
37877
+ Args:
37878
+ mode: One of:
37879
+ - "both": Include both source and target text (for proofreading)
37880
+ - "source": Include only source text (for translation/terminology questions)
37881
+ - "target": Include only target text (for consistency/style analysis)
37882
+
37883
+ Returns a formatted string with segments from the project for context.
37884
+ Uses the 'quickmenu_context_segments' setting (default: 50% of total segments).
37885
+ """
37886
+ if not hasattr(self, 'current_project') or not self.current_project or not self.current_project.segments:
37887
+ return "(No project context available)"
37888
+
37594
37889
  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
37890
+ # Get settings
37891
+ general_prefs = self.load_general_settings()
37892
+ context_percent = general_prefs.get('quickmenu_context_percent', 50) # Default: 50%
37893
+ max_context_segments = general_prefs.get('quickmenu_context_max', 100) # Safety limit
37894
+
37895
+ # Calculate how many segments to include
37896
+ total_segments = len(self.current_project.segments)
37897
+ num_segments = min(
37898
+ int(total_segments * context_percent / 100),
37899
+ max_context_segments
37900
+ )
37901
+
37902
+ if num_segments == 0:
37903
+ return "(Document context disabled)"
37904
+
37905
+ # Get segments (from start of document)
37906
+ context_segments = self.current_project.segments[:num_segments]
37907
+
37908
+ # Format segments based on mode
37909
+ context_parts = []
37910
+ mode_labels = {"both": "SOURCE + TARGET", "source": "SOURCE ONLY", "target": "TARGET ONLY"}
37911
+ context_parts.append(f"=== DOCUMENT CONTEXT ({mode_labels.get(mode, 'UNKNOWN')}) ===")
37912
+ context_parts.append(f"(Showing {num_segments} of {total_segments} segments - {context_percent}%)")
37913
+ context_parts.append("")
37914
+
37915
+ for seg in context_segments:
37916
+ if mode == "both":
37917
+ # Source + Target
37918
+ context_parts.append(f"[{seg.id}] {seg.source}")
37919
+ if seg.target and seg.target.strip():
37920
+ context_parts.append(f" → {seg.target}")
37921
+ elif mode == "source":
37922
+ # Source only
37923
+ context_parts.append(f"[{seg.id}] {seg.source}")
37924
+ elif mode == "target":
37925
+ # Target only (skip empty targets)
37926
+ if seg.target and seg.target.strip():
37927
+ context_parts.append(f"[{seg.id}] {seg.target}")
37928
+ context_parts.append("") # Blank line between segments
37929
+
37930
+ return "\n".join(context_parts)
37931
+
37932
+ except Exception as e:
37933
+ return f"(Error building document context: {e})"
37603
37934
 
37604
37935
  def _quickmenu_show_result_dialog(self, title: str, output_text: str, apply_callback=None):
37605
37936
  from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox, QPushButton, QApplication
@@ -37679,10 +38010,14 @@ OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""
37679
38010
  )
37680
38011
 
37681
38012
  client = LLMClient(api_key=api_key, provider=provider, model=model)
38013
+
38014
+ # Use translate() with empty text and custom_prompt for generic AI completion
38015
+ # This allows QuickMenu prompts to do anything (explain, define, search, etc.)
38016
+ # not just translation. Same pattern as AI Assistant.
37682
38017
  output_text = client.translate(
37683
- text=input_text,
37684
- source_lang=source_lang,
37685
- target_lang=target_lang,
38018
+ text="", # Empty - we're using custom_prompt for everything
38019
+ source_lang="en", # Dummy values
38020
+ target_lang="en",
37686
38021
  custom_prompt=custom_prompt
37687
38022
  )
37688
38023
 
@@ -44268,7 +44603,7 @@ class SuperlookupTab(QWidget):
44268
44603
  try:
44269
44604
  # Use multiple methods to ensure cleanup
44270
44605
  # Method 1: Kill by window title
44271
- subprocess.run(['taskkill', '/F', '/FI', 'WINDOWTITLE eq superlookup_hotkey.ahk*'],
44606
+ subprocess.run(['taskkill', '/F', '/FI', 'WINDOWTITLE eq supervertaler_hotkeys.ahk*'],
44272
44607
  capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
44273
44608
 
44274
44609
  # Method 2: Kill AutoHotkey processes more aggressively
@@ -44280,7 +44615,7 @@ class SuperlookupTab(QWidget):
44280
44615
  try:
44281
44616
  for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
44282
44617
  try:
44283
- if 'superlookup_hotkey' in ' '.join(proc.cmdline() or []):
44618
+ if 'supervertaler_hotkeys' in ' '.join(proc.cmdline() or []):
44284
44619
  proc.kill()
44285
44620
  except:
44286
44621
  pass
@@ -44293,8 +44628,8 @@ class SuperlookupTab(QWidget):
44293
44628
  ahk_exe, source = self._find_autohotkey_executable()
44294
44629
 
44295
44630
  if not ahk_exe:
44296
- print("[Superlookup] AutoHotkey not found.")
44297
- print("[Superlookup] Global hotkey (Ctrl+Alt+L) will not be available.")
44631
+ print("[Hotkeys] AutoHotkey not found.")
44632
+ print("[Hotkeys] Global hotkeys (Ctrl+Alt+L, Shift+Shift) will not be available.")
44298
44633
  self.hotkey_registered = False
44299
44634
  # Show setup dialog (deferred to avoid blocking startup) - unless user opted out
44300
44635
  if self.main_window and hasattr(self.main_window, 'general_settings'):
@@ -44304,11 +44639,11 @@ class SuperlookupTab(QWidget):
44304
44639
  QTimer.singleShot(2000, self._show_autohotkey_setup_dialog)
44305
44640
  return
44306
44641
 
44307
- print(f"[Superlookup] Found AutoHotkey at: {ahk_exe} (source: {source})")
44642
+ print(f"[Hotkeys] Found AutoHotkey at: {ahk_exe} (source: {source})")
44308
44643
 
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()}")
44644
+ ahk_script = Path(__file__).parent / "supervertaler_hotkeys.ahk"
44645
+ print(f"[Hotkeys] Looking for script at: {ahk_script}")
44646
+ print(f"[Hotkeys] Script exists: {ahk_script.exists()}")
44312
44647
 
44313
44648
  if ahk_script.exists():
44314
44649
  # Start AHK script in background (hidden)
@@ -44316,16 +44651,16 @@ class SuperlookupTab(QWidget):
44316
44651
  creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
44317
44652
  # Store in global variable for atexit cleanup
44318
44653
  _ahk_process = self.ahk_process
44319
- print(f"[Superlookup] AHK hotkey registered: Ctrl+Alt+L")
44654
+ print(f"[Hotkeys] AHK hotkeys registered (Ctrl+Alt+L, Shift+Shift)")
44320
44655
 
44321
44656
  # Start file watcher
44322
44657
  self.start_file_watcher()
44323
44658
  self.hotkey_registered = True
44324
44659
  else:
44325
- print(f"[Superlookup] AHK script not found: {ahk_script}")
44660
+ print(f"[Hotkeys] AHK script not found: {ahk_script}")
44326
44661
  self.hotkey_registered = False
44327
44662
  except Exception as e:
44328
- print(f"[Superlookup] Could not start AHK hotkey: {e}")
44663
+ print(f"[Hotkeys] Could not start AHK hotkeys: {e}")
44329
44664
  self.hotkey_registered = False
44330
44665
 
44331
44666
  def start_file_watcher(self):