sistemata visualizzazione minimappa

This commit is contained in:
VALLONGOL 2025-12-23 13:12:25 +01:00
parent 3beb36b584
commit d5d1ed9336

View File

@ -24,30 +24,70 @@ class DiffViewer(tk.Toplevel):
"""
Initializes the side-by-side layout and buttons.
"""
# Toolbar
toolbar = ttk.Frame(self, padding=5)
toolbar.pack(fill="x", side="top")
# Toolbar (LabelFrame) for future buttons
toolbar = ttk.LabelFrame(self, text="Actions", padding=6)
toolbar.pack(fill="x", side="top", padx=5, pady=3)
copy_all_btn = ttk.Button(toolbar, text="⤴ Copy Source to Destination", command=self._copy_source_to_dest)
copy_all_btn.pack(side="left", padx=5)
# Main diff container
# Main diff container: left text, minimap, right text, then scrollbar
main_frame = ttk.Frame(self)
main_frame.pack(fill="both", expand=True, padx=5, pady=5)
# Left pane (Source)
self.left_text = tk.Text(main_frame, wrap="none", undo=True)
# Use grid inside main_frame so left and right panes can share space equally
main_frame.grid_rowconfigure(0, weight=1)
main_frame.grid_columnconfigure(0, weight=1)
main_frame.grid_columnconfigure(1, weight=0)
main_frame.grid_columnconfigure(2, weight=1)
left_pane = ttk.Frame(main_frame)
left_pane.grid(row=0, column=0, sticky="nsew")
# Source label with path
src_label = ttk.Label(left_pane, text=f"Source: {self.source_path}", anchor="w")
src_label.pack(side="top", fill="x", padx=2, pady=(2, 4))
self.left_text = tk.Text(left_pane, wrap="none", undo=True)
self.left_text.pack(side="left", fill="both", expand=True)
# Right pane (Destination)
self.right_text = tk.Text(main_frame, wrap="none", undo=True)
self.left_scroll = ttk.Scrollbar(left_pane, orient="vertical", command=lambda *a: self._sync_scroll_from('left', *a))
self.left_scroll.pack(side="right", fill="y")
# Minimap between the two text widgets
center_pane = ttk.Frame(main_frame, width=60)
center_pane.grid(row=0, column=1, sticky="ns")
self.minimap = tk.Canvas(center_pane, width=60, bg="#f0f0f0", highlightthickness=1, highlightbackground='black')
self.minimap.pack(fill="y", expand=True, padx=4, pady=2)
right_pane = ttk.Frame(main_frame)
right_pane.grid(row=0, column=2, sticky="nsew")
# Destination label with path
dest_label = ttk.Label(right_pane, text=f"Destination: {self.dest_path}", anchor="w")
dest_label.pack(side="top", fill="x", padx=2, pady=(2, 4))
self.right_text = tk.Text(right_pane, wrap="none", undo=True)
self.right_text.pack(side="left", fill="both", expand=True)
# Scrollbar for both
self.right_scroll = ttk.Scrollbar(right_pane, orient="vertical", command=lambda *a: self._sync_scroll_from('right', *a))
self.right_scroll.pack(side="right", fill="y")
# Scrollbar for both text widgets (rightmost)
self.v_scroll = ttk.Scrollbar(main_frame, orient="vertical", command=self._sync_scroll)
self.v_scroll.pack(side="right", fill="y")
self.left_text.config(yscrollcommand=self.v_scroll.set)
self.right_text.config(yscrollcommand=self.v_scroll.set)
self.v_scroll.grid(row=0, column=3, sticky="ns")
# Use custom yscrollcommand so we can update minimap viewport
self.left_text.config(yscrollcommand=lambda *a: self._on_yscroll('left', *a))
self.right_text.config(yscrollcommand=lambda *a: self._on_yscroll('right', *a))
# Colors for diff
self.left_text.tag_config("removed", background="#ffeef0")
self.right_text.tag_config("added", background="#e6ffed")
self.left_text.tag_config("changed", background="#fffbdd")
self.right_text.tag_config("changed", background="#fffbdd")
# Bind mousewheel to synchronize scrolling between both panes
self.left_text.bind("<MouseWheel>", self._on_mousewheel)
self.right_text.bind("<MouseWheel>", self._on_mousewheel)
# For X11 systems
self.left_text.bind("<Button-4>", self._on_mousewheel)
self.left_text.bind("<Button-5>", self._on_mousewheel)
self.right_text.bind("<Button-4>", self._on_mousewheel)
self.right_text.bind("<Button-5>", self._on_mousewheel)
# Minimap interactions
self.minimap.bind("<Button-1>", self._on_minimap_click)
# rebuild minimap when resized
self.minimap.bind("<Configure>", lambda e: self._build_minimap())
self._minimap_marks = []
def _sync_scroll(self, *args) -> None:
"""
@ -55,48 +95,199 @@ class DiffViewer(tk.Toplevel):
"""
self.left_text.yview(*args)
self.right_text.yview(*args)
# update left/right scrollbar positions
try:
self.left_scroll.set(*self.left_text.yview())
except Exception:
pass
try:
self.right_scroll.set(*self.right_text.yview())
except Exception:
pass
# update minimap viewport
self._update_minimap_viewport()
def _on_yscroll(self, *args) -> None:
"""Callback used as yscrollcommand for text widgets to update scrollbar and minimap."""
side = args[0] if args else None
# args may be ('fraction1','fraction2') or ('moveto', frac)
try:
if side == '0.0':
pass
except Exception:
pass
# update both scrollbars positions from both text widgets
try:
self.left_scroll.set(*self.left_text.yview())
except Exception:
pass
try:
self.right_scroll.set(*self.right_text.yview())
except Exception:
pass
self._update_minimap_viewport()
def _sync_scroll_from(self, which: str, *args) -> None:
"""Scrollbar command handler for left/right scrollbars: forward to both text widgets."""
try:
# args forwarded to yview
self.left_text.yview(*args)
self.right_text.yview(*args)
# update both scrollbars
self.left_scroll.set(*self.left_text.yview())
self.right_scroll.set(*self.right_text.yview())
self._update_minimap_viewport()
except Exception:
pass
def _on_mousewheel(self, event) -> str:
"""Handle mouse wheel events to scroll both panes together."""
try:
if event.num == 5 or event.delta < 0:
delta = 1
else:
delta = -1
except Exception:
# fallback
delta = int(-1 * (event.delta / 120)) if hasattr(event, 'delta') else 0
try:
self.left_text.yview_scroll(delta, "units")
self.right_text.yview_scroll(delta, "units")
self._update_minimap_viewport()
except Exception:
pass
return "break"
def _load_and_compare(self) -> None:
"""
Reads files and highlights differences.
"""
try:
with open(self.source_path, "r", encoding="utf-8") as f:
source_lines = f.readlines()
source_lines = self._read_file_with_fallback(self.source_path)
# Handle case where destination file might not exist (Added files)
dest_lines = []
if os.path.exists(self.dest_path):
with open(self.dest_path, "r", encoding="utf-8") as f:
dest_lines = f.readlines()
dest_lines = self._read_file_with_fallback(self.dest_path)
diff = difflib.ndiff(dest_lines, source_lines)
self._display_diff(diff)
except Exception as e:
messagebox.showerror("Error", f"Could not read files: {e}")
self.destroy()
def _read_file_with_fallback(self, path: str):
"""Read a file and try common encodings if UTF-8 fails.
Returns a list of lines (with line endings) or raises an Exception
if the file appears to be binary.
"""
if not os.path.exists(path):
return []
# Read raw bytes first to detect NULs (binary files)
with open(path, 'rb') as bf:
data = bf.read()
if b'\x00' in data:
raise Exception('File appears to be binary (contains NUL bytes)')
# Try decodings in order
for enc in ('utf-8', 'cp1252', 'latin-1'):
try:
text = data.decode(enc)
return text.splitlines(keepends=True)
except UnicodeDecodeError:
continue
# As a last resort, decode with replacement to avoid crashing
text = data.decode('utf-8', errors='replace')
return text.splitlines(keepends=True)
def _display_diff(self, diff_generator) -> None:
"""
Parses the diff and populates the text widgets with highlighting.
"""
self.left_text.config(state="normal")
self.right_text.config(state="normal")
# We'll track line-level statuses to draw the minimap
left_lines = []
right_lines = []
for line in diff_generator:
code = line[:2]
content = line[2:]
if code == " ":
self.left_text.insert("end", content)
self.right_text.insert("end", content)
left_lines.append("unchanged")
right_lines.append("unchanged")
elif code == "- ":
self.left_text.insert("end", content, "removed")
self.right_text.insert("end", "\n")
left_lines.append("removed")
elif code == "+ ":
self.left_text.insert("end", "\n")
self.right_text.insert("end", content, "added")
right_lines.append("added")
elif code == "? ":
# fine-grained markers ignored for now
continue
# After building texts, ensure consistent line counts
nleft = int(self.left_text.index('end-1c').split('.')[0])
nright = int(self.right_text.index('end-1c').split('.')[0])
# If our tracked arrays are shorter than actual lines, pad with 'unchanged'
while len(left_lines) < nleft:
left_lines.append('unchanged')
while len(right_lines) < nright:
right_lines.append('unchanged')
# store for minimap rendering
self._left_lines_status = left_lines
self._right_lines_status = right_lines
# build minimap markers
self._build_minimap()
self.left_text.config(state="disabled")
self.right_text.config(state="disabled")
def _build_minimap(self) -> None:
"""Draw a simple minimap showing added/removed/changed lines."""
try:
self.minimap.delete("all")
height = max(100, self.minimap.winfo_height())
width = max(30, self.minimap.winfo_width())
# choose a unified total based on the maximum number of lines
left_len = len(getattr(self, '_left_lines_status', []))
right_len = len(getattr(self, '_right_lines_status', []))
total = max(left_len, right_len, 1)
# draw markers for removed (from left) and added (from right)
for i, status in enumerate(getattr(self, '_left_lines_status', [])):
if status == 'removed':
y = int((i / total) * height)
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#ff4d4d', outline='')
for i, status in enumerate(getattr(self, '_right_lines_status', [])):
if status == 'added':
y = int((i / total) * height)
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#4dff4d', outline='')
# viewport rectangle
self._update_minimap_viewport()
except Exception:
pass
def _update_minimap_viewport(self) -> None:
try:
self.minimap.delete('viewport')
height = max(100, self.minimap.winfo_height())
total = max(len(getattr(self, '_right_lines_status', [])), 1)
first, last = self.right_text.yview()
y1 = int(first * height)
y2 = int(last * height)
self.minimap.create_rectangle(1, y1, self.minimap.winfo_width()-1, y2, outline='#3333ff', tag='viewport')
except Exception:
pass
def _on_minimap_click(self, event) -> None:
try:
height = max(1, self.minimap.winfo_height())
frac = event.y / height
self.right_text.yview_moveto(frac)
self.left_text.yview_moveto(frac)
self._update_minimap_viewport()
except Exception:
pass
def _copy_source_to_dest(self) -> None:
"""
Overwrites the destination file with the source file content.