pomera-ai-commander 1.1.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -680
  3. package/bin/pomera-ai-commander.js +62 -62
  4. package/core/__init__.py +65 -65
  5. package/core/app_context.py +482 -482
  6. package/core/async_text_processor.py +421 -421
  7. package/core/backup_manager.py +655 -655
  8. package/core/backup_recovery_manager.py +1199 -1033
  9. package/core/content_hash_cache.py +508 -508
  10. package/core/context_menu.py +313 -313
  11. package/core/data_directory.py +549 -0
  12. package/core/data_validator.py +1066 -1066
  13. package/core/database_connection_manager.py +744 -744
  14. package/core/database_curl_settings_manager.py +608 -608
  15. package/core/database_promera_ai_settings_manager.py +446 -446
  16. package/core/database_schema.py +411 -411
  17. package/core/database_schema_manager.py +395 -395
  18. package/core/database_settings_manager.py +1507 -1507
  19. package/core/database_settings_manager_interface.py +456 -456
  20. package/core/dialog_manager.py +734 -734
  21. package/core/diff_utils.py +239 -0
  22. package/core/efficient_line_numbers.py +540 -510
  23. package/core/error_handler.py +746 -746
  24. package/core/error_service.py +431 -431
  25. package/core/event_consolidator.py +511 -511
  26. package/core/mcp/__init__.py +43 -43
  27. package/core/mcp/find_replace_diff.py +334 -0
  28. package/core/mcp/protocol.py +288 -288
  29. package/core/mcp/schema.py +251 -251
  30. package/core/mcp/server_stdio.py +299 -299
  31. package/core/mcp/tool_registry.py +2699 -2345
  32. package/core/memento.py +275 -0
  33. package/core/memory_efficient_text_widget.py +711 -711
  34. package/core/migration_manager.py +914 -914
  35. package/core/migration_test_suite.py +1085 -1085
  36. package/core/migration_validator.py +1143 -1143
  37. package/core/optimized_find_replace.py +714 -714
  38. package/core/optimized_pattern_engine.py +424 -424
  39. package/core/optimized_search_highlighter.py +552 -552
  40. package/core/performance_monitor.py +674 -674
  41. package/core/persistence_manager.py +712 -712
  42. package/core/progressive_stats_calculator.py +632 -632
  43. package/core/regex_pattern_cache.py +529 -529
  44. package/core/regex_pattern_library.py +350 -350
  45. package/core/search_operation_manager.py +434 -434
  46. package/core/settings_defaults_registry.py +1087 -1087
  47. package/core/settings_integrity_validator.py +1111 -1111
  48. package/core/settings_serializer.py +557 -557
  49. package/core/settings_validator.py +1823 -1823
  50. package/core/smart_stats_calculator.py +709 -709
  51. package/core/statistics_update_manager.py +619 -619
  52. package/core/stats_config_manager.py +858 -858
  53. package/core/streaming_text_handler.py +723 -723
  54. package/core/task_scheduler.py +596 -596
  55. package/core/update_pattern_library.py +168 -168
  56. package/core/visibility_monitor.py +596 -596
  57. package/core/widget_cache.py +498 -498
  58. package/mcp.json +51 -61
  59. package/migrate_data.py +127 -0
  60. package/package.json +64 -57
  61. package/pomera.py +7883 -7482
  62. package/pomera_mcp_server.py +183 -144
  63. package/requirements.txt +33 -0
  64. package/scripts/Dockerfile.alpine +43 -0
  65. package/scripts/Dockerfile.gui-test +54 -0
  66. package/scripts/Dockerfile.linux +43 -0
  67. package/scripts/Dockerfile.test-linux +80 -0
  68. package/scripts/Dockerfile.ubuntu +39 -0
  69. package/scripts/README.md +53 -0
  70. package/scripts/build-all.bat +113 -0
  71. package/scripts/build-docker.bat +53 -0
  72. package/scripts/build-docker.sh +55 -0
  73. package/scripts/build-optimized.bat +101 -0
  74. package/scripts/build.sh +78 -0
  75. package/scripts/docker-compose.test.yml +27 -0
  76. package/scripts/docker-compose.yml +32 -0
  77. package/scripts/postinstall.js +62 -0
  78. package/scripts/requirements-minimal.txt +33 -0
  79. package/scripts/test-linux-simple.bat +28 -0
  80. package/scripts/validate-release-workflow.py +450 -0
  81. package/tools/__init__.py +4 -4
  82. package/tools/ai_tools.py +2891 -2891
  83. package/tools/ascii_art_generator.py +352 -352
  84. package/tools/base64_tools.py +183 -183
  85. package/tools/base_tool.py +511 -511
  86. package/tools/case_tool.py +308 -308
  87. package/tools/column_tools.py +395 -395
  88. package/tools/cron_tool.py +884 -884
  89. package/tools/curl_history.py +600 -600
  90. package/tools/curl_processor.py +1207 -1207
  91. package/tools/curl_settings.py +502 -502
  92. package/tools/curl_tool.py +5467 -5467
  93. package/tools/diff_viewer.py +1817 -1072
  94. package/tools/email_extraction_tool.py +248 -248
  95. package/tools/email_header_analyzer.py +425 -425
  96. package/tools/extraction_tools.py +250 -250
  97. package/tools/find_replace.py +2289 -1750
  98. package/tools/folder_file_reporter.py +1463 -1463
  99. package/tools/folder_file_reporter_adapter.py +480 -480
  100. package/tools/generator_tools.py +1216 -1216
  101. package/tools/hash_generator.py +255 -255
  102. package/tools/html_tool.py +656 -656
  103. package/tools/jsonxml_tool.py +729 -729
  104. package/tools/line_tools.py +419 -419
  105. package/tools/markdown_tools.py +561 -561
  106. package/tools/mcp_widget.py +1417 -1417
  107. package/tools/notes_widget.py +978 -973
  108. package/tools/number_base_converter.py +372 -372
  109. package/tools/regex_extractor.py +571 -571
  110. package/tools/slug_generator.py +310 -310
  111. package/tools/sorter_tools.py +458 -458
  112. package/tools/string_escape_tool.py +392 -392
  113. package/tools/text_statistics_tool.py +365 -365
  114. package/tools/text_wrapper.py +430 -430
  115. package/tools/timestamp_converter.py +421 -421
  116. package/tools/tool_loader.py +710 -710
  117. package/tools/translator_tools.py +522 -522
  118. package/tools/url_link_extractor.py +261 -261
  119. package/tools/url_parser.py +204 -204
  120. package/tools/whitespace_tools.py +355 -355
  121. package/tools/word_frequency_counter.py +146 -146
  122. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  123. package/core/__pycache__/app_context.cpython-313.pyc +0 -0
  124. package/core/__pycache__/async_text_processor.cpython-313.pyc +0 -0
  125. package/core/__pycache__/backup_manager.cpython-313.pyc +0 -0
  126. package/core/__pycache__/backup_recovery_manager.cpython-313.pyc +0 -0
  127. package/core/__pycache__/content_hash_cache.cpython-313.pyc +0 -0
  128. package/core/__pycache__/context_menu.cpython-313.pyc +0 -0
  129. package/core/__pycache__/data_validator.cpython-313.pyc +0 -0
  130. package/core/__pycache__/database_connection_manager.cpython-313.pyc +0 -0
  131. package/core/__pycache__/database_curl_settings_manager.cpython-313.pyc +0 -0
  132. package/core/__pycache__/database_promera_ai_settings_manager.cpython-313.pyc +0 -0
  133. package/core/__pycache__/database_schema.cpython-313.pyc +0 -0
  134. package/core/__pycache__/database_schema_manager.cpython-313.pyc +0 -0
  135. package/core/__pycache__/database_settings_manager.cpython-313.pyc +0 -0
  136. package/core/__pycache__/database_settings_manager_interface.cpython-313.pyc +0 -0
  137. package/core/__pycache__/dialog_manager.cpython-313.pyc +0 -0
  138. package/core/__pycache__/efficient_line_numbers.cpython-313.pyc +0 -0
  139. package/core/__pycache__/error_handler.cpython-313.pyc +0 -0
  140. package/core/__pycache__/error_service.cpython-313.pyc +0 -0
  141. package/core/__pycache__/event_consolidator.cpython-313.pyc +0 -0
  142. package/core/__pycache__/memory_efficient_text_widget.cpython-313.pyc +0 -0
  143. package/core/__pycache__/migration_manager.cpython-313.pyc +0 -0
  144. package/core/__pycache__/migration_test_suite.cpython-313.pyc +0 -0
  145. package/core/__pycache__/migration_validator.cpython-313.pyc +0 -0
  146. package/core/__pycache__/optimized_find_replace.cpython-313.pyc +0 -0
  147. package/core/__pycache__/optimized_pattern_engine.cpython-313.pyc +0 -0
  148. package/core/__pycache__/optimized_search_highlighter.cpython-313.pyc +0 -0
  149. package/core/__pycache__/performance_monitor.cpython-313.pyc +0 -0
  150. package/core/__pycache__/persistence_manager.cpython-313.pyc +0 -0
  151. package/core/__pycache__/progressive_stats_calculator.cpython-313.pyc +0 -0
  152. package/core/__pycache__/regex_pattern_cache.cpython-313.pyc +0 -0
  153. package/core/__pycache__/regex_pattern_library.cpython-313.pyc +0 -0
  154. package/core/__pycache__/search_operation_manager.cpython-313.pyc +0 -0
  155. package/core/__pycache__/settings_defaults_registry.cpython-313.pyc +0 -0
  156. package/core/__pycache__/settings_integrity_validator.cpython-313.pyc +0 -0
  157. package/core/__pycache__/settings_serializer.cpython-313.pyc +0 -0
  158. package/core/__pycache__/settings_validator.cpython-313.pyc +0 -0
  159. package/core/__pycache__/smart_stats_calculator.cpython-313.pyc +0 -0
  160. package/core/__pycache__/statistics_update_manager.cpython-313.pyc +0 -0
  161. package/core/__pycache__/stats_config_manager.cpython-313.pyc +0 -0
  162. package/core/__pycache__/streaming_text_handler.cpython-313.pyc +0 -0
  163. package/core/__pycache__/task_scheduler.cpython-313.pyc +0 -0
  164. package/core/__pycache__/visibility_monitor.cpython-313.pyc +0 -0
  165. package/core/__pycache__/widget_cache.cpython-313.pyc +0 -0
  166. package/core/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
  167. package/core/mcp/__pycache__/protocol.cpython-313.pyc +0 -0
  168. package/core/mcp/__pycache__/schema.cpython-313.pyc +0 -0
  169. package/core/mcp/__pycache__/server_stdio.cpython-313.pyc +0 -0
  170. package/core/mcp/__pycache__/tool_registry.cpython-313.pyc +0 -0
  171. package/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  172. package/tools/__pycache__/ai_tools.cpython-313.pyc +0 -0
  173. package/tools/__pycache__/ascii_art_generator.cpython-313.pyc +0 -0
  174. package/tools/__pycache__/base64_tools.cpython-313.pyc +0 -0
  175. package/tools/__pycache__/base_tool.cpython-313.pyc +0 -0
  176. package/tools/__pycache__/case_tool.cpython-313.pyc +0 -0
  177. package/tools/__pycache__/column_tools.cpython-313.pyc +0 -0
  178. package/tools/__pycache__/cron_tool.cpython-313.pyc +0 -0
  179. package/tools/__pycache__/curl_history.cpython-313.pyc +0 -0
  180. package/tools/__pycache__/curl_processor.cpython-313.pyc +0 -0
  181. package/tools/__pycache__/curl_settings.cpython-313.pyc +0 -0
  182. package/tools/__pycache__/curl_tool.cpython-313.pyc +0 -0
  183. package/tools/__pycache__/diff_viewer.cpython-313.pyc +0 -0
  184. package/tools/__pycache__/email_extraction_tool.cpython-313.pyc +0 -0
  185. package/tools/__pycache__/email_header_analyzer.cpython-313.pyc +0 -0
  186. package/tools/__pycache__/extraction_tools.cpython-313.pyc +0 -0
  187. package/tools/__pycache__/find_replace.cpython-313.pyc +0 -0
  188. package/tools/__pycache__/folder_file_reporter.cpython-313.pyc +0 -0
  189. package/tools/__pycache__/folder_file_reporter_adapter.cpython-313.pyc +0 -0
  190. package/tools/__pycache__/generator_tools.cpython-313.pyc +0 -0
  191. package/tools/__pycache__/hash_generator.cpython-313.pyc +0 -0
  192. package/tools/__pycache__/html_tool.cpython-313.pyc +0 -0
  193. package/tools/__pycache__/huggingface_helper.cpython-313.pyc +0 -0
  194. package/tools/__pycache__/jsonxml_tool.cpython-313.pyc +0 -0
  195. package/tools/__pycache__/line_tools.cpython-313.pyc +0 -0
  196. package/tools/__pycache__/list_comparator.cpython-313.pyc +0 -0
  197. package/tools/__pycache__/markdown_tools.cpython-313.pyc +0 -0
  198. package/tools/__pycache__/mcp_widget.cpython-313.pyc +0 -0
  199. package/tools/__pycache__/notes_widget.cpython-313.pyc +0 -0
  200. package/tools/__pycache__/number_base_converter.cpython-313.pyc +0 -0
  201. package/tools/__pycache__/regex_extractor.cpython-313.pyc +0 -0
  202. package/tools/__pycache__/slug_generator.cpython-313.pyc +0 -0
  203. package/tools/__pycache__/sorter_tools.cpython-313.pyc +0 -0
  204. package/tools/__pycache__/string_escape_tool.cpython-313.pyc +0 -0
  205. package/tools/__pycache__/text_statistics_tool.cpython-313.pyc +0 -0
  206. package/tools/__pycache__/text_wrapper.cpython-313.pyc +0 -0
  207. package/tools/__pycache__/timestamp_converter.cpython-313.pyc +0 -0
  208. package/tools/__pycache__/tool_loader.cpython-313.pyc +0 -0
  209. package/tools/__pycache__/translator_tools.cpython-313.pyc +0 -0
  210. package/tools/__pycache__/url_link_extractor.cpython-313.pyc +0 -0
  211. package/tools/__pycache__/url_parser.cpython-313.pyc +0 -0
  212. package/tools/__pycache__/whitespace_tools.cpython-313.pyc +0 -0
  213. package/tools/__pycache__/word_frequency_counter.cpython-313.pyc +0 -0
@@ -1,1072 +1,1817 @@
1
- """
2
- Diff Viewer Tool Module
3
-
4
- This module provides a comprehensive text comparison tool with multiple diff algorithms
5
- and preprocessing options. It supports side-by-side comparison with synchronized scrolling
6
- and word-level highlighting of differences.
7
-
8
- Features:
9
- - Multiple comparison modes (ignore case, match case, ignore whitespace)
10
- - Side-by-side text comparison with synchronized scrolling
11
- - Word-level difference highlighting
12
- - Tab-based interface for multiple comparisons
13
- - Integration with optimized text widgets when available
14
-
15
- Author: Promera AI Commander
16
- """
17
-
18
- import tkinter as tk
19
- from tkinter import ttk
20
- import re
21
- import platform
22
- import logging
23
- import subprocess
24
- import os
25
- import sys
26
- from typing import Dict, Any, List, Optional
27
-
28
- # Import optimized components when available
29
- try:
30
- from core.efficient_line_numbers import OptimizedTextWithLineNumbers
31
- EFFICIENT_LINE_NUMBERS_AVAILABLE = True
32
- except ImportError:
33
- EFFICIENT_LINE_NUMBERS_AVAILABLE = False
34
-
35
- try:
36
- from core.memory_efficient_text_widget import MemoryEfficientTextWidget
37
- MEMORY_EFFICIENT_TEXT_AVAILABLE = True
38
- except ImportError:
39
- MEMORY_EFFICIENT_TEXT_AVAILABLE = False
40
-
41
-
42
- class TextWithLineNumbers(tk.Frame):
43
- """Fallback implementation of TextWithLineNumbers when optimized components are not available."""
44
-
45
- def __init__(self, *args, **kwargs):
46
- super().__init__(*args, **kwargs)
47
- self.text = tk.Text(self, wrap=tk.WORD, height=15, width=50, undo=True)
48
- self.linenumbers = tk.Canvas(self, width=40, bg='#f0f0f0', highlightthickness=0)
49
-
50
- self.linenumbers.pack(side=tk.LEFT, fill=tk.Y)
51
- self.text.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
52
-
53
- # Basic event bindings
54
- self.text.bind("<<Modified>>", self._on_text_modified)
55
- self.text.bind("<Configure>", self._on_text_modified)
56
- self._on_text_modified()
57
-
58
- def _on_text_modified(self, event=None):
59
- """Update line numbers when text is modified."""
60
- self.linenumbers.delete("all")
61
- line_info_cache = []
62
- i = self.text.index("@0,0")
63
- while True:
64
- dline = self.text.dlineinfo(i)
65
- if dline is None:
66
- break
67
- line_info_cache.append((i, dline[1]))
68
- i = self.text.index("%s+1line" % i)
69
-
70
- for i, y in line_info_cache:
71
- linenum = str(i).split(".")[0]
72
- self.linenumbers.create_text(20, y, anchor="n", text=linenum, fill="gray")
73
-
74
- if event and hasattr(event.widget, 'edit_modified') and event.widget.edit_modified():
75
- event.widget.edit_modified(False)
76
-
77
-
78
- class DiffViewerWidget:
79
- """
80
- A comprehensive diff viewer widget that provides side-by-side text comparison
81
- with multiple comparison algorithms and preprocessing options.
82
- """
83
-
84
- def __init__(self, parent, tab_count=7, logger=None, parent_callback=None, dialog_manager=None):
85
- """
86
- Initialize the diff viewer widget.
87
-
88
- Args:
89
- parent: Parent tkinter widget
90
- tab_count: Number of tabs to create (default: 7)
91
- logger: Logger instance for debugging
92
- parent_callback: Callback function to notify parent of changes
93
- dialog_manager: DialogManager instance for consistent dialog handling
94
- """
95
- self.parent = parent
96
- self.tab_count = tab_count
97
- self.logger = logger or logging.getLogger(__name__)
98
- self.parent_callback = parent_callback
99
- self.dialog_manager = dialog_manager
100
-
101
- # Settings for diff comparison
102
- self.settings = {
103
- "option": "ignore_case" # Default comparison mode
104
- }
105
-
106
- # Create the main frame
107
- self.diff_frame = ttk.Frame(parent, padding="10")
108
- self.diff_frame.grid_columnconfigure(0, weight=1)
109
- self.diff_frame.grid_columnconfigure(1, weight=1)
110
- self.diff_frame.grid_rowconfigure(1, weight=1)
111
-
112
- # Statistics bars
113
- self.input_stats_bar = None
114
- self.output_stats_bar = None
115
-
116
- # Initialize UI components
117
- self._create_ui()
118
- self._setup_event_bindings()
119
-
120
- def _show_error(self, title, message):
121
- """Show error dialog using DialogManager if available, otherwise use messagebox."""
122
- if self.dialog_manager:
123
- return self.dialog_manager.show_error(title, message, parent=self.parent)
124
- else:
125
- try:
126
- from tkinter import messagebox
127
- messagebox.showerror(title, message, parent=self.parent)
128
- return True
129
- except:
130
- return False
131
-
132
- def _show_warning(self, title, message, category="warning"):
133
- """Show warning dialog using DialogManager if available, otherwise use messagebox."""
134
- if self.dialog_manager:
135
- return self.dialog_manager.show_warning(title, message, category, parent=self.parent)
136
- else:
137
- try:
138
- import tkinter.messagebox as messagebox
139
- messagebox.showwarning(title, message, parent=self.parent)
140
- return True
141
- except:
142
- return False
143
-
144
- def _create_ui(self):
145
- """Create the user interface components."""
146
- self._create_title_rows()
147
- self._create_notebooks()
148
- self._create_tabs()
149
- self._create_statistics_bars()
150
- self._configure_text_tags()
151
-
152
- def _create_title_rows(self):
153
- """Create the title rows with buttons and controls."""
154
- # Input title row
155
- input_title_row = ttk.Frame(self.diff_frame)
156
- input_title_row.grid(row=0, column=0, sticky="ew", padx=(0, 5))
157
-
158
- # Input label and buttons
159
- input_controls = ttk.Frame(input_title_row)
160
- input_controls.pack(side=tk.TOP, fill=tk.X)
161
- ttk.Label(input_controls, text="Input", font=("Helvetica", 12, "bold")).pack(side=tk.LEFT)
162
-
163
- # Load from file button
164
- load_file_btn = ttk.Button(input_controls, text="📁", command=self.load_file_to_input, width=3)
165
- load_file_btn.pack(side=tk.LEFT, padx=(10, 0))
166
-
167
- # Erase button
168
- ttk.Button(input_controls, text="⌫", command=self.clear_all_input_tabs, width=3).pack(side=tk.LEFT, padx=(5, 0))
169
-
170
- # Input line filter
171
- input_filter_frame = ttk.Frame(input_title_row)
172
- input_filter_frame.pack(side=tk.TOP, fill=tk.X, pady=(5, 0))
173
- ttk.Label(input_filter_frame, text="Filter:").pack(side=tk.LEFT)
174
- self.input_filter_var = tk.StringVar()
175
- self.input_filter_entry = ttk.Entry(input_filter_frame, textvariable=self.input_filter_var, width=25)
176
- self.input_filter_entry.pack(side=tk.LEFT, padx=(5, 5), fill=tk.X, expand=True)
177
- self.input_filter_var.trace_add("write", self._on_input_filter_changed)
178
- ttk.Button(input_filter_frame, text="✕", command=self._clear_input_filter, width=3).pack(side=tk.LEFT)
179
-
180
- # Output title row
181
- output_title_row = ttk.Frame(self.diff_frame)
182
- output_title_row.grid(row=0, column=1, sticky="ew", padx=(5, 0))
183
-
184
- # Output label and buttons
185
- output_controls = ttk.Frame(output_title_row)
186
- output_controls.pack(side=tk.TOP, fill=tk.X)
187
- ttk.Label(output_controls, text="Output", font=("Helvetica", 12, "bold")).pack(side=tk.LEFT)
188
-
189
- # Send to Input dropdown
190
- self.send_to_input_var = tk.StringVar(value="Send to Input")
191
- send_to_input_menu = ttk.Menubutton(output_controls, textvariable=self.send_to_input_var, direction="below")
192
- send_to_input_menu.pack(side=tk.LEFT, padx=(10, 6))
193
-
194
- # Create the dropdown menu
195
- dropdown_menu = tk.Menu(send_to_input_menu, tearoff=0)
196
- send_to_input_menu.config(menu=dropdown_menu)
197
- for i in range(self.tab_count):
198
- dropdown_menu.add_command(label=f"Tab {i+1}", command=lambda tab=i: self.copy_to_specific_input_tab(tab))
199
-
200
- # Copy to clipboard button
201
- ttk.Button(output_controls, text="", command=self.copy_to_clipboard, width=3).pack(side=tk.LEFT, padx=(0, 6))
202
-
203
- # Erase button
204
- ttk.Button(output_controls, text="⌫", command=self.clear_all_output_tabs, width=3).pack(side=tk.LEFT)
205
-
206
- # Output line filter
207
- output_filter_frame = ttk.Frame(output_title_row)
208
- output_filter_frame.pack(side=tk.TOP, fill=tk.X, pady=(5, 0))
209
- ttk.Label(output_filter_frame, text="Filter:").pack(side=tk.LEFT)
210
- self.output_filter_var = tk.StringVar()
211
- self.output_filter_entry = ttk.Entry(output_filter_frame, textvariable=self.output_filter_var, width=25)
212
- self.output_filter_entry.pack(side=tk.LEFT, padx=(5, 5), fill=tk.X, expand=True)
213
- self.output_filter_var.trace_add("write", self._on_output_filter_changed)
214
- ttk.Button(output_filter_frame, text="✕", command=self._clear_output_filter, width=3).pack(side=tk.LEFT)
215
-
216
- # Store original content for filtering
217
- self.input_original_content = {}
218
- self.output_original_content = {}
219
-
220
- def _create_notebooks(self):
221
- """Create the notebook widgets for input and output tabs."""
222
- self.input_notebook = ttk.Notebook(self.diff_frame)
223
- self.input_notebook.grid(row=1, column=0, sticky="nsew", padx=(0, 5))
224
-
225
- self.output_notebook = ttk.Notebook(self.diff_frame)
226
- self.output_notebook.grid(row=1, column=1, sticky="nsew", padx=(5, 0))
227
-
228
- def _create_statistics_bars(self):
229
- """Create statistics bars below the text areas."""
230
- # Input statistics bar
231
- self.input_stats_bar = ttk.Label(
232
- self.diff_frame,
233
- text="Bytes: 0 | Word: 0 | Sentence: 0 | Line: 0 | Tokens: 0",
234
- relief=tk.SUNKEN,
235
- anchor=tk.W,
236
- padding=(5, 2)
237
- )
238
- self.input_stats_bar.grid(row=2, column=0, sticky="ew", padx=(0, 5), pady=(5, 0))
239
-
240
- # Output statistics bar
241
- self.output_stats_bar = ttk.Label(
242
- self.diff_frame,
243
- text="Bytes: 0 | Word: 0 | Sentence: 0 | Line: 0 | Tokens: 0",
244
- relief=tk.SUNKEN,
245
- anchor=tk.W,
246
- padding=(5, 2)
247
- )
248
- self.output_stats_bar.grid(row=2, column=1, sticky="ew", padx=(5, 0), pady=(5, 0))
249
-
250
- def _create_tabs(self):
251
- """Create the text tabs for input and output."""
252
- self.input_tabs = []
253
- self.output_tabs = []
254
-
255
- for i in range(self.tab_count):
256
- # Create input tab
257
- if EFFICIENT_LINE_NUMBERS_AVAILABLE:
258
- input_tab = OptimizedTextWithLineNumbers(self.input_notebook)
259
- elif MEMORY_EFFICIENT_TEXT_AVAILABLE:
260
- input_tab = MemoryEfficientTextWidget(self.input_notebook)
261
- else:
262
- input_tab = TextWithLineNumbers(self.input_notebook)
263
-
264
- input_tab.text.bind("<<Modified>>", self._on_tab_content_changed)
265
- input_tab.text.bind("<KeyRelease>", self._on_tab_content_changed)
266
- input_tab.text.bind("<Button-1>", self._on_tab_content_changed)
267
- self.input_tabs.append(input_tab)
268
- self.input_notebook.add(input_tab, text=f"{i+1}:")
269
-
270
- # Create output tab
271
- if EFFICIENT_LINE_NUMBERS_AVAILABLE:
272
- output_tab = OptimizedTextWithLineNumbers(self.output_notebook)
273
- elif MEMORY_EFFICIENT_TEXT_AVAILABLE:
274
- output_tab = MemoryEfficientTextWidget(self.output_notebook)
275
- else:
276
- output_tab = TextWithLineNumbers(self.output_notebook)
277
-
278
- output_tab.text.bind("<<Modified>>", self._on_tab_content_changed)
279
- output_tab.text.bind("<KeyRelease>", self._on_tab_content_changed)
280
- output_tab.text.bind("<Button-1>", self._on_tab_content_changed)
281
- self.output_tabs.append(output_tab)
282
- self.output_notebook.add(output_tab, text=f"{i+1}:")
283
-
284
- def _configure_text_tags(self):
285
- """Configure text tags for highlighting differences."""
286
- for tab_list in [self.input_tabs, self.output_tabs]:
287
- for tab in tab_list:
288
- widget = tab.text
289
- widget.config(state="normal")
290
- widget.tag_configure("addition", background="#e6ffed")
291
- widget.tag_configure("deletion", background="#ffebe9")
292
- widget.tag_configure("modification", background="#e6f7ff")
293
- widget.tag_configure("inline_add", background="#a7f0ba")
294
- widget.tag_configure("inline_del", background="#ffc9c9")
295
-
296
- def _setup_event_bindings(self):
297
- """Set up event bindings for synchronized scrolling."""
298
- self.input_notebook.bind("<<NotebookTabChanged>>", self._on_tab_changed)
299
- self.output_notebook.bind("<<NotebookTabChanged>>", self._on_tab_changed)
300
- self._setup_sync()
301
-
302
- def _on_tab_changed(self, event=None):
303
- """Handle tab change events."""
304
- # Clear filters when switching tabs
305
- if hasattr(self, 'input_filter_var'):
306
- self.input_filter_var.set("")
307
- if hasattr(self, 'output_filter_var'):
308
- self.output_filter_var.set("")
309
-
310
- self._setup_sync(event)
311
- self.update_statistics()
312
-
313
- def _setup_sync(self, event=None):
314
- """Configure scroll and mousewheel syncing for the active tabs."""
315
- try:
316
- active_input_tab = self.input_tabs[self.input_notebook.index("current")]
317
- active_output_tab = self.output_tabs[self.output_notebook.index("current")]
318
- except (tk.TclError, IndexError):
319
- return
320
-
321
- # Configure scrollbar syncing
322
- if hasattr(active_input_tab.text, 'vbar'):
323
- active_input_tab.text.vbar.config(command=self._sync_scroll)
324
- if hasattr(active_output_tab.text, 'vbar'):
325
- active_output_tab.text.vbar.config(command=self._sync_scroll)
326
-
327
- # Configure mouse wheel syncing
328
- for tab in [active_input_tab, active_output_tab]:
329
- tab.text.bind("<MouseWheel>", self._on_mousewheel)
330
- tab.text.bind("<Button-4>", self._on_mousewheel)
331
- tab.text.bind("<Button-5>", self._on_mousewheel)
332
-
333
- def _sync_scroll(self, *args):
334
- """Sync both text widgets when one's scrollbar is used."""
335
- try:
336
- active_input_tab = self.input_tabs[self.input_notebook.index("current")]
337
- active_output_tab = self.output_tabs[self.output_notebook.index("current")]
338
-
339
- active_input_tab.text.yview(*args)
340
- active_output_tab.text.yview(*args)
341
-
342
- # Update line numbers if available
343
- if hasattr(active_input_tab, '_on_text_modified'):
344
- active_input_tab._on_text_modified()
345
- if hasattr(active_output_tab, '_on_text_modified'):
346
- active_output_tab._on_text_modified()
347
- except (tk.TclError, IndexError):
348
- pass
349
-
350
- def _on_mousewheel(self, event):
351
- """Handle mouse wheel scrolling over either text widget."""
352
- if platform.system() == "Windows":
353
- delta = int(-1*(event.delta/120))
354
- elif platform.system() == "Darwin":
355
- delta = int(-1 * event.delta)
356
- else:
357
- delta = -1 if event.num == 4 else 1
358
-
359
- try:
360
- active_input_tab = self.input_tabs[self.input_notebook.index("current")]
361
- active_output_tab = self.output_tabs[self.output_notebook.index("current")]
362
-
363
- active_input_tab.text.yview_scroll(delta, "units")
364
- active_output_tab.text.yview_scroll(delta, "units")
365
-
366
- # Update line numbers if available
367
- if hasattr(active_input_tab, '_on_text_modified'):
368
- active_input_tab._on_text_modified()
369
- if hasattr(active_output_tab, '_on_text_modified'):
370
- active_output_tab._on_text_modified()
371
- except (tk.TclError, IndexError):
372
- pass
373
-
374
- return "break"
375
-
376
- def _on_tab_content_changed(self, event=None):
377
- """Handle tab content changes."""
378
- # Update tab labels when content changes
379
- self.update_tab_labels()
380
-
381
- # Update statistics
382
- self.update_statistics()
383
-
384
- # This can be overridden by the parent application
385
- if hasattr(self, 'parent_callback') and self.parent_callback:
386
- self.parent_callback()
387
-
388
- def get_frame(self):
389
- """Return the main frame widget."""
390
- return self.diff_frame
391
-
392
- def show(self):
393
- """Show the diff viewer."""
394
- self.diff_frame.grid(row=0, column=0, sticky="nsew", pady=5)
395
-
396
- def hide(self):
397
- """Hide the diff viewer."""
398
- self.diff_frame.grid_remove()
399
-
400
- def load_content(self, input_tabs_content, output_tabs_content):
401
- """
402
- Load content into the diff viewer tabs.
403
-
404
- Args:
405
- input_tabs_content: List of strings for input tabs
406
- output_tabs_content: List of strings for output tabs
407
- """
408
- self.logger.info("Loading content into Diff Viewer.")
409
-
410
- for i in range(min(len(input_tabs_content), self.tab_count)):
411
- self.input_tabs[i].text.delete("1.0", tk.END)
412
- self.input_tabs[i].text.insert("1.0", input_tabs_content[i])
413
-
414
- for i in range(min(len(output_tabs_content), self.tab_count)):
415
- self.output_tabs[i].text.delete("1.0", tk.END)
416
- self.output_tabs[i].text.insert("1.0", output_tabs_content[i])
417
-
418
- # Update tab labels after loading content
419
- self.update_tab_labels()
420
-
421
- def sync_content_back(self):
422
- """
423
- Get content from diff viewer tabs.
424
-
425
- Returns:
426
- tuple: (input_contents, output_contents) as lists of strings
427
- """
428
- self.logger.info("Syncing Diff Viewer content back.")
429
-
430
- input_contents = []
431
- output_contents = []
432
-
433
- for i in range(self.tab_count):
434
- input_content = self.input_tabs[i].text.get("1.0", tk.END)
435
- # Remove trailing newline that tkinter adds
436
- if input_content.endswith('\n'):
437
- input_content = input_content[:-1]
438
- input_contents.append(input_content)
439
-
440
- output_content = self.output_tabs[i].text.get("1.0", tk.END)
441
- # Remove trailing newline that tkinter adds
442
- if output_content.endswith('\n'):
443
- output_content = output_content[:-1]
444
- output_contents.append(output_content)
445
-
446
- # Debug logging
447
- non_empty_inputs = sum(1 for content in input_contents if content.strip())
448
- non_empty_outputs = sum(1 for content in output_contents if content.strip())
449
- self.logger.info(f"Syncing back {non_empty_inputs} non-empty input tabs, {non_empty_outputs} non-empty output tabs")
450
-
451
- return input_contents, output_contents
452
-
453
- def _preprocess_for_diff(self, text, option):
454
- """
455
- Preprocess text into line dicts according to diff option.
456
-
457
- Args:
458
- text: Input text to preprocess
459
- option: Comparison option ('ignore_case', 'match_case', 'ignore_whitespace')
460
-
461
- Returns:
462
- List of dicts with 'raw' and 'cmp' keys
463
- """
464
- lines = text.splitlines()
465
- processed = []
466
- for line in lines:
467
- cmp_line = line
468
- if option == "ignore_case":
469
- cmp_line = cmp_line.lower()
470
- elif option == "ignore_whitespace":
471
- cmp_line = re.sub(r"\s+", " ", cmp_line).strip()
472
- processed.append({"raw": line, "cmp": cmp_line})
473
- return processed
474
-
475
- def run_comparison(self, option=None):
476
- """
477
- Compare the active tabs and display the diff.
478
-
479
- Args:
480
- option: Comparison option ('ignore_case', 'match_case', 'ignore_whitespace')
481
- If None, uses the current setting
482
- """
483
- self.logger.info("Running Diff Viewer comparison.")
484
-
485
- if option is not None:
486
- self.settings["option"] = option
487
-
488
- current_option = self.settings.get("option", "ignore_case")
489
-
490
- try:
491
- active_input_idx = self.input_notebook.index("current")
492
- active_output_idx = self.output_notebook.index("current")
493
-
494
- input_widget = self.input_tabs[active_input_idx].text
495
- output_widget = self.output_tabs[active_output_idx].text
496
-
497
- # Get text content (remove trailing newline that tkinter adds)
498
- input_text = input_widget.get("1.0", tk.END)
499
- if input_text.endswith('\n'):
500
- input_text = input_text[:-1]
501
-
502
- output_text = output_widget.get("1.0", tk.END)
503
- if output_text.endswith('\n'):
504
- output_text = output_text[:-1]
505
-
506
- except (tk.TclError, IndexError):
507
- self.logger.error("Could not get active tabs for comparison")
508
- return
509
-
510
- # Clear filters before comparison
511
- if hasattr(self, 'input_filter_var'):
512
- self.input_filter_var.set("")
513
- if hasattr(self, 'output_filter_var'):
514
- self.output_filter_var.set("")
515
-
516
- # Clear stored original content
517
- if active_input_idx in self.input_original_content:
518
- del self.input_original_content[active_input_idx]
519
- if active_output_idx in self.output_original_content:
520
- del self.output_original_content[active_output_idx]
521
-
522
- # Clear existing content
523
- input_widget.delete("1.0", tk.END)
524
- output_widget.delete("1.0", tk.END)
525
-
526
- # Handle empty texts
527
- if not input_text.strip() and not output_text.strip():
528
- return
529
- elif not input_text.strip():
530
- for line in output_text.splitlines():
531
- input_widget.insert(tk.END, '\n')
532
- output_widget.insert(tk.END, line + '\n', 'addition')
533
- return
534
- elif not output_text.strip():
535
- for line in input_text.splitlines():
536
- input_widget.insert(tk.END, line + '\n', 'deletion')
537
- output_widget.insert(tk.END, '\n')
538
- return
539
-
540
- # Preprocess texts for comparison
541
- left_lines = self._preprocess_for_diff(input_text, current_option)
542
- right_lines = self._preprocess_for_diff(output_text, current_option)
543
- left_cmp = [l["cmp"] for l in left_lines]
544
- right_cmp = [r["cmp"] for r in right_lines]
545
-
546
- try:
547
- import difflib
548
- matcher = difflib.SequenceMatcher(None, left_cmp, right_cmp, autojunk=False)
549
-
550
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
551
- if tag == 'equal':
552
- for i in range(i1, i2):
553
- input_widget.insert(tk.END, left_lines[i]["raw"] + '\n')
554
- output_widget.insert(tk.END, right_lines[j1 + (i - i1)]["raw"] + '\n')
555
-
556
- elif tag == 'delete':
557
- for i in range(i1, i2):
558
- input_widget.insert(tk.END, left_lines[i]["raw"] + '\n', 'deletion')
559
- output_widget.insert(tk.END, '\n')
560
-
561
- elif tag == 'insert':
562
- for j in range(j1, j2):
563
- input_widget.insert(tk.END, '\n')
564
- output_widget.insert(tk.END, right_lines[j]["raw"] + '\n', 'addition')
565
-
566
- elif tag == 'replace':
567
- input_block = [l["raw"] for l in left_lines[i1:i2]]
568
- output_block = [r["raw"] for r in right_lines[j1:j2]]
569
-
570
- # Pad blocks to same length
571
- while len(input_block) < len(output_block):
572
- input_block.append("")
573
- while len(output_block) < len(input_block):
574
- output_block.append("")
575
-
576
- for line1, line2 in zip(input_block, output_block):
577
- if line1 and line2:
578
- self._highlight_word_diffs(input_widget, [line1], output_widget, [line2])
579
- elif line1:
580
- input_widget.insert(tk.END, line1 + '\n', 'deletion')
581
- output_widget.insert(tk.END, '\n')
582
- elif line2:
583
- input_widget.insert(tk.END, '\n')
584
- output_widget.insert(tk.END, line2 + '\n', 'addition')
585
-
586
- except Exception as e:
587
- self.logger.error(f"Error in diff computation: {e}")
588
- input_widget.insert(tk.END, input_text)
589
- output_widget.insert(tk.END, output_text)
590
-
591
- # Reset scroll position
592
- input_widget.yview_moveto(0)
593
- output_widget.yview_moveto(0)
594
- self._setup_sync()
595
-
596
- # Update tab labels after comparison
597
- self.update_tab_labels()
598
-
599
- def _highlight_word_diffs(self, w1, lines1, w2, lines2):
600
- """
601
- Highlight word-level differences within a 'replace' block.
602
-
603
- Args:
604
- w1: First text widget
605
- lines1: Lines for first widget
606
- w2: Second text widget
607
- lines2: Lines for second widget
608
- """
609
- for line1, line2 in zip(lines1, lines2):
610
- w1.insert(tk.END, line1 + '\n', 'modification')
611
- w2.insert(tk.END, line2 + '\n', 'modification')
612
-
613
- line_start1 = w1.index(f"{w1.index(tk.INSERT)} -1 lines linestart")
614
- line_start2 = w2.index(f"{w2.index(tk.INSERT)} -1 lines linestart")
615
-
616
- words1 = re.split(r'(\s+)', line1)
617
- words2 = re.split(r'(\s+)', line2)
618
-
619
- try:
620
- import difflib
621
- matcher = difflib.SequenceMatcher(None, words1, words2)
622
-
623
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
624
- if tag == 'delete' or tag == 'replace':
625
- start_char1 = len("".join(words1[:i1]))
626
- end_char1 = len("".join(words1[:i2]))
627
- w1.tag_add('inline_del', f"{line_start1}+{start_char1}c", f"{line_start1}+{end_char1}c")
628
- if tag == 'insert' or tag == 'replace':
629
- start_char2 = len("".join(words2[:j1]))
630
- end_char2 = len("".join(words2[:j2]))
631
- w2.tag_add('inline_add', f"{line_start2}+{start_char2}c", f"{line_start2}+{end_char2}c")
632
- except Exception as e:
633
- self.logger.error(f"Error in word-level diff highlighting: {e}")
634
-
635
- def clear_all_input_tabs(self):
636
- """Clear all input tabs."""
637
- for tab in self.input_tabs:
638
- tab.text.delete("1.0", tk.END)
639
- # Update tab labels after clearing
640
- self.update_tab_labels()
641
-
642
- def clear_all_output_tabs(self):
643
- """Clear all output tabs."""
644
- for tab in self.output_tabs:
645
- tab.text.delete("1.0", tk.END)
646
- # Update tab labels after clearing
647
- self.update_tab_labels()
648
-
649
- def copy_to_clipboard(self):
650
- """Copy active output tab content to clipboard."""
651
- try:
652
- active_output_tab = self.output_tabs[self.output_notebook.index("current")]
653
- content = active_output_tab.text.get("1.0", tk.END)
654
- self.parent.clipboard_clear()
655
- self.parent.clipboard_append(content)
656
- except (tk.TclError, IndexError):
657
- pass
658
-
659
- def copy_to_specific_input_tab(self, tab_index):
660
- """
661
- Copy active output tab content to a specific input tab.
662
-
663
- Args:
664
- tab_index: Index of the target input tab
665
- """
666
- try:
667
- active_output_tab = self.output_tabs[self.output_notebook.index("current")]
668
- content = active_output_tab.text.get("1.0", tk.END)
669
-
670
- if 0 <= tab_index < len(self.input_tabs):
671
- self.input_tabs[tab_index].text.delete("1.0", tk.END)
672
- self.input_tabs[tab_index].text.insert("1.0", content)
673
- except (tk.TclError, IndexError):
674
- pass
675
-
676
- def load_file_to_input(self):
677
- """Load file content to the active input tab."""
678
- try:
679
- from tkinter import filedialog
680
- file_path = filedialog.askopenfilename(
681
- title="Select file to load",
682
- filetypes=[
683
- ("Text files", "*.txt"),
684
- ("All files", "*.*")
685
- ],
686
- parent=self.parent
687
- )
688
-
689
- if file_path:
690
- with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
691
- content = f.read()
692
-
693
- # Load into active input tab
694
- active_input_tab = self.input_tabs[self.input_notebook.index("current")]
695
- active_input_tab.text.delete("1.0", tk.END)
696
- active_input_tab.text.insert("1.0", content)
697
-
698
- self.logger.info(f"Loaded file: {file_path}")
699
- except Exception as e:
700
- self.logger.error(f"Error loading file: {e}")
701
- self._show_error("Error", f"Could not load file: {str(e)}")
702
-
703
- def update_tab_labels(self):
704
- """Update tab labels based on content."""
705
- try:
706
- # Update input tab labels
707
- for i, tab in enumerate(self.input_tabs):
708
- content = tab.text.get("1.0", tk.END).strip()
709
- if content:
710
- # Get first few words for the label
711
- words = content.split()[:3]
712
- label = " ".join(words)
713
- if len(label) > 20:
714
- label = label[:17] + "..."
715
- if len(content.split()) > 3:
716
- label += "..."
717
- self.input_notebook.tab(i, text=f"{i+1}: {label}")
718
- else:
719
- self.input_notebook.tab(i, text=f"{i+1}:")
720
-
721
- # Update output tab labels
722
- for i, tab in enumerate(self.output_tabs):
723
- content = tab.text.get("1.0", tk.END).strip()
724
- if content:
725
- # Get first few words for the label
726
- words = content.split()[:3]
727
- label = " ".join(words)
728
- if len(label) > 20:
729
- label = label[:17] + "..."
730
- if len(content.split()) > 3:
731
- label += "..."
732
- self.output_notebook.tab(i, text=f"{i+1}: {label}")
733
- else:
734
- self.output_notebook.tab(i, text=f"{i+1}:")
735
-
736
- except Exception as e:
737
- self.logger.error(f"Error updating tab labels: {e}")
738
-
739
- def _on_input_filter_changed(self, *args):
740
- """Handle input filter text changes."""
741
- self._apply_input_filter()
742
-
743
- def _on_output_filter_changed(self, *args):
744
- """Handle output filter text changes."""
745
- self._apply_output_filter()
746
-
747
- def _clear_input_filter(self):
748
- """Clear the input filter."""
749
- self.input_filter_var.set("")
750
-
751
- def _clear_output_filter(self):
752
- """Clear the output filter."""
753
- self.output_filter_var.set("")
754
-
755
- def _apply_input_filter(self):
756
- """Apply line filter to the active input tab."""
757
- try:
758
- active_idx = self.input_notebook.index("current")
759
- current_tab = self.input_tabs[active_idx]
760
- filter_text = self.input_filter_var.get().strip()
761
-
762
- # Store original content if not already stored
763
- if active_idx not in self.input_original_content:
764
- self.input_original_content[active_idx] = current_tab.text.get("1.0", tk.END)
765
-
766
- original_content = self.input_original_content[active_idx]
767
-
768
- if filter_text:
769
- # Apply filter
770
- lines = original_content.split('\n')
771
- filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
772
- filtered_content = '\n'.join(filtered_lines)
773
-
774
- current_tab.text.delete("1.0", tk.END)
775
- current_tab.text.insert("1.0", filtered_content)
776
- else:
777
- # Restore original content
778
- current_tab.text.delete("1.0", tk.END)
779
- current_tab.text.insert("1.0", original_content)
780
- # Clear stored content
781
- if active_idx in self.input_original_content:
782
- del self.input_original_content[active_idx]
783
-
784
- # Update statistics
785
- self.update_statistics()
786
-
787
- except Exception as e:
788
- self.logger.error(f"Error applying input filter: {e}")
789
-
790
- def _apply_output_filter(self):
791
- """Apply line filter to the active output tab."""
792
- try:
793
- active_idx = self.output_notebook.index("current")
794
- current_tab = self.output_tabs[active_idx]
795
- filter_text = self.output_filter_var.get().strip()
796
-
797
- # Store original content if not already stored
798
- if active_idx not in self.output_original_content:
799
- self.output_original_content[active_idx] = current_tab.text.get("1.0", tk.END)
800
-
801
- original_content = self.output_original_content[active_idx]
802
-
803
- if filter_text:
804
- # Apply filter
805
- lines = original_content.split('\n')
806
- filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
807
- filtered_content = '\n'.join(filtered_lines)
808
-
809
- current_tab.text.delete("1.0", tk.END)
810
- current_tab.text.insert("1.0", filtered_content)
811
- else:
812
- # Restore original content
813
- current_tab.text.delete("1.0", tk.END)
814
- current_tab.text.insert("1.0", original_content)
815
- # Clear stored content
816
- if active_idx in self.output_original_content:
817
- del self.output_original_content[active_idx]
818
-
819
- # Update statistics
820
- self.update_statistics()
821
-
822
- except Exception as e:
823
- self.logger.error(f"Error applying output filter: {e}")
824
-
825
- def get_settings(self):
826
- """Get current diff viewer settings."""
827
- return self.settings.copy()
828
-
829
- def update_settings(self, settings):
830
- """
831
- Update diff viewer settings.
832
-
833
- Args:
834
- settings: Dictionary of settings to update
835
- """
836
- self.settings.update(settings)
837
-
838
- def apply_font_to_widgets(self, font_tuple):
839
- """
840
- Apply font to all text widgets in the diff viewer.
841
-
842
- Args:
843
- font_tuple: Tuple of (font_family, font_size)
844
- """
845
- try:
846
- for tab in self.input_tabs:
847
- if hasattr(tab, 'text'):
848
- tab.text.configure(font=font_tuple)
849
-
850
- for tab in self.output_tabs:
851
- if hasattr(tab, 'text'):
852
- tab.text.configure(font=font_tuple)
853
-
854
- self.logger.debug(f"Applied font {font_tuple} to diff viewer text widgets")
855
- except Exception as e:
856
- self.logger.error(f"Error applying font to diff viewer: {e}")
857
-
858
- def update_statistics(self):
859
- """Update statistics bars for the active tabs."""
860
- try:
861
- # Get active tab indices
862
- active_input_idx = self.input_notebook.index("current")
863
- active_output_idx = self.output_notebook.index("current")
864
-
865
- # Get text from active tabs
866
- input_text = self.input_tabs[active_input_idx].text.get("1.0", tk.END)
867
- output_text = self.output_tabs[active_output_idx].text.get("1.0", tk.END)
868
-
869
- # Update input statistics
870
- if self.input_stats_bar:
871
- self._update_stats_bar(self.input_stats_bar, input_text)
872
-
873
- # Update output statistics
874
- if self.output_stats_bar:
875
- self._update_stats_bar(self.output_stats_bar, output_text)
876
-
877
- except Exception as e:
878
- self.logger.error(f"Error updating statistics: {e}")
879
-
880
- def _update_stats_bar(self, stats_bar, text):
881
- """
882
- Update a statistics bar with text statistics.
883
-
884
- Args:
885
- stats_bar: The label widget to update
886
- text: The text to analyze
887
- """
888
- try:
889
- # Remove trailing newline that tkinter adds
890
- if text.endswith('\n'):
891
- text = text[:-1]
892
-
893
- # Handle empty text
894
- if not text:
895
- stats_bar.config(text="Bytes: 0 | Word: 0 | Sentence: 0 | Line: 0 | Tokens: 0")
896
- return
897
-
898
- stripped_text = text.strip()
899
- char_count = len(stripped_text)
900
- byte_count = len(text.encode('utf-8'))
901
-
902
- # Count lines (more accurate)
903
- line_count = text.count('\n') + 1
904
-
905
- # Count words
906
- if char_count == 0:
907
- word_count = 0
908
- else:
909
- words = [word for word in stripped_text.split() if word]
910
- word_count = len(words)
911
-
912
- # Count sentences (rough approximation)
913
- sentence_count = text.count('.') + text.count('!') + text.count('?')
914
- if sentence_count == 0 and char_count > 0:
915
- sentence_count = 1
916
-
917
- # Token estimation
918
- token_count = max(1, round(char_count / 4)) if char_count > 0 else 0
919
-
920
- # Format bytes
921
- if byte_count < 1024:
922
- formatted_bytes = f"{byte_count}"
923
- elif byte_count < 1024 * 1024:
924
- formatted_bytes = f"{byte_count / 1024:.1f}K"
925
- else:
926
- formatted_bytes = f"{byte_count / (1024 * 1024):.1f}M"
927
-
928
- stats_bar.config(
929
- text=f"Bytes: {formatted_bytes} | Word: {word_count} | Sentence: {sentence_count} | Line: {line_count} | Tokens: {token_count}"
930
- )
931
- except Exception as e:
932
- self.logger.error(f"Error calculating statistics: {e}")
933
-
934
-
935
- class DiffViewerSettingsWidget:
936
- """Settings widget for the diff viewer tool."""
937
-
938
- def __init__(self, parent, diff_viewer, on_setting_change=None):
939
- """
940
- Initialize the settings widget.
941
-
942
- Args:
943
- parent: Parent tkinter widget
944
- diff_viewer: DiffViewerWidget instance
945
- on_setting_change: Callback function for setting changes
946
- """
947
- self.parent = parent
948
- self.diff_viewer = diff_viewer
949
- self.on_setting_change = on_setting_change
950
-
951
- # Get current settings
952
- settings = diff_viewer.get_settings()
953
- default_option = settings.get("option", "ignore_case")
954
-
955
- # Create option variable
956
- self.option_var = tk.StringVar(value=default_option)
957
-
958
- # Create UI
959
- self._create_ui()
960
-
961
- def _create_ui(self):
962
- """Create the settings UI."""
963
- ttk.Radiobutton(
964
- self.parent,
965
- text="Ignore case",
966
- variable=self.option_var,
967
- value="ignore_case",
968
- command=self._on_option_change
969
- ).pack(side=tk.LEFT, padx=(0, 8))
970
-
971
- ttk.Radiobutton(
972
- self.parent,
973
- text="Match case",
974
- variable=self.option_var,
975
- value="match_case",
976
- command=self._on_option_change
977
- ).pack(side=tk.LEFT, padx=(0, 8))
978
-
979
- ttk.Radiobutton(
980
- self.parent,
981
- text="Ignore whitespace",
982
- variable=self.option_var,
983
- value="ignore_whitespace",
984
- command=self._on_option_change
985
- ).pack(side=tk.LEFT, padx=(0, 16))
986
-
987
- ttk.Button(
988
- self.parent,
989
- text="Compare Active Tabs",
990
- command=self._run_comparison
991
- ).pack(side=tk.LEFT, padx=5)
992
-
993
- # Add separator
994
- ttk.Label(self.parent, text="|").pack(side=tk.LEFT, padx=5)
995
-
996
- ttk.Button(
997
- self.parent,
998
- text="List Comparator",
999
- command=self._launch_list_comparator
1000
- ).pack(side=tk.LEFT, padx=5)
1001
-
1002
- def _on_option_change(self):
1003
- """Handle option change."""
1004
- option = self.option_var.get()
1005
- self.diff_viewer.update_settings({"option": option})
1006
-
1007
- if self.on_setting_change:
1008
- self.on_setting_change("Diff Viewer", {"option": option})
1009
-
1010
- def _run_comparison(self):
1011
- """Run the diff comparison."""
1012
- option = self.option_var.get()
1013
- self.diff_viewer.run_comparison(option)
1014
-
1015
- def _launch_list_comparator(self):
1016
- """Launch the list comparator application."""
1017
- try:
1018
- # Try to use the parent app's integrated list comparator if available
1019
- # Check on the diff_viewer instance (not self, which is the settings widget)
1020
- if hasattr(self.diff_viewer, 'open_list_comparator') and callable(self.diff_viewer.open_list_comparator):
1021
- self.diff_viewer.logger.info("✅ Found open_list_comparator method, calling it...")
1022
- self.diff_viewer.open_list_comparator()
1023
- self.diff_viewer.logger.info("✅ List Comparator launched via parent app")
1024
- else:
1025
- # Fallback: Launch as subprocess (standalone mode)
1026
- import subprocess
1027
- import os
1028
- import sys
1029
-
1030
- # Get the directory where the current script is located
1031
- current_dir = os.path.dirname(os.path.abspath(__file__))
1032
- list_comparator_path = os.path.join(current_dir, "list_comparator.py")
1033
-
1034
- # Check if the list_comparator.py file exists
1035
- if os.path.exists(list_comparator_path):
1036
- # Launch the list comparator as a separate process without console window
1037
- if sys.platform.startswith('win'):
1038
- # Windows - use pythonw.exe to avoid console window, or hide it
1039
- try:
1040
- # Try to use pythonw.exe first (no console window)
1041
- pythonw_path = sys.executable.replace('python.exe', 'pythonw.exe')
1042
- if os.path.exists(pythonw_path):
1043
- subprocess.Popen([pythonw_path, list_comparator_path])
1044
- else:
1045
- # Fallback: use regular python but hide the console window
1046
- startupinfo = subprocess.STARTUPINFO()
1047
- startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
1048
- startupinfo.wShowWindow = subprocess.SW_HIDE
1049
- subprocess.Popen([sys.executable, list_comparator_path],
1050
- startupinfo=startupinfo)
1051
- except:
1052
- # Final fallback
1053
- subprocess.Popen([sys.executable, list_comparator_path])
1054
- else:
1055
- # Unix/Linux/macOS
1056
- subprocess.Popen([sys.executable, list_comparator_path])
1057
-
1058
- print("✅ List Comparator launched successfully (subprocess)")
1059
- else:
1060
- print(f"❌ List Comparator not found at: {list_comparator_path}")
1061
- # Try to show a message to the user if possible
1062
- self._show_warning("List Comparator",
1063
- f"List Comparator application not found.\n\nExpected location: {list_comparator_path}")
1064
-
1065
- except Exception as e:
1066
- print(f"❌ Error launching List Comparator: {e}")
1067
- self._show_error("List Comparator",
1068
- f"Error launching List Comparator:\n{str(e)}")
1069
-
1070
- def get_settings(self):
1071
- """Get current settings."""
1072
- return {"option": self.option_var.get()}
1
+ """
2
+ Diff Viewer Tool Module
3
+
4
+ This module provides a comprehensive text comparison tool with multiple diff algorithms
5
+ and preprocessing options. It supports side-by-side comparison with synchronized scrolling
6
+ and word-level highlighting of differences.
7
+
8
+ Features:
9
+ - Multiple comparison modes (ignore case, match case, ignore whitespace)
10
+ - Side-by-side text comparison with synchronized scrolling
11
+ - Word-level difference highlighting
12
+ - Tab-based interface for multiple comparisons
13
+ - Integration with optimized text widgets when available
14
+
15
+ Author: Promera AI Commander
16
+ """
17
+
18
+ import tkinter as tk
19
+ from tkinter import ttk, filedialog, messagebox
20
+ import difflib
21
+ import re
22
+ import platform
23
+ import logging
24
+ import subprocess
25
+ import os
26
+ import sys
27
+ from typing import Dict, Any, List, Optional
28
+
29
+ # Import optimized components when available
30
+ try:
31
+ from core.efficient_line_numbers import OptimizedTextWithLineNumbers
32
+ EFFICIENT_LINE_NUMBERS_AVAILABLE = True
33
+ except ImportError:
34
+ EFFICIENT_LINE_NUMBERS_AVAILABLE = False
35
+
36
+ try:
37
+ from core.memory_efficient_text_widget import MemoryEfficientTextWidget
38
+ MEMORY_EFFICIENT_TEXT_AVAILABLE = True
39
+ except ImportError:
40
+ MEMORY_EFFICIENT_TEXT_AVAILABLE = False
41
+
42
+
43
+ class TextWithLineNumbers(tk.Frame):
44
+ """Fallback implementation of TextWithLineNumbers when optimized components are not available."""
45
+
46
+ def __init__(self, *args, **kwargs):
47
+ super().__init__(*args, **kwargs)
48
+ self.text = tk.Text(self, wrap=tk.WORD, height=15, width=50, undo=True)
49
+ self.linenumbers = tk.Canvas(self, width=50, bg='#f0f0f0', highlightthickness=0)
50
+
51
+ self.linenumbers.pack(side=tk.LEFT, fill=tk.Y)
52
+ self.text.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
53
+
54
+ # Basic event bindings
55
+ self.text.bind("<<Modified>>", self._on_text_modified)
56
+ self.text.bind("<Configure>", self._on_text_modified)
57
+ self._on_text_modified()
58
+
59
+ def _on_text_modified(self, event=None):
60
+ """Update line numbers when text is modified."""
61
+ self.linenumbers.delete("all")
62
+ line_info_cache = []
63
+ i = self.text.index("@0,0")
64
+ while True:
65
+ dline = self.text.dlineinfo(i)
66
+ if dline is None:
67
+ break
68
+ line_info_cache.append((i, dline[1]))
69
+ i = self.text.index("%s+1line" % i)
70
+
71
+ for i, y in line_info_cache:
72
+ linenum = str(i).split(".")[0]
73
+ self.linenumbers.create_text(20, y, anchor="n", text=linenum, fill="gray")
74
+
75
+ if event and hasattr(event.widget, 'edit_modified') and event.widget.edit_modified():
76
+ event.widget.edit_modified(False)
77
+
78
+
79
+ class DiffViewerWidget:
80
+ """
81
+ A comprehensive diff viewer widget that provides side-by-side text comparison
82
+ with multiple comparison algorithms and preprocessing options.
83
+ """
84
+
85
+ def __init__(self, parent, tab_count=7, logger=None, parent_callback=None, dialog_manager=None):
86
+ """
87
+ Initialize the diff viewer widget.
88
+
89
+ Args:
90
+ parent: Parent tkinter widget
91
+ tab_count: Number of tabs to create (default: 7)
92
+ logger: Logger instance for debugging
93
+ parent_callback: Callback function to notify parent of changes
94
+ dialog_manager: DialogManager instance for consistent dialog handling
95
+ """
96
+ self.parent = parent
97
+ self.tab_count = tab_count
98
+ self.logger = logger or logging.getLogger(__name__)
99
+ self.parent_callback = parent_callback
100
+ self.dialog_manager = dialog_manager
101
+
102
+ # Settings for diff comparison
103
+ self.settings = {
104
+ "option": "ignore_case", # Default comparison mode
105
+ "char_level_diff": False, # Character-level diff mode (vs word-level)
106
+ "detect_moved": False, # Detect moved lines
107
+ "syntax_highlight": False # Syntax highlighting for code
108
+ }
109
+
110
+ # Create the main frame
111
+ self.diff_frame = ttk.Frame(parent, padding="10")
112
+ self.diff_frame.grid_columnconfigure(0, weight=1)
113
+ self.diff_frame.grid_columnconfigure(1, weight=1)
114
+ self.diff_frame.grid_rowconfigure(1, weight=1)
115
+
116
+ # Statistics bars
117
+ self.input_stats_bar = None
118
+ self.output_stats_bar = None
119
+
120
+ # Diff tracking for navigation and summary
121
+ self.diff_positions = [] # List of (line_number, diff_type) tuples
122
+ self.current_diff_index = -1
123
+ self.diff_counts = {"additions": 0, "deletions": 0, "modifications": 0}
124
+ self.similarity_score = 0.0
125
+ self.diff_summary_bar = None
126
+
127
+ # Regex filter mode flags
128
+ self.input_regex_mode = tk.BooleanVar(value=False)
129
+ self.output_regex_mode = tk.BooleanVar(value=False)
130
+
131
+ # Store original source text for comparison (to avoid accumulating blank lines)
132
+ self.comparison_source_input = {} # {tab_idx: original_text}
133
+ self.comparison_source_output = {} # {tab_idx: original_text}
134
+ self.has_run_comparison = {} # {tab_idx: bool}
135
+
136
+ # Initialize UI components
137
+ self._create_ui()
138
+ self._setup_event_bindings()
139
+
140
+ def _show_error(self, title, message):
141
+ """Show error dialog using DialogManager if available, otherwise use messagebox."""
142
+ if self.dialog_manager:
143
+ return self.dialog_manager.show_error(title, message, parent=self.parent)
144
+ else:
145
+ try:
146
+ from tkinter import messagebox
147
+ messagebox.showerror(title, message, parent=self.parent)
148
+ return True
149
+ except Exception:
150
+ return False
151
+
152
+ def _show_warning(self, title, message, category="warning"):
153
+ """Show warning dialog using DialogManager if available, otherwise use messagebox."""
154
+ if self.dialog_manager:
155
+ return self.dialog_manager.show_warning(title, message, category, parent=self.parent)
156
+ else:
157
+ try:
158
+ import tkinter.messagebox as messagebox
159
+ messagebox.showwarning(title, message, parent=self.parent)
160
+ return True
161
+ except Exception:
162
+ return False
163
+
164
+ def _create_ui(self):
165
+ """Create the user interface components."""
166
+ self._create_title_rows()
167
+ self._create_notebooks()
168
+ self._create_tabs()
169
+ self._create_statistics_bars()
170
+ self._create_diff_summary_bar()
171
+ self._configure_text_tags()
172
+
173
+ def _create_title_rows(self):
174
+ """Create the title rows with buttons and controls."""
175
+ # Input title row
176
+ input_title_row = ttk.Frame(self.diff_frame)
177
+ input_title_row.grid(row=0, column=0, sticky="ew", padx=(0, 5))
178
+
179
+ # Input label and buttons
180
+ input_controls = ttk.Frame(input_title_row)
181
+ input_controls.pack(side=tk.TOP, fill=tk.X)
182
+ ttk.Label(input_controls, text="Input", font=("Helvetica", 12, "bold")).pack(side=tk.LEFT)
183
+
184
+ # Load from file button
185
+ load_file_btn = ttk.Button(input_controls, text="📁", command=self.load_file_to_input, width=3)
186
+ load_file_btn.pack(side=tk.LEFT, padx=(10, 0))
187
+
188
+ # Erase button
189
+ ttk.Button(input_controls, text="⌫", command=self.clear_all_input_tabs, width=3).pack(side=tk.LEFT, padx=(5, 0))
190
+
191
+ # Input line filter
192
+ input_filter_frame = ttk.Frame(input_title_row)
193
+ input_filter_frame.pack(side=tk.TOP, fill=tk.X, pady=(5, 0))
194
+ ttk.Label(input_filter_frame, text="Filter:").pack(side=tk.LEFT)
195
+ self.input_filter_var = tk.StringVar()
196
+ self.input_filter_entry = ttk.Entry(input_filter_frame, textvariable=self.input_filter_var, width=25)
197
+ self.input_filter_entry.pack(side=tk.LEFT, padx=(5, 2), fill=tk.X, expand=True)
198
+ self.input_filter_var.trace_add("write", self._on_input_filter_changed)
199
+ ttk.Checkbutton(input_filter_frame, text="Rx", variable=self.input_regex_mode,
200
+ command=self._on_input_filter_changed, width=3).pack(side=tk.LEFT, padx=(0, 2))
201
+ ttk.Button(input_filter_frame, text="", command=self._clear_input_filter, width=3).pack(side=tk.LEFT)
202
+
203
+ # Output title row
204
+ output_title_row = ttk.Frame(self.diff_frame)
205
+ output_title_row.grid(row=0, column=1, sticky="ew", padx=(5, 0))
206
+
207
+ # Output label and buttons
208
+ output_controls = ttk.Frame(output_title_row)
209
+ output_controls.pack(side=tk.TOP, fill=tk.X)
210
+ ttk.Label(output_controls, text="Output", font=("Helvetica", 12, "bold")).pack(side=tk.LEFT)
211
+
212
+ # Send to Input dropdown
213
+ self.send_to_input_var = tk.StringVar(value="Send to Input")
214
+ send_to_input_menu = ttk.Menubutton(output_controls, textvariable=self.send_to_input_var, direction="below")
215
+ send_to_input_menu.pack(side=tk.LEFT, padx=(10, 6))
216
+
217
+ # Create the dropdown menu
218
+ dropdown_menu = tk.Menu(send_to_input_menu, tearoff=0)
219
+ send_to_input_menu.config(menu=dropdown_menu)
220
+ for i in range(self.tab_count):
221
+ dropdown_menu.add_command(label=f"Tab {i+1}", command=lambda tab=i: self.copy_to_specific_input_tab(tab))
222
+
223
+ # Copy to clipboard button
224
+ ttk.Button(output_controls, text="⎘", command=self.copy_to_clipboard, width=3).pack(side=tk.LEFT, padx=(0, 6))
225
+
226
+ # Erase button
227
+ ttk.Button(output_controls, text="⌫", command=self.clear_all_output_tabs, width=3).pack(side=tk.LEFT)
228
+
229
+ # Output line filter
230
+ output_filter_frame = ttk.Frame(output_title_row)
231
+ output_filter_frame.pack(side=tk.TOP, fill=tk.X, pady=(5, 0))
232
+ ttk.Label(output_filter_frame, text="Filter:").pack(side=tk.LEFT)
233
+ self.output_filter_var = tk.StringVar()
234
+ self.output_filter_entry = ttk.Entry(output_filter_frame, textvariable=self.output_filter_var, width=25)
235
+ self.output_filter_entry.pack(side=tk.LEFT, padx=(5, 2), fill=tk.X, expand=True)
236
+ self.output_filter_var.trace_add("write", self._on_output_filter_changed)
237
+ ttk.Checkbutton(output_filter_frame, text="Rx", variable=self.output_regex_mode,
238
+ command=self._on_output_filter_changed, width=3).pack(side=tk.LEFT, padx=(0, 2))
239
+ ttk.Button(output_filter_frame, text="✕", command=self._clear_output_filter, width=3).pack(side=tk.LEFT)
240
+
241
+ # Store original content for filtering
242
+ self.input_original_content = {}
243
+ self.output_original_content = {}
244
+
245
+ def _create_notebooks(self):
246
+ """Create the notebook widgets for input and output tabs."""
247
+ self.input_notebook = ttk.Notebook(self.diff_frame)
248
+ self.input_notebook.grid(row=1, column=0, sticky="nsew", padx=(0, 5))
249
+
250
+ self.output_notebook = ttk.Notebook(self.diff_frame)
251
+ self.output_notebook.grid(row=1, column=1, sticky="nsew", padx=(5, 0))
252
+
253
+ def _create_statistics_bars(self):
254
+ """Create statistics bars below the text areas."""
255
+ # Input statistics bar
256
+ self.input_stats_bar = ttk.Label(
257
+ self.diff_frame,
258
+ text="Bytes: 0 | Word: 0 | Sentence: 0 | Line: 0 | Tokens: 0",
259
+ relief=tk.SUNKEN,
260
+ anchor=tk.W,
261
+ padding=(5, 2)
262
+ )
263
+ self.input_stats_bar.grid(row=2, column=0, sticky="ew", padx=(0, 5), pady=(5, 0))
264
+
265
+ # Output statistics bar
266
+ self.output_stats_bar = ttk.Label(
267
+ self.diff_frame,
268
+ text="Bytes: 0 | Word: 0 | Sentence: 0 | Line: 0 | Tokens: 0",
269
+ relief=tk.SUNKEN,
270
+ anchor=tk.W,
271
+ padding=(5, 2)
272
+ )
273
+ self.output_stats_bar.grid(row=2, column=1, sticky="ew", padx=(5, 0), pady=(5, 0))
274
+
275
+ def _create_diff_summary_bar(self):
276
+ """Create diff summary bar with navigation buttons and similarity score."""
277
+ # Container frame spanning both columns
278
+ summary_frame = ttk.Frame(self.diff_frame)
279
+ summary_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(5, 0))
280
+
281
+ # Navigation buttons on the left
282
+ nav_frame = ttk.Frame(summary_frame)
283
+ nav_frame.pack(side=tk.LEFT)
284
+
285
+ self.prev_diff_btn = ttk.Button(nav_frame, text="⬆ Prev", command=self._goto_prev_diff, width=8)
286
+ self.prev_diff_btn.pack(side=tk.LEFT, padx=(0, 5))
287
+ self.prev_diff_btn.state(['disabled'])
288
+
289
+ self.next_diff_btn = ttk.Button(nav_frame, text="⬇ Next", command=self._goto_next_diff, width=8)
290
+ self.next_diff_btn.pack(side=tk.LEFT, padx=(0, 10))
291
+ self.next_diff_btn.state(['disabled'])
292
+
293
+ # Diff summary label in the center
294
+ self.diff_summary_bar = ttk.Label(
295
+ summary_frame,
296
+ text="Run comparison to see diff summary",
297
+ anchor=tk.CENTER,
298
+ padding=(5, 2)
299
+ )
300
+ self.diff_summary_bar.pack(side=tk.LEFT, fill=tk.X, expand=True)
301
+
302
+ # Export button on the right
303
+ self.export_html_btn = ttk.Button(summary_frame, text="Export HTML", command=self._export_to_html, width=13)
304
+ self.export_html_btn.pack(side=tk.RIGHT, padx=(10, 0))
305
+
306
+ def _create_tabs(self):
307
+ """Create the text tabs for input and output."""
308
+ self.input_tabs = []
309
+ self.output_tabs = []
310
+
311
+ for i in range(self.tab_count):
312
+ # Create input tab
313
+ if EFFICIENT_LINE_NUMBERS_AVAILABLE:
314
+ input_tab = OptimizedTextWithLineNumbers(self.input_notebook)
315
+ elif MEMORY_EFFICIENT_TEXT_AVAILABLE:
316
+ input_tab = MemoryEfficientTextWidget(self.input_notebook)
317
+ else:
318
+ input_tab = TextWithLineNumbers(self.input_notebook)
319
+
320
+ input_tab.text.bind("<<Modified>>", self._on_tab_content_changed)
321
+ input_tab.text.bind("<KeyRelease>", self._on_tab_content_changed)
322
+ input_tab.text.bind("<Button-1>", self._on_tab_content_changed)
323
+ self.input_tabs.append(input_tab)
324
+ self.input_notebook.add(input_tab, text=f"{i+1}:")
325
+
326
+ # Create output tab
327
+ if EFFICIENT_LINE_NUMBERS_AVAILABLE:
328
+ output_tab = OptimizedTextWithLineNumbers(self.output_notebook)
329
+ elif MEMORY_EFFICIENT_TEXT_AVAILABLE:
330
+ output_tab = MemoryEfficientTextWidget(self.output_notebook)
331
+ else:
332
+ output_tab = TextWithLineNumbers(self.output_notebook)
333
+
334
+ output_tab.text.bind("<<Modified>>", self._on_tab_content_changed)
335
+ output_tab.text.bind("<KeyRelease>", self._on_tab_content_changed)
336
+ output_tab.text.bind("<Button-1>", self._on_tab_content_changed)
337
+ self.output_tabs.append(output_tab)
338
+ self.output_notebook.add(output_tab, text=f"{i+1}:")
339
+
340
+ def _configure_text_tags(self):
341
+ """Configure text tags for highlighting differences and syntax."""
342
+ for tab_list in [self.input_tabs, self.output_tabs]:
343
+ for tab in tab_list:
344
+ widget = tab.text
345
+ widget.config(state="normal")
346
+ # Diff highlighting tags
347
+ widget.tag_configure("addition", background="#e6ffed")
348
+ widget.tag_configure("deletion", background="#ffebe9")
349
+ widget.tag_configure("modification", background="#e6f7ff")
350
+ widget.tag_configure("inline_add", background="#a7f0ba")
351
+ widget.tag_configure("inline_del", background="#ffc9c9")
352
+ widget.tag_configure("moved", background="#f3e8ff") # Lavender for moved lines
353
+
354
+ # Syntax highlighting tags (lower priority than diff tags)
355
+ widget.tag_configure("syntax_keyword", foreground="#0000ff") # Blue
356
+ widget.tag_configure("syntax_string", foreground="#008000") # Green
357
+ widget.tag_configure("syntax_comment", foreground="#808080", font=("", 0, "italic")) # Gray italic
358
+ widget.tag_configure("syntax_number", foreground="#ff8c00") # Dark orange
359
+ widget.tag_configure("syntax_function", foreground="#800080") # Purple
360
+ widget.tag_configure("syntax_decorator", foreground="#b8860b") # Dark goldenrod
361
+ widget.tag_configure("syntax_class", foreground="#2e8b57") # Sea green
362
+
363
+ def _setup_event_bindings(self):
364
+ """Set up event bindings for synchronized scrolling."""
365
+ self.input_notebook.bind("<<NotebookTabChanged>>", self._on_tab_changed)
366
+ self.output_notebook.bind("<<NotebookTabChanged>>", self._on_tab_changed)
367
+ self._setup_sync()
368
+
369
+ def _on_tab_changed(self, event=None):
370
+ """Handle tab change events."""
371
+ # Clear filters when switching tabs
372
+ if hasattr(self, 'input_filter_var'):
373
+ self.input_filter_var.set("")
374
+ if hasattr(self, 'output_filter_var'):
375
+ self.output_filter_var.set("")
376
+
377
+ self._setup_sync(event)
378
+ self.update_statistics()
379
+
380
+ def _setup_sync(self, event=None):
381
+ """Configure scroll and mousewheel syncing for the active tabs."""
382
+ try:
383
+ active_input_tab = self.input_tabs[self.input_notebook.index("current")]
384
+ active_output_tab = self.output_tabs[self.output_notebook.index("current")]
385
+ except (tk.TclError, IndexError):
386
+ return
387
+
388
+ # Configure scrollbar syncing
389
+ if hasattr(active_input_tab.text, 'vbar'):
390
+ active_input_tab.text.vbar.config(command=self._sync_scroll)
391
+ if hasattr(active_output_tab.text, 'vbar'):
392
+ active_output_tab.text.vbar.config(command=self._sync_scroll)
393
+
394
+ # Configure mouse wheel syncing
395
+ for tab in [active_input_tab, active_output_tab]:
396
+ tab.text.bind("<MouseWheel>", self._on_mousewheel)
397
+ tab.text.bind("<Button-4>", self._on_mousewheel)
398
+ tab.text.bind("<Button-5>", self._on_mousewheel)
399
+
400
+ def _sync_scroll(self, *args):
401
+ """Sync both text widgets when one's scrollbar is used."""
402
+ try:
403
+ active_input_tab = self.input_tabs[self.input_notebook.index("current")]
404
+ active_output_tab = self.output_tabs[self.output_notebook.index("current")]
405
+
406
+ active_input_tab.text.yview(*args)
407
+ active_output_tab.text.yview(*args)
408
+
409
+ # Update line numbers if available
410
+ if hasattr(active_input_tab, '_on_text_modified'):
411
+ active_input_tab._on_text_modified()
412
+ if hasattr(active_output_tab, '_on_text_modified'):
413
+ active_output_tab._on_text_modified()
414
+ except (tk.TclError, IndexError):
415
+ pass
416
+
417
+ def _on_mousewheel(self, event):
418
+ """Handle mouse wheel scrolling over either text widget."""
419
+ if platform.system() == "Windows":
420
+ delta = int(-1*(event.delta/120))
421
+ elif platform.system() == "Darwin":
422
+ delta = int(-1 * event.delta)
423
+ else:
424
+ delta = -1 if event.num == 4 else 1
425
+
426
+ try:
427
+ active_input_tab = self.input_tabs[self.input_notebook.index("current")]
428
+ active_output_tab = self.output_tabs[self.output_notebook.index("current")]
429
+
430
+ active_input_tab.text.yview_scroll(delta, "units")
431
+ active_output_tab.text.yview_scroll(delta, "units")
432
+
433
+ # Update line numbers if available
434
+ if hasattr(active_input_tab, '_on_text_modified'):
435
+ active_input_tab._on_text_modified()
436
+ if hasattr(active_output_tab, '_on_text_modified'):
437
+ active_output_tab._on_text_modified()
438
+ except (tk.TclError, IndexError):
439
+ pass
440
+
441
+ return "break"
442
+
443
+ def _on_tab_content_changed(self, event=None):
444
+ """Handle tab content changes."""
445
+ # Update tab labels when content changes
446
+ self.update_tab_labels()
447
+
448
+ # Update statistics
449
+ self.update_statistics()
450
+
451
+ # This can be overridden by the parent application
452
+ if hasattr(self, 'parent_callback') and self.parent_callback:
453
+ self.parent_callback()
454
+
455
+ def get_frame(self):
456
+ """Return the main frame widget."""
457
+ return self.diff_frame
458
+
459
+ def show(self):
460
+ """Show the diff viewer."""
461
+ self.diff_frame.grid(row=0, column=0, sticky="nsew", pady=5)
462
+
463
+ def hide(self):
464
+ """Hide the diff viewer."""
465
+ self.diff_frame.grid_remove()
466
+
467
+ def load_content(self, input_tabs_content, output_tabs_content):
468
+ """
469
+ Load content into the diff viewer tabs.
470
+
471
+ Args:
472
+ input_tabs_content: List of strings for input tabs
473
+ output_tabs_content: List of strings for output tabs
474
+ """
475
+ self.logger.info("Loading content into Diff Viewer.")
476
+
477
+ for i in range(min(len(input_tabs_content), self.tab_count)):
478
+ self.input_tabs[i].text.delete("1.0", tk.END)
479
+ self.input_tabs[i].text.insert("1.0", input_tabs_content[i])
480
+
481
+ for i in range(min(len(output_tabs_content), self.tab_count)):
482
+ self.output_tabs[i].text.delete("1.0", tk.END)
483
+ self.output_tabs[i].text.insert("1.0", output_tabs_content[i])
484
+
485
+ # Clear stored comparison source text (so new content is compared fresh)
486
+ self.comparison_source_input.clear()
487
+ self.comparison_source_output.clear()
488
+ self.has_run_comparison.clear()
489
+
490
+ # Update tab labels after loading content
491
+ self.update_tab_labels()
492
+
493
+ def sync_content_back(self):
494
+ """
495
+ Get content from diff viewer tabs.
496
+
497
+ Returns:
498
+ tuple: (input_contents, output_contents) as lists of strings
499
+ """
500
+ self.logger.info("Syncing Diff Viewer content back.")
501
+
502
+ input_contents = []
503
+ output_contents = []
504
+
505
+ for i in range(self.tab_count):
506
+ input_content = self.input_tabs[i].text.get("1.0", tk.END)
507
+ # Remove trailing newline that tkinter adds
508
+ if input_content.endswith('\n'):
509
+ input_content = input_content[:-1]
510
+ input_contents.append(input_content)
511
+
512
+ output_content = self.output_tabs[i].text.get("1.0", tk.END)
513
+ # Remove trailing newline that tkinter adds
514
+ if output_content.endswith('\n'):
515
+ output_content = output_content[:-1]
516
+ output_contents.append(output_content)
517
+
518
+ # Debug logging
519
+ non_empty_inputs = sum(1 for content in input_contents if content.strip())
520
+ non_empty_outputs = sum(1 for content in output_contents if content.strip())
521
+ self.logger.info(f"Syncing back {non_empty_inputs} non-empty input tabs, {non_empty_outputs} non-empty output tabs")
522
+
523
+ return input_contents, output_contents
524
+
525
+ def _preprocess_for_diff(self, text, option):
526
+ """
527
+ Preprocess text into line dicts according to diff option.
528
+
529
+ Args:
530
+ text: Input text to preprocess
531
+ option: Comparison option ('ignore_case', 'match_case', 'ignore_whitespace', 'sentence_level')
532
+
533
+ Returns:
534
+ List of dicts with 'raw' and 'cmp' keys
535
+ """
536
+ # For sentence-level comparison, split by sentences instead of lines
537
+ if option == "sentence_level":
538
+ return self._preprocess_sentences(text)
539
+
540
+ lines = text.splitlines()
541
+ processed = []
542
+ for line in lines:
543
+ cmp_line = line
544
+ if option == "ignore_case":
545
+ cmp_line = cmp_line.lower()
546
+ elif option == "ignore_whitespace":
547
+ cmp_line = re.sub(r"\s+", " ", cmp_line).strip()
548
+ elif option == "ignore_punctuation":
549
+ # Remove all punctuation for comparison (useful for prose)
550
+ cmp_line = re.sub(r'[^\w\s]', '', cmp_line.lower()).strip()
551
+ cmp_line = re.sub(r"\s+", " ", cmp_line) # Normalize whitespace too
552
+ processed.append({"raw": line, "cmp": cmp_line})
553
+ return processed
554
+
555
+ def _preprocess_sentences(self, text):
556
+ """
557
+ Split text into sentences for sentence-level comparison.
558
+
559
+ Args:
560
+ text: Input text to split into sentences
561
+
562
+ Returns:
563
+ List of dicts with 'raw' (original sentence) and 'cmp' (normalized) keys
564
+ """
565
+ # Replace line breaks with spaces for continuous text
566
+ continuous_text = re.sub(r'\s+', ' ', text).strip()
567
+
568
+ if not continuous_text:
569
+ return []
570
+
571
+ # Split into sentences using pattern that handles common cases
572
+ # Matches: . ! ? followed by space or end of string
573
+ # But not: abbreviations like Mr. Mrs. Dr. etc.
574
+ sentence_pattern = r'(?<![A-Z][a-z])(?<![A-Z])(?<=\.|\!|\?)\s+'
575
+ sentences = re.split(sentence_pattern, continuous_text)
576
+
577
+ processed = []
578
+ for sentence in sentences:
579
+ sentence = sentence.strip()
580
+ if sentence:
581
+ # Normalize for comparison: lowercase, normalize whitespace
582
+ cmp_sentence = re.sub(r'\s+', ' ', sentence.lower()).strip()
583
+ processed.append({"raw": sentence, "cmp": cmp_sentence})
584
+
585
+ return processed
586
+
587
+ def _clean_alignment_blanks(self, text):
588
+ """
589
+ Remove consecutive empty lines (alignment artifacts) while preserving single empty lines.
590
+
591
+ This prevents blank lines from accumulating across comparison sessions.
592
+
593
+ Args:
594
+ text: Input text that may contain alignment blank lines
595
+
596
+ Returns:
597
+ Cleaned text with consecutive empty lines collapsed
598
+ """
599
+ if not text:
600
+ return text
601
+
602
+ lines = text.splitlines()
603
+ cleaned_lines = []
604
+ prev_was_empty = False
605
+
606
+ for line in lines:
607
+ is_empty = line.strip() == ""
608
+
609
+ if is_empty and prev_was_empty:
610
+ # Skip consecutive empty lines
611
+ continue
612
+
613
+ cleaned_lines.append(line)
614
+ prev_was_empty = is_empty
615
+
616
+ return '\n'.join(cleaned_lines)
617
+
618
+ def reset_comparison_source(self):
619
+ """
620
+ Reset comparison source cache and clean widget content of accumulated blanks.
621
+
622
+ Called when user clicks 'Compare Active Tabs' to ensure fresh content is used.
623
+ """
624
+ # Clear the source cache
625
+ self.comparison_source_input.clear()
626
+ self.comparison_source_output.clear()
627
+ self.has_run_comparison.clear()
628
+
629
+ # Clean the active tab widgets of accumulated blank lines
630
+ try:
631
+ active_input_idx = self.input_notebook.index("current")
632
+ active_output_idx = self.output_notebook.index("current")
633
+
634
+ input_widget = self.input_tabs[active_input_idx].text
635
+ output_widget = self.output_tabs[active_output_idx].text
636
+
637
+ # Get and clean input content
638
+ input_text = input_widget.get("1.0", tk.END)
639
+ if input_text.endswith('\n'):
640
+ input_text = input_text[:-1]
641
+ cleaned_input = self._clean_alignment_blanks(input_text)
642
+
643
+ # Get and clean output content
644
+ output_text = output_widget.get("1.0", tk.END)
645
+ if output_text.endswith('\n'):
646
+ output_text = output_text[:-1]
647
+ cleaned_output = self._clean_alignment_blanks(output_text)
648
+
649
+ # Replace widget content with cleaned version
650
+ input_widget.delete("1.0", tk.END)
651
+ input_widget.insert("1.0", cleaned_input)
652
+
653
+ output_widget.delete("1.0", tk.END)
654
+ output_widget.insert("1.0", cleaned_output)
655
+
656
+ except (tk.TclError, IndexError) as e:
657
+ self.logger.warning(f"Could not clean widget content: {e}")
658
+
659
+ def run_comparison(self, option=None):
660
+ """
661
+ Compare the active tabs and display the diff.
662
+
663
+ Args:
664
+ option: Comparison option ('ignore_case', 'match_case', 'ignore_whitespace')
665
+ If None, uses the current setting
666
+ """
667
+ self.logger.info("Running Diff Viewer comparison.")
668
+
669
+ if option is not None:
670
+ self.settings["option"] = option
671
+
672
+ current_option = self.settings.get("option", "ignore_case")
673
+
674
+ try:
675
+ active_input_idx = self.input_notebook.index("current")
676
+ active_output_idx = self.output_notebook.index("current")
677
+
678
+ input_widget = self.input_tabs[active_input_idx].text
679
+ output_widget = self.output_tabs[active_output_idx].text
680
+
681
+ # Check if we have stored original text from a previous comparison
682
+ # This prevents accumulating blank lines when switching comparison modes
683
+ tab_key = (active_input_idx, active_output_idx)
684
+
685
+ if tab_key in self.has_run_comparison and self.has_run_comparison[tab_key]:
686
+ # Use stored original text (without alignment blanks)
687
+ input_text = self.comparison_source_input.get(active_input_idx, "")
688
+ output_text = self.comparison_source_output.get(active_output_idx, "")
689
+ else:
690
+ # First comparison - read current widget content and store it
691
+ input_text = input_widget.get("1.0", tk.END)
692
+ if input_text.endswith('\n'):
693
+ input_text = input_text[:-1]
694
+
695
+ output_text = output_widget.get("1.0", tk.END)
696
+ if output_text.endswith('\n'):
697
+ output_text = output_text[:-1]
698
+
699
+ # Store the original source text (no blank line cleaning - preserves structure)
700
+ self.comparison_source_input[active_input_idx] = input_text
701
+ self.comparison_source_output[active_output_idx] = output_text
702
+ self.has_run_comparison[tab_key] = True
703
+
704
+ except (tk.TclError, IndexError):
705
+ self.logger.error("Could not get active tabs for comparison")
706
+ return
707
+
708
+ # Clear filters before comparison
709
+ if hasattr(self, 'input_filter_var'):
710
+ self.input_filter_var.set("")
711
+ if hasattr(self, 'output_filter_var'):
712
+ self.output_filter_var.set("")
713
+
714
+ # Clear stored original content
715
+ if active_input_idx in self.input_original_content:
716
+ del self.input_original_content[active_input_idx]
717
+ if active_output_idx in self.output_original_content:
718
+ del self.output_original_content[active_output_idx]
719
+
720
+ # Clear existing content
721
+ input_widget.delete("1.0", tk.END)
722
+ output_widget.delete("1.0", tk.END)
723
+
724
+ # Reset diff tracking
725
+ self.diff_positions = []
726
+ self.current_diff_index = -1
727
+ self.diff_counts = {"additions": 0, "deletions": 0, "modifications": 0}
728
+ self.similarity_score = 0.0
729
+
730
+ # Handle empty texts
731
+ if not input_text.strip() and not output_text.strip():
732
+ self._update_diff_summary()
733
+ return
734
+ elif not input_text.strip():
735
+ for i, line in enumerate(output_text.splitlines()):
736
+ input_widget.insert(tk.END, '\n')
737
+ output_widget.insert(tk.END, line + '\n', 'addition')
738
+ self.diff_positions.append((i + 1, 'addition'))
739
+ self.diff_counts["additions"] += 1
740
+ self._update_diff_summary()
741
+ return
742
+ elif not output_text.strip():
743
+ for i, line in enumerate(input_text.splitlines()):
744
+ input_widget.insert(tk.END, line + '\n', 'deletion')
745
+ output_widget.insert(tk.END, '\n')
746
+ self.diff_positions.append((i + 1, 'deletion'))
747
+ self.diff_counts["deletions"] += 1
748
+ self._update_diff_summary()
749
+ return
750
+
751
+ # Preprocess texts for comparison
752
+ left_lines = self._preprocess_for_diff(input_text, current_option)
753
+ right_lines = self._preprocess_for_diff(output_text, current_option)
754
+ left_cmp = [l["cmp"] for l in left_lines]
755
+ right_cmp = [r["cmp"] for r in right_lines]
756
+
757
+ try:
758
+ matcher = difflib.SequenceMatcher(None, left_cmp, right_cmp, autojunk=False)
759
+
760
+ # Compute similarity score
761
+ self.similarity_score = matcher.ratio() * 100
762
+
763
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
764
+ if tag == 'equal':
765
+ for i in range(i1, i2):
766
+ input_widget.insert(tk.END, left_lines[i]["raw"] + '\n')
767
+ output_widget.insert(tk.END, right_lines[j1 + (i - i1)]["raw"] + '\n')
768
+
769
+ elif tag == 'delete':
770
+ for i in range(i1, i2):
771
+ input_widget.insert(tk.END, left_lines[i]["raw"] + '\n', 'deletion')
772
+ output_widget.insert(tk.END, '\n')
773
+ # Get line number AFTER inserting (current line minus 1 since cursor is on next line)
774
+ actual_line = int(input_widget.index(tk.INSERT).split('.')[0]) - 1
775
+ if actual_line < 1:
776
+ actual_line = 1
777
+ self.diff_positions.append((actual_line, 'deletion'))
778
+ self.diff_counts["deletions"] += 1
779
+
780
+ elif tag == 'insert':
781
+ for j in range(j1, j2):
782
+ input_widget.insert(tk.END, '\n')
783
+ output_widget.insert(tk.END, right_lines[j]["raw"] + '\n', 'addition')
784
+ # Get line number AFTER inserting
785
+ actual_line = int(input_widget.index(tk.INSERT).split('.')[0]) - 1
786
+ if actual_line < 1:
787
+ actual_line = 1
788
+ self.diff_positions.append((actual_line, 'addition'))
789
+ self.diff_counts["additions"] += 1
790
+
791
+ elif tag == 'replace':
792
+ input_block = [l["raw"] for l in left_lines[i1:i2]]
793
+ output_block = [r["raw"] for r in right_lines[j1:j2]]
794
+
795
+ # Pad blocks to same length
796
+ while len(input_block) < len(output_block):
797
+ input_block.append("")
798
+ while len(output_block) < len(input_block):
799
+ output_block.append("")
800
+
801
+ for line1, line2 in zip(input_block, output_block):
802
+ if line1 and line2:
803
+ self._highlight_word_diffs(input_widget, [line1], output_widget, [line2])
804
+ # Get line number AFTER inserting
805
+ actual_line = int(input_widget.index(tk.INSERT).split('.')[0]) - 1
806
+ if actual_line < 1:
807
+ actual_line = 1
808
+ self.diff_positions.append((actual_line, 'modification'))
809
+ self.diff_counts["modifications"] += 1
810
+ elif line1:
811
+ input_widget.insert(tk.END, line1 + '\n', 'deletion')
812
+ output_widget.insert(tk.END, '\n')
813
+ actual_line = int(input_widget.index(tk.INSERT).split('.')[0]) - 1
814
+ if actual_line < 1:
815
+ actual_line = 1
816
+ self.diff_positions.append((actual_line, 'deletion'))
817
+ self.diff_counts["deletions"] += 1
818
+ elif line2:
819
+ input_widget.insert(tk.END, '\n')
820
+ output_widget.insert(tk.END, line2 + '\n', 'addition')
821
+ actual_line = int(input_widget.index(tk.INSERT).split('.')[0]) - 1
822
+ if actual_line < 1:
823
+ actual_line = 1
824
+ self.diff_positions.append((actual_line, 'addition'))
825
+ self.diff_counts["additions"] += 1
826
+
827
+ except Exception as e:
828
+ self.logger.error(f"Error in diff computation: {e}")
829
+ input_widget.insert(tk.END, input_text)
830
+ output_widget.insert(tk.END, output_text)
831
+
832
+ # Reset scroll position
833
+ input_widget.yview_moveto(0)
834
+ output_widget.yview_moveto(0)
835
+ self._setup_sync()
836
+
837
+ # Detect moved lines if enabled
838
+ if self.settings.get("detect_moved", False):
839
+ self._detect_moved_lines(input_widget, output_widget)
840
+
841
+ # Apply syntax highlighting if enabled
842
+ if self.settings.get("syntax_highlight", False):
843
+ self._apply_syntax_highlighting(input_widget)
844
+ self._apply_syntax_highlighting(output_widget)
845
+
846
+ # Update tab labels after comparison
847
+ self.update_tab_labels()
848
+
849
+ # Update diff summary bar
850
+ self._update_diff_summary()
851
+
852
+ def _detect_moved_lines(self, input_widget, output_widget):
853
+ """
854
+ Detect lines that were moved (appear in both delete and insert sections).
855
+ Re-tags them as 'moved' instead of deletion/addition.
856
+ """
857
+ try:
858
+ # Get all lines with deletion tag from input
859
+ deleted_lines = {}
860
+ for tag_range in input_widget.tag_ranges("deletion"):
861
+ if isinstance(tag_range, str):
862
+ continue
863
+ # Get line number from index
864
+ line_num = int(str(tag_range).split('.')[0])
865
+ line_content = input_widget.get(f"{line_num}.0", f"{line_num}.end").strip()
866
+ if line_content:
867
+ deleted_lines[line_content.lower()] = line_num
868
+
869
+ # Get all lines with addition tag from output
870
+ added_lines = {}
871
+ for tag_range in output_widget.tag_ranges("addition"):
872
+ if isinstance(tag_range, str):
873
+ continue
874
+ line_num = int(str(tag_range).split('.')[0])
875
+ line_content = output_widget.get(f"{line_num}.0", f"{line_num}.end").strip()
876
+ if line_content:
877
+ added_lines[line_content.lower()] = line_num
878
+
879
+ # Find lines that appear in both (moved lines)
880
+ moved_count = 0
881
+ for content, input_line in deleted_lines.items():
882
+ if content in added_lines:
883
+ output_line = added_lines[content]
884
+ # Re-tag as moved in both widgets
885
+ input_widget.tag_remove("deletion", f"{input_line}.0", f"{input_line}.end+1c")
886
+ input_widget.tag_add("moved", f"{input_line}.0", f"{input_line}.end+1c")
887
+
888
+ output_widget.tag_remove("addition", f"{output_line}.0", f"{output_line}.end+1c")
889
+ output_widget.tag_add("moved", f"{output_line}.0", f"{output_line}.end+1c")
890
+
891
+ moved_count += 1
892
+
893
+ # Update counts
894
+ if moved_count > 0:
895
+ self.diff_counts["moved"] = moved_count
896
+ # Reduce deletion/addition counts
897
+ self.diff_counts["deletions"] = max(0, self.diff_counts.get("deletions", 0) - moved_count)
898
+ self.diff_counts["additions"] = max(0, self.diff_counts.get("additions", 0) - moved_count)
899
+
900
+ except Exception as e:
901
+ self.logger.error(f"Error detecting moved lines: {e}")
902
+
903
+ def _apply_syntax_highlighting(self, widget):
904
+ """
905
+ Apply syntax highlighting to code content in a text widget.
906
+ Uses regex patterns for common programming constructs.
907
+ """
908
+ try:
909
+ content = widget.get("1.0", tk.END)
910
+
911
+ # Define patterns for syntax highlighting
912
+ patterns = [
913
+ # Python/JS keywords
914
+ (r'\b(def|class|import|from|return|if|elif|else|for|while|try|except|finally|with|as|'
915
+ r'raise|yield|lambda|and|or|not|in|is|True|False|None|async|await|'
916
+ r'function|const|let|var|new|this|typeof|instanceof|export|default)\b',
917
+ 'syntax_keyword'),
918
+
919
+ # Triple-quoted strings (must come before single/double quotes)
920
+ (r'"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'', 'syntax_string'),
921
+
922
+ # Single and double quoted strings
923
+ (r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'', 'syntax_string'),
924
+
925
+ # Comments (Python # and JS //)
926
+ (r'#[^\n]*|//[^\n]*', 'syntax_comment'),
927
+
928
+ # Numbers (integers, floats, hex)
929
+ (r'\b(?:0x[0-9a-fA-F]+|0b[01]+|0o[0-7]+|\d+\.?\d*(?:e[+-]?\d+)?)\b', 'syntax_number'),
930
+
931
+ # Function definitions
932
+ (r'\b(?:def|function)\s+([a-zA-Z_][a-zA-Z0-9_]*)', 'syntax_function'),
933
+
934
+ # Decorators
935
+ (r'@[a-zA-Z_][a-zA-Z0-9_.]*', 'syntax_decorator'),
936
+
937
+ # Class definitions
938
+ (r'\bclass\s+([a-zA-Z_][a-zA-Z0-9_]*)', 'syntax_class'),
939
+ ]
940
+
941
+ for pattern, tag in patterns:
942
+ for match in re.finditer(pattern, content):
943
+ start_idx = f"1.0+{match.start()}c"
944
+ end_idx = f"1.0+{match.end()}c"
945
+
946
+ # For function/class definitions, highlight just the name (group 1)
947
+ if tag in ('syntax_function', 'syntax_class') and match.lastindex:
948
+ name_start = match.start(1)
949
+ name_end = match.end(1)
950
+ start_idx = f"1.0+{name_start}c"
951
+ end_idx = f"1.0+{name_end}c"
952
+
953
+ widget.tag_add(tag, start_idx, end_idx)
954
+
955
+ # Ensure diff tags have higher priority (raise them above syntax tags)
956
+ for diff_tag in ['addition', 'deletion', 'modification', 'inline_add', 'inline_del', 'moved']:
957
+ widget.tag_raise(diff_tag)
958
+
959
+ except Exception as e:
960
+ self.logger.error(f"Error applying syntax highlighting: {e}")
961
+
962
+ def _highlight_word_diffs(self, w1, lines1, w2, lines2):
963
+ """
964
+ Highlight word-level differences within a 'replace' block.
965
+
966
+ Args:
967
+ w1: First text widget
968
+ lines1: Lines for first widget
969
+ w2: Second text widget
970
+ lines2: Lines for second widget
971
+ """
972
+ # Check if character-level diff is enabled
973
+ use_char_diff = self.settings.get("char_level_diff", False)
974
+
975
+ for line1, line2 in zip(lines1, lines2):
976
+ w1.insert(tk.END, line1 + '\n', 'modification')
977
+ w2.insert(tk.END, line2 + '\n', 'modification')
978
+
979
+ line_start1 = w1.index(f"{w1.index(tk.INSERT)} -1 lines linestart")
980
+ line_start2 = w2.index(f"{w2.index(tk.INSERT)} -1 lines linestart")
981
+
982
+ if use_char_diff:
983
+ # Character-level diff
984
+ self._apply_char_diff(w1, line1, line_start1, w2, line2, line_start2)
985
+ else:
986
+ # Word-level diff
987
+ self._apply_word_diff(w1, line1, line_start1, w2, line2, line_start2)
988
+
989
+ def _apply_word_diff(self, w1, line1, line_start1, w2, line2, line_start2):
990
+ """Apply word-level diff highlighting."""
991
+ try:
992
+ words1 = re.split(r'(\s+)', line1)
993
+ words2 = re.split(r'(\s+)', line2)
994
+
995
+ matcher = difflib.SequenceMatcher(None, words1, words2)
996
+
997
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
998
+ if tag == 'delete' or tag == 'replace':
999
+ start_char1 = len("".join(words1[:i1]))
1000
+ end_char1 = len("".join(words1[:i2]))
1001
+ w1.tag_add('inline_del', f"{line_start1}+{start_char1}c", f"{line_start1}+{end_char1}c")
1002
+ if tag == 'insert' or tag == 'replace':
1003
+ start_char2 = len("".join(words2[:j1]))
1004
+ end_char2 = len("".join(words2[:j2]))
1005
+ w2.tag_add('inline_add', f"{line_start2}+{start_char2}c", f"{line_start2}+{end_char2}c")
1006
+ except Exception as e:
1007
+ self.logger.error(f"Error in word-level diff highlighting: {e}")
1008
+
1009
+ def _apply_char_diff(self, w1, line1, line_start1, w2, line2, line_start2):
1010
+ """Apply character-level diff highlighting."""
1011
+ try:
1012
+ # Split into individual characters
1013
+ chars1 = list(line1)
1014
+ chars2 = list(line2)
1015
+
1016
+ matcher = difflib.SequenceMatcher(None, chars1, chars2)
1017
+
1018
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
1019
+ if tag == 'delete' or tag == 'replace':
1020
+ w1.tag_add('inline_del', f"{line_start1}+{i1}c", f"{line_start1}+{i2}c")
1021
+ if tag == 'insert' or tag == 'replace':
1022
+ w2.tag_add('inline_add', f"{line_start2}+{j1}c", f"{line_start2}+{j2}c")
1023
+ except Exception as e:
1024
+ self.logger.error(f"Error in character-level diff highlighting: {e}")
1025
+
1026
+ def clear_all_input_tabs(self):
1027
+ """Clear all input tabs."""
1028
+ for tab in self.input_tabs:
1029
+ tab.text.delete("1.0", tk.END)
1030
+ # Update tab labels after clearing
1031
+ self.update_tab_labels()
1032
+
1033
+ def clear_all_output_tabs(self):
1034
+ """Clear all output tabs."""
1035
+ for tab in self.output_tabs:
1036
+ tab.text.delete("1.0", tk.END)
1037
+ # Update tab labels after clearing
1038
+ self.update_tab_labels()
1039
+
1040
+ def copy_to_clipboard(self):
1041
+ """Copy active output tab content to clipboard."""
1042
+ try:
1043
+ active_output_tab = self.output_tabs[self.output_notebook.index("current")]
1044
+ content = active_output_tab.text.get("1.0", tk.END)
1045
+ self.parent.clipboard_clear()
1046
+ self.parent.clipboard_append(content)
1047
+ except (tk.TclError, IndexError):
1048
+ pass
1049
+
1050
+ def copy_to_specific_input_tab(self, tab_index):
1051
+ """
1052
+ Copy active output tab content to a specific input tab.
1053
+
1054
+ Args:
1055
+ tab_index: Index of the target input tab
1056
+ """
1057
+ try:
1058
+ active_output_tab = self.output_tabs[self.output_notebook.index("current")]
1059
+ content = active_output_tab.text.get("1.0", tk.END)
1060
+
1061
+ if 0 <= tab_index < len(self.input_tabs):
1062
+ self.input_tabs[tab_index].text.delete("1.0", tk.END)
1063
+ self.input_tabs[tab_index].text.insert("1.0", content)
1064
+ except (tk.TclError, IndexError):
1065
+ pass
1066
+
1067
+ def load_file_to_input(self):
1068
+ """Load file content to the active input tab."""
1069
+ try:
1070
+ file_path = filedialog.askopenfilename(
1071
+ title="Select file to load",
1072
+ filetypes=[
1073
+ ("Text files", "*.txt"),
1074
+ ("All files", "*.*")
1075
+ ],
1076
+ parent=self.parent
1077
+ )
1078
+
1079
+ if file_path:
1080
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
1081
+ content = f.read()
1082
+
1083
+ # Load into active input tab
1084
+ active_input_tab = self.input_tabs[self.input_notebook.index("current")]
1085
+ active_input_tab.text.delete("1.0", tk.END)
1086
+ active_input_tab.text.insert("1.0", content)
1087
+
1088
+ self.logger.info(f"Loaded file: {file_path}")
1089
+ except Exception as e:
1090
+ self.logger.error(f"Error loading file: {e}")
1091
+ self._show_error("Error", f"Could not load file: {str(e)}")
1092
+
1093
+ def update_tab_labels(self):
1094
+ """Update tab labels based on content."""
1095
+ try:
1096
+ # Update input tab labels
1097
+ for i, tab in enumerate(self.input_tabs):
1098
+ content = tab.text.get("1.0", tk.END).strip()
1099
+ if content:
1100
+ # Get first few words for the label
1101
+ words = content.split()[:3]
1102
+ label = " ".join(words)
1103
+ if len(label) > 20:
1104
+ label = label[:17] + "..."
1105
+ if len(content.split()) > 3:
1106
+ label += "..."
1107
+ self.input_notebook.tab(i, text=f"{i+1}: {label}")
1108
+ else:
1109
+ self.input_notebook.tab(i, text=f"{i+1}:")
1110
+
1111
+ # Update output tab labels
1112
+ for i, tab in enumerate(self.output_tabs):
1113
+ content = tab.text.get("1.0", tk.END).strip()
1114
+ if content:
1115
+ # Get first few words for the label
1116
+ words = content.split()[:3]
1117
+ label = " ".join(words)
1118
+ if len(label) > 20:
1119
+ label = label[:17] + "..."
1120
+ if len(content.split()) > 3:
1121
+ label += "..."
1122
+ self.output_notebook.tab(i, text=f"{i+1}: {label}")
1123
+ else:
1124
+ self.output_notebook.tab(i, text=f"{i+1}:")
1125
+
1126
+ except Exception as e:
1127
+ self.logger.error(f"Error updating tab labels: {e}")
1128
+
1129
+ def _update_diff_summary(self):
1130
+ """Update the diff summary bar with current comparison results."""
1131
+ if not self.diff_summary_bar:
1132
+ return
1133
+
1134
+ total_diffs = len(self.diff_positions)
1135
+ adds = self.diff_counts.get("additions", 0)
1136
+ dels = self.diff_counts.get("deletions", 0)
1137
+ mods = self.diff_counts.get("modifications", 0)
1138
+ moved = self.diff_counts.get("moved", 0)
1139
+
1140
+ if total_diffs == 0 and moved == 0:
1141
+ summary_text = "No differences found | 100% similar"
1142
+ self.prev_diff_btn.state(['disabled'])
1143
+ self.next_diff_btn.state(['disabled'])
1144
+ else:
1145
+ parts = [f"+{adds} additions", f"-{dels} deletions", f"~{mods} modifications"]
1146
+ if moved > 0:
1147
+ parts.append(f"↔{moved} moved")
1148
+ parts.append(f"{self.similarity_score:.1f}% similar")
1149
+ summary_text = " | ".join(parts)
1150
+ self.prev_diff_btn.state(['!disabled'])
1151
+ self.next_diff_btn.state(['!disabled'])
1152
+
1153
+ self.diff_summary_bar.config(text=summary_text)
1154
+
1155
+ def _goto_prev_diff(self):
1156
+ """Navigate to the previous difference."""
1157
+ if not self.diff_positions:
1158
+ return
1159
+
1160
+ if self.current_diff_index <= 0:
1161
+ self.current_diff_index = len(self.diff_positions) - 1
1162
+ else:
1163
+ self.current_diff_index -= 1
1164
+
1165
+ self._scroll_to_diff(self.current_diff_index)
1166
+
1167
+ def _goto_next_diff(self):
1168
+ """Navigate to the next difference."""
1169
+ if not self.diff_positions:
1170
+ return
1171
+
1172
+ if self.current_diff_index >= len(self.diff_positions) - 1:
1173
+ self.current_diff_index = 0
1174
+ else:
1175
+ self.current_diff_index += 1
1176
+
1177
+ self._scroll_to_diff(self.current_diff_index)
1178
+
1179
+ def _scroll_to_diff(self, diff_index):
1180
+ """Scroll both text widgets to show the specified difference."""
1181
+ try:
1182
+ if diff_index < 0 or diff_index >= len(self.diff_positions):
1183
+ return
1184
+
1185
+ line_num, diff_type = self.diff_positions[diff_index]
1186
+
1187
+ active_input_tab = self.input_tabs[self.input_notebook.index("current")]
1188
+ active_output_tab = self.output_tabs[self.output_notebook.index("current")]
1189
+
1190
+ input_widget = active_input_tab.text
1191
+ output_widget = active_output_tab.text
1192
+
1193
+ # Line index for the diff
1194
+ line_index = f"{line_num}.0"
1195
+ line_end = f"{line_num}.end"
1196
+
1197
+ # Remove any previous navigation highlight
1198
+ input_widget.tag_remove("nav_highlight", "1.0", tk.END)
1199
+ output_widget.tag_remove("nav_highlight", "1.0", tk.END)
1200
+
1201
+ # Configure navigation highlight tag (bright yellow background with high priority)
1202
+ input_widget.tag_configure("nav_highlight", background="#ffff00", foreground="#000000")
1203
+ output_widget.tag_configure("nav_highlight", background="#ffff00", foreground="#000000")
1204
+
1205
+ # Add highlight to current diff line
1206
+ input_widget.tag_add("nav_highlight", line_index, line_end)
1207
+ output_widget.tag_add("nav_highlight", line_index, line_end)
1208
+
1209
+ # Raise nav_highlight above all other tags so it's visible
1210
+ input_widget.tag_raise("nav_highlight")
1211
+ output_widget.tag_raise("nav_highlight")
1212
+
1213
+ # Move cursor to the beginning of the line
1214
+ input_widget.mark_set(tk.INSERT, line_index)
1215
+ output_widget.mark_set(tk.INSERT, line_index)
1216
+
1217
+ # Focus the input widget
1218
+ input_widget.focus_set()
1219
+
1220
+ # Scroll to make the line visible (centered if possible)
1221
+ input_widget.see(line_index)
1222
+ output_widget.see(line_index)
1223
+
1224
+ # Force update of line numbers if available
1225
+ if hasattr(active_input_tab, '_on_text_modified'):
1226
+ active_input_tab._on_text_modified()
1227
+ if hasattr(active_output_tab, '_on_text_modified'):
1228
+ active_output_tab._on_text_modified()
1229
+
1230
+ # Update summary to show current position
1231
+ total = len(self.diff_positions)
1232
+ current = diff_index + 1
1233
+ base_summary = self.diff_summary_bar.cget("text").split(" | Diff ")[0]
1234
+ self.diff_summary_bar.config(text=f"{base_summary} | Diff {current}/{total}")
1235
+
1236
+ except (tk.TclError, IndexError) as e:
1237
+ self.logger.error(f"Error scrolling to diff: {e}")
1238
+
1239
+ def _export_to_html(self):
1240
+ """Export the current diff comparison to an HTML file."""
1241
+ try:
1242
+ active_input_idx = self.input_notebook.index("current")
1243
+ active_output_idx = self.output_notebook.index("current")
1244
+
1245
+ input_text = self.input_tabs[active_input_idx].text.get("1.0", tk.END)
1246
+ output_text = self.output_tabs[active_output_idx].text.get("1.0", tk.END)
1247
+
1248
+ # Remove trailing newlines
1249
+ if input_text.endswith('\n'):
1250
+ input_text = input_text[:-1]
1251
+ if output_text.endswith('\n'):
1252
+ output_text = output_text[:-1]
1253
+
1254
+ if not input_text.strip() and not output_text.strip():
1255
+ messagebox.showinfo("Export", "No content to export.", parent=self.parent)
1256
+ return
1257
+
1258
+ # Ask for save location
1259
+ file_path = filedialog.asksaveasfilename(
1260
+ title="Export Diff as HTML",
1261
+ defaultextension=".html",
1262
+ filetypes=[("HTML files", "*.html"), ("All files", "*.*")],
1263
+ parent=self.parent
1264
+ )
1265
+
1266
+ if not file_path:
1267
+ return
1268
+
1269
+ # Generate HTML diff
1270
+ html_diff = difflib.HtmlDiff(wrapcolumn=80)
1271
+ html_content = html_diff.make_file(
1272
+ input_text.splitlines(),
1273
+ output_text.splitlines(),
1274
+ fromdesc="Input",
1275
+ todesc="Output",
1276
+ context=False
1277
+ )
1278
+
1279
+ # Add custom styling for better appearance
1280
+ custom_css = """
1281
+ <style>
1282
+ body { font-family: 'Segoe UI', Tahoma, sans-serif; margin: 20px; }
1283
+ table.diff { border-collapse: collapse; width: 100%; }
1284
+ .diff_header { background-color: #f0f0f0; }
1285
+ .diff_next { background-color: #e0e0e0; }
1286
+ td { padding: 2px 8px; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; }
1287
+ .diff_add { background-color: #e6ffed; }
1288
+ .diff_chg { background-color: #e6f7ff; }
1289
+ .diff_sub { background-color: #ffebe9; }
1290
+ .summary { margin-bottom: 15px; padding: 10px; background: #f5f5f5; border-radius: 5px; }
1291
+ </style>
1292
+ """
1293
+
1294
+ # Insert summary and custom CSS
1295
+ summary_html = f"""
1296
+ <div class="summary">
1297
+ <strong>Diff Summary:</strong>
1298
+ +{self.diff_counts.get('additions', 0)} additions |
1299
+ -{self.diff_counts.get('deletions', 0)} deletions |
1300
+ ~{self.diff_counts.get('modifications', 0)} modifications |
1301
+ {self.similarity_score:.1f}% similar
1302
+ </div>
1303
+ """
1304
+
1305
+ html_content = html_content.replace("</head>", f"{custom_css}</head>")
1306
+ html_content = html_content.replace("<body>", f"<body>{summary_html}")
1307
+
1308
+ # Write to file
1309
+ with open(file_path, 'w', encoding='utf-8') as f:
1310
+ f.write(html_content)
1311
+
1312
+ self.logger.info(f"Exported diff to: {file_path}")
1313
+ messagebox.showinfo("Export Complete", f"Diff exported to:\n{file_path}", parent=self.parent)
1314
+
1315
+ except Exception as e:
1316
+ self.logger.error(f"Error exporting to HTML: {e}")
1317
+ self._show_error("Export Error", f"Could not export diff: {str(e)}")
1318
+
1319
+ def _on_input_filter_changed(self, *args):
1320
+ """Handle input filter text changes."""
1321
+ self._apply_input_filter()
1322
+
1323
+ def _on_output_filter_changed(self, *args):
1324
+ """Handle output filter text changes."""
1325
+ self._apply_output_filter()
1326
+
1327
+ def _clear_input_filter(self):
1328
+ """Clear the input filter."""
1329
+ self.input_filter_var.set("")
1330
+
1331
+ def _clear_output_filter(self):
1332
+ """Clear the output filter."""
1333
+ self.output_filter_var.set("")
1334
+
1335
+ def _apply_input_filter(self):
1336
+ """Apply line filter to the active input tab."""
1337
+ try:
1338
+ active_idx = self.input_notebook.index("current")
1339
+ current_tab = self.input_tabs[active_idx]
1340
+ filter_text = self.input_filter_var.get().strip()
1341
+
1342
+ # Store original content if not already stored
1343
+ if active_idx not in self.input_original_content:
1344
+ self.input_original_content[active_idx] = current_tab.text.get("1.0", tk.END)
1345
+
1346
+ original_content = self.input_original_content[active_idx]
1347
+
1348
+ if filter_text:
1349
+ # Apply filter
1350
+ lines = original_content.split('\n')
1351
+
1352
+ if self.input_regex_mode.get():
1353
+ # Regex mode
1354
+ try:
1355
+ pattern = re.compile(filter_text, re.IGNORECASE)
1356
+ filtered_lines = [line for line in lines if pattern.search(line)]
1357
+ except re.error as e:
1358
+ self.logger.warning(f"Invalid regex pattern: {e}")
1359
+ # Fallback to literal search on regex error
1360
+ filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
1361
+ else:
1362
+ # Simple substring match
1363
+ filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
1364
+
1365
+ filtered_content = '\n'.join(filtered_lines)
1366
+
1367
+ current_tab.text.delete("1.0", tk.END)
1368
+ current_tab.text.insert("1.0", filtered_content)
1369
+ else:
1370
+ # Restore original content
1371
+ current_tab.text.delete("1.0", tk.END)
1372
+ current_tab.text.insert("1.0", original_content)
1373
+ # Clear stored content
1374
+ if active_idx in self.input_original_content:
1375
+ del self.input_original_content[active_idx]
1376
+
1377
+ # Update statistics
1378
+ self.update_statistics()
1379
+
1380
+ except Exception as e:
1381
+ self.logger.error(f"Error applying input filter: {e}")
1382
+
1383
+ def _apply_output_filter(self):
1384
+ """Apply line filter to the active output tab."""
1385
+ try:
1386
+ active_idx = self.output_notebook.index("current")
1387
+ current_tab = self.output_tabs[active_idx]
1388
+ filter_text = self.output_filter_var.get().strip()
1389
+
1390
+ # Store original content if not already stored
1391
+ if active_idx not in self.output_original_content:
1392
+ self.output_original_content[active_idx] = current_tab.text.get("1.0", tk.END)
1393
+
1394
+ original_content = self.output_original_content[active_idx]
1395
+
1396
+ if filter_text:
1397
+ # Apply filter
1398
+ lines = original_content.split('\n')
1399
+
1400
+ if self.output_regex_mode.get():
1401
+ # Regex mode
1402
+ try:
1403
+ pattern = re.compile(filter_text, re.IGNORECASE)
1404
+ filtered_lines = [line for line in lines if pattern.search(line)]
1405
+ except re.error as e:
1406
+ self.logger.warning(f"Invalid regex pattern: {e}")
1407
+ # Fallback to literal search on regex error
1408
+ filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
1409
+ else:
1410
+ # Simple substring match
1411
+ filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
1412
+
1413
+ filtered_content = '\n'.join(filtered_lines)
1414
+
1415
+ current_tab.text.delete("1.0", tk.END)
1416
+ current_tab.text.insert("1.0", filtered_content)
1417
+ else:
1418
+ # Restore original content
1419
+ current_tab.text.delete("1.0", tk.END)
1420
+ current_tab.text.insert("1.0", original_content)
1421
+ # Clear stored content
1422
+ if active_idx in self.output_original_content:
1423
+ del self.output_original_content[active_idx]
1424
+
1425
+ # Update statistics
1426
+ self.update_statistics()
1427
+
1428
+ except Exception as e:
1429
+ self.logger.error(f"Error applying output filter: {e}")
1430
+
1431
+ def get_settings(self):
1432
+ """Get current diff viewer settings."""
1433
+ return self.settings.copy()
1434
+
1435
+ def update_settings(self, settings):
1436
+ """
1437
+ Update diff viewer settings.
1438
+
1439
+ Args:
1440
+ settings: Dictionary of settings to update
1441
+ """
1442
+ self.settings.update(settings)
1443
+
1444
+ def apply_font_to_widgets(self, font_tuple):
1445
+ """
1446
+ Apply font to all text widgets in the diff viewer.
1447
+
1448
+ Args:
1449
+ font_tuple: Tuple of (font_family, font_size)
1450
+ """
1451
+ try:
1452
+ for tab in self.input_tabs:
1453
+ if hasattr(tab, 'text'):
1454
+ tab.text.configure(font=font_tuple)
1455
+
1456
+ for tab in self.output_tabs:
1457
+ if hasattr(tab, 'text'):
1458
+ tab.text.configure(font=font_tuple)
1459
+
1460
+ self.logger.debug(f"Applied font {font_tuple} to diff viewer text widgets")
1461
+ except Exception as e:
1462
+ self.logger.error(f"Error applying font to diff viewer: {e}")
1463
+
1464
+ def update_statistics(self):
1465
+ """Update statistics bars for the active tabs."""
1466
+ try:
1467
+ # Get active tab indices
1468
+ active_input_idx = self.input_notebook.index("current")
1469
+ active_output_idx = self.output_notebook.index("current")
1470
+
1471
+ # Get text from active tabs
1472
+ input_text = self.input_tabs[active_input_idx].text.get("1.0", tk.END)
1473
+ output_text = self.output_tabs[active_output_idx].text.get("1.0", tk.END)
1474
+
1475
+ # Update input statistics
1476
+ if self.input_stats_bar:
1477
+ self._update_stats_bar(self.input_stats_bar, input_text)
1478
+
1479
+ # Update output statistics
1480
+ if self.output_stats_bar:
1481
+ self._update_stats_bar(self.output_stats_bar, output_text)
1482
+
1483
+ except Exception as e:
1484
+ self.logger.error(f"Error updating statistics: {e}")
1485
+
1486
+ def _update_stats_bar(self, stats_bar, text):
1487
+ """
1488
+ Update a statistics bar with text statistics.
1489
+
1490
+ Args:
1491
+ stats_bar: The label widget to update
1492
+ text: The text to analyze
1493
+ """
1494
+ try:
1495
+ # Remove trailing newline that tkinter adds
1496
+ if text.endswith('\n'):
1497
+ text = text[:-1]
1498
+
1499
+ # Handle empty text
1500
+ if not text:
1501
+ stats_bar.config(text="Bytes: 0 | Word: 0 | Sentence: 0 | Line: 0 | Tokens: 0")
1502
+ return
1503
+
1504
+ stripped_text = text.strip()
1505
+ char_count = len(stripped_text)
1506
+ byte_count = len(text.encode('utf-8'))
1507
+
1508
+ # Count lines (more accurate)
1509
+ line_count = text.count('\n') + 1
1510
+
1511
+ # Count words
1512
+ if char_count == 0:
1513
+ word_count = 0
1514
+ else:
1515
+ words = [word for word in stripped_text.split() if word]
1516
+ word_count = len(words)
1517
+
1518
+ # Count sentences using regex pattern that handles abbreviations better
1519
+ # Looks for sentence-ending punctuation followed by space or end of string
1520
+ sentence_pattern = r'[.!?]+(?:\s|$)'
1521
+ sentence_matches = re.findall(sentence_pattern, text)
1522
+ sentence_count = len(sentence_matches)
1523
+ if sentence_count == 0 and char_count > 0:
1524
+ sentence_count = 1
1525
+
1526
+ # Token estimation
1527
+ token_count = max(1, round(char_count / 4)) if char_count > 0 else 0
1528
+
1529
+ # Format bytes
1530
+ if byte_count < 1024:
1531
+ formatted_bytes = f"{byte_count}"
1532
+ elif byte_count < 1024 * 1024:
1533
+ formatted_bytes = f"{byte_count / 1024:.1f}K"
1534
+ else:
1535
+ formatted_bytes = f"{byte_count / (1024 * 1024):.1f}M"
1536
+
1537
+ stats_bar.config(
1538
+ text=f"Bytes: {formatted_bytes} | Word: {word_count} | Sentence: {sentence_count} | Line: {line_count} | Tokens: {token_count}"
1539
+ )
1540
+ except Exception as e:
1541
+ self.logger.error(f"Error calculating statistics: {e}")
1542
+
1543
+
1544
+ class DiffViewerSettingsWidget:
1545
+ """Settings widget for the diff viewer tool."""
1546
+
1547
+ def __init__(self, parent, diff_viewer, on_setting_change=None):
1548
+ """
1549
+ Initialize the settings widget.
1550
+
1551
+ Args:
1552
+ parent: Parent tkinter widget
1553
+ diff_viewer: DiffViewerWidget instance
1554
+ on_setting_change: Callback function for setting changes
1555
+ """
1556
+ self.parent = parent
1557
+ self.diff_viewer = diff_viewer
1558
+ self.on_setting_change = on_setting_change
1559
+
1560
+ # Get current settings
1561
+ settings = diff_viewer.get_settings()
1562
+ default_option = settings.get("option", "ignore_case")
1563
+ default_char_level = settings.get("char_level_diff", False)
1564
+ default_detect_moved = settings.get("detect_moved", False)
1565
+ default_syntax = settings.get("syntax_highlight", False)
1566
+
1567
+ # Create option variables
1568
+ self.option_var = tk.StringVar(value=default_option)
1569
+ self.char_level_var = tk.BooleanVar(value=default_char_level)
1570
+ self.detect_moved_var = tk.BooleanVar(value=default_detect_moved)
1571
+ self.syntax_var = tk.BooleanVar(value=default_syntax)
1572
+
1573
+ # Create UI
1574
+ self._create_ui()
1575
+
1576
+ def _create_ui(self):
1577
+ """Create the settings UI in two rows for better layout."""
1578
+ # Row 1: Comparison mode radio buttons
1579
+ row1 = ttk.Frame(self.parent)
1580
+ row1.pack(fill=tk.X, pady=(0, 3))
1581
+
1582
+ ttk.Label(row1, text="Mode:").pack(side=tk.LEFT, padx=(0, 5))
1583
+
1584
+ ttk.Radiobutton(
1585
+ row1,
1586
+ text="Ignore case",
1587
+ variable=self.option_var,
1588
+ value="ignore_case",
1589
+ command=self._on_option_change
1590
+ ).pack(side=tk.LEFT, padx=(0, 8))
1591
+
1592
+ ttk.Radiobutton(
1593
+ row1,
1594
+ text="Match case",
1595
+ variable=self.option_var,
1596
+ value="match_case",
1597
+ command=self._on_option_change
1598
+ ).pack(side=tk.LEFT, padx=(0, 8))
1599
+
1600
+ ttk.Radiobutton(
1601
+ row1,
1602
+ text="Ignore whitespace",
1603
+ variable=self.option_var,
1604
+ value="ignore_whitespace",
1605
+ command=self._on_option_change
1606
+ ).pack(side=tk.LEFT, padx=(0, 8))
1607
+
1608
+ ttk.Radiobutton(
1609
+ row1,
1610
+ text="Ignore punctuation",
1611
+ variable=self.option_var,
1612
+ value="ignore_punctuation",
1613
+ command=self._on_option_change
1614
+ ).pack(side=tk.LEFT, padx=(0, 8))
1615
+
1616
+ ttk.Radiobutton(
1617
+ row1,
1618
+ text="Sentences",
1619
+ variable=self.option_var,
1620
+ value="sentence_level",
1621
+ command=self._on_option_change
1622
+ ).pack(side=tk.LEFT, padx=(0, 8))
1623
+
1624
+ # Row 2: Action buttons and options
1625
+ row2 = ttk.Frame(self.parent)
1626
+ row2.pack(fill=tk.X)
1627
+
1628
+ ttk.Button(
1629
+ row2,
1630
+ text="Compare Active Tabs",
1631
+ command=self._run_comparison
1632
+ ).pack(side=tk.LEFT, padx=(0, 10))
1633
+
1634
+ ttk.Label(row2, text="|").pack(side=tk.LEFT, padx=5)
1635
+
1636
+ ttk.Label(row2, text="Options:").pack(side=tk.LEFT, padx=(0, 5))
1637
+
1638
+ # Character-level diff checkbox
1639
+ ttk.Checkbutton(
1640
+ row2,
1641
+ text="Char diff",
1642
+ variable=self.char_level_var,
1643
+ command=self._on_char_level_change
1644
+ ).pack(side=tk.LEFT, padx=(0, 8))
1645
+
1646
+ # Detect moved lines checkbox
1647
+ ttk.Checkbutton(
1648
+ row2,
1649
+ text="Detect moved",
1650
+ variable=self.detect_moved_var,
1651
+ command=self._on_detect_moved_change
1652
+ ).pack(side=tk.LEFT, padx=(0, 8))
1653
+
1654
+ # Syntax highlighting checkbox
1655
+ ttk.Checkbutton(
1656
+ row2,
1657
+ text="Syntax",
1658
+ variable=self.syntax_var,
1659
+ command=self._on_syntax_change
1660
+ ).pack(side=tk.LEFT, padx=(0, 8))
1661
+
1662
+ ttk.Label(row2, text="|").pack(side=tk.LEFT, padx=5)
1663
+
1664
+ ttk.Button(
1665
+ row2,
1666
+ text="List Comparator",
1667
+ command=self._launch_list_comparator
1668
+ ).pack(side=tk.LEFT, padx=5)
1669
+
1670
+ def _on_option_change(self):
1671
+ """Handle option change."""
1672
+ option = self.option_var.get()
1673
+
1674
+ # Show confirmation dialog for sentence mode
1675
+ if option == "sentence_level":
1676
+ if not self._confirm_sentence_mode():
1677
+ # User cancelled - revert to previous option
1678
+ previous_option = self.diff_viewer.settings.get("option", "ignore_case")
1679
+ self.option_var.set(previous_option)
1680
+ return
1681
+
1682
+ self.diff_viewer.update_settings({"option": option})
1683
+
1684
+ if self.on_setting_change:
1685
+ self.on_setting_change("Diff Viewer", {"option": option})
1686
+
1687
+ def _confirm_sentence_mode(self):
1688
+ """Show confirmation dialog for sentence mode."""
1689
+ message = (
1690
+ "Sentence Mode restructures text for comparison.\n\n"
1691
+ "• Text will be split into sentences (not lines)\n"
1692
+ "• Each sentence appears on its own line\n"
1693
+ "• Original line breaks will not be preserved\n\n"
1694
+ "This is useful for comparing prose where sentences\n"
1695
+ "may span multiple lines or be wrapped differently.\n\n"
1696
+ "Continue with Sentence Mode?"
1697
+ )
1698
+ return messagebox.askyesno(
1699
+ "Sentence Mode",
1700
+ message,
1701
+ icon=messagebox.WARNING
1702
+ )
1703
+
1704
+ def _on_char_level_change(self):
1705
+ """Handle character-level diff toggle."""
1706
+ char_level = self.char_level_var.get()
1707
+ self.diff_viewer.update_settings({"char_level_diff": char_level})
1708
+
1709
+ # Re-run comparison with new setting
1710
+ self._run_comparison()
1711
+
1712
+ if self.on_setting_change:
1713
+ self.on_setting_change("Diff Viewer", {"char_level_diff": char_level})
1714
+
1715
+ def _on_detect_moved_change(self):
1716
+ """Handle detect moved lines toggle."""
1717
+ detect_moved = self.detect_moved_var.get()
1718
+ self.diff_viewer.update_settings({"detect_moved": detect_moved})
1719
+
1720
+ # Re-run comparison with new setting
1721
+ self._run_comparison()
1722
+
1723
+ if self.on_setting_change:
1724
+ self.on_setting_change("Diff Viewer", {"detect_moved": detect_moved})
1725
+
1726
+ def _on_syntax_change(self):
1727
+ """Handle syntax highlighting toggle."""
1728
+ syntax = self.syntax_var.get()
1729
+ self.diff_viewer.update_settings({"syntax_highlight": syntax})
1730
+
1731
+ # Re-run comparison with new setting
1732
+ self._run_comparison()
1733
+
1734
+ if self.on_setting_change:
1735
+ self.on_setting_change("Diff Viewer", {"syntax_highlight": syntax})
1736
+
1737
+ def _run_comparison(self):
1738
+ """Run the diff comparison."""
1739
+ option = self.option_var.get()
1740
+ # Reset comparison source and clean widget content of accumulated blanks
1741
+ self.diff_viewer.reset_comparison_source()
1742
+ self.diff_viewer.run_comparison(option)
1743
+
1744
+ def _launch_list_comparator(self):
1745
+ """Launch the list comparator application."""
1746
+ try:
1747
+ # Try to use the parent app's integrated list comparator if available
1748
+ # Check on the diff_viewer instance (not self, which is the settings widget)
1749
+ if hasattr(self.diff_viewer, 'open_list_comparator') and callable(self.diff_viewer.open_list_comparator):
1750
+ self.diff_viewer.logger.info("✅ Found open_list_comparator method, calling it...")
1751
+ self.diff_viewer.open_list_comparator()
1752
+ self.diff_viewer.logger.info("✅ List Comparator launched via parent app")
1753
+ else:
1754
+ # Fallback: Launch as subprocess (standalone mode)
1755
+ import subprocess
1756
+ import os
1757
+ import sys
1758
+
1759
+ # Get the directory where the current script is located
1760
+ current_dir = os.path.dirname(os.path.abspath(__file__))
1761
+ list_comparator_path = os.path.join(current_dir, "list_comparator.py")
1762
+
1763
+ # Check if the list_comparator.py file exists
1764
+ if os.path.exists(list_comparator_path):
1765
+ # Launch the list comparator as a separate process without console window
1766
+ if sys.platform.startswith('win'):
1767
+ # Windows - use pythonw.exe to avoid console window, or hide it
1768
+ try:
1769
+ # Try to use pythonw.exe first (no console window)
1770
+ pythonw_path = sys.executable.replace('python.exe', 'pythonw.exe')
1771
+ if os.path.exists(pythonw_path):
1772
+ subprocess.Popen([pythonw_path, list_comparator_path])
1773
+ else:
1774
+ # Fallback: use regular python but hide the console window
1775
+ startupinfo = subprocess.STARTUPINFO()
1776
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
1777
+ startupinfo.wShowWindow = subprocess.SW_HIDE
1778
+ subprocess.Popen([sys.executable, list_comparator_path],
1779
+ startupinfo=startupinfo)
1780
+ except Exception:
1781
+ # Final fallback
1782
+ subprocess.Popen([sys.executable, list_comparator_path])
1783
+ else:
1784
+ # Unix/Linux/macOS
1785
+ subprocess.Popen([sys.executable, list_comparator_path])
1786
+
1787
+ print("✅ List Comparator launched successfully (subprocess)")
1788
+ else:
1789
+ print(f"❌ List Comparator not found at: {list_comparator_path}")
1790
+ # Try to show a message to the user if possible
1791
+ self._show_warning("List Comparator",
1792
+ f"List Comparator application not found.\n\nExpected location: {list_comparator_path}")
1793
+
1794
+ except Exception as e:
1795
+ print(f"❌ Error launching List Comparator: {e}")
1796
+ self._show_error("List Comparator",
1797
+ f"Error launching List Comparator:\n{str(e)}")
1798
+
1799
+ def get_settings(self):
1800
+ """Get current settings."""
1801
+ return {"option": self.option_var.get()}
1802
+
1803
+ def _show_warning(self, title, message):
1804
+ """Show warning dialog using DialogManager if available, otherwise use messagebox."""
1805
+ if hasattr(self.diff_viewer, '_show_warning'):
1806
+ return self.diff_viewer._show_warning(title, message)
1807
+ else:
1808
+ messagebox.showwarning(title, message, parent=self.parent)
1809
+ return True
1810
+
1811
+ def _show_error(self, title, message):
1812
+ """Show error dialog using DialogManager if available, otherwise use messagebox."""
1813
+ if hasattr(self.diff_viewer, '_show_error'):
1814
+ return self.diff_viewer._show_error(title, message)
1815
+ else:
1816
+ messagebox.showerror(title, message, parent=self.parent)
1817
+ return True