SXXXXXXX_FlightMonitor/flightmonitor/gui/main_window.py
VALLONGOL 27e8459438 Chore: Stop tracking files based on .gitignore update.
Untracked files matching the following rules:
- Rule "!.vscode/launch.json": 1 file
2025-05-15 15:54:09 +02:00

453 lines
24 KiB
Python

# FlightMonitor/gui/main_window.py
import tkinter as tk
from tkinter import ttk
from tkinter import messagebox
from tkinter.scrolledtext import ScrolledText
from tkinter import font as tkFont
from typing import List, Dict, Optional, Tuple
# Relative imports
from ..data import config
from ..utils.logger import get_logger # Solo get_logger è usato direttamente qui
from ..data.common_models import CanonicalFlightState
module_logger = get_logger(__name__) # flightmonitor.gui.main_window
# --- Constants for Semaphore ---
SEMAPHORE_SIZE = 12
SEMAPHORE_PAD = 3 # Aumentato leggermente per più spazio visivo
SEMAPHORE_BORDER_WIDTH = 1
SEMAPHORE_TOTAL_SIZE = SEMAPHORE_SIZE + 2 * (SEMAPHORE_PAD + SEMAPHORE_BORDER_WIDTH)
SEMAPHORE_COLOR_OK = "green3"
SEMAPHORE_COLOR_WARNING = "gold"
SEMAPHORE_COLOR_ERROR = "red2"
SEMAPHORE_COLOR_UNKNOWN = "gray70"
SEMAPHORE_COLOR_FETCHING = "sky blue" # Nuovo colore per quando sta attivamente facendo qualcosa
class MainWindow:
"""
Main window of the Flight Monitor application.
Handles the layout, user interactions, and status display including a semaphore.
"""
def __init__(self, root: tk.Tk, controller):
self.root = root
self.controller = controller
self.root.title("Flight Monitor")
# Stime delle altezze dei componenti
controls_bbox_estimated_height = 120
log_area_min_height = 80 # Altezza minima desiderata per il log quando si ridimensiona
map_min_height = 200
status_bar_actual_height = 30
window_padding_buffer = 20
min_total_height = (controls_bbox_estimated_height +
map_min_height +
log_area_min_height +
status_bar_actual_height +
window_padding_buffer)
min_total_width = config.DEFAULT_CANVAS_WIDTH // 2 + 100
if min_total_width < 500: min_total_width = 500
self.root.minsize(min_total_width, min_total_height) # Questo è corretto per la finestra principale
module_logger.debug(f"Minimum window size set to: {min_total_width}x{min_total_height}")
initial_width = config.DEFAULT_CANVAS_WIDTH + 250
initial_height = (config.DEFAULT_CANVAS_HEIGHT +
controls_bbox_estimated_height +
100 + # Altezza iniziale log
status_bar_actual_height +
window_padding_buffer + 20)
if initial_height < min_total_height:
initial_height = min_total_height
self.root.geometry(f"{initial_width}x{initial_height}")
module_logger.debug(f"Initial window size set to: {initial_width}x{initial_height}")
# --- Main Layout: PanedWindow ---
self.main_paned_window = ttk.PanedWindow(self.root, orient=tk.VERTICAL)
self.main_paned_window.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5,0))
# --- Top Frame (Controls + Map) ---
self.top_frame = ttk.Frame(self.main_paned_window)
# MODIFIED: Rimosso minsize da .add()
self.main_paned_window.add(self.top_frame, weight=3)
self.controls_and_map_frame = ttk.Frame(self.top_frame)
self.controls_and_map_frame.pack(fill=tk.BOTH, expand=True)
# ... (Control Frame, BBox Frame, Output Frame (Canvas) come prima) ...
self.control_frame = ttk.LabelFrame(self.controls_and_map_frame, text="Controls", padding=(10, 5))
self.control_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(5,5))
self.mode_var = tk.StringVar(value="Live")
self.live_radio = ttk.Radiobutton(self.control_frame, text="Live", variable=self.mode_var, value="Live", command=self._on_mode_change)
self.live_radio.pack(side=tk.LEFT, padx=(0,5))
self.history_radio = ttk.Radiobutton(self.control_frame, text="History", variable=self.mode_var, value="History", command=self._on_mode_change)
self.history_radio.pack(side=tk.LEFT, padx=5)
self.start_button = ttk.Button(self.control_frame, text="Start Monitoring", command=self._start_monitoring)
self.start_button.pack(side=tk.LEFT, padx=5)
self.stop_button = ttk.Button(self.control_frame, text="Stop Monitoring", command=self._stop_monitoring, state=tk.DISABLED)
self.stop_button.pack(side=tk.LEFT, padx=5)
self.bbox_frame = ttk.LabelFrame(self.controls_and_map_frame, text="Geographic Area (Bounding Box)", padding=(10,5))
self.bbox_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5)
self.bbox_frame.columnconfigure(1, weight=1)
self.bbox_frame.columnconfigure(3, weight=1)
ttk.Label(self.bbox_frame, text="Lat Min:").grid(row=0, column=0, padx=(0,2), pady=2, sticky=tk.W)
self.lat_min_var = tk.StringVar(value=str(config.DEFAULT_BBOX_LAT_MIN))
self.lat_min_entry = ttk.Entry(self.bbox_frame, textvariable=self.lat_min_var, width=10)
self.lat_min_entry.grid(row=0, column=1, padx=(0,5), pady=2, sticky=tk.EW)
ttk.Label(self.bbox_frame, text="Lon Min:").grid(row=0, column=2, padx=(5,2), pady=2, sticky=tk.W)
self.lon_min_var = tk.StringVar(value=str(config.DEFAULT_BBOX_LON_MIN))
self.lon_min_entry = ttk.Entry(self.bbox_frame, textvariable=self.lon_min_var, width=10)
self.lon_min_entry.grid(row=0, column=3, padx=(0,0), pady=2, sticky=tk.EW)
ttk.Label(self.bbox_frame, text="Lat Max:").grid(row=1, column=0, padx=(0,2), pady=2, sticky=tk.W)
self.lat_max_var = tk.StringVar(value=str(config.DEFAULT_BBOX_LAT_MAX))
self.lat_max_entry = ttk.Entry(self.bbox_frame, textvariable=self.lat_max_var, width=10)
self.lat_max_entry.grid(row=1, column=1, padx=(0,5), pady=2, sticky=tk.EW)
ttk.Label(self.bbox_frame, text="Lon Max:").grid(row=1, column=2, padx=(5,2), pady=2, sticky=tk.W)
self.lon_max_var = tk.StringVar(value=str(config.DEFAULT_BBOX_LON_MAX))
self.lon_max_entry = ttk.Entry(self.bbox_frame, textvariable=self.lon_max_var, width=10)
self.lon_max_entry.grid(row=1, column=3, padx=(0,0), pady=2, sticky=tk.EW)
self.output_frame = ttk.LabelFrame(self.controls_and_map_frame, text="Flight Map", padding=5)
self.output_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5, pady=5)
self.flight_canvas = tk.Canvas(
self.output_frame, bg="gray90",
width=config.DEFAULT_CANVAS_WIDTH, height=config.DEFAULT_CANVAS_HEIGHT,
highlightthickness=0
)
self.flight_canvas.pack(fill=tk.BOTH, expand=True)
# --- Log Area ---
self.log_frame = ttk.LabelFrame(self.main_paned_window, text="Application Log", padding=5)
# MODIFIED: Rimosso minsize da .add()
self.main_paned_window.add(self.log_frame, weight=1)
log_font_family = "Consolas" if "Consolas" in tkFont.families() else "Courier New"
self.log_text_widget = ScrolledText(
self.log_frame, state=tk.DISABLED, height=8, wrap=tk.WORD,
font=(log_font_family, 9),
relief=tk.SUNKEN, borderwidth=1
)
self.log_text_widget.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
from ..utils.logger import setup_logging as setup_app_logging
setup_app_logging(gui_log_widget=self.log_text_widget)
# --- Status Bar ---
self.status_bar_frame = ttk.Frame(self.root, padding=(5, 3))
self.status_bar_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=(5,3))
self.semaphore_canvas = tk.Canvas(
self.status_bar_frame, width=SEMAPHORE_TOTAL_SIZE, height=SEMAPHORE_TOTAL_SIZE,
bg="SystemButtonFace", highlightthickness=0
)
self.semaphore_canvas.pack(side=tk.LEFT, padx=(0, 5))
x0 = SEMAPHORE_PAD + SEMAPHORE_BORDER_WIDTH; y0 = SEMAPHORE_PAD + SEMAPHORE_BORDER_WIDTH
x1 = x0 + SEMAPHORE_SIZE; y1 = y0 + SEMAPHORE_SIZE
self._semaphore_oval_id = self.semaphore_canvas.create_oval(
x0, y0, x1, y1, fill=SEMAPHORE_COLOR_UNKNOWN,
outline="gray30", width=SEMAPHORE_BORDER_WIDTH
)
self.status_label = ttk.Label(self.status_bar_frame, text="Status: Initializing...")
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0,2))
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
self._on_mode_change()
self.update_semaphore_and_status("OK", "System Initialized. Ready.")
module_logger.info("MainWindow fully initialized and displayed.")
# print("MW: __init__ - Fine, status iniziale impostato.") # Rimuovi o commenta i print di debug estremo
def update_semaphore_and_status(self,
status_level: str,
message: str):
"""
Updates the semaphore color and the status bar message.
Args:
status_level (str): The level of the status. Expected values:
"OK", "WARNING", "ERROR", "UNKNOWN", "FETCHING".
message (str): The message to display in the status bar.
"""
color_to_set = SEMAPHORE_COLOR_UNKNOWN # Default
if status_level == "OK":
color_to_set = SEMAPHORE_COLOR_OK
elif status_level == "WARNING":
color_to_set = SEMAPHORE_COLOR_WARNING
elif status_level == "ERROR":
color_to_set = SEMAPHORE_COLOR_ERROR
elif status_level == "FETCHING": # New visual state for active fetching
color_to_set = SEMAPHORE_COLOR_FETCHING
if hasattr(self, 'semaphore_canvas') and self.semaphore_canvas.winfo_exists():
try:
self.semaphore_canvas.itemconfig(self._semaphore_oval_id, fill=color_to_set)
except tk.TclError:
module_logger.warning("TclError updating semaphore color, canvas widget might be gone.")
current_status_text = f"Status: {message}"
if hasattr(self, 'status_label') and self.status_label.winfo_exists():
try:
self.status_label.config(text=current_status_text)
except tk.TclError:
module_logger.warning("TclError updating status label, widget might be gone.")
module_logger.debug(f"GUI Status Update: Level='{status_level}', Message='{message}'")
def _on_closing(self):
module_logger.info("Main window closing event triggered.")
if messagebox.askokcancel("Quit", "Do you want to quit Flight Monitor?", parent=self.root):
module_logger.info("User confirmed quit.")
if self.controller and hasattr(self.controller, 'on_application_exit'):
self.controller.on_application_exit()
if hasattr(self, 'root') and self.root.winfo_exists():
self.root.destroy()
module_logger.info("Application window destroyed.")
else:
module_logger.info("User cancelled quit.")
def _reset_gui_to_stopped_state(self, status_message: Optional[str] = "Monitoring stopped."):
if hasattr(self, 'start_button'): self.start_button.config(state=tk.NORMAL)
if hasattr(self, 'stop_button'): self.stop_button.config(state=tk.DISABLED)
if hasattr(self, 'live_radio'): self.live_radio.config(state=tk.NORMAL)
if hasattr(self, 'history_radio'): self.history_radio.config(state=tk.NORMAL)
current_mode_is_live = hasattr(self, 'mode_var') and self.mode_var.get() == "Live"
self._set_bbox_entries_state(tk.NORMAL if current_mode_is_live else tk.DISABLED)
status_level_for_semaphore = "OK" # Default for a clean stop by user
if status_message and ("failed" in status_message.lower() or "error" in status_message.lower()):
status_level_for_semaphore = "ERROR" # If stop was due to an error state
elif status_message and "warning" in status_message.lower(): # For less severe issues
status_level_for_semaphore = "WARNING"
self.update_semaphore_and_status(status_level_for_semaphore, status_message)
module_logger.debug(f"GUI controls reset to stopped state. Status: '{status_message}'")
def _on_mode_change(self):
selected_mode = self.mode_var.get()
status_message = f"Mode: {selected_mode}. Ready."
self.update_semaphore_and_status("OK", status_message)
placeholder_text_to_set = "Map Area - Select mode and configure."
if selected_mode == "Live":
placeholder_text_to_set = "Map Area - Ready for live data. Define area and press Start."
self._set_bbox_entries_state(tk.NORMAL)
elif selected_mode == "History":
placeholder_text_to_set = "Map Area - Ready for historical data. (Functionality TBD)"
self._set_bbox_entries_state(tk.DISABLED)
self.clear_canvas_data() # Clear old flight data
self._update_map_placeholder(placeholder_text_to_set) # Set new placeholder
def _update_map_placeholder(self, text_to_display: str):
if hasattr(self, 'flight_canvas') and self.flight_canvas.winfo_exists():
try:
self.flight_canvas.delete("placeholder_text") # Remove any old one first
canvas_w = self.flight_canvas.winfo_width() if self.flight_canvas.winfo_width() > 1 else config.DEFAULT_CANVAS_WIDTH
canvas_h = self.flight_canvas.winfo_height() if self.flight_canvas.winfo_height() > 1 else config.DEFAULT_CANVAS_HEIGHT
self.flight_canvas.create_text(
canvas_w / 2, canvas_h / 2,
text=text_to_display, tags="placeholder_text", fill="gray50" # Darker gray
)
except tk.TclError:
module_logger.warning("TclError updating map placeholder, canvas might be gone.")
def _set_bbox_entries_state(self, state: str):
if hasattr(self, 'lat_min_entry'): self.lat_min_entry.config(state=state)
# ... (checks for other entries)
if hasattr(self, 'lon_min_entry'): self.lon_min_entry.config(state=state)
if hasattr(self, 'lat_max_entry'): self.lat_max_entry.config(state=state)
if hasattr(self, 'lon_max_entry'): self.lon_max_entry.config(state=state)
module_logger.debug(f"Bounding box entries state set to: {state}")
def _start_monitoring(self):
selected_mode = self.mode_var.get()
module_logger.info(f"GUI: User requested to start {selected_mode} monitoring.")
self.update_semaphore_and_status("WARNING", f"Attempting to start {selected_mode} monitoring...")
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)
self._set_bbox_entries_state(tk.DISABLED)
if selected_mode == "Live":
bounding_box = self.get_bounding_box()
if bounding_box:
if self.controller:
self.controller.start_live_monitoring(bounding_box)
else: # Should not happen if app structure is correct
module_logger.critical("Controller not available when attempting to start live monitoring.")
self._reset_gui_to_stopped_state("Critical Error: Controller unavailable.")
self.show_error_message("Internal Error", "Application controller is missing.")
else: # Invalid bounding box, get_bounding_box() already showed error and set semaphore
self._reset_gui_to_stopped_state("Failed to start: Invalid bounding box.")
elif selected_mode == "History":
if self.controller: self.controller.start_history_monitoring()
self.clear_canvas_data()
self._update_map_placeholder("Displaying historical data (placeholder)...")
# ... (placeholder drawing for history)
if hasattr(self, 'flight_canvas') and self.flight_canvas.winfo_exists():
try:
self.flight_canvas.create_oval(90,90,110,110,fill="blue",outline="black",tags="flight_dot")
self.flight_canvas.create_text(100,125,text="HIST_FLT",tags="flight_label")
except tk.TclError: pass
module_logger.info("GUI: Switched to History mode (placeholder display).")
def _stop_monitoring(self):
module_logger.info("GUI: User requested to stop monitoring.")
selected_mode = self.mode_var.get()
if self.controller:
if selected_mode == "Live":
self.controller.stop_live_monitoring() # Controller will manage final status update
elif selected_mode == "History":
self.controller.stop_history_monitoring()
# Reset GUI controls. The final status message (OK, STOPPED) will come from the controller
# via a status update message, or _reset_gui_to_stopped_state provides a generic one.
self._reset_gui_to_stopped_state(f"{selected_mode} monitoring stopped by user.")
module_logger.debug("GUI controls reset after user initiated stop.")
def get_bounding_box(self) -> Optional[Dict[str, float]]:
# ... (implementation as in previous version, ensure self.show_error_message is called for errors)
module_logger.debug("Attempting to retrieve and validate bounding box.")
try:
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())
except ValueError:
msg = "Bounding box coordinates must be valid numbers."
module_logger.error(f"Input Error (ValueError): {msg}", exc_info=False) # No need for full stack trace here
self.show_error_message("Input Error", msg) # This sets semaphore to ERROR
return None
module_logger.debug(f"Raw BBox input: lat_min={lat_min}, lon_min={lon_min}, lat_max={lat_max}, lon_max={lon_max}")
if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90 and
-180 <= lon_min <= 180 and -180 <= lon_max <= 180):
msg = "Invalid geographic range. Lat: [-90, 90], Lon: [-180, 180]."
module_logger.error(f"Validation Error: {msg}")
self.show_error_message("Input Error", msg)
return None
if lat_min >= lat_max:
msg = "Latitude Min must be less than Latitude Max."
module_logger.error(f"Validation Error: {msg}")
self.show_error_message("Input Error", msg)
return None
if lon_min >= lon_max:
msg = "Longitude Min must be less than Longitude Max."
module_logger.error(f"Validation Error: {msg}")
self.show_error_message("Input Error", msg)
return None
valid_bbox = {"lat_min": lat_min, "lon_min": lon_min, "lat_max": lat_max, "lon_max": lon_max}
module_logger.info(f"Valid bounding box obtained: {valid_bbox}")
return valid_bbox
def display_flights_on_canvas(self,
flight_states: List[CanonicalFlightState],
bounding_box: Dict[str, float]):
"""Displays flight data from a list of CanonicalFlightState objects on the canvas."""
if not (hasattr(self, 'flight_canvas') and self.flight_canvas.winfo_exists()):
module_logger.warning("Flight canvas widget does not exist, cannot display flights.")
return
self.clear_canvas_data() # Clear previous flight dots and labels
if not flight_states:
self._update_map_placeholder("No flights currently in the selected area.")
module_logger.info("display_flights_on_canvas: No flight states to display.")
return
else: # Flights are present, ensure placeholder is removed
if hasattr(self, 'flight_canvas') and self.flight_canvas.winfo_exists():
self.flight_canvas.delete("placeholder_text")
canvas_width = self.flight_canvas.winfo_width()
canvas_height = self.flight_canvas.winfo_height()
if canvas_width <= 1 or canvas_height <= 1:
canvas_width = config.DEFAULT_CANVAS_WIDTH
canvas_height = config.DEFAULT_CANVAS_HEIGHT
lat_min_bbox, lon_min_bbox = bounding_box["lat_min"], bounding_box["lon_min"]
lat_max_bbox, lon_max_bbox = bounding_box["lat_max"], bounding_box["lon_max"]
delta_lat_bbox = (lat_max_bbox - lat_min_bbox) if (lat_max_bbox - lat_min_bbox) != 0 else 1.0
delta_lon_bbox = (lon_max_bbox - lon_min_bbox) if (lon_max_bbox - lon_min_bbox) != 0 else 1.0
displayed_count = 0
for state in flight_states:
if not isinstance(state, CanonicalFlightState) or state.latitude is None or state.longitude is None:
continue
if not (lon_min_bbox <= state.longitude <= lon_max_bbox and lat_min_bbox <= state.latitude <= lat_max_bbox):
continue
x_pixel = ((state.longitude - lon_min_bbox) / delta_lon_bbox) * canvas_width
y_pixel = canvas_height - (((state.latitude - lat_min_bbox) / delta_lat_bbox) * canvas_height)
radius = 3
display_label = state.callsign if state.callsign else state.icao24
try:
self.flight_canvas.create_oval(
x_pixel - radius, y_pixel - radius, x_pixel + radius, y_pixel + radius,
fill="red", outline="black", tags="flight_dot"
)
self.flight_canvas.create_text(
x_pixel, y_pixel - (radius + 7), text=display_label, # Increased y-offset for label
font=("Arial", 7), tags="flight_label", anchor=tk.S
)
displayed_count += 1
except tk.TclError:
module_logger.warning("TclError drawing flight on canvas, widget might be gone.")
break
if displayed_count > 0:
module_logger.info(f"Displayed {displayed_count} flight states on map.")
# Status message like "X flights displayed" will be set by AppController via update_semaphore_and_status
def clear_canvas_data(self):
"""Clears only flight-specific items (dots and labels) from the canvas."""
if hasattr(self, 'flight_canvas') and self.flight_canvas.winfo_exists():
try:
self.flight_canvas.delete("flight_dot")
self.flight_canvas.delete("flight_label")
module_logger.debug("Canvas flight items (dots and labels) cleared.")
except tk.TclError:
module_logger.warning("TclError clearing canvas flight items, widget might be gone.")
# This method becomes the primary way to update status text from within MainWindow if needed,
# but AppController should be the main driver for status updates via update_semaphore_and_status.
def _set_status_text(self, message: str):
"""Updates only the text part of the status bar. Internal use or simple messages."""
if hasattr(self, 'status_label') and self.status_label.winfo_exists():
try:
self.status_label.config(text=f"Status: {message}")
except tk.TclError:
module_logger.warning("TclError updating status label text directly, widget might be gone.")
def show_error_message(self, title: str, message: str):
"""Displays an error message box and updates the status bar to reflect the error."""
module_logger.error(f"Displaying error message to user: Title='{title}', Message='{message}'")
status_bar_msg = f"Error: {message[:70]}"
if len(message) > 70: status_bar_msg += "..."
self.update_semaphore_and_status("ERROR", status_bar_msg) # Set semaphore and status
if hasattr(self, 'root') and self.root.winfo_exists():
messagebox.showerror(title, message, parent=self.root)