# FlightMonitor/gui/main_window.py """ Main window of the Flight Monitor application. Handles the layout with multiple notebooks for functions and views, user interactions, status display including a semaphore, and logging. """ import tkinter as tk from tkinter import ttk from tkinter import messagebox from tkinter.scrolledtext import ScrolledText from tkinter import font as tkFont from typing import List, Dict, Optional, Tuple, Any # Relative imports from ..data import ( config as fm_config, ) from ..utils.logger import get_logger, setup_logging, shutdown_gui_logging from ..data.common_models import CanonicalFlightState try: from ..map.map_canvas_manager import MapCanvasManager from ..map.map_utils import _is_valid_bbox_dict # Import helper for bbox validation MAP_CANVAS_MANAGER_AVAILABLE = True except ImportError as e_map_import: MapCanvasManager = None # type: ignore _is_valid_bbox_dict = lambda x: False # Provide a fallback lambda MAP_CANVAS_MANAGER_AVAILABLE = False print( f"CRITICAL ERROR in MainWindow: Failed to import MapCanvasManager or map_utils: {e_map_import}. Map functionality will be disabled." ) module_logger = get_logger(__name__) # --- Constants for Semaphore --- SEMAPHORE_SIZE = 12 SEMAPHORE_PAD = 3 SEMAPHORE_BORDER_WIDTH = 1 SEMAPHORE_TOTAL_SIZE = SEMAPHORE_SIZE + 2 * (SEMAPHORE_PAD + SEMAPHORE_BORDER_WIDTH) SEMAPHORE_COLOR_OK = "green3" SEMAPHORE_COLOR_WARNING = "gold" SEMAPHORE_COLOR_ERROR = "red2" SEMAPHORE_COLOR_UNKNOWN = "gray70" SEMAPHORE_COLOR_FETCHING = "sky blue" # Constants for GUI Status Levels (match AppController) GUI_STATUS_OK = "OK" GUI_STATUS_WARNING = "WARNING" GUI_STATUS_ERROR = "ERROR" GUI_STATUS_FETCHING = "FETCHING" GUI_STATUS_UNKNOWN = "UNKNOWN" FALLBACK_CANVAS_WIDTH = 800 FALLBACK_CANVAS_HEIGHT = 600 # Colors for bounding box coordinate display BBOX_COLOR_INSIDE = "green4" BBOX_COLOR_OUTSIDE = "red2" BBOX_COLOR_PARTIAL = "darkorange" # For partial containment BBOX_COLOR_NA = "gray50" class MainWindow: """ Main window of the Flight Monitor application. Handles layout with function and view notebooks, interactions, status, and logging. """ def __init__( self, root: tk.Tk, controller: Any ): self.root = root self.controller = controller self.root.title("Flight Monitor") self.canvas_width = getattr(fm_config, 'DEFAULT_CANVAS_WIDTH', FALLBACK_CANVAS_WIDTH) self.canvas_height = getattr(fm_config, 'DEFAULT_CANVAS_HEIGHT', FALLBACK_CANVAS_HEIGHT) controls_estimated_height = 50 bbox_estimated_height = 70 log_area_min_height = 100 map_min_height = 300 status_bar_actual_height = 30 info_panel_min_width = 300 # Increased for new info fields min_function_notebook_content_height = ( controls_estimated_height + bbox_estimated_height + 30 ) min_paned_top_horizontal_panel_height = min_function_notebook_content_height min_views_notebook_panel_height = ( map_min_height + 30 ) min_paned_bottom_panel_height = ( log_area_min_height + status_bar_actual_height + 10 ) min_total_height = ( min_paned_top_horizontal_panel_height + min_views_notebook_panel_height + min_paned_bottom_panel_height + 30 ) min_func_nb_width = 400 min_total_width = ( min_func_nb_width + info_panel_min_width + 30 ) if min_total_width < 700: min_total_width = 700 self.root.minsize(min_total_width, min_total_height) module_logger.debug( f"Minimum window size set to: {min_total_width}x{min_total_height}" ) initial_func_nb_width = 450 initial_info_panel_width = 350 # Increased for new info fields initial_width = ( initial_func_nb_width + initial_info_panel_width + 20 ) if initial_width < min_total_width: initial_width = min_total_width initial_height = 800 if initial_height < min_total_height: initial_height = min_total_height self.root.geometry(f"{initial_width}x{initial_height}") module_logger.debug( f"Initial window size set to: {initial_width}x{initial_height}" ) # --- Main Layout: PanedWindow Verticale --- self.main_paned_window = ttk.PanedWindow(self.root, orient=tk.VERTICAL) self.main_paned_window.pack( fill=tk.BOTH, expand=True, padx=5, pady=5 ) # --- 1. Pannello Superiore Orizzontale (per Controlli e Info Mappa) --- self.top_horizontal_paned_window = ttk.PanedWindow( self.main_paned_window, orient=tk.HORIZONTAL ) self.main_paned_window.add(self.top_horizontal_paned_window, weight=1) # --- 1a. Pannello Sinistro del Top Horizontal Paned Window (Function Notebook) --- self.left_control_panel_frame = ttk.Frame( self.top_horizontal_paned_window, padding=(0, 0, 5, 0) ) self.top_horizontal_paned_window.add(self.left_control_panel_frame, weight=3) self.function_notebook = ttk.Notebook(self.left_control_panel_frame) self.function_notebook.pack( fill=tk.BOTH, expand=True ) # --- Schede del Function Notebook --- self.live_bbox_tab_frame = ttk.Frame(self.function_notebook, padding=5) self.function_notebook.add(self.live_bbox_tab_frame, text="Live: Area Monitor") self.controls_frame = ttk.Frame(self.live_bbox_tab_frame) self.controls_frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 5)) self.control_frame = ttk.LabelFrame( self.controls_frame, text="Controls", padding=(10, 5) ) self.control_frame.pack(side=tk.TOP, fill=tk.X) self.mode_var = tk.StringVar(value="Live") self.live_radio = ttk.Radiobutton( self.control_frame, text="Live", variable=self.mode_var, value="Live", command=self._on_mode_change, ) self.live_radio.pack(side=tk.LEFT, padx=(0, 5)) self.history_radio = ttk.Radiobutton( self.control_frame, text="History", variable=self.mode_var, value="History", command=self._on_mode_change, ) self.history_radio.pack(side=tk.LEFT, padx=5) self.start_button = ttk.Button( self.control_frame, text="Start Monitoring", command=self._start_monitoring ) self.start_button.pack(side=tk.LEFT, padx=5) self.stop_button = ttk.Button( self.control_frame, text="Stop Monitoring", command=self._stop_monitoring, state=tk.DISABLED, ) self.stop_button.pack(side=tk.LEFT, padx=5) self.bbox_frame = ttk.LabelFrame( self.controls_frame, text="Geographic Area (Bounding Box)", padding=(10, 5) ) self.bbox_frame.pack(side=tk.TOP, fill=tk.X, pady=5) self.bbox_frame.columnconfigure(1, weight=1) self.bbox_frame.columnconfigure(3, weight=1) ttk.Label(self.bbox_frame, text="Lat Min:").grid( row=0, column=0, padx=(0, 2), pady=2, sticky=tk.W ) self.lat_min_var = tk.StringVar(value=str(fm_config.DEFAULT_BBOX_LAT_MIN)) self.lat_min_entry = ttk.Entry( self.bbox_frame, textvariable=self.lat_min_var ) self.lat_min_entry.grid(row=0, column=1, padx=(0, 5), pady=2, sticky=tk.EW) ttk.Label(self.bbox_frame, text="Lon Min:").grid( row=0, column=2, padx=(5, 2), pady=2, sticky=tk.W ) self.lon_min_var = tk.StringVar(value=str(fm_config.DEFAULT_BBOX_LON_MIN)) self.lon_min_entry = ttk.Entry( self.bbox_frame, textvariable=self.lon_min_var ) self.lon_min_entry.grid(row=0, column=3, padx=(0, 0), pady=2, sticky=tk.EW) ttk.Label(self.bbox_frame, text="Lat Max:").grid( row=1, column=0, padx=(0, 2), pady=2, sticky=tk.W ) self.lat_max_var = tk.StringVar(value=str(fm_config.DEFAULT_BBOX_LAT_MAX)) self.lat_max_entry = ttk.Entry( self.bbox_frame, textvariable=self.lat_max_var ) self.lat_max_entry.grid(row=1, column=1, padx=(0, 5), pady=2, sticky=tk.EW) ttk.Label(self.bbox_frame, text="Lon Max:").grid( row=1, column=2, padx=(5, 2), pady=2, sticky=tk.W ) self.lon_max_var = tk.StringVar(value=str(fm_config.DEFAULT_BBOX_LON_MAX)) self.lon_max_entry = ttk.Entry( self.bbox_frame, textvariable=self.lon_max_var ) self.lon_max_entry.grid(row=1, column=3, padx=(0, 0), pady=2, sticky=tk.EW) self.live_airport_tab_frame = ttk.Frame(self.function_notebook, padding=5) self.function_notebook.add(self.live_airport_tab_frame, text="Live: Airport") ttk.Label( self.live_airport_tab_frame, text="Live from Airport - Coming Soon", font=("Arial", 10), ).pack(expand=True) self.history_tab_frame = ttk.Frame(self.function_notebook, padding=5) self.function_notebook.add(self.history_tab_frame, text="History") ttk.Label( self.history_tab_frame, text="History Analysis - Coming Soon", font=("Arial", 10), ).pack(expand=True) self.function_notebook.bind( "<>", self._on_function_tab_change ) # --- 1b. Pannello Destro del Top Horizontal Paned Window (Info Mappa) --- self.map_info_panel_frame = ttk.LabelFrame( self.top_horizontal_paned_window, text="Map Information", padding=10 ) self.top_horizontal_paned_window.add(self.map_info_panel_frame, weight=1) # Configure grid for map info panel self.map_info_panel_frame.columnconfigure(1, weight=1) # Values column expands self.map_info_panel_frame.columnconfigure(2, weight=1) # BBox values column expands self.map_info_panel_frame.columnconfigure(3, weight=1) # BBox values column expands # Info: Clicked Coordinates info_row = 0 ttk.Label(self.map_info_panel_frame, text="Click Lat:").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) self.info_lat_value = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_lat_value.grid(row=info_row, column=1, sticky=tk.W, pady=2) ttk.Label(self.map_info_panel_frame, text="Click Lon:").grid(row=info_row, column=2, sticky=tk.W, pady=2, padx=(5, 2)) self.info_lon_value = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_lon_value.grid(row=info_row, column=3, sticky=tk.W, pady=2) info_row += 1 ttk.Label(self.map_info_panel_frame, text="Lat DMS:").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) self.info_lat_dms_value = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_lat_dms_value.grid(row=info_row, column=1, sticky=tk.W, pady=2) ttk.Label(self.map_info_panel_frame, text="Lon DMS:").grid(row=info_row, column=2, sticky=tk.W, pady=2, padx=(5, 2)) self.info_lon_dms_value = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_lon_dms_value.grid(row=info_row, column=3, sticky=tk.W, pady=2) info_row += 1 ttk.Separator(self.map_info_panel_frame, orient=tk.HORIZONTAL).grid(row=info_row, column=0, columnspan=4, sticky=tk.EW, pady=5) # Info: Current Map View Bounds info_row += 1 ttk.Label(self.map_info_panel_frame, text="Map Bounds:").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) ttk.Label(self.map_info_panel_frame, text="W:").grid(row=info_row, column=1, sticky=tk.E, pady=2, padx=(0,2)) self.info_map_bounds_w = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_map_bounds_w.grid(row=info_row, column=2, sticky=tk.W, pady=2, padx=(0,5)) info_row += 1 ttk.Label(self.map_info_panel_frame, text="").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) # Placeholder label ttk.Label(self.map_info_panel_frame, text="S:").grid(row=info_row, column=1, sticky=tk.E, pady=2, padx=(0,2)) self.info_map_bounds_s = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_map_bounds_s.grid(row=info_row, column=2, sticky=tk.W, pady=2, padx=(0,5)) info_row += 1 ttk.Label(self.map_info_panel_frame, text="").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) # Placeholder label ttk.Label(self.map_info_panel_frame, text="E:").grid(row=info_row, column=1, sticky=tk.E, pady=2, padx=(0,2)) self.info_map_bounds_e = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_map_bounds_e.grid(row=info_row, column=2, sticky=tk.W, pady=2, padx=(0,5)) info_row += 1 ttk.Label(self.map_info_panel_frame, text="").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) # Placeholder label ttk.Label(self.map_info_panel_frame, text="N:").grid(row=info_row, column=1, sticky=tk.E, pady=2, padx=(0,2)) self.info_map_bounds_n = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_map_bounds_n.grid(row=info_row, column=2, sticky=tk.W, pady=2, padx=(0,5)) info_row += 1 ttk.Separator(self.map_info_panel_frame, orient=tk.HORIZONTAL).grid(row=info_row, column=0, columnspan=4, sticky=tk.EW, pady=5) # Info: Target BBox info_row += 1 ttk.Label(self.map_info_panel_frame, text="Target BBox:").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) ttk.Label(self.map_info_panel_frame, text="W:").grid(row=info_row, column=1, sticky=tk.E, pady=2, padx=(0,2)) self.info_target_bbox_w = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_target_bbox_w.grid(row=info_row, column=2, sticky=tk.W, pady=2, padx=(0,5)) info_row += 1 ttk.Label(self.map_info_panel_frame, text="").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) # Placeholder label ttk.Label(self.map_info_panel_frame, text="S:").grid(row=info_row, column=1, sticky=tk.E, pady=2, padx=(0,2)) self.info_target_bbox_s = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_target_bbox_s.grid(row=info_row, column=2, sticky=tk.W, pady=2, padx=(0,5)) info_row += 1 ttk.Label(self.map_info_panel_frame, text="").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) # Placeholder label ttk.Label(self.map_info_panel_frame, text="E:").grid(row=info_row, column=1, sticky=tk.E, pady=2, padx=(0,2)) self.info_target_bbox_e = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_target_bbox_e.grid(row=info_row, column=2, sticky=tk.W, pady=2, padx=(0,5)) info_row += 1 ttk.Label(self.map_info_panel_frame, text="").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) # Placeholder label ttk.Label(self.map_info_panel_frame, text="N:").grid(row=info_row, column=1, sticky=tk.E, pady=2, padx=(0,2)) self.info_target_bbox_n = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_target_bbox_n.grid(row=info_row, column=2, sticky=tk.W, pady=2, padx=(0,5)) info_row += 1 ttk.Separator(self.map_info_panel_frame, orient=tk.HORIZONTAL).grid(row=info_row, column=0, columnspan=4, sticky=tk.EW, pady=5) # Info: Map Zoom and Size info_row += 1 ttk.Label(self.map_info_panel_frame, text="Map Zoom:").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) self.info_zoom_value = ttk.Label(self.map_info_panel_frame, text="N/A", width=10) # Keep some width hint self.info_zoom_value.grid(row=info_row, column=1, sticky=tk.W, pady=2) ttk.Label(self.map_info_panel_frame, text="Map Size (km):").grid(row=info_row, column=2, sticky=tk.W, pady=2, padx=(5, 2)) self.info_map_size_value = ttk.Label(self.map_info_panel_frame, text="N/A", width=20, wraplength=150) # Keep some width hint self.info_map_size_value.grid(row=info_row, column=3, sticky=tk.W, pady=2) # Info: Flight Count info_row += 1 ttk.Label(self.map_info_panel_frame, text="Flights Shown:").grid(row=info_row, column=0, sticky=tk.W, pady=2, padx=(0, 2)) self.info_flight_count_value = ttk.Label(self.map_info_panel_frame, text="N/A") self.info_flight_count_value.grid(row=info_row, column=1, sticky=tk.W, pady=2) # --- 2. Pannello Centrale (per Views Notebook - Mappa) --- self.views_notebook_outer_frame = ttk.Frame( self.main_paned_window ) self.main_paned_window.add(self.views_notebook_outer_frame, weight=5) self.views_notebook = ttk.Notebook(self.views_notebook_outer_frame) self.views_notebook.pack(fill=tk.BOTH, expand=True, padx=0, pady=0) self.map_view_frame = ttk.Frame(self.views_notebook, padding=0) self.views_notebook.add(self.map_view_frame, text="Map View") self.flight_canvas = tk.Canvas( self.map_view_frame, bg="gray60", width=100, height=100, highlightthickness=0, ) self.flight_canvas.pack(fill=tk.BOTH, expand=True) self.map_manager_instance: Optional[MapCanvasManager] = None if MAP_CANVAS_MANAGER_AVAILABLE and MapCanvasManager is not None: default_map_bbox = { "lat_min": fm_config.DEFAULT_BBOX_LAT_MIN, "lon_min": fm_config.DEFAULT_BBOX_LON_MIN, "lat_max": fm_config.DEFAULT_BBOX_LAT_MAX, "lon_max": fm_config.DEFAULT_BBOX_LON_MAX, } self.root.after(50, self._initialize_map_manager, default_map_bbox) else: module_logger.error( "MapCanvasManager class not available. Map display will be a placeholder." ) self.root.after( 50, lambda: self._update_map_placeholder( "Map functionality disabled (Import Error)." ), ) self.table_view_frame = ttk.Frame(self.views_notebook, padding=5) self.views_notebook.add(self.table_view_frame, text="Table View") ttk.Label( self.table_view_frame, text="Table View - Coming Soon", font=("Arial", 12) ).pack(expand=True) self.views_notebook.bind("<>", self._on_view_tab_change) # --- 3. Pannello Inferiore (Log e Status Bar) --- self.paned_bottom_panel = ttk.Frame(self.main_paned_window) self.main_paned_window.add(self.paned_bottom_panel, weight=2) self.status_bar_frame = ttk.Frame(self.paned_bottom_panel, padding=(5, 3)) self.status_bar_frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 5)) self.semaphore_canvas = tk.Canvas( self.status_bar_frame, width=SEMAPHORE_TOTAL_SIZE, height=SEMAPHORE_TOTAL_SIZE, bg=self.root.cget("bg"), highlightthickness=0, ) self.semaphore_canvas.pack(side=tk.LEFT, padx=(0, 5)) x0 = SEMAPHORE_PAD + SEMAPHORE_BORDER_WIDTH y0 = SEMAPHORE_PAD + SEMAPHORE_BORDER_WIDTH x1 = x0 + SEMAPHORE_SIZE y1 = y0 + SEMAPHORE_SIZE self._semaphore_oval_id = self.semaphore_canvas.create_oval( x0, y0, x1, y1, fill=SEMAPHORE_COLOR_UNKNOWN, outline="gray30", width=SEMAPHORE_BORDER_WIDTH, ) self.status_label = ttk.Label( self.status_bar_frame, text="Status: Initializing..." ) self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 2)) self.log_frame = ttk.Frame(self.paned_bottom_panel, padding=(5, 0, 5, 5)) self.log_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=0, pady=0) log_font_family = ( "Consolas" if "Consolas" in tkFont.families() else "Courier New" ) self.log_text_widget = ScrolledText( self.log_frame, state=tk.DISABLED, height=10, wrap=tk.WORD, font=(log_font_family, 9), relief=tk.SUNKEN, borderwidth=1, ) self.log_text_widget.pack(fill=tk.BOTH, expand=True, padx=0, pady=0) from ..data.logging_config import LOGGING_CONFIG setup_logging( gui_log_widget=self.log_text_widget, root_tk_instance=self.root, logging_config_dict=LOGGING_CONFIG, ) self.root.protocol("WM_DELETE_WINDOW", self._on_closing) self.root.after( 10, self._on_mode_change ) self.update_semaphore_and_status("OK", "System Initialized. Ready.") module_logger.info("MainWindow fully initialized and displayed.") def _initialize_map_manager(self, initial_bbox_for_map: Dict[str, float]): if not MAP_CANVAS_MANAGER_AVAILABLE or MapCanvasManager is None: module_logger.error( "Attempted to initialize map manager, but MapCanvasManager class is not available." ) self._update_map_placeholder("Map Error: MapCanvasManager class missing.") if self.controller and hasattr(self.controller, "update_general_map_info"): self.controller.update_general_map_info() return canvas_w = self.flight_canvas.winfo_width() canvas_h = self.flight_canvas.winfo_height() if canvas_w <= 1: canvas_w = self.canvas_width if canvas_h <= 1: canvas_h = self.canvas_height if canvas_w > 1 and canvas_h > 1: module_logger.info( f"Canvas is ready ({canvas_w}x{canvas_h}), initializing MapCanvasManager." ) try: self.map_manager_instance = MapCanvasManager( app_controller=self.controller, tk_canvas=self.flight_canvas, initial_bbox_dict=initial_bbox_for_map, ) if self.controller and hasattr( self.controller, "update_general_map_info" ): self.controller.update_general_map_info() except ImportError as e_imp: module_logger.critical( f"Failed to initialize MapCanvasManager due to missing libraries during its init: {e_imp}", exc_info=True, ) self.show_error_message( "Map Error", f"Map libraries missing for manager: {e_imp}. Map functionality disabled.", ) self._update_map_placeholder(f"Map Error: Libraries missing.\n{e_imp}") if self.controller and hasattr( self.controller, "update_general_map_info" ): self.controller.update_general_map_info() except Exception as e_init: module_logger.critical( f"Failed to initialize MapCanvasManager: {e_init}", exc_info=True ) self.show_error_message( "Map Error", f"Could not initialize map: {e_init}" ) self._update_map_placeholder( f"Map Error: Initialization failed.\n{e_init}" ) if self.controller and hasattr( self.controller, "update_general_map_info" ): self.controller.update_general_map_info() else: module_logger.warning( f"Canvas not ready yet for MapCanvasManager initialization (dims: {canvas_w}x{canvas_h}), retrying..." ) self.root.after(200, self._initialize_map_manager, initial_bbox_for_map) def update_semaphore_and_status(self, status_level: str, message: str): color_to_set = SEMAPHORE_COLOR_UNKNOWN if status_level == GUI_STATUS_OK: color_to_set = SEMAPHORE_COLOR_OK elif status_level == GUI_STATUS_WARNING: color_to_set = SEMAPHORE_COLOR_WARNING elif status_level == GUI_STATUS_ERROR: color_to_set = SEMAPHORE_COLOR_ERROR elif status_level == GUI_STATUS_FETCHING: color_to_set = SEMAPHORE_COLOR_FETCHING if hasattr(self, "semaphore_canvas") and self.semaphore_canvas.winfo_exists(): try: self.semaphore_canvas.itemconfig( self._semaphore_oval_id, fill=color_to_set ) except tk.TclError: pass current_status_text = f"Status: {message}" if hasattr(self, "status_label") and self.status_label.winfo_exists(): try: self.status_label.config(text=current_status_text) except tk.TclError: pass if status_level in [GUI_STATUS_ERROR] or ( "error" in message.lower() and status_level != GUI_STATUS_WARNING ): module_logger.error( f"GUI Status Update: Level='{status_level}', Message='{message}'" ) elif status_level in [GUI_STATUS_WARNING]: module_logger.warning( f"GUI Status Update: Level='{status_level}', Message='{message}'" ) else: module_logger.debug( f"GUI Status Update: Level='{status_level}', Message='{message}'" ) def _on_closing(self): module_logger.info("Main window closing event triggered.") if ( hasattr(self, "root") and self.root.winfo_exists() and messagebox.askokcancel( "Quit", "Do you want to quit Flight Monitor?", parent=self.root ) ): module_logger.info("User confirmed quit.") if self.controller and hasattr(self.controller, "on_application_exit"): self.controller.on_application_exit() app_destroyed_msg = "Application window will be destroyed." module_logger.info(app_destroyed_msg) if ( hasattr(self, "root") and self.root.winfo_exists() and hasattr(self, "log_text_widget") and self.log_text_widget.winfo_exists() ): shutdown_gui_logging() if hasattr(self, "root") and self.root.winfo_exists(): self.root.destroy() print( f"INFO ({__name__}): Application window has been destroyed (post-destroy print)." ) else: module_logger.info("User cancelled quit.") def _reset_gui_to_stopped_state( self, status_message: Optional[str] = "Monitoring stopped." ): if hasattr(self, "start_button") and self.start_button.winfo_exists(): self.start_button.config(state=tk.NORMAL) if hasattr(self, "stop_button") and self.stop_button.winfo_exists(): self.stop_button.config(state=tk.DISABLED) if hasattr(self, "live_radio") and self.live_radio.winfo_exists(): self.live_radio.config(state=tk.NORMAL) if hasattr(self, "history_radio") and self.history_radio.winfo_exists(): self.history_radio.config(state=tk.NORMAL) current_mode_is_live = ( hasattr(self, "mode_var") and self.mode_var.get() == "Live" ) self._set_bbox_entries_state(tk.NORMAL if current_mode_is_live else tk.DISABLED) status_level_for_semaphore = GUI_STATUS_OK if status_message and ( "failed" in status_message.lower() or "error" in status_message.lower() ): status_level_for_semaphore = GUI_STATUS_ERROR elif status_message and "warning" in status_message.lower(): status_level_for_semaphore = GUI_STATUS_WARNING if hasattr(self, "root") and self.root.winfo_exists(): self.update_semaphore_and_status(status_level_for_semaphore, status_message) else: module_logger.debug( "_reset_gui_to_stopped_state: Root window does not exist, skipping status update." ) module_logger.debug( f"GUI controls reset to stopped state. Status: '{status_message}'" ) def _should_show_main_placeholder(self) -> bool: if ( hasattr(self, "map_manager_instance") and self.map_manager_instance is not None ): return False return True def _update_map_placeholder(self, text_to_display: str): if not self._should_show_main_placeholder() or not ( hasattr(self, "flight_canvas") and self.flight_canvas.winfo_exists() ): return try: if hasattr(self, "flight_canvas") and self.flight_canvas.winfo_exists(): self.flight_canvas.delete( "placeholder_text" ) module_logger.debug( f"MainWindow updating placeholder with: '{text_to_display}'" ) canvas_w = self.flight_canvas.winfo_width() canvas_h = self.flight_canvas.winfo_height() if canvas_w <= 1: canvas_w = self.canvas_width if canvas_h <= 1: canvas_h = self.canvas_height if canvas_w > 1 and canvas_h > 1: self.flight_canvas.create_text( canvas_w / 2, canvas_h / 2, text=text_to_display, tags="placeholder_text", fill="gray50", width=canvas_w - 40, ) module_logger.debug( f"Drew placeholder text on canvas {canvas_w}x{canvas_h}." ) else: module_logger.warning( f"Cannot draw placeholder text: Canvas dimensions invalid ({canvas_w}x{canvas_h})." ) except tk.TclError: module_logger.warning("TclError updating map placeholder in MainWindow.") except Exception as e_placeholder: module_logger.error( f"Unexpected error in _update_map_placeholder: {e_placeholder}", exc_info=True, ) def _on_mode_change(self): selected_mode = self.mode_var.get() status_message = f"Mode: {selected_mode}. Ready." if hasattr(self, "function_notebook") and self.function_notebook.winfo_exists(): try: live_bbox_tab_index = -1 history_tab_index = -1 if hasattr(self, "live_bbox_tab_frame"): live_bbox_tab_index = self.function_notebook.index( self.live_bbox_tab_frame ) if hasattr(self, "history_tab_frame"): history_tab_index = self.function_notebook.index( self.history_tab_frame ) if live_bbox_tab_index != -1 and history_tab_index != -1: if selected_mode == "Live": self.function_notebook.tab(live_bbox_tab_index, state="normal") self.function_notebook.tab(history_tab_index, state="disabled") current_func_tab_index = self.function_notebook.index("current") if current_func_tab_index == history_tab_index: self.function_notebook.select(live_bbox_tab_index) elif selected_mode == "History": self.function_notebook.tab( live_bbox_tab_index, state="disabled" ) self.function_notebook.tab(history_tab_index, state="normal") current_func_tab_index = self.function_notebook.index("current") if current_func_tab_index != history_tab_index: self.function_notebook.select(history_tab_index) else: module_logger.warning( "Could not find live_bbox_tab_frame or history_tab_frame indexes in function notebook." ) except Exception as e_tab_change: module_logger.warning( f"Error updating function notebook tabs state: {e_tab_change}" ) self.clear_all_views_data() placeholder_text_to_set = "Map Area." if selected_mode == "Live": placeholder_text_to_set = ( "Map Area - Ready for live data. Define area and press Start." ) self._set_bbox_entries_state(tk.NORMAL) elif selected_mode == "History": placeholder_text_to_set = ( "Map Area - Ready for historical data. (Functionality TBD)" ) self._set_bbox_entries_state(tk.DISABLED) self._update_map_placeholder(placeholder_text_to_set) if hasattr(self, "root") and self.root.winfo_exists(): self.update_semaphore_and_status(GUI_STATUS_OK, status_message) def _on_function_tab_change( self, event: Any = None ): if not ( hasattr(self, "function_notebook") and self.function_notebook.winfo_exists() ): return try: selected_tab_index = self.function_notebook.index("current") tab_text = self.function_notebook.tab(selected_tab_index, "text") module_logger.info(f"GUI: Switched function tab to: {tab_text}") placeholder_text = f"Selected tab: {tab_text} - Ready." if "Live: Area Monitor" in tab_text: placeholder_text = ( "Map Area - Ready for live data. Define area and press Start." ) self._set_bbox_entries_state(tk.NORMAL) # Ensure bbox entries are enabled when on Live Area tab elif "Live: Airport" in tab_text: placeholder_text = ( "Map Area - Live Airport Monitor. (Functionality TBD)" ) self._set_bbox_entries_state(tk.DISABLED) # Disable bbox entries on other Live tabs elif "History" in tab_text: placeholder_text = "Map Area - History Analysis. (Functionality TBD)" self._set_bbox_entries_state(tk.DISABLED) # Disable bbox entries on History tab self._update_map_placeholder( placeholder_text ) except tk.TclError: module_logger.warning( "TclError on function tab change, notebook state might be inconsistent." ) except ValueError: module_logger.warning( "ValueError on function tab change, 'current' tab not found." ) def _on_view_tab_change(self, event: Any = None): if not (hasattr(self, "views_notebook") and self.views_notebook.winfo_exists()): return try: selected_tab_index = self.views_notebook.index("current") tab_text = self.views_notebook.tab(selected_tab_index, "text") module_logger.info(f"GUI: Switched view tab to: {tab_text}") except Exception as e_view_tab: module_logger.warning(f"Error on view tab change: {e_view_tab}") def _set_bbox_entries_state(self, state: str): if hasattr(self, "lat_min_entry") and self.lat_min_entry.winfo_exists(): self.lat_min_entry.config(state=state) if hasattr(self, "lon_min_entry") and self.lon_min_entry.winfo_exists(): self.lon_min_entry.config(state=state) if hasattr(self, "lat_max_entry") and self.lat_max_entry.winfo_exists(): self.lat_max_entry.config(state=state) if hasattr(self, "lon_max_entry") and self.lon_max_entry.winfo_exists(): self.lon_max_entry.config(state=state) module_logger.debug(f"Bounding box entries state set to: {state}") def _start_monitoring(self): selected_mode = self.mode_var.get() module_logger.info(f"GUI: User requested to start {selected_mode} monitoring.") active_func_tab_text = "Unknown Tab" if hasattr(self, "function_notebook") and self.function_notebook.winfo_exists(): try: active_func_tab_index = self.function_notebook.index("current") active_func_tab_text = self.function_notebook.tab( active_func_tab_index, "text" ) module_logger.debug( f"GUI: Active function tab is: {active_func_tab_text}" ) except Exception: pass if hasattr(self, "start_button") and self.start_button.winfo_exists(): self.start_button.config(state=tk.DISABLED) if hasattr(self, "stop_button") and self.stop_button.winfo_exists(): self.stop_button.config(state=tk.NORMAL) if hasattr(self, "live_radio") and self.live_radio.winfo_exists(): self.live_radio.config(state=tk.DISABLED) if hasattr(self, "history_radio") and self.history_radio.winfo_exists(): self.history_radio.config(state=tk.DISABLED) if selected_mode == "Live": if "Live: Area Monitor" in active_func_tab_text: bounding_box = self.get_bounding_box() if bounding_box: module_logger.debug( f"Valid bounding box for live monitoring: {bounding_box}" ) if self.controller: self.controller.start_live_monitoring(bounding_box) else: module_logger.critical("Controller not available.") self._reset_gui_to_stopped_state( "Critical Error: Controller unavailable." ) self.show_error_message( "Internal Error", "Application controller is missing." ) else: self._reset_gui_to_stopped_state( "Monitoring not started: Invalid bounding box details." ) else: module_logger.warning( f"GUI: Start pressed in Live mode, but active tab '{active_func_tab_text}' is not the Live Area Monitor tab." ) self.update_semaphore_and_status( GUI_STATUS_WARNING, f"Start not supported on '{active_func_tab_text}' tab in Live mode.", ) self._reset_gui_to_stopped_state( f"Start not supported on {active_func_tab_text} tab." ) elif selected_mode == "History": if "History" in active_func_tab_text: if self.controller: self.controller.start_history_monitoring() module_logger.info("GUI: History monitoring started (placeholder).") else: module_logger.warning( f"GUI: Start pressed in History mode, but active tab '{active_func_tab_text}' is not the History tab." ) self.update_semaphore_and_status( GUI_STATUS_WARNING, f"Start not supported on '{active_func_tab_text}' tab in History mode.", ) self._reset_gui_to_stopped_state( f"Start not supported on {active_func_tab_text} tab." ) self._set_bbox_entries_state(tk.DISABLED) def _stop_monitoring(self): module_logger.info("GUI: User requested to stop monitoring.") selected_mode = self.mode_var.get() if self.controller: if selected_mode == "Live": self.controller.stop_live_monitoring() elif selected_mode == "History": self.controller.stop_history_monitoring() if hasattr(self, "root") and self.root.winfo_exists(): self.update_semaphore_and_status( GUI_STATUS_OK, f"{selected_mode} monitoring stopping..." ) def get_bounding_box(self) -> Optional[Dict[str, float]]: module_logger.debug("Attempting to retrieve and validate bounding box.") if not ( hasattr(self, "lat_min_var") and hasattr(self, "lon_min_var") and hasattr(self, "lat_max_var") and hasattr(self, "lon_max_var") ): module_logger.error("BBox entry variables are not initialized.") self.show_error_message( "Internal Error", "Bounding box input fields not ready." ) return None try: lat_min = float(self.lat_min_var.get()) lon_min = float(self.lon_min_var.get()) lat_max = float(self.lat_max_var.get()) lon_max = float(self.lon_max_var.get()) except ValueError: msg = "Bounding box coordinates must be valid numbers." module_logger.error(f"Input Error (ValueError): {msg}", exc_info=False) self.show_error_message("Input Error", msg) return None bbox_dict = { "lat_min": lat_min, "lon_min": lon_min, "lat_max": lat_max, "lon_max": lon_max, } # Use the imported helper function for validation if not _is_valid_bbox_dict(bbox_dict): # The helper logs warnings for specific failures. # Show a general error message to the user. msg = "Invalid geographic range or order for Bounding Box. Check values and ensure Min < Max for both Lat and Lon." module_logger.error(f"Validation Error: {msg}") self.show_error_message("Input Error", msg) return None module_logger.debug( f"Validated BBox input: {bbox_dict}" ) return bbox_dict def update_bbox_gui_fields(self, bbox_dict: Dict[str, float]): """Updates the bounding box input fields in the GUI.""" if not hasattr(self, "lat_min_var"): module_logger.warning("BBox GUI variables not ready for update.") return if bbox_dict and _is_valid_bbox_dict(bbox_dict): self.lat_min_var.set(f"{bbox_dict['lat_min']:.{fm_config.COORDINATE_DECIMAL_PLACES}f}") self.lon_min_var.set(f"{bbox_dict['lon_min']:.{fm_config.COORDINATE_DECIMAL_PLACES}f}") self.lat_max_var.set(f"{bbox_dict['lat_max']:.{fm_config.COORDINATE_DECIMAL_PLACES}f}") self.lon_max_var.set(f"{bbox_dict['lon_max']:.{fm_config.COORDINATE_DECIMAL_PLACES}f}") module_logger.debug(f"Updated BBox GUI fields to: {bbox_dict}") else: module_logger.warning(f"Invalid or empty bbox_dict provided for GUI update: {bbox_dict}") self.lat_min_var.set("N/A") self.lon_min_var.set("N/A") self.lat_max_var.set("N/A") self.lon_max_var.set("N/A") def display_flights_on_canvas( self, flight_states: List[CanonicalFlightState], _active_bounding_box_context: Dict[str, float], # This is the API request BBox ): if not ( hasattr(self, "map_manager_instance") and self.map_manager_instance is not None ): module_logger.warning( "MapCanvasManager not initialized, cannot display flights on map." ) return try: self.map_manager_instance.update_flights_on_map(flight_states) except Exception as e_map_update: module_logger.error( f"Error updating flights on map via MapCanvasManager: {e_map_update}", exc_info=True, ) self.show_error_message( "Map Display Error", "Could not update flights on map." ) def clear_all_views_data(self): """Clears all displayed flight data from all view tabs.""" if ( hasattr(self, "map_manager_instance") and self.map_manager_instance is not None ): try: self.map_manager_instance.update_flights_on_map( [] ) except Exception as e_map_clear: module_logger.warning( f"Error clearing flights from map manager: {e_map_clear}" ) else: if hasattr(self, "root") and self.root.winfo_exists(): self._update_map_placeholder("Map system not available.") module_logger.debug("Cleared data from views (map delegated to map manager).") def show_error_message(self, title: str, message: str): module_logger.error( f"Displaying error message to user: Title='{title}', Message='{message}'" ) status_bar_msg = f"Error: {message[:70]}" if len(message) > 70: status_bar_msg += "..." if hasattr(self, "root") and self.root.winfo_exists(): if self.root.winfo_exists(): self.update_semaphore_and_status(GUI_STATUS_ERROR, status_bar_msg) else: module_logger.warning( "Root window does not exist, skipping status update." ) else: module_logger.warning( "Root window does not exist, skipping status update and messagebox." ) print(f"ERROR: {title} - {message}") if hasattr(self, "root") and self.root.winfo_exists(): messagebox.showerror(title, message, parent=self.root) def show_map_context_menu( self, latitude: float, longitude: float, screen_x: int, screen_y: int ): module_logger.info( f"Placeholder: Show context menu for map click at Lat {latitude:.4f}, Lon {longitude:.4f}" ) if self.controller and hasattr(self.controller, "on_map_context_menu_request"): self.controller.on_map_context_menu_request(latitude, longitude, screen_x, screen_y) else: module_logger.warning("Controller or context menu request handler not available.") # Fallback to basic status update if controller method doesn't exist if hasattr(self, "root") and self.root.winfo_exists(): self.update_semaphore_and_status( GUI_STATUS_OK, f"Context click: Lat {latitude:.3f}, Lon {longitude:.3f}" ) def _center_map_at_coords(self, lat: float, lon: float): module_logger.info( f"Placeholder: Request to center map at {lat:.4f}, {lon:.4f}" ) if ( hasattr(self, "map_manager_instance") and self.map_manager_instance is not None and hasattr(self.map_manager_instance, "recenter_map_at_coords") ): try: self.map_manager_instance.recenter_map_at_coords(lat, lon) except Exception as e_recenter: module_logger.error(f"Error calling map_manager_instance.recenter_map_at_coords: {e_recenter}", exc_info=False) self.show_error_message("Map Error", "Failed to recenter map.") else: module_logger.warning("MapCanvasManager or recenter_map_at_coords method not available, cannot center map.") def _is_bbox_inside_bbox(self, inner_bbox: Dict[str, float], outer_bbox: Tuple[float, float, float, float]) -> str: """ Checks if the inner_bbox is fully inside, partially inside, or outside the outer_bbox. Returns "Inside", "Partial", or "Outside". """ if not _is_valid_bbox_dict(inner_bbox) or outer_bbox is None or len(outer_bbox) != 4: return "N/A" # Cannot determine containment # Convert outer_bbox tuple to dict for easier comparison outer_bbox_dict = {"lon_min": outer_bbox[0], "lat_min": outer_bbox[1], "lon_max": outer_bbox[2], "lat_max": outer_bbox[3]} # Check if the inner bbox is fully contained within the outer bbox (ignoring antimeridian for simplicity here) is_fully_inside = ( inner_bbox["lon_min"] >= outer_bbox_dict["lon_min"] and inner_bbox["lat_min"] >= outer_bbox_dict["lat_min"] and inner_bbox["lon_max"] <= outer_bbox_dict["lon_max"] and inner_bbox["lat_max"] <= outer_bbox_dict["lat_max"] ) if is_fully_inside: return "Inside" # Check for any overlap (partial containment) # Two boxes overlap if they overlap on both axes. # They DON'T overlap if one is entirely to the left/right/above/below the other. # Check for no overlap: no_overlap = ( inner_bbox["lon_max"] < outer_bbox_dict["lon_min"] or inner_bbox["lon_min"] > outer_bbox_dict["lon_max"] or inner_bbox["lat_max"] < outer_bbox_dict["lat_min"] or inner_bbox["lat_min"] > outer_bbox_dict["lat_max"] ) if no_overlap: return "Outside" else: return "Partial" def update_map_info_panel( self, lat_deg: Optional[float], lon_deg: Optional[float], lat_dms: str, lon_dms: str, zoom: Optional[int], map_size_str: str, map_geo_bounds: Optional[Tuple[float, float, float, float]], # New: Map's actual geo bounds target_bbox_input: Optional[Dict[str, float]], # New: User's input bbox flight_count: Optional[int] # New: Number of flights shown ): """Aggiorna le etichette nel pannello informazioni mappa.""" if not hasattr(self, "info_lat_value"): module_logger.warning("Map info panel widgets not ready for update.") return # Update clicked coordinates info if hasattr(self, "info_lat_value") and self.info_lat_value.winfo_exists(): self.info_lat_value.config(text=f"{lat_deg:.{fm_config.COORDINATE_DECIMAL_PLACES}f}" if lat_deg is not None else "N/A") if hasattr(self, "info_lon_value") and self.info_lon_value.winfo_exists(): self.info_lon_value.config(text=f"{lon_deg:.{fm_config.COORDINATE_DECIMAL_PLACES}f}" if lon_deg is not None else "N/A") if hasattr(self, "info_lat_dms_value") and self.info_lat_dms_value.winfo_exists(): self.info_lat_dms_value.config(text=lat_dms) if hasattr(self, "info_lon_dms_value") and self.info_lon_dms_value.winfo_exists(): self.info_lon_dms_value.config(text=lon_dms) # Update current map view bounds info if map_geo_bounds: map_w, map_s, map_e, map_n = map_geo_bounds # MODIFIED: Use configured decimal places for coordinate display. # WHY: Consistent formatting. # HOW: Added format specifier. map_w_str = f"{map_w:.{fm_config.COORDINATE_DECIMAL_PLACES}f}" map_s_str = f"{map_s:.{fm_config.COORDINATE_DECIMAL_PLACES}f}" map_e_str = f"{map_e:.{fm_config.COORDINATE_DECIMAL_PLACES}f}" map_n_str = f"{map_n:.{fm_config.COORDINATE_DECIMAL_PLACES}f}" else: map_w_str, map_s_str, map_e_str, map_n_str = "N/A", "N/A", "N/A", "N/A" if hasattr(self, "info_map_bounds_w") and self.info_map_bounds_w.winfo_exists(): self.info_map_bounds_w.config(text=map_w_str) if hasattr(self, "info_map_bounds_s") and self.info_map_bounds_s.winfo_exists(): self.info_map_bounds_s.config(text=map_s_str) if hasattr(self, "info_map_bounds_e") and self.info_map_bounds_e.winfo_exists(): self.info_map_bounds_e.config(text=map_e_str) if hasattr(self, "info_map_bounds_n") and self.info_map_bounds_n.winfo_exists(): self.info_map_bounds_n.config(text=map_n_str) # Update target BBox info and color based on containment target_bbox_w_str, target_bbox_s_str, target_bbox_e_str, target_bbox_n_str = "N/A", "N/A", "N/A", "N/A" bbox_color = BBOX_COLOR_NA # Default color containment_status = "N/A" if target_bbox_input and _is_valid_bbox_dict(target_bbox_input): # MODIFIED: Use configured decimal places for coordinate display. target_bbox_w_str = f"{target_bbox_input['lon_min']:.{fm_config.COORDINATE_DECIMAL_PLACES}f}" target_bbox_s_str = f"{target_bbox_input['lat_min']:.{fm_config.COORDINATE_DECIMAL_PLACES}f}" target_bbox_e_str = f"{target_bbox_input['lon_max']:.{fm_config.COORDINATE_DECIMAL_PLACES}f}" target_bbox_n_str = f"{target_bbox_input['lat_max']:.{fm_config.COORDINATE_DECIMAL_PLACES}f}" if map_geo_bounds: containment_status = self._is_bbox_inside_bbox(target_bbox_input, map_geo_bounds) if containment_status == "Inside": bbox_color = BBOX_COLOR_INSIDE elif containment_status == "Partial": bbox_color = BBOX_COLOR_PARTIAL else: bbox_color = BBOX_COLOR_OUTSIDE # "Outside" or other cases # Update labels for Target BBox bbox_labels_to_update = [ self.info_target_bbox_w, self.info_target_bbox_s, self.info_target_bbox_e, self.info_target_bbox_n ] bbox_values = [target_bbox_w_str, target_bbox_s_str, target_bbox_e_str, target_bbox_n_str] for label, value in zip(bbox_labels_to_update, bbox_values): if hasattr(label, "winfo_exists") and label.winfo_exists(): label.config(text=value, foreground=bbox_color) # Set text and color # Update Map Zoom, Size, and Flight Count if hasattr(self, "info_zoom_value") and self.info_zoom_value.winfo_exists(): self.info_zoom_value.config(text=str(zoom) if zoom is not None else "N/A") if hasattr(self, "info_map_size_value") and self.info_map_size_value.winfo_exists(): # MODIFIED: Use configured decimal places for map size. # WHY: Consistent formatting. # HOW: Added format specifier. map_size_formatted = f"{map_size_str:.{fm_config.MAP_SIZE_KM_DECIMAL_PLACES}f}" if isinstance(map_size_str, (int, float)) else map_size_str # Format only if number self.info_map_size_value.config(text=map_size_formatted) # map_size_str should already be formatted string from controller if hasattr(self, "info_flight_count_value") and self.info_flight_count_value.winfo_exists(): self.info_flight_count_value.config(text=str(flight_count) if flight_count is not None else "N/A") # Log the updated information for debugging module_logger.debug( f"Map info panel updated: ClickLat={lat_deg}, ClickLon={lon_deg}, Zoom={zoom}, MapSize='{map_size_str}', " f"MapBounds=({map_w_str},{map_s_str},{map_e_str},{map_n_str}), " f"TargetBBox=({target_bbox_w_str},{target_bbox_s_str},{target_bbox_e_str},{target_bbox_n_str}) [{containment_status}], " f"Flights={flight_count}." )