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.
|
||||
"""
|
||||
# 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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user