SXXXXXXX_FlightMonitor/flightmonitor/gui/panels/function_notebook_panel.py
2025-06-04 10:14:38 +02:00

531 lines
26 KiB
Python

# 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("<<NotebookTabChanged>>", 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}")