# FlightMonitor/gui/panels/function_notebook_panel.py """ Panel managing the function-related notebooks (Live, History) and their associated controls like BBox input and monitoring buttons. """ import tkinter as tk from tkinter import ttk from typing import Dict, Any, Optional, Callable from ...utils.logger import get_logger from ...data import config as app_config from ...map.map_utils import _is_valid_bbox_dict # For BBox validation module_logger = get_logger(__name__) # Constants for default values DEFAULT_TRACK_LENGTH = getattr(app_config, "DEFAULT_TRACK_HISTORY_POINTS", 20) class FunctionNotebookPanel: """ Manages the function-selection notebook and its associated controls (Live/History mode, Bounding Box input, Start/Stop buttons, Track options). """ 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 and receive status updates from (e.g., for start/stop monitoring). """ self.parent_frame = parent_frame self.controller = controller # Initialize Tkinter variables for controls self.mode_var = tk.StringVar(value="Live") self.lat_min_var = tk.StringVar(value=str(app_config.DEFAULT_BBOX_LAT_MIN)) self.lon_min_var = tk.StringVar(value=str(app_config.DEFAULT_BBOX_LON_MIN)) self.lat_max_var = tk.StringVar(value=str(app_config.DEFAULT_BBOX_LAT_MAX)) self.lon_max_var = tk.StringVar(value=str(app_config.DEFAULT_BBOX_LON_MAX)) self.track_length_var = tk.IntVar(value=DEFAULT_TRACK_LENGTH) self._build_ui() # Call a helper method to build the UI elements module_logger.debug("FunctionNotebookPanel 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) # --- Live: Area Monitor Tab --- 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) # Control Frame (Mode Radiobuttons, Start/Stop Buttons) 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.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._on_start_monitoring ) self.start_button.pack(side=tk.LEFT, padx=5) self.stop_button = ttk.Button( self.control_frame, text="Stop Monitoring", command=self._on_stop_monitoring, state=tk.DISABLED, ) self.stop_button.pack(side=tk.LEFT, padx=5) # Bounding Box Input Frame 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_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) # Track Options Frame 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_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) # --- Live: Airport Tab (Placeholder) --- 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) # --- History Tab (Placeholder) --- 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) # Bind event for tab changes self.function_notebook.bind("<>", self._on_function_tab_change) def _on_mode_change(self): """ Handles changes in the selected mode (Live/History). Updates tab states and internal control states. Delegates mode change notification to the controller. """ selected_mode = self.mode_var.get() module_logger.info(f"FunctionNotebookPanel: Mode changed to {selected_mode}") try: 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 tk.TclError as e: module_logger.warning(f"FunctionNotebookPanel: TclError updating notebook tabs on mode change: {e}", exc_info=False) except Exception as e: module_logger.error(f"FunctionNotebookPanel: Error updating function notebook tabs: {e}", exc_info=True) # Notify controller about mode change, which implies clearing views if self.controller and hasattr(self.controller, "main_window") and self.controller.main_window: main_window = self.controller.main_window if hasattr(main_window, "clear_all_views_data"): main_window.clear_all_views_data() if hasattr(main_window, "_update_controls_state_based_on_mode_and_tab"): main_window._update_controls_state_based_on_mode_and_tab() main_window.update_semaphore_and_status(tk.NORMAL, f"Mode: {selected_mode}. Ready.") def _on_function_tab_change(self, event: Optional[tk.Event] = None): """ Handles changes in the selected function tab (e.g., Live: Area Monitor, History). Updates placeholder text on the map view and control states. """ try: tab_text = self.function_notebook.tab(self.function_notebook.index("current"), "text") module_logger.info(f"FunctionNotebookPanel: 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)" # Delegate map placeholder update and control state update to MainWindow if self.controller and hasattr(self.controller, "main_window") and self.controller.main_window: main_window = self.controller.main_window if hasattr(main_window, "_update_map_placeholder"): main_window._update_map_placeholder(placeholder_text_map) if hasattr(main_window, "_update_controls_state_based_on_mode_and_tab"): main_window._update_controls_state_based_on_mode_and_tab() except tk.TclError as e: module_logger.warning(f"FunctionNotebookPanel: TclError on function tab change: {e}", exc_info=False) except Exception as e: module_logger.error(f"FunctionNotebookPanel: Error processing function tab change: {e}", exc_info=True) def _on_start_monitoring(self): """ Callback for the 'Start Monitoring' button. Retrieves current settings and delegates to the controller to start monitoring. Updates internal button states. """ selected_mode = self.mode_var.get() active_func_tab_text = self.function_notebook.tab(self.function_notebook.index("current"), "text") # Disable start button, enable stop button, disable mode radios self.start_button.config(state=tk.DISABLED) self.stop_button.config(state=tk.NORMAL) self.live_radio.config(state=tk.DISABLED) self.history_radio.config(state=tk.DISABLED) # This will also disable BBox/Track entries if monitoring is active self._update_internal_controls_state() if not self.controller: module_logger.error("FunctionNotebookPanel: Controller unavailable for start monitoring.") self._show_error_message("Internal Error", "Application controller missing.") self._on_monitoring_failure_reset() # Reset GUI state on internal error return if selected_mode == "Live" and "Live: Area Monitor" in active_func_tab_text: bbox = self.get_bounding_box_input() if bbox: module_logger.info(f"FunctionNotebookPanel: Starting Live Area monitoring with BBox: {bbox}") self.controller.start_live_monitoring(bbox) if self.controller and hasattr(self.controller, "main_window") and self.controller.main_window: self.controller.main_window.update_semaphore_and_status( "FETCHING", "Live monitoring starting..." ) else: self._show_error_message("Input Error", "Bounding Box values are invalid or incomplete.") self._on_monitoring_failure_reset() elif selected_mode == "History" and "History" in active_func_tab_text: module_logger.info("FunctionNotebookPanel: Starting History monitoring.") self.controller.start_history_monitoring() # Placeholder if self.controller and hasattr(self.controller, "main_window") and self.controller.main_window: self.controller.main_window.update_semaphore_and_status( "OK", "History mode (placeholder)." ) else: err_msg = f"Start monitoring not supported on tab '{active_func_tab_text}' for mode '{selected_mode}'." module_logger.warning(f"FunctionNotebookPanel: {err_msg}") self._show_error_message("Operation Not Supported", err_msg) self._on_monitoring_failure_reset() def _on_stop_monitoring(self): """ Callback for the 'Stop Monitoring' button. Delegates to the controller to stop the currently active monitoring. """ selected_mode = self.mode_var.get() module_logger.info(f"FunctionNotebookPanel: Stop monitoring requested for mode: {selected_mode}") if not self.controller: module_logger.error("FunctionNotebookPanel: Controller unavailable for stop monitoring.") self._show_error_message("Internal Error", "Application controller missing.") self._on_monitoring_failure_reset() # Reset GUI state on internal error return if selected_mode == "Live": self.controller.stop_live_monitoring() elif selected_mode == "History": self.controller.stop_history_monitoring() # Placeholder # The GUI state will be reset by `MainWindow._reset_gui_to_stopped_state` # which is typically called by the controller after the monitoring thread fully stops. # However, we can set an immediate visual feedback here if desired. if self.controller and hasattr(self.controller, "main_window") and self.controller.main_window: self.controller.main_window.update_semaphore_and_status( "FETCHING", f"{selected_mode} monitoring stopping..." ) def _on_monitoring_failure_reset(self): """ Resets the GUI state of the panel after an internal failure to start monitoring. This is separate from `set_monitoring_active_state` as it's an internal reset due to error. """ self.start_button.config(state=tk.NORMAL) self.stop_button.config(state=tk.DISABLED) self.live_radio.config(state=tk.NORMAL) self.history_radio.config(state=tk.NORMAL) self._update_internal_controls_state() # Re-enable bbox/track based on mode def _on_track_length_change(self): """ Callback for the track length spinbox. Delegates the new track length to the application controller. """ if 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"FunctionNotebookPanel: Track length set to {new_length}") else: module_logger.warning(f"FunctionNotebookPanel: Invalid track length from spinbox: {new_length}") except tk.TclError: # Spinbox might not be fully ready or being destroyed module_logger.warning("FunctionNotebookPanel: TclError on track length change, spinbox might not be ready.", exc_info=False) except Exception as e: module_logger.error(f"FunctionNotebookPanel: Error processing track length change: {e}", exc_info=True) else: module_logger.warning("FunctionNotebookPanel: Controller or set_map_track_length method not available.") def _update_internal_controls_state(self): """ Updates the state of internal controls (BBox entries, track spinbox) based on current mode and monitoring status. This is a common method called by _on_mode_change and start/stop handlers. """ is_live_mode = self.mode_var.get() == "Live" is_monitoring_active = self.stop_button.cget("state") == tk.NORMAL # If stop button is enabled, monitoring is active active_func_tab_text = self.function_notebook.tab(self.function_notebook.index("current"), "text") # 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 AND not currently monitoring # (or depending on desired behavior, could allow changes during monitoring for next refresh) enable_track_spinbox = ( is_live_mode and "Live: Area Monitor" in active_func_tab_text and not is_monitoring_active # Current choice: disable if monitoring active ) if self.track_length_spinbox.winfo_exists(): try: new_state = "readonly" if enable_track_spinbox else tk.DISABLED self.track_length_spinbox.config(state=new_state) except tk.TclError: module_logger.warning("FunctionNotebookPanel: TclError updating track length spinbox state.", exc_info=False) def _set_bbox_entries_state(self, state: str): """ Sets the state (e.g., tk.NORMAL, tk.DISABLED) of the BBox input entries. """ for entry_widget in [self.lat_min_entry, self.lon_min_entry, self.lat_max_entry, self.lon_max_entry]: if entry_widget.winfo_exists(): try: entry_widget.config(state=state) except tk.TclError: module_logger.debug(f"FunctionNotebookPanel: TclError setting BBox entry state for {entry_widget}, widget likely destroyed.", exc_info=False) def get_selected_mode(self) -> str: """ Returns the currently selected monitoring mode ('Live' or 'History'). """ return self.mode_var.get() def get_active_function_tab_text(self) -> str: """ Returns the text of the currently selected function notebook tab. """ try: return self.function_notebook.tab(self.function_notebook.index("current"), "text") except tk.TclError: module_logger.warning("FunctionNotebookPanel: TclError getting active function tab text.", exc_info=False) return "" 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: str_values = [ self.lat_min_var.get(), self.lon_min_var.get(), self.lat_max_var.get(), self.lon_max_var.get(), ] if not all(s.strip() for s in str_values): # Check for empty or whitespace-only strings module_logger.debug("FunctionNotebookPanel: One or more BBox GUI fields are empty.") return None # Convert to float and create dictionary bbox_candidate = { "lat_min": float(str_values[0]), "lon_min": float(str_values[1]), "lat_max": float(str_values[2]), "lon_max": float(str_values[3]), } # Validate using the utility function if _is_valid_bbox_dict(bbox_candidate): return bbox_candidate else: module_logger.warning(f"FunctionNotebookPanel: BBox from GUI failed validation: {bbox_candidate}") return None except ValueError: module_logger.warning("FunctionNotebookPanel: Invalid numeric format in BBox GUI fields.", exc_info=False) return None # ValueError if float conversion fails except Exception as e: module_logger.error(f"FunctionNotebookPanel: Unexpected error getting BBox from GUI: {e}", exc_info=True) return None def get_track_length_input(self) -> int: """ Returns the configured track length (number of points) from the spinbox. """ return self.track_length_var.get() def update_bbox_gui_fields(self, bbox_dict: Dict[str, float]): """ Updates the bounding box input fields in the GUI with provided values. """ 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 module_logger.debug("FunctionNotebookPanel: TclError setting BBox GUI fields, widgets likely destroyed.", exc_info=False) except Exception as e: module_logger.error(f"FunctionNotebookPanel: Error setting BBox GUI fields: {e}", exc_info=False) 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: module_logger.debug("FunctionNotebookPanel: TclError clearing BBox GUI fields, widgets likely destroyed.", exc_info=False) def set_monitoring_button_states(self, is_monitoring_active: bool): """ Sets the state of the Start/Stop buttons and mode radio buttons. Args: is_monitoring_active: True if monitoring is active, False otherwise. """ if is_monitoring_active: self.start_button.config(state=tk.DISABLED) self.stop_button.config(state=tk.NORMAL) self.live_radio.config(state=tk.DISABLED) self.history_radio.config(state=tk.DISABLED) else: self.start_button.config(state=tk.NORMAL) self.stop_button.config(state=tk.DISABLED) self.live_radio.config(state=tk.NORMAL) self.history_radio.config(state=tk.NORMAL) self._update_internal_controls_state() # Re-evaluate state of BBox/Track inputs def _show_error_message(self, title: str, message: str): """ Helper method to show an error message, delegating to MainWindow via controller. """ if self.controller and hasattr(self.controller, "main_window") and self.controller.main_window: main_window = self.controller.main_window if hasattr(main_window, "show_error_message"): main_window.show_error_message(title, message) else: module_logger.error(f"MainWindow.show_error_message not found. Error: {title} - {message}") else: module_logger.error(f"Controller or MainWindow not available to show error: {title} - {message}") def _show_info_message(self, title: str, message: str): """ Helper method to show an info message, delegating to MainWindow via controller. """ if self.controller and hasattr(self.controller, "main_window") and self.controller.main_window: main_window = self.controller.main_window if hasattr(main_window, "show_info_message"): main_window.show_info_message(title, message) else: module_logger.warning(f"MainWindow.show_info_message not found. Info: {title} - {message}") else: module_logger.warning(f"Controller or MainWindow not available to show info: {title} - {message}")