SXXXXXXX_Radalyze/radalyze/gui/main_window.py
2025-06-13 14:13:29 +02:00

502 lines
20 KiB
Python

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("<<ComboboxSelected>>", 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("<KeyPress-p>", lambda e: self._on_p_key_change(True))
self.root.bind("<KeyRelease-p>", 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))