pomera-ai-commander 1.2.1 → 1.2.3

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.
@@ -16,7 +16,8 @@ Author: Promera AI Commander
16
16
  """
17
17
 
18
18
  import tkinter as tk
19
- from tkinter import ttk
19
+ from tkinter import ttk, filedialog, messagebox
20
+ import difflib
20
21
  import re
21
22
  import platform
22
23
  import logging
@@ -45,7 +46,7 @@ class TextWithLineNumbers(tk.Frame):
45
46
  def __init__(self, *args, **kwargs):
46
47
  super().__init__(*args, **kwargs)
47
48
  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
+ self.linenumbers = tk.Canvas(self, width=50, bg='#f0f0f0', highlightthickness=0)
49
50
 
50
51
  self.linenumbers.pack(side=tk.LEFT, fill=tk.Y)
51
52
  self.text.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
@@ -100,7 +101,10 @@ class DiffViewerWidget:
100
101
 
101
102
  # Settings for diff comparison
102
103
  self.settings = {
103
- "option": "ignore_case" # Default comparison mode
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
104
108
  }
105
109
 
106
110
  # Create the main frame
@@ -113,6 +117,22 @@ class DiffViewerWidget:
113
117
  self.input_stats_bar = None
114
118
  self.output_stats_bar = None
115
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
+
116
136
  # Initialize UI components
117
137
  self._create_ui()
118
138
  self._setup_event_bindings()
@@ -126,7 +146,7 @@ class DiffViewerWidget:
126
146
  from tkinter import messagebox
127
147
  messagebox.showerror(title, message, parent=self.parent)
128
148
  return True
129
- except:
149
+ except Exception:
130
150
  return False
131
151
 
132
152
  def _show_warning(self, title, message, category="warning"):
@@ -138,7 +158,7 @@ class DiffViewerWidget:
138
158
  import tkinter.messagebox as messagebox
139
159
  messagebox.showwarning(title, message, parent=self.parent)
140
160
  return True
141
- except:
161
+ except Exception:
142
162
  return False
143
163
 
144
164
  def _create_ui(self):
@@ -147,6 +167,7 @@ class DiffViewerWidget:
147
167
  self._create_notebooks()
148
168
  self._create_tabs()
149
169
  self._create_statistics_bars()
170
+ self._create_diff_summary_bar()
150
171
  self._configure_text_tags()
151
172
 
152
173
  def _create_title_rows(self):
@@ -173,8 +194,10 @@ class DiffViewerWidget:
173
194
  ttk.Label(input_filter_frame, text="Filter:").pack(side=tk.LEFT)
174
195
  self.input_filter_var = tk.StringVar()
175
196
  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)
197
+ self.input_filter_entry.pack(side=tk.LEFT, padx=(5, 2), fill=tk.X, expand=True)
177
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))
178
201
  ttk.Button(input_filter_frame, text="✕", command=self._clear_input_filter, width=3).pack(side=tk.LEFT)
179
202
 
180
203
  # Output title row
@@ -209,8 +232,10 @@ class DiffViewerWidget:
209
232
  ttk.Label(output_filter_frame, text="Filter:").pack(side=tk.LEFT)
210
233
  self.output_filter_var = tk.StringVar()
211
234
  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)
235
+ self.output_filter_entry.pack(side=tk.LEFT, padx=(5, 2), fill=tk.X, expand=True)
213
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))
214
239
  ttk.Button(output_filter_frame, text="✕", command=self._clear_output_filter, width=3).pack(side=tk.LEFT)
215
240
 
216
241
  # Store original content for filtering
@@ -246,6 +271,37 @@ class DiffViewerWidget:
246
271
  padding=(5, 2)
247
272
  )
248
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))
249
305
 
250
306
  def _create_tabs(self):
251
307
  """Create the text tabs for input and output."""
@@ -282,16 +338,27 @@ class DiffViewerWidget:
282
338
  self.output_notebook.add(output_tab, text=f"{i+1}:")
283
339
 
284
340
  def _configure_text_tags(self):
285
- """Configure text tags for highlighting differences."""
341
+ """Configure text tags for highlighting differences and syntax."""
286
342
  for tab_list in [self.input_tabs, self.output_tabs]:
287
343
  for tab in tab_list:
288
344
  widget = tab.text
289
345
  widget.config(state="normal")
346
+ # Diff highlighting tags
290
347
  widget.tag_configure("addition", background="#e6ffed")
291
348
  widget.tag_configure("deletion", background="#ffebe9")
292
349
  widget.tag_configure("modification", background="#e6f7ff")
293
350
  widget.tag_configure("inline_add", background="#a7f0ba")
294
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
295
362
 
296
363
  def _setup_event_bindings(self):
297
364
  """Set up event bindings for synchronized scrolling."""
@@ -415,6 +482,11 @@ class DiffViewerWidget:
415
482
  self.output_tabs[i].text.delete("1.0", tk.END)
416
483
  self.output_tabs[i].text.insert("1.0", output_tabs_content[i])
417
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
+
418
490
  # Update tab labels after loading content
419
491
  self.update_tab_labels()
420
492
 
@@ -456,11 +528,15 @@ class DiffViewerWidget:
456
528
 
457
529
  Args:
458
530
  text: Input text to preprocess
459
- option: Comparison option ('ignore_case', 'match_case', 'ignore_whitespace')
531
+ option: Comparison option ('ignore_case', 'match_case', 'ignore_whitespace', 'sentence_level')
460
532
 
461
533
  Returns:
462
534
  List of dicts with 'raw' and 'cmp' keys
463
535
  """
536
+ # For sentence-level comparison, split by sentences instead of lines
537
+ if option == "sentence_level":
538
+ return self._preprocess_sentences(text)
539
+
464
540
  lines = text.splitlines()
465
541
  processed = []
466
542
  for line in lines:
@@ -469,8 +545,116 @@ class DiffViewerWidget:
469
545
  cmp_line = cmp_line.lower()
470
546
  elif option == "ignore_whitespace":
471
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
472
552
  processed.append({"raw": line, "cmp": cmp_line})
473
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}")
474
658
 
475
659
  def run_comparison(self, option=None):
476
660
  """
@@ -494,14 +678,28 @@ class DiffViewerWidget:
494
678
  input_widget = self.input_tabs[active_input_idx].text
495
679
  output_widget = self.output_tabs[active_output_idx].text
496
680
 
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]
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)
501
684
 
502
- output_text = output_widget.get("1.0", tk.END)
503
- if output_text.endswith('\n'):
504
- output_text = output_text[:-1]
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
505
703
 
506
704
  except (tk.TclError, IndexError):
507
705
  self.logger.error("Could not get active tabs for comparison")
@@ -522,19 +720,32 @@ class DiffViewerWidget:
522
720
  # Clear existing content
523
721
  input_widget.delete("1.0", tk.END)
524
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
525
729
 
526
730
  # Handle empty texts
527
- if not input_text.strip() and not output_text.strip():
731
+ if not input_text.strip() and not output_text.strip():
732
+ self._update_diff_summary()
528
733
  return
529
734
  elif not input_text.strip():
530
- for line in output_text.splitlines():
735
+ for i, line in enumerate(output_text.splitlines()):
531
736
  input_widget.insert(tk.END, '\n')
532
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()
533
741
  return
534
742
  elif not output_text.strip():
535
- for line in input_text.splitlines():
743
+ for i, line in enumerate(input_text.splitlines()):
536
744
  input_widget.insert(tk.END, line + '\n', 'deletion')
537
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()
538
749
  return
539
750
 
540
751
  # Preprocess texts for comparison
@@ -544,9 +755,11 @@ class DiffViewerWidget:
544
755
  right_cmp = [r["cmp"] for r in right_lines]
545
756
 
546
757
  try:
547
- import difflib
548
758
  matcher = difflib.SequenceMatcher(None, left_cmp, right_cmp, autojunk=False)
549
759
 
760
+ # Compute similarity score
761
+ self.similarity_score = matcher.ratio() * 100
762
+
550
763
  for tag, i1, i2, j1, j2 in matcher.get_opcodes():
551
764
  if tag == 'equal':
552
765
  for i in range(i1, i2):
@@ -557,11 +770,23 @@ class DiffViewerWidget:
557
770
  for i in range(i1, i2):
558
771
  input_widget.insert(tk.END, left_lines[i]["raw"] + '\n', 'deletion')
559
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
560
779
 
561
780
  elif tag == 'insert':
562
781
  for j in range(j1, j2):
563
782
  input_widget.insert(tk.END, '\n')
564
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
565
790
 
566
791
  elif tag == 'replace':
567
792
  input_block = [l["raw"] for l in left_lines[i1:i2]]
@@ -576,12 +801,28 @@ class DiffViewerWidget:
576
801
  for line1, line2 in zip(input_block, output_block):
577
802
  if line1 and line2:
578
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
579
810
  elif line1:
580
811
  input_widget.insert(tk.END, line1 + '\n', 'deletion')
581
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
582
818
  elif line2:
583
819
  input_widget.insert(tk.END, '\n')
584
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
585
826
 
586
827
  except Exception as e:
587
828
  self.logger.error(f"Error in diff computation: {e}")
@@ -593,8 +834,130 @@ class DiffViewerWidget:
593
834
  output_widget.yview_moveto(0)
594
835
  self._setup_sync()
595
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
+
596
846
  # Update tab labels after comparison
597
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}")
598
961
 
599
962
  def _highlight_word_diffs(self, w1, lines1, w2, lines2):
600
963
  """
@@ -606,6 +969,9 @@ class DiffViewerWidget:
606
969
  w2: Second text widget
607
970
  lines2: Lines for second widget
608
971
  """
972
+ # Check if character-level diff is enabled
973
+ use_char_diff = self.settings.get("char_level_diff", False)
974
+
609
975
  for line1, line2 in zip(lines1, lines2):
610
976
  w1.insert(tk.END, line1 + '\n', 'modification')
611
977
  w2.insert(tk.END, line2 + '\n', 'modification')
@@ -613,24 +979,49 @@ class DiffViewerWidget:
613
979
  line_start1 = w1.index(f"{w1.index(tk.INSERT)} -1 lines linestart")
614
980
  line_start2 = w2.index(f"{w2.index(tk.INSERT)} -1 lines linestart")
615
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:
616
992
  words1 = re.split(r'(\s+)', line1)
617
993
  words2 = re.split(r'(\s+)', line2)
618
994
 
619
- try:
620
- import difflib
621
- matcher = difflib.SequenceMatcher(None, words1, words2)
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)
622
1017
 
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}")
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}")
634
1025
 
635
1026
  def clear_all_input_tabs(self):
636
1027
  """Clear all input tabs."""
@@ -676,7 +1067,6 @@ class DiffViewerWidget:
676
1067
  def load_file_to_input(self):
677
1068
  """Load file content to the active input tab."""
678
1069
  try:
679
- from tkinter import filedialog
680
1070
  file_path = filedialog.askopenfilename(
681
1071
  title="Select file to load",
682
1072
  filetypes=[
@@ -735,6 +1125,196 @@ class DiffViewerWidget:
735
1125
 
736
1126
  except Exception as e:
737
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)}")
738
1318
 
739
1319
  def _on_input_filter_changed(self, *args):
740
1320
  """Handle input filter text changes."""
@@ -768,7 +1348,20 @@ class DiffViewerWidget:
768
1348
  if filter_text:
769
1349
  # Apply filter
770
1350
  lines = original_content.split('\n')
771
- filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
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
+
772
1365
  filtered_content = '\n'.join(filtered_lines)
773
1366
 
774
1367
  current_tab.text.delete("1.0", tk.END)
@@ -803,7 +1396,20 @@ class DiffViewerWidget:
803
1396
  if filter_text:
804
1397
  # Apply filter
805
1398
  lines = original_content.split('\n')
806
- filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
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
+
807
1413
  filtered_content = '\n'.join(filtered_lines)
808
1414
 
809
1415
  current_tab.text.delete("1.0", tk.END)
@@ -909,8 +1515,11 @@ class DiffViewerWidget:
909
1515
  words = [word for word in stripped_text.split() if word]
910
1516
  word_count = len(words)
911
1517
 
912
- # Count sentences (rough approximation)
913
- sentence_count = text.count('.') + text.count('!') + text.count('?')
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)
914
1523
  if sentence_count == 0 and char_count > 0:
915
1524
  sentence_count = 1
916
1525
 
@@ -951,17 +1560,29 @@ class DiffViewerSettingsWidget:
951
1560
  # Get current settings
952
1561
  settings = diff_viewer.get_settings()
953
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)
954
1566
 
955
- # Create option variable
1567
+ # Create option variables
956
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)
957
1572
 
958
1573
  # Create UI
959
1574
  self._create_ui()
960
1575
 
961
1576
  def _create_ui(self):
962
- """Create the settings UI."""
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
+
963
1584
  ttk.Radiobutton(
964
- self.parent,
1585
+ row1,
965
1586
  text="Ignore case",
966
1587
  variable=self.option_var,
967
1588
  value="ignore_case",
@@ -969,7 +1590,7 @@ class DiffViewerSettingsWidget:
969
1590
  ).pack(side=tk.LEFT, padx=(0, 8))
970
1591
 
971
1592
  ttk.Radiobutton(
972
- self.parent,
1593
+ row1,
973
1594
  text="Match case",
974
1595
  variable=self.option_var,
975
1596
  value="match_case",
@@ -977,24 +1598,71 @@ class DiffViewerSettingsWidget:
977
1598
  ).pack(side=tk.LEFT, padx=(0, 8))
978
1599
 
979
1600
  ttk.Radiobutton(
980
- self.parent,
1601
+ row1,
981
1602
  text="Ignore whitespace",
982
1603
  variable=self.option_var,
983
1604
  value="ignore_whitespace",
984
1605
  command=self._on_option_change
985
- ).pack(side=tk.LEFT, padx=(0, 16))
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)
986
1627
 
987
1628
  ttk.Button(
988
- self.parent,
1629
+ row2,
989
1630
  text="Compare Active Tabs",
990
1631
  command=self._run_comparison
991
- ).pack(side=tk.LEFT, padx=5)
1632
+ ).pack(side=tk.LEFT, padx=(0, 10))
1633
+
1634
+ ttk.Label(row2, text="|").pack(side=tk.LEFT, padx=5)
992
1635
 
993
- # Add separator
994
- ttk.Label(self.parent, text="|").pack(side=tk.LEFT, padx=5)
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)
995
1663
 
996
1664
  ttk.Button(
997
- self.parent,
1665
+ row2,
998
1666
  text="List Comparator",
999
1667
  command=self._launch_list_comparator
1000
1668
  ).pack(side=tk.LEFT, padx=5)
@@ -1002,14 +1670,75 @@ class DiffViewerSettingsWidget:
1002
1670
  def _on_option_change(self):
1003
1671
  """Handle option change."""
1004
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
+
1005
1682
  self.diff_viewer.update_settings({"option": option})
1006
1683
 
1007
1684
  if self.on_setting_change:
1008
1685
  self.on_setting_change("Diff Viewer", {"option": option})
1009
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
+
1010
1737
  def _run_comparison(self):
1011
1738
  """Run the diff comparison."""
1012
1739
  option = self.option_var.get()
1740
+ # Reset comparison source and clean widget content of accumulated blanks
1741
+ self.diff_viewer.reset_comparison_source()
1013
1742
  self.diff_viewer.run_comparison(option)
1014
1743
 
1015
1744
  def _launch_list_comparator(self):
@@ -1048,7 +1777,7 @@ class DiffViewerSettingsWidget:
1048
1777
  startupinfo.wShowWindow = subprocess.SW_HIDE
1049
1778
  subprocess.Popen([sys.executable, list_comparator_path],
1050
1779
  startupinfo=startupinfo)
1051
- except:
1780
+ except Exception:
1052
1781
  # Final fallback
1053
1782
  subprocess.Popen([sys.executable, list_comparator_path])
1054
1783
  else:
@@ -1069,4 +1798,20 @@ class DiffViewerSettingsWidget:
1069
1798
 
1070
1799
  def get_settings(self):
1071
1800
  """Get current settings."""
1072
- return {"option": self.option_var.get()}
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