pomera-ai-commander 1.1.1 → 1.2.2

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.
Files changed (213) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -680
  3. package/bin/pomera-ai-commander.js +62 -62
  4. package/core/__init__.py +65 -65
  5. package/core/app_context.py +482 -482
  6. package/core/async_text_processor.py +421 -421
  7. package/core/backup_manager.py +655 -655
  8. package/core/backup_recovery_manager.py +1199 -1033
  9. package/core/content_hash_cache.py +508 -508
  10. package/core/context_menu.py +313 -313
  11. package/core/data_directory.py +549 -0
  12. package/core/data_validator.py +1066 -1066
  13. package/core/database_connection_manager.py +744 -744
  14. package/core/database_curl_settings_manager.py +608 -608
  15. package/core/database_promera_ai_settings_manager.py +446 -446
  16. package/core/database_schema.py +411 -411
  17. package/core/database_schema_manager.py +395 -395
  18. package/core/database_settings_manager.py +1507 -1507
  19. package/core/database_settings_manager_interface.py +456 -456
  20. package/core/dialog_manager.py +734 -734
  21. package/core/diff_utils.py +239 -0
  22. package/core/efficient_line_numbers.py +540 -510
  23. package/core/error_handler.py +746 -746
  24. package/core/error_service.py +431 -431
  25. package/core/event_consolidator.py +511 -511
  26. package/core/mcp/__init__.py +43 -43
  27. package/core/mcp/find_replace_diff.py +334 -0
  28. package/core/mcp/protocol.py +288 -288
  29. package/core/mcp/schema.py +251 -251
  30. package/core/mcp/server_stdio.py +299 -299
  31. package/core/mcp/tool_registry.py +2699 -2345
  32. package/core/memento.py +275 -0
  33. package/core/memory_efficient_text_widget.py +711 -711
  34. package/core/migration_manager.py +914 -914
  35. package/core/migration_test_suite.py +1085 -1085
  36. package/core/migration_validator.py +1143 -1143
  37. package/core/optimized_find_replace.py +714 -714
  38. package/core/optimized_pattern_engine.py +424 -424
  39. package/core/optimized_search_highlighter.py +552 -552
  40. package/core/performance_monitor.py +674 -674
  41. package/core/persistence_manager.py +712 -712
  42. package/core/progressive_stats_calculator.py +632 -632
  43. package/core/regex_pattern_cache.py +529 -529
  44. package/core/regex_pattern_library.py +350 -350
  45. package/core/search_operation_manager.py +434 -434
  46. package/core/settings_defaults_registry.py +1087 -1087
  47. package/core/settings_integrity_validator.py +1111 -1111
  48. package/core/settings_serializer.py +557 -557
  49. package/core/settings_validator.py +1823 -1823
  50. package/core/smart_stats_calculator.py +709 -709
  51. package/core/statistics_update_manager.py +619 -619
  52. package/core/stats_config_manager.py +858 -858
  53. package/core/streaming_text_handler.py +723 -723
  54. package/core/task_scheduler.py +596 -596
  55. package/core/update_pattern_library.py +168 -168
  56. package/core/visibility_monitor.py +596 -596
  57. package/core/widget_cache.py +498 -498
  58. package/mcp.json +51 -61
  59. package/migrate_data.py +127 -0
  60. package/package.json +64 -57
  61. package/pomera.py +7883 -7482
  62. package/pomera_mcp_server.py +183 -144
  63. package/requirements.txt +33 -0
  64. package/scripts/Dockerfile.alpine +43 -0
  65. package/scripts/Dockerfile.gui-test +54 -0
  66. package/scripts/Dockerfile.linux +43 -0
  67. package/scripts/Dockerfile.test-linux +80 -0
  68. package/scripts/Dockerfile.ubuntu +39 -0
  69. package/scripts/README.md +53 -0
  70. package/scripts/build-all.bat +113 -0
  71. package/scripts/build-docker.bat +53 -0
  72. package/scripts/build-docker.sh +55 -0
  73. package/scripts/build-optimized.bat +101 -0
  74. package/scripts/build.sh +78 -0
  75. package/scripts/docker-compose.test.yml +27 -0
  76. package/scripts/docker-compose.yml +32 -0
  77. package/scripts/postinstall.js +62 -0
  78. package/scripts/requirements-minimal.txt +33 -0
  79. package/scripts/test-linux-simple.bat +28 -0
  80. package/scripts/validate-release-workflow.py +450 -0
  81. package/tools/__init__.py +4 -4
  82. package/tools/ai_tools.py +2891 -2891
  83. package/tools/ascii_art_generator.py +352 -352
  84. package/tools/base64_tools.py +183 -183
  85. package/tools/base_tool.py +511 -511
  86. package/tools/case_tool.py +308 -308
  87. package/tools/column_tools.py +395 -395
  88. package/tools/cron_tool.py +884 -884
  89. package/tools/curl_history.py +600 -600
  90. package/tools/curl_processor.py +1207 -1207
  91. package/tools/curl_settings.py +502 -502
  92. package/tools/curl_tool.py +5467 -5467
  93. package/tools/diff_viewer.py +1817 -1072
  94. package/tools/email_extraction_tool.py +248 -248
  95. package/tools/email_header_analyzer.py +425 -425
  96. package/tools/extraction_tools.py +250 -250
  97. package/tools/find_replace.py +2289 -1750
  98. package/tools/folder_file_reporter.py +1463 -1463
  99. package/tools/folder_file_reporter_adapter.py +480 -480
  100. package/tools/generator_tools.py +1216 -1216
  101. package/tools/hash_generator.py +255 -255
  102. package/tools/html_tool.py +656 -656
  103. package/tools/jsonxml_tool.py +729 -729
  104. package/tools/line_tools.py +419 -419
  105. package/tools/markdown_tools.py +561 -561
  106. package/tools/mcp_widget.py +1417 -1417
  107. package/tools/notes_widget.py +978 -973
  108. package/tools/number_base_converter.py +372 -372
  109. package/tools/regex_extractor.py +571 -571
  110. package/tools/slug_generator.py +310 -310
  111. package/tools/sorter_tools.py +458 -458
  112. package/tools/string_escape_tool.py +392 -392
  113. package/tools/text_statistics_tool.py +365 -365
  114. package/tools/text_wrapper.py +430 -430
  115. package/tools/timestamp_converter.py +421 -421
  116. package/tools/tool_loader.py +710 -710
  117. package/tools/translator_tools.py +522 -522
  118. package/tools/url_link_extractor.py +261 -261
  119. package/tools/url_parser.py +204 -204
  120. package/tools/whitespace_tools.py +355 -355
  121. package/tools/word_frequency_counter.py +146 -146
  122. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  123. package/core/__pycache__/app_context.cpython-313.pyc +0 -0
  124. package/core/__pycache__/async_text_processor.cpython-313.pyc +0 -0
  125. package/core/__pycache__/backup_manager.cpython-313.pyc +0 -0
  126. package/core/__pycache__/backup_recovery_manager.cpython-313.pyc +0 -0
  127. package/core/__pycache__/content_hash_cache.cpython-313.pyc +0 -0
  128. package/core/__pycache__/context_menu.cpython-313.pyc +0 -0
  129. package/core/__pycache__/data_validator.cpython-313.pyc +0 -0
  130. package/core/__pycache__/database_connection_manager.cpython-313.pyc +0 -0
  131. package/core/__pycache__/database_curl_settings_manager.cpython-313.pyc +0 -0
  132. package/core/__pycache__/database_promera_ai_settings_manager.cpython-313.pyc +0 -0
  133. package/core/__pycache__/database_schema.cpython-313.pyc +0 -0
  134. package/core/__pycache__/database_schema_manager.cpython-313.pyc +0 -0
  135. package/core/__pycache__/database_settings_manager.cpython-313.pyc +0 -0
  136. package/core/__pycache__/database_settings_manager_interface.cpython-313.pyc +0 -0
  137. package/core/__pycache__/dialog_manager.cpython-313.pyc +0 -0
  138. package/core/__pycache__/efficient_line_numbers.cpython-313.pyc +0 -0
  139. package/core/__pycache__/error_handler.cpython-313.pyc +0 -0
  140. package/core/__pycache__/error_service.cpython-313.pyc +0 -0
  141. package/core/__pycache__/event_consolidator.cpython-313.pyc +0 -0
  142. package/core/__pycache__/memory_efficient_text_widget.cpython-313.pyc +0 -0
  143. package/core/__pycache__/migration_manager.cpython-313.pyc +0 -0
  144. package/core/__pycache__/migration_test_suite.cpython-313.pyc +0 -0
  145. package/core/__pycache__/migration_validator.cpython-313.pyc +0 -0
  146. package/core/__pycache__/optimized_find_replace.cpython-313.pyc +0 -0
  147. package/core/__pycache__/optimized_pattern_engine.cpython-313.pyc +0 -0
  148. package/core/__pycache__/optimized_search_highlighter.cpython-313.pyc +0 -0
  149. package/core/__pycache__/performance_monitor.cpython-313.pyc +0 -0
  150. package/core/__pycache__/persistence_manager.cpython-313.pyc +0 -0
  151. package/core/__pycache__/progressive_stats_calculator.cpython-313.pyc +0 -0
  152. package/core/__pycache__/regex_pattern_cache.cpython-313.pyc +0 -0
  153. package/core/__pycache__/regex_pattern_library.cpython-313.pyc +0 -0
  154. package/core/__pycache__/search_operation_manager.cpython-313.pyc +0 -0
  155. package/core/__pycache__/settings_defaults_registry.cpython-313.pyc +0 -0
  156. package/core/__pycache__/settings_integrity_validator.cpython-313.pyc +0 -0
  157. package/core/__pycache__/settings_serializer.cpython-313.pyc +0 -0
  158. package/core/__pycache__/settings_validator.cpython-313.pyc +0 -0
  159. package/core/__pycache__/smart_stats_calculator.cpython-313.pyc +0 -0
  160. package/core/__pycache__/statistics_update_manager.cpython-313.pyc +0 -0
  161. package/core/__pycache__/stats_config_manager.cpython-313.pyc +0 -0
  162. package/core/__pycache__/streaming_text_handler.cpython-313.pyc +0 -0
  163. package/core/__pycache__/task_scheduler.cpython-313.pyc +0 -0
  164. package/core/__pycache__/visibility_monitor.cpython-313.pyc +0 -0
  165. package/core/__pycache__/widget_cache.cpython-313.pyc +0 -0
  166. package/core/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
  167. package/core/mcp/__pycache__/protocol.cpython-313.pyc +0 -0
  168. package/core/mcp/__pycache__/schema.cpython-313.pyc +0 -0
  169. package/core/mcp/__pycache__/server_stdio.cpython-313.pyc +0 -0
  170. package/core/mcp/__pycache__/tool_registry.cpython-313.pyc +0 -0
  171. package/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  172. package/tools/__pycache__/ai_tools.cpython-313.pyc +0 -0
  173. package/tools/__pycache__/ascii_art_generator.cpython-313.pyc +0 -0
  174. package/tools/__pycache__/base64_tools.cpython-313.pyc +0 -0
  175. package/tools/__pycache__/base_tool.cpython-313.pyc +0 -0
  176. package/tools/__pycache__/case_tool.cpython-313.pyc +0 -0
  177. package/tools/__pycache__/column_tools.cpython-313.pyc +0 -0
  178. package/tools/__pycache__/cron_tool.cpython-313.pyc +0 -0
  179. package/tools/__pycache__/curl_history.cpython-313.pyc +0 -0
  180. package/tools/__pycache__/curl_processor.cpython-313.pyc +0 -0
  181. package/tools/__pycache__/curl_settings.cpython-313.pyc +0 -0
  182. package/tools/__pycache__/curl_tool.cpython-313.pyc +0 -0
  183. package/tools/__pycache__/diff_viewer.cpython-313.pyc +0 -0
  184. package/tools/__pycache__/email_extraction_tool.cpython-313.pyc +0 -0
  185. package/tools/__pycache__/email_header_analyzer.cpython-313.pyc +0 -0
  186. package/tools/__pycache__/extraction_tools.cpython-313.pyc +0 -0
  187. package/tools/__pycache__/find_replace.cpython-313.pyc +0 -0
  188. package/tools/__pycache__/folder_file_reporter.cpython-313.pyc +0 -0
  189. package/tools/__pycache__/folder_file_reporter_adapter.cpython-313.pyc +0 -0
  190. package/tools/__pycache__/generator_tools.cpython-313.pyc +0 -0
  191. package/tools/__pycache__/hash_generator.cpython-313.pyc +0 -0
  192. package/tools/__pycache__/html_tool.cpython-313.pyc +0 -0
  193. package/tools/__pycache__/huggingface_helper.cpython-313.pyc +0 -0
  194. package/tools/__pycache__/jsonxml_tool.cpython-313.pyc +0 -0
  195. package/tools/__pycache__/line_tools.cpython-313.pyc +0 -0
  196. package/tools/__pycache__/list_comparator.cpython-313.pyc +0 -0
  197. package/tools/__pycache__/markdown_tools.cpython-313.pyc +0 -0
  198. package/tools/__pycache__/mcp_widget.cpython-313.pyc +0 -0
  199. package/tools/__pycache__/notes_widget.cpython-313.pyc +0 -0
  200. package/tools/__pycache__/number_base_converter.cpython-313.pyc +0 -0
  201. package/tools/__pycache__/regex_extractor.cpython-313.pyc +0 -0
  202. package/tools/__pycache__/slug_generator.cpython-313.pyc +0 -0
  203. package/tools/__pycache__/sorter_tools.cpython-313.pyc +0 -0
  204. package/tools/__pycache__/string_escape_tool.cpython-313.pyc +0 -0
  205. package/tools/__pycache__/text_statistics_tool.cpython-313.pyc +0 -0
  206. package/tools/__pycache__/text_wrapper.cpython-313.pyc +0 -0
  207. package/tools/__pycache__/timestamp_converter.cpython-313.pyc +0 -0
  208. package/tools/__pycache__/tool_loader.cpython-313.pyc +0 -0
  209. package/tools/__pycache__/translator_tools.cpython-313.pyc +0 -0
  210. package/tools/__pycache__/url_link_extractor.cpython-313.pyc +0 -0
  211. package/tools/__pycache__/url_parser.cpython-313.pyc +0 -0
  212. package/tools/__pycache__/whitespace_tools.cpython-313.pyc +0 -0
  213. package/tools/__pycache__/word_frequency_counter.cpython-313.pyc +0 -0
@@ -1,1751 +1,2290 @@
1
- """
2
- Find & Replace Tool Module for Promera AI Commander
3
-
4
- This module contains all the logic and UI components for the Find & Replace functionality,
5
- extracted from the main application for better modularity and maintainability.
6
- """
7
-
8
- import tkinter as tk
9
- from tkinter import ttk, messagebox
10
- import re
11
- import logging
12
- import time
13
- from typing import Optional, Tuple, Dict, Any, List
14
-
15
- # Import optimized components if available
16
- try:
17
- from core.optimized_search_highlighter import get_search_highlighter, OptimizedSearchHighlighter, HighlightMode
18
- from core.optimized_find_replace import get_find_replace_processor, OptimizedFindReplace, ProcessingMode
19
- from core.search_operation_manager import get_operation_manager, SearchOperationManager, CancellationReason
20
- PROGRESSIVE_SEARCH_AVAILABLE = True
21
- except ImportError as e:
22
- PROGRESSIVE_SEARCH_AVAILABLE = False
23
- print(f"Progressive search not available: {e}")
24
-
25
- # Import AI Tools if available
26
- try:
27
- from .ai_tools import AIToolsWidget
28
- AI_TOOLS_AVAILABLE = True
29
- except ImportError:
30
- AI_TOOLS_AVAILABLE = False
31
-
32
-
33
- class FindReplaceWidget:
34
- """
35
- A comprehensive Find & Replace widget with advanced features including:
36
- - Text and Regex modes
37
- - Case sensitivity options
38
- - Whole words, prefix, and suffix matching
39
- - Progressive search and highlighting
40
- - Pattern library integration
41
- - History tracking
42
- - Single replace and skip functionality
43
- """
44
-
45
- def __init__(self, parent, settings_manager, logger=None, dialog_manager=None):
46
- """
47
- Initialize the Find & Replace widget.
48
-
49
- Args:
50
- parent: Parent widget/window
51
- settings_manager: Object that handles settings persistence
52
- logger: Logger instance for debugging
53
- dialog_manager: DialogManager instance for consistent dialog handling
54
- """
55
- self.parent = parent
56
- self.settings_manager = settings_manager
57
- self.logger = logger or logging.getLogger(__name__)
58
- self.dialog_manager = dialog_manager
59
-
60
- # Initialize optimized components if available
61
- if PROGRESSIVE_SEARCH_AVAILABLE:
62
- self.search_highlighter = get_search_highlighter()
63
- self.find_replace_processor = get_find_replace_processor()
64
- self.operation_manager = get_operation_manager()
65
- self.active_search_operations = {}
66
- else:
67
- self.search_highlighter = None
68
- self.find_replace_processor = None
69
- self.operation_manager = None
70
- self.active_search_operations = {}
71
-
72
- # Internal state
73
- self._regex_cache = {}
74
- self._regex_cache_max_size = 100 # Limit cache size
75
- self.current_match_index = 0
76
- self.current_matches = []
77
- self.input_matches = []
78
- self.replaced_count = 0
79
- self.skipped_matches = set()
80
- self.all_matches_processed = False
81
- self.loop_start_position = None
82
- self.undo_stack = [] # For undo functionality
83
- self.max_undo_stack = 10 # Limit undo history
84
-
85
- # UI components (will be created by create_widgets)
86
- self.find_text_field = None
87
- self.replace_text_field = None
88
- self.match_count_label = None
89
- self.replaced_count_label = None
90
- self.regex_mode_var = None
91
- self.match_case_var = None
92
- self.fr_option_var = None
93
- self.option_radiobuttons = {}
94
- self.pattern_library_button = None
95
-
96
- def _show_info(self, title, message, category="success"):
97
- """Show info dialog using DialogManager if available, otherwise use messagebox."""
98
- if self.dialog_manager:
99
- return self.dialog_manager.show_info(title, message, category, parent=self.parent)
100
- else:
101
- messagebox.showinfo(title, message, parent=self.parent)
102
- return True
103
-
104
- def _show_warning(self, title, message, category="warning"):
105
- """Show warning dialog using DialogManager if available, otherwise use messagebox."""
106
- if self.dialog_manager:
107
- return self.dialog_manager.show_warning(title, message, category, parent=self.parent)
108
- else:
109
- messagebox.showwarning(title, message, parent=self.parent)
110
- return True
111
-
112
- def create_widgets(self, parent_frame, settings: Dict[str, Any]):
113
- """
114
- Creates the Find & Replace UI widgets.
115
-
116
- Args:
117
- parent_frame: Parent frame to contain the widgets
118
- settings: Current tool settings
119
- """
120
- # Left side controls frame (under Input)
121
- left_frame = ttk.Frame(parent_frame)
122
- left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=5)
123
-
124
- # Options frame (middle)
125
- options_frame = ttk.Frame(parent_frame)
126
- options_frame.pack(side=tk.LEFT, fill=tk.Y, padx=5)
127
-
128
- # Right side controls frame (right of Options)
129
- right_frame = ttk.Frame(parent_frame)
130
- right_frame.pack(side=tk.LEFT, fill=tk.Y, padx=5)
131
-
132
- self.match_count_label = ttk.Label(left_frame, text="Found matches: 0")
133
- self.match_count_label.pack(anchor="w")
134
-
135
- # Find field with history button (left side)
136
- find_frame = ttk.Frame(left_frame)
137
- find_frame.pack(fill=tk.X, pady=2)
138
- ttk.Label(find_frame, text="Find:").pack(side=tk.LEFT)
139
- find_input_frame = ttk.Frame(find_frame)
140
- find_input_frame.pack(side=tk.LEFT, expand=True, fill=tk.X)
141
- self.find_text_field = tk.Entry(find_input_frame, width=30)
142
- self.find_text_field.insert(0, settings.get("find", ""))
143
- self.find_text_field.pack(side=tk.LEFT, expand=True, fill=tk.X)
144
- self.find_text_field.bind('<KeyRelease>', self._on_find_text_change)
145
- ttk.Button(find_input_frame, text="History", command=self.show_find_history, width=8).pack(side=tk.RIGHT, padx=(2,0))
146
-
147
- # Buttons under Find field (left side)
148
- find_buttons_frame = ttk.Frame(left_frame)
149
- find_buttons_frame.pack(fill=tk.X, pady=2)
150
- ttk.Button(find_buttons_frame, text="Find All", command=self.preview_find_replace).pack(side=tk.LEFT, padx=5)
151
- ttk.Button(find_buttons_frame, text="Previous", command=self.find_previous).pack(side=tk.LEFT, padx=5)
152
- ttk.Button(find_buttons_frame, text="Next", command=self.find_next).pack(side=tk.LEFT, padx=5)
153
-
154
- # Regex mode checkbox below Search button (left side)
155
- self.regex_mode_var = tk.BooleanVar(value=settings.get("mode", "Text") == "Regex")
156
- regex_checkbox = ttk.Checkbutton(left_frame, text="Regex mode", variable=self.regex_mode_var, command=self.on_regex_mode_change)
157
- regex_checkbox.pack(anchor="w", pady=(2,5))
158
-
159
- # Pattern Library button below Regex mode checkbox
160
- self.pattern_library_button = ttk.Button(left_frame, text="Pattern Library", command=self.show_pattern_library)
161
- self.pattern_library_button.pack(anchor="w", pady=(2,5))
162
-
163
- # Info label for escape sequences
164
- info_label = ttk.Label(left_frame, text="Tip: Use \\n \\t \\r in text mode",
165
- font=("Arial", 8), foreground="gray")
166
- info_label.pack(anchor="w", pady=(0,2))
167
-
168
- # Options in the middle
169
- ttk.Label(options_frame, text="Options:").pack(anchor="w", pady=(0,5))
170
-
171
- # Match case checkbox (can be combined with other options)
172
- current_option = settings.get("option", "ignore_case")
173
-
174
- # Determine if match case is enabled
175
- is_match_case = current_option == "match_case" or "_match_case" in current_option
176
- self.match_case_var = tk.BooleanVar(value=is_match_case)
177
- self.match_case_checkbox = ttk.Checkbutton(options_frame, text="Match case", variable=self.match_case_var, command=self.on_find_replace_option_change)
178
- self.match_case_checkbox.pack(anchor="w")
179
-
180
- # Separator
181
- ttk.Separator(options_frame, orient='horizontal').pack(fill='x', pady=5)
182
-
183
- # Text matching options (radio buttons)
184
- # Extract base option (remove case sensitivity suffix)
185
- base_option = current_option.replace("_match_case", "") if "_match_case" in current_option else current_option
186
- if base_option in ["match_case", "ignore_case"]:
187
- base_option = "none"
188
- self.fr_option_var = tk.StringVar(value=base_option)
189
- text_options = {
190
- "none": "No special matching",
191
- "whole_words": "Find whole words only",
192
- "match_prefix": "Match prefix",
193
- "match_suffix": "Match suffix"
194
- }
195
-
196
- self.option_radiobuttons = {}
197
- for key, text in text_options.items():
198
- rb = ttk.Radiobutton(options_frame, text=text, variable=self.fr_option_var, value=key, command=self.on_find_replace_option_change)
199
- rb.pack(anchor="w")
200
- self.option_radiobuttons[key] = rb
201
-
202
- # Replaced matches counter (right side)
203
- self.replaced_count_label = ttk.Label(right_frame, text="Replaced matches: 0")
204
- self.replaced_count_label.pack(anchor="w")
205
-
206
- # Replace field with history button (right side)
207
- replace_frame = ttk.Frame(right_frame)
208
- replace_frame.pack(fill=tk.X, pady=2)
209
- ttk.Label(replace_frame, text="Replace:").pack(side=tk.LEFT)
210
- replace_input_frame = ttk.Frame(replace_frame)
211
- replace_input_frame.pack(side=tk.LEFT, expand=True, fill=tk.X)
212
- self.replace_text_field = tk.Entry(replace_input_frame, width=30)
213
- self.replace_text_field.insert(0, settings.get("replace", ""))
214
- self.replace_text_field.pack(side=tk.LEFT, expand=True, fill=tk.X)
215
- self.replace_text_field.bind('<KeyRelease>', self._on_replace_text_change)
216
- ttk.Button(replace_input_frame, text="History", command=self.show_replace_history, width=8).pack(side=tk.RIGHT, padx=(2,0))
217
-
218
- # Buttons under Replace field (right side) - Row 1
219
- replace_buttons_frame = ttk.Frame(right_frame)
220
- replace_buttons_frame.pack(fill=tk.X, pady=2)
221
- ttk.Button(replace_buttons_frame, text="Replace All", command=self.trigger_replace_all).pack(side=tk.LEFT, padx=5)
222
- ttk.Button(replace_buttons_frame, text="Replace > Find", command=self.replace_single).pack(side=tk.LEFT, padx=5)
223
- ttk.Button(replace_buttons_frame, text="Skip", command=self.skip_single).pack(side=tk.LEFT, padx=5)
224
-
225
- # Buttons under Replace field (right side) - Row 2
226
- replace_buttons_frame2 = ttk.Frame(right_frame)
227
- replace_buttons_frame2.pack(fill=tk.X, pady=2)
228
- self.undo_button = ttk.Button(replace_buttons_frame2, text="Undo", command=self.undo_replace_all, state="disabled")
229
- self.undo_button.pack(side=tk.LEFT, padx=5)
230
-
231
- # Initialize search state
232
- self.current_match_index = 0
233
- self.current_matches = []
234
- self.input_matches = []
235
- self.replaced_count = 0
236
- self.skipped_matches = set()
237
- self.all_matches_processed = False
238
- self.loop_start_position = None
239
-
240
- # Initialize history if not exists
241
- if "find_history" not in settings:
242
- settings["find_history"] = []
243
- if "replace_history" not in settings:
244
- settings["replace_history"] = []
245
-
246
- # Set initial state of options based on regex mode
247
- self.on_regex_mode_change()
248
-
249
- # Setup keyboard shortcuts
250
- self._setup_keyboard_shortcuts()
251
-
252
- def get_settings(self) -> Dict[str, Any]:
253
- """
254
- Get current Find & Replace settings.
255
-
256
- Returns:
257
- Dictionary containing current settings
258
- """
259
- if not self.find_text_field:
260
- return {}
261
-
262
- settings = {
263
- "find": self.find_text_field.get(),
264
- "replace": self.replace_text_field.get(),
265
- "mode": "Regex" if self.regex_mode_var.get() else "Text"
266
- }
267
-
268
- # Combine case sensitivity with text matching option
269
- if self.match_case_var.get():
270
- if self.fr_option_var.get() == "none":
271
- settings["option"] = "match_case"
272
- else:
273
- settings["option"] = f"{self.fr_option_var.get()}_match_case"
274
- else:
275
- if self.fr_option_var.get() == "none":
276
- settings["option"] = "ignore_case"
277
- else:
278
- settings["option"] = self.fr_option_var.get()
279
-
280
- return settings
281
-
282
- def set_text_widgets(self, input_tabs, output_tabs, input_notebook, output_notebook):
283
- """
284
- Set the text widgets that this Find & Replace tool will operate on.
285
-
286
- Args:
287
- input_tabs: List of input tab objects with .text attribute
288
- output_tabs: List of output tab objects with .text attribute
289
- input_notebook: Input notebook widget for getting current selection
290
- output_notebook: Output notebook widget for getting current selection
291
- """
292
- self.input_tabs = input_tabs
293
- self.output_tabs = output_tabs
294
- self.input_notebook = input_notebook
295
- self.output_notebook = output_notebook
296
-
297
- def _setup_keyboard_shortcuts(self):
298
- """Setup keyboard shortcuts for Find & Replace operations."""
299
- if not self.find_text_field or not self.replace_text_field:
300
- return
301
-
302
- # Bind shortcuts to both find and replace fields
303
- for widget in [self.find_text_field, self.replace_text_field]:
304
- # F3 - Find Next
305
- widget.bind('<F3>', lambda e: self.find_next())
306
- # Shift+F3 - Find Previous
307
- widget.bind('<Shift-F3>', lambda e: self.find_previous())
308
- # Ctrl+Enter - Search/Preview
309
- widget.bind('<Control-Return>', lambda e: self.preview_find_replace())
310
- # Ctrl+H - Focus Replace field
311
- widget.bind('<Control-h>', lambda e: self.replace_text_field.focus_set())
312
- # Ctrl+F - Focus Find field
313
- widget.bind('<Control-f>', lambda e: self.find_text_field.focus_set())
314
- # Escape - Clear highlights
315
- widget.bind('<Escape>', lambda e: self._clear_all_highlights())
316
-
317
- def _clear_all_highlights(self):
318
- """Clear all search highlights from input and output tabs."""
319
- if not self.input_tabs or not self.output_tabs:
320
- return
321
-
322
- try:
323
- active_input_tab, active_output_tab = self._get_active_tabs()
324
-
325
- # Clear highlights
326
- active_input_tab.text.tag_remove("yellow_highlight", "1.0", tk.END)
327
- active_input_tab.text.tag_remove("current_match", "1.0", tk.END)
328
- active_output_tab.text.tag_remove("pink_highlight", "1.0", tk.END)
329
- active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
330
-
331
- # Reset match count
332
- self.match_count_label.config(text="Found matches: 0")
333
- except Exception as e:
334
- self.logger.warning(f"Error clearing highlights: {e}")
335
-
336
- def _get_active_tabs(self) -> Tuple[Any, Any]:
337
- """Get the currently active input and output tabs."""
338
- try:
339
- if not self.input_tabs or not self.output_tabs:
340
- raise ValueError("No tabs available")
341
-
342
- input_selection = self.input_notebook.select()
343
- output_selection = self.output_notebook.select()
344
-
345
- if not input_selection or not output_selection:
346
- raise ValueError("No tab selected")
347
-
348
- active_input_tab = self.input_tabs[self.input_notebook.index(input_selection)]
349
- active_output_tab = self.output_tabs[self.output_notebook.index(output_selection)]
350
- return active_input_tab, active_output_tab
351
- except (IndexError, ValueError, tk.TclError) as e:
352
- self.logger.error(f"Error getting active tabs: {e}")
353
- raise
354
-
355
- def _get_search_pattern(self) -> str:
356
- """
357
- Helper to build the regex pattern for Find & Replace.
358
-
359
- Returns:
360
- Compiled regex pattern string
361
- """
362
- find_str = self.find_text_field.get().strip()
363
-
364
- # Process escape sequences if not in regex mode
365
- if not self.regex_mode_var.get():
366
- find_str = self._process_escape_sequences(find_str)
367
-
368
- # Determine case sensitivity and base option
369
- is_case_sensitive = self.match_case_var.get()
370
- base_option = self.fr_option_var.get()
371
-
372
- # Check cache with size limit
373
- cache_key = (find_str, base_option, is_case_sensitive, self.regex_mode_var.get())
374
- if cache_key in self._regex_cache:
375
- return self._regex_cache[cache_key]
376
-
377
- # Clear cache if it's too large
378
- if len(self._regex_cache) >= self._regex_cache_max_size:
379
- # Remove oldest entries (simple FIFO)
380
- keys_to_remove = list(self._regex_cache.keys())[:self._regex_cache_max_size // 2]
381
- for key in keys_to_remove:
382
- del self._regex_cache[key]
383
-
384
- if self.regex_mode_var.get():
385
- pattern = find_str
386
- else:
387
- search_term = re.escape(find_str)
388
- if base_option == "whole_words":
389
- search_term = r'\b' + search_term + r'\b'
390
- elif base_option == "match_prefix":
391
- search_term = r'\b' + search_term
392
- elif base_option == "match_suffix":
393
- search_term = search_term + r'\b'
394
- pattern = search_term
395
-
396
- self._regex_cache[cache_key] = pattern
397
- return pattern
398
-
399
- def _process_escape_sequences(self, text: str) -> str:
400
- """Process escape sequences like \\n, \\t, \\r in text mode."""
401
- # Only process if the text contains backslash
402
- if '\\' not in text:
403
- return text
404
-
405
- # Replace common escape sequences
406
- replacements = {
407
- '\\n': '\n',
408
- '\\t': '\t',
409
- '\\r': '\r',
410
- '\\\\': '\\',
411
- }
412
-
413
- result = text
414
- for escape, char in replacements.items():
415
- result = result.replace(escape, char)
416
-
417
- return result
418
-
419
- def preview_find_replace(self):
420
- """Highlights matches in input and output without replacing using progressive search."""
421
- if not self.input_tabs or not self.output_tabs:
422
- self.logger.warning("Text widgets not set for Find & Replace")
423
- return
424
-
425
- active_input_tab, active_output_tab = self._get_active_tabs()
426
-
427
- # Reset replacement count and skip tracking when starting new search
428
- self.replaced_count = 0
429
- self.replaced_count_label.config(text="Replaced matches: 0")
430
- self.skipped_matches = set()
431
- self.all_matches_processed = False
432
- self.loop_start_position = None
433
-
434
- # Clear existing highlights
435
- if PROGRESSIVE_SEARCH_AVAILABLE and self.search_highlighter:
436
- self.search_highlighter.clear_highlights(active_input_tab.text, "yellow_highlight")
437
- self.search_highlighter.clear_highlights(active_output_tab.text, "pink_highlight")
438
- else:
439
- active_input_tab.text.tag_remove("yellow_highlight", "1.0", tk.END)
440
- active_output_tab.text.tag_remove("pink_highlight", "1.0", tk.END)
441
-
442
- active_output_tab.text.config(state="normal")
443
- input_content = active_input_tab.text.get("1.0", tk.END)
444
- active_output_tab.text.delete("1.0", tk.END)
445
- active_output_tab.text.insert("1.0", input_content)
446
-
447
- find_str = self.find_text_field.get()
448
- if not find_str:
449
- active_output_tab.text.config(state="disabled")
450
- self.match_count_label.config(text="Found matches: 0")
451
- return
452
-
453
- # Use progressive search if available
454
- if PROGRESSIVE_SEARCH_AVAILABLE and self.search_highlighter and self.find_replace_processor:
455
- self._preview_with_progressive_search(active_input_tab, active_output_tab, find_str)
456
- else:
457
- self._preview_with_basic_search(active_input_tab, active_output_tab, find_str)
458
-
459
- active_output_tab.text.config(state="disabled")
460
-
461
- def _preview_with_progressive_search(self, input_tab, output_tab, find_str):
462
- """Use progressive search for preview highlighting."""
463
- pattern = self._get_search_pattern()
464
- replace_str = self.replace_text_field.get()
465
- case_sensitive = self.match_case_var.get()
466
- use_regex = True # Always use regex since we generate regex patterns
467
-
468
- # Cancel any existing operations for these widgets
469
- self.operation_manager.cancel_widget_operations(input_tab.text)
470
- self.operation_manager.cancel_widget_operations(output_tab.text)
471
-
472
- # Progress callback for updating match count
473
- def progress_callback(operation):
474
- if hasattr(operation, 'progress') and hasattr(operation.progress, 'matches_found'):
475
- self.match_count_label.config(text=f"Found matches: {operation.progress.matches_found}")
476
-
477
- # Completion callback
478
- def completion_callback(operation):
479
- if hasattr(operation, 'matches'):
480
- self.match_count_label.config(text=f"Found matches: {len(operation.matches)}")
481
-
482
- # Error callback
483
- def error_callback(operation, error_msg):
484
- self.logger.error(f"Progressive search error: {error_msg}")
485
- self.match_count_label.config(text="Search Error")
486
-
487
- try:
488
- # Start progressive highlighting for input (find matches)
489
- input_op_id = self.search_highlighter.search_and_highlight(
490
- text_widget=input_tab.text,
491
- pattern=pattern,
492
- tag_name="yellow_highlight",
493
- mode=HighlightMode.PROGRESSIVE,
494
- flags=0 if case_sensitive else re.IGNORECASE,
495
- progress_callback=progress_callback,
496
- completion_callback=completion_callback
497
- )
498
-
499
- # Generate preview with find/replace processor
500
- if replace_str:
501
- preview_op_id = self.find_replace_processor.generate_preview(
502
- text_widget=output_tab.text,
503
- find_pattern=pattern,
504
- replace_text=replace_str,
505
- case_sensitive=case_sensitive,
506
- use_regex=use_regex,
507
- progress_callback=progress_callback
508
- )
509
-
510
- # Track operations
511
- self.active_search_operations[input_op_id] = 'input_highlight'
512
- self.active_search_operations[preview_op_id] = 'preview_generation'
513
- else:
514
- # Just highlight matches in output too
515
- output_op_id = self.search_highlighter.search_and_highlight(
516
- text_widget=output_tab.text,
517
- pattern=pattern,
518
- tag_name="pink_highlight",
519
- mode=HighlightMode.PROGRESSIVE,
520
- flags=0 if case_sensitive else re.IGNORECASE
521
- )
522
-
523
- self.active_search_operations[input_op_id] = 'input_highlight'
524
- self.active_search_operations[output_op_id] = 'output_highlight'
525
-
526
- except Exception as e:
527
- self.logger.error(f"Error starting progressive search: {e}")
528
- self._preview_with_basic_search(input_tab, output_tab, find_str)
529
-
530
- def _preview_with_basic_search(self, input_tab, output_tab, find_str):
531
- """Fallback to basic search for preview highlighting."""
532
- pattern = self._get_search_pattern()
533
- flags = 0 if self.match_case_var.get() else re.IGNORECASE
534
-
535
- match_count = 0
536
- try:
537
- input_content = input_tab.text.get("1.0", tk.END)
538
-
539
- for match in re.finditer(pattern, input_content, flags):
540
- start, end = match.span()
541
- input_tab.text.tag_add("yellow_highlight", f"1.0 + {start}c", f"1.0 + {end}c")
542
- match_count += 1
543
-
544
- for match in re.finditer(pattern, output_tab.text.get("1.0", tk.END), flags):
545
- start, end = match.span()
546
- output_tab.text.tag_add("pink_highlight", f"1.0 + {start}c", f"1.0 + {end}c")
547
-
548
- except re.error as e:
549
- self.logger.error(f"Regex error in preview: {e}")
550
- match_count = "Regex Error"
551
- # Show helpful error message
552
- error_msg = self._get_regex_error_help(str(e))
553
- self._show_warning("Regex Error", f"Invalid regular expression:\n\n{e}\n\n{error_msg}")
554
-
555
- self.match_count_label.config(text=f"Found matches: {match_count}")
556
-
557
- def _get_regex_error_help(self, error_msg: str) -> str:
558
- """Provide helpful suggestions for common regex errors."""
559
- error_msg_lower = error_msg.lower()
560
-
561
- if "unbalanced parenthesis" in error_msg_lower or "missing )" in error_msg_lower:
562
- return "Tip: Make sure all opening parentheses '(' have matching closing parentheses ')'."
563
- elif "nothing to repeat" in error_msg_lower:
564
- return "Tip: Quantifiers like *, +, ? must follow a character or group. Use \\* to match a literal asterisk."
565
- elif "bad escape" in error_msg_lower:
566
- return "Tip: Invalid escape sequence. Use \\\\ for a literal backslash."
567
- elif "unterminated character set" in error_msg_lower or "missing ]" in error_msg_lower:
568
- return "Tip: Character sets must be closed with ']'. Use \\[ to match a literal bracket."
569
- elif "bad character range" in error_msg_lower:
570
- return "Tip: In character sets like [a-z], the first character must come before the second."
571
- else:
572
- return "Tip: Check your regex syntax. Common issues: unescaped special characters (. * + ? [ ] ( ) { } ^ $ | \\)"
573
-
574
- def highlight_processed_results(self):
575
- """Highlights input (found) and output (replaced) text after processing."""
576
- if not self.input_tabs or not self.output_tabs:
577
- return
578
-
579
- active_input_tab, active_output_tab = self._get_active_tabs()
580
-
581
- active_input_tab.text.tag_remove("yellow_highlight", "1.0", tk.END)
582
- active_output_tab.text.config(state="normal")
583
- active_output_tab.text.tag_remove("pink_highlight", "1.0", tk.END)
584
-
585
- find_str = self.find_text_field.get()
586
- replace_str = self.replace_text_field.get()
587
- if not find_str:
588
- active_output_tab.text.config(state="disabled")
589
- self.match_count_label.config(text="Found matches: 0")
590
- return
591
-
592
- pattern = self._get_search_pattern()
593
- flags = 0 if self.match_case_var.get() else re.IGNORECASE
594
-
595
- match_count = 0
596
- try:
597
- for match in re.finditer(pattern, active_input_tab.text.get("1.0", tk.END), flags):
598
- start, end = match.span()
599
- active_input_tab.text.tag_add("yellow_highlight", f"1.0 + {start}c", f"1.0 + {end}c")
600
- match_count += 1
601
-
602
- if replace_str:
603
- for match in re.finditer(re.escape(replace_str), active_output_tab.text.get("1.0", tk.END), flags):
604
- start, end = match.span()
605
- active_output_tab.text.tag_add("pink_highlight", f"1.0 + {start}c", f"1.0 + {end}c")
606
- except re.error as e:
607
- self.logger.error(f"Regex error in highlight: {e}")
608
- match_count = "Regex Error"
609
-
610
- self.match_count_label.config(text=f"Found matches: {match_count}")
611
- active_output_tab.text.config(state="disabled")
612
-
613
- def find_next(self):
614
- """Moves to the next match in the input text area with automatic highlighting."""
615
- if not self.input_tabs or not self.output_tabs:
616
- return
617
-
618
- active_input_tab, active_output_tab = self._get_active_tabs()
619
- find_str = self.find_text_field.get()
620
-
621
- if not find_str:
622
- return
623
-
624
- # First, run preview to highlight all matches
625
- self.preview_find_replace()
626
-
627
- # Focus on input text area
628
- active_input_tab.text.focus_set()
629
-
630
- # Get current cursor position
631
- try:
632
- current_pos = active_input_tab.text.index(tk.INSERT)
633
- except:
634
- current_pos = "1.0"
635
-
636
- # Search for next occurrence
637
- try:
638
- # Get the search pattern (handles all matching options)
639
- pattern = self._get_search_pattern()
640
- content = active_input_tab.text.get("1.0", tk.END)
641
- flags = 0 if self.match_case_var.get() else re.IGNORECASE
642
- matches = list(re.finditer(pattern, content, flags))
643
-
644
- if matches:
645
- # Store matches for navigation
646
- self.input_matches = matches
647
-
648
- # Find current position in characters
649
- current_char = len(active_input_tab.text.get("1.0", current_pos))
650
-
651
- # Find next match after current position
652
- next_match = None
653
- next_index = 0
654
- for i, match in enumerate(matches):
655
- if match.start() > current_char:
656
- next_match = match
657
- next_index = i
658
- break
659
-
660
- # If no match found after current position, wrap to first match
661
- if not next_match:
662
- next_match = matches[0]
663
- next_index = 0
664
-
665
- self.current_match_index = next_index
666
-
667
- # Convert character position back to line.column
668
- start_pos = f"1.0 + {next_match.start()}c"
669
- end_pos = f"1.0 + {next_match.end()}c"
670
-
671
- # Clear previous selection and highlight current match
672
- active_input_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
673
- active_input_tab.text.tag_remove("current_match", "1.0", tk.END)
674
- active_input_tab.text.tag_add("current_match", start_pos, end_pos)
675
- active_input_tab.text.tag_config("current_match", background="red", foreground="white")
676
- active_input_tab.text.mark_set(tk.INSERT, end_pos)
677
- active_input_tab.text.see(start_pos)
678
-
679
- # Update match count label
680
- self.match_count_label.config(text=f"Found matches: {len(matches)} (current: {next_index + 1})")
681
-
682
- except Exception as e:
683
- self.logger.warning(f"Find next error: {e}")
684
-
685
- def find_previous(self):
686
- """Moves to the previous match in the input text area with automatic highlighting."""
687
- if not self.input_tabs or not self.output_tabs:
688
- return
689
-
690
- active_input_tab, active_output_tab = self._get_active_tabs()
691
- find_str = self.find_text_field.get()
692
-
693
- if not find_str:
694
- return
695
-
696
- # First, run preview to highlight all matches
697
- self.preview_find_replace()
698
-
699
- # Focus on input text area
700
- active_input_tab.text.focus_set()
701
-
702
- # Get current cursor position
703
- try:
704
- current_pos = active_input_tab.text.index(tk.INSERT)
705
- except:
706
- current_pos = "1.0"
707
-
708
- # Search for previous occurrence
709
- try:
710
- # Get the search pattern (handles all matching options)
711
- pattern = self._get_search_pattern()
712
- content = active_input_tab.text.get("1.0", tk.END)
713
- flags = 0 if self.match_case_var.get() else re.IGNORECASE
714
- matches = list(re.finditer(pattern, content, flags))
715
-
716
- if matches:
717
- # Store matches for navigation
718
- self.input_matches = matches
719
-
720
- # Find current position in characters
721
- current_char = len(active_input_tab.text.get("1.0", current_pos))
722
-
723
- # Find previous match before current position
724
- prev_match = None
725
- prev_index = 0
726
- for i in reversed(range(len(matches))):
727
- match = matches[i]
728
- if match.start() < current_char:
729
- prev_match = match
730
- prev_index = i
731
- break
732
-
733
- # If no match found before current position, wrap to last match
734
- if not prev_match:
735
- prev_match = matches[-1]
736
- prev_index = len(matches) - 1
737
-
738
- self.current_match_index = prev_index
739
-
740
- # Convert character position back to line.column
741
- start_pos = f"1.0 + {prev_match.start()}c"
742
- end_pos = f"1.0 + {prev_match.end()}c"
743
-
744
- # Clear previous selection and highlight current match
745
- active_input_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
746
- active_input_tab.text.tag_remove("current_match", "1.0", tk.END)
747
- active_input_tab.text.tag_add("current_match", start_pos, end_pos)
748
- active_input_tab.text.tag_config("current_match", background="red", foreground="white")
749
- active_input_tab.text.mark_set(tk.INSERT, start_pos)
750
- active_input_tab.text.see(start_pos)
751
-
752
- # Update match count label
753
- self.match_count_label.config(text=f"Found matches: {len(matches)} (current: {prev_index + 1})")
754
-
755
- except Exception as e:
756
- self.logger.warning(f"Find previous error: {e}")
757
-
758
- def replace_all(self) -> str:
759
- """
760
- Performs find and replace on all matches.
761
-
762
- Returns:
763
- Processed text with all replacements made
764
- """
765
- if not self.input_tabs:
766
- return ""
767
-
768
- active_input_tab, _ = self._get_active_tabs()
769
- find_str = self.find_text_field.get().strip()
770
- replace_str = self.replace_text_field.get().strip()
771
-
772
- input_text = active_input_tab.text.get("1.0", tk.END)
773
-
774
- if not find_str:
775
- return input_text.strip()
776
-
777
- # Save state for undo
778
- self._save_undo_state(input_text, find_str, replace_str)
779
-
780
- # Reset replacement count for Replace All
781
- self.replaced_count = 0
782
-
783
- # Add to history
784
- self._add_to_history("find_history", find_str)
785
- if replace_str: # Only add to replace history if not empty
786
- self._add_to_history("replace_history", replace_str)
787
-
788
- # Use optimized find/replace processor if available
789
- if PROGRESSIVE_SEARCH_AVAILABLE and self.find_replace_processor:
790
- try:
791
- result = self.find_replace_processor.process_find_replace(
792
- text=input_text,
793
- find_pattern=find_str,
794
- replace_text=replace_str,
795
- mode="Regex" if self.regex_mode_var.get() else "Text",
796
- options={
797
- 'ignore_case': not self.match_case_var.get(),
798
- 'whole_words': self.fr_option_var.get() == "whole_words",
799
- 'match_prefix': self.fr_option_var.get() == "match_prefix",
800
- 'match_suffix': self.fr_option_var.get() == "match_suffix"
801
- }
802
- )
803
-
804
- return result.processed_text if result.success else f"Find/Replace Error: {result.error_message}"
805
-
806
- except Exception as e:
807
- self.logger.warning(f"Optimized find/replace failed, falling back to basic: {e}")
808
-
809
- # Fallback to basic find/replace implementation
810
- is_case_sensitive = self.match_case_var.get()
811
- base_option = self.fr_option_var.get()
812
-
813
- if self.regex_mode_var.get():
814
- try:
815
- flags = 0 if is_case_sensitive else re.IGNORECASE
816
- # Count matches before replacement
817
- matches = re.findall(find_str, input_text, flags)
818
- self.replaced_count += len(matches)
819
- result = re.sub(find_str, replace_str, input_text, flags=flags).strip()
820
- self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
821
- return result
822
- except re.error as e:
823
- return f"Regex Error: {e}"
824
-
825
- # Handle different text matching options
826
- if base_option == "whole_words":
827
- # Implement whole words matching
828
- flags = 0 if is_case_sensitive else re.IGNORECASE
829
- pattern = r'\b' + re.escape(find_str) + r'\b'
830
- try:
831
- # Count matches before replacement
832
- matches = re.findall(pattern, input_text, flags)
833
- self.replaced_count += len(matches)
834
- result = re.sub(pattern, replace_str, input_text, flags=flags).strip()
835
- self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
836
- return result
837
- except re.error as e:
838
- return f"Whole words error: {e}"
839
-
840
- elif base_option == "match_prefix":
841
- # Implement prefix matching
842
- flags = 0 if is_case_sensitive else re.IGNORECASE
843
- pattern = r'\b' + re.escape(find_str)
844
- try:
845
- # Count matches before replacement
846
- matches = re.findall(pattern, input_text, flags)
847
- self.replaced_count += len(matches)
848
- result = re.sub(pattern, replace_str, input_text, flags=flags).strip()
849
- self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
850
- return result
851
- except re.error as e:
852
- return f"Prefix match error: {e}"
853
-
854
- elif base_option == "match_suffix":
855
- # Implement suffix matching
856
- flags = 0 if is_case_sensitive else re.IGNORECASE
857
- pattern = re.escape(find_str) + r'\b'
858
- try:
859
- # Count matches before replacement
860
- matches = re.findall(pattern, input_text, flags)
861
- self.replaced_count += len(matches)
862
- result = re.sub(pattern, replace_str, input_text, flags=flags).strip()
863
- self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
864
- return result
865
- except re.error as e:
866
- return f"Suffix match error: {e}"
867
-
868
- else:
869
- # Simple case-sensitive or case-insensitive replacement
870
- if is_case_sensitive:
871
- # Count occurrences before replacement
872
- count = input_text.count(find_str)
873
- self.replaced_count += count
874
- result = input_text.replace(find_str, replace_str).strip()
875
- self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
876
- return result
877
- else:
878
- # Case-insensitive replacement
879
- pattern = re.escape(find_str)
880
- # Count matches before replacement
881
- matches = re.findall(pattern, input_text, re.IGNORECASE)
882
- self.replaced_count += len(matches)
883
- result = re.sub(pattern, replace_str, input_text, flags=re.IGNORECASE).strip()
884
- self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
885
- return result
886
-
887
- def replace_single(self):
888
- """Replaces the current match and moves to next match in the output text area."""
889
- if not self.output_tabs:
890
- return
891
-
892
- active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
893
- find_str = self.find_text_field.get()
894
- replace_str = self.replace_text_field.get()
895
-
896
- if not find_str:
897
- return
898
-
899
- # Focus on output text area
900
- active_output_tab.text.focus_set()
901
-
902
- # Enable editing
903
- active_output_tab.text.config(state="normal")
904
-
905
- try:
906
- # Get current cursor position
907
- try:
908
- current_pos = active_output_tab.text.index(tk.INSERT)
909
- except:
910
- current_pos = "1.0"
911
-
912
- # Find the next match from current position
913
- next_match_pos = self._find_next_match_in_output(current_pos)
914
-
915
- if next_match_pos:
916
- start_pos, end_pos = next_match_pos
917
-
918
- # Replace the match (handle regex replacement)
919
- matched_text = active_output_tab.text.get(start_pos, end_pos)
920
-
921
- if self.regex_mode_var.get():
922
- # Use regex replacement with backreferences
923
- pattern = self._get_search_pattern()
924
- flags = 0 if self.match_case_var.get() else re.IGNORECASE
925
-
926
- try:
927
- # Use re.sub to handle backreferences like \1, \2, \3
928
- replacement_text = re.sub(pattern, replace_str, matched_text, count=1, flags=flags)
929
-
930
- # If no replacement happened, use literal replacement
931
- if replacement_text == matched_text:
932
- replacement_text = replace_str
933
- except re.error:
934
- # Regex error, use literal replacement
935
- replacement_text = replace_str
936
- else:
937
- replacement_text = replace_str
938
-
939
- active_output_tab.text.delete(start_pos, end_pos)
940
- active_output_tab.text.insert(start_pos, replacement_text)
941
-
942
- # Update replacement count
943
- self.replaced_count += 1
944
- self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
945
-
946
- # Update history
947
- self._add_to_history("find_history", find_str)
948
- if replace_str: # Only add to replace history if not empty
949
- self._add_to_history("replace_history", replace_str)
950
-
951
- # Calculate new end position after replacement
952
- new_end_pos = f"{start_pos} + {len(replace_str)}c"
953
-
954
- # Set cursor after the replacement
955
- active_output_tab.text.mark_set(tk.INSERT, new_end_pos)
956
-
957
- # Find and highlight the next match
958
- next_next_match = self._find_next_match_in_output(new_end_pos)
959
- if next_next_match:
960
- next_start, next_end = next_next_match
961
-
962
- # Clear previous highlights
963
- active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
964
- active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
965
-
966
- # Highlight the next match
967
- active_output_tab.text.tag_add("current_match", next_start, next_end)
968
- active_output_tab.text.tag_config("current_match", background="red", foreground="white")
969
- active_output_tab.text.see(next_start)
970
-
971
- # Set cursor to the highlighted match
972
- active_output_tab.text.mark_set(tk.INSERT, next_start)
973
- else:
974
- # No more matches after current position, check for looping
975
- self._handle_end_of_matches_replace()
976
- else:
977
- # No matches found at current position, start from beginning
978
- first_match = self._find_next_match_in_output("1.0")
979
- if first_match:
980
- start_pos, end_pos = first_match
981
-
982
- # Replace the match
983
- active_output_tab.text.delete(start_pos, end_pos)
984
- active_output_tab.text.insert(start_pos, replace_str)
985
-
986
- # Update replacement count
987
- self.replaced_count += 1
988
- self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
989
-
990
- # Update history
991
- self._add_to_history("find_history", find_str)
992
- if replace_str:
993
- self._add_to_history("replace_history", replace_str)
994
-
995
- # Set cursor after replacement
996
- new_end_pos = f"{start_pos} + {len(replace_str)}c"
997
- active_output_tab.text.mark_set(tk.INSERT, new_end_pos)
998
- active_output_tab.text.see(start_pos)
999
-
1000
- # Find and highlight the next match
1001
- next_next_match = self._find_next_match_in_output(new_end_pos)
1002
- if next_next_match:
1003
- next_start, next_end = next_next_match
1004
-
1005
- # Clear previous highlights
1006
- active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1007
- active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1008
-
1009
- # Highlight the next match
1010
- active_output_tab.text.tag_add("current_match", next_start, next_end)
1011
- active_output_tab.text.tag_config("current_match", background="red", foreground="white")
1012
- active_output_tab.text.see(next_start)
1013
-
1014
- # Set cursor to the highlighted match
1015
- active_output_tab.text.mark_set(tk.INSERT, next_start)
1016
- else:
1017
- # No matches found at all, clear highlights
1018
- active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1019
- active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1020
-
1021
- except Exception as e:
1022
- self.logger.warning(f"Replace single error: {e}")
1023
- finally:
1024
- active_output_tab.text.config(state="disabled")
1025
-
1026
- def skip_single(self):
1027
- """Skips the current match and moves to next match in the output text area."""
1028
- if not self.output_tabs:
1029
- return
1030
-
1031
- active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1032
- find_str = self.find_text_field.get()
1033
-
1034
- if not find_str:
1035
- return
1036
-
1037
- # Focus on output text area
1038
- active_output_tab.text.focus_set()
1039
-
1040
- try:
1041
- # Get current cursor position
1042
- try:
1043
- current_pos = active_output_tab.text.index(tk.INSERT)
1044
- except:
1045
- current_pos = "1.0"
1046
-
1047
- # Find the current match at cursor position
1048
- current_match_pos = self._find_current_match_in_output(current_pos)
1049
-
1050
- if current_match_pos:
1051
- start_pos, end_pos = current_match_pos
1052
-
1053
- # Add this match position to skipped matches
1054
- self.skipped_matches.add((start_pos, end_pos))
1055
-
1056
- # Find the next match after current position
1057
- next_match_pos = self._find_next_match_in_output(end_pos)
1058
-
1059
- if next_match_pos:
1060
- next_start, next_end = next_match_pos
1061
-
1062
- # Clear previous highlights
1063
- active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1064
- active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1065
-
1066
- # Highlight the next match
1067
- active_output_tab.text.tag_add("current_match", next_start, next_end)
1068
- active_output_tab.text.tag_config("current_match", background="yellow", foreground="black")
1069
- active_output_tab.text.see(next_start)
1070
-
1071
- # Set cursor to the highlighted match
1072
- active_output_tab.text.mark_set(tk.INSERT, next_start)
1073
- else:
1074
- # No more matches found, check if we should loop back to beginning
1075
- self._handle_end_of_matches_skip()
1076
- else:
1077
- # No current match, find the first match from current position
1078
- next_match_pos = self._find_next_match_in_output(current_pos)
1079
- if next_match_pos:
1080
- next_start, next_end = next_match_pos
1081
-
1082
- # Clear previous highlights
1083
- active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1084
- active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1085
-
1086
- # Highlight the match
1087
- active_output_tab.text.tag_add("current_match", next_start, next_end)
1088
- active_output_tab.text.tag_config("current_match", background="yellow", foreground="black")
1089
- active_output_tab.text.see(next_start)
1090
-
1091
- # Set cursor to the highlighted match
1092
- active_output_tab.text.mark_set(tk.INSERT, next_start)
1093
-
1094
- except Exception as e:
1095
- self.logger.warning(f"Skip single error: {e}")
1096
-
1097
- def _find_current_match_in_output(self, cursor_pos) -> Optional[Tuple[str, str]]:
1098
- """Find the match at the current cursor position in output text area."""
1099
- if not self.output_tabs:
1100
- return None
1101
-
1102
- active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1103
- find_str = self.find_text_field.get()
1104
-
1105
- if not find_str:
1106
- return None
1107
-
1108
- try:
1109
- # Get the search pattern (handles all matching options)
1110
- pattern = self._get_search_pattern()
1111
- content = active_output_tab.text.get("1.0", tk.END)
1112
- flags = 0 if self.match_case_var.get() else re.IGNORECASE
1113
- matches = list(re.finditer(pattern, content, flags))
1114
-
1115
- if matches:
1116
- # Find current position in characters
1117
- cursor_char = len(active_output_tab.text.get("1.0", cursor_pos))
1118
-
1119
- # Find match that contains or starts at current position
1120
- for match in matches:
1121
- if match.start() <= cursor_char <= match.end():
1122
- start_pos = f"1.0 + {match.start()}c"
1123
- end_pos = f"1.0 + {match.end()}c"
1124
- return (start_pos, end_pos)
1125
-
1126
- except Exception as e:
1127
- self.logger.warning(f"Find current match in output error: {e}")
1128
-
1129
- return None
1130
-
1131
- def _find_next_match_in_output(self, from_pos) -> Optional[Tuple[str, str]]:
1132
- """Find the next match in output text area from given position."""
1133
- if not self.output_tabs:
1134
- return None
1135
-
1136
- active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1137
- find_str = self.find_text_field.get()
1138
-
1139
- if not find_str:
1140
- return None
1141
-
1142
- try:
1143
- # Get the search pattern (handles all matching options)
1144
- pattern = self._get_search_pattern()
1145
- content = active_output_tab.text.get("1.0", tk.END)
1146
- flags = 0 if self.match_case_var.get() else re.IGNORECASE
1147
- matches = list(re.finditer(pattern, content, flags))
1148
-
1149
- if matches:
1150
- # Find current position in characters
1151
- from_char = len(active_output_tab.text.get("1.0", from_pos))
1152
-
1153
- # Find next match after current position
1154
- for match in matches:
1155
- if match.start() >= from_char:
1156
- start_pos = f"1.0 + {match.start()}c"
1157
- end_pos = f"1.0 + {match.end()}c"
1158
- return (start_pos, end_pos)
1159
-
1160
- # If no match found after current position, wrap to first match
1161
- if matches:
1162
- match = matches[0]
1163
- start_pos = f"1.0 + {match.start()}c"
1164
- end_pos = f"1.0 + {match.end()}c"
1165
- return (start_pos, end_pos)
1166
-
1167
- except Exception as e:
1168
- self.logger.warning(f"Find next match in output error: {e}")
1169
-
1170
- return None
1171
-
1172
- def _handle_end_of_matches_skip(self):
1173
- """Handle looping when reaching end of matches during skip operation."""
1174
- if not self.output_tabs:
1175
- return
1176
-
1177
- active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1178
-
1179
- # Check if there are any unprocessed matches from the beginning
1180
- first_match = self._find_next_match_in_output("1.0")
1181
- if first_match:
1182
- start_pos, end_pos = first_match
1183
-
1184
- # Check if this match was already skipped
1185
- if (start_pos, end_pos) not in self.skipped_matches:
1186
- # Clear previous highlights
1187
- active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1188
- active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1189
-
1190
- # Highlight the first match
1191
- active_output_tab.text.tag_add("current_match", start_pos, end_pos)
1192
- active_output_tab.text.tag_config("current_match", background="yellow", foreground="black")
1193
- active_output_tab.text.see(start_pos)
1194
-
1195
- # Set cursor to the highlighted match
1196
- active_output_tab.text.mark_set(tk.INSERT, start_pos)
1197
- return
1198
-
1199
- # All matches have been processed, clear highlights
1200
- active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1201
- active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1202
-
1203
- def _handle_end_of_matches_replace(self):
1204
- """Handle looping when reaching end of matches during replace operation."""
1205
- if not self.output_tabs:
1206
- return
1207
-
1208
- active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1209
- find_str = self.find_text_field.get()
1210
- replace_str = self.replace_text_field.get()
1211
-
1212
- # Check if there are any matches from the beginning that haven't been replaced
1213
- first_match = self._find_next_match_in_output("1.0")
1214
- if first_match:
1215
- start_pos, end_pos = first_match
1216
-
1217
- # Replace the match
1218
- active_output_tab.text.delete(start_pos, end_pos)
1219
- active_output_tab.text.insert(start_pos, replace_str)
1220
-
1221
- # Update replacement count
1222
- self.replaced_count += 1
1223
- self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
1224
-
1225
- # Update history
1226
- self._add_to_history("find_history", find_str)
1227
- if replace_str:
1228
- self._add_to_history("replace_history", replace_str)
1229
-
1230
- # Set cursor after replacement
1231
- new_end_pos = f"{start_pos} + {len(replace_str)}c"
1232
- active_output_tab.text.mark_set(tk.INSERT, new_end_pos)
1233
- active_output_tab.text.see(start_pos)
1234
-
1235
- # Find and highlight the next match
1236
- next_match = self._find_next_match_in_output(new_end_pos)
1237
- if next_match:
1238
- next_start, next_end = next_match
1239
-
1240
- # Clear previous highlights
1241
- active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1242
- active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1243
-
1244
- # Highlight the next match
1245
- active_output_tab.text.tag_add("current_match", next_start, next_end)
1246
- active_output_tab.text.tag_config("current_match", background="red", foreground="white")
1247
- active_output_tab.text.see(next_start)
1248
-
1249
- # Set cursor to the highlighted match
1250
- active_output_tab.text.mark_set(tk.INSERT, next_start)
1251
- else:
1252
- # No more matches, clear highlights
1253
- active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1254
- active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1255
- else:
1256
- # No matches found at all, clear highlights
1257
- active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1258
- active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1259
-
1260
- def show_find_history(self):
1261
- """Shows find history in a popup window."""
1262
- self._show_history_popup("Find History", "find_history", self.find_text_field)
1263
-
1264
- def show_replace_history(self):
1265
- """Shows replace history in a popup window."""
1266
- self._show_history_popup("Replace History", "replace_history", self.replace_text_field)
1267
-
1268
- def _show_history_popup(self, title: str, history_key: str, target_field):
1269
- """Generic method to show history popup."""
1270
- settings = self.settings_manager.get_tool_settings("Find & Replace Text")
1271
- history = settings.get(history_key, [])
1272
-
1273
- popup = tk.Toplevel(self.parent)
1274
- popup.title(title)
1275
- popup.geometry("400x300")
1276
- popup.transient(self.parent)
1277
- popup.grab_set()
1278
-
1279
- # Center the popup
1280
- popup.update_idletasks()
1281
- x = (popup.winfo_screenwidth() // 2) - (popup.winfo_width() // 2)
1282
- y = (popup.winfo_screenheight() // 2) - (popup.winfo_height() // 2)
1283
- popup.geometry(f"+{x}+{y}")
1284
-
1285
- # History listbox
1286
- frame = ttk.Frame(popup)
1287
- frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
1288
-
1289
- ttk.Label(frame, text=f"Last 50 {title.lower()} terms:").pack(anchor="w")
1290
-
1291
- listbox_frame = ttk.Frame(frame)
1292
- listbox_frame.pack(fill=tk.BOTH, expand=True, pady=(5,0))
1293
-
1294
- scrollbar = ttk.Scrollbar(listbox_frame)
1295
- scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
1296
-
1297
- listbox = tk.Listbox(listbox_frame, yscrollcommand=scrollbar.set)
1298
- listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
1299
- scrollbar.config(command=listbox.yview)
1300
-
1301
- # Populate listbox with history (most recent first)
1302
- for item in reversed(history[-50:]):
1303
- listbox.insert(tk.END, item)
1304
-
1305
- # Buttons
1306
- button_frame = ttk.Frame(frame)
1307
- button_frame.pack(fill=tk.X, pady=(10,0))
1308
-
1309
- def use_selected():
1310
- selection = listbox.curselection()
1311
- if selection:
1312
- selected_text = listbox.get(selection[0])
1313
- target_field.delete(0, tk.END)
1314
- target_field.insert(0, selected_text)
1315
- popup.destroy()
1316
- self.on_setting_change()
1317
-
1318
- def clear_history():
1319
- settings[history_key] = []
1320
- self.settings_manager.save_settings()
1321
- listbox.delete(0, tk.END)
1322
-
1323
- ttk.Button(button_frame, text="Use Selected", command=use_selected).pack(side=tk.LEFT, padx=(0,5))
1324
- ttk.Button(button_frame, text="Clear History", command=clear_history).pack(side=tk.LEFT, padx=5)
1325
- ttk.Button(button_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT)
1326
-
1327
- # Double-click to use selected
1328
- listbox.bind('<Double-Button-1>', lambda e: use_selected())
1329
-
1330
- def _add_to_history(self, history_key: str, value: str):
1331
- """Add a value to the specified history list."""
1332
- if not value:
1333
- return
1334
-
1335
- settings = self.settings_manager.get_tool_settings("Find & Replace Text")
1336
- history = settings.get(history_key, [])
1337
-
1338
- # Remove if already exists to avoid duplicates
1339
- if value in history:
1340
- history.remove(value)
1341
-
1342
- # Add to end (most recent)
1343
- history.append(value)
1344
-
1345
- # Keep only last 50 items
1346
- if len(history) > 50:
1347
- history = history[-50:]
1348
-
1349
- settings[history_key] = history
1350
- self.settings_manager.save_settings()
1351
-
1352
- def _save_undo_state(self, text: str, find_str: str, replace_str: str):
1353
- """Save current state for undo functionality."""
1354
- undo_entry = {
1355
- 'text': text,
1356
- 'find': find_str,
1357
- 'replace': replace_str,
1358
- 'timestamp': time.time()
1359
- }
1360
-
1361
- self.undo_stack.append(undo_entry)
1362
-
1363
- # Limit undo stack size
1364
- if len(self.undo_stack) > self.max_undo_stack:
1365
- self.undo_stack.pop(0)
1366
-
1367
- # Enable undo button
1368
- if hasattr(self, 'undo_button'):
1369
- self.undo_button.config(state="normal")
1370
-
1371
- def undo_replace_all(self):
1372
- """Undo the last Replace All operation."""
1373
- if not self.undo_stack:
1374
- self._show_info("Undo", "No Replace All operations to undo.")
1375
- return
1376
-
1377
- if not self.output_tabs:
1378
- return
1379
-
1380
- try:
1381
- # Get last undo state
1382
- undo_entry = self.undo_stack.pop()
1383
-
1384
- # Restore the text
1385
- active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1386
- active_output_tab.text.config(state="normal")
1387
- active_output_tab.text.delete("1.0", tk.END)
1388
- active_output_tab.text.insert("1.0", undo_entry['text'])
1389
- active_output_tab.text.config(state="disabled")
1390
-
1391
- # Reset replacement count
1392
- self.replaced_count = 0
1393
- self.replaced_count_label.config(text="Replaced matches: 0")
1394
-
1395
- # Disable undo button if no more undo states
1396
- if not self.undo_stack and hasattr(self, 'undo_button'):
1397
- self.undo_button.config(state="disabled")
1398
-
1399
- self._show_info("Undo", f"Undone: Replace '{undo_entry['find']}' with '{undo_entry['replace']}'")
1400
- except Exception as e:
1401
- self.logger.error(f"Error during undo: {e}")
1402
- self._show_warning("Undo Error", f"Failed to undo: {e}")
1403
-
1404
- def on_regex_mode_change(self):
1405
- """Handle changes to regex mode checkbox."""
1406
- if not self.option_radiobuttons:
1407
- return
1408
-
1409
- is_regex = self.regex_mode_var.get()
1410
-
1411
- # Disable/enable text matching options when regex mode is on/off
1412
- for key, rb in self.option_radiobuttons.items():
1413
- if key in ["whole_words", "match_prefix", "match_suffix"]:
1414
- if is_regex:
1415
- rb.config(state="disabled")
1416
- else:
1417
- rb.config(state="normal")
1418
-
1419
- # If regex mode is enabled and a disabled option is selected, reset to "none"
1420
- if is_regex and self.fr_option_var.get() in ["whole_words", "match_prefix", "match_suffix"]:
1421
- self.fr_option_var.set("none")
1422
-
1423
- # Enable/disable Pattern Library button based on regex mode
1424
- if hasattr(self, 'pattern_library_button'):
1425
- if is_regex:
1426
- self.pattern_library_button.config(state="normal")
1427
- else:
1428
- self.pattern_library_button.config(state="disabled")
1429
-
1430
- # Clear regex cache when options change
1431
- self._regex_cache.clear()
1432
-
1433
- # Notify parent of setting change
1434
- self.on_setting_change()
1435
-
1436
- # Re-run search if there's a find string
1437
- if hasattr(self, 'find_text_field') and self.find_text_field.get().strip():
1438
- self.parent.after_idle(self.preview_find_replace)
1439
-
1440
- def on_find_replace_option_change(self):
1441
- """Handle changes to Find & Replace radio button options."""
1442
- # Clear regex cache when options change
1443
- self._regex_cache.clear()
1444
-
1445
- # Notify parent of setting change
1446
- self.on_setting_change()
1447
-
1448
- # Re-run search if there's a find string
1449
- if hasattr(self, 'find_text_field') and self.find_text_field.get().strip():
1450
- # Use after_idle to avoid recursion and ensure UI is updated first
1451
- self.parent.after_idle(self.preview_find_replace)
1452
-
1453
- def _on_find_text_change(self, event=None):
1454
- """Handle changes to find text field."""
1455
- self.on_setting_change()
1456
-
1457
- def _on_replace_text_change(self, event=None):
1458
- """Handle changes to replace text field."""
1459
- self.on_setting_change()
1460
-
1461
- def on_setting_change(self):
1462
- """Notify parent that settings have changed."""
1463
- # This should be overridden by the parent or connected to a callback
1464
- pass
1465
-
1466
- def trigger_replace_all(self):
1467
- """Trigger the parent application's apply_tool method for Replace All functionality."""
1468
- # This calls the parent application's apply_tool method, which will:
1469
- # 1. Call _process_text_with_tool
1470
- # 2. Which calls self.find_replace_widget.replace_all()
1471
- # 3. And then update the output UI automatically
1472
- if hasattr(self, 'apply_tool_callback') and self.apply_tool_callback:
1473
- self.apply_tool_callback()
1474
- else:
1475
- # Fallback to direct replace_all if no callback is set
1476
- result = self.replace_all()
1477
- # We need to update the output manually since we're not going through the normal pipeline
1478
- if hasattr(self, 'output_tabs') and self.output_tabs:
1479
- active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1480
- active_output_tab.text.config(state="normal")
1481
- active_output_tab.text.delete("1.0", tk.END)
1482
- active_output_tab.text.insert("1.0", result)
1483
- active_output_tab.text.config(state="disabled")
1484
-
1485
- def show_pattern_library(self):
1486
- """Shows the Pattern Library window with regex patterns."""
1487
- # Get pattern library from settings
1488
- pattern_library = self.settings_manager.get_pattern_library()
1489
-
1490
- popup = tk.Toplevel(self.parent)
1491
- popup.title("Pattern Library")
1492
- popup.geometry("800x500")
1493
- popup.transient(self.parent)
1494
- popup.grab_set()
1495
-
1496
- # Center the popup
1497
- popup.update_idletasks()
1498
- x = (popup.winfo_screenwidth() // 2) - (popup.winfo_width() // 2)
1499
- y = (popup.winfo_screenheight() // 2) - (popup.winfo_height() // 2)
1500
- popup.geometry(f"+{x}+{y}")
1501
-
1502
- # Main frame
1503
- main_frame = ttk.Frame(popup)
1504
- main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
1505
-
1506
- # Title
1507
- ttk.Label(main_frame, text="Regex Pattern Library", font=("Arial", 12, "bold")).pack(anchor="w", pady=(0,10))
1508
-
1509
- # Treeview for the table
1510
- tree_frame = ttk.Frame(main_frame)
1511
- tree_frame.pack(fill=tk.BOTH, expand=True)
1512
-
1513
- # Create Treeview with scrollbars
1514
- tree_scroll_y = ttk.Scrollbar(tree_frame)
1515
- tree_scroll_y.pack(side=tk.RIGHT, fill=tk.Y)
1516
-
1517
- tree_scroll_x = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL)
1518
- tree_scroll_x.pack(side=tk.BOTTOM, fill=tk.X)
1519
-
1520
- tree = ttk.Treeview(tree_frame,
1521
- columns=("Find", "Replace", "Purpose"),
1522
- show="headings",
1523
- yscrollcommand=tree_scroll_y.set,
1524
- xscrollcommand=tree_scroll_x.set)
1525
- tree.pack(fill=tk.BOTH, expand=True)
1526
-
1527
- tree_scroll_y.config(command=tree.yview)
1528
- tree_scroll_x.config(command=tree.xview)
1529
-
1530
- # Configure columns
1531
- tree.heading("Find", text="Find")
1532
- tree.heading("Replace", text="Replace")
1533
- tree.heading("Purpose", text="Purpose")
1534
-
1535
- tree.column("Find", width=200, minwidth=150)
1536
- tree.column("Replace", width=150, minwidth=100)
1537
- tree.column("Purpose", width=300, minwidth=200)
1538
-
1539
- # Populate tree with patterns
1540
- def refresh_tree():
1541
- tree.delete(*tree.get_children())
1542
- for i, pattern in enumerate(pattern_library):
1543
- tree.insert("", tk.END, iid=i, values=(pattern["find"], pattern["replace"], pattern["purpose"]))
1544
-
1545
- refresh_tree()
1546
-
1547
- # Management buttons frame
1548
- button_frame = ttk.Frame(main_frame)
1549
- button_frame.pack(fill=tk.X, pady=(10,0))
1550
-
1551
- # Left side buttons (management)
1552
- left_buttons = ttk.Frame(button_frame)
1553
- left_buttons.pack(side=tk.LEFT)
1554
-
1555
- def add_pattern():
1556
- pattern_library.append({"find": "", "replace": "", "purpose": ""})
1557
- refresh_tree()
1558
- # Select the new item for editing
1559
- new_item_id = len(pattern_library) - 1
1560
- tree.selection_set(str(new_item_id))
1561
- tree.focus(str(new_item_id))
1562
- self.settings_manager.save_settings()
1563
-
1564
- def delete_pattern():
1565
- selection = tree.selection()
1566
- if selection:
1567
- item_id = int(selection[0])
1568
- del pattern_library[item_id]
1569
- refresh_tree()
1570
- self.settings_manager.save_settings()
1571
-
1572
- def move_up():
1573
- selection = tree.selection()
1574
- if selection:
1575
- item_id = int(selection[0])
1576
- if item_id > 0:
1577
- # Swap with previous item
1578
- pattern_library[item_id], pattern_library[item_id-1] = \
1579
- pattern_library[item_id-1], pattern_library[item_id]
1580
- refresh_tree()
1581
- tree.selection_set(str(item_id-1))
1582
- tree.focus(str(item_id-1))
1583
- self.settings_manager.save_settings()
1584
-
1585
- def move_down():
1586
- selection = tree.selection()
1587
- if selection:
1588
- item_id = int(selection[0])
1589
- if item_id < len(pattern_library) - 1:
1590
- # Swap with next item
1591
- pattern_library[item_id], pattern_library[item_id+1] = \
1592
- pattern_library[item_id+1], pattern_library[item_id]
1593
- refresh_tree()
1594
- tree.selection_set(str(item_id+1))
1595
- tree.focus(str(item_id+1))
1596
- self.settings_manager.save_settings()
1597
-
1598
- ttk.Button(left_buttons, text="Add", command=add_pattern).pack(side=tk.LEFT, padx=(0,5))
1599
- ttk.Button(left_buttons, text="Delete", command=delete_pattern).pack(side=tk.LEFT, padx=5)
1600
- ttk.Button(left_buttons, text="Move Up", command=move_up).pack(side=tk.LEFT, padx=5)
1601
- ttk.Button(left_buttons, text="Move Down", command=move_down).pack(side=tk.LEFT, padx=5)
1602
-
1603
- # Right side buttons (use/close)
1604
- right_buttons = ttk.Frame(button_frame)
1605
- right_buttons.pack(side=tk.RIGHT)
1606
-
1607
- def use_pattern():
1608
- selection = tree.selection()
1609
- if selection:
1610
- item_id = int(selection[0])
1611
- pattern = pattern_library[item_id]
1612
- self.find_text_field.delete(0, tk.END)
1613
- self.find_text_field.insert(0, pattern["find"])
1614
- self.replace_text_field.delete(0, tk.END)
1615
- self.replace_text_field.insert(0, pattern["replace"])
1616
- popup.destroy()
1617
- self.on_setting_change()
1618
-
1619
- def ai_help():
1620
- """Copy selected pattern to next empty input tab and switch to AI Tools."""
1621
- # Check if AI Tools is available
1622
- if not AI_TOOLS_AVAILABLE:
1623
- self._show_warning("AI Tools Not Available",
1624
- "AI Tools module is not available. Please ensure the ai_tools.py module is properly installed.")
1625
- return
1626
-
1627
- selection = tree.selection()
1628
- if not selection:
1629
- self._show_info("No Selection", "Please select a pattern from the list first.")
1630
- return
1631
-
1632
- item_id = int(selection[0])
1633
- pattern = pattern_library[item_id]
1634
-
1635
- # Create the AI help text
1636
- ai_help_text = f"""## Please help me understand this regex:
1637
-
1638
- Purpose: {pattern.get('purpose', 'No purpose specified')}
1639
- Find: {pattern['find']}
1640
- Replace: {pattern['replace']}"""
1641
-
1642
- # This would need to be implemented by the parent application
1643
- # For now, just show a message
1644
- self._show_info("AI Help", "AI Help integration would be implemented by the parent application.")
1645
- popup.destroy()
1646
-
1647
- ai_help_button = ttk.Button(right_buttons, text="AI Help", command=ai_help)
1648
- ai_help_button.pack(side=tk.LEFT, padx=5)
1649
- use_pattern_button = ttk.Button(right_buttons, text="Use Pattern", command=use_pattern)
1650
- use_pattern_button.pack(side=tk.LEFT, padx=5)
1651
- ttk.Button(right_buttons, text="Close", command=popup.destroy).pack(side=tk.LEFT, padx=(5,0))
1652
-
1653
- # Function to update button states based on selection
1654
- def update_button_states():
1655
- selection = tree.selection()
1656
- state = "normal" if selection else "disabled"
1657
- use_pattern_button.config(state=state)
1658
- if AI_TOOLS_AVAILABLE:
1659
- ai_help_button.config(state=state)
1660
- else:
1661
- ai_help_button.config(state="disabled")
1662
-
1663
- # Bind selection change to update button states
1664
- tree.bind('<<TreeviewSelect>>', lambda e: update_button_states())
1665
-
1666
- # Initial button state update
1667
- update_button_states()
1668
-
1669
- # Double-click to use pattern
1670
- tree.bind('<Double-Button-1>', lambda e: use_pattern())
1671
-
1672
- # Cell editing functionality
1673
- def on_cell_click(event):
1674
- item = tree.selection()[0] if tree.selection() else None
1675
- if item:
1676
- column = tree.identify_column(event.x)
1677
- if column in ['#1', '#2', '#3']: # Find, Replace, Purpose columns
1678
- self._edit_cell(tree, item, column, popup, pattern_library)
1679
-
1680
- tree.bind('<Button-1>', on_cell_click)
1681
-
1682
- def _edit_cell(self, tree, item, column, parent_window, pattern_library):
1683
- """Edit a cell in the pattern library tree."""
1684
- # Get current value
1685
- item_id = int(item)
1686
- pattern = pattern_library[item_id]
1687
-
1688
- column_map = {'#1': 'find', '#2': 'replace', '#3': 'purpose'}
1689
- field_name = column_map[column]
1690
- current_value = pattern[field_name]
1691
-
1692
- # Get cell position
1693
- bbox = tree.bbox(item, column)
1694
- if not bbox:
1695
- return
1696
-
1697
- # Create entry widget for editing
1698
- entry = tk.Entry(tree)
1699
- entry.place(x=bbox[0], y=bbox[1], width=bbox[2], height=bbox[3])
1700
- entry.insert(0, current_value)
1701
- entry.select_range(0, tk.END)
1702
- entry.focus()
1703
-
1704
- def save_edit():
1705
- new_value = entry.get()
1706
- pattern[field_name] = new_value
1707
- tree.set(item, column, new_value)
1708
- entry.destroy()
1709
- self.settings_manager.save_settings()
1710
-
1711
- def cancel_edit():
1712
- entry.destroy()
1713
-
1714
- entry.bind('<Return>', lambda e: save_edit())
1715
- entry.bind('<Escape>', lambda e: cancel_edit())
1716
- entry.bind('<FocusOut>', lambda e: save_edit())
1717
-
1718
-
1719
- class SettingsManager:
1720
- """
1721
- Interface for settings management that the FindReplaceWidget expects.
1722
- This should be implemented by the parent application.
1723
- """
1724
-
1725
- def get_tool_settings(self, tool_name: str) -> Dict[str, Any]:
1726
- """Get settings for a specific tool."""
1727
- raise NotImplementedError("Must be implemented by parent application")
1728
-
1729
- def save_settings(self):
1730
- """Save current settings to persistent storage."""
1731
- raise NotImplementedError("Must be implemented by parent application")
1732
-
1733
- def get_pattern_library(self) -> List[Dict[str, str]]:
1734
- """Get the regex pattern library."""
1735
- raise NotImplementedError("Must be implemented by parent application")
1736
-
1737
-
1738
- # Example usage and integration helper
1739
- def create_find_replace_widget(parent, settings_manager, logger=None):
1740
- """
1741
- Factory function to create a Find & Replace widget.
1742
-
1743
- Args:
1744
- parent: Parent widget/window
1745
- settings_manager: Object implementing SettingsManager interface
1746
- logger: Optional logger instance
1747
-
1748
- Returns:
1749
- FindReplaceWidget instance
1750
- """
1
+ """
2
+ Find & Replace Tool Module for Promera AI Commander
3
+
4
+ This module contains all the logic and UI components for the Find & Replace functionality,
5
+ extracted from the main application for better modularity and maintainability.
6
+ """
7
+
8
+ import tkinter as tk
9
+ from tkinter import ttk, messagebox
10
+ import re
11
+ import logging
12
+ import time
13
+ from typing import Optional, Tuple, Dict, Any, List
14
+
15
+ # Import diff utilities for preview
16
+ try:
17
+ from core.diff_utils import generate_find_replace_preview, FindReplacePreview
18
+ DIFF_UTILS_AVAILABLE = True
19
+ except ImportError:
20
+ DIFF_UTILS_AVAILABLE = False
21
+
22
+ # Import optimized components if available
23
+ try:
24
+ from core.optimized_search_highlighter import get_search_highlighter, OptimizedSearchHighlighter, HighlightMode
25
+ from core.optimized_find_replace import get_find_replace_processor, OptimizedFindReplace, ProcessingMode
26
+ from core.search_operation_manager import get_operation_manager, SearchOperationManager, CancellationReason
27
+ PROGRESSIVE_SEARCH_AVAILABLE = True
28
+ except ImportError as e:
29
+ PROGRESSIVE_SEARCH_AVAILABLE = False
30
+ print(f"Progressive search not available: {e}")
31
+
32
+ # Import AI Tools if available
33
+ try:
34
+ from .ai_tools import AIToolsWidget
35
+ AI_TOOLS_AVAILABLE = True
36
+ except ImportError:
37
+ AI_TOOLS_AVAILABLE = False
38
+
39
+ # Import Memento pattern for undo/redo functionality
40
+ try:
41
+ from core.memento import (
42
+ FindReplaceMemento, MementoCaretaker, TextState,
43
+ capture_text_state, restore_text_state
44
+ )
45
+ MEMENTO_AVAILABLE = True
46
+ except ImportError:
47
+ MEMENTO_AVAILABLE = False
48
+
49
+
50
+ class FindReplaceWidget:
51
+ """
52
+ A comprehensive Find & Replace widget with advanced features including:
53
+ - Text and Regex modes
54
+ - Case sensitivity options
55
+ - Whole words, prefix, and suffix matching
56
+ - Progressive search and highlighting
57
+ - Pattern library integration
58
+ - History tracking
59
+ - Single replace and skip functionality
60
+ """
61
+
62
+ def __init__(self, parent, settings_manager, logger=None, dialog_manager=None):
63
+ """
64
+ Initialize the Find & Replace widget.
65
+
66
+ Args:
67
+ parent: Parent widget/window
68
+ settings_manager: Object that handles settings persistence
69
+ logger: Logger instance for debugging
70
+ dialog_manager: DialogManager instance for consistent dialog handling
71
+ """
72
+ self.parent = parent
73
+ self.settings_manager = settings_manager
74
+ self.logger = logger or logging.getLogger(__name__)
75
+ self.dialog_manager = dialog_manager
76
+
77
+ # Initialize optimized components if available
78
+ if PROGRESSIVE_SEARCH_AVAILABLE:
79
+ self.search_highlighter = get_search_highlighter()
80
+ self.find_replace_processor = get_find_replace_processor()
81
+ self.operation_manager = get_operation_manager()
82
+ self.active_search_operations = {}
83
+ else:
84
+ self.search_highlighter = None
85
+ self.find_replace_processor = None
86
+ self.operation_manager = None
87
+ self.active_search_operations = {}
88
+
89
+ # Internal state
90
+ self._regex_cache = {}
91
+ self._regex_cache_max_size = 100 # Limit cache size
92
+ self.current_match_index = 0
93
+ self.current_matches = []
94
+ self.input_matches = []
95
+ self.replaced_count = 0
96
+ self.skipped_matches = set()
97
+ self.all_matches_processed = False
98
+ self.loop_start_position = None
99
+
100
+ # Undo/Redo system using Memento pattern
101
+ if MEMENTO_AVAILABLE:
102
+ self.memento_caretaker = MementoCaretaker(max_history=50)
103
+ self.memento_caretaker.add_change_callback(self._on_undo_redo_change)
104
+ else:
105
+ self.memento_caretaker = None
106
+ # Legacy undo stack for fallback
107
+ self.undo_stack = [] # For undo functionality
108
+ self.max_undo_stack = 10 # Limit undo history
109
+
110
+
111
+ # UI components (will be created by create_widgets)
112
+ self.find_text_field = None
113
+ self.replace_text_field = None
114
+ self.match_count_label = None
115
+ self.replaced_count_label = None
116
+ self.regex_mode_var = None
117
+ self.match_case_var = None
118
+ self.fr_option_var = None
119
+ self.option_radiobuttons = {}
120
+ self.pattern_library_button = None
121
+
122
+ def _show_info(self, title, message, category="success"):
123
+ """Show info dialog using DialogManager if available, otherwise use messagebox."""
124
+ if self.dialog_manager:
125
+ return self.dialog_manager.show_info(title, message, category, parent=self.parent)
126
+ else:
127
+ messagebox.showinfo(title, message, parent=self.parent)
128
+ return True
129
+
130
+ def _show_warning(self, title, message, category="warning"):
131
+ """Show warning dialog using DialogManager if available, otherwise use messagebox."""
132
+ if self.dialog_manager:
133
+ return self.dialog_manager.show_warning(title, message, category, parent=self.parent)
134
+ else:
135
+ messagebox.showwarning(title, message, parent=self.parent)
136
+ return True
137
+
138
+ def create_widgets(self, parent_frame, settings: Dict[str, Any]):
139
+ """
140
+ Creates the Find & Replace UI widgets.
141
+
142
+ Args:
143
+ parent_frame: Parent frame to contain the widgets
144
+ settings: Current tool settings
145
+ """
146
+ # Left side controls frame (under Input)
147
+ left_frame = ttk.Frame(parent_frame)
148
+ left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=5)
149
+
150
+ # Options frame (middle)
151
+ options_frame = ttk.Frame(parent_frame)
152
+ options_frame.pack(side=tk.LEFT, fill=tk.Y, padx=5)
153
+
154
+ # Right side controls frame (right of Options)
155
+ right_frame = ttk.Frame(parent_frame)
156
+ right_frame.pack(side=tk.LEFT, fill=tk.Y, padx=5)
157
+
158
+ self.match_count_label = ttk.Label(left_frame, text="Found matches: 0")
159
+ self.match_count_label.pack(anchor="w")
160
+
161
+ # Find field with history button (left side)
162
+ find_frame = ttk.Frame(left_frame)
163
+ find_frame.pack(fill=tk.X, pady=2)
164
+ ttk.Label(find_frame, text="Find:").pack(side=tk.LEFT)
165
+ find_input_frame = ttk.Frame(find_frame)
166
+ find_input_frame.pack(side=tk.LEFT, expand=True, fill=tk.X)
167
+ self.find_text_field = tk.Entry(find_input_frame, width=30)
168
+ self.find_text_field.insert(0, settings.get("find", ""))
169
+ self.find_text_field.pack(side=tk.LEFT, expand=True, fill=tk.X)
170
+ self.find_text_field.bind('<KeyRelease>', self._on_find_text_change)
171
+ ttk.Button(find_input_frame, text="History", command=self.show_find_history, width=8).pack(side=tk.RIGHT, padx=(2,0))
172
+
173
+ # Buttons under Find field (left side)
174
+ find_buttons_frame = ttk.Frame(left_frame)
175
+ find_buttons_frame.pack(fill=tk.X, pady=2)
176
+ ttk.Button(find_buttons_frame, text="Find All", command=self.preview_find_replace).pack(side=tk.LEFT, padx=5)
177
+ ttk.Button(find_buttons_frame, text="Previous", command=self.find_previous).pack(side=tk.LEFT, padx=5)
178
+ ttk.Button(find_buttons_frame, text="Next", command=self.find_next).pack(side=tk.LEFT, padx=5)
179
+
180
+ # Regex mode checkbox below Search button (left side)
181
+ self.regex_mode_var = tk.BooleanVar(value=settings.get("mode", "Text") == "Regex")
182
+ regex_checkbox = ttk.Checkbutton(left_frame, text="Regex mode", variable=self.regex_mode_var, command=self.on_regex_mode_change)
183
+ regex_checkbox.pack(anchor="w", pady=(2,5))
184
+
185
+ # Pattern Library button below Regex mode checkbox
186
+ self.pattern_library_button = ttk.Button(left_frame, text="Pattern Library", command=self.show_pattern_library)
187
+ self.pattern_library_button.pack(anchor="w", pady=(2,5))
188
+
189
+ # Info label for escape sequences
190
+ info_label = ttk.Label(left_frame, text="Tip: Use \\n \\t \\r in text mode",
191
+ font=("Arial", 8), foreground="gray")
192
+ info_label.pack(anchor="w", pady=(0,2))
193
+
194
+ # Options in the middle
195
+ ttk.Label(options_frame, text="Options:").pack(anchor="w", pady=(0,5))
196
+
197
+ # Match case checkbox (can be combined with other options)
198
+ current_option = settings.get("option", "ignore_case")
199
+
200
+ # Determine if match case is enabled
201
+ is_match_case = current_option == "match_case" or "_match_case" in current_option
202
+ self.match_case_var = tk.BooleanVar(value=is_match_case)
203
+ self.match_case_checkbox = ttk.Checkbutton(options_frame, text="Match case", variable=self.match_case_var, command=self.on_find_replace_option_change)
204
+ self.match_case_checkbox.pack(anchor="w")
205
+
206
+ # Separator
207
+ ttk.Separator(options_frame, orient='horizontal').pack(fill='x', pady=5)
208
+
209
+ # Text matching options (radio buttons)
210
+ # Extract base option (remove case sensitivity suffix)
211
+ base_option = current_option.replace("_match_case", "") if "_match_case" in current_option else current_option
212
+ if base_option in ["match_case", "ignore_case"]:
213
+ base_option = "none"
214
+ self.fr_option_var = tk.StringVar(value=base_option)
215
+ text_options = {
216
+ "none": "No special matching",
217
+ "whole_words": "Find whole words only",
218
+ "match_prefix": "Match prefix",
219
+ "match_suffix": "Match suffix"
220
+ }
221
+
222
+ self.option_radiobuttons = {}
223
+ for key, text in text_options.items():
224
+ rb = ttk.Radiobutton(options_frame, text=text, variable=self.fr_option_var, value=key, command=self.on_find_replace_option_change)
225
+ rb.pack(anchor="w")
226
+ self.option_radiobuttons[key] = rb
227
+
228
+ # Replaced matches counter (right side)
229
+ self.replaced_count_label = ttk.Label(right_frame, text="Replaced matches: 0")
230
+ self.replaced_count_label.pack(anchor="w")
231
+
232
+ # Replace field with history button (right side)
233
+ replace_frame = ttk.Frame(right_frame)
234
+ replace_frame.pack(fill=tk.X, pady=2)
235
+ ttk.Label(replace_frame, text="Replace:").pack(side=tk.LEFT)
236
+ replace_input_frame = ttk.Frame(replace_frame)
237
+ replace_input_frame.pack(side=tk.LEFT, expand=True, fill=tk.X)
238
+ self.replace_text_field = tk.Entry(replace_input_frame, width=30)
239
+ self.replace_text_field.insert(0, settings.get("replace", ""))
240
+ self.replace_text_field.pack(side=tk.LEFT, expand=True, fill=tk.X)
241
+ self.replace_text_field.bind('<KeyRelease>', self._on_replace_text_change)
242
+ ttk.Button(replace_input_frame, text="History", command=self.show_replace_history, width=8).pack(side=tk.RIGHT, padx=(2,0))
243
+
244
+ # Buttons under Replace field (right side) - Row 1
245
+ replace_buttons_frame = ttk.Frame(right_frame)
246
+ replace_buttons_frame.pack(fill=tk.X, pady=2)
247
+ ttk.Button(replace_buttons_frame, text="Replace All", command=self.trigger_replace_all).pack(side=tk.LEFT, padx=5)
248
+ ttk.Button(replace_buttons_frame, text="Replace > Find", command=self.replace_single).pack(side=tk.LEFT, padx=5)
249
+ ttk.Button(replace_buttons_frame, text="Skip", command=self.skip_single).pack(side=tk.LEFT, padx=5)
250
+
251
+ # Buttons under Replace field (right side) - Row 2
252
+ replace_buttons_frame2 = ttk.Frame(right_frame)
253
+ replace_buttons_frame2.pack(fill=tk.X, pady=2)
254
+ self.undo_button = ttk.Button(replace_buttons_frame2, text="Undo", command=self.undo_replace_all, state="disabled")
255
+ self.undo_button.pack(side=tk.LEFT, padx=5)
256
+ self.redo_button = ttk.Button(replace_buttons_frame2, text="Redo", command=self.redo_replace_all, state="disabled")
257
+ self.redo_button.pack(side=tk.LEFT, padx=5)
258
+ # Preview Diff button - shows unified diff before Replace All
259
+ if DIFF_UTILS_AVAILABLE:
260
+ ttk.Button(replace_buttons_frame2, text="Preview Diff", command=self.show_diff_preview).pack(side=tk.LEFT, padx=5)
261
+
262
+
263
+ # Initialize search state
264
+ self.current_match_index = 0
265
+ self.current_matches = []
266
+ self.input_matches = []
267
+ self.replaced_count = 0
268
+ self.skipped_matches = set()
269
+ self.all_matches_processed = False
270
+ self.loop_start_position = None
271
+
272
+ # Initialize history if not exists
273
+ if "find_history" not in settings:
274
+ settings["find_history"] = []
275
+ if "replace_history" not in settings:
276
+ settings["replace_history"] = []
277
+
278
+ # Set initial state of options based on regex mode
279
+ self.on_regex_mode_change()
280
+
281
+ # Setup keyboard shortcuts
282
+ self._setup_keyboard_shortcuts()
283
+
284
+ def get_settings(self) -> Dict[str, Any]:
285
+ """
286
+ Get current Find & Replace settings.
287
+
288
+ Returns:
289
+ Dictionary containing current settings
290
+ """
291
+ if not self.find_text_field:
292
+ return {}
293
+
294
+ settings = {
295
+ "find": self.find_text_field.get(),
296
+ "replace": self.replace_text_field.get(),
297
+ "mode": "Regex" if self.regex_mode_var.get() else "Text"
298
+ }
299
+
300
+ # Combine case sensitivity with text matching option
301
+ if self.match_case_var.get():
302
+ if self.fr_option_var.get() == "none":
303
+ settings["option"] = "match_case"
304
+ else:
305
+ settings["option"] = f"{self.fr_option_var.get()}_match_case"
306
+ else:
307
+ if self.fr_option_var.get() == "none":
308
+ settings["option"] = "ignore_case"
309
+ else:
310
+ settings["option"] = self.fr_option_var.get()
311
+
312
+ return settings
313
+
314
+ def set_text_widgets(self, input_tabs, output_tabs, input_notebook, output_notebook):
315
+ """
316
+ Set the text widgets that this Find & Replace tool will operate on.
317
+
318
+ Args:
319
+ input_tabs: List of input tab objects with .text attribute
320
+ output_tabs: List of output tab objects with .text attribute
321
+ input_notebook: Input notebook widget for getting current selection
322
+ output_notebook: Output notebook widget for getting current selection
323
+ """
324
+ self.input_tabs = input_tabs
325
+ self.output_tabs = output_tabs
326
+ self.input_notebook = input_notebook
327
+ self.output_notebook = output_notebook
328
+
329
+ def _setup_keyboard_shortcuts(self):
330
+ """Setup keyboard shortcuts for Find & Replace operations."""
331
+ if not self.find_text_field or not self.replace_text_field:
332
+ return
333
+
334
+ # Bind shortcuts to both find and replace fields
335
+ for widget in [self.find_text_field, self.replace_text_field]:
336
+ # F3 - Find Next
337
+ widget.bind('<F3>', lambda e: self.find_next())
338
+ # Shift+F3 - Find Previous
339
+ widget.bind('<Shift-F3>', lambda e: self.find_previous())
340
+ # Ctrl+Enter - Search/Preview
341
+ widget.bind('<Control-Return>', lambda e: self.preview_find_replace())
342
+ # Ctrl+H - Focus Replace field
343
+ widget.bind('<Control-h>', lambda e: self.replace_text_field.focus_set())
344
+ # Ctrl+F - Focus Find field
345
+ widget.bind('<Control-f>', lambda e: self.find_text_field.focus_set())
346
+ # Escape - Clear highlights
347
+ widget.bind('<Escape>', lambda e: self._clear_all_highlights())
348
+
349
+ def _clear_all_highlights(self):
350
+ """Clear all search highlights from input and output tabs."""
351
+ if not self.input_tabs or not self.output_tabs:
352
+ return
353
+
354
+ try:
355
+ active_input_tab, active_output_tab = self._get_active_tabs()
356
+
357
+ # Clear highlights
358
+ active_input_tab.text.tag_remove("yellow_highlight", "1.0", tk.END)
359
+ active_input_tab.text.tag_remove("current_match", "1.0", tk.END)
360
+ active_output_tab.text.tag_remove("pink_highlight", "1.0", tk.END)
361
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
362
+
363
+ # Reset match count
364
+ self.match_count_label.config(text="Found matches: 0")
365
+ except Exception as e:
366
+ self.logger.warning(f"Error clearing highlights: {e}")
367
+
368
+ def _get_active_tabs(self) -> Tuple[Any, Any]:
369
+ """Get the currently active input and output tabs."""
370
+ try:
371
+ if not self.input_tabs or not self.output_tabs:
372
+ raise ValueError("No tabs available")
373
+
374
+ input_selection = self.input_notebook.select()
375
+ output_selection = self.output_notebook.select()
376
+
377
+ if not input_selection or not output_selection:
378
+ raise ValueError("No tab selected")
379
+
380
+ active_input_tab = self.input_tabs[self.input_notebook.index(input_selection)]
381
+ active_output_tab = self.output_tabs[self.output_notebook.index(output_selection)]
382
+ return active_input_tab, active_output_tab
383
+ except (IndexError, ValueError, tk.TclError) as e:
384
+ self.logger.error(f"Error getting active tabs: {e}")
385
+ raise
386
+
387
+ def _get_search_pattern(self) -> str:
388
+ """
389
+ Helper to build the regex pattern for Find & Replace.
390
+
391
+ Returns:
392
+ Compiled regex pattern string
393
+ """
394
+ find_str = self.find_text_field.get().strip()
395
+
396
+ # Process escape sequences if not in regex mode
397
+ if not self.regex_mode_var.get():
398
+ find_str = self._process_escape_sequences(find_str)
399
+
400
+ # Determine case sensitivity and base option
401
+ is_case_sensitive = self.match_case_var.get()
402
+ base_option = self.fr_option_var.get()
403
+
404
+ # Check cache with size limit
405
+ cache_key = (find_str, base_option, is_case_sensitive, self.regex_mode_var.get())
406
+ if cache_key in self._regex_cache:
407
+ return self._regex_cache[cache_key]
408
+
409
+ # Clear cache if it's too large
410
+ if len(self._regex_cache) >= self._regex_cache_max_size:
411
+ # Remove oldest entries (simple FIFO)
412
+ keys_to_remove = list(self._regex_cache.keys())[:self._regex_cache_max_size // 2]
413
+ for key in keys_to_remove:
414
+ del self._regex_cache[key]
415
+
416
+ if self.regex_mode_var.get():
417
+ # Validate regex before using it
418
+ is_valid, error_msg, suggestion = self._validate_regex(find_str)
419
+ if not is_valid:
420
+ # Return empty pattern for invalid regex - caller should check validation first
421
+ self.logger.warning(f"Invalid regex pattern: {error_msg}")
422
+ return ""
423
+ pattern = find_str
424
+ else:
425
+ search_term = re.escape(find_str)
426
+ if base_option == "whole_words":
427
+ search_term = r'\b' + search_term + r'\b'
428
+ elif base_option == "match_prefix":
429
+ search_term = r'\b' + search_term
430
+ elif base_option == "match_suffix":
431
+ search_term = search_term + r'\b'
432
+ pattern = search_term
433
+
434
+ self._regex_cache[cache_key] = pattern
435
+ return pattern
436
+
437
+ def _process_escape_sequences(self, text: str) -> str:
438
+ """Process escape sequences like \\n, \\t, \\r in text mode."""
439
+ # Only process if the text contains backslash
440
+ if '\\' not in text:
441
+ return text
442
+
443
+ # Replace common escape sequences
444
+ replacements = {
445
+ '\\n': '\n',
446
+ '\\t': '\t',
447
+ '\\r': '\r',
448
+ '\\\\': '\\',
449
+ }
450
+
451
+ result = text
452
+ for escape, char in replacements.items():
453
+ result = result.replace(escape, char)
454
+
455
+ return result
456
+
457
+ def _validate_regex(self, pattern: str) -> Tuple[bool, str, str]:
458
+ """
459
+ Validate a regex pattern before execution.
460
+
461
+ Args:
462
+ pattern: The regex pattern string to validate
463
+
464
+ Returns:
465
+ Tuple of (is_valid, error_message, suggestion)
466
+ If valid: (True, "", "")
467
+ If invalid: (False, error_message, helpful_suggestion)
468
+ """
469
+ if not pattern:
470
+ return True, "", ""
471
+
472
+ try:
473
+ compiled = re.compile(pattern)
474
+ # Return info about groups for debugging
475
+ return True, "", ""
476
+ except re.error as e:
477
+ error_msg = str(e)
478
+ suggestion = self._get_regex_error_help(error_msg)
479
+ return False, error_msg, suggestion
480
+
481
+ def validate_current_pattern(self) -> Tuple[bool, str]:
482
+ """
483
+ Validate the current find pattern in the UI.
484
+
485
+ Returns:
486
+ Tuple of (is_valid, error_message_with_suggestion)
487
+ """
488
+ if not self.regex_mode_var.get():
489
+ # Text mode patterns are always valid (they get escaped)
490
+ return True, ""
491
+
492
+ find_str = self.find_text_field.get().strip()
493
+ if not find_str:
494
+ return True, ""
495
+
496
+ is_valid, error_msg, suggestion = self._validate_regex(find_str)
497
+ if is_valid:
498
+ return True, ""
499
+ else:
500
+ return False, f"Invalid regex: {error_msg}\n{suggestion}"
501
+
502
+ def preview_find_replace(self):
503
+ """Highlights matches in input and output without replacing using progressive search."""
504
+ if not self.input_tabs or not self.output_tabs:
505
+ self.logger.warning("Text widgets not set for Find & Replace")
506
+ return
507
+
508
+ active_input_tab, active_output_tab = self._get_active_tabs()
509
+
510
+ # Reset replacement count and skip tracking when starting new search
511
+ self.replaced_count = 0
512
+ self.replaced_count_label.config(text="Replaced matches: 0")
513
+ self.skipped_matches = set()
514
+ self.all_matches_processed = False
515
+ self.loop_start_position = None
516
+
517
+ # Clear existing highlights
518
+ if PROGRESSIVE_SEARCH_AVAILABLE and self.search_highlighter:
519
+ self.search_highlighter.clear_highlights(active_input_tab.text, "yellow_highlight")
520
+ self.search_highlighter.clear_highlights(active_output_tab.text, "pink_highlight")
521
+ else:
522
+ active_input_tab.text.tag_remove("yellow_highlight", "1.0", tk.END)
523
+ active_output_tab.text.tag_remove("pink_highlight", "1.0", tk.END)
524
+
525
+ active_output_tab.text.config(state="normal")
526
+ input_content = active_input_tab.text.get("1.0", tk.END)
527
+ active_output_tab.text.delete("1.0", tk.END)
528
+ active_output_tab.text.insert("1.0", input_content)
529
+
530
+ find_str = self.find_text_field.get()
531
+ if not find_str:
532
+ active_output_tab.text.config(state="disabled")
533
+ self.match_count_label.config(text="Found matches: 0")
534
+ return
535
+
536
+ # Validate regex before proceeding
537
+ is_valid, error_msg = self.validate_current_pattern()
538
+ if not is_valid:
539
+ self._show_warning("Regex Error", error_msg)
540
+ self.match_count_label.config(text="Found matches: Regex Error")
541
+ active_output_tab.text.config(state="disabled")
542
+ return
543
+
544
+ # Use progressive search if available
545
+ if PROGRESSIVE_SEARCH_AVAILABLE and self.search_highlighter and self.find_replace_processor:
546
+ self._preview_with_progressive_search(active_input_tab, active_output_tab, find_str)
547
+ else:
548
+ self._preview_with_basic_search(active_input_tab, active_output_tab, find_str)
549
+
550
+ active_output_tab.text.config(state="disabled")
551
+
552
+ def _preview_with_progressive_search(self, input_tab, output_tab, find_str):
553
+ """Use progressive search for preview highlighting."""
554
+ pattern = self._get_search_pattern()
555
+ replace_str = self.replace_text_field.get()
556
+ case_sensitive = self.match_case_var.get()
557
+ use_regex = True # Always use regex since we generate regex patterns
558
+
559
+ # Cancel any existing operations for these widgets
560
+ self.operation_manager.cancel_widget_operations(input_tab.text)
561
+ self.operation_manager.cancel_widget_operations(output_tab.text)
562
+
563
+ # Progress callback for updating match count
564
+ def progress_callback(operation):
565
+ if hasattr(operation, 'progress') and hasattr(operation.progress, 'matches_found'):
566
+ self.match_count_label.config(text=f"Found matches: {operation.progress.matches_found}")
567
+
568
+ # Completion callback
569
+ def completion_callback(operation):
570
+ if hasattr(operation, 'matches'):
571
+ self.match_count_label.config(text=f"Found matches: {len(operation.matches)}")
572
+
573
+ # Error callback
574
+ def error_callback(operation, error_msg):
575
+ self.logger.error(f"Progressive search error: {error_msg}")
576
+ self.match_count_label.config(text="Search Error")
577
+
578
+ try:
579
+ # Start progressive highlighting for input (find matches)
580
+ input_op_id = self.search_highlighter.search_and_highlight(
581
+ text_widget=input_tab.text,
582
+ pattern=pattern,
583
+ tag_name="yellow_highlight",
584
+ mode=HighlightMode.PROGRESSIVE,
585
+ flags=0 if case_sensitive else re.IGNORECASE,
586
+ progress_callback=progress_callback,
587
+ completion_callback=completion_callback
588
+ )
589
+
590
+ # Generate preview with find/replace processor
591
+ if replace_str:
592
+ preview_op_id = self.find_replace_processor.generate_preview(
593
+ text_widget=output_tab.text,
594
+ find_pattern=pattern,
595
+ replace_text=replace_str,
596
+ case_sensitive=case_sensitive,
597
+ use_regex=use_regex,
598
+ progress_callback=progress_callback
599
+ )
600
+
601
+ # Track operations
602
+ self.active_search_operations[input_op_id] = 'input_highlight'
603
+ self.active_search_operations[preview_op_id] = 'preview_generation'
604
+ else:
605
+ # Just highlight matches in output too
606
+ output_op_id = self.search_highlighter.search_and_highlight(
607
+ text_widget=output_tab.text,
608
+ pattern=pattern,
609
+ tag_name="pink_highlight",
610
+ mode=HighlightMode.PROGRESSIVE,
611
+ flags=0 if case_sensitive else re.IGNORECASE
612
+ )
613
+
614
+ self.active_search_operations[input_op_id] = 'input_highlight'
615
+ self.active_search_operations[output_op_id] = 'output_highlight'
616
+
617
+ except Exception as e:
618
+ self.logger.error(f"Error starting progressive search: {e}")
619
+ self._preview_with_basic_search(input_tab, output_tab, find_str)
620
+
621
+ def _preview_with_basic_search(self, input_tab, output_tab, find_str):
622
+ """Fallback to basic search for preview highlighting."""
623
+ pattern = self._get_search_pattern()
624
+ flags = 0 if self.match_case_var.get() else re.IGNORECASE
625
+
626
+ match_count = 0
627
+ try:
628
+ input_content = input_tab.text.get("1.0", tk.END)
629
+
630
+ for match in re.finditer(pattern, input_content, flags):
631
+ start, end = match.span()
632
+ input_tab.text.tag_add("yellow_highlight", f"1.0 + {start}c", f"1.0 + {end}c")
633
+ match_count += 1
634
+
635
+ for match in re.finditer(pattern, output_tab.text.get("1.0", tk.END), flags):
636
+ start, end = match.span()
637
+ output_tab.text.tag_add("pink_highlight", f"1.0 + {start}c", f"1.0 + {end}c")
638
+
639
+ except re.error as e:
640
+ self.logger.error(f"Regex error in preview: {e}")
641
+ match_count = "Regex Error"
642
+ # Show helpful error message
643
+ error_msg = self._get_regex_error_help(str(e))
644
+ self._show_warning("Regex Error", f"Invalid regular expression:\n\n{e}\n\n{error_msg}")
645
+
646
+ self.match_count_label.config(text=f"Found matches: {match_count}")
647
+
648
+ def _get_regex_error_help(self, error_msg: str) -> str:
649
+ """Provide helpful suggestions for common regex errors."""
650
+ error_msg_lower = error_msg.lower()
651
+
652
+ if "unbalanced parenthesis" in error_msg_lower or "missing )" in error_msg_lower:
653
+ return "Tip: Make sure all opening parentheses '(' have matching closing parentheses ')'."
654
+ elif "nothing to repeat" in error_msg_lower:
655
+ return "Tip: Quantifiers like *, +, ? must follow a character or group. Use \\* to match a literal asterisk."
656
+ elif "bad escape" in error_msg_lower:
657
+ return "Tip: Invalid escape sequence. Use \\\\ for a literal backslash."
658
+ elif "unterminated character set" in error_msg_lower or "missing ]" in error_msg_lower:
659
+ return "Tip: Character sets must be closed with ']'. Use \\[ to match a literal bracket."
660
+ elif "bad character range" in error_msg_lower:
661
+ return "Tip: In character sets like [a-z], the first character must come before the second."
662
+ else:
663
+ return "Tip: Check your regex syntax. Common issues: unescaped special characters (. * + ? [ ] ( ) { } ^ $ | \\)"
664
+
665
+ def highlight_processed_results(self):
666
+ """Highlights input (found) and output (replaced) text after processing."""
667
+ if not self.input_tabs or not self.output_tabs:
668
+ return
669
+
670
+ active_input_tab, active_output_tab = self._get_active_tabs()
671
+
672
+ active_input_tab.text.tag_remove("yellow_highlight", "1.0", tk.END)
673
+ active_output_tab.text.config(state="normal")
674
+ active_output_tab.text.tag_remove("pink_highlight", "1.0", tk.END)
675
+
676
+ find_str = self.find_text_field.get()
677
+ replace_str = self.replace_text_field.get()
678
+ if not find_str:
679
+ active_output_tab.text.config(state="disabled")
680
+ self.match_count_label.config(text="Found matches: 0")
681
+ return
682
+
683
+ pattern = self._get_search_pattern()
684
+ flags = 0 if self.match_case_var.get() else re.IGNORECASE
685
+
686
+ match_count = 0
687
+ try:
688
+ for match in re.finditer(pattern, active_input_tab.text.get("1.0", tk.END), flags):
689
+ start, end = match.span()
690
+ active_input_tab.text.tag_add("yellow_highlight", f"1.0 + {start}c", f"1.0 + {end}c")
691
+ match_count += 1
692
+
693
+ if replace_str:
694
+ for match in re.finditer(re.escape(replace_str), active_output_tab.text.get("1.0", tk.END), flags):
695
+ start, end = match.span()
696
+ active_output_tab.text.tag_add("pink_highlight", f"1.0 + {start}c", f"1.0 + {end}c")
697
+ except re.error as e:
698
+ self.logger.error(f"Regex error in highlight: {e}")
699
+ match_count = "Regex Error"
700
+
701
+ self.match_count_label.config(text=f"Found matches: {match_count}")
702
+ active_output_tab.text.config(state="disabled")
703
+
704
+ def find_next(self):
705
+ """Moves to the next match in the input text area with automatic highlighting."""
706
+ if not self.input_tabs or not self.output_tabs:
707
+ return
708
+
709
+ active_input_tab, active_output_tab = self._get_active_tabs()
710
+ find_str = self.find_text_field.get()
711
+
712
+ if not find_str:
713
+ return
714
+
715
+ # First, run preview to highlight all matches
716
+ self.preview_find_replace()
717
+
718
+ # Focus on input text area
719
+ active_input_tab.text.focus_set()
720
+
721
+ # Get current cursor position
722
+ try:
723
+ current_pos = active_input_tab.text.index(tk.INSERT)
724
+ except:
725
+ current_pos = "1.0"
726
+
727
+ # Search for next occurrence
728
+ try:
729
+ # Get the search pattern (handles all matching options)
730
+ pattern = self._get_search_pattern()
731
+ content = active_input_tab.text.get("1.0", tk.END)
732
+ flags = 0 if self.match_case_var.get() else re.IGNORECASE
733
+ matches = list(re.finditer(pattern, content, flags))
734
+
735
+ if matches:
736
+ # Store matches for navigation
737
+ self.input_matches = matches
738
+
739
+ # Find current position in characters
740
+ current_char = len(active_input_tab.text.get("1.0", current_pos))
741
+
742
+ # Find next match after current position
743
+ next_match = None
744
+ next_index = 0
745
+ for i, match in enumerate(matches):
746
+ if match.start() > current_char:
747
+ next_match = match
748
+ next_index = i
749
+ break
750
+
751
+ # If no match found after current position, wrap to first match
752
+ if not next_match:
753
+ next_match = matches[0]
754
+ next_index = 0
755
+
756
+ self.current_match_index = next_index
757
+
758
+ # Convert character position back to line.column
759
+ start_pos = f"1.0 + {next_match.start()}c"
760
+ end_pos = f"1.0 + {next_match.end()}c"
761
+
762
+ # Clear previous selection and highlight current match
763
+ active_input_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
764
+ active_input_tab.text.tag_remove("current_match", "1.0", tk.END)
765
+ active_input_tab.text.tag_add("current_match", start_pos, end_pos)
766
+ active_input_tab.text.tag_config("current_match", background="red", foreground="white")
767
+ active_input_tab.text.mark_set(tk.INSERT, end_pos)
768
+ active_input_tab.text.see(start_pos)
769
+
770
+ # Update match count label
771
+ self.match_count_label.config(text=f"Found matches: {len(matches)} (current: {next_index + 1})")
772
+
773
+ except Exception as e:
774
+ self.logger.warning(f"Find next error: {e}")
775
+
776
+ def find_previous(self):
777
+ """Moves to the previous match in the input text area with automatic highlighting."""
778
+ if not self.input_tabs or not self.output_tabs:
779
+ return
780
+
781
+ active_input_tab, active_output_tab = self._get_active_tabs()
782
+ find_str = self.find_text_field.get()
783
+
784
+ if not find_str:
785
+ return
786
+
787
+ # First, run preview to highlight all matches
788
+ self.preview_find_replace()
789
+
790
+ # Focus on input text area
791
+ active_input_tab.text.focus_set()
792
+
793
+ # Get current cursor position
794
+ try:
795
+ current_pos = active_input_tab.text.index(tk.INSERT)
796
+ except:
797
+ current_pos = "1.0"
798
+
799
+ # Search for previous occurrence
800
+ try:
801
+ # Get the search pattern (handles all matching options)
802
+ pattern = self._get_search_pattern()
803
+ content = active_input_tab.text.get("1.0", tk.END)
804
+ flags = 0 if self.match_case_var.get() else re.IGNORECASE
805
+ matches = list(re.finditer(pattern, content, flags))
806
+
807
+ if matches:
808
+ # Store matches for navigation
809
+ self.input_matches = matches
810
+
811
+ # Find current position in characters
812
+ current_char = len(active_input_tab.text.get("1.0", current_pos))
813
+
814
+ # Find previous match before current position
815
+ prev_match = None
816
+ prev_index = 0
817
+ for i in reversed(range(len(matches))):
818
+ match = matches[i]
819
+ if match.start() < current_char:
820
+ prev_match = match
821
+ prev_index = i
822
+ break
823
+
824
+ # If no match found before current position, wrap to last match
825
+ if not prev_match:
826
+ prev_match = matches[-1]
827
+ prev_index = len(matches) - 1
828
+
829
+ self.current_match_index = prev_index
830
+
831
+ # Convert character position back to line.column
832
+ start_pos = f"1.0 + {prev_match.start()}c"
833
+ end_pos = f"1.0 + {prev_match.end()}c"
834
+
835
+ # Clear previous selection and highlight current match
836
+ active_input_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
837
+ active_input_tab.text.tag_remove("current_match", "1.0", tk.END)
838
+ active_input_tab.text.tag_add("current_match", start_pos, end_pos)
839
+ active_input_tab.text.tag_config("current_match", background="red", foreground="white")
840
+ active_input_tab.text.mark_set(tk.INSERT, start_pos)
841
+ active_input_tab.text.see(start_pos)
842
+
843
+ # Update match count label
844
+ self.match_count_label.config(text=f"Found matches: {len(matches)} (current: {prev_index + 1})")
845
+
846
+ except Exception as e:
847
+ self.logger.warning(f"Find previous error: {e}")
848
+
849
+ def replace_all(self) -> str:
850
+ """
851
+ Performs find and replace on all matches.
852
+
853
+ Returns:
854
+ Processed text with all replacements made
855
+ """
856
+ if not self.input_tabs:
857
+ return ""
858
+
859
+ active_input_tab, _ = self._get_active_tabs()
860
+ find_str = self.find_text_field.get().strip()
861
+ replace_str = self.replace_text_field.get().strip()
862
+
863
+ input_text = active_input_tab.text.get("1.0", "end-1c")
864
+
865
+ if not find_str:
866
+ return input_text
867
+
868
+ # Validate regex before proceeding
869
+ is_valid, error_msg = self.validate_current_pattern()
870
+ if not is_valid:
871
+ self._show_warning("Regex Error", error_msg)
872
+ return input_text
873
+
874
+ # Save OUTPUT tab state for undo (not input tab - undo restores output)
875
+ if self.output_tabs:
876
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
877
+ output_text = active_output_tab.text.get("1.0", "end-1c")
878
+ self._save_undo_state(output_text, find_str, replace_str)
879
+
880
+
881
+ # Reset replacement count for Replace All
882
+ self.replaced_count = 0
883
+
884
+ # Add to history
885
+ self._add_to_history("find_history", find_str)
886
+ if replace_str: # Only add to replace history if not empty
887
+ self._add_to_history("replace_history", replace_str)
888
+
889
+ # Use optimized find/replace processor if available
890
+ if PROGRESSIVE_SEARCH_AVAILABLE and self.find_replace_processor:
891
+ try:
892
+ result = self.find_replace_processor.process_find_replace(
893
+ text=input_text,
894
+ find_pattern=find_str,
895
+ replace_text=replace_str,
896
+ mode="Regex" if self.regex_mode_var.get() else "Text",
897
+ options={
898
+ 'ignore_case': not self.match_case_var.get(),
899
+ 'whole_words': self.fr_option_var.get() == "whole_words",
900
+ 'match_prefix': self.fr_option_var.get() == "match_prefix",
901
+ 'match_suffix': self.fr_option_var.get() == "match_suffix"
902
+ }
903
+ )
904
+
905
+ return result.processed_text if result.success else f"Find/Replace Error: {result.error_message}"
906
+
907
+ except Exception as e:
908
+ self.logger.warning(f"Optimized find/replace failed, falling back to basic: {e}")
909
+
910
+ # Fallback to basic find/replace implementation
911
+ is_case_sensitive = self.match_case_var.get()
912
+ base_option = self.fr_option_var.get()
913
+
914
+ if self.regex_mode_var.get():
915
+ try:
916
+ flags = 0 if is_case_sensitive else re.IGNORECASE
917
+ # Count matches before replacement
918
+ matches = re.findall(find_str, input_text, flags)
919
+ self.replaced_count += len(matches)
920
+ result = re.sub(find_str, replace_str, input_text, flags=flags).strip()
921
+ self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
922
+ return result
923
+ except re.error as e:
924
+ return f"Regex Error: {e}"
925
+
926
+ # Handle different text matching options
927
+ if base_option == "whole_words":
928
+ # Implement whole words matching
929
+ flags = 0 if is_case_sensitive else re.IGNORECASE
930
+ pattern = r'\b' + re.escape(find_str) + r'\b'
931
+ try:
932
+ # Count matches before replacement
933
+ matches = re.findall(pattern, input_text, flags)
934
+ self.replaced_count += len(matches)
935
+ result = re.sub(pattern, replace_str, input_text, flags=flags).strip()
936
+ self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
937
+ return result
938
+ except re.error as e:
939
+ return f"Whole words error: {e}"
940
+
941
+ elif base_option == "match_prefix":
942
+ # Implement prefix matching
943
+ flags = 0 if is_case_sensitive else re.IGNORECASE
944
+ pattern = r'\b' + re.escape(find_str)
945
+ try:
946
+ # Count matches before replacement
947
+ matches = re.findall(pattern, input_text, flags)
948
+ self.replaced_count += len(matches)
949
+ result = re.sub(pattern, replace_str, input_text, flags=flags).strip()
950
+ self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
951
+ return result
952
+ except re.error as e:
953
+ return f"Prefix match error: {e}"
954
+
955
+ elif base_option == "match_suffix":
956
+ # Implement suffix matching
957
+ flags = 0 if is_case_sensitive else re.IGNORECASE
958
+ pattern = re.escape(find_str) + r'\b'
959
+ try:
960
+ # Count matches before replacement
961
+ matches = re.findall(pattern, input_text, flags)
962
+ self.replaced_count += len(matches)
963
+ result = re.sub(pattern, replace_str, input_text, flags=flags).strip()
964
+ self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
965
+ return result
966
+ except re.error as e:
967
+ return f"Suffix match error: {e}"
968
+
969
+ else:
970
+ # Simple case-sensitive or case-insensitive replacement
971
+ if is_case_sensitive:
972
+ # Count occurrences before replacement
973
+ count = input_text.count(find_str)
974
+ self.replaced_count += count
975
+ result = input_text.replace(find_str, replace_str).strip()
976
+ self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
977
+ return result
978
+ else:
979
+ # Case-insensitive replacement
980
+ pattern = re.escape(find_str)
981
+ # Count matches before replacement
982
+ matches = re.findall(pattern, input_text, re.IGNORECASE)
983
+ self.replaced_count += len(matches)
984
+ result = re.sub(pattern, replace_str, input_text, flags=re.IGNORECASE).strip()
985
+ self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
986
+ return result
987
+
988
+ def replace_single(self):
989
+ """Replaces the current match and moves to next match in the output text area."""
990
+ if not self.output_tabs:
991
+ return
992
+
993
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
994
+ find_str = self.find_text_field.get()
995
+ replace_str = self.replace_text_field.get()
996
+
997
+ if not find_str:
998
+ return
999
+
1000
+ # Focus on output text area
1001
+ active_output_tab.text.focus_set()
1002
+
1003
+ # Save undo state before making changes (use end-1c to avoid trailing newline)
1004
+ current_text = active_output_tab.text.get("1.0", "end-1c")
1005
+ self._save_undo_state(current_text, find_str, replace_str)
1006
+
1007
+ # Enable editing
1008
+ active_output_tab.text.config(state="normal")
1009
+
1010
+ try:
1011
+ # Get current cursor position
1012
+ try:
1013
+ current_pos = active_output_tab.text.index(tk.INSERT)
1014
+ except:
1015
+ current_pos = "1.0"
1016
+
1017
+ # Find the next match from current position
1018
+ next_match_pos = self._find_next_match_in_output(current_pos)
1019
+
1020
+ if next_match_pos:
1021
+ start_pos, end_pos = next_match_pos
1022
+
1023
+ # Replace the match (handle regex replacement)
1024
+ matched_text = active_output_tab.text.get(start_pos, end_pos)
1025
+
1026
+ if self.regex_mode_var.get():
1027
+ # Use regex replacement with backreferences
1028
+ pattern = self._get_search_pattern()
1029
+ flags = 0 if self.match_case_var.get() else re.IGNORECASE
1030
+
1031
+ try:
1032
+ # Use re.sub to handle backreferences like \1, \2, \3
1033
+ replacement_text = re.sub(pattern, replace_str, matched_text, count=1, flags=flags)
1034
+
1035
+ # If no replacement happened, use literal replacement
1036
+ if replacement_text == matched_text:
1037
+ replacement_text = replace_str
1038
+ except re.error:
1039
+ # Regex error, use literal replacement
1040
+ replacement_text = replace_str
1041
+ else:
1042
+ replacement_text = replace_str
1043
+
1044
+ active_output_tab.text.delete(start_pos, end_pos)
1045
+ active_output_tab.text.insert(start_pos, replacement_text)
1046
+
1047
+ # Update replacement count
1048
+ self.replaced_count += 1
1049
+ self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
1050
+
1051
+ # Update history
1052
+ self._add_to_history("find_history", find_str)
1053
+ if replace_str: # Only add to replace history if not empty
1054
+ self._add_to_history("replace_history", replace_str)
1055
+
1056
+ # Calculate new end position after replacement
1057
+ new_end_pos = f"{start_pos} + {len(replace_str)}c"
1058
+
1059
+ # Set cursor after the replacement
1060
+ active_output_tab.text.mark_set(tk.INSERT, new_end_pos)
1061
+
1062
+ # Find and highlight the next match
1063
+ next_next_match = self._find_next_match_in_output(new_end_pos)
1064
+ if next_next_match:
1065
+ next_start, next_end = next_next_match
1066
+
1067
+ # Clear previous highlights
1068
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1069
+ active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1070
+
1071
+ # Highlight the next match
1072
+ active_output_tab.text.tag_add("current_match", next_start, next_end)
1073
+ active_output_tab.text.tag_config("current_match", background="red", foreground="white")
1074
+ active_output_tab.text.see(next_start)
1075
+
1076
+ # Set cursor to the highlighted match
1077
+ active_output_tab.text.mark_set(tk.INSERT, next_start)
1078
+ else:
1079
+ # No more matches after current position, check for looping
1080
+ self._handle_end_of_matches_replace()
1081
+ else:
1082
+ # No matches found at current position, start from beginning
1083
+ first_match = self._find_next_match_in_output("1.0")
1084
+ if first_match:
1085
+ start_pos, end_pos = first_match
1086
+
1087
+ # Replace the match
1088
+ active_output_tab.text.delete(start_pos, end_pos)
1089
+ active_output_tab.text.insert(start_pos, replace_str)
1090
+
1091
+ # Update replacement count
1092
+ self.replaced_count += 1
1093
+ self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
1094
+
1095
+ # Update history
1096
+ self._add_to_history("find_history", find_str)
1097
+ if replace_str:
1098
+ self._add_to_history("replace_history", replace_str)
1099
+
1100
+ # Set cursor after replacement
1101
+ new_end_pos = f"{start_pos} + {len(replace_str)}c"
1102
+ active_output_tab.text.mark_set(tk.INSERT, new_end_pos)
1103
+ active_output_tab.text.see(start_pos)
1104
+
1105
+ # Find and highlight the next match
1106
+ next_next_match = self._find_next_match_in_output(new_end_pos)
1107
+ if next_next_match:
1108
+ next_start, next_end = next_next_match
1109
+
1110
+ # Clear previous highlights
1111
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1112
+ active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1113
+
1114
+ # Highlight the next match
1115
+ active_output_tab.text.tag_add("current_match", next_start, next_end)
1116
+ active_output_tab.text.tag_config("current_match", background="red", foreground="white")
1117
+ active_output_tab.text.see(next_start)
1118
+
1119
+ # Set cursor to the highlighted match
1120
+ active_output_tab.text.mark_set(tk.INSERT, next_start)
1121
+ else:
1122
+ # No matches found at all, clear highlights
1123
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1124
+ active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1125
+
1126
+ except Exception as e:
1127
+ self.logger.warning(f"Replace single error: {e}")
1128
+ finally:
1129
+ active_output_tab.text.config(state="disabled")
1130
+
1131
+ def skip_single(self):
1132
+ """Skips the current match and moves to next match in the output text area."""
1133
+ if not self.output_tabs:
1134
+ return
1135
+
1136
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1137
+ find_str = self.find_text_field.get()
1138
+
1139
+ if not find_str:
1140
+ return
1141
+
1142
+ # Focus on output text area
1143
+ active_output_tab.text.focus_set()
1144
+
1145
+ try:
1146
+ # Get current cursor position
1147
+ try:
1148
+ current_pos = active_output_tab.text.index(tk.INSERT)
1149
+ except:
1150
+ current_pos = "1.0"
1151
+
1152
+ # Find the current match at cursor position
1153
+ current_match_pos = self._find_current_match_in_output(current_pos)
1154
+
1155
+ if current_match_pos:
1156
+ start_pos, end_pos = current_match_pos
1157
+
1158
+ # Add this match position to skipped matches
1159
+ self.skipped_matches.add((start_pos, end_pos))
1160
+
1161
+ # Find the next match after current position
1162
+ next_match_pos = self._find_next_match_in_output(end_pos)
1163
+
1164
+ if next_match_pos:
1165
+ next_start, next_end = next_match_pos
1166
+
1167
+ # Clear previous highlights
1168
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1169
+ active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1170
+
1171
+ # Highlight the next match
1172
+ active_output_tab.text.tag_add("current_match", next_start, next_end)
1173
+ active_output_tab.text.tag_config("current_match", background="yellow", foreground="black")
1174
+ active_output_tab.text.see(next_start)
1175
+
1176
+ # Set cursor to the highlighted match
1177
+ active_output_tab.text.mark_set(tk.INSERT, next_start)
1178
+ else:
1179
+ # No more matches found, check if we should loop back to beginning
1180
+ self._handle_end_of_matches_skip()
1181
+ else:
1182
+ # No current match, find the first match from current position
1183
+ next_match_pos = self._find_next_match_in_output(current_pos)
1184
+ if next_match_pos:
1185
+ next_start, next_end = next_match_pos
1186
+
1187
+ # Clear previous highlights
1188
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1189
+ active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1190
+
1191
+ # Highlight the match
1192
+ active_output_tab.text.tag_add("current_match", next_start, next_end)
1193
+ active_output_tab.text.tag_config("current_match", background="yellow", foreground="black")
1194
+ active_output_tab.text.see(next_start)
1195
+
1196
+ # Set cursor to the highlighted match
1197
+ active_output_tab.text.mark_set(tk.INSERT, next_start)
1198
+
1199
+ except Exception as e:
1200
+ self.logger.warning(f"Skip single error: {e}")
1201
+
1202
+ def _find_current_match_in_output(self, cursor_pos) -> Optional[Tuple[str, str]]:
1203
+ """Find the match at the current cursor position in output text area."""
1204
+ if not self.output_tabs:
1205
+ return None
1206
+
1207
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1208
+ find_str = self.find_text_field.get()
1209
+
1210
+ if not find_str:
1211
+ return None
1212
+
1213
+ try:
1214
+ # Get the search pattern (handles all matching options)
1215
+ pattern = self._get_search_pattern()
1216
+ content = active_output_tab.text.get("1.0", tk.END)
1217
+ flags = 0 if self.match_case_var.get() else re.IGNORECASE
1218
+ matches = list(re.finditer(pattern, content, flags))
1219
+
1220
+ if matches:
1221
+ # Find current position in characters
1222
+ cursor_char = len(active_output_tab.text.get("1.0", cursor_pos))
1223
+
1224
+ # Find match that contains or starts at current position
1225
+ for match in matches:
1226
+ if match.start() <= cursor_char <= match.end():
1227
+ start_pos = f"1.0 + {match.start()}c"
1228
+ end_pos = f"1.0 + {match.end()}c"
1229
+ return (start_pos, end_pos)
1230
+
1231
+ except Exception as e:
1232
+ self.logger.warning(f"Find current match in output error: {e}")
1233
+
1234
+ return None
1235
+
1236
+ def _find_next_match_in_output(self, from_pos) -> Optional[Tuple[str, str]]:
1237
+ """Find the next match in output text area from given position."""
1238
+ if not self.output_tabs:
1239
+ return None
1240
+
1241
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1242
+ find_str = self.find_text_field.get()
1243
+
1244
+ if not find_str:
1245
+ return None
1246
+
1247
+ try:
1248
+ # Get the search pattern (handles all matching options)
1249
+ pattern = self._get_search_pattern()
1250
+ content = active_output_tab.text.get("1.0", tk.END)
1251
+ flags = 0 if self.match_case_var.get() else re.IGNORECASE
1252
+ matches = list(re.finditer(pattern, content, flags))
1253
+
1254
+ if matches:
1255
+ # Find current position in characters
1256
+ from_char = len(active_output_tab.text.get("1.0", from_pos))
1257
+
1258
+ # Find next match after current position
1259
+ for match in matches:
1260
+ if match.start() >= from_char:
1261
+ start_pos = f"1.0 + {match.start()}c"
1262
+ end_pos = f"1.0 + {match.end()}c"
1263
+ return (start_pos, end_pos)
1264
+
1265
+ # If no match found after current position, wrap to first match
1266
+ if matches:
1267
+ match = matches[0]
1268
+ start_pos = f"1.0 + {match.start()}c"
1269
+ end_pos = f"1.0 + {match.end()}c"
1270
+ return (start_pos, end_pos)
1271
+
1272
+ except Exception as e:
1273
+ self.logger.warning(f"Find next match in output error: {e}")
1274
+
1275
+ return None
1276
+
1277
+ def _handle_end_of_matches_skip(self):
1278
+ """Handle looping when reaching end of matches during skip operation."""
1279
+ if not self.output_tabs:
1280
+ return
1281
+
1282
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1283
+
1284
+ # Check if there are any unprocessed matches from the beginning
1285
+ first_match = self._find_next_match_in_output("1.0")
1286
+ if first_match:
1287
+ start_pos, end_pos = first_match
1288
+
1289
+ # Check if this match was already skipped
1290
+ if (start_pos, end_pos) not in self.skipped_matches:
1291
+ # Clear previous highlights
1292
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1293
+ active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1294
+
1295
+ # Highlight the first match
1296
+ active_output_tab.text.tag_add("current_match", start_pos, end_pos)
1297
+ active_output_tab.text.tag_config("current_match", background="yellow", foreground="black")
1298
+ active_output_tab.text.see(start_pos)
1299
+
1300
+ # Set cursor to the highlighted match
1301
+ active_output_tab.text.mark_set(tk.INSERT, start_pos)
1302
+ return
1303
+
1304
+ # All matches have been processed, clear highlights
1305
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1306
+ active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1307
+
1308
+ def _handle_end_of_matches_replace(self):
1309
+ """Handle looping when reaching end of matches during replace operation."""
1310
+ if not self.output_tabs:
1311
+ return
1312
+
1313
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1314
+ find_str = self.find_text_field.get()
1315
+ replace_str = self.replace_text_field.get()
1316
+
1317
+ # Check if there are any matches from the beginning that haven't been replaced
1318
+ first_match = self._find_next_match_in_output("1.0")
1319
+ if first_match:
1320
+ start_pos, end_pos = first_match
1321
+
1322
+ # Replace the match
1323
+ active_output_tab.text.delete(start_pos, end_pos)
1324
+ active_output_tab.text.insert(start_pos, replace_str)
1325
+
1326
+ # Update replacement count
1327
+ self.replaced_count += 1
1328
+ self.replaced_count_label.config(text=f"Replaced matches: {self.replaced_count}")
1329
+
1330
+ # Update history
1331
+ self._add_to_history("find_history", find_str)
1332
+ if replace_str:
1333
+ self._add_to_history("replace_history", replace_str)
1334
+
1335
+ # Set cursor after replacement
1336
+ new_end_pos = f"{start_pos} + {len(replace_str)}c"
1337
+ active_output_tab.text.mark_set(tk.INSERT, new_end_pos)
1338
+ active_output_tab.text.see(start_pos)
1339
+
1340
+ # Find and highlight the next match
1341
+ next_match = self._find_next_match_in_output(new_end_pos)
1342
+ if next_match:
1343
+ next_start, next_end = next_match
1344
+
1345
+ # Clear previous highlights
1346
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1347
+ active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1348
+
1349
+ # Highlight the next match
1350
+ active_output_tab.text.tag_add("current_match", next_start, next_end)
1351
+ active_output_tab.text.tag_config("current_match", background="red", foreground="white")
1352
+ active_output_tab.text.see(next_start)
1353
+
1354
+ # Set cursor to the highlighted match
1355
+ active_output_tab.text.mark_set(tk.INSERT, next_start)
1356
+ else:
1357
+ # No more matches, clear highlights
1358
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1359
+ active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1360
+ else:
1361
+ # No matches found at all, clear highlights
1362
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1363
+ active_output_tab.text.tag_remove(tk.SEL, "1.0", tk.END)
1364
+
1365
+ def show_find_history(self):
1366
+ """Shows find history in a popup window."""
1367
+ self._show_history_popup("Find History", "find_history", self.find_text_field)
1368
+
1369
+ def show_replace_history(self):
1370
+ """Shows replace history in a popup window."""
1371
+ self._show_history_popup("Replace History", "replace_history", self.replace_text_field)
1372
+
1373
+ def _show_history_popup(self, title: str, history_key: str, target_field):
1374
+ """Generic method to show history popup."""
1375
+ settings = self.settings_manager.get_tool_settings("Find & Replace Text")
1376
+ history = settings.get(history_key, [])
1377
+
1378
+ popup = tk.Toplevel(self.parent)
1379
+ popup.title(title)
1380
+ popup.geometry("400x300")
1381
+ popup.transient(self.parent)
1382
+ popup.grab_set()
1383
+
1384
+ # Center the popup
1385
+ popup.update_idletasks()
1386
+ x = (popup.winfo_screenwidth() // 2) - (popup.winfo_width() // 2)
1387
+ y = (popup.winfo_screenheight() // 2) - (popup.winfo_height() // 2)
1388
+ popup.geometry(f"+{x}+{y}")
1389
+
1390
+ # History listbox
1391
+ frame = ttk.Frame(popup)
1392
+ frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
1393
+
1394
+ ttk.Label(frame, text=f"Last 50 {title.lower()} terms:").pack(anchor="w")
1395
+
1396
+ listbox_frame = ttk.Frame(frame)
1397
+ listbox_frame.pack(fill=tk.BOTH, expand=True, pady=(5,0))
1398
+
1399
+ scrollbar = ttk.Scrollbar(listbox_frame)
1400
+ scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
1401
+
1402
+ listbox = tk.Listbox(listbox_frame, yscrollcommand=scrollbar.set)
1403
+ listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
1404
+ scrollbar.config(command=listbox.yview)
1405
+
1406
+ # Populate listbox with history (most recent first)
1407
+ for item in reversed(history[-50:]):
1408
+ listbox.insert(tk.END, item)
1409
+
1410
+ # Buttons
1411
+ button_frame = ttk.Frame(frame)
1412
+ button_frame.pack(fill=tk.X, pady=(10,0))
1413
+
1414
+ def use_selected():
1415
+ selection = listbox.curselection()
1416
+ if selection:
1417
+ selected_text = listbox.get(selection[0])
1418
+ target_field.delete(0, tk.END)
1419
+ target_field.insert(0, selected_text)
1420
+ popup.destroy()
1421
+ self.on_setting_change()
1422
+
1423
+ def clear_history():
1424
+ settings[history_key] = []
1425
+ self.settings_manager.save_settings()
1426
+ listbox.delete(0, tk.END)
1427
+
1428
+ ttk.Button(button_frame, text="Use Selected", command=use_selected).pack(side=tk.LEFT, padx=(0,5))
1429
+ ttk.Button(button_frame, text="Clear History", command=clear_history).pack(side=tk.LEFT, padx=5)
1430
+ ttk.Button(button_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT)
1431
+
1432
+ # Double-click to use selected
1433
+ listbox.bind('<Double-Button-1>', lambda e: use_selected())
1434
+
1435
+ def _add_to_history(self, history_key: str, value: str):
1436
+ """Add a value to the specified history list."""
1437
+ if not value:
1438
+ return
1439
+
1440
+ settings = self.settings_manager.get_tool_settings("Find & Replace Text")
1441
+ history = settings.get(history_key, [])
1442
+
1443
+ # Remove if already exists to avoid duplicates
1444
+ if value in history:
1445
+ history.remove(value)
1446
+
1447
+ # Add to end (most recent)
1448
+ history.append(value)
1449
+
1450
+ # Keep only last 50 items
1451
+ if len(history) > 50:
1452
+ history = history[-50:]
1453
+
1454
+ settings[history_key] = history
1455
+ self.settings_manager.save_settings()
1456
+
1457
+ def _save_undo_state(self, text: str, find_str: str, replace_str: str):
1458
+ """Save current state for undo functionality using Memento pattern if available."""
1459
+ # Use Memento pattern if available
1460
+ if self.memento_caretaker and MEMENTO_AVAILABLE:
1461
+ try:
1462
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1463
+ before_state = capture_text_state(active_output_tab.text)
1464
+ before_state.content = text # Original text before replacement
1465
+
1466
+ memento = FindReplaceMemento(
1467
+ before_state=before_state,
1468
+ find_pattern=find_str,
1469
+ replace_pattern=replace_str,
1470
+ is_regex=self.regex_mode_var.get() if self.regex_mode_var else False,
1471
+ match_case=self.match_case_var.get() if self.match_case_var else False
1472
+ )
1473
+ self.memento_caretaker.save(memento)
1474
+ return
1475
+ except Exception as e:
1476
+ self.logger.warning(f"Memento save failed, using fallback: {e}")
1477
+
1478
+ # Fallback to legacy undo stack
1479
+ undo_entry = {
1480
+ 'text': text,
1481
+ 'find': find_str,
1482
+ 'replace': replace_str,
1483
+ 'timestamp': time.time()
1484
+ }
1485
+
1486
+ self.undo_stack.append(undo_entry)
1487
+
1488
+ # Limit undo stack size
1489
+ if len(self.undo_stack) > self.max_undo_stack:
1490
+ self.undo_stack.pop(0)
1491
+
1492
+ # Enable undo button
1493
+ if hasattr(self, 'undo_button'):
1494
+ self.undo_button.config(state="normal")
1495
+
1496
+ def _on_undo_redo_change(self, can_undo: bool, can_redo: bool):
1497
+ """Callback when undo/redo availability changes."""
1498
+ try:
1499
+ if hasattr(self, 'undo_button'):
1500
+ self.undo_button.config(state="normal" if can_undo else "disabled")
1501
+ if hasattr(self, 'redo_button'):
1502
+ self.redo_button.config(state="normal" if can_redo else "disabled")
1503
+ except Exception:
1504
+ pass # UI might not be initialized yet
1505
+
1506
+ def undo_replace_all(self):
1507
+ """Undo the last Replace All operation."""
1508
+ # Try Memento-based undo first
1509
+ if self.memento_caretaker and MEMENTO_AVAILABLE and self.memento_caretaker.can_undo():
1510
+ try:
1511
+ memento = self.memento_caretaker.undo()
1512
+ if memento and memento.before_state:
1513
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1514
+
1515
+ # Capture current state as after_state before restoring
1516
+ memento.after_state = capture_text_state(active_output_tab.text)
1517
+
1518
+ # Restore the before state
1519
+ restore_text_state(active_output_tab.text, memento.before_state)
1520
+
1521
+ # Reset replacement count
1522
+ self.replaced_count = 0
1523
+ self.replaced_count_label.config(text="Replaced matches: 0")
1524
+
1525
+ # Silent undo - just log instead of showing dialog
1526
+ self.logger.info(f"Undone: Replace '{memento.find_pattern[:30]}...' with '{memento.replace_pattern[:30]}...'")
1527
+
1528
+ # Re-apply highlighting to show remaining matches
1529
+ self._refresh_highlighting_after_undo()
1530
+ return
1531
+ except Exception as e:
1532
+ self.logger.error(f"Memento undo failed: {e}")
1533
+
1534
+ # Fallback to legacy undo stack
1535
+ if not self.undo_stack:
1536
+ # Silent - no operations to undo
1537
+ return
1538
+
1539
+ if not self.output_tabs:
1540
+ return
1541
+
1542
+ try:
1543
+ # Get last undo state
1544
+ undo_entry = self.undo_stack.pop()
1545
+
1546
+ # Restore the text
1547
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1548
+ active_output_tab.text.config(state="normal")
1549
+ active_output_tab.text.delete("1.0", tk.END)
1550
+ active_output_tab.text.insert("1.0", undo_entry['text'])
1551
+ active_output_tab.text.config(state="disabled")
1552
+
1553
+ # Reset replacement count
1554
+ self.replaced_count = 0
1555
+ self.replaced_count_label.config(text="Replaced matches: 0")
1556
+
1557
+ # Disable undo button if no more undo states
1558
+ if not self.undo_stack and hasattr(self, 'undo_button'):
1559
+ self.undo_button.config(state="disabled")
1560
+
1561
+ # Silent undo - just log
1562
+ self.logger.info(f"Undone: Replace '{undo_entry['find']}' with '{undo_entry['replace']}'")
1563
+
1564
+ # Re-apply highlighting to show remaining matches
1565
+ self._refresh_highlighting_after_undo()
1566
+ except Exception as e:
1567
+ self.logger.error(f"Error during undo: {e}")
1568
+ self._show_warning("Undo Error", f"Failed to undo: {e}")
1569
+
1570
+ def _refresh_highlighting_after_undo(self):
1571
+ """Refresh match highlighting after an undo operation.
1572
+
1573
+ This highlights remaining matches in the EXISTING output content,
1574
+ without overwriting it with input content (unlike preview_find_replace).
1575
+ """
1576
+ try:
1577
+ find_str = self.find_text_field.get()
1578
+ if not find_str or not self.output_tabs:
1579
+ return
1580
+
1581
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1582
+
1583
+ # Clear existing highlights
1584
+ active_output_tab.text.tag_remove("pink_highlight", "1.0", tk.END)
1585
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1586
+
1587
+ # Get current output content (already restored by undo)
1588
+ output_text = active_output_tab.text.get("1.0", "end-1c")
1589
+
1590
+ # Find all matches in the output
1591
+ try:
1592
+ if self.regex_mode_var.get():
1593
+ flags = 0 if self.match_case_var.get() else re.IGNORECASE
1594
+ pattern = re.compile(find_str, flags)
1595
+ matches = list(pattern.finditer(output_text))
1596
+ else:
1597
+ # Plain text search
1598
+ matches = []
1599
+ search_text = output_text if self.match_case_var.get() else output_text.lower()
1600
+ search_pattern = find_str if self.match_case_var.get() else find_str.lower()
1601
+ start = 0
1602
+ while True:
1603
+ pos = search_text.find(search_pattern, start)
1604
+ if pos == -1:
1605
+ break
1606
+ # Create a simple match-like object
1607
+ class SimpleMatch:
1608
+ def __init__(self, s, e):
1609
+ self._start = s
1610
+ self._end = e
1611
+ def start(self):
1612
+ return self._start
1613
+ def end(self):
1614
+ return self._end
1615
+ matches.append(SimpleMatch(pos, pos + len(find_str)))
1616
+ start = pos + 1
1617
+
1618
+ # Highlight all matches
1619
+ active_output_tab.text.config(state="normal")
1620
+ for match in matches:
1621
+ start_idx = f"1.0+{match.start()}c"
1622
+ end_idx = f"1.0+{match.end()}c"
1623
+ active_output_tab.text.tag_add("pink_highlight", start_idx, end_idx)
1624
+
1625
+ active_output_tab.text.tag_config("pink_highlight", background="pink")
1626
+ active_output_tab.text.config(state="disabled")
1627
+
1628
+ # Update match count
1629
+ self.match_count_label.config(text=f"Found matches: {len(matches)}")
1630
+
1631
+ except re.error:
1632
+ pass # Invalid regex, skip highlighting
1633
+
1634
+ except Exception as e:
1635
+ self.logger.debug(f"Could not refresh highlighting after undo: {e}")
1636
+
1637
+
1638
+
1639
+ def redo_replace_all(self):
1640
+ """Redo the last undone Replace All operation."""
1641
+ if not self.memento_caretaker or not MEMENTO_AVAILABLE:
1642
+ # Silent - redo not available
1643
+ return
1644
+
1645
+ if not self.memento_caretaker.can_redo():
1646
+ # Silent - no operations to redo
1647
+ return
1648
+
1649
+ if not self.output_tabs:
1650
+ return
1651
+
1652
+ try:
1653
+ memento = self.memento_caretaker.redo()
1654
+ if memento and memento.after_state:
1655
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1656
+
1657
+ # Restore the after state (the result of the replacement)
1658
+ restore_text_state(active_output_tab.text, memento.after_state)
1659
+
1660
+ # Silent redo - just log
1661
+ self.logger.info(f"Redone: Replace '{memento.find_pattern[:30]}...' with '{memento.replace_pattern[:30]}...'")
1662
+ else:
1663
+ # Silent - can't redo without after state
1664
+ self.logger.warning("Cannot redo - after state not captured.")
1665
+
1666
+ except Exception as e:
1667
+ self.logger.error(f"Error during redo: {e}")
1668
+ self._show_warning("Redo Error", f"Failed to redo: {e}")
1669
+
1670
+
1671
+ def show_diff_preview(self):
1672
+ """
1673
+ Show a unified diff preview of the find/replace operation.
1674
+ Displays in a popup window before executing Replace All.
1675
+ """
1676
+ if not DIFF_UTILS_AVAILABLE:
1677
+ self._show_warning("Not Available", "Diff preview is not available. Missing diff_utils module.")
1678
+ return
1679
+
1680
+ if not self.input_tabs:
1681
+ return
1682
+
1683
+ # Validate regex first
1684
+ is_valid, error_msg = self.validate_current_pattern()
1685
+ if not is_valid:
1686
+ self._show_warning("Regex Error", error_msg)
1687
+ return
1688
+
1689
+ active_input_tab, _ = self._get_active_tabs()
1690
+ find_str = self.find_text_field.get().strip()
1691
+ replace_str = self.replace_text_field.get()
1692
+
1693
+ if not find_str:
1694
+ self._show_info("No Pattern", "Enter a find pattern to preview changes.")
1695
+ return
1696
+
1697
+ input_text = active_input_tab.text.get("1.0", tk.END)
1698
+ if input_text.endswith('\n'):
1699
+ input_text = input_text[:-1]
1700
+
1701
+ try:
1702
+ # Generate preview using diff_utils
1703
+ preview = generate_find_replace_preview(
1704
+ text=input_text,
1705
+ find_pattern=find_str if self.regex_mode_var.get() else find_str,
1706
+ replace_pattern=replace_str,
1707
+ use_regex=self.regex_mode_var.get(),
1708
+ case_sensitive=self.match_case_var.get(),
1709
+ context_lines=3
1710
+ )
1711
+
1712
+ if preview.match_count == 0:
1713
+ self._show_info("No Matches", "No matches found for the current find pattern.")
1714
+ return
1715
+
1716
+ # Create preview popup window
1717
+ self._show_diff_preview_window(preview, find_str, replace_str)
1718
+
1719
+ except Exception as e:
1720
+ self.logger.error(f"Error generating diff preview: {e}")
1721
+ self._show_warning("Preview Error", f"Failed to generate preview: {e}")
1722
+
1723
+ def _show_diff_preview_window(self, preview: 'FindReplacePreview', find_str: str, replace_str: str):
1724
+ """Display the diff preview in a popup window."""
1725
+ popup = tk.Toplevel(self.parent)
1726
+ popup.title("Find & Replace Preview")
1727
+ popup.geometry("800x500")
1728
+ popup.transient(self.parent)
1729
+
1730
+ # Center the popup
1731
+ popup.update_idletasks()
1732
+ x = (popup.winfo_screenwidth() // 2) - (popup.winfo_width() // 2)
1733
+ y = (popup.winfo_screenheight() // 2) - (popup.winfo_height() // 2)
1734
+ popup.geometry(f"+{x}+{y}")
1735
+
1736
+ # Main frame
1737
+ main_frame = ttk.Frame(popup)
1738
+ main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
1739
+
1740
+ # Summary info
1741
+ summary_frame = ttk.Frame(main_frame)
1742
+ summary_frame.pack(fill=tk.X, pady=(0, 10))
1743
+
1744
+ mode_str = "Regex" if self.regex_mode_var.get() else "Text"
1745
+ case_str = "Case-sensitive" if self.match_case_var.get() else "Case-insensitive"
1746
+
1747
+ ttk.Label(summary_frame, text=f"Find: ", font=("Arial", 10, "bold")).pack(side=tk.LEFT)
1748
+ ttk.Label(summary_frame, text=f"'{find_str}' ", font=("Courier", 10)).pack(side=tk.LEFT)
1749
+ ttk.Label(summary_frame, text=f"→ Replace: ", font=("Arial", 10, "bold")).pack(side=tk.LEFT)
1750
+ ttk.Label(summary_frame, text=f"'{replace_str}'", font=("Courier", 10)).pack(side=tk.LEFT)
1751
+
1752
+ stats_frame = ttk.Frame(main_frame)
1753
+ stats_frame.pack(fill=tk.X, pady=(0, 10))
1754
+ ttk.Label(stats_frame,
1755
+ text=f"Matches: {preview.match_count} | Lines affected: {preview.lines_affected} | Mode: {mode_str} ({case_str})",
1756
+ foreground="gray").pack(side=tk.LEFT)
1757
+
1758
+ # Options frame for checkboxes
1759
+ options_frame = ttk.Frame(main_frame)
1760
+ options_frame.pack(fill=tk.X, pady=(0, 5))
1761
+
1762
+ # Word wrap checkbox
1763
+ wrap_var = tk.BooleanVar(value=True)
1764
+ char_diff_var = tk.BooleanVar(value=False)
1765
+ syntax_var = tk.BooleanVar(value=False)
1766
+
1767
+ def refresh_diff_display():
1768
+ """Refresh the diff display with current options."""
1769
+ diff_text.config(state="normal")
1770
+ diff_text.delete("1.0", tk.END)
1771
+
1772
+ # Apply word wrap setting
1773
+ if wrap_var.get():
1774
+ diff_text.config(wrap=tk.WORD)
1775
+ else:
1776
+ diff_text.config(wrap=tk.NONE)
1777
+
1778
+ # Insert diff with syntax highlighting
1779
+ for line in preview.unified_diff.splitlines(keepends=True):
1780
+ if line.startswith('---') or line.startswith('+++'):
1781
+ diff_text.insert(tk.END, line, "header")
1782
+ elif line.startswith('@@'):
1783
+ diff_text.insert(tk.END, line, "context")
1784
+ elif line.startswith('+'):
1785
+ diff_text.insert(tk.END, line, "addition")
1786
+ elif line.startswith('-'):
1787
+ diff_text.insert(tk.END, line, "deletion")
1788
+ else:
1789
+ diff_text.insert(tk.END, line)
1790
+
1791
+ # Apply char-level highlighting to replacement text in + lines
1792
+ if char_diff_var.get() and replace_str:
1793
+ self._highlight_replacements_in_diff(diff_text, replace_str)
1794
+
1795
+ # Apply syntax highlighting if enabled
1796
+ if syntax_var.get():
1797
+ self._apply_code_syntax_highlighting(diff_text)
1798
+
1799
+ diff_text.config(state="disabled")
1800
+
1801
+
1802
+ ttk.Checkbutton(options_frame, text="Word Wrap", variable=wrap_var,
1803
+ command=refresh_diff_display).pack(side=tk.LEFT, padx=5)
1804
+ ttk.Checkbutton(options_frame, text="Char Diff", variable=char_diff_var,
1805
+ command=refresh_diff_display).pack(side=tk.LEFT, padx=5)
1806
+ ttk.Checkbutton(options_frame, text="Syntax", variable=syntax_var,
1807
+ command=refresh_diff_display).pack(side=tk.LEFT, padx=5)
1808
+
1809
+ # Diff display with scrollbars
1810
+ diff_frame = ttk.Frame(main_frame)
1811
+ diff_frame.pack(fill=tk.BOTH, expand=True)
1812
+
1813
+ # Vertical scrollbar
1814
+ v_scrollbar = ttk.Scrollbar(diff_frame, orient=tk.VERTICAL)
1815
+ v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
1816
+
1817
+ # Horizontal scrollbar
1818
+ h_scrollbar = ttk.Scrollbar(diff_frame, orient=tk.HORIZONTAL)
1819
+ h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)
1820
+
1821
+ diff_text = tk.Text(diff_frame, wrap=tk.WORD, # Default to word wrap ON
1822
+ yscrollcommand=v_scrollbar.set, xscrollcommand=h_scrollbar.set,
1823
+ font=("Courier New", 10), bg="#1e1e1e", fg="#d4d4d4")
1824
+ diff_text.pack(fill=tk.BOTH, expand=True)
1825
+ v_scrollbar.config(command=diff_text.yview)
1826
+ h_scrollbar.config(command=diff_text.xview)
1827
+
1828
+
1829
+ # Configure diff syntax highlighting tags
1830
+ diff_text.tag_configure("header", foreground="#569cd6") # Blue for headers
1831
+ diff_text.tag_configure("addition", foreground="#4ec9b0", background="#1e3a1e") # Green
1832
+ diff_text.tag_configure("deletion", foreground="#ce9178", background="#3a1e1e") # Red
1833
+ diff_text.tag_configure("context", foreground="#808080") # Gray for @@ lines
1834
+ diff_text.tag_configure("char_add", foreground="#ffffff", background="#2d5a2d") # Bright green for char changes
1835
+ diff_text.tag_configure("char_del", foreground="#ffffff", background="#5a2d2d") # Bright red for char changes
1836
+ # Syntax highlighting tags
1837
+ diff_text.tag_configure("keyword", foreground="#c586c0") # Purple for keywords
1838
+ diff_text.tag_configure("string", foreground="#ce9178") # Orange for strings
1839
+ diff_text.tag_configure("comment", foreground="#6a9955") # Green for comments
1840
+ diff_text.tag_configure("number", foreground="#b5cea8") # Light green for numbers
1841
+
1842
+ # Initial render
1843
+ refresh_diff_display()
1844
+
1845
+
1846
+ # Buttons frame
1847
+ button_frame = ttk.Frame(main_frame)
1848
+ button_frame.pack(fill=tk.X, pady=(10, 0))
1849
+
1850
+ def apply_and_close():
1851
+ popup.destroy()
1852
+ self.trigger_replace_all()
1853
+
1854
+ ttk.Button(button_frame, text="Apply Replace All", command=apply_and_close).pack(side=tk.LEFT, padx=5)
1855
+ ttk.Button(button_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT, padx=5)
1856
+
1857
+ def _highlight_replacements_in_diff(self, text_widget, replace_str: str):
1858
+ """
1859
+ Highlight occurrences of the replacement string within + (addition) lines.
1860
+ This shows exactly what was replaced in the diff output.
1861
+ """
1862
+ if not replace_str:
1863
+ return
1864
+
1865
+ # Get all content
1866
+ content = text_widget.get("1.0", tk.END)
1867
+ lines = content.split('\n')
1868
+
1869
+ # Escape the replace string for regex search (treat as literal)
1870
+ escaped_replace = re.escape(replace_str)
1871
+
1872
+ current_line = 1
1873
+ for line in lines:
1874
+ if line.startswith('+') and not line.startswith('+++'):
1875
+ # This is an addition line - find replacement text in it
1876
+ line_content = line[1:] # Skip the + prefix
1877
+ for match in re.finditer(escaped_replace, line_content, re.IGNORECASE if not self.match_case_var.get() else 0):
1878
+ # Calculate positions (add 1 for the + prefix)
1879
+ start_col = match.start() + 1
1880
+ end_col = match.end() + 1
1881
+ start_idx = f"{current_line}.{start_col}"
1882
+ end_idx = f"{current_line}.{end_col}"
1883
+ text_widget.tag_add("char_add", start_idx, end_idx)
1884
+ current_line += 1
1885
+
1886
+
1887
+ def _apply_code_syntax_highlighting(self, text_widget):
1888
+ """
1889
+ Apply basic code syntax highlighting to the diff text.
1890
+ Highlights keywords, strings, comments, and numbers.
1891
+ """
1892
+ content = text_widget.get("1.0", tk.END)
1893
+
1894
+ # Python/JavaScript keywords
1895
+ keywords = r'\b(def|class|return|if|else|elif|for|while|try|except|import|from|as|' \
1896
+ r'function|const|let|var|async|await|true|false|True|False|None|null)\b'
1897
+
1898
+ # Strings (single and double quoted)
1899
+ strings = r'(["\'])(?:(?!\1|\\).|\\.)*\1'
1900
+
1901
+ # Comments
1902
+ comments = r'(#.*$|//.*$|/\*.*?\*/)'
1903
+
1904
+ # Numbers
1905
+ numbers = r'\b\d+\.?\d*\b'
1906
+
1907
+ import re
1908
+
1909
+ patterns = [
1910
+ (keywords, "keyword"),
1911
+ (strings, "string"),
1912
+ (comments, "comment"),
1913
+ (numbers, "number"),
1914
+ ]
1915
+
1916
+ for pattern, tag in patterns:
1917
+ for match in re.finditer(pattern, content, re.MULTILINE):
1918
+ start = f"1.0+{match.start()}c"
1919
+ end = f"1.0+{match.end()}c"
1920
+ # Only apply if not already in a header/context line
1921
+ try:
1922
+ line_start = text_widget.index(f"{start} linestart")
1923
+ first_char = text_widget.get(line_start, f"{line_start}+1c")
1924
+ if first_char not in ('@', '-', '+'):
1925
+ continue # Only highlight actual diff content
1926
+ text_widget.tag_add(tag, start, end)
1927
+ except tk.TclError:
1928
+ pass
1929
+
1930
+
1931
+ def on_regex_mode_change(self):
1932
+ """Handle changes to regex mode checkbox."""
1933
+ if not self.option_radiobuttons:
1934
+ return
1935
+
1936
+ is_regex = self.regex_mode_var.get()
1937
+
1938
+ # Disable/enable text matching options when regex mode is on/off
1939
+ for key, rb in self.option_radiobuttons.items():
1940
+ if key in ["whole_words", "match_prefix", "match_suffix"]:
1941
+ if is_regex:
1942
+ rb.config(state="disabled")
1943
+ else:
1944
+ rb.config(state="normal")
1945
+
1946
+ # If regex mode is enabled and a disabled option is selected, reset to "none"
1947
+ if is_regex and self.fr_option_var.get() in ["whole_words", "match_prefix", "match_suffix"]:
1948
+ self.fr_option_var.set("none")
1949
+
1950
+ # Enable/disable Pattern Library button based on regex mode
1951
+ if hasattr(self, 'pattern_library_button'):
1952
+ if is_regex:
1953
+ self.pattern_library_button.config(state="normal")
1954
+ else:
1955
+ self.pattern_library_button.config(state="disabled")
1956
+
1957
+ # Clear regex cache when options change
1958
+ self._regex_cache.clear()
1959
+
1960
+ # Notify parent of setting change
1961
+ self.on_setting_change()
1962
+
1963
+ # Re-run search if there's a find string
1964
+ if hasattr(self, 'find_text_field') and self.find_text_field.get().strip():
1965
+ self.parent.after_idle(self.preview_find_replace)
1966
+
1967
+ def on_find_replace_option_change(self):
1968
+ """Handle changes to Find & Replace radio button options."""
1969
+ # Clear regex cache when options change
1970
+ self._regex_cache.clear()
1971
+
1972
+ # Notify parent of setting change
1973
+ self.on_setting_change()
1974
+
1975
+ # Re-run search if there's a find string
1976
+ if hasattr(self, 'find_text_field') and self.find_text_field.get().strip():
1977
+ # Use after_idle to avoid recursion and ensure UI is updated first
1978
+ self.parent.after_idle(self.preview_find_replace)
1979
+
1980
+ def _on_find_text_change(self, event=None):
1981
+ """Handle changes to find text field."""
1982
+ self.on_setting_change()
1983
+
1984
+ def _on_replace_text_change(self, event=None):
1985
+ """Handle changes to replace text field."""
1986
+ self.on_setting_change()
1987
+
1988
+ def on_setting_change(self):
1989
+ """Notify parent that settings have changed."""
1990
+ # This should be overridden by the parent or connected to a callback
1991
+ pass
1992
+
1993
+ def trigger_replace_all(self):
1994
+ """Trigger the parent application's apply_tool method for Replace All functionality."""
1995
+ # This calls the parent application's apply_tool method, which will:
1996
+ # 1. Call _process_text_with_tool
1997
+ # 2. Which calls self.find_replace_widget.replace_all()
1998
+ # 3. And then update the output UI automatically
1999
+ if hasattr(self, 'apply_tool_callback') and self.apply_tool_callback:
2000
+ self.apply_tool_callback()
2001
+ else:
2002
+ # Fallback to direct replace_all if no callback is set
2003
+ result = self.replace_all()
2004
+ # We need to update the output manually since we're not going through the normal pipeline
2005
+ if hasattr(self, 'output_tabs') and self.output_tabs:
2006
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
2007
+ active_output_tab.text.config(state="normal")
2008
+ active_output_tab.text.delete("1.0", tk.END)
2009
+ active_output_tab.text.insert("1.0", result)
2010
+ active_output_tab.text.config(state="disabled")
2011
+
2012
+ def show_pattern_library(self):
2013
+ """Shows the Pattern Library window with regex patterns."""
2014
+ # Get pattern library from settings
2015
+ pattern_library = self.settings_manager.get_pattern_library()
2016
+
2017
+ popup = tk.Toplevel(self.parent)
2018
+ popup.title("Pattern Library")
2019
+ popup.geometry("800x500")
2020
+ popup.transient(self.parent)
2021
+ popup.grab_set()
2022
+
2023
+ # Center the popup
2024
+ popup.update_idletasks()
2025
+ x = (popup.winfo_screenwidth() // 2) - (popup.winfo_width() // 2)
2026
+ y = (popup.winfo_screenheight() // 2) - (popup.winfo_height() // 2)
2027
+ popup.geometry(f"+{x}+{y}")
2028
+
2029
+ # Main frame
2030
+ main_frame = ttk.Frame(popup)
2031
+ main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
2032
+
2033
+ # Title
2034
+ ttk.Label(main_frame, text="Regex Pattern Library", font=("Arial", 12, "bold")).pack(anchor="w", pady=(0,10))
2035
+
2036
+ # Treeview for the table
2037
+ tree_frame = ttk.Frame(main_frame)
2038
+ tree_frame.pack(fill=tk.BOTH, expand=True)
2039
+
2040
+ # Create Treeview with scrollbars
2041
+ tree_scroll_y = ttk.Scrollbar(tree_frame)
2042
+ tree_scroll_y.pack(side=tk.RIGHT, fill=tk.Y)
2043
+
2044
+ tree_scroll_x = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL)
2045
+ tree_scroll_x.pack(side=tk.BOTTOM, fill=tk.X)
2046
+
2047
+ tree = ttk.Treeview(tree_frame,
2048
+ columns=("Find", "Replace", "Purpose"),
2049
+ show="headings",
2050
+ yscrollcommand=tree_scroll_y.set,
2051
+ xscrollcommand=tree_scroll_x.set)
2052
+ tree.pack(fill=tk.BOTH, expand=True)
2053
+
2054
+ tree_scroll_y.config(command=tree.yview)
2055
+ tree_scroll_x.config(command=tree.xview)
2056
+
2057
+ # Configure columns
2058
+ tree.heading("Find", text="Find")
2059
+ tree.heading("Replace", text="Replace")
2060
+ tree.heading("Purpose", text="Purpose")
2061
+
2062
+ tree.column("Find", width=200, minwidth=150)
2063
+ tree.column("Replace", width=150, minwidth=100)
2064
+ tree.column("Purpose", width=300, minwidth=200)
2065
+
2066
+ # Populate tree with patterns
2067
+ def refresh_tree():
2068
+ tree.delete(*tree.get_children())
2069
+ for i, pattern in enumerate(pattern_library):
2070
+ tree.insert("", tk.END, iid=i, values=(pattern["find"], pattern["replace"], pattern["purpose"]))
2071
+
2072
+ refresh_tree()
2073
+
2074
+ # Management buttons frame
2075
+ button_frame = ttk.Frame(main_frame)
2076
+ button_frame.pack(fill=tk.X, pady=(10,0))
2077
+
2078
+ # Left side buttons (management)
2079
+ left_buttons = ttk.Frame(button_frame)
2080
+ left_buttons.pack(side=tk.LEFT)
2081
+
2082
+ def add_pattern():
2083
+ pattern_library.append({"find": "", "replace": "", "purpose": ""})
2084
+ refresh_tree()
2085
+ # Select the new item for editing
2086
+ new_item_id = len(pattern_library) - 1
2087
+ tree.selection_set(str(new_item_id))
2088
+ tree.focus(str(new_item_id))
2089
+ # Explicitly update and save pattern library
2090
+ if hasattr(self.settings_manager, 'set_pattern_library'):
2091
+ self.settings_manager.set_pattern_library(pattern_library)
2092
+ else:
2093
+ self.settings_manager.save_settings()
2094
+
2095
+ def delete_pattern():
2096
+ selection = tree.selection()
2097
+ if selection:
2098
+ item_id = int(selection[0])
2099
+ del pattern_library[item_id]
2100
+ refresh_tree()
2101
+ # Explicitly update and save pattern library
2102
+ if hasattr(self.settings_manager, 'set_pattern_library'):
2103
+ self.settings_manager.set_pattern_library(pattern_library)
2104
+ else:
2105
+ self.settings_manager.save_settings()
2106
+
2107
+ def move_up():
2108
+ selection = tree.selection()
2109
+ if selection:
2110
+ item_id = int(selection[0])
2111
+ if item_id > 0:
2112
+ # Swap with previous item
2113
+ pattern_library[item_id], pattern_library[item_id-1] = \
2114
+ pattern_library[item_id-1], pattern_library[item_id]
2115
+ refresh_tree()
2116
+ tree.selection_set(str(item_id-1))
2117
+ tree.focus(str(item_id-1))
2118
+ self.settings_manager.set_pattern_library(pattern_library) if hasattr(self.settings_manager, 'set_pattern_library') else self.settings_manager.save_settings()
2119
+
2120
+ def move_down():
2121
+ selection = tree.selection()
2122
+ if selection:
2123
+ item_id = int(selection[0])
2124
+ if item_id < len(pattern_library) - 1:
2125
+ # Swap with next item
2126
+ pattern_library[item_id], pattern_library[item_id+1] = \
2127
+ pattern_library[item_id+1], pattern_library[item_id]
2128
+ refresh_tree()
2129
+ tree.selection_set(str(item_id+1))
2130
+ tree.focus(str(item_id+1))
2131
+ self.settings_manager.set_pattern_library(pattern_library) if hasattr(self.settings_manager, 'set_pattern_library') else self.settings_manager.save_settings()
2132
+
2133
+ ttk.Button(left_buttons, text="Add", command=add_pattern).pack(side=tk.LEFT, padx=(0,5))
2134
+ ttk.Button(left_buttons, text="Delete", command=delete_pattern).pack(side=tk.LEFT, padx=5)
2135
+ ttk.Button(left_buttons, text="Move Up", command=move_up).pack(side=tk.LEFT, padx=5)
2136
+ ttk.Button(left_buttons, text="Move Down", command=move_down).pack(side=tk.LEFT, padx=5)
2137
+
2138
+ # Right side buttons (use/close)
2139
+ right_buttons = ttk.Frame(button_frame)
2140
+ right_buttons.pack(side=tk.RIGHT)
2141
+
2142
+ def use_pattern():
2143
+ selection = tree.selection()
2144
+ if selection:
2145
+ item_id = int(selection[0])
2146
+ pattern = pattern_library[item_id]
2147
+ self.find_text_field.delete(0, tk.END)
2148
+ self.find_text_field.insert(0, pattern["find"])
2149
+ self.replace_text_field.delete(0, tk.END)
2150
+ self.replace_text_field.insert(0, pattern["replace"])
2151
+ popup.destroy()
2152
+ self.on_setting_change()
2153
+
2154
+ def ai_help():
2155
+ """Copy selected pattern to next empty input tab and switch to AI Tools."""
2156
+ # Check if AI Tools is available
2157
+ if not AI_TOOLS_AVAILABLE:
2158
+ self._show_warning("AI Tools Not Available",
2159
+ "AI Tools module is not available. Please ensure the ai_tools.py module is properly installed.")
2160
+ return
2161
+
2162
+ selection = tree.selection()
2163
+ if not selection:
2164
+ self._show_info("No Selection", "Please select a pattern from the list first.")
2165
+ return
2166
+
2167
+ item_id = int(selection[0])
2168
+ pattern = pattern_library[item_id]
2169
+
2170
+ # Create the AI help text
2171
+ ai_help_text = f"""## Please help me understand this regex:
2172
+
2173
+ Purpose: {pattern.get('purpose', 'No purpose specified')}
2174
+ Find: {pattern['find']}
2175
+ Replace: {pattern['replace']}"""
2176
+
2177
+ # This would need to be implemented by the parent application
2178
+ # For now, just show a message
2179
+ self._show_info("AI Help", "AI Help integration would be implemented by the parent application.")
2180
+ popup.destroy()
2181
+
2182
+ ai_help_button = ttk.Button(right_buttons, text="AI Help", command=ai_help)
2183
+ ai_help_button.pack(side=tk.LEFT, padx=5)
2184
+ use_pattern_button = ttk.Button(right_buttons, text="Use Pattern", command=use_pattern)
2185
+ use_pattern_button.pack(side=tk.LEFT, padx=5)
2186
+ ttk.Button(right_buttons, text="Close", command=popup.destroy).pack(side=tk.LEFT, padx=(5,0))
2187
+
2188
+ # Function to update button states based on selection
2189
+ def update_button_states():
2190
+ selection = tree.selection()
2191
+ state = "normal" if selection else "disabled"
2192
+ use_pattern_button.config(state=state)
2193
+ if AI_TOOLS_AVAILABLE:
2194
+ ai_help_button.config(state=state)
2195
+ else:
2196
+ ai_help_button.config(state="disabled")
2197
+
2198
+ # Bind selection change to update button states
2199
+ tree.bind('<<TreeviewSelect>>', lambda e: update_button_states())
2200
+
2201
+ # Initial button state update
2202
+ update_button_states()
2203
+
2204
+ # Double-click to use pattern
2205
+ tree.bind('<Double-Button-1>', lambda e: use_pattern())
2206
+
2207
+ # Cell editing functionality
2208
+ def on_cell_click(event):
2209
+ item = tree.selection()[0] if tree.selection() else None
2210
+ if item:
2211
+ column = tree.identify_column(event.x)
2212
+ if column in ['#1', '#2', '#3']: # Find, Replace, Purpose columns
2213
+ self._edit_cell(tree, item, column, popup, pattern_library)
2214
+
2215
+ tree.bind('<Button-1>', on_cell_click)
2216
+
2217
+ def _edit_cell(self, tree, item, column, parent_window, pattern_library):
2218
+ """Edit a cell in the pattern library tree."""
2219
+ # Get current value
2220
+ item_id = int(item)
2221
+ pattern = pattern_library[item_id]
2222
+
2223
+ column_map = {'#1': 'find', '#2': 'replace', '#3': 'purpose'}
2224
+ field_name = column_map[column]
2225
+ current_value = pattern[field_name]
2226
+
2227
+ # Get cell position
2228
+ bbox = tree.bbox(item, column)
2229
+ if not bbox:
2230
+ return
2231
+
2232
+ # Create entry widget for editing
2233
+ entry = tk.Entry(tree)
2234
+ entry.place(x=bbox[0], y=bbox[1], width=bbox[2], height=bbox[3])
2235
+ entry.insert(0, current_value)
2236
+ entry.select_range(0, tk.END)
2237
+ entry.focus()
2238
+
2239
+ def save_edit():
2240
+ new_value = entry.get()
2241
+ pattern[field_name] = new_value
2242
+ tree.set(item, column, new_value)
2243
+ entry.destroy()
2244
+ # Explicitly update and save pattern library
2245
+ if hasattr(self.settings_manager, 'set_pattern_library'):
2246
+ self.settings_manager.set_pattern_library(pattern_library)
2247
+ else:
2248
+ self.settings_manager.save_settings()
2249
+
2250
+ def cancel_edit():
2251
+ entry.destroy()
2252
+
2253
+ entry.bind('<Return>', lambda e: save_edit())
2254
+ entry.bind('<Escape>', lambda e: cancel_edit())
2255
+ entry.bind('<FocusOut>', lambda e: save_edit())
2256
+
2257
+
2258
+ class SettingsManager:
2259
+ """
2260
+ Interface for settings management that the FindReplaceWidget expects.
2261
+ This should be implemented by the parent application.
2262
+ """
2263
+
2264
+ def get_tool_settings(self, tool_name: str) -> Dict[str, Any]:
2265
+ """Get settings for a specific tool."""
2266
+ raise NotImplementedError("Must be implemented by parent application")
2267
+
2268
+ def save_settings(self):
2269
+ """Save current settings to persistent storage."""
2270
+ raise NotImplementedError("Must be implemented by parent application")
2271
+
2272
+ def get_pattern_library(self) -> List[Dict[str, str]]:
2273
+ """Get the regex pattern library."""
2274
+ raise NotImplementedError("Must be implemented by parent application")
2275
+
2276
+
2277
+ # Example usage and integration helper
2278
+ def create_find_replace_widget(parent, settings_manager, logger=None):
2279
+ """
2280
+ Factory function to create a Find & Replace widget.
2281
+
2282
+ Args:
2283
+ parent: Parent widget/window
2284
+ settings_manager: Object implementing SettingsManager interface
2285
+ logger: Optional logger instance
2286
+
2287
+ Returns:
2288
+ FindReplaceWidget instance
2289
+ """
1751
2290
  return FindReplaceWidget(parent, settings_manager, logger)