diff --git a/config/area_profiles.json b/config/area_profiles.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/config/area_profiles.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/flightmonitor/controller/app_controller.py b/flightmonitor/controller/app_controller.py index ea29c19..8127fca 100644 --- a/flightmonitor/controller/app_controller.py +++ b/flightmonitor/controller/app_controller.py @@ -1,6 +1,7 @@ # FlightMonitor/controller/app_controller.py from queue import Queue, Empty as QueueEmpty from typing import List, Optional, Dict, Any, TYPE_CHECKING +from tkinter import simpledialog from ..data.opensky_live_adapter import OpenSkyLiveAdapter, AdapterMessage from ..data import config as app_config @@ -12,6 +13,7 @@ from .aircraft_db_importer import AircraftDBImporter from .live_data_processor import LiveDataProcessor from .map_command_handler import MapCommandHandler from .cleanup_manager import CleanupManager +from ..data.area_profile_manager import AreaProfileManager if TYPE_CHECKING: from ..gui.main_window import MainWindow @@ -41,6 +43,7 @@ class AppController: self.live_data_processor: Optional[LiveDataProcessor] = None self.map_command_handler: Optional[MapCommandHandler] = None self.cleanup_manager: Optional[CleanupManager] = None + self.profile_manager: Optional[AreaProfileManager] = None self.active_detail_window_icao: Optional[str] = None self.active_detail_window_ref: Optional["FullFlightDetailsWindow"] = None # type: ignore @@ -59,6 +62,13 @@ class AppController: module_logger.critical(f"CRITICAL: Failed to initialize AircraftDatabaseManager: {e}", exc_info=True) self.aircraft_db_manager = None + try: + self.profile_manager = AreaProfileManager() # NUOVA RIGA + module_logger.info("AreaProfileManager initialized successfully.") + except Exception as e: + module_logger.error(f"Failed to initialize AreaProfileManager: {e}", exc_info=True) + + module_logger.info("AppController initialized.") def set_main_window(self, main_window_instance: "MainWindow"): @@ -449,4 +459,77 @@ class AppController: if self.main_window and self.main_window.map_manager_instance: self.main_window.map_manager_instance.set_max_track_points(length) else: - module_logger.warning("Controller: Cannot set track length, map manager not available.") \ No newline at end of file + module_logger.warning("Controller: Cannot set track length, map manager not available.") + + def get_profile_names(self) -> List[str]: + """Returns the list of available profile names.""" + if self.profile_manager: + return self.profile_manager.get_profile_names() + return [] + + def load_area_profile(self, profile_name: str): + """Loads a profile and updates the GUI and map.""" + if not self.profile_manager: + module_logger.error("Profile manager not available.") + return + + profile_data = self.profile_manager.get_profile_data(profile_name) + if profile_data: + module_logger.info(f"Loading profile: '{profile_name}'") + # Aggiorna i campi della GUI + if self.main_window: + self.main_window.update_bbox_gui_fields(profile_data) + + # Aggiorna la mappa + if self.main_window and self.main_window.map_manager_instance: + self.main_window.map_manager_instance.set_target_bbox(profile_data) + else: + module_logger.warning(f"Profile '{profile_name}' not found.") + + def save_current_area_as_profile(self): + """Saves the current BBox values as a new profile.""" + if not self.profile_manager or not self.main_window: + module_logger.error("Profile manager or main window not available.") + return + + current_bbox = self.main_window.get_bounding_box_from_gui() + if not current_bbox: + self.main_window.show_error_message("Save Error", "The current Bounding Box values are invalid.") + return + + # Chiedi all'utente un nome per il nuovo profilo + profile_name = simpledialog.askstring( + "Save Profile", "Enter a name for the new area profile:", + parent=self.main_window.root + ) + + if not profile_name or not profile_name.strip(): + module_logger.info("Profile save cancelled by user.") + return + + profile_name = profile_name.strip() + + if self.profile_manager.save_profile(profile_name, current_bbox): + module_logger.info(f"Successfully saved profile '{profile_name}'.") + # Aggiorna la ComboBox nella GUI + if hasattr(self.main_window, "area_management_panel"): + self.main_window.area_management_panel.update_profile_list() + self.main_window.area_management_panel.set_selected_profile(profile_name) + self.main_window.show_info_message("Success", f"Profile '{profile_name}' saved successfully.") + else: + self.main_window.show_error_message("Save Error", f"Could not save profile '{profile_name}'. The name might be invalid or reserved.") + + def delete_area_profile(self, profile_name: str): + """Deletes a user-defined profile.""" + if not self.profile_manager or not self.main_window: + module_logger.error("Profile manager or main window not available.") + return + + if self.profile_manager.delete_profile(profile_name): + module_logger.info(f"Successfully deleted profile '{profile_name}'.") + # Aggiorna la ComboBox nella GUI + if hasattr(self.main_window, "area_management_panel"): + self.main_window.area_management_panel.update_profile_list() + self.main_window.show_info_message("Success", f"Profile '{profile_name}' deleted.") + else: + self.main_window.show_error_message("Delete Error", f"Could not delete profile '{profile_name}'. It might be the default profile or not exist.") \ No newline at end of file diff --git a/flightmonitor/data/area_profile_manager.py b/flightmonitor/data/area_profile_manager.py new file mode 100644 index 0000000..63e1102 --- /dev/null +++ b/flightmonitor/data/area_profile_manager.py @@ -0,0 +1,114 @@ +# FlightMonitor/data/area_profile_manager.py +""" +Manages loading, saving, and handling of geographic area profiles. +""" +import json +import os +from typing import Dict, Optional, List + +from ..utils.logger import get_logger +from . import config as app_config + +module_logger = get_logger(__name__) + +PROFILE_FILENAME = "area_profiles.json" +DEFAULT_PROFILE_NAME = "Default Zone" + +class AreaProfileManager: + """ + Handles reading from and writing to the area profiles JSON file. + """ + def __init__(self, profiles_directory: str = "config"): + """ + Initializes the AreaProfileManager. + + Args: + profiles_directory: The directory where the profiles file will be stored. + """ + self.profiles_path = os.path.join(os.path.abspath(profiles_directory), PROFILE_FILENAME) + self._profiles: Dict[str, Dict[str, float]] = {} + self._load_profiles() + + def _create_default_profile(self) -> Dict[str, float]: + """Creates the default profile from the application config.""" + return { + "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, + } + + def _load_profiles(self): + """Loads profiles from the JSON file and ensures the default profile exists.""" + # Start with the mandatory default profile + self._profiles = {DEFAULT_PROFILE_NAME: self._create_default_profile()} + + if os.path.exists(self.profiles_path): + try: + with open(self.profiles_path, "r") as f: + user_profiles = json.load(f) + if isinstance(user_profiles, dict): + # Merge user profiles, default profile cannot be overwritten + for name, data in user_profiles.items(): + if name != DEFAULT_PROFILE_NAME: + self._profiles[name] = data + module_logger.info(f"Loaded {len(user_profiles)} user profiles from {self.profiles_path}") + except (json.JSONDecodeError, IOError) as e: + module_logger.error(f"Error loading profiles from {self.profiles_path}: {e}") + else: + module_logger.info("Area profiles file not found. Starting with default profile.") + # We can optionally save the default profile to create the file on first run + self._save_profiles() + + def _save_profiles(self): + """Saves the current user-defined profiles to the JSON file.""" + # Ensure the directory exists + try: + os.makedirs(os.path.dirname(self.profiles_path), exist_ok=True) + except OSError as e: + module_logger.error(f"Could not create directory for profiles file: {e}") + return + + # Prepare user-defined profiles for saving (exclude the default) + profiles_to_save = { + name: data for name, data in self._profiles.items() if name != DEFAULT_PROFILE_NAME + } + + try: + with open(self.profiles_path, "w") as f: + json.dump(profiles_to_save, f, indent=4) + module_logger.info(f"Successfully saved {len(profiles_to_save)} profiles to {self.profiles_path}") + except IOError as e: + module_logger.error(f"Error saving profiles to {self.profiles_path}: {e}") + + def get_profile_names(self) -> List[str]: + """Returns a sorted list of all available profile names.""" + return sorted(self._profiles.keys()) + + def get_profile_data(self, name: str) -> Optional[Dict[str, float]]: + """Returns the BBox data for a given profile name.""" + return self._profiles.get(name) + + def save_profile(self, name: str, data: Dict[str, float]) -> bool: + """ + Saves a new or updated profile. Returns False if name is invalid or default. + """ + if not name or name == DEFAULT_PROFILE_NAME: + module_logger.warning(f"Attempt to save invalid or default profile name: '{name}'") + return False + + self._profiles[name] = data + self._save_profiles() + return True + + def delete_profile(self, name: str) -> bool: + """Deletes a profile. Returns False if the profile is default or not found.""" + if name == DEFAULT_PROFILE_NAME: + module_logger.warning("Cannot delete the default profile.") + return False + if name in self._profiles: + del self._profiles[name] + self._save_profiles() + return True + module_logger.warning(f"Profile '{name}' not found for deletion.") + return False \ No newline at end of file diff --git a/flightmonitor/gui/main_window.py b/flightmonitor/gui/main_window.py index bf37218..cc19989 100644 --- a/flightmonitor/gui/main_window.py +++ b/flightmonitor/gui/main_window.py @@ -5,10 +5,8 @@ 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, messagebox, filedialog, Menu, font as tkFont -from typing import List, Dict, Optional, Any, Tuple, TYPE_CHECKING - -import os +from tkinter import ttk, messagebox, filedialog, Menu +from typing import List, Dict, Optional, Any, Tuple from ..data import config as app_config from ..utils.logger import get_logger, add_tkinter_handler, shutdown_logging_system @@ -20,25 +18,23 @@ from .panels.map_tools_panel import MapToolsPanel from .panels.map_info_panel import MapInfoPanel from .panels.selected_flight_details_panel import SelectedFlightDetailsPanel from .panels.views_notebook_panel import ViewsNotebookPanel -# New and updated imports from .panels.function_notebook_panel import FunctionNotebookPanel -from .panels.area_management_panel import AreaManagementPanel # New panel try: from ..map.map_canvas_manager import MapCanvasManager MAP_CANVAS_MANAGER_AVAILABLE = True -except ImportError: +except ImportError as e_map_import: MapCanvasManager = None MAP_CANVAS_MANAGER_AVAILABLE = False - print("CRITICAL ERROR in MainWindow: Failed to import MapCanvasManager.", flush=True) + print(f"CRITICAL ERROR in MainWindow: Failed to import MapCanvasManager: {e_map_import}.", flush=True) try: from .dialogs.import_progress_dialog import ImportProgressDialog IMPORT_DIALOG_AVAILABLE = True -except ImportError: +except ImportError as e_dialog_import: ImportProgressDialog = None IMPORT_DIALOG_AVAILABLE = False - print("ERROR in MainWindow: Failed to import ImportProgressDialog.", flush=True) + print(f"ERROR in MainWindow: Failed to import ImportProgressDialog: {e_dialog_import}.", flush=True) module_logger = get_logger(__name__) @@ -59,15 +55,14 @@ class MainWindow: except tk.TclError: pass self.root.minsize( - getattr(app_config, "LAYOUT_WINDOW_MIN_WIDTH", 900), - getattr(app_config, "LAYOUT_WINDOW_MIN_HEIGHT", 650) + app_config.LAYOUT_WINDOW_MIN_WIDTH, + app_config.LAYOUT_WINDOW_MIN_HEIGHT ) self._create_menu() self._create_main_layout() self._create_panels() - # Setup logging to the GUI widget if self.log_status_panel.get_log_widget() and self.root: add_tkinter_handler( gui_log_widget=self.log_status_panel.get_log_widget(), @@ -94,53 +89,37 @@ class MainWindow: self.root.config(menu=menubar) def _create_main_layout(self): - # Main horizontal division main_hpane = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL) main_hpane.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - # Left column left_column_frame = ttk.Frame(main_hpane) - main_hpane.add(left_column_frame, weight=app_config.LAYOUT_MAIN_HORIZONTAL_WEIGHTS.get("left_column", 25)) + main_hpane.add(left_column_frame, weight=app_config.LAYOUT_MAIN_HORIZONTAL_WEIGHTS["left_column"]) - # Right column right_column_frame = ttk.Frame(main_hpane) - main_hpane.add(right_column_frame, weight=app_config.LAYOUT_MAIN_HORIZONTAL_WEIGHTS.get("right_column", 75)) + main_hpane.add(right_column_frame, weight=app_config.LAYOUT_MAIN_HORIZONTAL_WEIGHTS["right_column"]) - # --- Configure Left Column --- - left_column_frame.rowconfigure(0, weight=3) # Area for BBox and Profiles - left_column_frame.rowconfigure(1, weight=3) # Area for Function Notebook - left_column_frame.rowconfigure(2, weight=4) # Area for Log/Status - left_column_frame.columnconfigure(0, weight=1) + left_vpane = ttk.PanedWindow(left_column_frame, orient=tk.VERTICAL) + left_vpane.pack(fill=tk.BOTH, expand=True) - self.area_management_frame = ttk.Frame(left_column_frame) - self.area_management_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 5)) + self.function_and_area_frame = ttk.Frame(left_vpane) + left_vpane.add(self.function_and_area_frame, weight=app_config.LAYOUT_LEFT_VERTICAL_WEIGHTS["function_notebook"]) - self.function_notebook_frame = ttk.Frame(left_column_frame) - self.function_notebook_frame.grid(row=1, column=0, sticky="nsew") + self.log_status_area_frame_container = ttk.Frame(left_vpane) + left_vpane.add(self.log_status_area_frame_container, weight=app_config.LAYOUT_LEFT_VERTICAL_WEIGHTS["log_status_area"]) - self.log_status_area_frame_container = ttk.Frame(left_column_frame, padding=(0,5,0,0)) - self.log_status_area_frame_container.grid(row=2, column=0, sticky="nsew") - - # --- Configure Right Column --- right_vpane = ttk.PanedWindow(right_column_frame, orient=tk.VERTICAL) right_vpane.pack(fill=tk.BOTH, expand=True) - self.views_notebook_outer_frame = ttk.Frame(right_vpane) - right_vpane.add(self.views_notebook_outer_frame, weight=app_config.LAYOUT_RIGHT_VERTICAL_WEIGHTS.get("views_notebook", 80)) - - self.map_tools_info_area_frame = ttk.Frame(right_vpane, padding=(0, 5, 0, 0)) - right_vpane.add(self.map_tools_info_area_frame, weight=app_config.LAYOUT_RIGHT_VERTICAL_WEIGHTS.get("map_tools_info", 20)) - + right_vpane.add(self.views_notebook_outer_frame, weight=app_config.LAYOUT_RIGHT_VERTICAL_WEIGHTS["views_notebook"]) + self.map_tools_info_area_frame = ttk.Frame(right_vpane) + right_vpane.add(self.map_tools_info_area_frame, weight=app_config.LAYOUT_RIGHT_VERTICAL_WEIGHTS["map_tools_info"]) + def _create_panels(self): - # Left column panels - self.area_management_panel = AreaManagementPanel(self.area_management_frame, self.controller) - self.function_notebook_panel = FunctionNotebookPanel(self.function_notebook_frame, self.controller) + self.function_notebook_panel = FunctionNotebookPanel(self.function_and_area_frame, self.controller) self.log_status_panel = LogStatusPanel(self.log_status_area_frame_container, self.root) - # Right column panels self.views_notebook_panel = ViewsNotebookPanel(self.views_notebook_outer_frame) - # Bottom panels container bottom_panel_container = ttk.Frame(self.map_tools_info_area_frame) bottom_panel_container.pack(fill=tk.BOTH, expand=True) bottom_weights = app_config.LAYOUT_BOTTOM_PANELS_HORIZONTAL_WEIGHTS @@ -160,37 +139,29 @@ class MainWindow: selected_flight_details_frame.grid(row=0, column=2, sticky="nsew", padx=(2, 0)) self.selected_flight_details_panel = SelectedFlightDetailsPanel(selected_flight_details_frame, self.controller) - # --- Public Methods and Callbacks --- - def get_bounding_box_from_gui(self) -> Optional[Dict[str, float]]: - """Retrieves the bounding box from the dedicated AreaManagementPanel.""" - return self.area_management_panel.get_bounding_box_input() + return self.function_notebook_panel.get_bounding_box_input() def update_bbox_gui_fields(self, bbox_dict: Dict[str, float]): - """Delegates updating BBox GUI fields to the AreaManagementPanel.""" - self.area_management_panel.update_bbox_gui_fields(bbox_dict) + self.function_notebook_panel.update_bbox_gui_fields(bbox_dict) def set_monitoring_button_states(self, is_monitoring_active: bool): - """Delegates setting the state of monitoring buttons.""" self.function_notebook_panel.set_monitoring_button_states(is_monitoring_active) - self.area_management_panel.set_controls_state(not is_monitoring_active) + self.function_notebook_panel.set_controls_state(not is_monitoring_active) def update_semaphore_and_status(self, status_level: str, message: str): self.log_status_panel.update_status_display(status_level, message) def _reset_gui_to_stopped_state(self, status_message: Optional[str] = "Monitoring stopped."): - """Resets the GUI to its initial, non-monitoring state.""" self.set_monitoring_button_states(False) status_level = GUI_STATUS_ERROR if "error" in status_message.lower() else GUI_STATUS_OK self.update_semaphore_and_status(status_level, status_message) def _on_closing(self): if messagebox.askokcancel("Quit", "Do you want to quit Flight Monitor?", parent=self.root): - if self.controller and hasattr(self.controller, "on_application_exit"): - self.controller.on_application_exit() + if self.controller: self.controller.on_application_exit() shutdown_logging_system() - if self.root and self.root.winfo_exists(): - self.root.destroy() + if self.root: self.root.destroy() def _import_aircraft_db_csv(self): if not (self.controller and hasattr(self.controller, "import_aircraft_database_from_file_with_progress")): @@ -203,22 +174,18 @@ class MainWindow: ) if filepath: if IMPORT_DIALOG_AVAILABLE and ImportProgressDialog: + if self.progress_dialog and self.progress_dialog.winfo_exists(): + self.progress_dialog.destroy() self.progress_dialog = ImportProgressDialog(self.root, file_name=filepath) self.controller.import_aircraft_database_from_file_with_progress( filepath, self.progress_dialog ) else: self.show_info_message("Import Started", "Importing in background. Check logs for completion.") - # Fallback to a non-progress version if you have one - # self.controller.import_aircraft_database_from_file(filepath) - - # Other methods (show_error_message, update_selected_flight_details, etc.) remain mostly the same - # But now they delegate to the appropriate sub-panel if necessary. def show_error_message(self, title: str, message: str): if self.root.winfo_exists(): messagebox.showerror(title, message, parent=self.root) - self.update_semaphore_and_status(GUI_STATUS_ERROR, message) def show_info_message(self, title: str, message: str): if self.root.winfo_exists(): @@ -238,20 +205,18 @@ class MainWindow: def _delayed_initialization(self): if not self.root.winfo_exists(): return - if MAP_CANVAS_MANAGER_AVAILABLE and MapCanvasManager: - default_map_bbox = self.get_bounding_box_from_gui() - # Initial track length is now handled by map_canvas_manager default - self.root.after(200, self._initialize_map_manager, default_map_bbox) + if MAP_CANVAS_MANAGER_AVAILABLE: + initial_bbox = self.get_bounding_box_from_gui() + self.root.after(200, self._initialize_map_manager, initial_bbox) else: self._update_map_placeholder("Map functionality disabled (Import Error).") - self.function_notebook_panel._on_tab_change() # Trigger initial state + self.function_notebook_panel._on_tab_change() module_logger.info("MainWindow fully initialized.") def _initialize_map_manager(self, initial_bbox_for_map: Optional[Dict[str, float]]): flight_canvas = self.views_notebook_panel.get_map_canvas() if not (flight_canvas and flight_canvas.winfo_exists()): - module_logger.warning("Flight canvas not available for map manager init.") return canvas_w, canvas_h = flight_canvas.winfo_width(), flight_canvas.winfo_height() @@ -275,8 +240,7 @@ class MainWindow: flight_canvas = self.views_notebook_panel.get_map_canvas() if not (flight_canvas and flight_canvas.winfo_exists()): return - # Check if map manager is active. If so, don't show placeholder. - if self.map_manager_instance: + if self.map_manager_instance and not self.map_manager_instance.is_detail_map: flight_canvas.delete("placeholder_text") return @@ -290,24 +254,11 @@ class MainWindow: except tk.TclError: pass - # Pass-through methods for map info updates def update_clicked_map_info(self, lat_deg, lon_deg, lat_dms, lon_dms): self.map_info_panel.update_clicked_map_info(lat_deg, lon_deg, lat_dms, lon_dms) - def update_general_map_info_display( - self, - zoom: Optional[int], - map_size_str: str, - map_geo_bounds: Optional[Tuple[float, float, float, float]], - target_bbox_input: Optional[Dict[str, float]], # Aggiunto il parametro mancante - flight_count: Optional[int], - ): - if hasattr(self, "map_info_panel") and self.map_info_panel: - self.map_info_panel.update_general_map_info_display( - zoom, map_size_str, map_geo_bounds, target_bbox_input, flight_count - ) - else: - module_logger.warning("MapInfoPanel not initialized for update_general_map_info_display.") + def update_general_map_info_display(self, zoom, map_size_str, map_geo_bounds, target_bbox_input, flight_count): + self.map_info_panel.update_general_map_info_display(zoom, map_size_str, map_geo_bounds, target_bbox_input, flight_count) def show_map_context_menu(self, latitude, longitude, screen_x, screen_y): if self.map_manager_instance: diff --git a/flightmonitor/gui/panels/area_management_panel.py b/flightmonitor/gui/panels/area_management_panel.py deleted file mode 100644 index 42db9f0..0000000 --- a/flightmonitor/gui/panels/area_management_panel.py +++ /dev/null @@ -1,123 +0,0 @@ -# FlightMonitor/gui/panels/area_management_panel.py -""" -Panel for managing the geographic area of interest (Bounding Box) -and area profiles. -""" -import tkinter as tk -from tkinter import ttk -from typing import Dict, Any, Optional - -from ...utils.logger import get_logger -from ...data import config as app_config -from ...map.map_utils import _is_valid_bbox_dict - -module_logger = get_logger(__name__) - -class AreaManagementPanel: - """ - Manages the GUI elements for Bounding Box input and area profiles. - """ - def __init__(self, parent_frame: ttk.Frame, controller: Any): - """ - Initializes the AreaManagementPanel. - - Args: - parent_frame: The parent ttk.Frame where this panel will be placed. - controller: The application controller instance. - """ - self.parent_frame = parent_frame - self.controller = controller - - # --- Tkinter Variables --- - # Creiamo le variabili, ma le popoliamo subito dopo. - self.lat_min_var = tk.StringVar() - self.lon_min_var = tk.StringVar() - self.lat_max_var = tk.StringVar() - self.lon_max_var = tk.StringVar() - - # Popoliamo le variabili con i valori di default dal config - self.lat_min_var.set(str(app_config.DEFAULT_BBOX_LAT_MIN)) - self.lon_min_var.set(str(app_config.DEFAULT_BBOX_LON_MIN)) - self.lat_max_var.set(str(app_config.DEFAULT_BBOX_LAT_MAX)) - self.lon_max_var.set(str(app_config.DEFAULT_BBOX_LON_MAX)) - - self._build_ui() - module_logger.debug("AreaManagementPanel initialized.") - - def _build_ui(self): - """Builds the UI elements for area management.""" - # Bounding Box Input Frame - self.bbox_frame = ttk.LabelFrame( - self.parent_frame, - text="Geographic Area (Bounding Box)", - padding=(10, 5) - ) - self.bbox_frame.pack(side=tk.TOP, fill=tk.X, expand=True, padx=2, pady=(0,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_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_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_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_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) - - # Placeholder for Profile Management - self.profiles_frame = ttk.LabelFrame( - self.parent_frame, text="Area Profiles", padding=(10, 5) - ) - self.profiles_frame.pack(side=tk.TOP, fill=tk.X, expand=True, padx=2, pady=5) - ttk.Label(self.profiles_frame, text="Profile management coming soon...").pack() - - def get_bounding_box_input(self) -> Optional[Dict[str, float]]: - """ - Retrieves and validates the bounding box coordinates from the GUI input fields. - Returns a dictionary {lat_min, lon_min, lat_max, lon_max} or None if invalid. - """ - try: - bbox_candidate = { - "lat_min": float(self.lat_min_var.get()), - "lon_min": float(self.lon_min_var.get()), - "lat_max": float(self.lat_max_var.get()), - "lon_max": float(self.lon_max_var.get()), - } - if _is_valid_bbox_dict(bbox_candidate): - return bbox_candidate - else: - module_logger.warning(f"BBox from GUI failed validation: {bbox_candidate}") - return None - except (ValueError, TypeError): - module_logger.warning("Invalid numeric format in BBox GUI fields.") - return None - - def update_bbox_gui_fields(self, bbox_dict: Dict[str, float]): - """Updates the bounding box input fields in the GUI.""" - if bbox_dict and _is_valid_bbox_dict(bbox_dict): - decimals = getattr(app_config, "COORDINATE_DECIMAL_PLACES", 5) - 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}") - else: - self.lat_min_var.set("") - self.lon_min_var.set("") - self.lat_max_var.set("") - self.lon_max_var.set("") - - def set_controls_state(self, enabled: bool): - """Enables or disables all controls in this panel.""" - state = tk.NORMAL if enabled else tk.DISABLED - for entry in [self.lat_min_entry, self.lon_min_entry, self.lat_max_entry, self.lon_max_entry]: - if entry.winfo_exists(): - entry.config(state=state) - # Future: Add profile buttons here \ No newline at end of file diff --git a/flightmonitor/gui/panels/function_notebook_panel.py b/flightmonitor/gui/panels/function_notebook_panel.py index ecf2f78..5c37631 100644 --- a/flightmonitor/gui/panels/function_notebook_panel.py +++ b/flightmonitor/gui/panels/function_notebook_panel.py @@ -1,136 +1,195 @@ # FlightMonitor/gui/panels/function_notebook_panel.py """ -Panel managing the function-related notebooks (Live, Historical Download, Playback) -and their associated controls. +Panel managing the area profiles, BBox input, and the function notebooks. """ import tkinter as tk -from tkinter import ttk +from tkinter import ttk, messagebox from typing import Dict, Any, Optional from ...utils.logger import get_logger +from ...data import config as app_config +from ...map.map_utils import _is_valid_bbox_dict +from ...data.area_profile_manager import DEFAULT_PROFILE_NAME module_logger = get_logger(__name__) class FunctionNotebookPanel: """ - Manages the function-selection notebook (Live, Historical Download, Playback). + Manages the combined Area Profiles, BBox, and Function Notebooks panel. """ - def __init__(self, parent_frame: ttk.Frame, controller: Any): - """ - Initializes the FunctionNotebookPanel. - - Args: - parent_frame: The parent ttk.Frame where this notebook will be placed. - controller: The application controller instance to delegate actions. - """ self.parent_frame = parent_frame self.controller = controller - # --- UI Elements --- - self.function_notebook: Optional[ttk.Notebook] = None - self.start_live_button: Optional[ttk.Button] = None - self.stop_live_button: Optional[ttk.Button] = None - - # Placeholder for future controls - self.start_download_button: Optional[ttk.Button] = None + # --- Tkinter Variables --- + self.lat_min_var = tk.StringVar() + self.lon_min_var = tk.StringVar() + self.lat_max_var = tk.StringVar() + self.lon_max_var = tk.StringVar() + self.selected_profile_var = tk.StringVar() self._build_ui() - module_logger.debug("FunctionNotebookPanel initialized.") + self.update_profile_list() + self.set_selected_profile(DEFAULT_PROFILE_NAME) + + module_logger.debug("FunctionNotebookPanel (unified) initialized.") def _build_ui(self): - """ - Builds the main notebook structure and all its tabs and controls. - """ - self.function_notebook = ttk.Notebook(self.parent_frame) - self.function_notebook.pack(fill=tk.BOTH, expand=True, padx=2, pady=2) + # --- Main Container for Area Management --- + area_frame = ttk.LabelFrame(self.parent_frame, text="Area Profiles & BBox", padding=10) + area_frame.pack(side=tk.TOP, fill=tk.X, padx=2, pady=(0, 5)) + # Configure grid for the area frame + area_frame.columnconfigure(1, weight=1) + + # --- Row 0: Profile Selection and Buttons --- + profile_controls_frame = ttk.Frame(area_frame) + profile_controls_frame.grid(row=0, column=0, columnspan=4, sticky="ew", pady=(0, 10)) + profile_controls_frame.columnconfigure(0, weight=1) # Let combobox expand + + self.profile_combobox = ttk.Combobox( + profile_controls_frame, textvariable=self.selected_profile_var, state="readonly" + ) + self.profile_combobox.grid(row=0, column=0, sticky="ew", padx=(0, 5)) + self.profile_combobox.bind("<>", self._on_profile_selected) + + new_button = ttk.Button(profile_controls_frame, text="New", command=self._on_new_profile, width=5) + new_button.grid(row=0, column=1, padx=(0, 2)) + + save_button = ttk.Button(profile_controls_frame, text="Save", command=self._on_save_profile, width=5) + save_button.grid(row=0, column=2, padx=(0, 2)) + + delete_button = ttk.Button(profile_controls_frame, text="Delete", command=self._on_delete_profile, width=7) + delete_button.grid(row=0, column=3) + + # --- Row 1 & 2: Bounding Box Entries --- + ttk.Label(area_frame, text="Lat Min:").grid(row=1, column=0, padx=(0, 2), pady=2, sticky=tk.W) + self.lat_min_entry = ttk.Entry(area_frame, textvariable=self.lat_min_var) + self.lat_min_entry.grid(row=1, column=1, padx=(0, 5), pady=2, sticky=tk.EW) + + ttk.Label(area_frame, text="Lon Min:").grid(row=1, column=2, padx=(5, 2), pady=2, sticky=tk.W) + self.lon_min_entry = ttk.Entry(area_frame, textvariable=self.lon_min_var) + self.lon_min_entry.grid(row=1, column=3, padx=(0, 0), pady=2, sticky=tk.EW) + + ttk.Label(area_frame, text="Lat Max:").grid(row=2, column=0, padx=(0, 2), pady=2, sticky=tk.W) + self.lat_max_entry = ttk.Entry(area_frame, textvariable=self.lat_max_var) + self.lat_max_entry.grid(row=2, column=1, padx=(0, 5), pady=2, sticky=tk.EW) + + ttk.Label(area_frame, text="Lon Max:").grid(row=2, column=2, padx=(5, 2), pady=2, sticky=tk.W) + self.lon_max_entry = ttk.Entry(area_frame, textvariable=self.lon_max_var) + self.lon_max_entry.grid(row=2, column=3, padx=(0, 0), pady=2, sticky=tk.EW) + + # --- Function Notebook --- + self.function_notebook = ttk.Notebook(self.parent_frame) + self.function_notebook.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=2, pady=2) + # --- Live Tab --- live_tab_frame = ttk.Frame(self.function_notebook, padding=10) self.function_notebook.add(live_tab_frame, text="Live Monitor") - - self.start_live_button = ttk.Button( - live_tab_frame, text="Start Live Monitoring", command=self._on_start_live_monitoring - ) + self.start_live_button = ttk.Button(live_tab_frame, text="Start Live Monitoring", command=self._on_start_live_monitoring) self.start_live_button.pack(side=tk.LEFT, padx=5, pady=5) - - self.stop_live_button = ttk.Button( - live_tab_frame, - text="Stop Live Monitoring", - command=self._on_stop_live_monitoring, - state=tk.DISABLED, - ) + self.stop_live_button = ttk.Button(live_tab_frame, text="Stop Live Monitoring", command=self._on_stop_live_monitoring, state=tk.DISABLED) self.stop_live_button.pack(side=tk.LEFT, padx=5, pady=5) - + # --- Historical Download Tab --- download_tab_frame = ttk.Frame(self.function_notebook, padding=10) self.function_notebook.add(download_tab_frame, text="Historical Download") + ttk.Label(download_tab_frame, text="Historical download controls...").pack() - ttk.Label( - download_tab_frame, - text="Historical download controls will be here.", - font=("Arial", 10, "italic") - ).pack(expand=True) - # --- Playback Tab --- playback_tab_frame = ttk.Frame(self.function_notebook, padding=10) self.function_notebook.add(playback_tab_frame, text="Playback") - - ttk.Label( - playback_tab_frame, - text="Playback controls will be here.", - font=("Arial", 10, "italic") - ).pack(expand=True) + ttk.Label(playback_tab_frame, text="Playback controls...").pack() self.function_notebook.bind("<>", self._on_tab_change) + # --- Event Handlers --- def _on_start_live_monitoring(self): - """Callback for the 'Start Live Monitoring' button.""" - if not self.controller: - module_logger.error("Controller not available to start live monitoring.") - return - - module_logger.info("FunctionNotebookPanel: Start Live Monitoring clicked.") - # The controller will fetch the BBox from AreaManagementPanel via MainWindow - self.controller.start_live_monitoring() + if self.controller: self.controller.start_live_monitoring() def _on_stop_live_monitoring(self): - """Callback for the 'Stop Live Monitoring' button.""" - if not self.controller: - module_logger.error("Controller not available to stop live monitoring.") + if self.controller: self.controller.stop_live_monitoring() + + def _on_profile_selected(self, event=None): + selected_name = self.selected_profile_var.get() + if selected_name and self.controller: + self.controller.load_area_profile(selected_name) + + def _on_new_profile(self): + self.selected_profile_var.set("") + self.update_bbox_gui_fields({}) + + def _on_save_profile(self): + if self.controller: self.controller.save_current_area_as_profile() + + def _on_delete_profile(self): + profile_to_delete = self.selected_profile_var.get() + if not profile_to_delete or profile_to_delete == DEFAULT_PROFILE_NAME: + messagebox.showerror("Delete Error", "Cannot delete the default profile or no profile selected.", parent=self.parent_frame) return - - module_logger.info("FunctionNotebookPanel: Stop Live Monitoring clicked.") - self.controller.stop_live_monitoring() + if messagebox.askyesno("Confirm Delete", f"Delete profile '{profile_to_delete}'?", parent=self.parent_frame): + if self.controller: self.controller.delete_area_profile(profile_to_delete) - def _on_tab_change(self, event: Optional[tk.Event] = None): - """Handles changes in the selected function tab.""" + def _on_tab_change(self, event=None): if not self.function_notebook: return + tab_text = self.function_notebook.tab(self.function_notebook.index("current"), "text") + if self.controller and hasattr(self.controller, "on_function_tab_changed"): + self.controller.on_function_tab_changed(tab_text) + + # --- Public Methods --- + def update_profile_list(self): + if self.controller: + profile_names = self.controller.get_profile_names() + self.profile_combobox["values"] = profile_names + self.set_selected_profile(self.selected_profile_var.get() or DEFAULT_PROFILE_NAME) + + def set_selected_profile(self, profile_name: str): + if profile_name in self.profile_combobox["values"]: + self.selected_profile_var.set(profile_name) + elif self.profile_combobox["values"]: + self.selected_profile_var.set(self.profile_combobox["values"][0]) + + def get_bounding_box_input(self): + # ... (metodo rimane invariato) try: - tab_text = self.function_notebook.tab(self.function_notebook.index("current"), "text") - module_logger.info(f"FunctionNotebookPanel: Switched to tab: {tab_text}") + bbox_candidate = { + "lat_min": float(self.lat_min_var.get()), "lon_min": float(self.lon_min_var.get()), + "lat_max": float(self.lat_max_var.get()), "lon_max": float(self.lon_max_var.get()), + } + return bbox_candidate if _is_valid_bbox_dict(bbox_candidate) else None + except (ValueError, TypeError): + return None + + def update_bbox_gui_fields(self, bbox_dict: Dict[str, float]): + # ... (metodo rimane invariato) + if bbox_dict and _is_valid_bbox_dict(bbox_dict): + decimals = getattr(app_config, "COORDINATE_DECIMAL_PLACES", 5) + 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}") + else: + self.lat_min_var.set("") + self.lon_min_var.set("") + self.lat_max_var.set("") + self.lon_max_var.set("") - # Delegate placeholder updates to MainWindow via controller - if self.controller and hasattr(self.controller, "on_function_tab_changed"): - self.controller.on_function_tab_changed(tab_text) - - except (tk.TclError, IndexError) as e: - module_logger.warning(f"Error getting selected tab info: {e}") - def set_monitoring_button_states(self, is_monitoring_active: bool): - """ - Sets the state of the Start/Stop buttons for the Live tab. - """ + # ... (metodo rimane invariato) if self.start_live_button and self.start_live_button.winfo_exists(): self.start_live_button.config(state=tk.DISABLED if is_monitoring_active else tk.NORMAL) - if self.stop_live_button and self.stop_live_button.winfo_exists(): self.stop_live_button.config(state=tk.NORMAL if is_monitoring_active else tk.DISABLED) - def get_active_tab_text(self) -> Optional[str]: - """Returns the text of the currently active tab.""" - if not self.function_notebook: return None - try: - return self.function_notebook.tab(self.function_notebook.index("current"), "text") - except (tk.TclError, IndexError): - return None \ No newline at end of file + def set_controls_state(self, enabled: bool): + state = tk.NORMAL if enabled else tk.DISABLED + readonly_state = "readonly" if enabled else tk.DISABLED + + self.profile_combobox.config(state=readonly_state) + for entry in [self.lat_min_entry, self.lon_min_entry, self.lat_max_entry, self.lon_max_entry]: + if entry.winfo_exists(): entry.config(state=state) + + # Abilita/Disabilita i bottoni dei profili + for child in self.profile_combobox.master.winfo_children(): + if isinstance(child, ttk.Button): + child.config(state=state) \ No newline at end of file