SXXXXXXX_GitUtility/gitutility/gui/diff_viewer.py

937 lines
39 KiB
Python

import tkinter as tk
from tkinter import ttk, scrolledtext, Canvas, messagebox
import difflib
import os
import locale
import logging # Solo per livelli
from typing import List, Optional, TYPE_CHECKING # Aggiunto TYPE_CHECKING
from gitutility.logging_setup import log_handler
from gitutility.gui.tooltip import Tooltip
# Importa GitCommands dal nuovo percorso (con forward declaration)
try:
from gitutility.commands.git_commands import GitCommands, GitCommandError
except ImportError:
# Fallback nel caso estremamente raro che l'import fallisca
# (dovrebbe indicare un problema più grave di struttura o PYTHONPATH)
print(
"FATAL ERROR: Could not import GitCommands from gitsync_tool.commands in diff_viewer.py",
file=sys.stderr,
)
# Definisci placeholder per permettere al codice di caricare, ma fallirà dopo
GitCommands = object # Un tipo generico per evitare NameError immediato
GitCommandError = Exception
# --- Tooltip Class Definition (Copiata da gui.py per standalone, o importata) ---
# (Assumiamo sia definita qui o importata correttamente se messa in un file separato)
class Tooltip:
"""Simple tooltip implementation for Tkinter widgets."""
def __init__(self, widget, text):
self.widget = widget
self.text = text
self.tooltip_window = None
self.id = None
if self.widget and self.widget.winfo_exists():
# Usiamo add='+' per non sovrascrivere altri binding Enter/Leave
self.widget.bind("<Enter>", self.enter, add="+")
self.widget.bind("<Leave>", self.leave, add="+")
self.widget.bind("<ButtonPress>", self.leave, add="+") # Nasconde su click
def enter(self, event=None):
self.unschedule()
# Pianifica la visualizzazione dopo un ritardo (es. 500ms)
self.id = self.widget.after(500, self.showtip)
def leave(self, event=None):
self.unschedule()
self.hidetip()
def unschedule(self):
id_to_cancel = self.id
self.id = None
if id_to_cancel:
try:
# Necessario controllare che widget esista prima di chiamare after_cancel
if self.widget and self.widget.winfo_exists():
self.widget.after_cancel(id_to_cancel)
except Exception:
# Ignora errori se il widget è già distrutto
pass
def showtip(self):
# Non mostrare se il widget non esiste più
if not self.widget or not self.widget.winfo_exists():
return
# Nascondi tooltip precedente se esiste
self.hidetip()
# Calcola posizione tooltip vicino al cursore o al widget
x_cursor, y_cursor = 0, 0
try:
# Prova a ottenere posizione cursore
x_cursor = self.widget.winfo_pointerx() + 15
y_cursor = self.widget.winfo_pointery() + 10
except Exception:
# Fallback: posizione relativa al widget
try:
x_root = self.widget.winfo_rootx()
y_root = self.widget.winfo_rooty()
x_cursor = x_root + self.widget.winfo_width() // 2
y_cursor = y_root + self.widget.winfo_height() + 5
except Exception:
# Impossibile determinare posizione
return
# Crea la finestra Toplevel per il tooltip
self.tooltip_window = tw = tk.Toplevel(self.widget)
# Rimuovi decorazioni finestra (titolo, bordi standard)
tw.wm_overrideredirect(True)
try:
# Posiziona la finestra
tw.wm_geometry(f"+{int(x_cursor)}+{int(y_cursor)}")
except tk.TclError:
# Può fallire se le coordinate sono fuori schermo
tw.destroy()
self.tooltip_window = None
return
# Crea etichetta dentro la finestra Toplevel
label = tk.Label(
tw,
text=self.text,
justify=tk.LEFT,
background="#ffffe0", # Sfondo giallo chiaro
relief=tk.SOLID,
borderwidth=1,
font=("tahoma", "8", "normal"), # Font piccolo standard
wraplength=350, # Larghezza massima prima di andare a capo
)
label.pack(ipadx=3, ipady=3) # Piccolo padding interno
def hidetip(self):
tw = self.tooltip_window
self.tooltip_window = None
if tw:
try:
# Controlla se la finestra esiste prima di distruggerla
if tw.winfo_exists():
tw.destroy()
except Exception:
# Ignora errori se la finestra è già distrutta
pass
class DiffViewerWindow(tk.Toplevel):
"""
Toplevel window to display a side-by-side diff of a file.
Can show differences between working dir vs HEAD/Index, or between two Git references.
Includes synchronized scrolling via clicking on the overview minimap.
Uses log_handler for logging.
"""
def __init__(
self,
master,
git_commands: GitCommands,
repo_path: str,
relative_file_path: str, # Path relativo del file
ref1: str, # Riferimento Sinistra (es. 'WORKING_DIR', 'HEAD', 'master', 'origin/dev')
ref2: str, # Riferimento Destra (es. 'HEAD', 'origin/main', commit_hash)
):
"""
Initializes the Diff Viewer window.
Args:
master: The parent widget.
git_commands (GitCommands): Instance for Git interaction.
repo_path (str): Absolute path to the Git repository.
relative_file_path (str): Relative path of the file within the repo.
ref1 (str): Reference for the left pane (e.g., 'WORKING_DIR', branch, tag, commit).
ref2 (str): Reference for the right pane (e.g., 'HEAD', branch, tag, commit).
"""
super().__init__(master)
func_name = "__init__ (DiffViewer)" # Nome per logging
# --- Validazione Input Base ---
if not isinstance(git_commands, GitCommands):
# Logga errore critico 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 mostrare messagebox qui, ma ValueError è più standard per argomenti
raise ValueError(msg)
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)
raise ValueError(msg)
if not relative_file_path:
msg = "Relative file path cannot be empty."
log_handler.log_error(msg, func_name=func_name)
raise ValueError(msg)
if not ref1 or not ref2:
msg = "Both ref1 and ref2 must be specified for comparison."
log_handler.log_error(msg, func_name=func_name)
raise ValueError(msg)
# Memorizza argomenti
self.git_commands = git_commands
self.repo_path = repo_path
self.relative_file_path = relative_file_path
self.ref1 = ref1
self.ref2 = ref2
# Nomi brevi per visualizzazione nelle etichette e messaggi
self.ref1_display_name = (
"Working Dir" if self.ref1 == "WORKING_DIR" else self.ref1
)
self.ref2_display_name = (
"Working Dir" if self.ref2 == "WORKING_DIR" else self.ref2
)
log_handler.log_info(
f"Opening diff for '{self.relative_file_path}': Comparing '{self.ref1_display_name}' (Left) vs '{self.ref2_display_name}' (Right)",
func_name=func_name,
)
# --- Configurazione Finestra Toplevel ---
self.title(f"Diff - {os.path.basename(self.relative_file_path)}")
self.geometry("920x650") # Dimensioni iniziali suggerite
self.minsize(550, 400) # Dimensioni minime
self.grab_set() # Rende modale rispetto al parent
self.transient(master) # Appare sopra il parent
# --- Stato Interno ---
self.pane1_content_lines: List[str] = [] # Contenuto sinistra
self.pane2_content_lines: List[str] = [] # Contenuto destra
self.diff_map: List[int] = [] # Mappa per minimap
self._scrolling_active: bool = False # Flag per scroll sincronizzato
self._configure_timer_id = None # ID per debounce resize minimap
self.loading_label: Optional[ttk.Label] = None
# --- Costruzione Interfaccia Grafica ---
log_handler.log_debug("Creating diff viewer widgets...", func_name=func_name)
self._create_widgets(self.ref1_display_name, self.ref2_display_name)
# --- Caricamento Contenuti e Calcolo Diff ---
log_handler.log_debug(
"Loading content and computing diff...", func_name=func_name
)
load_ok = False
try:
self._show_loading_message()
# Forza l'aggiornamento della GUI per mostrare il messaggio prima del blocco
self.update_idletasks()
load_ok = self._load_content() # Usa self.ref1 e self.ref2 internamente
if load_ok:
# Se il caricamento (anche parziale, es. file non trovato) è ok, calcola diff
self._compute_and_display_diff()
# Imposta lo scrolling sincronizzato (solo minimap click)
self._setup_scrolling()
else:
# Se _load_content ritorna False (errore critico lettura/git), mostra errore
log_handler.log_warning(
"Content loading failed critically, populating text widgets with error.",
func_name=func_name,
)
self._populate_text(
self.text_pane1,
[
(
"error",
f"<Error loading content for '{self.ref1_display_name}'>",
)
],
)
self._populate_text(
self.text_pane2,
[
(
"error",
f"<Error loading content for '{self.ref2_display_name}'>",
)
],
)
# Disegna comunque minimappa vuota
self.minimap_canvas.after(50, self._draw_minimap)
self._hide_loading_message()
except Exception as load_err:
# Errore imprevisto durante caricamento o calcolo diff
self._hide_loading_message()
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
)
# Chiudi la finestra se l'inizializzazione fallisce gravemente
self.after_idle(self.destroy)
return # Esce da __init__
# Centra la finestra dopo che i widget sono stati creati
self._center_window(master)
log_handler.log_debug(
"DiffViewerWindow initialized successfully.", func_name=func_name
)
def _center_window(self, parent):
"""Centers the Toplevel window relative to its parent."""
func_name = "_center_window (DiffViewer)"
try:
self.update_idletasks() # Assicura dimensioni widget aggiornate
# Calcola coordinate (come prima)
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à
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, label1: str, label2: str):
"""Creates the main widgets for the diff view, using provided labels."""
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
main_frame.columnconfigure(0, weight=1) # Colonna Sinistra (Pane 1)
main_frame.columnconfigure(1, weight=1) # Colonna Destra (Pane 2)
main_frame.columnconfigure(2, weight=0, minsize=40) # Minimap
# Etichette Titoli (usano i parametri passati)
ttk.Label(main_frame, text=label1).grid(
row=0, column=0, sticky="w", padx=(5, 2), pady=(0, 2)
)
ttk.Label(main_frame, text=label2).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 (rinominati pane1/pane2)
text_font = ("Consolas", 10) # Font Monospace
common_text_opts = {
"wrap": tk.NONE,
"font": text_font,
"padx": 5,
"pady": 5,
"undo": False,
"state": tk.DISABLED,
"borderwidth": 1,
"relief": tk.SUNKEN,
}
# Pane Sinistra
self.text_pane1 = tk.Text(main_frame, name="text_pane1", **common_text_opts)
self.text_pane1.grid(row=1, column=0, sticky="nsew", padx=(5, 2))
# Pane Destra
self.text_pane2 = tk.Text(main_frame, name="text_pane2", **common_text_opts)
self.text_pane2.grid(row=1, column=1, sticky="nsew", padx=(2, 5))
# Minimap Canvas
self.minimap_canvas = Canvas(
main_frame,
width=40,
bg="#F0F0F0",
relief=tk.SUNKEN,
borderwidth=1,
highlightthickness=0,
)
self.minimap_canvas.grid(row=1, column=2, sticky="ns", padx=(5, 0))
# Ridisegna su resize
self.minimap_canvas.bind("<Configure>", self._on_minimap_resize)
self.loading_label = ttk.Label(
main_frame, # Appoggia al main_frame per essere sopra gli altri
text="Loading file versions, please wait...", # Testo in inglese
font=("Segoe UI", 12, "italic"),
background="#FFFACD", # Giallo chiaro
foreground="#555555", # Grigio scuro
relief=tk.SOLID,
borderwidth=1,
padding=(10, 5),
)
# Configurazione Tag Highlighting (aggiornato nomi widget)
self.text_pane1.tag_config(
"removed", background="#FFE0E0"
) # Righe solo a sinistra
self.text_pane1.tag_config(
"empty", background="#F5F5F5", foreground="#A0A0A0"
) # Filler
self.text_pane2.tag_config("added", background="#E0FFE0") # Righe solo a destra
self.text_pane2.tag_config(
"empty", background="#F5F5F5", foreground="#A0A0A0"
) # Filler
# Tag per errori (comune)
self.text_pane1.tag_config("error", background="yellow", foreground="red")
self.text_pane2.tag_config("error", background="yellow", foreground="red")
def _load_content(self) -> bool:
"""Loads file content based on self.ref1 and self.ref2."""
func_name = "_load_content (DiffViewer)"
log_handler.log_info(
f"Loading content for diff: '{self.relative_file_path}' ({self.ref1} vs {self.ref2})",
func_name=func_name,
)
load_success = True # Traccia successo globale
# --- Carica Contenuto PANE 1 (Sinistra) ---
ref1_source = self.ref1
log_handler.log_debug(
f"Loading content for Pane 1 from: {ref1_source}", func_name=func_name
)
content_pane1_raw = "" # Inizializza
if ref1_source == "WORKING_DIR":
# Leggi dal filesystem locale
try:
workdir_full_path = os.path.join(
self.repo_path, self.relative_file_path
)
if os.path.exists(workdir_full_path) and os.path.isfile(
workdir_full_path
):
try: # Prova UTF-8
with open(workdir_full_path, "r", encoding="utf-8") as f:
content_pane1_raw = f.read()
except UnicodeDecodeError: # Fallback
log_handler.log_warning(
f"UTF-8 decode failed for WD '{workdir_full_path}', trying fallback.",
func_name=func_name,
)
try:
fallback_encoding = (
locale.getpreferredencoding(False) or "latin-1"
)
log_handler.log_debug(
f"Using fallback encoding: {fallback_encoding}",
func_name=func_name,
)
with open(
workdir_full_path,
"r",
encoding=fallback_encoding,
errors="replace",
) as f:
content_pane1_raw = f.read()
except Exception as fb_e: # Errore anche nel fallback
log_handler.log_error(
f"WD fallback read failed for '{workdir_full_path}': {fb_e}",
func_name=func_name,
)
content_pane1_raw = (
f"<Error reading WD with fallback: {fb_e}>"
)
load_success = False # Errore grave lettura
else: # File non trovato sul disco
log_handler.log_info(
f"Working directory file not found for Pane 1: {workdir_full_path}",
func_name=func_name,
)
content_pane1_raw = "<File not found in Working Directory>"
# Consideriamo questo un successo parziale (l'altra parte potrebbe caricare)
except Exception as e_wd: # Errore generico lettura WD
log_handler.log_exception(
f"Error loading WD content for Pane 1 ('{self.relative_file_path}'): {e_wd}",
func_name=func_name,
)
content_pane1_raw = f"<Error loading Working Dir: {e_wd}>"
load_success = False # Errore grave
self.pane1_content_lines = content_pane1_raw.splitlines()
else:
# Leggi da riferimento Git
try:
# Chiama il metodo GitCommands che gestisce 'git show ref:path'
content_pane1_raw = self.git_commands.get_file_content_from_ref(
self.repo_path, self.relative_file_path, ref=ref1_source
)
if content_pane1_raw is None: # File non trovato nel ref Git
log_handler.log_info(
f"File '{self.relative_file_path}' not found in ref '{ref1_source}' for Pane 1.",
func_name=func_name,
)
content_pane1_raw = f"<File not found in '{ref1_source}'>"
# Successo parziale
self.pane1_content_lines = content_pane1_raw.splitlines()
except Exception as e_ref1: # Errore durante 'git show'
log_handler.log_exception(
f"Error loading Git ref '{ref1_source}' for Pane 1 ('{self.relative_file_path}'): {e_ref1}",
func_name=func_name,
)
self.pane1_content_lines = [
f"<Error loading '{ref1_source}': {e_ref1}>"
]
load_success = False # Errore grave
# --- Carica Contenuto PANE 2 (Destra) ---
# (Logica speculare a Pane 1, usando ref2_source)
ref2_source = self.ref2
log_handler.log_debug(
f"Loading content for Pane 2 from: {ref2_source}", func_name=func_name
)
content_pane2_raw = "" # Inizializza
if ref2_source == "WORKING_DIR":
try:
workdir_full_path = os.path.join(
self.repo_path, self.relative_file_path
)
if os.path.exists(workdir_full_path) and os.path.isfile(
workdir_full_path
):
try:
with open(workdir_full_path, "r", encoding="utf-8") as f:
content_pane2_raw = f.read()
except UnicodeDecodeError:
log_handler.log_warning(
f"UTF-8 decode failed for WD '{workdir_full_path}' (Pane 2), trying fallback.",
func_name=func_name,
)
try:
fallback_encoding = (
locale.getpreferredencoding(False) or "latin-1"
)
with open(
workdir_full_path,
"r",
encoding=fallback_encoding,
errors="replace",
) as f:
content_pane2_raw = f.read()
except Exception as fb_e:
log_handler.log_error(
f"WD fallback read failed for '{workdir_full_path}' (Pane 2): {fb_e}",
func_name=func_name,
)
content_pane2_raw = (
f"<Error reading WD with fallback: {fb_e}>"
)
load_success = False
else:
log_handler.log_info(
f"Working directory file not found for Pane 2: {workdir_full_path}",
func_name=func_name,
)
content_pane2_raw = "<File not found in Working Directory>"
except Exception as e_wd2:
log_handler.log_exception(
f"Error loading WD content for Pane 2 ('{self.relative_file_path}'): {e_wd2}",
func_name=func_name,
)
content_pane2_raw = f"<Error loading Working Dir: {e_wd2}>"
load_success = False
self.pane2_content_lines = content_pane2_raw.splitlines()
else:
# Leggi da riferimento Git
try:
content_pane2_raw = self.git_commands.get_file_content_from_ref(
self.repo_path, self.relative_file_path, ref=ref2_source
)
if content_pane2_raw is None:
log_handler.log_info(
f"File '{self.relative_file_path}' not found in ref '{ref2_source}' for Pane 2.",
func_name=func_name,
)
content_pane2_raw = f"<File not found in '{ref2_source}'>"
self.pane2_content_lines = content_pane2_raw.splitlines()
except Exception as e_ref2:
log_handler.log_exception(
f"Error loading Git ref '{ref2_source}' for Pane 2 ('{self.relative_file_path}'): {e_ref2}",
func_name=func_name,
)
self.pane2_content_lines = [
f"<Error loading '{ref2_source}': {e_ref2}>"
]
load_success = False
# Ritorna True se non ci sono stati errori gravi di caricamento
return load_success
def _compute_and_display_diff(self):
"""Calculates diff using SequenceMatcher and populates text widgets."""
func_name = "_compute_and_display_diff (DiffViewer)"
log_handler.log_debug("Computing and displaying diff...", func_name=func_name)
# Usa le variabili membro aggiornate
lines1 = self.pane1_content_lines
lines2 = self.pane2_content_lines
# Caso base: nessun contenuto
if not lines1 and not lines2:
log_handler.log_warning(
"Both panes have empty content for diff.", func_name=func_name
)
self._populate_text(
self.text_pane1,
[("empty", f"(No content in '{self.ref1_display_name}')")],
)
self._populate_text(
self.text_pane2,
[("empty", f"(No content in '{self.ref2_display_name}')")],
)
self.diff_map = []
self.minimap_canvas.after(
50, self._draw_minimap
) # Aggiorna minimappa vuota
return
# Calcola differenze
matcher = difflib.SequenceMatcher(None, lines1, lines2, autojunk=False)
# Prepara liste per visualizzazione allineata
pane1_lines_display = [] # Righe per PANE 1 (Sinistra)
pane2_lines_display = [] # Righe per PANE 2 (Destra)
diff_map_for_minimap = [] # Mappa colori per minimappa
log_handler.log_debug(
"Generating aligned display lines using opcodes...", func_name=func_name
)
# Itera sugli opcodes del SequenceMatcher
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
chunk1 = lines1[i1:i2] # Chunk da lista sinistra
chunk2 = lines2[j1:j2] # Chunk da lista destra
if tag == "equal": # Righe uguali
for k in range(len(chunk1)):
pane1_lines_display.append(
("equal", chunk1[k])
) # Nessun tag speciale o 'equal'
pane2_lines_display.append(("equal", chunk2[k]))
diff_map_for_minimap.append(0) # 0 = uguale (grigio)
elif tag == "delete": # Presente solo in Pane 1 (sinistra) -> Rimosso
for k in range(len(chunk1)):
pane1_lines_display.append(
("removed", chunk1[k])
) # Tag 'removed' per sinistra
pane2_lines_display.append(("empty", "")) # Filler per destra
diff_map_for_minimap.append(1) # 1 = rimosso/modificato (rosso)
elif tag == "insert": # Presente solo in Pane 2 (destra) -> Aggiunto
for k in range(len(chunk2)):
pane1_lines_display.append(("empty", "")) # Filler per sinistra
pane2_lines_display.append(
("added", chunk2[k])
) # Tag 'added' per destra
diff_map_for_minimap.append(2) # 2 = aggiunto/modificato (verde)
elif tag == "replace": # Modificato (diverso tra sinistra e destra)
len1 = len(chunk1)
len2 = len(chunk2)
max_len = max(len1, len2)
for k in range(max_len):
line1 = chunk1[k] if k < len1 else ""
line2 = chunk2[k] if k < len2 else ""
tag1 = (
"removed" if k < len1 else "empty"
) # Sinistra usa 'removed' o filler
tag2 = (
"added" if k < len2 else "empty"
) # Destra usa 'added' o filler
pane1_lines_display.append((tag1, line1))
pane2_lines_display.append((tag2, line2))
# Minimap: rosso se c'è contenuto a sinistra, verde altrimenti
minimap_code = 1 if tag1 != "empty" else 2
diff_map_for_minimap.append(minimap_code)
# Controllo coerenza (debug)
if len(pane1_lines_display) != len(pane2_lines_display) or len(
pane1_lines_display
) != len(diff_map_for_minimap):
log_handler.log_error(
"Internal Diff Error: Mismatch in aligned line counts!",
func_name=func_name,
)
# Potremmo mostrare errore all'utente o popolare con messaggio di errore
# Aggiorna mappa per minimap
self.diff_map = diff_map_for_minimap
# Popola i widget di testo
log_handler.log_debug(
"Populating text widgets with aligned lines...", func_name=func_name
)
self._populate_text(self.text_pane1, pane1_lines_display) # Popola PANE 1
self._populate_text(self.text_pane2, pane2_lines_display) # Popola PANE 2
# Disegna/Aggiorna minimappa (con leggero ritardo)
log_handler.log_debug("Scheduling minimap draw.", func_name=func_name)
self.minimap_canvas.after(100, self._draw_minimap)
def _populate_text(self, text_widget: tk.Text, lines_data: List[tuple[str, str]]):
"""Populates a text widget with lines data, applying tags."""
func_name = "_populate_text"
widget_name = "unknown_widget"
try:
widget_name = text_widget.winfo_name()
except Exception:
pass
log_handler.log_debug(
f"Populating widget '{widget_name}' with {len(lines_data)} lines.",
func_name=func_name,
)
try:
# Abilita, pulisci, inserisci, applica tag, disabilita
original_state = text_widget.cget("state")
text_widget.config(state=tk.NORMAL)
text_widget.delete("1.0", tk.END)
if not lines_data:
text_widget.insert("1.0", "(No content)\n", ("empty",))
else:
content_string = ""
tags_to_apply = []
current_line_num = 1
for tag_code, content in lines_data:
line_content = content + "\n"
content_string += line_content
start_index = f"{current_line_num}.0"
end_index = f"{current_line_num}.end"
# Mappa codice tag a nome tag effettivo
if tag_code in ["removed", "added", "empty", "error"]:
tags_to_apply.append((tag_code, start_index, end_index))
current_line_num += 1
# Inserisci tutto il testo
text_widget.insert("1.0", content_string)
# Applica i tag
for tag_name, start, end in tags_to_apply:
try:
text_widget.tag_add(tag_name, start, end)
except tk.TclError as tag_err:
log_handler.log_error(
f"Error applying tag '{tag_name}' from {start} to {end}: {tag_err}",
func_name=func_name,
)
# Ripristina stato e posizione vista
text_widget.config(state=tk.DISABLED)
text_widget.yview_moveto(0.0)
log_handler.log_debug(
f"Finished populating widget '{widget_name}'.", func_name=func_name
)
except Exception as e:
log_handler.log_exception(
f"Error populating text widget '{widget_name}': {e}",
func_name=func_name,
)
# Prova a mostrare errore nel widget stesso
try:
if text_widget.winfo_exists():
text_widget.config(state=tk.NORMAL)
text_widget.delete("1.0", tk.END)
text_widget.insert(
"1.0", f"<Error populating content: {e}>", ("error",)
)
text_widget.config(state=tk.DISABLED)
except Exception:
pass
def _draw_minimap(self):
"""Draws the minimap overview based on self.diff_map."""
func_name = "_draw_minimap"
canvas = self.minimap_canvas
# Pulisci elementi precedenti
canvas.delete("diff_line")
canvas.delete("viewport_indicator")
num_lines = len(self.diff_map)
if num_lines == 0:
return
# Assicura dimensioni valide
canvas.update_idletasks()
canvas_width = canvas.winfo_width()
canvas_height = canvas.winfo_height()
if canvas_width <= 1 or canvas_height <= 1:
return
log_handler.log_debug(
f"Drawing minimap ({canvas_width}x{canvas_height}) for {num_lines} lines.",
func_name=func_name,
)
# Calcola altezza linea e disegna rettangoli
exact_line_height = float(canvas_height) / num_lines
accumulated_height = 0.0
for i, diff_type in enumerate(self.diff_map):
y_start = round(accumulated_height)
accumulated_height += exact_line_height
y_end = round(accumulated_height)
if y_end <= y_start:
y_end = y_start + 1
if i == num_lines - 1:
y_end = canvas_height # Forza ultimo pixel
# Colori: 0=uguale(grigio), 1=rimosso/mod(rosso), 2=aggiunto/mod(verde)
color = "#F0F0F0" # Default grigio
if diff_type == 1:
color = "#FFD0D0" # Rosso chiaro
elif diff_type == 2:
color = "#D0FFD0" # Verde chiaro
canvas.create_rectangle(
0,
y_start,
canvas_width,
y_end,
fill=color,
outline="",
tags="diff_line",
)
# Disegna indicatore viewport
self._update_minimap_viewport()
log_handler.log_debug("Minimap drawing complete.", func_name=func_name)
def _hide_loading_message(self):
"""Removes the loading label from view."""
if self.loading_label and self.loading_label.winfo_exists():
try:
self.loading_label.place_forget()
log_handler.log_debug(
"Loading message hidden.", func_name="_hide_loading_message"
)
except Exception as e:
log_handler.log_error(
f"Failed to hide loading label: {e}",
func_name="_hide_loading_message",
)
def _show_loading_message(self):
"""Places and lifts the loading label to make it visible."""
if self.loading_label and self.loading_label.winfo_exists():
try:
# Place al centro del main_frame (primo figlio di self)
container = self.winfo_children()[0] if self.winfo_children() else self
self.loading_label.place(
in_=container, relx=0.5, rely=0.5, anchor=tk.CENTER
)
self.loading_label.lift()
log_handler.log_debug(
"Loading message shown.", func_name="_show_loading_message"
)
except Exception as e:
log_handler.log_error(
f"Failed to place/lift loading label: {e}",
func_name="_show_loading_message",
)
# --- Scrolling Logic ---
def _setup_scrolling(self):
"""Configures minimap click for navigation."""
# Associa evento click sinistro sul canvas alla funzione di scroll
self.minimap_canvas.bind("<Button-1>", self._on_minimap_click)
def _reset_scroll_flag(self):
"""Resets the scrolling flag."""
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
canvas.delete("viewport_indicator") # Rimuovi indicatore precedente
try:
# Ottieni frazioni y visibili da un widget testo (pane1 va bene)
first_visible_fraction, last_visible_fraction = self.text_pane1.yview()
except tk.TclError:
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:
return # Canvas non pronto
# Calcola coordinate y del rettangolo indicatore
y_start = first_visible_fraction * canvas_height
y_end = last_visible_fraction * canvas_height
if y_end <= y_start:
y_end = y_start + 1 # Altezza minima 1px
# Disegna nuovo rettangolo indicatore
canvas.create_rectangle(
1,
y_start,
canvas_width - 1,
y_end, # Margine laterale 1px
outline="black",
width=1,
tags="viewport_indicator",
)
# Assicura che sia 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"
if self._scrolling_active:
return # Ignora click se già in scroll
self._scrolling_active = True
try:
canvas = self.minimap_canvas
canvas_height = canvas.winfo_height()
if canvas_height <= 1:
self._scrolling_active = False
return
# Calcola frazione verticale del click
target_fraction = max(0.0, min(event.y / canvas_height, 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 alla stessa frazione
self.text_pane1.yview_moveto(target_fraction)
self.text_pane2.yview_moveto(target_fraction)
# Aggiorna indicatore
self._update_minimap_viewport()
finally:
# Resetta flag dopo breve ritardo
self.after(50, self._reset_scroll_flag)
# --- Minimap Resize Handling ---
def _on_minimap_resize(self, event):
"""Called when the minimap canvas is resized. Schedules redraw (debounce)."""
func_name = "_on_minimap_resize"
if self._configure_timer_id:
self.after_cancel(self._configure_timer_id)
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) # Log opzionale
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
self._draw_minimap() # Chiama funzione di disegno
# --- END OF FILE diff_viewer.py ---