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.
- package/LICENSE +21 -0
- package/README.md +680 -0
- package/bin/pomera-ai-commander.js +62 -0
- package/core/__init__.py +66 -0
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/app_context.cpython-313.pyc +0 -0
- package/core/__pycache__/async_text_processor.cpython-313.pyc +0 -0
- package/core/__pycache__/backup_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/backup_recovery_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/content_hash_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/context_menu.cpython-313.pyc +0 -0
- package/core/__pycache__/data_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/database_connection_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_curl_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_promera_ai_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_schema.cpython-313.pyc +0 -0
- package/core/__pycache__/database_schema_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_settings_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/database_settings_manager_interface.cpython-313.pyc +0 -0
- package/core/__pycache__/dialog_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/efficient_line_numbers.cpython-313.pyc +0 -0
- package/core/__pycache__/error_handler.cpython-313.pyc +0 -0
- package/core/__pycache__/error_service.cpython-313.pyc +0 -0
- package/core/__pycache__/event_consolidator.cpython-313.pyc +0 -0
- package/core/__pycache__/memory_efficient_text_widget.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_test_suite.cpython-313.pyc +0 -0
- package/core/__pycache__/migration_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_find_replace.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_pattern_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/optimized_search_highlighter.cpython-313.pyc +0 -0
- package/core/__pycache__/performance_monitor.cpython-313.pyc +0 -0
- package/core/__pycache__/persistence_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/progressive_stats_calculator.cpython-313.pyc +0 -0
- package/core/__pycache__/regex_pattern_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/regex_pattern_library.cpython-313.pyc +0 -0
- package/core/__pycache__/search_operation_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_defaults_registry.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_integrity_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_serializer.cpython-313.pyc +0 -0
- package/core/__pycache__/settings_validator.cpython-313.pyc +0 -0
- package/core/__pycache__/smart_stats_calculator.cpython-313.pyc +0 -0
- package/core/__pycache__/statistics_update_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/stats_config_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/streaming_text_handler.cpython-313.pyc +0 -0
- package/core/__pycache__/task_scheduler.cpython-313.pyc +0 -0
- package/core/__pycache__/visibility_monitor.cpython-313.pyc +0 -0
- package/core/__pycache__/widget_cache.cpython-313.pyc +0 -0
- package/core/app_context.py +482 -0
- package/core/async_text_processor.py +422 -0
- package/core/backup_manager.py +656 -0
- package/core/backup_recovery_manager.py +1034 -0
- package/core/content_hash_cache.py +509 -0
- package/core/context_menu.py +313 -0
- package/core/data_validator.py +1067 -0
- package/core/database_connection_manager.py +745 -0
- package/core/database_curl_settings_manager.py +609 -0
- package/core/database_promera_ai_settings_manager.py +447 -0
- package/core/database_schema.py +412 -0
- package/core/database_schema_manager.py +396 -0
- package/core/database_settings_manager.py +1508 -0
- package/core/database_settings_manager_interface.py +457 -0
- package/core/dialog_manager.py +735 -0
- package/core/efficient_line_numbers.py +511 -0
- package/core/error_handler.py +747 -0
- package/core/error_service.py +431 -0
- package/core/event_consolidator.py +512 -0
- package/core/mcp/__init__.py +43 -0
- package/core/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/protocol.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/server_stdio.cpython-313.pyc +0 -0
- package/core/mcp/__pycache__/tool_registry.cpython-313.pyc +0 -0
- package/core/mcp/protocol.py +288 -0
- package/core/mcp/schema.py +251 -0
- package/core/mcp/server_stdio.py +299 -0
- package/core/mcp/tool_registry.py +2345 -0
- package/core/memory_efficient_text_widget.py +712 -0
- package/core/migration_manager.py +915 -0
- package/core/migration_test_suite.py +1086 -0
- package/core/migration_validator.py +1144 -0
- package/core/optimized_find_replace.py +715 -0
- package/core/optimized_pattern_engine.py +424 -0
- package/core/optimized_search_highlighter.py +553 -0
- package/core/performance_monitor.py +675 -0
- package/core/persistence_manager.py +713 -0
- package/core/progressive_stats_calculator.py +632 -0
- package/core/regex_pattern_cache.py +530 -0
- package/core/regex_pattern_library.py +351 -0
- package/core/search_operation_manager.py +435 -0
- package/core/settings_defaults_registry.py +1087 -0
- package/core/settings_integrity_validator.py +1112 -0
- package/core/settings_serializer.py +558 -0
- package/core/settings_validator.py +1824 -0
- package/core/smart_stats_calculator.py +710 -0
- package/core/statistics_update_manager.py +619 -0
- package/core/stats_config_manager.py +858 -0
- package/core/streaming_text_handler.py +723 -0
- package/core/task_scheduler.py +596 -0
- package/core/update_pattern_library.py +169 -0
- package/core/visibility_monitor.py +596 -0
- package/core/widget_cache.py +498 -0
- package/mcp.json +61 -0
- package/package.json +57 -0
- package/pomera.py +7483 -0
- package/pomera_mcp_server.py +144 -0
- package/tools/__init__.py +5 -0
- package/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/tools/__pycache__/ai_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/ascii_art_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/base64_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/base_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/case_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/column_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/cron_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_history.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_processor.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_settings.cpython-313.pyc +0 -0
- package/tools/__pycache__/curl_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/diff_viewer.cpython-313.pyc +0 -0
- package/tools/__pycache__/email_extraction_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/email_header_analyzer.cpython-313.pyc +0 -0
- package/tools/__pycache__/extraction_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/find_replace.cpython-313.pyc +0 -0
- package/tools/__pycache__/folder_file_reporter.cpython-313.pyc +0 -0
- package/tools/__pycache__/folder_file_reporter_adapter.cpython-313.pyc +0 -0
- package/tools/__pycache__/generator_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/hash_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/html_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/huggingface_helper.cpython-313.pyc +0 -0
- package/tools/__pycache__/jsonxml_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/line_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/list_comparator.cpython-313.pyc +0 -0
- package/tools/__pycache__/markdown_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_widget.cpython-313.pyc +0 -0
- package/tools/__pycache__/notes_widget.cpython-313.pyc +0 -0
- package/tools/__pycache__/number_base_converter.cpython-313.pyc +0 -0
- package/tools/__pycache__/regex_extractor.cpython-313.pyc +0 -0
- package/tools/__pycache__/slug_generator.cpython-313.pyc +0 -0
- package/tools/__pycache__/sorter_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/string_escape_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/text_statistics_tool.cpython-313.pyc +0 -0
- package/tools/__pycache__/text_wrapper.cpython-313.pyc +0 -0
- package/tools/__pycache__/timestamp_converter.cpython-313.pyc +0 -0
- package/tools/__pycache__/tool_loader.cpython-313.pyc +0 -0
- package/tools/__pycache__/translator_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/url_link_extractor.cpython-313.pyc +0 -0
- package/tools/__pycache__/url_parser.cpython-313.pyc +0 -0
- package/tools/__pycache__/whitespace_tools.cpython-313.pyc +0 -0
- package/tools/__pycache__/word_frequency_counter.cpython-313.pyc +0 -0
- package/tools/ai_tools.py +2892 -0
- package/tools/ascii_art_generator.py +353 -0
- package/tools/base64_tools.py +184 -0
- package/tools/base_tool.py +511 -0
- package/tools/case_tool.py +309 -0
- package/tools/column_tools.py +396 -0
- package/tools/cron_tool.py +885 -0
- package/tools/curl_history.py +601 -0
- package/tools/curl_processor.py +1208 -0
- package/tools/curl_settings.py +503 -0
- package/tools/curl_tool.py +5467 -0
- package/tools/diff_viewer.py +1072 -0
- package/tools/email_extraction_tool.py +249 -0
- package/tools/email_header_analyzer.py +426 -0
- package/tools/extraction_tools.py +250 -0
- package/tools/find_replace.py +1751 -0
- package/tools/folder_file_reporter.py +1463 -0
- package/tools/folder_file_reporter_adapter.py +480 -0
- package/tools/generator_tools.py +1217 -0
- package/tools/hash_generator.py +256 -0
- package/tools/html_tool.py +657 -0
- package/tools/huggingface_helper.py +449 -0
- package/tools/jsonxml_tool.py +730 -0
- package/tools/line_tools.py +419 -0
- package/tools/list_comparator.py +720 -0
- package/tools/markdown_tools.py +562 -0
- package/tools/mcp_widget.py +1417 -0
- package/tools/notes_widget.py +973 -0
- package/tools/number_base_converter.py +373 -0
- package/tools/regex_extractor.py +572 -0
- package/tools/slug_generator.py +311 -0
- package/tools/sorter_tools.py +459 -0
- package/tools/string_escape_tool.py +393 -0
- package/tools/text_statistics_tool.py +366 -0
- package/tools/text_wrapper.py +431 -0
- package/tools/timestamp_converter.py +422 -0
- package/tools/tool_loader.py +710 -0
- package/tools/translator_tools.py +523 -0
- package/tools/url_link_extractor.py +262 -0
- package/tools/url_parser.py +205 -0
- package/tools/whitespace_tools.py +356 -0
- 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)
|