456 lines
20 KiB
Python
456 lines
20 KiB
Python
"""
|
|
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('<Configure>', 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('<Button-1>', 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('<MouseWheel>', self._on_mousewheel)
|
|
self.text_b.bind('<MouseWheel>', self._on_mousewheel)
|
|
|
|
# Bind arrow keys
|
|
self.text_a.bind('<Up>', lambda e: self._scroll_both('scroll', -1, 'units'))
|
|
self.text_a.bind('<Down>', lambda e: self._scroll_both('scroll', 1, 'units'))
|
|
self.text_b.bind('<Up>', lambda e: self._scroll_both('scroll', -1, 'units'))
|
|
self.text_b.bind('<Down>', 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
|