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("", self.show_tooltip) self.widget.bind("", 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("", lambda e: self.destroy()) self.bind("", self._find_next) self.search_entry.bind("", 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("", 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("<>", 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("<>", self._on_event_selected) self.event_tree.bind("", 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()