502 lines
20 KiB
Python
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)) |