SXXXXXXX_CppPythonDebug/cpp_python_debug/gui/timeline_window.py
2025-09-25 13:08:09 +02:00

524 lines
23 KiB
Python

import tkinter as tk
from tkinter import ttk, messagebox
import pandas as pd
import json
import difflib
from typing import List, Dict, Any
class Tooltip:
"""
Create a tooltip for a given widget.
"""
def __init__(self, widget, text):
self.widget = widget
self.text = text
self.tooltip_window = None
self.widget.bind("<Enter>", self.show_tooltip)
self.widget.bind("<Leave>", self.hide_tooltip)
def show_tooltip(self, event=None):
x, y, _, _ = self.widget.bbox("insert")
x += self.widget.winfo_rootx() + 25
y += self.widget.winfo_rooty() + 25
self.tooltip_window = tk.Toplevel(self.widget)
self.tooltip_window.wm_overrideredirect(True)
self.tooltip_window.wm_geometry(f"+{x}+{y}")
label = tk.Label(self.tooltip_window, text=self.text, justify='left',
background="#ffffe0", relief='solid', borderwidth=1,
font=("tahoma", "8", "normal"))
label.pack(ipadx=1)
def hide_tooltip(self, event=None):
if self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
class FullValueWindow(tk.Toplevel):
"""A simple window to show the full, pretty-printed value with search functionality."""
def __init__(self, parent, title: str, value_str: str):
super().__init__(parent)
self.title(title)
self.transient(parent)
self.grab_set()
# --- State ---
self.matches = []
self.current_match_index = -1
self.search_term_var = tk.StringVar()
self.search_status_var = tk.StringVar()
# --- UI ---
self.geometry("800x600")
self._create_widgets(value_str)
self._bind_events()
def _create_widgets(self, value_str: str):
main_frame = ttk.Frame(self)
main_frame.pack(expand=True, fill=tk.BOTH)
main_frame.rowconfigure(1, weight=1)
main_frame.columnconfigure(0, weight=1)
# --- Search Bar ---
search_frame = ttk.Frame(main_frame, padding=(5, 5))
search_frame.grid(row=0, column=0, sticky="ew")
search_frame.columnconfigure(1, weight=1)
ttk.Label(search_frame, text="Find:").grid(row=0, column=0, padx=(0, 5))
self.search_entry = ttk.Entry(search_frame, textvariable=self.search_term_var)
self.search_entry.grid(row=0, column=1, sticky="ew")
search_button = ttk.Button(search_frame, text="Find", command=self._perform_search)
search_button.grid(row=0, column=2, padx=5)
search_status_label = ttk.Label(search_frame, textvariable=self.search_status_var, anchor="w")
search_status_label.grid(row=0, column=3, padx=5, sticky="w")
hint_label = ttk.Label(search_frame, text="(F3 for next)", foreground="gray")
hint_label.grid(row=0, column=4, padx=(5, 0), sticky="w")
# --- Text Area ---
text_frame = ttk.Frame(main_frame)
text_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0,5))
text_frame.rowconfigure(0, weight=1)
text_frame.columnconfigure(0, weight=1)
self.text_widget = tk.Text(text_frame, wrap=tk.NONE, font=("Consolas", 10))
self.text_widget.grid(row=0, column=0, sticky="nsew")
v_scroll = ttk.Scrollbar(text_frame, orient="vertical", command=self.text_widget.yview)
v_scroll.grid(row=0, column=1, sticky="ns")
h_scroll = ttk.Scrollbar(text_frame, orient="horizontal", command=self.text_widget.xview)
h_scroll.grid(row=1, column=0, sticky="ew")
self.text_widget.configure(yscrollcommand=v_scroll.set, xscrollcommand=h_scroll.set)
# --- Populate and Configure Text ---
try:
pretty_value = json.dumps(json.loads(value_str), indent=4)
self.text_widget.insert("1.0", pretty_value)
except (json.JSONDecodeError, TypeError):
self.text_widget.insert("1.0", value_str)
self.text_widget.config(state=tk.DISABLED)
self.text_widget.tag_configure("match", background="yellow", foreground="black")
self.text_widget.tag_configure("current_match", background="orange", foreground="black")
def _bind_events(self):
self.bind("<Escape>", lambda e: self.destroy())
self.bind("<F3>", self._find_next)
self.search_entry.bind("<Return>", self._perform_search)
def _clear_search(self):
self.text_widget.config(state=tk.NORMAL)
self.text_widget.tag_remove("match", "1.0", tk.END)
self.text_widget.tag_remove("current_match", "1.0", tk.END)
self.text_widget.config(state=tk.DISABLED)
self.matches = []
self.current_match_index = -1
self.search_status_var.set("")
def _perform_search(self, event=None):
self._clear_search()
term = self.search_term_var.get()
if not term:
return
self.text_widget.config(state=tk.NORMAL)
start_pos = "1.0"
while True:
start_pos = self.text_widget.search(term, start_pos, stopindex=tk.END, nocase=True, count=tk.IntVar())
if not start_pos:
break
end_pos = f"{start_pos}+{len(term)}c"
self.matches.append((start_pos, end_pos))
self.text_widget.tag_add("match", start_pos, end_pos)
start_pos = end_pos
self.text_widget.config(state=tk.DISABLED)
if self.matches:
self.search_status_var.set(f"{len(self.matches)} found")
self._find_next()
else:
self.search_status_var.set("Not found")
def _find_next(self, event=None):
if not self.matches:
return
self.text_widget.config(state=tk.NORMAL)
# Remove tag from previous current match
if self.current_match_index != -1:
start, end = self.matches[self.current_match_index]
self.text_widget.tag_add("match", start, end)
self.text_widget.tag_remove("current_match", start, end)
# Move to next match
self.current_match_index = (self.current_match_index + 1) % len(self.matches)
# Highlight new current match
start, end = self.matches[self.current_match_index]
self.text_widget.tag_remove("match", start, end)
self.text_widget.tag_add("current_match", start, end)
self.text_widget.see(start)
self.text_widget.config(state=tk.DISABLED)
self.search_status_var.set(f"{self.current_match_index + 1}/{len(self.matches)}")
class TimelineWindow(tk.Toplevel):
"""
A window to display a master-detail view of variable change events.
"""
def __init__(self, parent, timeline_df: pd.DataFrame, summary_data: Dict[str, Any]):
super().__init__(parent)
self.summary_data = summary_data
profile_name = self.summary_data.get("profile_name", "Unknown Profile")
self.title(f"Change Event Analysis for: {profile_name}")
self.is_fullscreen = True
self.attributes('-fullscreen', True)
self.bind("<Escape>", self._close_window)
self.grab_set()
self.all_change_events: List[Dict[str, Any]] = []
self.filter_var = tk.StringVar(value="ALL")
self._process_data_for_event_table(timeline_df)
self._create_widgets()
self._populate_filter_combobox()
self._populate_event_table()
def _process_data_for_event_table(self, df: pd.DataFrame):
self.all_change_events = []
if df.empty:
return
for var_name, group in df.groupby('variable_name'):
sorted_group = group.sort_values(by='timestamp').reset_index()
for i in range(1, len(sorted_group)):
prev_row = sorted_group.iloc[i-1]
curr_row = sorted_group.iloc[i]
if prev_row['value'] != curr_row['value']:
event = {
"timestamp": curr_row['timestamp'],
"variable_name": var_name,
"previous_value": prev_row['value'],
"new_value": curr_row['value']
}
self.all_change_events.append(event)
self.all_change_events.sort(key=lambda x: x['timestamp'])
def _create_widgets(self):
main_frame = ttk.Frame(self, padding="10")
main_frame.pack(expand=True, fill=tk.BOTH)
main_frame.rowconfigure(2, weight=1) # Main content (paned window) will expand
main_frame.columnconfigure(0, weight=1)
# --- Header Frame ---
header_frame = ttk.Frame(main_frame)
header_frame.grid(row=0, column=0, sticky="ew", pady=(0, 5))
header_frame.columnconfigure(0, weight=1)
profile_name = self.summary_data.get("profile_name", "Unknown Profile")
run_start_time = self.summary_data.get("start_time", "Unknown Time")
title_label = ttk.Label(
header_frame,
text=f"Profile: '{profile_name}' (Run: {run_start_time})",
font=("TkDefaultFont", 14, "bold")
)
title_label.grid(row=0, column=0, sticky="w")
button_group = ttk.Frame(header_frame)
button_group.grid(row=0, column=1, sticky="e")
toggle_fullscreen_button = ttk.Button(
button_group, text="Toggle Fullscreen", command=self._toggle_fullscreen
)
toggle_fullscreen_button.pack(side=tk.LEFT, padx=5)
close_button = ttk.Button(button_group, text="Close", command=self._close_window)
close_button.pack(side=tk.LEFT, padx=5)
# --- Breakpoint Status Frame ---
bp_status_frame = ttk.LabelFrame(main_frame, text="Breakpoint Status", padding=5)
bp_status_frame.grid(row=1, column=0, sticky="ew", pady=5)
# Add "View Summary" button
view_summary_button = ttk.Button(
bp_status_frame, text="View Summary", command=self._view_summary
)
view_summary_button.grid(row=0, column=0, sticky="w", padx=5)
actions = self.summary_data.get('actions_summary', [])
for i, action in enumerate(actions):
spec = action.get("breakpoint_spec", "N/A")
status = action.get("status", "Unknown")
if "Error" in status:
color = "red"
elif "Completed" in status:
color = "green"
else:
color = "orange" # For pending or other states
bp_label = ttk.Label(bp_status_frame, text=f"{spec}", foreground=color)
bp_label.grid(row=0, column=i+1, sticky="w", padx=10) # Shift columns for breakpoint labels
Tooltip(bp_label, text=status)
# --- Paned Window for Master-Detail (Left/Right) ---
paned_window = ttk.PanedWindow(main_frame, orient=tk.HORIZONTAL)
paned_window.grid(row=2, column=0, sticky="nsew")
# --- Left Pane (Master): Event Table & Filter ---
master_frame = ttk.Frame(paned_window, padding="5")
paned_window.add(master_frame, weight=1)
master_frame.rowconfigure(1, weight=1)
master_frame.columnconfigure(0, weight=1)
filter_frame = ttk.Frame(master_frame)
filter_frame.grid(row=0, column=0, sticky="ew", pady=(0, 5))
filter_frame.columnconfigure(1, weight=1)
ttk.Label(filter_frame, text="Filter by Variable:").grid(row=0, column=0, sticky="w", padx=(0,5))
self.filter_combo = ttk.Combobox(filter_frame, textvariable=self.filter_var, state="readonly")
self.filter_combo.grid(row=0, column=1, sticky="ew")
self.filter_combo.bind("<<ComboboxSelected>>", self._on_filter_selected)
table_container = ttk.LabelFrame(master_frame, text="Change Events")
table_container.grid(row=1, column=0, sticky="nsew")
table_container.rowconfigure(0, weight=1)
table_container.columnconfigure(0, weight=1)
self.event_tree = ttk.Treeview(
table_container, columns=("timestamp", "variable"), show="headings", selectmode="browse"
)
self.event_tree.grid(row=0, column=0, sticky="nsew")
self.event_tree.heading("timestamp", text="Timestamp")
self.event_tree.heading("variable", text="Variable Name")
self.event_tree.column("timestamp", width=180, stretch=False)
self.event_tree.column("variable", width=200, stretch=True)
tree_scroll = ttk.Scrollbar(table_container, orient="vertical", command=self.event_tree.yview)
tree_scroll.grid(row=0, column=1, sticky="ns")
self.event_tree.configure(yscrollcommand=tree_scroll.set)
self.event_tree.bind("<<TreeviewSelect>>", self._on_event_selected)
self.event_tree.bind("<Button-3>", self._show_context_menu)
# --- Right Pane (Detail): Diff View ---
detail_frame = ttk.LabelFrame(paned_window, text="Value Diff", padding="5")
paned_window.add(detail_frame, weight=3)
detail_frame.rowconfigure(0, weight=1)
detail_frame.columnconfigure(0, weight=1)
# Frame for Unified Diff (JSON)
self.unified_diff_frame = ttk.Frame(detail_frame)
self.unified_diff_frame.grid(row=0, column=0, sticky="nsew")
self.unified_diff_frame.rowconfigure(0, weight=1)
self.unified_diff_frame.columnconfigure(0, weight=1)
self.diff_text = tk.Text(self.unified_diff_frame, wrap=tk.NONE, state=tk.DISABLED, font=("Consolas", 10))
self.diff_text.grid(row=0, column=0, sticky="nsew")
diff_v_scroll = ttk.Scrollbar(self.unified_diff_frame, orient="vertical", command=self.diff_text.yview)
diff_v_scroll.grid(row=0, column=1, sticky="ns")
diff_h_scroll = ttk.Scrollbar(self.unified_diff_frame, orient="horizontal", command=self.diff_text.xview)
diff_h_scroll.grid(row=1, column=0, sticky="ew")
self.diff_text.configure(yscrollcommand=diff_v_scroll.set, xscrollcommand=diff_h_scroll.set)
self.diff_text.tag_configure("added", foreground="#008800")
self.diff_text.tag_configure("removed", foreground="#CC0000")
self.diff_text.tag_configure("header", foreground="blue", font=("Consolas", 10, "bold"))
# Frame for Side-by-Side Diff (CSV)
self.side_by_side_diff_frame = ttk.Frame(detail_frame)
# Initially hidden by unified_diff_frame, will be shown with grid()
self.side_by_side_diff_frame.grid(row=0, column=0, sticky="nsew")
self.side_by_side_diff_frame.rowconfigure(0, weight=1)
self.side_by_side_diff_frame.columnconfigure(0, weight=1)
self.side_by_side_diff_frame.columnconfigure(1, weight=1)
# PanedWindow for side-by-side view
self.side_by_side_paned_window = ttk.PanedWindow(self.side_by_side_diff_frame, orient=tk.HORIZONTAL)
self.side_by_side_paned_window.pack(expand=True, fill=tk.BOTH)
# Left Text Widget (Previous Value)
prev_frame = ttk.Frame(self.side_by_side_paned_window)
self.side_by_side_paned_window.add(prev_frame, weight=1)
prev_frame.rowconfigure(0, weight=1)
prev_frame.columnconfigure(0, weight=1)
ttk.Label(prev_frame, text="Previous Value", font=("TkDefaultFont", 10, "bold")).pack(side=tk.TOP, fill=tk.X)
self.prev_value_text = tk.Text(prev_frame, wrap=tk.NONE, state=tk.DISABLED, font=("Consolas", 10))
self.prev_value_text.pack(expand=True, fill=tk.BOTH)
prev_v_scroll = ttk.Scrollbar(prev_frame, orient="vertical", command=self.prev_value_text.yview)
prev_v_scroll.pack(side=tk.RIGHT, fill=tk.Y)
prev_h_scroll = ttk.Scrollbar(prev_frame, orient="horizontal", command=self.prev_value_text.xview)
prev_h_scroll.pack(side=tk.BOTTOM, fill=tk.X)
self.prev_value_text.configure(yscrollcommand=prev_v_scroll.set, xscrollcommand=prev_h_scroll.set)
# Right Text Widget (New Value)
new_frame = ttk.Frame(self.side_by_side_paned_window)
self.side_by_side_paned_window.add(new_frame, weight=1)
new_frame.rowconfigure(0, weight=1)
new_frame.columnconfigure(0, weight=1)
ttk.Label(new_frame, text="New Value", font=("TkDefaultFont", 10, "bold")).pack(side=tk.TOP, fill=tk.X)
self.new_value_text = tk.Text(new_frame, wrap=tk.NONE, state=tk.DISABLED, font=("Consolas", 10))
self.new_value_text.pack(expand=True, fill=tk.BOTH)
new_v_scroll = ttk.Scrollbar(new_frame, orient="vertical", command=self.new_value_text.yview)
new_v_scroll.pack(side=tk.RIGHT, fill=tk.Y)
new_h_scroll = ttk.Scrollbar(new_frame, orient="horizontal", command=self.new_value_text.xview)
new_h_scroll.pack(side=tk.BOTTOM, fill=tk.X)
self.new_value_text.configure(yscrollcommand=new_v_scroll.set, xscrollcommand=new_h_scroll.set)
# Synchronize scrolling for side-by-side view
self.prev_value_text.configure(yscrollcommand=lambda *args: self._on_yview_scroll(self.prev_value_text, self.new_value_text, *args))
self.new_value_text.configure(yscrollcommand=lambda *args: self._on_yview_scroll(self.new_value_text, self.prev_value_text, *args))
# --- Context Menu ---
self.context_menu = tk.Menu(self, tearoff=0)
self.context_menu.add_command(label="View Full Value at this Timestamp", command=self._view_full_value)
def _on_yview_scroll(self, source_widget, target_widget, *args):
# The first argument in *args is the fraction representing the top of the visible content
source_widget.yview_moveto(args[0])
target_widget.yview_moveto(args[0])
def _populate_filter_combobox(self):
variable_names = sorted(list(set(event['variable_name'] for event in self.all_change_events)))
self.filter_combo['values'] = ["ALL"] + variable_names
def _on_filter_selected(self, event=None):
self._populate_event_table()
def _populate_event_table(self):
for item in self.event_tree.get_children():
self.event_tree.delete(item)
filter_value = self.filter_var.get()
events_to_show = self.all_change_events
if filter_value != "ALL":
events_to_show = [e for e in self.all_change_events if e['variable_name'] == filter_value]
for i, event in enumerate(events_to_show):
ts = event['timestamp'].strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
var_name = event['variable_name']
# Use the original index from all_change_events as the IID
original_index = self.all_change_events.index(event)
self.event_tree.insert("", tk.END, iid=str(original_index), values=(ts, var_name))
def _is_json(self, value_str):
try:
json.loads(value_str)
return True
except (json.JSONDecodeError, TypeError):
return False
def _on_event_selected(self, event=None):
selection = self.event_tree.selection()
if not selection:
# Clear all diff views
self.unified_diff_frame.grid_remove()
self.side_by_side_diff_frame.grid_remove()
return
event_index = int(selection[0])
event_data = self.all_change_events[event_index]
prev_val_str = event_data['previous_value']
new_val_str = event_data['new_value']
is_prev_json = self._is_json(prev_val_str)
is_new_json = self._is_json(new_val_str)
# Clear previous content and hide all frames
self.diff_text.config(state=tk.NORMAL)
self.diff_text.delete('1.0', tk.END)
self.diff_text.config(state=tk.DISABLED)
self.prev_value_text.config(state=tk.NORMAL)
self.prev_value_text.delete('1.0', tk.END)
self.prev_value_text.config(state=tk.DISABLED)
self.new_value_text.config(state=tk.NORMAL)
self.new_value_text.delete('1.0', tk.END)
self.new_value_text.config(state=tk.DISABLED)
self.unified_diff_frame.grid_remove()
self.side_by_side_diff_frame.grid_remove()
if is_prev_json and is_new_json:
# Show Unified Diff for JSON
self.unified_diff_frame.grid(row=0, column=0, sticky="nsew")
self.diff_text.config(state=tk.NORMAL)
try:
prev_pretty = json.dumps(json.loads(prev_val_str), indent=4).splitlines()
new_pretty = json.dumps(json.loads(new_val_str), indent=4).splitlines()
diff = difflib.unified_diff(prev_pretty, new_pretty, lineterm='', fromfile='Previous', tofile='New')
for line in diff:
if line.startswith('+') and not line.startswith('+++'):
self.diff_text.insert(tk.END, line + '\n', "added")
elif line.startswith('-') and not line.startswith('---'):
self.diff_text.insert(tk.END, line + '\n', "removed")
elif line.startswith('@@'):
self.diff_text.insert(tk.END, line + '\n', "header")
else:
self.diff_text.insert(tk.END, line + '\n')
except (json.JSONDecodeError, TypeError):
# Fallback to plain text if pretty-printing fails unexpectedly
self.diff_text.insert(tk.END, "--- PREVIOUS VALUE ---\n", "header")
self.diff_text.insert(tk.END, prev_val_str + "\n\n")
self.diff_text.insert(tk.END, "--- NEW VALUE ---\n", "header")
self.diff_text.insert(tk.END, new_val_str + "\n")
finally:
self.diff_text.config(state=tk.DISABLED)
else:
# Show Side-by-Side Diff for CSV/Plain Text
self.side_by_side_diff_frame.grid(row=0, column=0, sticky="nsew")
self.prev_value_text.config(state=tk.NORMAL)
self.prev_value_text.insert(tk.END, prev_val_str)
self.prev_value_text.config(state=tk.DISABLED)
self.new_value_text.config(state=tk.NORMAL)
self.new_value_text.insert(tk.END, new_val_str)
self.new_value_text.config(state=tk.DISABLED)
def _show_context_menu(self, event):
selection = self.event_tree.identify_row(event.y)
if selection:
self.event_tree.selection_set(selection)
self.context_menu.post(event.x_root, event.y_root)
def _view_full_value(self):
selection = self.event_tree.selection()
if not selection:
return
event_index = int(selection[0])
event_data = self.all_change_events[event_index]
value_str = event_data['new_value']
var_name = event_data['variable_name']
timestamp = event_data['timestamp'].strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
title = f"Full Value of '{var_name}' at {timestamp}"
FullValueWindow(self, title=title, value_str=value_str)
def _toggle_fullscreen(self, event=None):
self.is_fullscreen = not self.is_fullscreen
self.attributes("-fullscreen", self.is_fullscreen)
def _view_summary(self):
"""Opens a FullValueWindow to display the profile summary data."""
try:
summary_json_str = json.dumps(self.summary_data, indent=4)
except TypeError:
# Fallback for non-JSON serializable data
summary_json_str = str(self.summary_data)
title = "Profile Summary"
FullValueWindow(self, title=title, value_str=summary_json_str)
def _close_window(self, event=None):
self.destroy()