# 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 import filedialog from tkinter import Menu from tkinter.scrolledtext import ScrolledText from tkinter import font as tkFont from typing import List, Dict, Optional, Tuple, Any import logging # Import standard logging import os from datetime import datetime, timezone import webbrowser from ..data import config as app_config # MODIFIED: Import add_tkinter_handler and shutdown_logging_system from our logger module # Also import get_logger for module-level logging # Import LOGGING_CONFIG to pass to add_tkinter_handler from ..utils.logger import get_logger, add_tkinter_handler, shutdown_logging_system # MODIFIED HERE from ..data.logging_config import LOGGING_CONFIG # MODIFIED HERE from ..data.common_models import CanonicalFlightState from ..utils.gui_utils import ( GUI_STATUS_OK, GUI_STATUS_WARNING, GUI_STATUS_ERROR, GUI_STATUS_FETCHING, GUI_STATUS_UNKNOWN, GUI_STATUS_PERMANENT_FAILURE, SEMAPHORE_COLOR_STATUS_MAP, ) try: from ..map.map_canvas_manager import MapCanvasManager from ..map.map_utils import _is_valid_bbox_dict MAP_CANVAS_MANAGER_AVAILABLE = True except ImportError as e_map_import: MapCanvasManager = None # type: ignore _is_valid_bbox_dict = lambda x: False # type: ignore MAP_CANVAS_MANAGER_AVAILABLE = False # Use standard print for early critical errors if logger not fully up print( f"CRITICAL ERROR in MainWindow import: Failed to import MapCanvasManager or map_utils: {e_map_import}. Map functionality will be disabled.", flush=True ) try: from .dialogs.import_progress_dialog import ImportProgressDialog IMPORT_DIALOG_AVAILABLE = True except ImportError as e_dialog_import: ImportProgressDialog = None # type: ignore IMPORT_DIALOG_AVAILABLE = False print( f"ERROR in MainWindow import: Failed to import ImportProgressDialog: {e_dialog_import}. Import progress UI will be basic.", flush=True ) module_logger = get_logger(__name__) # Get logger for this module SEMAPHORE_SIZE = 12 SEMAPHORE_PAD = 3 SEMAPHORE_BORDER_WIDTH = 1 SEMAPHORE_TOTAL_SIZE = SEMAPHORE_SIZE + 2 * (SEMAPHORE_PAD + SEMAPHORE_BORDER_WIDTH) FALLBACK_CANVAS_WIDTH = getattr(app_config, "DEFAULT_CANVAS_WIDTH", 800) FALLBACK_CANVAS_HEIGHT = getattr(app_config, "DEFAULT_CANVAS_HEIGHT", 600) BBOX_COLOR_INSIDE = "green4" BBOX_COLOR_OUTSIDE = "red2" BBOX_COLOR_PARTIAL = "darkorange" BBOX_COLOR_NA = "gray50" DEFAULT_TRACK_LENGTH = getattr(app_config, "DEFAULT_TRACK_HISTORY_POINTS", 20) class MainWindow: def __init__(self, root: tk.Tk, controller: Any): self.root = root self.controller = controller self.root.title("Flight Monitor") self.progress_dialog: Optional[ImportProgressDialog] = None self.full_flight_details_window: Optional[tk.Toplevel] = ( None ) if app_config.LAYOUT_START_MAXIMIZED: try: self.root.state("zoomed") except tk.TclError: try: self.root.geometry( f"{self.root.winfo_screenwidth()}x{self.root.winfo_screenheight()}+0+0" ) except tk.TclError: pass min_win_w = getattr(app_config, "LAYOUT_WINDOW_MIN_WIDTH", 900) min_win_h = getattr(app_config, "LAYOUT_WINDOW_MIN_HEIGHT", 650) self.root.minsize(min_win_w, min_win_h) self.canvas_width = getattr( app_config, "DEFAULT_CANVAS_WIDTH", FALLBACK_CANVAS_WIDTH ) self.canvas_height = getattr( app_config, "DEFAULT_CANVAS_HEIGHT", FALLBACK_CANVAS_HEIGHT ) self.menubar = Menu(self.root) self.file_menu = Menu(self.menubar, tearoff=0) self.file_menu.add_command( label="Import Aircraft Database (CSV)...", command=self._import_aircraft_db_csv, ) self.file_menu.add_separator() self.file_menu.add_command(label="Exit", command=self._on_closing) self.menubar.add_cascade(label="File", menu=self.file_menu) self.root.config(menu=self.menubar) self.main_horizontal_paned_window = ttk.PanedWindow( self.root, orient=tk.HORIZONTAL ) self.main_horizontal_paned_window.pack( fill=tk.BOTH, expand=True, padx=5, pady=5 ) self.left_column_frame = ttk.Frame(self.main_horizontal_paned_window) self.main_horizontal_paned_window.add( self.left_column_frame, weight=app_config.LAYOUT_MAIN_HORIZONTAL_WEIGHTS.get("left_column", 25), ) self.left_vertical_paned_window = ttk.PanedWindow( self.left_column_frame, orient=tk.VERTICAL ) self.left_vertical_paned_window.pack(fill=tk.BOTH, expand=True) self.function_notebook_frame = ttk.Frame(self.left_vertical_paned_window) self.left_vertical_paned_window.add( self.function_notebook_frame, weight=app_config.LAYOUT_LEFT_VERTICAL_WEIGHTS.get("function_notebook", 65), ) self.function_notebook = ttk.Notebook(self.function_notebook_frame) self.function_notebook.pack(fill=tk.BOTH, expand=True, padx=2, pady=2) 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.live_controls_options_frame = ttk.Frame(self.live_bbox_tab_frame) self.live_controls_options_frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 5)) self.controls_frame_live_area = ttk.Frame(self.live_controls_options_frame) self.controls_frame_live_area.pack(side=tk.TOP, fill=tk.X) self.control_frame = ttk.LabelFrame( self.controls_frame_live_area, 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_live_area, 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(app_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(app_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(app_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(app_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.track_options_frame = ttk.LabelFrame( self.live_controls_options_frame, text="Track Options", padding=(10, 5) ) self.track_options_frame.pack(side=tk.TOP, fill=tk.X, pady=(10, 0)) ttk.Label(self.track_options_frame, text="Track Length (points):").pack( side=tk.LEFT, padx=(0, 5) ) self.track_length_var = tk.IntVar(value=DEFAULT_TRACK_LENGTH) self.track_length_spinbox = ttk.Spinbox( self.track_options_frame, from_=2, to=100, textvariable=self.track_length_var, width=5, command=self._on_track_length_change, state="readonly", ) self.track_length_spinbox.pack(side=tk.LEFT, padx=5) 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 ) self.log_status_area_frame = ttk.Frame(self.left_vertical_paned_window) self.left_vertical_paned_window.add( self.log_status_area_frame, weight=app_config.LAYOUT_LEFT_VERTICAL_WEIGHTS.get("log_status_area", 35), ) self.status_bar_frame = ttk.Frame(self.log_status_area_frame, 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, y0, x1, y1 = ( SEMAPHORE_PAD + SEMAPHORE_BORDER_WIDTH, SEMAPHORE_PAD + SEMAPHORE_BORDER_WIDTH, SEMAPHORE_PAD + SEMAPHORE_BORDER_WIDTH + SEMAPHORE_SIZE, SEMAPHORE_PAD + SEMAPHORE_BORDER_WIDTH + SEMAPHORE_SIZE, ) self._semaphore_oval_id = self.semaphore_canvas.create_oval( x0, y0, x1, y1, fill=SEMAPHORE_COLOR_STATUS_MAP.get(GUI_STATUS_UNKNOWN, "gray70"), 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.log_status_area_frame, 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) self.right_column_frame = ttk.Frame(self.main_horizontal_paned_window) self.main_horizontal_paned_window.add( self.right_column_frame, weight=app_config.LAYOUT_MAIN_HORIZONTAL_WEIGHTS.get("right_column", 75), ) self.right_vertical_paned_window = ttk.PanedWindow( self.right_column_frame, orient=tk.VERTICAL ) self.right_vertical_paned_window.pack(fill=tk.BOTH, expand=True) self.views_notebook_outer_frame = ttk.Frame(self.right_vertical_paned_window) self.right_vertical_paned_window.add( self.views_notebook_outer_frame, weight=app_config.LAYOUT_RIGHT_VERTICAL_WEIGHTS.get("views_notebook", 80), ) self.views_notebook = ttk.Notebook(self.views_notebook_outer_frame) self.views_notebook.pack(fill=tk.BOTH, expand=True, padx=0, pady=(2, 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=self.canvas_width, height=self.canvas_height, highlightthickness=0, ) self.flight_canvas.pack(fill=tk.BOTH, expand=True) self.map_manager_instance: Optional[MapCanvasManager] = None 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) self.map_tools_info_area_frame = ttk.Frame( self.right_vertical_paned_window, padding=(0, 5, 0, 0) ) self.right_vertical_paned_window.add( self.map_tools_info_area_frame, weight=app_config.LAYOUT_RIGHT_VERTICAL_WEIGHTS.get("map_tools_info", 20), ) self.bottom_panel_container = ttk.Frame(self.map_tools_info_area_frame) self.bottom_panel_container.pack(fill=tk.BOTH, expand=True) bottom_panel_weights = getattr( app_config, "LAYOUT_BOTTOM_PANELS_HORIZONTAL_WEIGHTS", {"map_tools": 1, "map_info": 2, "flight_details": 2}, ) self.bottom_panel_container.columnconfigure( 0, weight=bottom_panel_weights.get("map_tools", 1) ) self.bottom_panel_container.columnconfigure( 1, weight=bottom_panel_weights.get("map_info", 2) ) self.bottom_panel_container.columnconfigure( 2, weight=bottom_panel_weights.get("flight_details", 2) ) self.map_tool_frame = ttk.LabelFrame( self.bottom_panel_container, text="Map Tools", padding=5 ) self.map_tool_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 2)) self._recreate_map_tools_content(self.map_tool_frame) self.map_info_panel_frame = ttk.LabelFrame( self.bottom_panel_container, text="Map Information", padding=10 ) self.map_info_panel_frame.grid(row=0, column=1, sticky="nsew", padx=2) self._recreate_map_info_content(self.map_info_panel_frame) self.selected_flight_details_frame = ttk.LabelFrame( self.bottom_panel_container, text="Selected Flight Details", padding=10 ) self.selected_flight_details_frame.grid( row=0, column=2, sticky="nsew", padx=(2, 0) ) self._create_selected_flight_details_content(self.selected_flight_details_frame) # MODIFIED: Call add_tkinter_handler instead of setup_logging # This assumes setup_basic_logging has already been called in __main__.py if self.log_text_widget and self.root: # Ensure widgets are created add_tkinter_handler( # MODIFIED HERE gui_log_widget=self.log_text_widget, root_tk_instance_for_gui_handler=self.root, # Pass self.root logging_config_dict=LOGGING_CONFIG # Pass the imported config ) else: module_logger.error("log_text_widget or root not available in MainWindow init for logger setup.") self.root.protocol("WM_DELETE_WINDOW", self._on_closing) self.root.after(100, self._delayed_initialization) module_logger.info( "MainWindow basic structure initialized. Delayed init pending." ) def _on_closing(self): module_logger.info("Main window closing event triggered.") user_confirmed_quit = ( messagebox.askokcancel( "Quit", "Do you want to quit Flight Monitor?", parent=self.root ) if self.root.winfo_exists() else True ) if user_confirmed_quit: if self.controller and hasattr(self.controller, "on_application_exit"): try: self.controller.on_application_exit() except Exception as e: module_logger.error( f"Error during controller.on_application_exit: {e}", exc_info=True, ) try: # shutdown_logging_system() is called here to ensure all handlers are closed # and remaining logs are processed before the application fully exits. shutdown_logging_system() # MODIFIED: Ensured this is called except Exception as e: module_logger.error( f"Error during shutdown_logging_system: {e}", exc_info=True ) if hasattr(self, "root") and self.root.winfo_exists(): try: self.root.destroy() except Exception: pass else: module_logger.info("User cancelled quit.") # ... (il resto dei metodi di MainWindow rimane invariato) ... # ... (_delayed_initialization, _initialize_map_manager, _recreate_map_tools_content, etc.) # COPIA QUI TUTTI GLI ALTRI METODI DI MainWindow OMETTENDO SOLO __init__ e _on_closing GIA' RIPORTATI SOPRA def _import_aircraft_db_csv(self): if not self.controller: self.show_error_message( "Controller Error", "Application controller not available." ) module_logger.error("Controller not available for aircraft DB import.") return if not hasattr( self.controller, "import_aircraft_database_from_file_with_progress" ): self.show_error_message( "Function Error", "Progress import function not available in controller.", ) module_logger.error( "Method 'import_aircraft_database_from_file_with_progress' missing in controller." ) return filepath = filedialog.askopenfilename( master=self.root, title="Select Aircraft Database CSV File", filetypes=(("CSV files", "*.csv"), ("All files", "*.*")), ) if filepath: module_logger.info(f"GUI: Selected CSV file for import: {filepath}") if IMPORT_DIALOG_AVAILABLE and ImportProgressDialog is not None: if self.progress_dialog and self.progress_dialog.winfo_exists(): try: self.progress_dialog.destroy() except tk.TclError: module_logger.warning( "Could not destroy previous progress dialog cleanly." ) self.progress_dialog = ImportProgressDialog( self.root, file_name=filepath ) self.controller.import_aircraft_database_from_file_with_progress( filepath, self.progress_dialog ) else: module_logger.warning( "ImportProgressDialog class not available. Using basic status update for import." ) if hasattr( self.controller, "import_aircraft_database_from_file" ): # Fallback self.show_info_message( "Import Started", f"Starting import of {os.path.basename(filepath)}...\nThis might take a while. Check logs for completion.", ) self.controller.import_aircraft_database_from_file(filepath) # type: ignore else: self.show_error_message( "Import Error", "Import progress UI is not available and no fallback import method found.", ) else: module_logger.info("GUI: CSV import cancelled by user.") def _show_full_flight_details_action(self): icao_to_show = None if ( hasattr(self, "flight_detail_labels") and "icao24" in self.flight_detail_labels ): label_widget = self.flight_detail_labels["icao24"] if label_widget.winfo_exists(): current_icao_text = label_widget.cget("text") if current_icao_text != "N/A" and current_icao_text.strip(): icao_to_show = current_icao_text if icao_to_show: if self.controller and hasattr( self.controller, "request_and_show_full_flight_details" ): module_logger.info( f"Requesting full details window for ICAO: {icao_to_show}" ) self.controller.request_and_show_full_flight_details(icao_to_show) else: module_logger.error( "Controller or method 'request_and_show_full_flight_details' not available." ) self.show_error_message( "Error", "Cannot open full details window (controller issue)." ) else: self.show_info_message( "No Flight Selected", "Please select a flight on the map first to see full details.", ) module_logger.warning( "Full details button clicked, but no flight ICAO found in details panel." ) def _delayed_initialization(self): if not self.root.winfo_exists(): module_logger.warning( "Root window destroyed before delayed initialization." ) return # MODIFIED: Moved set_map_track_length to be called AFTER map_manager_instance is created # inside _initialize_map_manager or ensure controller handles it if map_manager not ready. # For now, we'll ensure _initialize_map_manager handles the initial track length. if MAP_CANVAS_MANAGER_AVAILABLE and MapCanvasManager is not None: default_map_bbox = { "lat_min": app_config.DEFAULT_BBOX_LAT_MIN, "lon_min": app_config.DEFAULT_BBOX_LON_MIN, "lat_max": app_config.DEFAULT_BBOX_LAT_MAX, "lon_max": app_config.DEFAULT_BBOX_LON_MAX, } # Pass initial track length to _initialize_map_manager initial_track_len = self.track_length_var.get() if hasattr(self, "track_length_var") else DEFAULT_TRACK_LENGTH self.root.after(200, self._initialize_map_manager, default_map_bbox, initial_track_len) else: module_logger.error("MapCanvasManager class not available post-init.") self.root.after( 50, lambda: self._update_map_placeholder( "Map functionality disabled (Import Error)." ), ) self.root.after(10, self._on_mode_change) # Ensure mode change updates UI correctly module_logger.info("MainWindow fully initialized.") def _initialize_map_manager(self, initial_bbox_for_map: Dict[str, float], initial_track_length: int): # Added initial_track_length if not MAP_CANVAS_MANAGER_AVAILABLE or MapCanvasManager is None: self._update_map_placeholder("Map Error: Manager class missing.") if self.controller and hasattr(self.controller, "update_general_map_info"): self.controller.update_general_map_info() return if not self.flight_canvas.winfo_exists(): module_logger.warning("Flight canvas does not exist. Cannot initialize MapCanvasManager.") return canvas_w, canvas_h = ( self.flight_canvas.winfo_width(), 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: try: self.map_manager_instance = MapCanvasManager( app_controller=self.controller, tk_canvas=self.flight_canvas, initial_bbox_dict=initial_bbox_for_map, ) # MODIFIED: Set track length AFTER map_manager_instance is created if self.controller and hasattr(self.controller, "set_map_track_length"): try: self.controller.set_map_track_length(initial_track_length) module_logger.info(f"Initial map track length set to {initial_track_length} via controller.") except Exception as e_trk: module_logger.error( f"Error setting initial track length for map manager: {e_trk}" ) else: module_logger.warning("Controller or set_map_track_length not available for initial setup.") 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: Init failed.\n{e_init}") if self.controller and hasattr(self.controller, "update_general_map_info"): self.controller.update_general_map_info() else: if self.root.winfo_exists(): # Retry if canvas not sized yet module_logger.info("Canvas not sized yet, retrying map manager initialization.") self.root.after(300, self._initialize_map_manager, initial_bbox_for_map, initial_track_length) def _recreate_map_tools_content(self, parent_frame: ttk.Frame): controls_map_container = ttk.Frame(parent_frame) controls_map_container.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) zoom_frame = ttk.Frame(controls_map_container) zoom_frame.pack(side=tk.TOP, pady=(0, 10)) ttk.Button(zoom_frame, text="Zoom In (+)", command=self._map_zoom_in).pack( side=tk.LEFT, padx=2 ) ttk.Button(zoom_frame, text="Zoom Out (-)", command=self._map_zoom_out).pack( side=tk.LEFT, padx=2 ) pan_frame = ttk.Frame(controls_map_container) pan_frame.pack(side=tk.TOP, pady=(0, 10)) ttk.Button(pan_frame, text="Up", command=lambda: self._map_pan("up")).grid( row=0, column=1, padx=2, pady=2 ) ttk.Button(pan_frame, text="Left", command=lambda: self._map_pan("left")).grid( row=1, column=0, padx=2, pady=2 ) ttk.Button( pan_frame, text="Right", command=lambda: self._map_pan("right") ).grid(row=1, column=2, padx=2, pady=2) ttk.Button(pan_frame, text="Down", command=lambda: self._map_pan("down")).grid( row=2, column=1, padx=2, pady=2 ) pan_frame.columnconfigure(0, weight=1) pan_frame.columnconfigure(2, weight=1) center_frame = ttk.LabelFrame( controls_map_container, text="Center Map", padding=5 ) center_frame.pack(side=tk.TOP, fill=tk.X, pady=(5, 0)) center_frame.columnconfigure(1, weight=1) center_frame.columnconfigure(3, weight=1) ttk.Label(center_frame, text="Lat:").grid( row=0, column=0, padx=(0, 2), pady=2, sticky=tk.W ) self.center_lat_var = tk.StringVar() self.center_lat_entry = ttk.Entry( center_frame, textvariable=self.center_lat_var, width=10 ) self.center_lat_entry.grid(row=0, column=1, padx=(0, 5), pady=2, sticky=tk.EW) ttk.Label(center_frame, text="Lon:").grid( row=0, column=2, padx=(5, 2), pady=2, sticky=tk.W ) self.center_lon_var = tk.StringVar() self.center_lon_entry = ttk.Entry( center_frame, textvariable=self.center_lon_var, width=10 ) self.center_lon_entry.grid(row=0, column=3, padx=(0, 0), pady=2, sticky=tk.EW) ttk.Label(center_frame, text="Patch (km):").grid( row=1, column=0, padx=(0, 2), pady=2, sticky=tk.W ) self.center_patch_size_var = tk.StringVar(value="100") self.center_patch_size_entry = ttk.Entry( center_frame, textvariable=self.center_patch_size_var, width=7 ) self.center_patch_size_entry.grid( row=1, column=1, padx=(0, 5), pady=2, sticky=tk.W ) self.center_map_button = ttk.Button( center_frame, text="Center & Fit Patch", command=self._map_center_and_fit ) self.center_map_button.grid( row=1, column=2, columnspan=2, padx=5, pady=5, sticky=tk.E ) def _recreate_map_info_content(self, parent_frame: ttk.Frame): parent_frame.columnconfigure(1, weight=0) # Smaller weight for value labels parent_frame.columnconfigure(3, weight=0) # Smaller weight for value labels info_row = 0 ttk.Label(parent_frame, text="Click Lat:").grid( row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2) ) self.info_lat_value = ttk.Label(parent_frame, text="N/A", width=12) self.info_lat_value.grid( row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5) ) ttk.Label(parent_frame, text="Lon:").grid( row=info_row, column=2, sticky=tk.W, pady=1, padx=(5, 2) ) self.info_lon_value = ttk.Label(parent_frame, text="N/A", width=12) self.info_lon_value.grid( row=info_row, column=3, sticky=tk.W, pady=1, padx=(0, 5) ) info_row += 1 ttk.Label(parent_frame, text="Lat DMS:").grid( row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2) ) self.info_lat_dms_value = ttk.Label( parent_frame, text="N/A", width=18, wraplength=140 ) self.info_lat_dms_value.grid( row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5) ) ttk.Label(parent_frame, text="Lon DMS:").grid( row=info_row, column=2, sticky=tk.W, pady=1, padx=(5, 2) ) self.info_lon_dms_value = ttk.Label( parent_frame, text="N/A", width=18, wraplength=140 ) self.info_lon_dms_value.grid( row=info_row, column=3, sticky=tk.W, pady=1, padx=(0, 5) ) info_row += 1 ttk.Separator(parent_frame, orient=tk.HORIZONTAL).grid( row=info_row, column=0, columnspan=4, sticky=tk.EW, pady=2 ) info_row += 1 ttk.Label(parent_frame, text="Map N:").grid( row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2) ) self.info_map_bounds_n = ttk.Label(parent_frame, text="N/A", width=12) self.info_map_bounds_n.grid( row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5) ) ttk.Label(parent_frame, text="W:").grid( row=info_row, column=2, sticky=tk.W, pady=1, padx=(5, 2) ) self.info_map_bounds_w = ttk.Label(parent_frame, text="N/A", width=12) self.info_map_bounds_w.grid( row=info_row, column=3, sticky=tk.W, pady=1, padx=(0, 5) ) info_row += 1 ttk.Label(parent_frame, text="Map S:").grid( row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2) ) self.info_map_bounds_s = ttk.Label(parent_frame, text="N/A", width=12) self.info_map_bounds_s.grid( row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5) ) ttk.Label(parent_frame, text="E:").grid( row=info_row, column=2, sticky=tk.W, pady=1, padx=(5, 2) ) self.info_map_bounds_e = ttk.Label(parent_frame, text="N/A", width=12) self.info_map_bounds_e.grid( row=info_row, column=3, sticky=tk.W, pady=1, padx=(0, 5) ) info_row += 1 ttk.Separator(parent_frame, orient=tk.HORIZONTAL).grid( row=info_row, column=0, columnspan=4, sticky=tk.EW, pady=2 ) info_row += 1 ttk.Label(parent_frame, text="Target N:").grid( row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2) ) self.info_target_bbox_n = ttk.Label(parent_frame, text="N/A", width=12) self.info_target_bbox_n.grid( row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5) ) ttk.Label(parent_frame, text="W:").grid( row=info_row, column=2, sticky=tk.W, pady=1, padx=(5, 2) ) self.info_target_bbox_w = ttk.Label(parent_frame, text="N/A", width=12) self.info_target_bbox_w.grid( row=info_row, column=3, sticky=tk.W, pady=1, padx=(0, 5) ) info_row += 1 ttk.Label(parent_frame, text="Target S:").grid( row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2) ) self.info_target_bbox_s = ttk.Label(parent_frame, text="N/A", width=12) self.info_target_bbox_s.grid( row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5) ) ttk.Label(parent_frame, text="E:").grid( row=info_row, column=2, sticky=tk.W, pady=1, padx=(5, 2) ) self.info_target_bbox_e = ttk.Label(parent_frame, text="N/A", width=12) self.info_target_bbox_e.grid( row=info_row, column=3, sticky=tk.W, pady=1, padx=(0, 5) ) info_row += 1 ttk.Separator(parent_frame, orient=tk.HORIZONTAL).grid( row=info_row, column=0, columnspan=4, sticky=tk.EW, pady=2 ) info_row += 1 ttk.Label(parent_frame, text="Zoom:").grid( row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2) ) self.info_zoom_value = ttk.Label(parent_frame, text="N/A", width=4) self.info_zoom_value.grid( row=info_row, column=1, sticky=tk.W, pady=1, padx=(0, 5) ) ttk.Label(parent_frame, text="Flights:").grid( row=info_row, column=2, sticky=tk.W, pady=1, padx=(5, 2) ) self.info_flight_count_value = ttk.Label(parent_frame, text="N/A", width=5) self.info_flight_count_value.grid( row=info_row, column=3, sticky=tk.W, pady=1, padx=(0, 5) ) info_row += 1 ttk.Label(parent_frame, text="Map Size:").grid( row=info_row, column=0, sticky=tk.W, pady=1, padx=(0, 2) ) self.info_map_size_value = ttk.Label(parent_frame, text="N/A", wraplength=180) self.info_map_size_value.grid( row=info_row, column=1, columnspan=3, sticky=tk.W, pady=1, padx=(0, 5) ) def _create_selected_flight_details_content(self, parent_frame: ttk.LabelFrame): parent_frame.columnconfigure(1, weight=1) parent_frame.columnconfigure(3, weight=1) self.flight_detail_labels: Dict[str, ttk.Label] = {} fields_col1 = [ ("icao24", "ICAO24:"), ("baro_altitude_m", "Altitude (baro):"), ("velocity_mps", "Speed (GS):"), ("true_track_deg", "Track (°):"), ("squawk", "Squawk:"), ("position_source", "Pos. Source:"), ("registration", "Registration:"), ("model", "Model:"), ("operator", "Operator:"), ("categorydescription", "Category:"), ("serialnumber", "Serial No.:"), ("firstflightdate", "First Flight:"), ] fields_col2 = [ ("callsign", "Callsign:"), ("geo_altitude_m", "Altitude (Geo):"), ("vertical_rate_mps", "Vert. Rate:"), ("on_ground", "On Ground:"), ("spi", "SPI:"), ("origin_country", "Origin Country:"), ("manufacturername", "Manufacturer:"), ("typecode", "Type Code:"), ("operatorcallsign", "Op. Callsign:"), ("built_year", "Built Year:"), ("country", "Country (Reg):"), ("timestamp_metadata", "DB Timestamp:"), ] max_rows = max(len(fields_col1), len(fields_col2)) for i in range(max_rows): if i < len(fields_col1): key1, label_text1 = fields_col1[i] ttk.Label( parent_frame, text=label_text1, font="-weight bold" if key1 == "icao24" else None, ).grid(row=i, column=0, sticky=tk.W, pady=0, padx=(0, 2)) value_label1 = ttk.Label(parent_frame, text="N/A", wraplength=130) value_label1.grid(row=i, column=1, sticky=tk.W, pady=0, padx=(0, 10)) self.flight_detail_labels[key1] = value_label1 if i < len(fields_col2): key2, label_text2 = fields_col2[i] ttk.Label(parent_frame, text=label_text2).grid( row=i, column=2, sticky=tk.W, pady=0, padx=(5, 2) ) value_label2 = ttk.Label(parent_frame, text="N/A", wraplength=130) value_label2.grid(row=i, column=3, sticky=tk.W, pady=0, padx=(0, 0)) self.flight_detail_labels[key2] = value_label2 self.full_details_button = ttk.Button( parent_frame, text="Aircraft Full Details...", command=self._show_full_flight_details_action, state=tk.DISABLED, ) self.full_details_button.grid( row=max_rows, column=0, columnspan=4, pady=(10, 0), sticky="ew" ) def update_selected_flight_details(self, flight_data: Optional[Dict[str, Any]]): module_logger.debug( f"GUI: Updating flight details panel for: {flight_data.get('icao24', 'None') if flight_data else 'None'}" ) if not hasattr(self, "flight_detail_labels"): module_logger.error( "flight_detail_labels not initialized. Cannot update details." ) return all_panel_keys = list(self.flight_detail_labels.keys()) for key in all_panel_keys: label_widget = self.flight_detail_labels.get(key) if label_widget and label_widget.winfo_exists(): label_widget.config(text="N/A") if flight_data: baro_alt_val = flight_data.get("baro_altitude_m") geo_alt_val = flight_data.get("geo_altitude_m") if ( "baro_altitude_m" in self.flight_detail_labels and self.flight_detail_labels["baro_altitude_m"].winfo_exists() ): alt_text = "N/A" if baro_alt_val is not None: alt_text = f"{baro_alt_val:.0f} m" elif geo_alt_val is not None: # Fallback to geo if baro is N/A alt_text = f"{geo_alt_val:.0f} m (geo)" self.flight_detail_labels["baro_altitude_m"].config(text=alt_text) if ( "geo_altitude_m" in self.flight_detail_labels and self.flight_detail_labels["geo_altitude_m"].winfo_exists() ): alt_geo_text = "N/A" if geo_alt_val is not None: alt_geo_text = f"{geo_alt_val:.0f} m" # Show geo_altitude only if it's significantly different from baro, or if baro is N/A if (baro_alt_val is not None and geo_alt_val is not None and abs(baro_alt_val - geo_alt_val) > 1) or \ (baro_alt_val is None and geo_alt_val is not None) : self.flight_detail_labels["geo_altitude_m"].config(text=alt_geo_text) # else, leave it N/A if baro is present and geo is similar or N/A for key, label_widget in self.flight_detail_labels.items(): if key in ["baro_altitude_m", "geo_altitude_m"]: # Already handled continue if label_widget and label_widget.winfo_exists(): value = flight_data.get(key) formatted_value = "N/A" if value is not None and str(value).strip() != "": if key == "velocity_mps" and isinstance(value, (float, int)): formatted_value = ( f"{value:.1f} m/s ({value * 1.94384:.1f} kts)" ) elif key == "vertical_rate_mps" and isinstance( value, (float, int) ): formatted_value = ( f"{value * 196.85:.0f} ft/min ({value:.1f} m/s)" ) elif key == "true_track_deg" and isinstance( value, (float, int) ): formatted_value = f"{value:.1f}°" elif key in [ "timestamp", "last_contact_timestamp", "firstflightdate", # String from DB "timestamp_metadata", # Timestamp from DB ]: if isinstance(value, (int, float)) and value > 0 : # If it's a raw timestamp try: formatted_value = datetime.fromtimestamp( value, tz=timezone.utc ).strftime("%Y-%m-%d %H:%M:%S Z") except: # Should not happen with valid ts formatted_value = str(value) + " (raw ts)" elif isinstance(value, str) and value.strip(): # If it's already a date string formatted_value = value elif isinstance(value, bool): formatted_value = str(value) elif key == "built_year" and value: # Ensure built_year without decimals formatted_value = str(int(value)) if isinstance(value, (float,int)) and value > 0 else str(value) else: formatted_value = str(value) label_widget.config(text=formatted_value) if ( hasattr(self, "full_details_button") and self.full_details_button.winfo_exists() ): self.full_details_button.config( state=tk.NORMAL if flight_data.get("icao24") else tk.DISABLED ) else: # No flight_data if ( hasattr(self, "full_details_button") and self.full_details_button.winfo_exists() ): self.full_details_button.config(state=tk.DISABLED) module_logger.debug("Selected flight details panel updated.") def update_semaphore_and_status(self, status_level: str, message: str): color_to_set = SEMAPHORE_COLOR_STATUS_MAP.get( status_level, SEMAPHORE_COLOR_STATUS_MAP.get(GUI_STATUS_UNKNOWN, "gray60") ) if ( hasattr(self, "semaphore_canvas") and 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: # Can happen if called during shutdown pass current_status_text = f"Status: {message}" if ( hasattr(self, "status_label") and self.status_label and self.status_label.winfo_exists() ): try: self.status_label.config(text=current_status_text) except tk.TclError: # Can happen if called during shutdown pass # Log the status update using the module logger (already configured) log_level_to_use = logging.INFO # Default if status_level == GUI_STATUS_ERROR: log_level_to_use = logging.ERROR elif status_level == GUI_STATUS_PERMANENT_FAILURE: log_level_to_use = logging.CRITICAL elif status_level == GUI_STATUS_WARNING: log_level_to_use = logging.WARNING elif status_level in [GUI_STATUS_FETCHING, GUI_STATUS_OK, GUI_STATUS_UNKNOWN]: log_level_to_use = logging.DEBUG module_logger.log(log_level_to_use, f"GUI Status Update: Level='{status_level}', Message='{message}'") 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) # Re-enable mode radio buttons 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) self._update_controls_state_based_on_mode_and_tab() # Update BBox/Track based on new state status_level = GUI_STATUS_OK if status_message and ("failed" in status_message.lower() or "error" in status_message.lower()): status_level = GUI_STATUS_ERROR elif status_message and "warning" in status_message.lower(): status_level = GUI_STATUS_WARNING if hasattr(self, "root") and self.root.winfo_exists(): self.update_semaphore_and_status(status_level, status_message if status_message else "System Ready.") def _should_show_main_placeholder(self) -> bool: return not ( hasattr(self, "map_manager_instance") and self.map_manager_instance is not None and MAP_CANVAS_MANAGER_AVAILABLE ) def _update_map_placeholder(self, text_to_display: str): if not ( hasattr(self, "flight_canvas") and self.flight_canvas and self.flight_canvas.winfo_exists() ): return if not self._should_show_main_placeholder(): # Map manager is active, clear placeholder try: self.flight_canvas.delete("placeholder_text") except: # tk.TclError if item doesn't exist or canvas gone pass return try: self.flight_canvas.delete("placeholder_text") # Clear previous one canvas_w, canvas_h = ( self.flight_canvas.winfo_width(), self.flight_canvas.winfo_height(), ) if canvas_w <= 1: canvas_w = self.canvas_width # Use fallback if not sized 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", font=("Arial", 12, "italic"), justify=tk.CENTER, width=canvas_w - 40 # Wrap text ) except tk.TclError: # Canvas might be gone pass except Exception as e_placeholder_draw: module_logger.error(f"Error drawing placeholder: {e_placeholder_draw}") def _on_mode_change(self): if not ( hasattr(self, "mode_var") and hasattr(self, "function_notebook") and self.function_notebook.winfo_exists() ): return selected_mode = self.mode_var.get() status_message = f"Mode: {selected_mode}. Ready." module_logger.info(f"GUI: Mode changed to {selected_mode}") try: # Get tab indices by their text name for robustness tab_indices = {} for i in range(self.function_notebook.index("end")): tab_text = self.function_notebook.tab(i, "text") tab_indices[tab_text] = i live_bbox_idx = tab_indices.get("Live: Area Monitor", -1) history_idx = tab_indices.get("History", -1) live_airport_idx = tab_indices.get("Live: Airport", -1) if selected_mode == "Live": if live_bbox_idx != -1: self.function_notebook.tab(live_bbox_idx, state="normal") if live_airport_idx != -1: self.function_notebook.tab(live_airport_idx, state="normal") if history_idx != -1: self.function_notebook.tab(history_idx, state="disabled") # Switch to the first live tab if history was selected if self.function_notebook.index("current") == history_idx and live_bbox_idx != -1: self.function_notebook.select(live_bbox_idx) elif selected_mode == "History": if live_bbox_idx != -1: self.function_notebook.tab(live_bbox_idx, state="disabled") if live_airport_idx != -1: self.function_notebook.tab(live_airport_idx, state="disabled") if history_idx != -1: self.function_notebook.tab(history_idx, state="normal") # Switch to history tab if not already selected if self.function_notebook.index("current") != history_idx and history_idx != -1: self.function_notebook.select(history_idx) except Exception as e: # tk.TclError can happen if notebook/tabs don't exist as expected module_logger.warning(f"Error updating function notebook tabs on mode change: {e}", exc_info=True) self.clear_all_views_data() # Clear map/table when mode changes self._update_controls_state_based_on_mode_and_tab() # Update BBox/Track state 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: Optional[tk.Event] = None): if not ( hasattr(self, "function_notebook") and self.function_notebook.winfo_exists() ): return try: tab_text = self.function_notebook.tab( self.function_notebook.index("current"), "text" ) module_logger.info(f"GUI: Switched function tab to: {tab_text}") placeholder_text_map = "Map Area." # Default if "Live: Area Monitor" in tab_text: placeholder_text_map = "Map - Live Area. Define area and press Start." elif "Live: Airport" in tab_text: placeholder_text_map = "Map - Live Airport. (Coming Soon)" elif "History" in tab_text: placeholder_text_map = "Map - History Analysis. (Coming Soon)" self._update_map_placeholder(placeholder_text_map) self._update_controls_state_based_on_mode_and_tab() except tk.TclError: # Can happen if notebook is in a weird state module_logger.warning("TclError on function tab change, notebook might be closing.", exc_info=False) except Exception as e_tab_change: module_logger.error(f"Error processing function tab change: {e_tab_change}", exc_info=True) def _update_controls_state_based_on_mode_and_tab(self): is_live_mode = hasattr(self, "mode_var") and self.mode_var.get() == "Live" is_monitoring_active = ( hasattr(self, "stop_button") and self.stop_button.winfo_exists() and self.stop_button.cget("state") == tk.NORMAL # Monitoring if stop button is enabled ) active_func_tab_text = "" if hasattr(self, "function_notebook") and self.function_notebook.winfo_exists(): try: active_func_tab_text = self.function_notebook.tab( self.function_notebook.index("current"), "text" ) except tk.TclError: # Tab might not be selectable or notebook closing pass # Enable BBox entries only if in Live Area mode AND not currently monitoring enable_bbox_entries = ( is_live_mode and "Live: Area Monitor" in active_func_tab_text and not is_monitoring_active ) self._set_bbox_entries_state(tk.NORMAL if enable_bbox_entries else tk.DISABLED) # Enable Track Length spinbox if in Live Area mode (can be changed even if monitoring, effect on next data) # OR make it disabled if monitoring, depending on desired behavior. # Current behavior: disable if monitoring. enable_track_spinbox = ( is_live_mode and "Live: Area Monitor" in active_func_tab_text # and not is_monitoring_active # Uncomment to disable if monitoring active ) if ( hasattr(self, "track_length_spinbox") and self.track_length_spinbox.winfo_exists() ): try: # If monitoring is active, disable. Otherwise, enable if conditions met. new_state = tk.DISABLED if is_monitoring_active else ("readonly" if enable_track_spinbox else tk.DISABLED) self.track_length_spinbox.config(state=new_state) except tk.TclError: pass def _on_view_tab_change(self, event: Optional[tk.Event] = None): if not (hasattr(self, "views_notebook") and self.views_notebook.winfo_exists()): return try: module_logger.info( f"GUI: Switched view tab to: {self.views_notebook.tab(self.views_notebook.index('current'), 'text')}" ) except tk.TclError: # Can happen if notebook is in a weird state module_logger.warning("TclError on view tab change, notebook might be closing.", exc_info=False) except Exception as e_view_tab: module_logger.error(f"Error getting view tab text: {e_view_tab}", exc_info=True) def _set_bbox_entries_state(self, state: str): for entry_name_attr in [ "lat_min_entry", "lon_min_entry", "lat_max_entry", "lon_max_entry", ]: entry_widget = getattr(self, entry_name_attr, None) if entry_widget and hasattr(entry_widget, "winfo_exists") and entry_widget.winfo_exists(): try: entry_widget.config(state=state) except tk.TclError: # Can happen if widget is destroyed pass def _start_monitoring(self): if not hasattr(self, "mode_var"): self.show_error_message("Internal Error", "Application mode not available.") return selected_mode = self.mode_var.get() active_func_tab_text = "" if hasattr(self, "function_notebook") and self.function_notebook.winfo_exists(): try: active_func_tab_text = self.function_notebook.tab( self.function_notebook.index("current"), "text" ) except tk.TclError: module_logger.warning("Could not get active function tab text on start.", exc_info=False) # Disable start button, enable stop button, disable mode radios for widget_attr_name, new_state in [ ("start_button", tk.DISABLED), ("stop_button", tk.NORMAL), ("live_radio", tk.DISABLED), ("history_radio", tk.DISABLED), ]: widget = getattr(self, widget_attr_name, None) if widget and hasattr(widget, "winfo_exists") and widget.winfo_exists(): widget.config(state=new_state) self._update_controls_state_based_on_mode_and_tab() # This will disable BBox/Track if needed if not self.controller: self._reset_gui_to_stopped_state("Critical Error: Controller unavailable.") self.show_error_message("Internal Error", "Application controller missing.") return if selected_mode == "Live" and "Live: Area Monitor" in active_func_tab_text: bbox = self.get_bounding_box_from_gui() if bbox: module_logger.info(f"GUI: Starting Live Area monitoring with BBox: {bbox}") self.controller.start_live_monitoring(bbox) self.update_semaphore_and_status(GUI_STATUS_FETCHING, "Live monitoring starting...") else: self._reset_gui_to_stopped_state("Start failed: Invalid Bounding Box.") self.show_error_message("Input Error", "Bounding Box values are invalid or incomplete.") elif selected_mode == "History" and "History" in active_func_tab_text: module_logger.info("GUI: Starting History monitoring.") self.controller.start_history_monitoring() # Placeholder self.update_semaphore_and_status(GUI_STATUS_OK, "History mode (placeholder).") # Placeholder status else: err_msg = f"Start monitoring not supported on tab '{active_func_tab_text}' for mode '{selected_mode}'." module_logger.warning(err_msg) self._reset_gui_to_stopped_state(f"Start failed: {err_msg}") def _stop_monitoring(self): selected_mode = self.mode_var.get() if hasattr(self, "mode_var") else "Unknown" module_logger.info(f"GUI: Stop monitoring requested for mode: {selected_mode}") if not self.controller: self._reset_gui_to_stopped_state("Error: Controller missing on stop.") return if selected_mode == "Live": self.controller.stop_live_monitoring() elif selected_mode == "History": self.controller.stop_history_monitoring() # Placeholder # GUI will be fully reset by _reset_gui_to_stopped_state called by controller # but we can set an intermediate status here. if hasattr(self, "root") and self.root.winfo_exists(): self.update_semaphore_and_status( GUI_STATUS_FETCHING, f"{selected_mode} monitoring stopping..." ) def get_bounding_box_from_gui(self) -> Optional[Dict[str, float]]: required_var_names = ["lat_min_var", "lon_min_var", "lat_max_var", "lon_max_var"] if not all(hasattr(self, var_name) for var_name in required_var_names): module_logger.error("BBox string variables not found in GUI.") return None try: # Get all values first str_values = [getattr(self, var_name).get() for var_name in required_var_names] # Check if any are empty before attempting conversion if not all(s.strip() for s in str_values): # Checks for empty or whitespace-only strings module_logger.debug("One or more BBox GUI fields are empty.") return None lat_min_val, lon_min_val, lat_max_val, lon_max_val = map(float, str_values) except ValueError: module_logger.warning("Invalid numeric format in BBox GUI fields.") return None # ValueError if conversion fails bbox_candidate = { "lat_min": lat_min_val, "lon_min": lon_min_val, "lat_max": lat_max_val, "lon_max": lon_max_val, } if _is_valid_bbox_dict(bbox_candidate): return bbox_candidate else: module_logger.warning(f"BBox from GUI failed validation: {bbox_candidate}") return None def update_bbox_gui_fields(self, bbox_dict: Dict[str, float]): if not hasattr(self, "lat_min_var"): # Check if vars are initialized return if bbox_dict and _is_valid_bbox_dict(bbox_dict): decimals = getattr(app_config, "COORDINATE_DECIMAL_PLACES", 5) # Use a config for decimals try: self.lat_min_var.set(f"{bbox_dict['lat_min']:.{decimals}f}") self.lon_min_var.set(f"{bbox_dict['lon_min']:.{decimals}f}") self.lat_max_var.set(f"{bbox_dict['lat_max']:.{decimals}f}") self.lon_max_var.set(f"{bbox_dict['lon_max']:.{decimals}f}") except tk.TclError: # Can happen if widget is destroyed pass except Exception as e_set_bbox: module_logger.error(f"Error setting BBox GUI fields: {e_set_bbox}") else: # Clear fields if bbox_dict is invalid or None try: self.lat_min_var.set("") self.lon_min_var.set("") self.lat_max_var.set("") self.lon_max_var.set("") except tk.TclError: pass def display_flights_on_canvas( self, flight_states: List[CanonicalFlightState], _active_bbox_context: Optional[Dict[str, float]], # bbox_context might be useful later ): if not ( hasattr(self, "map_manager_instance") and self.map_manager_instance and MAP_CANVAS_MANAGER_AVAILABLE ): if hasattr(self, "root") and self.root.winfo_exists(): self._update_map_placeholder("Map N/A to display flights.") return try: self.map_manager_instance.update_flights_on_map(flight_states) # Update placeholder only if map manager is supposed to be active but no flights are shown # (and it's not already showing an error/other message) if not flight_states and not self._should_show_main_placeholder(): # Map manager IS active # Let MapCanvasManager handle its own placeholder for "no flights" pass elif not flight_states and self._should_show_main_placeholder(): # Map manager NOT active self._update_map_placeholder("No flights in the selected area.") except Exception as e: module_logger.error(f"Error in display_flights_on_canvas: {e}", exc_info=True) self.show_error_message( "Map Display Error", "Could not update flights on map." ) def clear_all_views_data(self): module_logger.info("GUI: Clearing all views data.") if ( hasattr(self, "map_manager_instance") and self.map_manager_instance and MAP_CANVAS_MANAGER_AVAILABLE ): try: self.map_manager_instance.clear_map_display() except Exception as e: module_logger.warning(f"Error clearing map via map manager: {e}") elif self._should_show_main_placeholder(): # No map manager, ensure placeholder is appropriate mode = self.mode_var.get() if hasattr(self, "mode_var") else "Unknown" text = f"Map - {mode}. Data cleared." if mode == "Live": text = "Map - Live. Define area and Start." elif mode == "History": text = "Map - History. (Coming Soon)" self._update_map_placeholder(text) # Clear selected flight details panel if hasattr(self, "update_selected_flight_details"): self.update_selected_flight_details(None) # Future: Clear table view if it's implemented and holds data # if hasattr(self, "flight_table_view") and self.flight_table_view: # self.flight_table_view.clear_table() def show_error_message(self, title: str, message: str): status_msg = f"Error: {message[:70]}{'...' if len(message)>70 else ''}" if hasattr(self, "root") and self.root.winfo_exists(): self.update_semaphore_and_status(GUI_STATUS_ERROR, status_msg) try: messagebox.showerror(title, message, parent=self.root) except tk.TclError: # Can happen if root is being destroyed pass else: # Fallback if GUI not fully up or during shutdown print(f"ERROR (No GUI messagebox): {title} - {message}", flush=True) def show_info_message(self, title: str, message: str): # Added for convenience if hasattr(self, "root") and self.root.winfo_exists(): try: messagebox.showinfo(title, message, parent=self.root) except tk.TclError: pass else: print(f"INFO (No GUI messagebox): {title} - {message}", flush=True) def show_map_context_menu( self, latitude: float, longitude: float, screen_x: int, screen_y: int ): if ( hasattr(self, "map_manager_instance") and self.map_manager_instance and hasattr(self.map_manager_instance, "show_map_context_menu_from_gui") and MAP_CANVAS_MANAGER_AVAILABLE ): try: self.map_manager_instance.show_map_context_menu_from_gui( latitude, longitude, screen_x, screen_y ) except Exception as e: module_logger.error( f"Error delegating context menu to MapCanvasManager: {e}", exc_info=True ) else: module_logger.warning("Map manager or context menu method not available for GUI.") def update_clicked_map_info( self, lat_deg: Optional[float], lon_deg: Optional[float], lat_dms: str, # Already formatted string lon_dms: str, # Already formatted string ): if not hasattr(self, "info_lat_value"): # Check if info panel widgets are initialized return decimals = getattr(app_config, "COORDINATE_DECIMAL_PLACES", 5) try: if hasattr(self, "info_lat_value") and self.info_lat_value.winfo_exists(): self.info_lat_value.config( text=f"{lat_deg:.{decimals}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:.{decimals}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 or "N/A") if ( hasattr(self, "info_lon_dms_value") and self.info_lon_dms_value.winfo_exists() ): self.info_lon_dms_value.config(text=lon_dms or "N/A") except tk.TclError: # Widgets might be destroyed pass except Exception as e_update_click: module_logger.error(f"Error updating clicked map info panel: {e_update_click}", exc_info=False) def update_general_map_info_display( self, zoom: Optional[int], map_size_str: str, # Already formatted (e.g., "100km x 80km") map_geo_bounds: Optional[Tuple[float, float, float, float]], # W, S, E, N target_bbox_input: Optional[Dict[str, float]], # Standard dict flight_count: Optional[int], ): if not hasattr(self, "info_zoom_value"): # Check if info panel widgets are initialized return decimals = getattr(app_config, "COORDINATE_DECIMAL_PLACES", 5) try: # Map geographic bounds map_w, map_s, map_e, map_n = ("N/A",) * 4 if map_geo_bounds: map_w_val, map_s_val, map_e_val, map_n_val = map_geo_bounds map_w, map_s, map_e, map_n = ( f"{map_w_val:.{decimals}f}", f"{map_s_val:.{decimals}f}", f"{map_e_val:.{decimals}f}", f"{map_n_val:.{decimals}f}" ) for attr_name, value_str in [ ("info_map_bounds_w", map_w), ("info_map_bounds_s", map_s), ("info_map_bounds_e", map_e), ("info_map_bounds_n", map_n), ]: label_widget = getattr(self, attr_name, None) if label_widget and label_widget.winfo_exists(): label_widget.config(text=value_str) # Target BBox input (monitoring area) target_w, target_s, target_e, target_n = ("N/A",) * 4 color_for_target_bbox = BBOX_COLOR_NA # Default color if target_bbox_input and _is_valid_bbox_dict(target_bbox_input): target_w, target_s, target_e, target_n = ( f"{target_bbox_input['lon_min']:.{decimals}f}", f"{target_bbox_input['lat_min']:.{decimals}f}", f"{target_bbox_input['lon_max']:.{decimals}f}", f"{target_bbox_input['lat_max']:.{decimals}f}" ) # Determine color based on how target BBox relates to current map view if map_geo_bounds: relation_status = self._is_bbox_inside_bbox(target_bbox_input, map_geo_bounds) if relation_status == "Inside": color_for_target_bbox = BBOX_COLOR_INSIDE elif relation_status == "Partial": color_for_target_bbox = BBOX_COLOR_PARTIAL else: color_for_target_bbox = BBOX_COLOR_OUTSIDE else: # No map view bounds, can't determine relation color_for_target_bbox = BBOX_COLOR_NA for attr_name, value_str in [ ("info_target_bbox_w", target_w), ("info_target_bbox_s", target_s), ("info_target_bbox_e", target_e), ("info_target_bbox_n", target_n), ]: label_widget = getattr(self, attr_name, None) if label_widget and label_widget.winfo_exists(): label_widget.config(text=value_str, foreground=color_for_target_bbox) # Other general info 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(): self.info_map_size_value.config(text=map_size_str or "N/A") 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") except tk.TclError: # Widgets might be destroyed pass except Exception as e_update_gen_info: module_logger.error(f"Error updating general map info panel: {e_update_gen_info}", exc_info=False) def _is_bbox_inside_bbox( self, inner_bbox_dict: Dict[str, float], # Standard dict {lat_min, lon_min, ...} outer_bbox_tuple: Tuple[float, float, float, float], # W, S, E, N ) -> str: # Returns "Inside", "Outside", "Partial", "N/A" if not _is_valid_bbox_dict(inner_bbox_dict) or \ not (outer_bbox_tuple and len(outer_bbox_tuple) == 4 and all(isinstance(c, (int, float)) for c in outer_bbox_tuple)): return "N/A" # Convert outer tuple to a comparable dict for clarity outer_dict = { "lon_min": outer_bbox_tuple[0], "lat_min": outer_bbox_tuple[1], "lon_max": outer_bbox_tuple[2], "lat_max": outer_bbox_tuple[3] } eps = 1e-6 # Epsilon for float comparisons # Check for full containment if (inner_bbox_dict["lon_min"] >= outer_dict["lon_min"] - eps and inner_bbox_dict["lat_min"] >= outer_dict["lat_min"] - eps and inner_bbox_dict["lon_max"] <= outer_dict["lon_max"] + eps and inner_bbox_dict["lat_max"] <= outer_dict["lat_max"] + eps): return "Inside" # Check for no overlap (completely outside) # Inner is to the right of outer OR inner is to the left of outer OR ... if (inner_bbox_dict["lon_min"] >= outer_dict["lon_max"] - eps or # Inner right of outer inner_bbox_dict["lon_max"] <= outer_dict["lon_min"] + eps or # Inner left of outer inner_bbox_dict["lat_min"] >= outer_dict["lat_max"] - eps or # Inner above outer inner_bbox_dict["lat_max"] <= outer_dict["lat_min"] + eps): # Inner below outer return "Outside" return "Partial" # If not fully inside and not fully outside, it must be partial def _map_zoom_in(self): if self.controller and hasattr(self.controller, "map_zoom_in"): self.controller.map_zoom_in() def _map_zoom_out(self): if self.controller and hasattr(self.controller, "map_zoom_out"): self.controller.map_zoom_out() def _map_pan(self, direction: str): if self.controller and hasattr(self.controller, "map_pan_direction"): self.controller.map_pan_direction(direction) def _map_center_and_fit(self): try: lat_str, lon_str, patch_str = self.center_lat_var.get(), self.center_lon_var.get(), self.center_patch_size_var.get() if not lat_str or not lon_str or not patch_str: # Check for empty strings self.show_error_message("Input Error", "Latitude, Longitude, and Patch size are required.") return lat = float(lat_str) lon = float(lon_str) patch = float(patch_str) if not (-90 <= lat <= 90 and -180 <= lon <= 180 and patch > 0): self.show_error_message("Input Error", "Invalid latitude (-90 to 90), longitude (-180 to 180), or patch size (>0).") return if self.controller and hasattr( self.controller, "map_center_on_coords_and_fit_patch" ): self.controller.map_center_on_coords_and_fit_patch(lat, lon, patch) except ValueError: self.show_error_message("Input Error", "Latitude, Longitude, and Patch size must be valid numbers.") except Exception as e: module_logger.error(f"Error in _map_center_and_fit: {e}", exc_info=True) self.show_error_message("Error", f"An unexpected error occurred: {e}") def _on_track_length_change(self): if ( hasattr(self, "track_length_var") and self.controller and hasattr(self.controller, "set_map_track_length") ): try: new_length = self.track_length_var.get() if isinstance(new_length, int) and new_length >=2: # Basic validation self.controller.set_map_track_length(new_length) module_logger.info(f"GUI: Track length changed to {new_length}") else: module_logger.warning(f"Invalid track length from spinbox: {new_length}") except tk.TclError: # Spinbox might not be fully ready or being destroyed module_logger.warning("TclError on track length change, spinbox might not be ready.") except Exception as e_track_len: module_logger.error(f"Error processing track length change: {e_track_len}", exc_info=True)