# FlightMonitor/gui/panels/function_notebook_panel.py """ Panel managing the area profiles, data logging controls, BBox input, and the function notebooks (Live, Historical, etc.). """ import tkinter as tk from tkinter import ttk, messagebox, font as tkFont from typing import Dict, Any, Optional, Tuple import os try: from PIL import Image, ImageTk PIL_AVAILABLE = True except ImportError: PIL_AVAILABLE = False from flightmonitor.utils.logger import get_logger from flightmonitor.data import config as app_config from flightmonitor.map.map_utils import _is_valid_bbox_dict from flightmonitor.data.area_profile_manager import DEFAULT_PROFILE_NAME from flightmonitor.gui.panels.historical_download_panel import HistoricalDownloadPanel from flightmonitor.gui.panels.data_logging_panel import DataLoggingPanel from flightmonitor.gui.panels.playback_panel import PlaybackPanel module_logger = get_logger(__name__) ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "assets", "icons") PLAY_ICON_PATH = os.path.join(ICON_PATH, "play_icon.png") STOP_ICON_PATH = os.path.join(ICON_PATH, "stop_icon.png") COLOR_START_BG = "#28a745" COLOR_START_ACTIVE = "#218838" COLOR_STOP_BG = "#dc3545" COLOR_STOP_ACTIVE = "#c82333" COLOR_DISABLED_BG = "#e0e0e0" COLOR_DISABLED_FG = "#a0a0a0" COLOR_TEXT = "white" class FunctionNotebookPanel: """ Manages the combined Area Profiles, Data Logging, BBox, and Function Notebooks panel. """ def __init__(self, parent_frame: ttk.Frame, controller: Any): self.parent_frame = parent_frame self.controller = controller self.historical_panel: Optional[HistoricalDownloadPanel] = None self.data_logging_panel: Optional[DataLoggingPanel] = None self.playback_panel: Optional[PlaybackPanel] = None self.play_icon: Optional[ImageTk.PhotoImage] = None self.stop_icon: Optional[ImageTk.PhotoImage] = None 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() self.update_profile_list() self.set_selected_profile(DEFAULT_PROFILE_NAME) module_logger.debug("FunctionNotebookPanel (container) initialized.") def _load_icons(self): if not PIL_AVAILABLE: return try: icon_size = (24, 24) play_img = Image.open(PLAY_ICON_PATH).resize( icon_size, Image.Resampling.LANCZOS ) self.play_icon = ImageTk.PhotoImage(play_img) stop_img = Image.open(STOP_ICON_PATH).resize( icon_size, Image.Resampling.LANCZOS ) self.stop_icon = ImageTk.PhotoImage(stop_img) except Exception as e: module_logger.warning( f"Could not load icons: {e}. Buttons will be text-only." ) def _build_ui(self): """ Builds the UI by creating and packing the Area, Logging, and Function Notebook panels. """ self._load_icons() button_font = tkFont.Font(family="Helvetica", size=10, weight="bold") area_frame = ttk.LabelFrame( self.parent_frame, text="Area Profiles & BBox", padding=10 ) area_frame.pack(side=tk.TOP, fill=tk.X, expand=False, padx=2, pady=(0, 0)) area_frame.columnconfigure(1, weight=1) area_frame.columnconfigure(3, weight=1) 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) 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) 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) self.data_logging_panel = DataLoggingPanel(self.parent_frame, self.controller) 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_frame = ttk.Frame(self.function_notebook, padding=(10, 10, 10, 10)) self.function_notebook.add(live_tab_frame, text="Live Monitor") button_container = ttk.Frame(live_tab_frame) button_container.pack(side=tk.TOP, fill=tk.X, expand=False, anchor="n") button_container.columnconfigure(0, weight=1) button_container.columnconfigure(3, weight=1) self.start_live_button = tk.Button( button_container, text="Start Live", image=self.play_icon, compound=tk.LEFT, command=self._on_start_live_monitoring, font=button_font, bg=COLOR_START_BG, fg=COLOR_TEXT, activebackground=COLOR_START_ACTIVE, activeforeground=COLOR_TEXT, relief=tk.RAISED, borderwidth=2, padx=10, pady=5, ) self.start_live_button.grid(row=0, column=1, padx=15, pady=5) self.stop_live_button = tk.Button( button_container, text="Stop Live", image=self.stop_icon, compound=tk.LEFT, command=self._on_stop_live_monitoring, font=button_font, bg=COLOR_DISABLED_BG, fg=COLOR_DISABLED_FG, activebackground=COLOR_DISABLED_BG, activeforeground=COLOR_DISABLED_FG, state=tk.DISABLED, relief=tk.RAISED, borderwidth=2, padx=10, pady=5, ) self.stop_live_button.grid(row=0, column=2, padx=15, pady=5) download_tab_frame = ttk.Frame(self.function_notebook) self.function_notebook.add(download_tab_frame, text="Historical Download") self.historical_panel = HistoricalDownloadPanel( download_tab_frame, self.controller ) playback_tab_frame = ttk.Frame(self.function_notebook) self.function_notebook.add(playback_tab_frame, text="Playback") self.playback_panel = PlaybackPanel(playback_tab_frame, self.controller) self.function_notebook.bind("<>", self._on_tab_change) def set_monitoring_button_states(self, is_monitoring_active: bool): if is_monitoring_active: self.start_live_button.config( state=tk.DISABLED, bg=COLOR_DISABLED_BG, fg=COLOR_DISABLED_FG ) self.stop_live_button.config( state=tk.NORMAL, bg=COLOR_STOP_BG, fg=COLOR_TEXT ) else: self.start_live_button.config( state=tk.NORMAL, bg=COLOR_START_BG, fg=COLOR_TEXT ) self.stop_live_button.config( state=tk.DISABLED, bg=COLOR_DISABLED_BG, fg=COLOR_DISABLED_FG ) 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) for child in self.profile_combobox.master.winfo_children(): if isinstance(child, ttk.Button): child.config(state=state) def set_logging_controls_state(self, enabled: bool): if self.data_logging_panel: state = tk.NORMAL if enabled else tk.DISABLED for child in self.data_logging_panel.parent_frame.winfo_children(): if hasattr(child, "configure"): child.configure(state=state) def get_logging_panel_settings(self) -> Optional[Dict[str, Any]]: if self.data_logging_panel: return { "enabled": self.data_logging_panel.is_logging_enabled(), "directory": self.data_logging_panel.get_log_directory(), } return None def clear_all_panel_data(self): if self.data_logging_panel: self.data_logging_panel.clear_summary_table() def update_logging_panel_settings(self, settings: Dict[str, Any]): if self.data_logging_panel: self.data_logging_panel.update_settings( enabled=settings.get("enabled", False), directory=settings.get("directory", ""), ) def _on_start_live_monitoring(self): if self.controller: self.controller.start_live_monitoring() def _on_stop_live_monitoring(self): 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 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=None): if self.function_notebook: try: tab_text = self.function_notebook.tab( self.function_notebook.index("current"), "text" ) if self.controller: self.controller.on_function_tab_changed(tab_text) except tk.TclError: pass 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 ( self.profile_combobox["values"] and 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): 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()), } 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]): 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("")