SXXXXXXX_PyUCC/pyucc/gui/diff_viewer.py

530 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