""" 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) # 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='#ffb3b3', foreground='#000000') self.text_a.tag_configure('replace', background='#fff799', foreground='#000000') self.text_a.tag_configure('equal', background='#ffffff') self.text_a.tag_configure('char_diff', background='#ff6666', foreground='#000000', font=('Courier', 9, 'bold')) # File B: linee aggiunte in verde più visibile self.text_b.tag_configure('insert', background='#b3ffb3', foreground='#000000') self.text_b.tag_configure('replace', background='#fff799', foreground='#000000') self.text_b.tag_configure('equal', background='#ffffff') self.text_b.tag_configure('char_diff', background='#66ff66', foreground='#000000', font=('Courier', 9, 'bold')) # Numeri di riga self.text_a.tag_configure('linenum', foreground='#666666', font=('Courier', 8, 'bold')) self.text_b.tag_configure('linenum', foreground='#666666', font=('Courier', 8, 'bold')) self.text_a.tag_configure('linenum_modified', foreground='#cc6600', font=('Courier', 8, 'bold')) self.text_b.tag_configure('linenum_modified', foreground='#cc6600', 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 # Processa ogni blocco di diff for tag, i1, i2, j1, j2 in self.diff_blocks: if tag == 'equal': # Linee uguali 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') elif tag == 'delete': # Linee cancellate (solo in A) total_deleted += (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], 'delete') # Aggiungi linee vuote in B per mantenere allineamento for _ in range(i2 - i1): self.text_b.insert('end', " ~ \n", 'equal') elif tag == 'insert': # Linee aggiunte (solo in B) total_added += (j2 - j1) # Aggiungi linee vuote in A per mantenere allineamento for _ in range(j2 - j1): 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') self.text_b.insert('end', self.lines_b[j], 'insert') elif tag == 'replace': # Linee modificate - evidenzia anche differenze a livello carattere total_modified += max(i2 - i1, j2 - j1) max_lines = max(i2 - i1, j2 - j1) 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') 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') total_lines = max(len(self.lines_a), len(self.lines_b), 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 = max(1, canvas_height / total_lines) current_line = 0 # Disegna ogni blocco di diff sulla minimappa for tag, i1, i2, j1, j2 in self.diff_blocks: num_lines = max(i2 - i1, j2 - j1) y1 = current_line * line_height y2 = (current_line + num_lines) * line_height # Colore basato sul tipo di differenza if tag == 'delete': color = '#ff9999' # Rosso elif tag == 'insert': color = '#99ff99' # Verde elif tag == 'replace': color = '#ffff99' # Giallo else: color = '#e8e8e8' # Grigio chiaro per linee uguali self.minimap.create_rectangle(0, y1, canvas_width, y2, fill=color, outline='') current_line += num_lines # 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.""" canvas_height = self.minimap.winfo_height() total_lines = max(len(self.lines_a), len(self.lines_b), 1) # Calcola la linea cliccata clicked_ratio = event.y / canvas_height target_line = int(clicked_ratio * total_lines) # 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') # Calcola posizione viewport yview = self.text_a.yview() canvas_height = self.minimap.winfo_height() y1 = yview[0] * canvas_height y2 = yview[1] * canvas_height canvas_width = self.minimap.winfo_width() # 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