811 lines
35 KiB
Python
811 lines
35 KiB
Python
# --- START OF FILE diff_viewer.py ---
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, scrolledtext, Canvas, messagebox
|
|
import difflib
|
|
|
|
# Rimosso import logging
|
|
import os
|
|
import locale # Per fallback encoding
|
|
|
|
# Importa il nuovo gestore della coda log
|
|
import log_handler
|
|
|
|
# Importa GitCommands per interagire con Git
|
|
from git_commands import GitCommands, GitCommandError
|
|
|
|
|
|
class DiffViewerWindow(tk.Toplevel):
|
|
"""
|
|
Toplevel window to display a side-by-side diff of a file.
|
|
Shows differences between the working directory version and the HEAD version.
|
|
Includes synchronized scrolling via clicking on the overview minimap.
|
|
Uses log_handler for logging.
|
|
"""
|
|
|
|
def __init__(
|
|
self, master, git_commands: GitCommands, repo_path: str, file_status_line: str
|
|
):
|
|
"""
|
|
Initializes the Diff Viewer window.
|
|
|
|
Args:
|
|
master: The parent widget (usually the main Tkinter root).
|
|
git_commands (GitCommands): Instance of GitCommands for interacting with Git.
|
|
repo_path (str): Absolute path to the Git repository.
|
|
file_status_line (str): The full status line from 'git status --short'
|
|
for the file to be diffed (e.g., " M path/to/file.py").
|
|
"""
|
|
super().__init__(master)
|
|
func_name = "__init__" # Per i log nell'init
|
|
|
|
# Rimosso setup logger e fallback
|
|
# self.logger = logger (Rimosso)
|
|
|
|
# Setup GitCommands
|
|
if not isinstance(git_commands, GitCommands):
|
|
# Logga errore e solleva eccezione se git_commands non è valido
|
|
msg = "DiffViewerWindow requires a valid GitCommands instance."
|
|
log_handler.log_critical(msg, func_name=func_name)
|
|
# Potremmo anche mostrare un messagebox qui, ma l'eccezione è più standard
|
|
raise ValueError(msg)
|
|
self.git_commands = git_commands
|
|
|
|
# Validazione repo_path (base)
|
|
if not repo_path or not os.path.isdir(repo_path):
|
|
msg = f"Invalid repository path provided: '{repo_path}'"
|
|
log_handler.log_error(msg, func_name=func_name)
|
|
messagebox.showerror("Initialization Error", msg, parent=master)
|
|
self.after_idle(self.destroy) # Chiudi subito la finestra
|
|
return
|
|
self.repo_path = repo_path
|
|
|
|
# Pulisci e valida il percorso relativo del file dalla riga di stato
|
|
self.relative_file_path = self._clean_relative_path(file_status_line)
|
|
if not self.relative_file_path:
|
|
log_handler.log_error(
|
|
f"Cannot show diff: Invalid path from '{file_status_line}'.",
|
|
func_name=func_name,
|
|
)
|
|
messagebox.showerror(
|
|
"Path Error", "Invalid file path extracted for diff.", parent=master
|
|
)
|
|
self.after_idle(self.destroy) # Chiudi se percorso non valido
|
|
return # Esce dall'init
|
|
|
|
# Configurazione Finestra Toplevel
|
|
self.title(f"Diff - {os.path.basename(self.relative_file_path)}")
|
|
self.geometry("920x650") # Dimensioni finestra iniziali
|
|
self.minsize(550, 400) # Dimensioni minime consentite
|
|
self.grab_set() # Rendi modale rispetto al parent
|
|
self.transient(master) # Appare sopra il parent
|
|
|
|
# Stato interno
|
|
self.head_content_lines = []
|
|
self.workdir_content_lines = []
|
|
self.diff_map = (
|
|
[]
|
|
) # Mappa per minimappa: 0=uguale, 1=rimosso/mod, 2=aggiunto/mod
|
|
self._scrolling_active = False # Flag per prevenire eventi ricorsivi di scroll
|
|
self._configure_timer_id = None # ID per debounce resize minimap
|
|
|
|
# Costruisci l'interfaccia grafica
|
|
log_handler.log_debug("Creating diff viewer widgets...", func_name=func_name)
|
|
self._create_widgets()
|
|
|
|
# Carica il contenuto dei file e calcola/mostra il diff
|
|
log_handler.log_debug(
|
|
"Loading content and computing diff...", func_name=func_name
|
|
)
|
|
load_ok = False
|
|
try:
|
|
load_ok = self._load_content() # Carica HEAD e Workdir
|
|
if load_ok:
|
|
# Calcola e visualizza le differenze se il caricamento è andato a buon fine
|
|
self._compute_and_display_diff()
|
|
# Configura solo il click sulla minimappa per lo scroll
|
|
self._setup_scrolling()
|
|
else:
|
|
# Se _load_content fallisce, mostra errore nei widget di testo
|
|
log_handler.log_warning(
|
|
"Content loading failed, populating text widgets with error.",
|
|
func_name=func_name,
|
|
)
|
|
self._populate_text(
|
|
self.text_head, [("error", "<Error loading HEAD content>")]
|
|
)
|
|
self._populate_text(
|
|
self.text_workdir, [("error", "<Error loading Workdir content>")]
|
|
)
|
|
# Disegna comunque la minimappa (sarà vuota o con errore)
|
|
self.minimap_canvas.after(50, self._draw_minimap)
|
|
|
|
except Exception as load_err:
|
|
# Errore imprevisto durante il caricamento/diff
|
|
log_handler.log_exception(
|
|
f"Unexpected error during diff setup for '{self.relative_file_path}': {load_err}",
|
|
func_name=func_name,
|
|
)
|
|
messagebox.showerror(
|
|
"Fatal Error", f"Failed to display diff:\n{load_err}", parent=self
|
|
)
|
|
self.after_idle(self.destroy) # Chiudi in caso di errore grave
|
|
return # Esce dall'init
|
|
|
|
# Centra la finestra rispetto al parent dopo aver creato i widget
|
|
self._center_window(master)
|
|
log_handler.log_debug(
|
|
"DiffViewerWindow initialized successfully.", func_name=func_name
|
|
)
|
|
|
|
def _clean_relative_path(self, file_status_line):
|
|
"""Extracts a clean relative path from a git status line."""
|
|
func_name = "_clean_relative_path"
|
|
try:
|
|
# Pulisci caratteri null e spazi esterni
|
|
line = file_status_line.strip("\x00").strip()
|
|
if not line:
|
|
log_handler.log_warning(
|
|
"Received empty status line for cleaning.", func_name=func_name
|
|
)
|
|
return ""
|
|
|
|
# Trova il primo spazio per separare lo stato dal percorso
|
|
space_index = line.find(" ")
|
|
# Se non c'è spazio o è all'inizio/fine, formato non valido
|
|
if space_index <= 0 or space_index + 1 >= len(line):
|
|
log_handler.log_warning(
|
|
f"Could not find valid space separator in status line: '{line}'",
|
|
func_name=func_name,
|
|
)
|
|
return ""
|
|
|
|
# Estrai la parte del percorso dopo il primo spazio
|
|
relative_path_raw = line[space_index + 1 :].strip()
|
|
|
|
# Gestisci rinominati "XY orig -> new" (prendi 'new')
|
|
if "->" in relative_path_raw:
|
|
# Prendi la parte dopo l'ultima occorrenza di "->" e puliscila
|
|
relative_path = relative_path_raw.split("->")[-1].strip()
|
|
else:
|
|
relative_path = relative_path_raw
|
|
|
|
# Gestisci le virgolette se presenti (tipico se non si usa -z)
|
|
if (
|
|
len(relative_path) >= 2
|
|
and relative_path.startswith('"')
|
|
and relative_path.endswith('"')
|
|
):
|
|
relative_path = relative_path[1:-1]
|
|
# Gestisci eventuali escape interni se necessario (più complesso, omesso per ora)
|
|
|
|
# Controlla se il risultato è vuoto dopo la pulizia
|
|
if not relative_path:
|
|
log_handler.log_warning(
|
|
f"Extracted path is empty from status line: '{line}'",
|
|
func_name=func_name,
|
|
)
|
|
return ""
|
|
|
|
log_handler.log_debug(
|
|
f"Cleaned path from '{file_status_line}' -> '{relative_path}'",
|
|
func_name=func_name,
|
|
)
|
|
return relative_path
|
|
|
|
except Exception as e:
|
|
log_handler.log_exception(
|
|
f"Error cleaning path from status line '{file_status_line}': {e}",
|
|
func_name=func_name,
|
|
)
|
|
return "" # Ritorna vuoto in caso di errore
|
|
|
|
def _center_window(self, parent):
|
|
"""Centers the Toplevel window relative to its parent."""
|
|
func_name = "_center_window"
|
|
try:
|
|
self.update_idletasks() # Assicura dimensioni widget aggiornate
|
|
# Calcola coordinate
|
|
parent_x = parent.winfo_rootx()
|
|
parent_y = parent.winfo_rooty()
|
|
parent_w = parent.winfo_width()
|
|
parent_h = parent.winfo_height()
|
|
win_w = self.winfo_width()
|
|
win_h = self.winfo_height()
|
|
pos_x = parent_x + (parent_w // 2) - (win_w // 2)
|
|
pos_y = parent_y + (parent_h // 2) - (win_h // 2)
|
|
# Assicura visibilità sullo schermo
|
|
screen_w = self.winfo_screenwidth()
|
|
screen_h = self.winfo_screenheight()
|
|
pos_x = max(0, min(pos_x, screen_w - win_w))
|
|
pos_y = max(0, min(pos_y, screen_h - win_h))
|
|
# Imposta geometria
|
|
self.geometry(f"+{int(pos_x)}+{int(pos_y)}")
|
|
except Exception as e:
|
|
log_handler.log_error(
|
|
f"Could not center DiffViewerWindow: {e}", func_name=func_name
|
|
)
|
|
|
|
def _create_widgets(self):
|
|
"""Creates the main widgets for the diff view (NO main scrollbar)."""
|
|
main_frame = ttk.Frame(self, padding=5)
|
|
main_frame.pack(fill=tk.BOTH, expand=True)
|
|
main_frame.rowconfigure(1, weight=1) # Riga text widget si espande
|
|
# Colonne: Sinistra (HEAD), Destra (Workdir), Minimap
|
|
main_frame.columnconfigure(0, weight=1) # Colonna HEAD si espande
|
|
main_frame.columnconfigure(1, weight=1) # Colonna Workdir si espande
|
|
main_frame.columnconfigure(2, weight=0, minsize=40) # Minimap larghezza fissa
|
|
|
|
# Etichette Titoli
|
|
ttk.Label(main_frame, text="HEAD (Repository Version)").grid(
|
|
row=0, column=0, sticky="w", padx=(5, 2), pady=(0, 2)
|
|
)
|
|
ttk.Label(main_frame, text="Working Directory Version").grid(
|
|
row=0, column=1, sticky="w", padx=(2, 5), pady=(0, 2)
|
|
)
|
|
ttk.Label(main_frame, text="Overview").grid(
|
|
row=0, column=2, sticky="w", padx=(5, 0), pady=(0, 2)
|
|
)
|
|
|
|
# Widget Testo (senza scrollbar verticali individuali)
|
|
text_font = ("Consolas", 10) # Font Monospace suggerito
|
|
common_text_opts = {
|
|
"wrap": tk.NONE, # No a capo automatico
|
|
"font": text_font,
|
|
"padx": 5,
|
|
"pady": 5, # Padding interno
|
|
"undo": False, # Non serve undo in visualizzazione
|
|
"state": tk.DISABLED, # Inizia non modificabile
|
|
"borderwidth": 1, # Bordo
|
|
"relief": tk.SUNKEN,
|
|
}
|
|
self.text_head = tk.Text(main_frame, name="text_head", **common_text_opts)
|
|
self.text_workdir = tk.Text(main_frame, name="text_workdir", **common_text_opts)
|
|
|
|
self.text_head.grid(row=1, column=0, sticky="nsew", padx=(5, 2))
|
|
self.text_workdir.grid(row=1, column=1, sticky="nsew", padx=(2, 5))
|
|
|
|
# Minimap Canvas
|
|
self.minimap_canvas = Canvas(
|
|
main_frame,
|
|
width=40,
|
|
bg="#F0F0F0", # Colore sfondo leggermente grigio
|
|
relief=tk.SUNKEN,
|
|
borderwidth=1,
|
|
highlightthickness=0, # Aspetto
|
|
)
|
|
self.minimap_canvas.grid(row=1, column=2, sticky="ns", padx=(5, 0))
|
|
# Ridisegna minimap quando il canvas viene ridimensionato
|
|
self.minimap_canvas.bind("<Configure>", self._on_minimap_resize)
|
|
|
|
# Configurazione Tag per Highlighting Differenze
|
|
# Usiamo nomi tag semplici e colori distinti
|
|
self.text_head.tag_config(
|
|
"removed", background="#FFE0E0"
|
|
) # Rosso chiaro per righe rimosse/modificate
|
|
self.text_head.tag_config(
|
|
"empty", background="#F5F5F5", foreground="#A0A0A0"
|
|
) # Grigio per filler
|
|
self.text_workdir.tag_config(
|
|
"added", background="#E0FFE0"
|
|
) # Verde chiaro per righe aggiunte/modificate
|
|
self.text_workdir.tag_config(
|
|
"empty", background="#F5F5F5", foreground="#A0A0A0"
|
|
) # Grigio per filler
|
|
# Tag per errori (opzionale)
|
|
self.text_head.tag_config("error", background="yellow", foreground="red")
|
|
self.text_workdir.tag_config("error", background="yellow", foreground="red")
|
|
|
|
def _load_content(self):
|
|
"""Loads file content from HEAD and working directory. Uses log_handler."""
|
|
func_name = "_load_content"
|
|
log_handler.log_info(
|
|
f"Loading content for diff: {self.relative_file_path}", func_name=func_name
|
|
)
|
|
load_success = True # Flag per tracciare successo caricamento
|
|
|
|
# --- Carica Contenuto HEAD ---
|
|
try:
|
|
# Usa GitCommands per ottenere il contenuto da HEAD
|
|
head_content_raw = self.git_commands.get_file_content_from_ref(
|
|
self.repo_path, self.relative_file_path, ref="HEAD"
|
|
)
|
|
# Dividi in righe, gestisci caso None (file non in HEAD)
|
|
self.head_content_lines = (
|
|
head_content_raw.splitlines() if head_content_raw is not None else []
|
|
)
|
|
if head_content_raw is None:
|
|
# Non è un errore, ma un'informazione utile
|
|
log_handler.log_info(
|
|
f"File '{self.relative_file_path}' not found in HEAD (likely new).",
|
|
func_name=func_name,
|
|
)
|
|
except GitCommandError as e_git_head:
|
|
log_handler.log_error(
|
|
f"Git error loading HEAD content for '{self.relative_file_path}': {e_git_head}",
|
|
func_name=func_name,
|
|
)
|
|
self.head_content_lines = [f"<Error loading HEAD: {e_git_head}>"]
|
|
load_success = False
|
|
except Exception as e_head:
|
|
log_handler.log_exception(
|
|
f"Unexpected error loading HEAD for '{self.relative_file_path}': {e_head}",
|
|
func_name=func_name,
|
|
)
|
|
self.head_content_lines = [f"<Unexpected error loading HEAD: {e_head}>"]
|
|
load_success = False
|
|
|
|
# --- Carica Contenuto Working Directory ---
|
|
try:
|
|
# Costruisci percorso completo
|
|
workdir_full_path = os.path.join(self.repo_path, self.relative_file_path)
|
|
# Controlla se il file esiste sul disco
|
|
if os.path.exists(workdir_full_path) and os.path.isfile(workdir_full_path):
|
|
try:
|
|
# Prova a leggere con UTF-8 (encoding più comune)
|
|
with open(workdir_full_path, "r", encoding="utf-8") as f:
|
|
self.workdir_content_lines = f.read().splitlines()
|
|
except UnicodeDecodeError:
|
|
# Fallback se UTF-8 fallisce: prova encoding preferito dal sistema o latin-1
|
|
log_handler.log_warning(
|
|
f"UTF-8 decode failed for WD '{workdir_full_path}', trying fallback.",
|
|
func_name=func_name,
|
|
)
|
|
try:
|
|
# Ottieni encoding preferito (o usa latin-1 come ultima risorsa)
|
|
fallback_encoding = (
|
|
locale.getpreferredencoding(False) or "latin-1"
|
|
)
|
|
log_handler.log_debug(
|
|
f"Using fallback encoding: {fallback_encoding}",
|
|
func_name=func_name,
|
|
)
|
|
# Leggi con fallback encoding, rimpiazza caratteri non decodificabili
|
|
with open(
|
|
workdir_full_path,
|
|
"r",
|
|
encoding=fallback_encoding,
|
|
errors="replace",
|
|
) as f:
|
|
self.workdir_content_lines = f.read().splitlines()
|
|
except Exception as fb_e:
|
|
# Se anche il fallback fallisce
|
|
log_handler.log_error(
|
|
f"Working directory fallback read failed for '{workdir_full_path}': {fb_e}",
|
|
func_name=func_name,
|
|
)
|
|
self.workdir_content_lines = [
|
|
f"<Error reading WD with fallback: {fb_e}>"
|
|
]
|
|
load_success = False
|
|
else:
|
|
# File non trovato sul disco (es. cancellato localmente)
|
|
log_handler.log_info(
|
|
f"Working directory file not found: {workdir_full_path}",
|
|
func_name=func_name,
|
|
)
|
|
self.workdir_content_lines = [] # Lista vuota se file non esiste
|
|
except Exception as e_wd:
|
|
log_handler.log_exception(
|
|
f"Error loading Workdir content for '{self.relative_file_path}': {e_wd}",
|
|
func_name=func_name,
|
|
)
|
|
self.workdir_content_lines = [f"<Error loading Workdir: {e_wd}>"]
|
|
load_success = False
|
|
|
|
return load_success
|
|
|
|
def _compute_and_display_diff(self):
|
|
"""Calculates diff using SequenceMatcher and populates text widgets."""
|
|
func_name = "_compute_and_display_diff"
|
|
log_handler.log_debug("Computing and displaying diff...", func_name=func_name)
|
|
|
|
# Caso base: nessun contenuto da confrontare
|
|
if not self.head_content_lines and not self.workdir_content_lines:
|
|
log_handler.log_warning(
|
|
"Both HEAD and Workdir content are empty or failed to load.",
|
|
func_name=func_name,
|
|
)
|
|
# Popola con messaggi appropriati
|
|
self._populate_text(self.text_head, [("empty", "(No content in HEAD)")])
|
|
self._populate_text(
|
|
self.text_workdir, [("empty", "(No content in Workdir)")]
|
|
)
|
|
self.diff_map = [] # Mappa vuota
|
|
# Richiama disegno minimappa (che sarà vuota)
|
|
self.minimap_canvas.after(50, self._draw_minimap)
|
|
return
|
|
|
|
# Usa SequenceMatcher per trovare le differenze
|
|
# autojunk=False considera tutte le righe, anche quelle "rumorose"
|
|
matcher = difflib.SequenceMatcher(
|
|
None, self.head_content_lines, self.workdir_content_lines, autojunk=False
|
|
)
|
|
|
|
# Liste per contenere le righe da visualizzare, allineate
|
|
head_lines_display = []
|
|
workdir_lines_display = []
|
|
# Mappa per la minimappa (0: uguale, 1: rimosso/modificato, 2: aggiunto/modificato)
|
|
diff_map_for_minimap = []
|
|
|
|
log_handler.log_debug(
|
|
"Generating aligned display lines using SequenceMatcher opcodes...",
|
|
func_name=func_name,
|
|
)
|
|
# Itera sugli opcodes generati da SequenceMatcher
|
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
# Estrai i chunk di righe corrispondenti dagli array originali
|
|
head_chunk = self.head_content_lines[i1:i2]
|
|
workdir_chunk = self.workdir_content_lines[j1:j2]
|
|
|
|
if tag == "equal":
|
|
# Righe uguali: aggiungi entrambe alle liste display senza tag specifici
|
|
for k in range(len(head_chunk)):
|
|
head_lines_display.append(
|
|
("equal", head_chunk[k])
|
|
) # Usa 'equal' o '' per nessun tag speciale
|
|
workdir_lines_display.append(("equal", workdir_chunk[k]))
|
|
diff_map_for_minimap.append(0) # 0 = uguale
|
|
elif tag == "delete":
|
|
# Righe presenti solo in HEAD (rimosse in Workdir)
|
|
for k in range(len(head_chunk)):
|
|
head_lines_display.append(
|
|
("removed", head_chunk[k])
|
|
) # Tag 'removed' per HEAD
|
|
workdir_lines_display.append(
|
|
("empty", "")
|
|
) # Riga vuota filler per Workdir
|
|
diff_map_for_minimap.append(1) # 1 = deleted/modified head side
|
|
elif tag == "insert":
|
|
# Righe presenti solo in Workdir (aggiunte rispetto a HEAD)
|
|
for k in range(len(workdir_chunk)):
|
|
head_lines_display.append(
|
|
("empty", "")
|
|
) # Riga vuota filler per HEAD
|
|
workdir_lines_display.append(
|
|
("added", workdir_chunk[k])
|
|
) # Tag 'added' per Workdir
|
|
diff_map_for_minimap.append(2) # 2 = added/modified workdir side
|
|
elif tag == "replace":
|
|
# Righe modificate (diverse tra HEAD e Workdir)
|
|
len_head = len(head_chunk)
|
|
len_workdir = len(workdir_chunk)
|
|
max_len = max(
|
|
len_head, len_workdir
|
|
) # Lunghezza massima tra i due chunk
|
|
# Itera per allineare le righe modificate, riempiendo con righe vuote se necessario
|
|
for k in range(max_len):
|
|
head_line = head_chunk[k] if k < len_head else ""
|
|
workdir_line = workdir_chunk[k] if k < len_workdir else ""
|
|
# Determina il tag ('removed'/'added' o 'empty' per filler)
|
|
head_tag = "removed" if k < len_head else "empty"
|
|
workdir_tag = "added" if k < len_workdir else "empty"
|
|
# Aggiungi alle liste display
|
|
head_lines_display.append((head_tag, head_line))
|
|
workdir_lines_display.append((workdir_tag, workdir_line))
|
|
# Segna come modificato sulla minimappa (rosso o verde)
|
|
# Usa 1 (rosso) se la riga HEAD esiste, altrimenti 2 (verde)
|
|
minimap_code = 1 if head_tag != "empty" else 2
|
|
diff_map_for_minimap.append(minimap_code)
|
|
|
|
# Verifica coerenza interna (opzionale ma utile per debug)
|
|
if len(head_lines_display) != len(workdir_lines_display) or len(
|
|
head_lines_display
|
|
) != len(diff_map_for_minimap):
|
|
log_handler.log_error(
|
|
"Internal Diff Error: Mismatch in aligned line counts!",
|
|
func_name=func_name,
|
|
)
|
|
# Potrebbe essere utile mostrare un errore all'utente qui
|
|
else:
|
|
log_handler.log_debug(
|
|
f"Aligned display generated with {len(head_lines_display)} lines.",
|
|
func_name=func_name,
|
|
)
|
|
|
|
# Aggiorna la mappa per la minimappa
|
|
self.diff_map = diff_map_for_minimap
|
|
|
|
# Popola i widget di testo con le righe allineate e i tag
|
|
log_handler.log_debug("Populating text widgets...", func_name=func_name)
|
|
self._populate_text(self.text_head, head_lines_display)
|
|
self._populate_text(self.text_workdir, workdir_lines_display)
|
|
|
|
# Disegna la minimappa (con un leggero ritardo per permettere rendering widget)
|
|
log_handler.log_debug("Scheduling minimap draw.", func_name=func_name)
|
|
self.minimap_canvas.after(100, self._draw_minimap) # Ritardo 100ms
|
|
|
|
def _populate_text(self, text_widget, lines_data):
|
|
"""Populates a text widget with lines data, applying tags."""
|
|
func_name = "_populate_text"
|
|
widget_name = text_widget.winfo_name() # Ottieni nome widget per log
|
|
log_handler.log_debug(
|
|
f"Populating widget '{widget_name}' with {len(lines_data)} lines.",
|
|
func_name=func_name,
|
|
)
|
|
|
|
# Abilita widget per modifica
|
|
text_widget.config(state=tk.NORMAL)
|
|
# Pulisci contenuto precedente
|
|
text_widget.delete("1.0", tk.END)
|
|
|
|
if not lines_data:
|
|
# Caso senza dati
|
|
log_handler.log_debug(
|
|
f"No lines data provided for widget '{widget_name}'.",
|
|
func_name=func_name,
|
|
)
|
|
text_widget.insert(
|
|
"1.0", "(No content)\n", ("empty",)
|
|
) # Mostra messaggio vuoto con tag 'empty'
|
|
else:
|
|
# Costruisci la stringa completa e raccogli i tag da applicare
|
|
content_string = ""
|
|
tags_to_apply = [] # Lista di tuple: (tag_name, start_index, end_index)
|
|
current_line_num = 1 # Tkinter usa indice 1-based per le righe
|
|
|
|
for data_tuple in lines_data:
|
|
# Valida formato dati (deve essere tupla tag, contenuto)
|
|
if not isinstance(data_tuple, tuple) or len(data_tuple) != 2:
|
|
log_handler.log_warning(
|
|
f"Skipping malformed data in '{widget_name}': {data_tuple}",
|
|
func_name=func_name,
|
|
)
|
|
tag_code = "error" # Tag per indicare errore interno
|
|
line_content = "<Internal Error: Bad data format>\n"
|
|
else:
|
|
tag_code, content = data_tuple
|
|
# Assicura newline per il widget Text
|
|
line_content = content + "\n"
|
|
|
|
# Aggiungi il contenuto della riga alla stringa completa
|
|
content_string += line_content
|
|
|
|
# Calcola indici Tkinter per la riga corrente (es. "1.0", "1.end")
|
|
start_index = f"{current_line_num}.0"
|
|
end_index = f"{current_line_num}.end" # .end si riferisce alla fine logica della linea
|
|
|
|
# Aggiungi tag alla lista se il codice corrisponde a un tag definito
|
|
if tag_code == "removed":
|
|
tags_to_apply.append(("removed", start_index, end_index))
|
|
elif tag_code == "added":
|
|
tags_to_apply.append(("added", start_index, end_index))
|
|
elif tag_code == "empty":
|
|
tags_to_apply.append(("empty", start_index, end_index))
|
|
elif tag_code == "error":
|
|
tags_to_apply.append(("error", start_index, end_index))
|
|
# Ignora tag 'equal' o altri codici non mappati
|
|
|
|
# Incrementa numero linea per la prossima iterazione
|
|
current_line_num += 1
|
|
|
|
# Inserisci tutto il testo in una volta sola (più efficiente)
|
|
text_widget.insert("1.0", content_string)
|
|
|
|
# Applica tutti i tag raccolti in un secondo momento
|
|
for tag_name, start, end in tags_to_apply:
|
|
try:
|
|
# Applica il tag agli indici specificati
|
|
text_widget.tag_add(tag_name, start, end)
|
|
except tk.TclError as tag_err:
|
|
# Logga errore se l'applicazione del tag fallisce
|
|
log_handler.log_error(
|
|
f"Error applying tag '{tag_name}' from {start} to {end}: {tag_err}",
|
|
func_name=func_name,
|
|
)
|
|
|
|
# Disabilita widget dopo aver popolato (read-only)
|
|
text_widget.config(state=tk.DISABLED)
|
|
# Assicura che la vista sia all'inizio del testo
|
|
text_widget.yview_moveto(0.0)
|
|
log_handler.log_debug(
|
|
f"Finished populating widget '{widget_name}'.", func_name=func_name
|
|
)
|
|
|
|
def _draw_minimap(self):
|
|
"""Draws the minimap overview based on self.diff_map."""
|
|
func_name = "_draw_minimap"
|
|
canvas = self.minimap_canvas
|
|
# Pulisci elementi precedenti (linee diff e indicatore viewport)
|
|
canvas.delete("diff_line")
|
|
canvas.delete("viewport_indicator")
|
|
|
|
num_lines = len(self.diff_map)
|
|
if num_lines == 0:
|
|
log_handler.log_debug(
|
|
"No diff map data for minimap drawing.", func_name=func_name
|
|
)
|
|
return # Niente da disegnare
|
|
|
|
# Assicura che le dimensioni del canvas siano valide prima di disegnare
|
|
canvas.update_idletasks() # Aggiorna dimensioni widget
|
|
canvas_width = canvas.winfo_width()
|
|
canvas_height = canvas.winfo_height()
|
|
|
|
# Non disegnare se il canvas non ha dimensioni valide (es. finestra minimizzata)
|
|
if canvas_width <= 1 or canvas_height <= 1:
|
|
log_handler.log_debug(
|
|
f"Skipping minimap draw due to invalid dimensions: {canvas_width}x{canvas_height}",
|
|
func_name=func_name,
|
|
)
|
|
return
|
|
|
|
log_handler.log_debug(
|
|
f"Drawing minimap ({canvas_width}x{canvas_height}) for {num_lines} lines.",
|
|
func_name=func_name,
|
|
)
|
|
|
|
# Calcola altezza per linea (può essere < 1 pixel)
|
|
exact_line_height = float(canvas_height) / num_lines
|
|
|
|
# Usa altezza accumulata per posizionamento preciso e arrotondamento
|
|
accumulated_height = 0.0
|
|
for i, diff_type in enumerate(self.diff_map):
|
|
# Arrotonda y iniziale e finale per disegno pixel
|
|
y_start = round(accumulated_height)
|
|
accumulated_height += exact_line_height
|
|
y_end = round(accumulated_height)
|
|
|
|
# Assicura altezza minima di 1 pixel per visibilità
|
|
if y_end <= y_start:
|
|
y_end = y_start + 1
|
|
|
|
# Forza ultima linea a raggiungere il bordo inferiore del canvas
|
|
if i == num_lines - 1:
|
|
y_end = canvas_height
|
|
|
|
# Determina colore linea
|
|
# 0 = uguale (grigio sfondo), 1 = rimosso/mod (rosso), 2 = aggiunto/mod (verde)
|
|
color = "#F0F0F0" # Grigio default per uguale
|
|
if diff_type == 1:
|
|
color = "#FFD0D0" # Rosso chiaro
|
|
elif diff_type == 2:
|
|
color = "#D0FFD0" # Verde chiaro
|
|
|
|
# Disegna rettangolo per la linea senza bordo
|
|
canvas.create_rectangle(
|
|
0,
|
|
y_start,
|
|
canvas_width,
|
|
y_end,
|
|
fill=color,
|
|
outline="",
|
|
tags="diff_line",
|
|
)
|
|
|
|
# Disegna/aggiorna l'indicatore della viewport
|
|
self._update_minimap_viewport()
|
|
log_handler.log_debug("Minimap drawing complete.", func_name=func_name)
|
|
|
|
# --- Scrolling Logic (Minimap Click Only) ---
|
|
|
|
def _setup_scrolling(self):
|
|
"""Configures ONLY minimap click for navigation."""
|
|
# Associa evento click sinistro sul canvas alla funzione di scroll
|
|
self.minimap_canvas.bind("<Button-1>", self._on_minimap_click)
|
|
# Nessun altro binding necessario (no scrollbar, no yscrollcommand)
|
|
|
|
def _reset_scroll_flag(self):
|
|
"""Resets the scrolling flag to allow next scroll event."""
|
|
self._scrolling_active = False
|
|
|
|
def _update_minimap_viewport(self):
|
|
"""Updates the indicator rectangle on the minimap showing current view."""
|
|
func_name = "_update_minimap_viewport"
|
|
canvas = self.minimap_canvas
|
|
# Rimuovi indicatore precedente
|
|
canvas.delete("viewport_indicator")
|
|
try:
|
|
# Ottieni frazioni y visibili (inizio, fine) da un widget testo
|
|
# Usiamo text_head, ma dovrebbero essere sincronizzati
|
|
first_visible_fraction, last_visible_fraction = self.text_head.yview()
|
|
except tk.TclError:
|
|
# Gestisci errore se il widget testo è distrutto
|
|
log_handler.log_warning(
|
|
"TclError getting text yview, cannot update minimap viewport.",
|
|
func_name=func_name,
|
|
)
|
|
return
|
|
|
|
# Assicura dimensioni canvas valide
|
|
canvas.update_idletasks()
|
|
canvas_height = canvas.winfo_height()
|
|
canvas_width = canvas.winfo_width()
|
|
if canvas_height <= 1 or canvas_width <= 1:
|
|
log_handler.log_debug(
|
|
"Canvas not ready for viewport indicator update.", func_name=func_name
|
|
)
|
|
return
|
|
|
|
# Calcola coordinate y del rettangolo indicatore
|
|
y_start = first_visible_fraction * canvas_height
|
|
y_end = last_visible_fraction * canvas_height
|
|
# Assicura altezza minima di 1 pixel per visibilità
|
|
if y_end <= y_start:
|
|
y_end = y_start + 1
|
|
|
|
# Disegna il nuovo rettangolo indicatore con bordo nero
|
|
canvas.create_rectangle(
|
|
1,
|
|
y_start,
|
|
canvas_width - 1,
|
|
y_end, # Margine laterale 1px
|
|
outline="black",
|
|
width=1, # Bordo nero sottile
|
|
tags="viewport_indicator", # Tag per poterlo cancellare
|
|
)
|
|
# Assicura che l'indicatore sia disegnato sopra le linee colorate
|
|
canvas.tag_raise("viewport_indicator")
|
|
|
|
def _on_minimap_click(self, event):
|
|
"""Handles clicks on the minimap to scroll the text views."""
|
|
func_name = "_on_minimap_click"
|
|
# Ignora click se uno scroll è già in corso (previene eventi multipli)
|
|
if self._scrolling_active:
|
|
return
|
|
self._scrolling_active = True # Imposta flag blocco
|
|
|
|
try:
|
|
canvas = self.minimap_canvas
|
|
canvas_height = canvas.winfo_height()
|
|
# Non fare nulla se altezza non valida
|
|
if canvas_height <= 1:
|
|
self._scrolling_active = False # Resetta flag se esce subito
|
|
return
|
|
|
|
# Calcola la frazione verticale del click rispetto all'altezza canvas
|
|
target_fraction = event.y / canvas_height
|
|
# Limita la frazione tra 0.0 e 1.0
|
|
target_fraction = max(0.0, min(target_fraction, 1.0))
|
|
|
|
log_handler.log_debug(
|
|
f"Minimap clicked at y={event.y}, fraction={target_fraction:.3f}",
|
|
func_name=func_name,
|
|
)
|
|
|
|
# Muovi entrambi i widget di testo a quella frazione
|
|
# yview_moveto imposta la *prima* linea visibile a quella frazione
|
|
self.text_head.yview_moveto(target_fraction)
|
|
self.text_workdir.yview_moveto(target_fraction)
|
|
|
|
# Aggiorna l'indicatore sulla minimappa per riflettere la nuova vista
|
|
self._update_minimap_viewport()
|
|
|
|
finally:
|
|
# Resetta il flag dopo un breve ritardo per evitare loop o blocchi
|
|
# Usiamo after per assicurarci che venga eseguito nell'event loop Tkinter
|
|
self.after(50, self._reset_scroll_flag) # Ritardo 50ms
|
|
|
|
# --- Minimap Resize Handling ---
|
|
|
|
def _on_minimap_resize(self, event):
|
|
"""Called when the minimap canvas is resized. Schedules redraw (debounce)."""
|
|
func_name = "_on_minimap_resize"
|
|
# Cancella timer precedente se esiste per evitare ridisegni multipli
|
|
if self._configure_timer_id:
|
|
self.after_cancel(self._configure_timer_id)
|
|
log_handler.log_debug(
|
|
"Cancelled previous minimap resize timer.", func_name=func_name
|
|
)
|
|
|
|
# Pianifica il ridisegno dopo un breve ritardo (es. 150ms)
|
|
# Questo evita ridisegni continui durante il trascinamento della finestra
|
|
self._configure_timer_id = self.after(150, self._redraw_minimap_on_resize)
|
|
log_handler.log_debug(
|
|
f"Scheduled minimap redraw timer: {self._configure_timer_id}",
|
|
func_name=func_name,
|
|
)
|
|
|
|
def _redraw_minimap_on_resize(self):
|
|
"""Actually redraws the minimap. Called by the debounced timer."""
|
|
func_name = "_redraw_minimap_on_resize"
|
|
log_handler.log_debug(
|
|
"Executing debounced minimap redraw due to resize.", func_name=func_name
|
|
)
|
|
self._configure_timer_id = None # Resetta ID timer
|
|
# Chiama la funzione di disegno esistente
|
|
self._draw_minimap()
|
|
|
|
|
|
# --- END OF FILE diff_viewer.py ---
|