sistemata visualizzazione minimappa
This commit is contained in:
parent
3beb36b584
commit
d5d1ed9336
@ -24,30 +24,70 @@ class DiffViewer(tk.Toplevel):
|
|||||||
"""
|
"""
|
||||||
Initializes the side-by-side layout and buttons.
|
Initializes the side-by-side layout and buttons.
|
||||||
"""
|
"""
|
||||||
# Toolbar
|
# Toolbar (LabelFrame) for future buttons
|
||||||
toolbar = ttk.Frame(self, padding=5)
|
toolbar = ttk.LabelFrame(self, text="Actions", padding=6)
|
||||||
toolbar.pack(fill="x", side="top")
|
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 = ttk.Button(toolbar, text="⤴ Copy Source to Destination", command=self._copy_source_to_dest)
|
||||||
copy_all_btn.pack(side="left", padx=5)
|
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 = ttk.Frame(self)
|
||||||
main_frame.pack(fill="both", expand=True, padx=5, pady=5)
|
main_frame.pack(fill="both", expand=True, padx=5, pady=5)
|
||||||
# Left pane (Source)
|
# Use grid inside main_frame so left and right panes can share space equally
|
||||||
self.left_text = tk.Text(main_frame, wrap="none", undo=True)
|
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)
|
self.left_text.pack(side="left", fill="both", expand=True)
|
||||||
# Right pane (Destination)
|
self.left_scroll = ttk.Scrollbar(left_pane, orient="vertical", command=lambda *a: self._sync_scroll_from('left', *a))
|
||||||
self.right_text = tk.Text(main_frame, wrap="none", undo=True)
|
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)
|
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 = ttk.Scrollbar(main_frame, orient="vertical", command=self._sync_scroll)
|
||||||
self.v_scroll.pack(side="right", fill="y")
|
self.v_scroll.grid(row=0, column=3, sticky="ns")
|
||||||
self.left_text.config(yscrollcommand=self.v_scroll.set)
|
# Use custom yscrollcommand so we can update minimap viewport
|
||||||
self.right_text.config(yscrollcommand=self.v_scroll.set)
|
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
|
# Colors for diff
|
||||||
self.left_text.tag_config("removed", background="#ffeef0")
|
self.left_text.tag_config("removed", background="#ffeef0")
|
||||||
self.right_text.tag_config("added", background="#e6ffed")
|
self.right_text.tag_config("added", background="#e6ffed")
|
||||||
self.left_text.tag_config("changed", background="#fffbdd")
|
self.left_text.tag_config("changed", background="#fffbdd")
|
||||||
self.right_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:
|
def _sync_scroll(self, *args) -> None:
|
||||||
"""
|
"""
|
||||||
@ -55,48 +95,199 @@ class DiffViewer(tk.Toplevel):
|
|||||||
"""
|
"""
|
||||||
self.left_text.yview(*args)
|
self.left_text.yview(*args)
|
||||||
self.right_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:
|
def _load_and_compare(self) -> None:
|
||||||
"""
|
"""
|
||||||
Reads files and highlights differences.
|
Reads files and highlights differences.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(self.source_path, "r", encoding="utf-8") as f:
|
source_lines = self._read_file_with_fallback(self.source_path)
|
||||||
source_lines = f.readlines()
|
|
||||||
# Handle case where destination file might not exist (Added files)
|
# Handle case where destination file might not exist (Added files)
|
||||||
dest_lines = []
|
dest_lines = []
|
||||||
if os.path.exists(self.dest_path):
|
if os.path.exists(self.dest_path):
|
||||||
with open(self.dest_path, "r", encoding="utf-8") as f:
|
dest_lines = self._read_file_with_fallback(self.dest_path)
|
||||||
dest_lines = f.readlines()
|
|
||||||
diff = difflib.ndiff(dest_lines, source_lines)
|
diff = difflib.ndiff(dest_lines, source_lines)
|
||||||
self._display_diff(diff)
|
self._display_diff(diff)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messagebox.showerror("Error", f"Could not read files: {e}")
|
messagebox.showerror("Error", f"Could not read files: {e}")
|
||||||
self.destroy()
|
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:
|
def _display_diff(self, diff_generator) -> None:
|
||||||
"""
|
"""
|
||||||
Parses the diff and populates the text widgets with highlighting.
|
Parses the diff and populates the text widgets with highlighting.
|
||||||
"""
|
"""
|
||||||
self.left_text.config(state="normal")
|
self.left_text.config(state="normal")
|
||||||
self.right_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:
|
for line in diff_generator:
|
||||||
code = line[:2]
|
code = line[:2]
|
||||||
content = line[2:]
|
content = line[2:]
|
||||||
if code == " ":
|
if code == " ":
|
||||||
self.left_text.insert("end", content)
|
self.left_text.insert("end", content)
|
||||||
self.right_text.insert("end", content)
|
self.right_text.insert("end", content)
|
||||||
|
left_lines.append("unchanged")
|
||||||
|
right_lines.append("unchanged")
|
||||||
elif code == "- ":
|
elif code == "- ":
|
||||||
self.left_text.insert("end", content, "removed")
|
self.left_text.insert("end", content, "removed")
|
||||||
self.right_text.insert("end", "\n")
|
self.right_text.insert("end", "\n")
|
||||||
|
left_lines.append("removed")
|
||||||
elif code == "+ ":
|
elif code == "+ ":
|
||||||
self.left_text.insert("end", "\n")
|
self.left_text.insert("end", "\n")
|
||||||
self.right_text.insert("end", content, "added")
|
self.right_text.insert("end", content, "added")
|
||||||
|
right_lines.append("added")
|
||||||
elif code == "? ":
|
elif code == "? ":
|
||||||
|
# fine-grained markers ignored for now
|
||||||
continue
|
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.left_text.config(state="disabled")
|
||||||
self.right_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:
|
def _copy_source_to_dest(self) -> None:
|
||||||
"""
|
"""
|
||||||
Overwrites the destination file with the source file content.
|
Overwrites the destination file with the source file content.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user