""" Diff Viewer - Visualizza due versioni di un file affiancate con minimappa delle differenze. """ import tkinter as tk from tkinter import ttk, scrolledtext import difflib from pathlib import Path from typing import Optional, List, Tuple class DiffViewer(tk.Toplevel): """Finestra per visualizzare le differenze tra due file affiancati.""" def __init__( self, parent, file_a_path: Optional[str], file_b_path: Optional[str], title_a: str = "Baseline", title_b: str = "Current", ): super().__init__(parent) self.file_a_path = file_a_path self.file_b_path = file_b_path self.title_a = title_a self.title_b = title_b # Configurazione finestra self.title("Diff Viewer") self.geometry("1400x800") # Centro la finestra rispetto al parent self.transient(parent) self._center_window() # Carica i contenuti dei file self.lines_a = self._load_file(file_a_path) if file_a_path else [] self.lines_b = self._load_file(file_b_path) if file_b_path else [] # Calcola le differenze self.diff_blocks = self._compute_diff_blocks() # Costruisci UI self._build_ui() # Popola i contenuti self._populate_content() # Sincronizza scroll self._setup_scroll_sync() def _center_window(self): """Centra la finestra sullo schermo.""" self.update_idletasks() width = self.winfo_width() height = self.winfo_height() x = (self.winfo_screenwidth() // 2) - (width // 2) y = (self.winfo_screenheight() // 2) - (height // 2) self.geometry(f"{width}x{height}+{x}+{y}") def _load_file(self, filepath: str) -> List[str]: """Carica il contenuto di un file.""" try: with open(filepath, "r", encoding="utf-8", errors="ignore") as f: return f.readlines() except Exception: return [] def _compute_diff_blocks(self) -> List[Tuple[str, int, int, int, int]]: """ Calcola i blocchi di differenze tra i due file. Ritorna una lista di tuple (tag, i1, i2, j1, j2) dove: - tag: 'equal', 'delete', 'insert', 'replace' - i1, i2: range di linee nel file A - j1, j2: range di linee nel file B """ sm = difflib.SequenceMatcher(a=self.lines_a, b=self.lines_b) return sm.get_opcodes() def _build_ui(self): """Costruisce l'interfaccia utente.""" # Frame principale con 3 colonne: file A, minimappa, file B main_frame = ttk.Frame(self) main_frame.pack(fill="both", expand=True, padx=5, pady=5) # Configurazione griglia: colonna 0 e 2 espandibili, colonna 1 (minimappa) fissa main_frame.columnconfigure(0, weight=1) main_frame.columnconfigure(1, weight=0, minsize=60) main_frame.columnconfigure(2, weight=1) main_frame.rowconfigure(1, weight=1) # Headers header_a = ttk.Label( main_frame, text=f"{self.title_a}: {Path(self.file_a_path).name if self.file_a_path else 'N/A'}", font=("Arial", 10, "bold"), ) header_a.grid(row=0, column=0, sticky="ew", padx=2, pady=2) minimap_header = ttk.Label( main_frame, text="Diff Map", font=("Arial", 9, "bold") ) minimap_header.grid(row=0, column=1, sticky="ew", padx=2, pady=2) header_b = ttk.Label( main_frame, text=f"{self.title_b}: {Path(self.file_b_path).name if self.file_b_path else 'N/A'}", font=("Arial", 10, "bold"), ) header_b.grid(row=0, column=2, sticky="ew", padx=2, pady=2) # Text widgets per file A e B con scrollbar self.text_a = tk.Text( main_frame, wrap="none", width=60, height=40, font=("Courier", 9), bg="#f8f8f8", state="disabled", ) self.text_a.grid(row=1, column=0, sticky="nsew", padx=(2, 1)) # Scrollbar verticale condivisa scrollbar_v = ttk.Scrollbar(main_frame, orient="vertical") scrollbar_v.grid(row=1, column=1, sticky="ns", padx=0) self.text_b = tk.Text( main_frame, wrap="none", width=60, height=40, font=("Courier", 9), bg="#f8f8f8", state="disabled", ) self.text_b.grid(row=1, column=2, sticky="nsew", padx=(1, 2)) # Canvas per minimappa self.minimap = tk.Canvas( main_frame, width=50, bg="#e8e8e8", highlightthickness=1, highlightbackground="#ccc", ) self.minimap.grid(row=1, column=1, sticky="nsew", padx=2, pady=0) # Ridisegna minimap quando il canvas viene ridimensionato self.minimap.bind("", lambda e: self._draw_minimap()) # Scrollbar orizzontali scrollbar_h_a = ttk.Scrollbar( main_frame, orient="horizontal", command=self.text_a.xview ) scrollbar_h_a.grid(row=2, column=0, sticky="ew", padx=(2, 1)) self.text_a.config(xscrollcommand=scrollbar_h_a.set) scrollbar_h_b = ttk.Scrollbar( main_frame, orient="horizontal", command=self.text_b.xview ) scrollbar_h_b.grid(row=2, column=2, sticky="ew", padx=(1, 2)) self.text_b.config(xscrollcommand=scrollbar_h_b.set) # Collega scrollbar verticale a entrambi i text widget self.text_a.config(yscrollcommand=self._on_scroll_a) self.text_b.config(yscrollcommand=self._on_scroll_b) scrollbar_v.config(command=self._on_scrollbar) # Tag per colorare le differenze self._configure_tags() # Info bar in basso info_frame = ttk.Frame(self) info_frame.pack(fill="x", padx=5, pady=(0, 5)) self.info_label = ttk.Label(info_frame, text="", font=("Arial", 9)) self.info_label.pack(side="left") close_btn = ttk.Button(info_frame, text="❌ Close", command=self.destroy) close_btn.pack(side="right", padx=5) def _configure_tags(self): """Configura i tag per evidenziare le differenze.""" # File A: linee cancellate in rosso più visibile self.text_a.tag_configure("delete", background="#ffd0d0", foreground="#000000") self.text_a.tag_configure("replace", background="#ffffb0", foreground="#000000") self.text_a.tag_configure("equal", background="#ffffff") self.text_a.tag_configure( "char_diff", background="#ff8888", foreground="#000000", font=("Courier", 9, "bold"), ) # File B: linee aggiunte in verde più visibile self.text_b.tag_configure("insert", background="#d0ffd0", foreground="#000000") self.text_b.tag_configure("replace", background="#ffffb0", foreground="#000000") self.text_b.tag_configure("equal", background="#ffffff") self.text_b.tag_configure( "char_diff", background="#88ff88", foreground="#000000", font=("Courier", 9, "bold"), ) # Numeri di riga self.text_a.tag_configure("linenum", foreground="#888888", font=("Courier", 8)) self.text_b.tag_configure("linenum", foreground="#888888", font=("Courier", 8)) self.text_a.tag_configure( "linenum_deleted", foreground="#dd0000", font=("Courier", 8, "bold") ) self.text_b.tag_configure( "linenum_added", foreground="#00aa00", font=("Courier", 8, "bold") ) self.text_a.tag_configure( "linenum_modified", foreground="#cc8800", font=("Courier", 8, "bold") ) self.text_b.tag_configure( "linenum_modified", foreground="#cc8800", font=("Courier", 8, "bold") ) def _populate_content(self): """Popola i text widget con il contenuto evidenziando le differenze.""" self.text_a.config(state="normal") self.text_b.config(state="normal") # Cancella contenuto precedente self.text_a.delete("1.0", "end") self.text_b.delete("1.0", "end") total_added = 0 total_deleted = 0 total_modified = 0 # Track minimap blocks with actual display line positions self.minimap_blocks = [] current_display_line = 0 # Processa ogni blocco di diff for tag, i1, i2, j1, j2 in self.diff_blocks: block_start_line = current_display_line if tag == "equal": # Linee uguali num_lines = i2 - i1 for i in range(i1, i2): line_num = f"{i+1:4d} | " self.text_a.insert("end", line_num, "linenum") self.text_a.insert("end", self.lines_a[i], "equal") for j in range(j1, j2): line_num = f"{j+1:4d} | " self.text_b.insert("end", line_num, "linenum") self.text_b.insert("end", self.lines_b[j], "equal") current_display_line += num_lines self.minimap_blocks.append( (tag, block_start_line, current_display_line) ) elif tag == "delete": # Linee cancellate (solo in A) num_lines = i2 - i1 total_deleted += num_lines for i in range(i1, i2): line_num = f"{i+1:4d} - " self.text_a.insert("end", line_num, "linenum_deleted") self.text_a.insert("end", self.lines_a[i], "delete") # Aggiungi linee vuote in B per mantenere allineamento for _ in range(num_lines): self.text_b.insert("end", " ~ \n", "equal") current_display_line += num_lines self.minimap_blocks.append( (tag, block_start_line, current_display_line) ) elif tag == "insert": # Linee aggiunte (solo in B) num_lines = j2 - j1 total_added += num_lines # Aggiungi linee vuote in A per mantenere allineamento for _ in range(num_lines): self.text_a.insert("end", " ~ \n", "equal") for j in range(j1, j2): line_num = f"{j+1:4d} + " self.text_b.insert("end", line_num, "linenum_added") self.text_b.insert("end", self.lines_b[j], "insert") current_display_line += num_lines self.minimap_blocks.append( (tag, block_start_line, current_display_line) ) elif tag == "replace": # Linee modificate - evidenzia anche differenze a livello carattere max_lines = max(i2 - i1, j2 - j1) total_modified += max_lines for k in range(max_lines): # File A if k < (i2 - i1): line_num = f"{i1+k+1:4d} ~ " self.text_a.insert("end", line_num, "linenum_modified") # Se esiste una corrispondente linea in B, evidenzia differenze a carattere if k < (j2 - j1): self._insert_line_with_char_diff( self.text_a, self.lines_a[i1 + k], self.lines_b[j1 + k], "replace", "char_diff", ) else: self.text_a.insert("end", self.lines_a[i1 + k], "replace") else: self.text_a.insert("end", " ~ \n", "equal") # File B if k < (j2 - j1): line_num = f"{j1+k+1:4d} ~ " self.text_b.insert("end", line_num, "linenum_modified") # Se esiste una corrispondente linea in A, evidenzia differenze a carattere if k < (i2 - i1): self._insert_line_with_char_diff( self.text_b, self.lines_b[j1 + k], self.lines_a[i1 + k], "replace", "char_diff", ) else: self.text_b.insert("end", self.lines_b[j1 + k], "replace") else: self.text_b.insert("end", " ~ \n", "equal") current_display_line += max_lines self.minimap_blocks.append( (tag, block_start_line, current_display_line) ) self.text_a.config(state="disabled") self.text_b.config(state="disabled") # Aggiorna info label info_text = f"Lines: {len(self.lines_a)} → {len(self.lines_b)} | " info_text += f"Added: {total_added} Deleted: {total_deleted} Modified: {total_modified}" self.info_label.config(text=info_text) # Disegna minimappa self._draw_minimap() def _draw_minimap(self): """Disegna la minimappa con rappresentazione visiva delle differenze.""" self.minimap.delete("all") # Use actual display lines (including padding lines) if not hasattr(self, "minimap_blocks") or not self.minimap_blocks: return # Total display lines is the end of the last block total_display_lines = self.minimap_blocks[-1][2] if self.minimap_blocks else 1 canvas_height = self.minimap.winfo_height() if canvas_height <= 1: canvas_height = 600 # Default se non ancora renderizzato canvas_width = self.minimap.winfo_width() if canvas_width <= 1: canvas_width = 50 # Calcola altezza per ogni linea nella minimappa line_height = canvas_height / max(total_display_lines, 1) # Disegna ogni blocco usando le posizioni di visualizzazione effettive for tag, start_line, end_line in self.minimap_blocks: y1 = start_line * line_height y2 = end_line * line_height # Colore basato sul tipo di differenza if tag == "delete": color = "#ff6666" # Rosso più intenso elif tag == "insert": color = "#66ff66" # Verde più intenso elif tag == "replace": color = "#ffff66" # Giallo più intenso else: color = "#f0f0f0" # Grigio chiaro per linee uguali self.minimap.create_rectangle( 0, y1, canvas_width, y2, fill=color, outline="" ) # Bind click sulla minimappa per navigare self.minimap.bind("", self._on_minimap_click) def _insert_line_with_char_diff( self, text_widget, line_current, line_other, line_tag, char_tag ): """ Inserisce una linea evidenziando le differenze a livello carattere. Args: text_widget: widget Text dove inserire line_current: linea corrente da inserire line_other: linea dell'altro file per confronto line_tag: tag per la linea intera char_tag: tag per caratteri differenti """ # Usa SequenceMatcher per confronto carattere per carattere sm = difflib.SequenceMatcher(a=line_other, b=line_current) for tag, i1, i2, j1, j2 in sm.get_opcodes(): chunk = line_current[j1:j2] if tag == "equal": # Caratteri identici text_widget.insert("end", chunk, line_tag) else: # Caratteri diversi (insert, delete, replace) text_widget.insert("end", chunk, (line_tag, char_tag)) def _on_minimap_click(self, event): """Gestisce il click sulla minimappa per navigare.""" if not hasattr(self, "minimap_blocks") or not self.minimap_blocks: return canvas_height = self.minimap.winfo_height() total_display_lines = self.minimap_blocks[-1][2] if self.minimap_blocks else 1 # Calcola la linea cliccata basandosi sulle linee di visualizzazione effettive clicked_ratio = event.y / canvas_height target_line = int(clicked_ratio * total_display_lines) + 1 # Scroll a quella linea self.text_a.see(f"{target_line}.0") self.text_b.see(f"{target_line}.0") def _setup_scroll_sync(self): """Configura la sincronizzazione dello scroll tra i due text widget.""" # Bind mouse wheel self.text_a.bind("", self._on_mousewheel) self.text_b.bind("", self._on_mousewheel) # Bind arrow keys self.text_a.bind("", lambda e: self._scroll_both("scroll", -1, "units")) self.text_a.bind("", lambda e: self._scroll_both("scroll", 1, "units")) self.text_b.bind("", lambda e: self._scroll_both("scroll", -1, "units")) self.text_b.bind("", lambda e: self._scroll_both("scroll", 1, "units")) def _on_scroll_a(self, *args): """Callback per scroll del text A.""" self.text_b.yview_moveto(args[0]) self._update_minimap_viewport() def _on_scroll_b(self, *args): """Callback per scroll del text B.""" self.text_a.yview_moveto(args[0]) self._update_minimap_viewport() def _on_scrollbar(self, *args): """Callback per scrollbar condivisa.""" self.text_a.yview(*args) self.text_b.yview(*args) self._update_minimap_viewport() def _on_mousewheel(self, event): """Gestisce scroll con mouse wheel.""" delta = -1 if event.delta > 0 else 1 self._scroll_both("scroll", delta, "units") return "break" def _scroll_both(self, *args): """Scrolla entrambi i text widget insieme.""" self.text_a.yview(*args) self.text_b.yview(*args) self._update_minimap_viewport() def _update_minimap_viewport(self): """Aggiorna il rettangolo del viewport sulla minimappa.""" # Rimuovi rettangolo precedente self.minimap.delete("viewport") if not hasattr(self, "minimap_blocks") or not self.minimap_blocks: return # Calcola posizione viewport basandosi sul totale delle linee yview = self.text_a.yview() canvas_height = self.minimap.winfo_height() canvas_width = self.minimap.winfo_width() # yview restituisce (frazione_inizio, frazione_fine) del documento # Usa queste frazioni direttamente sull'altezza del canvas y1 = yview[0] * canvas_height y2 = yview[1] * canvas_height # Assicurati che il viewport sia visibile (minimo 5 pixel) if y2 - y1 < 5: y2 = y1 + 5 # Disegna rettangolo viewport self.minimap.create_rectangle( 0, y1, canvas_width, y2, outline="#0066cc", width=2, tags="viewport" ) def show_diff_viewer( parent, file_a_path: Optional[str], file_b_path: Optional[str], title_a: str = "Baseline", title_b: str = "Current", ): """ Funzione helper per mostrare il diff viewer. Args: parent: Widget parent (tipicamente la finestra principale) file_a_path: Path del file baseline (può essere None) file_b_path: Path del file corrente (può essere None) title_a: Titolo per il file A title_b: Titolo per il file B """ viewer = DiffViewer(parent, file_a_path, file_b_path, title_a, title_b) viewer.grab_set() # Rende la finestra modale return viewer