import tkinter as tk from tkinter import ttk, messagebox from tkinter.scrolledtext import ScrolledText import os import numpy as np import matplotlib.pyplot as plt from matplotlib.figure import Figure from matplotlib.axes import Axes from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.patches import Rectangle from typing import Optional, Tuple # Radalyze specific imports from radalyze.config.manager import ConfigManager from radalyze.core.state import ApplicationState from radalyze.core import file_io, analysis, logger from radalyze.gui import dialogs, visualizer from radalyze.gui.profile_window import ProfileWindow from radalyze.gui.help_window import HelpWindow # --- Constants --- APP_NAME = "Radalyze" CONFIG_FILE = "radalyze_config.ini" DEFAULT_GEOMETRY = "1280x1024" class RadalyzeApp: """ The main class for the Radalyze data analysis application. This class initializes the main window, manages the application state, and connects all UI events to their corresponding logic. """ def __init__(self, root: tk.Tk): """ Initializes the main application. Args: root: The main Tkinter root window. """ self.root = root self._setup_window() # --- Core Components --- # Note: Logger must be set up early self._log_widget: Optional[ScrolledText] = None self._setup_logging() self.logger = logger.get_logger(__name__) self.state = ApplicationState() self.config_manager = ConfigManager(CONFIG_FILE) # --- Matplotlib Components --- self.fig: Figure self.ax: Axes self.canvas: FigureCanvasTkAgg # --- UI Interaction State --- self._status_text = tk.StringVar() self._profile_selection = tk.StringVar(value="row") self._zoom_start_pos: Optional[Tuple[float, float]] = None self._zoom_rect_patch: Optional[Rectangle] = None # --- Build UI --- self._create_widgets() self._connect_events() self.logger.info(f"{APP_NAME} started successfully.") self._load_configuration() def run(self) -> None: """Starts the Tkinter main event loop.""" self.root.mainloop() # --- Setup Methods --- def _setup_window(self) -> None: """Configures the main application window.""" self.root.title(APP_NAME) self.root.geometry(DEFAULT_GEOMETRY) def _setup_logging(self) -> None: """Initializes the application-wide logging system.""" # The text widget for logging will be created in _create_widgets # but the system is set up here. log_config = { "default_root_level": logger.logging.INFO, "format": "%(asctime)s [%(levelname)-8s] %(name)-20s: %(message)s", "date_format": "%H:%M:%S", "enable_console": True, "enable_file": False, # Can be enabled later "colors": { logger.logging.DEBUG: "gray", logger.logging.INFO: "black", logger.logging.WARNING: "orange", logger.logging.ERROR: "red", logger.logging.CRITICAL: "red", } } logger.setup_basic_logging(self.root, log_config) def _create_widgets(self) -> None: """Creates and arranges all UI components in the main window.""" # --- Main Layout Frames --- top_frame = ttk.Frame(self.root) top_frame.pack(side=tk.TOP, fill=tk.X) main_content_frame = ttk.Frame(self.root, padding=5) main_content_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) main_content_frame.grid_columnconfigure(0, weight=3) # Canvas takes more space main_content_frame.grid_columnconfigure(1, weight=1) # Sidebar main_content_frame.grid_rowconfigure(0, weight=1) # --- Toolbar --- self._create_toolbar(top_frame) # --- Matplotlib Canvas --- canvas_frame = ttk.Frame(main_content_frame) canvas_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 5)) self._create_matplotlib_canvas(canvas_frame) # --- Sidebar (Summary Table and Logger) --- sidebar_frame = ttk.Frame(main_content_frame) sidebar_frame.grid(row=0, column=1, sticky="nsew") sidebar_frame.grid_rowconfigure(0, weight=1) # Table sidebar_frame.grid_rowconfigure(1, weight=1) # Logger sidebar_frame.grid_columnconfigure(0, weight=1) self._create_summary_table(sidebar_frame) self._create_log_viewer(sidebar_frame) # --- Status Bar --- status_bar = ttk.Label( self.root, textvariable=self._status_text, relief=tk.SUNKEN, anchor=tk.W ) status_bar.pack(side=tk.BOTTOM, fill=tk.X) def _create_toolbar(self, parent: ttk.Frame) -> None: """Creates the main application toolbar.""" toolbar = ttk.Frame(parent, padding=5) toolbar.pack(side=tk.LEFT, fill=tk.X, expand=True) # --- File Operations --- open_button = ttk.Button(toolbar, text="Open File", command=self._open_file) open_button.pack(side=tk.LEFT, padx=2) # --- Visualization Controls --- dezoom_button = ttk.Button(toolbar, text="Reset Zoom", command=self._reset_zoom) dezoom_button.pack(side=tk.LEFT, padx=2) ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, padx=5, fill='y') cmap_label = ttk.Label(toolbar, text="Colormap:") cmap_label.pack(side=tk.LEFT, padx=(5, 2)) self.cmap_combo = ttk.Combobox( toolbar, values=sorted(plt.colormaps()), state="readonly", width=15 ) self.cmap_combo.pack(side=tk.LEFT) self.cmap_combo.bind("<>", self._change_colormap) # --- Domain Toggling --- toggle_domain_button = ttk.Button( toolbar, text="Toggle Domain (Freq/Space)", command=self._toggle_domain ) toggle_domain_button.pack(side=tk.LEFT, padx=5) ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, padx=5, fill='y') # --- Profile Selection --- profile_label = ttk.Label(toolbar, text="Profile ('p' key):") profile_label.pack(side=tk.LEFT) row_radio = ttk.Radiobutton( toolbar, text="Row", variable=self._profile_selection, value="row" ) col_radio = ttk.Radiobutton( toolbar, text="Column", variable=self._profile_selection, value="column" ) row_radio.pack(side=tk.LEFT, padx=2) col_radio.pack(side=tk.LEFT, padx=2) # --- Help Button --- help_button = ttk.Button(toolbar, text="Help", command=self._show_help) help_button.pack(side=tk.RIGHT, padx=5) def _create_matplotlib_canvas(self, parent: ttk.Frame) -> None: """Creates the Matplotlib figure and canvas.""" self.fig = Figure(figsize=(7, 5), dpi=100) self.ax = self.fig.add_subplot(111) self.ax.set_title("No data loaded") self.ax.grid(True) self.canvas = FigureCanvasTkAgg(self.fig, master=parent) canvas_widget = self.canvas.get_tk_widget() canvas_widget.pack(fill=tk.BOTH, expand=True) def _create_summary_table(self, parent: ttk.Frame) -> None: """Creates the summary statistics table.""" table_frame = ttk.LabelFrame(parent, text="Data Summary", padding=5) table_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 5)) table_frame.grid_rowconfigure(0, weight=1) table_frame.grid_columnconfigure(0, weight=1) self.summary_table = ttk.Treeview( table_frame, columns=("Statistic", "Value"), show="headings" ) self.summary_table.heading("Statistic", text="Statistic") self.summary_table.heading("Value", text="Value") self.summary_table.column("Statistic", width=120, anchor=tk.W) self.summary_table.column("Value", width=150, anchor=tk.W) # Add scrollbar scrollbar = ttk.Scrollbar( table_frame, orient=tk.VERTICAL, command=self.summary_table.yview ) self.summary_table.configure(yscrollcommand=scrollbar.set) self.summary_table.grid(row=0, column=0, sticky="nsew") scrollbar.grid(row=0, column=1, sticky="ns") def _create_log_viewer(self, parent: ttk.Frame) -> None: """Creates the text widget for viewing logs.""" log_frame = ttk.LabelFrame(parent, text="Log Messages", padding=5) log_frame.grid(row=1, column=0, sticky="nsew") log_frame.grid_rowconfigure(0, weight=1) log_frame.grid_columnconfigure(0, weight=1) self._log_widget = ScrolledText(log_frame, state=tk.DISABLED, wrap=tk.WORD) self._log_widget.grid(row=0, column=0, sticky="nsew") # Now that the widget exists, add the Tkinter handler to the logger log_config = logger.setup_basic_logging(self.root, None) # Retrieve existing config if self._log_widget: logger.add_tkinter_handler(self._log_widget, self.root, log_config or {}) def _connect_events(self) -> None: """Connects all application-level events.""" self.root.protocol("WM_DELETE_WINDOW", self._on_closing) # Matplotlib events self.canvas.mpl_connect('button_press_event', self._on_mouse_press) self.canvas.mpl_connect('button_release_event', self._on_mouse_release) self.canvas.mpl_connect('motion_notify_event', self._on_mouse_move) # Keyboard events self.root.bind("", lambda e: self._on_p_key_change(True)) self.root.bind("", lambda e: self._on_p_key_change(False)) # --- Configuration Management --- def _load_configuration(self) -> None: """Loads settings from the config file and applies them.""" try: config = self.config_manager.load_config() self.state.last_used_directory = config.get('last_directory', '.') self.state.current_colormap = config.get('last_colormap', 'viridis') self.cmap_combo.set(self.state.current_colormap) self.logger.info("Configuration loaded successfully.") except Exception as e: self.logger.error(f"Failed to load configuration: {e}") def _save_configuration(self) -> None: """Saves the current state to the config file.""" try: settings = { 'last_directory': self.state.last_used_directory, 'last_colormap': self.state.current_colormap } self.config_manager.save_config(settings) self.logger.info("Configuration saved.") except Exception as e: self.logger.error(f"Failed to save configuration: {e}") # --- UI Event Handlers --- def _on_closing(self) -> None: """Handles the window closing event.""" if messagebox.askokcancel("Quit", f"Do you want to quit {APP_NAME}?"): self.logger.info(f"{APP_NAME} is shutting down.") self._save_configuration() logger.shutdown_logging_system() self.root.destroy() def _open_file(self) -> None: """Handles the 'Open File' action.""" dialog = dialogs.DataFileSelectorDialog(self.root, self.state.last_used_directory) if not dialog.result: self.logger.info("File open dialog was cancelled.") return file_path = dialog.result["file_path"] data_type = dialog.result["data_type"] self.state.last_used_directory = os.path.dirname(file_path) self.logger.info(f"Attempting to load {data_type} from: {file_path}") try: if data_type == "Vector": data = file_io.load_vector_from_csv(file_path) self.state.matrix_data = None # Clear previous matrix data visualizer.visualize_vector(self.ax, data) elif data_type == "Matrix": data = file_io.load_matrix_from_csv(file_path) self.state.matrix_data = data visualizer.visualize_matrix( self.fig, self.ax, data, self.state.current_colormap ) else: raise ValueError(f"Unsupported data type: {data_type}") self.state.last_file_path = file_path self.state.last_data_type = data_type self.root.title(f"{APP_NAME} - {os.path.basename(file_path)}") # Analyze and display results analysis_results = analysis.analyze_data(data) self._update_summary_table(analysis_results) self.state.original_xlim = self.ax.get_xlim() self.state.original_ylim = self.ax.get_ylim() self.canvas.draw() self.logger.info("File loaded and visualized successfully.") except (FileNotFoundError, ValueError, IOError) as e: self.logger.error(f"Failed to load file '{file_path}': {e}") messagebox.showerror("File Load Error", str(e), parent=self.root) self.ax.clear() self.ax.set_title("Failed to load data") self.canvas.draw() def _change_colormap(self, event=None) -> None: """Updates the colormap for the matrix visualization.""" if self.state.last_data_type != "Matrix" or self.state.matrix_data is None: return new_cmap = self.cmap_combo.get() self.state.current_colormap = new_cmap self.logger.info(f"Changed colormap to '{new_cmap}'") # Preserve zoom current_xlim = self.ax.get_xlim() current_ylim = self.ax.get_ylim() visualizer.visualize_matrix( self.fig, self.ax, self.state.matrix_data, new_cmap ) self.ax.set_xlim(current_xlim) self.ax.set_ylim(current_ylim) self.canvas.draw() def _reset_zoom(self) -> None: """Resets the plot view to its original zoom level.""" if self.state.original_xlim and self.state.original_ylim: self.ax.set_xlim(self.state.original_xlim) self.ax.set_ylim(self.state.original_ylim) self.canvas.draw() self.logger.info("Zoom has been reset.") def _toggle_domain(self) -> None: """Switches between frequency and space domain for matrix data.""" if not self.state.matrix_data is not None: self.logger.warning("Toggle domain attempted with no matrix data.") return current_xlim = self.ax.get_xlim() current_ylim = self.ax.get_ylim() self.ax.clear() try: if self.state.current_domain == "frequencies": self.logger.info("Performing Inverse Fourier Transform.") image = analysis.inverse_fourier_transform(self.state.matrix_data) if image is None: raise RuntimeError("Inverse Fourier Transform failed.") im = self.ax.imshow( image, cmap=self.state.current_colormap, interpolation='nearest' ) self.ax.set_title("Image (Space Domain)") self.fig.colorbar(im, ax=self.ax, label="Magnitude") self.state.current_domain = "space" else: # From space back to frequencies self.logger.info("Reverting to original Frequency Domain data.") visualizer.visualize_matrix( self.fig, self.ax, self.state.matrix_data, self.state.current_colormap ) self.state.current_domain = "frequencies" self.ax.set_xlim(current_xlim) self.ax.set_ylim(current_ylim) self.canvas.draw() except Exception as e: self.logger.error(f"Error toggling domain: {e}") messagebox.showerror("Processing Error", f"Could not toggle domain: {e}") self._reset_zoom() # Attempt to restore a sane state def _show_help(self) -> None: """Displays the help window.""" HelpWindow(self.root) def _on_p_key_change(self, is_pressed: bool) -> None: """Handles the press/release of the 'p' key for profiling.""" self.state.is_profile_hotkey_pressed = is_pressed status_msg = "ON" if is_pressed else "OFF" self._status_text.set(f"Profile mode: {status_msg}") # --- Mouse Event Handlers --- def _on_mouse_press(self, event) -> None: """Handles mouse press events on the canvas for value display and zoom.""" if not event.inaxes or event.xdata is None or event.ydata is None: return # Left click: Value/Profile display if event.button == 1: if self.state.last_data_type == "Matrix" and self.state.matrix_data is not None: row, col = int(event.ydata), int(event.xdata) if 0 <= row < self.state.matrix_data.shape[0] and 0 <= col < self.state.matrix_data.shape[1]: if self.state.is_profile_hotkey_pressed: profile_type = self._profile_selection.get() if profile_type == "row": data = self.state.matrix_data[row, :] title = f"Profile of Row {row}" else: data = self.state.matrix_data[:, col] title = f"Profile of Column {col}" ProfileWindow(self.root, data, title) self.logger.info(f"Opened {title}") else: value = self.state.matrix_data[row, col] self._status_text.set(f"Row: {row}, Col: {col}, Value: {value:.3f}") else: self._status_text.set("Cursor is outside data bounds.") elif self.state.last_data_type == "Vector": self._status_text.set(f"X: {event.xdata:.2f}, Y: {event.ydata:.2f}") # Right click: Start zoom elif event.button == 3: self._zoom_start_pos = (event.xdata, event.ydata) self._zoom_rect_patch = Rectangle( self._zoom_start_pos, 0, 0, linewidth=1, edgecolor='r', facecolor='r', alpha=0.2 ) self.ax.add_patch(self._zoom_rect_patch) self.canvas.draw() def _on_mouse_release(self, event) -> None: """Handles mouse release for completing a zoom action.""" if event.button == 3 and self._zoom_start_pos and self._zoom_rect_patch: x_start, y_start = self._zoom_start_pos x_end, y_end = event.xdata, event.ydata # Clean up the rectangle patch self._zoom_rect_patch.remove() self._zoom_rect_patch = None self._zoom_start_pos = None if x_end is not None and y_end is not None: # Ensure zoom area has a non-zero size if abs(x_end - x_start) > 1e-6 and abs(y_end - y_start) > 1e-6: self.ax.set_xlim(sorted((x_start, x_end))) self.ax.set_ylim(sorted((y_start, y_end))) self.logger.info(f"Zoomed to X:{self.ax.get_xlim()} Y:{self.ax.get_ylim()}") self.canvas.draw() def _on_mouse_move(self, event) -> None: """Handles mouse movement for drawing the zoom rectangle.""" if self._zoom_start_pos and self._zoom_rect_patch and event.inaxes: x_start, y_start = self._zoom_start_pos x_end, y_end = event.xdata, event.ydata if x_end is not None and y_end is not None: self._zoom_rect_patch.set_width(x_end - x_start) self._zoom_rect_patch.set_height(y_end - y_start) self.canvas.draw_idle() # --- Data Display Methods --- def _update_summary_table(self, analysis_results: dict) -> None: """Clears and repopulates the summary table with new data.""" # Clear previous data for item in self.summary_table.get_children(): self.summary_table.delete(item) # Add new data for key, value in analysis_results.items(): if isinstance(value, tuple): value_str = str(value) elif isinstance(value, (int, np.integer)): value_str = f"{value}" elif isinstance(value, (float, np.floating)): value_str = f"{value:.4f}" else: value_str = str(value) # Capitalize and replace underscores for display display_key = key.replace("_", " ").capitalize() self.summary_table.insert("", tk.END, values=(display_key, value_str))