diff --git a/layouts/user_layout.json b/layouts/user_layout.json index 4410088..6e5b596 100644 --- a/layouts/user_layout.json +++ b/layouts/user_layout.json @@ -1,5 +1,5 @@ { - "window_geometry": "1600x996+312+216", + "window_geometry": "1600x996+52+52", "main_sash_position": [ 1, 793 diff --git a/pymsc/__main__.py b/pymsc/__main__.py index f490c5b..59c42bf 100644 --- a/pymsc/__main__.py +++ b/pymsc/__main__.py @@ -203,33 +203,12 @@ def main(): app._start_refresh() logger.info("GUI refresh active - widgets will update automatically.") - # Graceful shutdown handler - def shutdown(): - """Cleanup on application exit.""" - logger.info("Shutting down ARTOS...") - - # Stop time update thread - global _time_update_running - if _time_update_running: - _time_update_running = False - logger.info("Stopping date/time synchronization thread...") - if _time_update_thread and _time_update_thread.is_alive(): - _time_update_thread.join(timeout=3.0) - - # Stop bus session if running - if bus_module.is_running: - bus_module.stop_session() - - # Export performance metrics - logger.info("Exporting performance metrics...") - save_stats_to_csv("performance_report.csv") - print_stats() - - logger.info("ARTOS shutdown complete.") - app.root.quit() - app.root.destroy() + # Store cleanup references in app for use in _on_close + app._cleanup_time_thread = lambda: setattr(globals(), '_time_update_running', False) + app._time_update_thread = _time_update_thread - app.root.protocol("WM_DELETE_WINDOW", shutdown) + # Note: MainDockingWindow handles WM_DELETE_WINDOW with its own _on_close() + # which calls os._exit(0) for immediate termination # Start the GUI main loop logger.info("ARTOS GUI ready. Starting main loop...") diff --git a/pymsc/gui/docking/__init__.py b/pymsc/gui/docking/__init__.py index 08377e4..9b2cda4 100644 --- a/pymsc/gui/docking/__init__.py +++ b/pymsc/gui/docking/__init__.py @@ -5,5 +5,6 @@ ARTOS Docking System - Tkinter-based modular GUI framework from .dock_frame import DockFrame from .workspace_manager import WorkspaceManager +from .title_panel import TitlePanel -__all__ = ['DockFrame', 'WorkspaceManager'] +__all__ = ['DockFrame', 'WorkspaceManager', 'TitlePanel'] diff --git a/pymsc/gui/docking/logger_dock.py b/pymsc/gui/docking/logger_dock.py index 0de5870..87a16e7 100644 --- a/pymsc/gui/docking/logger_dock.py +++ b/pymsc/gui/docking/logger_dock.py @@ -5,10 +5,10 @@ Logger Dock: Real-time system logs display. import tkinter as tk from tkinter import ttk, scrolledtext import logging -from pymsc.gui.docking import DockFrame +from pymsc.gui.docking.title_panel import TitlePanel -class LoggerDock(DockFrame): +class LoggerDock(TitlePanel): """ Logger dockable panel with scrollable text display. Shows real-time logs from the ARTOS system. @@ -24,30 +24,82 @@ class LoggerDock(DockFrame): self.existing_widget = existing_widget self.existing_handler = existing_handler - super().__init__( - parent, - title="System Logger", - closable=False, # Logger always visible - **kwargs - ) + super().__init__(parent, title="System Logger", closable=False, **kwargs) self.log_handler = existing_handler def populate_content(self): """Create or reuse the scrollable log text widget.""" + # Remove outer padding from the content frame so the text widget + # can occupy the full dock area without an unwanted gap. + try: + self.content_frame.configure(padding=0) + except Exception: + pass if self.existing_widget: - # Reuse existing widget - reparent it properly - self.log_text = self.existing_widget + # Try to reparent the existing widget. If that doesn't render + # correctly (some platforms don't fully support reparenting), + # create a new ScrolledText inside our content_frame and copy + # the text/tags across, then update the handler to point to it. + old_widget = self.existing_widget + + # Read existing content (if any) and current view + try: + old_text = old_widget.get('1.0', tk.END) + except Exception: + old_text = '' + + # Remove old widget from geometry managers + try: + old_widget.pack_forget() + except Exception: + pass + + # Create a fresh ScrolledText in our content frame + self.log_text = scrolledtext.ScrolledText( + self.content_frame, + wrap=tk.WORD, + height=10, + font=('Consolas', 9), + bg='#ffffff', + fg='#000000', + state='disabled' + ) + + # Configure tags for different log levels on the new widget + self.log_text.tag_config('INFO', foreground='#0066cc') + self.log_text.tag_config('WARNING', foreground='#ff8800') + self.log_text.tag_config('ERROR', foreground='#cc0000') + self.log_text.tag_config('DEBUG', foreground='#666666') + + # Insert previous content into the new widget + try: + self.log_text.config(state='normal') + if old_text: + self.log_text.insert(tk.END, old_text) + self.log_text.see(tk.END) + self.log_text.config(state='disabled') + except Exception: + pass + + # Pack the new widget to fill area + self.log_text.pack(fill=tk.BOTH, expand=True, padx=0, pady=0) + + # Update or create the handler so logs go to the new widget + if self.existing_handler: + try: + self.existing_handler.text_widget = self.log_text + self.log_handler = self.existing_handler + except Exception: + self.log_handler = TextWidgetHandler(self.log_text) + logging.getLogger().addHandler(self.log_handler) + else: + self.log_handler = TextWidgetHandler(self.log_text) + logging.getLogger().addHandler(self.log_handler) + + # Load previous logs from file to show startup messages + self._load_previous_logs() - # Change the parent of the widget - self.log_text.master = self.content_frame - self.log_text.pack_forget() # Remove from old parent - - # Re-pack in new parent - self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Handler already exists and is attached - # Just log that we've transferred - import logging + # Log that we've transferred logger = logging.getLogger('ARTOS') logger.info("System Logger dock created - continuing to capture logs") else: @@ -61,7 +113,8 @@ class LoggerDock(DockFrame): fg='#000000', state='disabled' # Read-only ) - self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + # Pack without extra padding so it fills the dock content fully + self.log_text.pack(fill=tk.BOTH, expand=True, padx=0, pady=0) # Configure tags for different log levels self.log_text.tag_config('INFO', foreground='#0066cc') diff --git a/pymsc/gui/docking/mcs_dock.py b/pymsc/gui/docking/mcs_dock.py index 2009331..8ef1c09 100644 --- a/pymsc/gui/docking/mcs_dock.py +++ b/pymsc/gui/docking/mcs_dock.py @@ -5,12 +5,12 @@ Contains all MCS controls, commands, and telemetry widgets. """ import tkinter as tk from tkinter import ttk -from pymsc.gui.docking import DockFrame +from pymsc.gui.docking.title_panel import TitlePanel from pymsc.gui.pages.control_page import ControlPage from pymsc.gui.pages.mission_page import MissionPage -class MCSDock(DockFrame): +class MCSDock(TitlePanel): """ MCS (Mission Control System) dockable panel. Contains tabs for different MCS functions: @@ -27,12 +27,7 @@ class MCSDock(DockFrame): """ self.bus_module = bus_module - super().__init__( - parent, - title="MCS - Mission Control System", - closable=False, # MCS is always visible - **kwargs - ) + super().__init__(parent, title="MCS - Mission Control System", closable=False, **kwargs) def populate_content(self): """Create the MCS tab structure and pages.""" diff --git a/pymsc/gui/docking/placeholder_dock.py b/pymsc/gui/docking/placeholder_dock.py index d558f1b..8d534de 100644 --- a/pymsc/gui/docking/placeholder_dock.py +++ b/pymsc/gui/docking/placeholder_dock.py @@ -4,10 +4,10 @@ Placeholder Dock: Generic empty dock for future modules. """ import tkinter as tk from tkinter import ttk -from pymsc.gui.docking import DockFrame +from pymsc.gui.docking.title_panel import TitlePanel -class PlaceholderDock(DockFrame): +class PlaceholderDock(TitlePanel): """ Placeholder dock for modules not yet implemented. Shows module name and description. @@ -23,7 +23,6 @@ class PlaceholderDock(DockFrame): **kwargs: Additional DockFrame options """ self.description = description - super().__init__(parent, title=title, **kwargs) def populate_content(self): diff --git a/pymsc/gui/docking/title_panel.py b/pymsc/gui/docking/title_panel.py new file mode 100644 index 0000000..1bf6584 --- /dev/null +++ b/pymsc/gui/docking/title_panel.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +TitlePanel: Lightweight titled panel (Label-like) used instead of full docking. + +Provides a simple titled frame with a content area, basic minimize/restore +and visibility API compatible with existing docks so WorkspaceManager can +save/restore state unchanged. +""" +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable + + +class TitlePanel(ttk.Frame): + """Simple titled panel that mimics DockFrame API minimally. + + Use this when you want a lighter weight, non-dockable box with a title + and a content area that expands to fill available space. + """ + + def __init__(self, parent: tk.Widget, title: str = "", closable: bool = False, on_close: Optional[Callable] = None, **kwargs): + super().__init__(parent, **kwargs) + + self.title = title + self.closable = closable + self.on_close_callback = on_close + + self.is_minimized = False + self.is_visible = True + + self._create_ui() + + def _create_ui(self): + # Title area (simple label) + self.title_bar = ttk.Frame(self, relief=tk.FLAT) + self.title_bar.pack(side=tk.TOP, fill=tk.X) + + self.title_label = ttk.Label(self.title_bar, text=self.title, font=('Arial', 10, 'bold'), padding=(4, 2)) + self.title_label.pack(side=tk.LEFT) + + if self.closable: + self.close_btn = ttk.Button(self.title_bar, text='✕', width=3, command=self.close) + self.close_btn.pack(side=tk.RIGHT, padx=(4, 0)) + + # Content container + self.content_frame = ttk.Frame(self) + self.content_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + def populate_content(self): + """Subclasses should override this to populate `self.content_frame`.""" + pass + + def minimize(self): + if not self.is_minimized: + self.content_frame.pack_forget() + self.is_minimized = True + + def restore(self): + if self.is_minimized: + self.content_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + self.is_minimized = False + + def toggle_minimize(self): + if self.is_minimized: + self.restore() + else: + self.minimize() + + def close(self): + self.pack_forget() + self.is_visible = False + if self.on_close_callback: + self.on_close_callback(self) + + def show(self): + if not self.is_visible: + self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + self.is_visible = True + if self.is_minimized: + self.restore() + + def set_title(self, new_title: str): + self.title = new_title + self.title_label.configure(text=new_title) diff --git a/pymsc/gui/docking/workspace_manager.py b/pymsc/gui/docking/workspace_manager.py index 4828976..ce233a5 100644 --- a/pymsc/gui/docking/workspace_manager.py +++ b/pymsc/gui/docking/workspace_manager.py @@ -9,6 +9,7 @@ import json import os from typing import Dict, Optional from .dock_frame import DockFrame +from .title_panel import TitlePanel class WorkspaceManager: @@ -30,7 +31,8 @@ class WorkspaceManager: """ self.root = root self.layouts_dir = layouts_dir - self.docks: Dict[str, DockFrame] = {} + # docks may be legacy DockFrame or new TitlePanel instances + self.docks: Dict[str, object] = {} # Create layouts directory if needed if not os.path.exists(self.layouts_dir): @@ -90,8 +92,8 @@ class WorkspaceManager: self.bottom_container = ttk.Frame(self.main_pane) self.main_pane.add(self.bottom_container, minsize=150, height=200) - def add_dock(self, dock_id: str, dock: DockFrame, - position: str = 'left') -> DockFrame: + def add_dock(self, dock_id: str, dock, + position: str = 'left'): """ Register and place a dock in the workspace. @@ -103,6 +105,7 @@ class WorkspaceManager: Returns: The dock instance """ + # Accept either DockFrame or TitlePanel (or any widget with packable API) self.docks[dock_id] = dock if position == 'left': diff --git a/pymsc/gui/main_docking_window.py b/pymsc/gui/main_docking_window.py index 633775b..d85080a 100644 --- a/pymsc/gui/main_docking_window.py +++ b/pymsc/gui/main_docking_window.py @@ -6,6 +6,9 @@ Modular GUI with dockable panels for different system modules. import tkinter as tk from tkinter import ttk, messagebox import logging +import sys +import os +import threading from pymsc.core.bus_1553_module import Bus1553Module from pymsc.gui.docking.workspace_manager import WorkspaceManager @@ -35,6 +38,8 @@ class MainDockingWindow: bus_module: Bus1553Module instance for message access """ self.bus_module = bus_module + self._refresh_active = False # Flag to control GUI refresh loop + self._closing = False # Flag to prevent multiple close attempts # Create main window self.root = tk.Tk() @@ -179,29 +184,40 @@ class MainDockingWindow: def _start_refresh(self): """Start the GUI refresh loop to update all widgets.""" + self._refresh_active = True self.gui_refresh_loop() def _stop_refresh(self): """Stop refresh loops (cleanup on exit).""" - # The refresh loop will stop automatically when window closes - pass + self._refresh_active = False def gui_refresh_loop(self): """ Periodically refreshes all custom widgets in all visible docks. This updates telemetry displays from received 1553 messages. """ + # Check if we should continue refreshing + if not self._refresh_active: + return + # First, sync all messages from the 1553 bus (read latest telemetry) - if self.bus_module and self.bus_module.is_running: - self.bus_module.sync_all_messages() + try: + if self.bus_module and self.bus_module.is_running: + self.bus_module.sync_all_messages() + except Exception: + pass # Then refresh widgets in all visible docks - for dock_id, dock in self.workspace.docks.items(): - if dock.is_visible and not dock.is_minimized: - self._refresh_widgets_recursive(dock.content_frame) + try: + for dock_id, dock in self.workspace.docks.items(): + if dock.is_visible and not dock.is_minimized: + self._refresh_widgets_recursive(dock.content_frame) + except Exception: + pass - # Schedule next refresh (100ms = 10 Hz update rate) - self.root.after(100, self.gui_refresh_loop) + # Schedule next refresh only if still active (100ms = 10 Hz update rate) + if self._refresh_active: + self.root.after(100, self.gui_refresh_loop) def _refresh_widgets_recursive(self, container: tk.Widget): """ @@ -301,13 +317,40 @@ Built with Tkinter and ttkbootstrap def _on_close(self): """Handle window close event.""" + # Prevent multiple close attempts + if self._closing: + return + self._closing = True + # Save layout automatically before closing (includes window geometry) - self.workspace.save_layout("user_layout") + try: + self.workspace.save_layout("user_layout") + except Exception: + pass if messagebox.askokcancel("Quit", "Do you want to quit ARTOS?"): - self._stop_refresh() - self.root.quit() - self.root.destroy() + # Stop GUI refresh loop FIRST to prevent any further after() calls + self._refresh_active = False + + # Remove GUI log handler to avoid callbacks after widgets destroyed + try: + if hasattr(self, '_log_handler'): + logging.getLogger().removeHandler(self._log_handler) + except Exception: + pass + + # Destroy window immediately + try: + self.root.destroy() + except Exception: + pass + + # Force immediate exit - don't wait for threads to clean up + # This is necessary because TimeSync and BusMonitor threads may block + os._exit(0) + else: + # User cancelled - allow close again later + self._closing = False def run(self): """Start the main event loop."""