pomera-ai-commander 0.1.0

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 (192) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +680 -0
  3. package/bin/pomera-ai-commander.js +62 -0
  4. package/core/__init__.py +66 -0
  5. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/core/__pycache__/app_context.cpython-313.pyc +0 -0
  7. package/core/__pycache__/async_text_processor.cpython-313.pyc +0 -0
  8. package/core/__pycache__/backup_manager.cpython-313.pyc +0 -0
  9. package/core/__pycache__/backup_recovery_manager.cpython-313.pyc +0 -0
  10. package/core/__pycache__/content_hash_cache.cpython-313.pyc +0 -0
  11. package/core/__pycache__/context_menu.cpython-313.pyc +0 -0
  12. package/core/__pycache__/data_validator.cpython-313.pyc +0 -0
  13. package/core/__pycache__/database_connection_manager.cpython-313.pyc +0 -0
  14. package/core/__pycache__/database_curl_settings_manager.cpython-313.pyc +0 -0
  15. package/core/__pycache__/database_promera_ai_settings_manager.cpython-313.pyc +0 -0
  16. package/core/__pycache__/database_schema.cpython-313.pyc +0 -0
  17. package/core/__pycache__/database_schema_manager.cpython-313.pyc +0 -0
  18. package/core/__pycache__/database_settings_manager.cpython-313.pyc +0 -0
  19. package/core/__pycache__/database_settings_manager_interface.cpython-313.pyc +0 -0
  20. package/core/__pycache__/dialog_manager.cpython-313.pyc +0 -0
  21. package/core/__pycache__/efficient_line_numbers.cpython-313.pyc +0 -0
  22. package/core/__pycache__/error_handler.cpython-313.pyc +0 -0
  23. package/core/__pycache__/error_service.cpython-313.pyc +0 -0
  24. package/core/__pycache__/event_consolidator.cpython-313.pyc +0 -0
  25. package/core/__pycache__/memory_efficient_text_widget.cpython-313.pyc +0 -0
  26. package/core/__pycache__/migration_manager.cpython-313.pyc +0 -0
  27. package/core/__pycache__/migration_test_suite.cpython-313.pyc +0 -0
  28. package/core/__pycache__/migration_validator.cpython-313.pyc +0 -0
  29. package/core/__pycache__/optimized_find_replace.cpython-313.pyc +0 -0
  30. package/core/__pycache__/optimized_pattern_engine.cpython-313.pyc +0 -0
  31. package/core/__pycache__/optimized_search_highlighter.cpython-313.pyc +0 -0
  32. package/core/__pycache__/performance_monitor.cpython-313.pyc +0 -0
  33. package/core/__pycache__/persistence_manager.cpython-313.pyc +0 -0
  34. package/core/__pycache__/progressive_stats_calculator.cpython-313.pyc +0 -0
  35. package/core/__pycache__/regex_pattern_cache.cpython-313.pyc +0 -0
  36. package/core/__pycache__/regex_pattern_library.cpython-313.pyc +0 -0
  37. package/core/__pycache__/search_operation_manager.cpython-313.pyc +0 -0
  38. package/core/__pycache__/settings_defaults_registry.cpython-313.pyc +0 -0
  39. package/core/__pycache__/settings_integrity_validator.cpython-313.pyc +0 -0
  40. package/core/__pycache__/settings_serializer.cpython-313.pyc +0 -0
  41. package/core/__pycache__/settings_validator.cpython-313.pyc +0 -0
  42. package/core/__pycache__/smart_stats_calculator.cpython-313.pyc +0 -0
  43. package/core/__pycache__/statistics_update_manager.cpython-313.pyc +0 -0
  44. package/core/__pycache__/stats_config_manager.cpython-313.pyc +0 -0
  45. package/core/__pycache__/streaming_text_handler.cpython-313.pyc +0 -0
  46. package/core/__pycache__/task_scheduler.cpython-313.pyc +0 -0
  47. package/core/__pycache__/visibility_monitor.cpython-313.pyc +0 -0
  48. package/core/__pycache__/widget_cache.cpython-313.pyc +0 -0
  49. package/core/app_context.py +482 -0
  50. package/core/async_text_processor.py +422 -0
  51. package/core/backup_manager.py +656 -0
  52. package/core/backup_recovery_manager.py +1034 -0
  53. package/core/content_hash_cache.py +509 -0
  54. package/core/context_menu.py +313 -0
  55. package/core/data_validator.py +1067 -0
  56. package/core/database_connection_manager.py +745 -0
  57. package/core/database_curl_settings_manager.py +609 -0
  58. package/core/database_promera_ai_settings_manager.py +447 -0
  59. package/core/database_schema.py +412 -0
  60. package/core/database_schema_manager.py +396 -0
  61. package/core/database_settings_manager.py +1508 -0
  62. package/core/database_settings_manager_interface.py +457 -0
  63. package/core/dialog_manager.py +735 -0
  64. package/core/efficient_line_numbers.py +511 -0
  65. package/core/error_handler.py +747 -0
  66. package/core/error_service.py +431 -0
  67. package/core/event_consolidator.py +512 -0
  68. package/core/mcp/__init__.py +43 -0
  69. package/core/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
  70. package/core/mcp/__pycache__/protocol.cpython-313.pyc +0 -0
  71. package/core/mcp/__pycache__/schema.cpython-313.pyc +0 -0
  72. package/core/mcp/__pycache__/server_stdio.cpython-313.pyc +0 -0
  73. package/core/mcp/__pycache__/tool_registry.cpython-313.pyc +0 -0
  74. package/core/mcp/protocol.py +288 -0
  75. package/core/mcp/schema.py +251 -0
  76. package/core/mcp/server_stdio.py +299 -0
  77. package/core/mcp/tool_registry.py +2345 -0
  78. package/core/memory_efficient_text_widget.py +712 -0
  79. package/core/migration_manager.py +915 -0
  80. package/core/migration_test_suite.py +1086 -0
  81. package/core/migration_validator.py +1144 -0
  82. package/core/optimized_find_replace.py +715 -0
  83. package/core/optimized_pattern_engine.py +424 -0
  84. package/core/optimized_search_highlighter.py +553 -0
  85. package/core/performance_monitor.py +675 -0
  86. package/core/persistence_manager.py +713 -0
  87. package/core/progressive_stats_calculator.py +632 -0
  88. package/core/regex_pattern_cache.py +530 -0
  89. package/core/regex_pattern_library.py +351 -0
  90. package/core/search_operation_manager.py +435 -0
  91. package/core/settings_defaults_registry.py +1087 -0
  92. package/core/settings_integrity_validator.py +1112 -0
  93. package/core/settings_serializer.py +558 -0
  94. package/core/settings_validator.py +1824 -0
  95. package/core/smart_stats_calculator.py +710 -0
  96. package/core/statistics_update_manager.py +619 -0
  97. package/core/stats_config_manager.py +858 -0
  98. package/core/streaming_text_handler.py +723 -0
  99. package/core/task_scheduler.py +596 -0
  100. package/core/update_pattern_library.py +169 -0
  101. package/core/visibility_monitor.py +596 -0
  102. package/core/widget_cache.py +498 -0
  103. package/mcp.json +61 -0
  104. package/package.json +57 -0
  105. package/pomera.py +7483 -0
  106. package/pomera_mcp_server.py +144 -0
  107. package/tools/__init__.py +5 -0
  108. package/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/tools/__pycache__/ai_tools.cpython-313.pyc +0 -0
  110. package/tools/__pycache__/ascii_art_generator.cpython-313.pyc +0 -0
  111. package/tools/__pycache__/base64_tools.cpython-313.pyc +0 -0
  112. package/tools/__pycache__/base_tool.cpython-313.pyc +0 -0
  113. package/tools/__pycache__/case_tool.cpython-313.pyc +0 -0
  114. package/tools/__pycache__/column_tools.cpython-313.pyc +0 -0
  115. package/tools/__pycache__/cron_tool.cpython-313.pyc +0 -0
  116. package/tools/__pycache__/curl_history.cpython-313.pyc +0 -0
  117. package/tools/__pycache__/curl_processor.cpython-313.pyc +0 -0
  118. package/tools/__pycache__/curl_settings.cpython-313.pyc +0 -0
  119. package/tools/__pycache__/curl_tool.cpython-313.pyc +0 -0
  120. package/tools/__pycache__/diff_viewer.cpython-313.pyc +0 -0
  121. package/tools/__pycache__/email_extraction_tool.cpython-313.pyc +0 -0
  122. package/tools/__pycache__/email_header_analyzer.cpython-313.pyc +0 -0
  123. package/tools/__pycache__/extraction_tools.cpython-313.pyc +0 -0
  124. package/tools/__pycache__/find_replace.cpython-313.pyc +0 -0
  125. package/tools/__pycache__/folder_file_reporter.cpython-313.pyc +0 -0
  126. package/tools/__pycache__/folder_file_reporter_adapter.cpython-313.pyc +0 -0
  127. package/tools/__pycache__/generator_tools.cpython-313.pyc +0 -0
  128. package/tools/__pycache__/hash_generator.cpython-313.pyc +0 -0
  129. package/tools/__pycache__/html_tool.cpython-313.pyc +0 -0
  130. package/tools/__pycache__/huggingface_helper.cpython-313.pyc +0 -0
  131. package/tools/__pycache__/jsonxml_tool.cpython-313.pyc +0 -0
  132. package/tools/__pycache__/line_tools.cpython-313.pyc +0 -0
  133. package/tools/__pycache__/list_comparator.cpython-313.pyc +0 -0
  134. package/tools/__pycache__/markdown_tools.cpython-313.pyc +0 -0
  135. package/tools/__pycache__/mcp_widget.cpython-313.pyc +0 -0
  136. package/tools/__pycache__/notes_widget.cpython-313.pyc +0 -0
  137. package/tools/__pycache__/number_base_converter.cpython-313.pyc +0 -0
  138. package/tools/__pycache__/regex_extractor.cpython-313.pyc +0 -0
  139. package/tools/__pycache__/slug_generator.cpython-313.pyc +0 -0
  140. package/tools/__pycache__/sorter_tools.cpython-313.pyc +0 -0
  141. package/tools/__pycache__/string_escape_tool.cpython-313.pyc +0 -0
  142. package/tools/__pycache__/text_statistics_tool.cpython-313.pyc +0 -0
  143. package/tools/__pycache__/text_wrapper.cpython-313.pyc +0 -0
  144. package/tools/__pycache__/timestamp_converter.cpython-313.pyc +0 -0
  145. package/tools/__pycache__/tool_loader.cpython-313.pyc +0 -0
  146. package/tools/__pycache__/translator_tools.cpython-313.pyc +0 -0
  147. package/tools/__pycache__/url_link_extractor.cpython-313.pyc +0 -0
  148. package/tools/__pycache__/url_parser.cpython-313.pyc +0 -0
  149. package/tools/__pycache__/whitespace_tools.cpython-313.pyc +0 -0
  150. package/tools/__pycache__/word_frequency_counter.cpython-313.pyc +0 -0
  151. package/tools/ai_tools.py +2892 -0
  152. package/tools/ascii_art_generator.py +353 -0
  153. package/tools/base64_tools.py +184 -0
  154. package/tools/base_tool.py +511 -0
  155. package/tools/case_tool.py +309 -0
  156. package/tools/column_tools.py +396 -0
  157. package/tools/cron_tool.py +885 -0
  158. package/tools/curl_history.py +601 -0
  159. package/tools/curl_processor.py +1208 -0
  160. package/tools/curl_settings.py +503 -0
  161. package/tools/curl_tool.py +5467 -0
  162. package/tools/diff_viewer.py +1072 -0
  163. package/tools/email_extraction_tool.py +249 -0
  164. package/tools/email_header_analyzer.py +426 -0
  165. package/tools/extraction_tools.py +250 -0
  166. package/tools/find_replace.py +1751 -0
  167. package/tools/folder_file_reporter.py +1463 -0
  168. package/tools/folder_file_reporter_adapter.py +480 -0
  169. package/tools/generator_tools.py +1217 -0
  170. package/tools/hash_generator.py +256 -0
  171. package/tools/html_tool.py +657 -0
  172. package/tools/huggingface_helper.py +449 -0
  173. package/tools/jsonxml_tool.py +730 -0
  174. package/tools/line_tools.py +419 -0
  175. package/tools/list_comparator.py +720 -0
  176. package/tools/markdown_tools.py +562 -0
  177. package/tools/mcp_widget.py +1417 -0
  178. package/tools/notes_widget.py +973 -0
  179. package/tools/number_base_converter.py +373 -0
  180. package/tools/regex_extractor.py +572 -0
  181. package/tools/slug_generator.py +311 -0
  182. package/tools/sorter_tools.py +459 -0
  183. package/tools/string_escape_tool.py +393 -0
  184. package/tools/text_statistics_tool.py +366 -0
  185. package/tools/text_wrapper.py +431 -0
  186. package/tools/timestamp_converter.py +422 -0
  187. package/tools/tool_loader.py +710 -0
  188. package/tools/translator_tools.py +523 -0
  189. package/tools/url_link_extractor.py +262 -0
  190. package/tools/url_parser.py +205 -0
  191. package/tools/whitespace_tools.py +356 -0
  192. package/tools/word_frequency_counter.py +147 -0
@@ -0,0 +1,1751 @@
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
+ """
1751
+ return FindReplaceWidget(parent, settings_manager, logger)