501 lines
23 KiB
Python
501 lines
23 KiB
Python
# profileAnalyzer/gui/gui.py
|
|
|
|
"""
|
|
GUI for the Profile Analyzer application.
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox, scrolledtext
|
|
import csv
|
|
import os
|
|
import threading
|
|
import subprocess
|
|
import json
|
|
from shlex import split as shlex_split
|
|
from typing import Optional
|
|
|
|
# Correctly import from sibling directories
|
|
from profileanalyzer.core.core import (
|
|
ProfileAnalyzer, LaunchProfile, run_and_profile_script, is_graphviz_installed
|
|
)
|
|
from profileanalyzer.core.profile_manager import LaunchProfileManager
|
|
from profileanalyzer.gui.launch_manager_window import LaunchManagerWindow
|
|
from profileanalyzer.gui.compare_window import CompareWindow
|
|
from profileanalyzer.gui.settings_window import SettingsWindow
|
|
|
|
try:
|
|
from PIL import Image, ImageTk
|
|
PIL_AVAILABLE = True
|
|
except ImportError:
|
|
PIL_AVAILABLE = False
|
|
|
|
|
|
class ScrolledFrame(ttk.Frame):
|
|
"""A pure Tkinter scrollable frame that actually works!"""
|
|
def __init__(self, parent, *args, **kw):
|
|
ttk.Frame.__init__(self, parent, *args, **kw)
|
|
|
|
vscrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL)
|
|
vscrollbar.pack(fill=tk.Y, side=tk.RIGHT, expand=tk.FALSE)
|
|
hscrollbar = ttk.Scrollbar(self, orient=tk.HORIZONTAL)
|
|
hscrollbar.pack(fill=tk.X, side=tk.BOTTOM, expand=tk.FALSE)
|
|
|
|
self.canvas = tk.Canvas(self, bd=0, highlightthickness=0,
|
|
yscrollcommand=vscrollbar.set,
|
|
xscrollcommand=hscrollbar.set)
|
|
self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=tk.TRUE)
|
|
|
|
vscrollbar.config(command=self.canvas.yview)
|
|
hscrollbar.config(command=self.canvas.xview)
|
|
|
|
self.canvas.xview_moveto(0)
|
|
self.canvas.yview_moveto(0)
|
|
|
|
self.interior = ttk.Frame(self.canvas)
|
|
self.interior_id = self.canvas.create_window(0, 0, window=self.interior, anchor=tk.NW)
|
|
|
|
self.interior.bind('<Configure>', self._configure_interior)
|
|
self.canvas.bind('<Configure>', self._configure_canvas)
|
|
|
|
def _configure_interior(self, event):
|
|
size = (self.interior.winfo_reqwidth(), self.interior.winfo_reqheight())
|
|
self.canvas.config(scrollregion="0 0 %s %s" % size)
|
|
if self.interior.winfo_reqwidth() != self.canvas.winfo_width():
|
|
self.canvas.config(width=self.interior.winfo_reqwidth())
|
|
|
|
def _configure_canvas(self, event):
|
|
if self.interior.winfo_reqwidth() != self.canvas.winfo_width():
|
|
self.canvas.itemconfigure(self.interior_id, width=self.canvas.winfo_width())
|
|
|
|
|
|
# --- Import Version Info FOR THE WRAPPER ITSELF ---
|
|
try:
|
|
# Use absolute import based on package name
|
|
from profileanalyzer import _version as wrapper_version
|
|
WRAPPER_APP_VERSION_STRING = f"{wrapper_version.__version__} ({wrapper_version.GIT_BRANCH}/{wrapper_version.GIT_COMMIT_HASH[:7]})"
|
|
WRAPPER_BUILD_INFO = f"Wrapper Built: {wrapper_version.BUILD_TIMESTAMP}"
|
|
except ImportError:
|
|
# This might happen if you run the wrapper directly from source
|
|
# without generating its _version.py first (if you use that approach for the wrapper itself)
|
|
WRAPPER_APP_VERSION_STRING = "(Dev Wrapper)"
|
|
WRAPPER_BUILD_INFO = "Wrapper build time unknown"
|
|
|
|
# --- Constants for Version Generation ---
|
|
DEFAULT_VERSION = "0.0.0+unknown"
|
|
DEFAULT_COMMIT = "Unknown"
|
|
DEFAULT_BRANCH = "Unknown"
|
|
# --- End Constants ---
|
|
|
|
class ProfileAnalyzerGUI(tk.Frame):
|
|
"""
|
|
The main GUI frame for the profile analyzer application.
|
|
"""
|
|
SORT_OPTIONS = {
|
|
"Total Time (tottime)": "tottime", "Cumulative Time (cumtime)": "cumulative",
|
|
"Number of Calls (ncalls)": "ncalls", "Function Name": "filename"
|
|
}
|
|
COLUMN_SORT_MAP = {
|
|
"ncalls": "ncalls", "tottime": "tottime",
|
|
"cumtime": "cumulative", "function": "filename"
|
|
}
|
|
|
|
def __init__(self, master: tk.Tk):
|
|
super().__init__(master)
|
|
self.master = master
|
|
self.master.title(f"Python Profile Analyzer - {WRAPPER_APP_VERSION_STRING}")
|
|
self.master.geometry("1400x800")
|
|
self.master.protocol("WM_DELETE_WINDOW", self._on_closing)
|
|
|
|
self.analyzer = ProfileAnalyzer()
|
|
self.profile_manager = LaunchProfileManager("launch_profiles.json")
|
|
self.current_stats_data = []
|
|
self.filtered_stats_data = []
|
|
|
|
self.graphviz_available = is_graphviz_installed() and PIL_AVAILABLE
|
|
self.graph_image_path: Optional[str] = None
|
|
self.graph_photo_image: Optional[ImageTk.PhotoImage] = None
|
|
|
|
self.loaded_filepath_var = tk.StringVar(value="No profile loaded.")
|
|
self.sort_by_var = tk.StringVar(value=list(self.SORT_OPTIONS.keys())[0])
|
|
self.filter_var = tk.StringVar()
|
|
self.filter_var.trace_add("write", self._apply_filter_and_refresh_views)
|
|
self.graph_threshold_var = tk.DoubleVar(value=1.0)
|
|
|
|
self._create_widgets()
|
|
self.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
|
|
def _create_widgets(self):
|
|
top_frame = ttk.Frame(self)
|
|
top_frame.pack(fill=tk.X, pady=(0, 10))
|
|
top_frame.columnconfigure(6, weight=1)
|
|
|
|
ttk.Button(top_frame, text="Load Profile File...", command=self._load_profile).grid(row=0, column=0, padx=(0, 5))
|
|
ttk.Button(top_frame, text="Profile a Script...", command=self._open_launch_manager).grid(row=0, column=1, padx=5)
|
|
ttk.Button(top_frame, text="Compare Profiles...", command=self._open_compare_window).grid(row=0, column=2, padx=5)
|
|
ttk.Button(top_frame, text="Export to CSV...", command=self._export_to_csv).grid(row=0, column=3, padx=5)
|
|
ttk.Button(top_frame, text="Settings", command=self._open_settings_window).grid(row=0, column=4, padx=5)
|
|
|
|
loaded_file_label = ttk.Label(top_frame, text="Current Profile:")
|
|
loaded_file_label.grid(row=0, column=5, sticky="w", padx=(10, 0))
|
|
loaded_file_display = ttk.Label(top_frame, textvariable=self.loaded_filepath_var, anchor="w", relief="sunken")
|
|
loaded_file_display.grid(row=0, column=6, sticky="ew", padx=5)
|
|
|
|
main_pane = ttk.PanedWindow(self, orient=tk.VERTICAL)
|
|
main_pane.pack(fill=tk.BOTH, expand=True)
|
|
|
|
stats_view_pane = ttk.Frame(main_pane)
|
|
main_pane.add(stats_view_pane, weight=3)
|
|
self._create_main_stats_view(stats_view_pane)
|
|
|
|
call_details_pane = ttk.Frame(main_pane)
|
|
main_pane.add(call_details_pane, weight=1)
|
|
self._create_call_details_view(call_details_pane)
|
|
|
|
def _create_main_stats_view(self, parent_frame):
|
|
parent_frame.rowconfigure(0, weight=1)
|
|
parent_frame.columnconfigure(0, weight=1)
|
|
|
|
self.notebook = ttk.Notebook(parent_frame)
|
|
self.notebook.grid(row=0, column=0, sticky="nsew")
|
|
|
|
table_tab = ttk.Frame(self.notebook)
|
|
text_tab = ttk.Frame(self.notebook)
|
|
self.notebook.add(table_tab, text="Table View")
|
|
self.notebook.add(text_tab, text="Text View")
|
|
|
|
if self.graphviz_available:
|
|
graph_tab = ttk.Frame(self.notebook)
|
|
self.notebook.add(graph_tab, text="Graph View")
|
|
self._create_graph_tab(graph_tab)
|
|
if not PIL_AVAILABLE:
|
|
self.notebook.tab(2, state="disabled")
|
|
messagebox.showwarning("Missing Dependency", "Pillow library not found. Graph view is disabled. Please run 'pip install Pillow'.")
|
|
|
|
self._create_table_tab(table_tab)
|
|
self._create_text_tab(text_tab)
|
|
|
|
controls_frame = ttk.Frame(parent_frame)
|
|
controls_frame.grid(row=1, column=0, sticky="ew", pady=(5,0))
|
|
controls_frame.columnconfigure(3, weight=1)
|
|
|
|
ttk.Label(controls_frame, text="Sort by:").grid(row=0, column=0, padx=(0, 5))
|
|
self.sort_combo = ttk.Combobox(controls_frame, textvariable=self.sort_by_var, values=list(self.SORT_OPTIONS.keys()), state="readonly")
|
|
self.sort_combo.grid(row=0, column=1, padx=(0, 10))
|
|
self.sort_combo.bind("<<ComboboxSelected>>", self._on_sort_change)
|
|
ttk.Label(controls_frame, text="Filter:").grid(row=0, column=2, padx=(10, 5))
|
|
filter_entry = ttk.Entry(controls_frame, textvariable=self.filter_var)
|
|
filter_entry.grid(row=0, column=3, sticky="ew")
|
|
|
|
def _create_graph_tab(self, parent_frame):
|
|
parent_frame.rowconfigure(0, weight=1)
|
|
parent_frame.columnconfigure(0, weight=1)
|
|
|
|
graph_controls_frame = ttk.Frame(parent_frame, padding=5)
|
|
graph_controls_frame.grid(row=1, column=0, sticky="ew")
|
|
|
|
ttk.Label(graph_controls_frame, text="Node Threshold (%):").pack(side=tk.LEFT)
|
|
scale = ttk.Scale(graph_controls_frame, from_=0, to=5, orient=tk.HORIZONTAL, variable=self.graph_threshold_var)
|
|
scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
|
|
ttk.Button(graph_controls_frame, text="Generate Graph", command=self._generate_and_display_graph).pack(side=tk.LEFT, padx=5)
|
|
|
|
self.scrolled_canvas_frame = ScrolledFrame(parent_frame)
|
|
self.scrolled_canvas_frame.grid(row=0, column=0, sticky="nsew")
|
|
self.graph_canvas = self.scrolled_canvas_frame.canvas
|
|
|
|
def _create_call_details_view(self, parent_frame):
|
|
parent_frame.rowconfigure(0, weight=1)
|
|
parent_frame.columnconfigure(0, weight=1)
|
|
pane = ttk.PanedWindow(parent_frame, orient=tk.HORIZONTAL)
|
|
pane.grid(row=0, column=0, sticky="nsew", pady=(5,0))
|
|
callers_frame = ttk.LabelFrame(pane, text="Callers (who called the selected function)")
|
|
callees_frame = ttk.LabelFrame(pane, text="Callees (what the selected function called)")
|
|
pane.add(callers_frame, weight=1)
|
|
pane.add(callees_frame, weight=1)
|
|
self.callers_tree = self._create_call_tree(callers_frame)
|
|
self.callees_tree = self._create_call_tree(callees_frame)
|
|
|
|
def _create_call_tree(self, parent) -> ttk.Treeview:
|
|
parent.rowconfigure(0, weight=1)
|
|
parent.columnconfigure(0, weight=1)
|
|
tree = ttk.Treeview(parent, columns=("info", "function"), show="headings")
|
|
tree.heading("info", text="Call Info")
|
|
tree.heading("function", text="Function")
|
|
tree.column("info", width=200, stretch=False)
|
|
tree.column("function", width=400, stretch=True)
|
|
vsb = ttk.Scrollbar(parent, orient="vertical", command=tree.yview)
|
|
tree.configure(yscrollcommand=vsb.set)
|
|
tree.grid(row=0, column=0, sticky="nsew")
|
|
vsb.grid(row=0, column=1, sticky="ns")
|
|
return tree
|
|
|
|
def _create_table_tab(self, parent_frame):
|
|
parent_frame.rowconfigure(0, weight=1)
|
|
parent_frame.columnconfigure(0, weight=1)
|
|
|
|
columns = ("ncalls", "tottime", "tottime_percent", "percall_tottime", "cumtime", "percall_cumtime", "function")
|
|
self.tree = ttk.Treeview(parent_frame, columns=columns, show="headings")
|
|
self.tree.bind("<<TreeviewSelect>>", self._on_function_select)
|
|
self.tree.bind("<Button-3>", self._show_context_menu)
|
|
|
|
col_map = {
|
|
"ncalls": ("N-Calls", 80), "tottime": ("Total Time (s)", 120),
|
|
"tottime_percent": ("% Total Time", 100),
|
|
"percall_tottime": ("Per Call (s)", 100), "cumtime": ("Cum. Time (s)", 120),
|
|
"percall_cumtime": ("Per Call (s)", 100), "function": ("Function", 500)
|
|
}
|
|
for col_id, (text, width) in col_map.items():
|
|
anchor = "w" if col_id == "function" else "e"
|
|
sort_key = col_id if col_id != "tottime_percent" else "tottime"
|
|
self.tree.heading(col_id, text=text, anchor="w", command=lambda c=sort_key: self._on_header_click(c))
|
|
self.tree.column(col_id, width=width, stretch=(col_id == "function"), anchor=anchor)
|
|
|
|
self.tree.tag_configure('heat_critical', background='#ffcdd2')
|
|
self.tree.tag_configure('heat_high', background='#ffecb3')
|
|
self.tree.tag_configure('heat_medium', background='#e8f5e9')
|
|
|
|
vsb = ttk.Scrollbar(parent_frame, orient="vertical", command=self.tree.yview)
|
|
hsb = ttk.Scrollbar(parent_frame, orient="horizontal", command=self.tree.xview)
|
|
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
|
self.tree.grid(row=0, column=0, sticky="nsew")
|
|
vsb.grid(row=0, column=1, sticky="ns")
|
|
hsb.grid(row=1, column=0, sticky="ew")
|
|
|
|
def _create_text_tab(self, parent_frame):
|
|
parent_frame.rowconfigure(0, weight=1)
|
|
parent_frame.columnconfigure(0, weight=1)
|
|
self.text_view = scrolledtext.ScrolledText(parent_frame, wrap=tk.NONE, state=tk.DISABLED, font=("Courier New", 9))
|
|
self.text_view.pack(fill=tk.BOTH, expand=True)
|
|
|
|
def _on_function_select(self, event=None):
|
|
selection = self.tree.selection()
|
|
if not selection: return
|
|
item_id = selection[0]
|
|
selected_data = self.filtered_stats_data[int(item_id)]
|
|
func_info = selected_data[6]
|
|
|
|
self.callers_tree.delete(*self.callers_tree.get_children())
|
|
callers_data = self.analyzer.get_callers(func_info)
|
|
for timing_info, function_info in callers_data:
|
|
self.callers_tree.insert("", "end", values=(timing_info, function_info))
|
|
|
|
self.callees_tree.delete(*self.callees_tree.get_children())
|
|
callees_data = self.analyzer.get_callees(func_info)
|
|
for timing_info, function_info in callees_data:
|
|
self.callees_tree.insert("", "end", values=(timing_info, function_info))
|
|
|
|
def _clear_all_views(self):
|
|
self.tree.delete(*self.tree.get_children())
|
|
self.text_view.config(state=tk.NORMAL)
|
|
self.text_view.delete("1.0", tk.END)
|
|
self.text_view.config(state=tk.DISABLED)
|
|
self.callers_tree.delete(*self.callers_tree.get_children())
|
|
self.callees_tree.delete(*self.callees_tree.get_children())
|
|
|
|
if self.graphviz_available:
|
|
self.graph_canvas.delete("all")
|
|
self._cleanup_temp_files()
|
|
|
|
def _on_closing(self):
|
|
self._cleanup_temp_files()
|
|
self.master.destroy()
|
|
|
|
def _cleanup_temp_files(self):
|
|
if self.graph_image_path and os.path.exists(self.graph_image_path):
|
|
try:
|
|
os.remove(self.graph_image_path)
|
|
self.graph_image_path = None
|
|
except OSError as e:
|
|
print(f"Error removing temp file {self.graph_image_path}: {e}")
|
|
|
|
def _load_profile(self, filepath=None):
|
|
if not filepath:
|
|
filepath = filedialog.askopenfilename(title="Select a .prof file", filetypes=[("Profile files", "*.prof"), ("All files", "*.*")])
|
|
if not filepath: return
|
|
|
|
if self.analyzer.load_profile(filepath):
|
|
self.loaded_filepath_var.set(os.path.basename(filepath))
|
|
self.filter_var.set("")
|
|
self._clear_all_views()
|
|
self._update_stats_display()
|
|
if self.graphviz_available:
|
|
self._generate_and_display_graph()
|
|
else:
|
|
self.loaded_filepath_var.set("Failed to load profile.")
|
|
self._clear_all_views()
|
|
messagebox.showerror("Error", f"Could not load or parse profile file:\n{filepath}")
|
|
|
|
def _open_launch_manager(self):
|
|
manager_window = LaunchManagerWindow(self, self.profile_manager)
|
|
self.wait_window(manager_window)
|
|
if manager_window.selected_profile_to_run:
|
|
self._start_profiling_thread(manager_window.selected_profile_to_run)
|
|
|
|
def _open_compare_window(self):
|
|
compare_win = CompareWindow(self, self.analyzer)
|
|
compare_win.focus_set()
|
|
|
|
def _open_settings_window(self):
|
|
settings_win = SettingsWindow(self)
|
|
settings_win.focus_set()
|
|
|
|
def _start_profiling_thread(self, profile: LaunchProfile):
|
|
self.master.config(cursor="watch")
|
|
self.loaded_filepath_var.set(f"Profiling '{profile.name}' in progress...")
|
|
thread = threading.Thread(target=lambda: self._run_profiling_and_callback(profile), daemon=True)
|
|
thread.start()
|
|
|
|
def _run_profiling_and_callback(self, profile: LaunchProfile):
|
|
output_path = run_and_profile_script(profile)
|
|
self.master.after(0, self._on_profiling_complete, output_path)
|
|
|
|
def _on_profiling_complete(self, profile_path: Optional[str]):
|
|
self.master.config(cursor="")
|
|
if profile_path and os.path.exists(profile_path):
|
|
self._load_profile(filepath=profile_path)
|
|
else:
|
|
self.loaded_filepath_var.set("Profiling failed. Check console for errors.")
|
|
messagebox.showerror("Profiling Failed", "The script did not generate a valid profile file.")
|
|
|
|
def _on_header_click(self, column_id):
|
|
sort_key = self.COLUMN_SORT_MAP.get(column_id, column_id)
|
|
for display_name, key in self.SORT_OPTIONS.items():
|
|
if key == sort_key:
|
|
self.sort_by_var.set(display_name)
|
|
break
|
|
self._update_stats_display()
|
|
|
|
def _on_sort_change(self, event=None):
|
|
self._update_stats_display()
|
|
|
|
def _update_stats_display(self):
|
|
sort_key = self.SORT_OPTIONS.get(self.sort_by_var.get())
|
|
if not sort_key or not self.analyzer.stats:
|
|
self.current_stats_data = []
|
|
else:
|
|
self.current_stats_data = self.analyzer.get_stats(sort_by=sort_key, limit=500)
|
|
self._apply_filter_and_refresh_views()
|
|
|
|
def _apply_filter_and_refresh_views(self, *args):
|
|
filter_term = self.filter_var.get().lower()
|
|
|
|
if filter_term:
|
|
self.filtered_stats_data = [row for row in self.current_stats_data if filter_term in row[6].lower()]
|
|
else:
|
|
self.filtered_stats_data = self.current_stats_data
|
|
|
|
self.tree.delete(*self.tree.get_children())
|
|
for i, row in enumerate(self.filtered_stats_data):
|
|
ncalls, tottime, percentage, percall_tottime, cumtime, percall_cumtime, func_str, _, _ = row
|
|
|
|
tags = ()
|
|
if percentage > 10.0: tags = ('heat_critical',)
|
|
elif percentage > 2.0: tags = ('heat_high',)
|
|
elif percentage > 0.5: tags = ('heat_medium',)
|
|
|
|
formatted_row = (ncalls, f"{tottime:.6f}", f"{percentage:.2f}%", f"{percall_tottime:.6f}", f"{cumtime:.6f}", f"{percall_cumtime:.6f}", func_str)
|
|
self.tree.insert("", "end", iid=i, values=formatted_row, tags=tags)
|
|
|
|
self._update_text_view()
|
|
|
|
def _update_text_view(self):
|
|
self.text_view.config(state=tk.NORMAL)
|
|
self.text_view.delete("1.0", tk.END)
|
|
if not self.filtered_stats_data:
|
|
self.text_view.config(state=tk.DISABLED)
|
|
return
|
|
|
|
header = f"{'N-Calls':>12s} {'Total Time':>15s} {'% Time':>8s} {'Per Call':>12s} {'Cum. Time':>15s} {'Per Call':>12s} {'Function'}\n"
|
|
separator = f"{'-'*12} {'-'*15} {'-'*8} {'-'*12} {'-'*15} {'-'*12} {'-'*40}\n"
|
|
self.text_view.insert(tk.END, header)
|
|
self.text_view.insert(tk.END, separator)
|
|
|
|
for row in self.filtered_stats_data:
|
|
ncalls, tottime, percentage, percall_tottime, cumtime, percall_cumtime, func_str, _, _ = row
|
|
line = f"{str(ncalls):>12s} {tottime:15.6f} {percentage:7.2f}% {percall_tottime:12.6f} {cumtime:15.6f} {percall_cumtime:12.6f} {func_str}\n"
|
|
self.text_view.insert(tk.END, line)
|
|
|
|
self.text_view.config(state=tk.DISABLED)
|
|
|
|
def _export_to_csv(self):
|
|
if not self.filtered_stats_data:
|
|
messagebox.showwarning("No Data", "No data to export.")
|
|
return
|
|
filepath = filedialog.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV files", "*.csv"), ("All files", "*.*")], title="Save Profile Stats as CSV")
|
|
if not filepath: return
|
|
try:
|
|
with open(filepath, 'w', newline='', encoding='utf-8') as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow(["ncalls", "tottime", "percentage_tottime", "percall_tottime", "cumtime", "percall_cumtime", "function_details", "filepath", "line_number"])
|
|
writer.writerows(self.filtered_stats_data)
|
|
messagebox.showinfo("Export Successful", f"Stats successfully exported to:\n{filepath}")
|
|
except IOError as e:
|
|
messagebox.showerror("Export Error", f"Failed to save CSV file:\n{e}")
|
|
|
|
def _generate_and_display_graph(self):
|
|
if not self.analyzer.stats:
|
|
messagebox.showinfo("No Data", "Please load a profile file first.", parent=self)
|
|
return
|
|
self.master.config(cursor="watch")
|
|
threshold_value = self.graph_threshold_var.get() / 100.0
|
|
thread = threading.Thread(target=self._run_graph_generation, args=(threshold_value,), daemon=True)
|
|
thread.start()
|
|
|
|
def _run_graph_generation(self, threshold: float):
|
|
image_path = self.analyzer.generate_call_graph(threshold=threshold)
|
|
self.master.after(0, self._display_graph_image, image_path)
|
|
|
|
def _display_graph_image(self, image_path: Optional[str]):
|
|
self.master.config(cursor="")
|
|
self._cleanup_temp_files()
|
|
|
|
if not image_path:
|
|
messagebox.showerror("Graph Error", "Could not generate call graph. Check console for details.", parent=self)
|
|
return
|
|
|
|
self.graph_image_path = image_path
|
|
try:
|
|
img = Image.open(self.graph_image_path)
|
|
self.graph_photo_image = ImageTk.PhotoImage(img)
|
|
self.graph_canvas.delete("all")
|
|
self.graph_canvas.create_image(0, 0, anchor=tk.NW, image=self.graph_photo_image)
|
|
self.scrolled_canvas_frame.interior.update_idletasks()
|
|
self.graph_canvas.config(scrollregion=self.graph_canvas.bbox("all"))
|
|
except Exception as e:
|
|
messagebox.showerror("Display Error", f"Failed to display graph image:\n{e}", parent=self)
|
|
|
|
def _show_context_menu(self, event):
|
|
item_id = self.tree.identify_row(event.y)
|
|
if not item_id: return
|
|
|
|
self.tree.selection_set(item_id)
|
|
selected_data = self.filtered_stats_data[int(item_id)]
|
|
filepath = selected_data[7]
|
|
|
|
context_menu = tk.Menu(self.tree, tearoff=0)
|
|
state = "normal" if filepath and not filepath.startswith('~') and os.path.exists(filepath) else "disabled"
|
|
context_menu.add_command(label="Open in Editor", command=self._open_in_editor, state=state)
|
|
context_menu.tk_popup(event.x_root, event.y_root)
|
|
|
|
def _open_in_editor(self):
|
|
selection = self.tree.selection()
|
|
if not selection: return
|
|
|
|
item_id = selection[0]
|
|
selected_data = self.filtered_stats_data[int(item_id)]
|
|
filepath, line_number = selected_data[7], selected_data[8]
|
|
|
|
try:
|
|
with open(SettingsWindow.CONFIG_FILE, 'r', encoding='utf-8') as f:
|
|
config = json.load(f)
|
|
editor_command = config.get("editor_command", 'code -g "{file}:{line}"')
|
|
except (IOError, json.JSONDecodeError):
|
|
editor_command = 'code -g "{file}:{line}"'
|
|
|
|
command_to_run = editor_command.format(file=filepath, line=line_number)
|
|
print(f"Executing: {command_to_run}")
|
|
try:
|
|
subprocess.Popen(shlex_split(command_to_run))
|
|
except FileNotFoundError:
|
|
messagebox.showerror("Error", f"Could not execute command. Make sure the editor '{shlex_split(command_to_run)[0]}' is in your system's PATH.")
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to open editor: {e}") |