SXXXXXXX_PyUCC/pyucc/gui/diff_viewer.py

367 lines
15 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)
# 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 chiaro
self.text_a.tag_configure('delete', background='#ffcccc', foreground='#800000')
self.text_a.tag_configure('replace', background='#ffffcc', foreground='#804000')
self.text_a.tag_configure('equal', background='#f8f8f8')
# File B: linee aggiunte in verde chiaro
self.text_b.tag_configure('insert', background='#ccffcc', foreground='#008000')
self.text_b.tag_configure('replace', background='#ffffcc', foreground='#804000')
self.text_b.tag_configure('equal', background='#f8f8f8')
# 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))
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
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')
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')
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('<Button-1>', self._on_minimap_click)
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('<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')
# 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