SXXXXXXX_FlightMonitor/flightmonitor/gui/main_window.py
2025-06-03 12:10:39 +02:00

1872 lines
79 KiB
Python

# 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(
"<<NotebookTabChanged>>", 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("<<NotebookTabChanged>>", 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 # Inner right of outer
or inner_bbox_dict["lon_max"]
<= outer_dict["lon_min"] + eps # Inner left of outer
or inner_bbox_dict["lat_min"]
>= outer_dict["lat_max"] - eps # Inner above outer
or 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,
)