1717from tkinter import messagebox
1818from tkinter import ttk
1919import tkinter .font
20- from typing import Callable , Dict , List , Union
20+ from typing import Callable
2121import webbrowser
22+ from concurrent .futures import Future
2223
2324from filelock import FileLock
2425import httpx
2526import pystray
2627import multiprocess
2728import multiprocess .spawn
29+ from openpectus .engine .engine import Engine
30+ from openpectus .engine .engine_runner import EngineRunner
2831
2932__version__ = "0.1.0"
3033# This application is written for Windows
@@ -75,7 +78,7 @@ def read(self):
7578 except (json .JSONDecodeError , IOError ):
7679 pass
7780
78- def write (self , payload : Dict [str , str | int | float | bool ]):
81+ def write (self , payload : dict [str , str | int | float | bool ]):
7982 with self ._lock :
8083 self .read ()
8184 for k , v in payload .items ():
@@ -119,9 +122,9 @@ class LogRecorder(logging.Handler):
119122 """
120123 def __init__ (self , * args , ** kwargs ):
121124 super ().__init__ (* args , ** kwargs )
122- self .logs : Dict [str , List [logging .LogRecord ]] = defaultdict (list )
123- self .emit_callbacks : List [Callable ] = []
124- self .engine_names : List [str ] = []
125+ self .logs : dict [str , list [logging .LogRecord ]] = defaultdict (list )
126+ self .emit_callbacks : list [Callable ] = []
127+ self .engine_names : list [str ] = []
125128
126129 def emit (self , record : logging .LogRecord ):
127130 assert record .threadName is not None
@@ -151,15 +154,16 @@ def __init__(self, log_handler: logging.Handler, persistent_data):
151154 self .log_handler = log_handler
152155 self .persistent_data = persistent_data
153156 # Internal state
154- self .engines = dict ()
155- self .threads = dict ()
156- self .loops = dict ()
157- self ._tasks = set ()
157+ self .engines : dict [str , tuple [Engine , EngineRunner ]] = dict ()
158+ self .threads : dict [str , threading .Thread ] = dict ()
159+ self .loops : dict [str , asyncio .AbstractEventLoop ] = dict ()
160+ self ._tasks : set [Future ] = set ()
161+ self .running_engines_names : set [str ] = set ()
158162
159163 def set_status_for_item (self , status , item ):
160164 raise NotImplementedError
161165
162- def start_engine (self , engine_item : Dict [str , str ]):
166+ def start_engine (self , engine_item : dict [str , str ]):
163167 log .info (f"Starting engine in engine manager { engine_item } " )
164168 engine_name = engine_item ["engine_name" ]
165169 uod_filename = engine_item ["filename" ]
@@ -286,8 +290,9 @@ async def async_task(loop):
286290 daemon = True ,
287291 )
288292 self .threads [engine_name ].start ()
293+ self .running_engines_names .add (engine_name )
289294
290- def stop_engine (self , engine_item : Dict [str , str ]):
295+ def stop_engine (self , engine_item : dict [str , str ]):
291296 assert engine_item ["engine_name" ] in self .loops
292297 engine_name = engine_item ["engine_name" ]
293298
@@ -307,8 +312,9 @@ async def cancel():
307312 )
308313 self ._tasks .add (task )
309314 task .add_done_callback (self ._tasks .discard )
315+ task .add_done_callback (lambda _ : self .running_engines_names .discard (engine_name ))
310316
311- def validate_engine (self , engine_item : Dict [str , str ]):
317+ def validate_engine (self , engine_item : dict [str , str ]):
312318 engine_name = engine_item ["engine_name" ]
313319 uod_filename = engine_item ["filename" ]
314320
@@ -357,15 +363,20 @@ def validate():
357363 )
358364 self .threads [engine_name ].start ()
359365
360- def get_running_engines (self ) -> List [str ]:
366+ def get_running_engines (self ) -> list [str ]:
361367 running_engines = []
362368 for engine_name , loop in self .loops .items ():
363369 if loop .is_running ():
364370 running_engines .append (engine_name )
365371 return running_engines
366372
373+ def stop_all_running_engines (self ):
374+ for engine_name in self .running_engines_names :
375+ engine_item = dict (engine_name = engine_name )
376+ self .stop_engine (engine_item )
367377
368- class VerticalScrolledText (ttk .Frame ):
378+
379+ class VerticalScrolledZoomableLockedText (ttk .Frame ):
369380 """
370381 Scrollable, zoomable frame for text widgets.
371382
@@ -404,9 +415,9 @@ def __init__(self, parent, Text, *args, **kwargs):
404415 def _proxy (self , * args ):
405416 """Step in between modifications to the contained text to
406417 enforce no-edit."""
407- if args [0 ] == "delete" and args [1 :] != ("1.0" , "end" ,):
418+ if args [0 ] == "delete" and args [1 :] != ("1.0" , tk . END ,):
408419 return
409- elif args [0 ] == "insert" and args [1 ] != "end" :
420+ elif args [0 ] == "insert" and args [1 ] != tk . END :
410421 return
411422 cmd = (self ._orig ,) + args
412423 result = self .tk .call (cmd )
@@ -460,7 +471,7 @@ def start(self) -> bool:
460471 if self .exists :
461472 self .window .deiconify () # Bring the window into view.
462473 return False
463- self .window = tk .Toplevel (self .parent )
474+ self .window : tk . Toplevel = tk .Toplevel (self .parent )
464475 self .window .protocol ("WM_DELETE_WINDOW" , self .exit )
465476 self .exists = True
466477 return True
@@ -593,10 +604,11 @@ def __init__(self, master, *args, **kwargs):
593604 self .treeview = treeview
594605
595606 # Callback endpoints
596- self .select_item_callback : List [Callable ] = []
597- self .on_start_callback : List [Callable ] = []
598- self .on_stop_callback : List [Callable ] = []
599- self .on_validate_callback : List [Callable ] = []
607+ self .select_item_callback : list [Callable ] = []
608+ self .on_start_callback : list [Callable ] = []
609+ self .on_stop_callback : list [Callable ] = []
610+ self .on_restart_callback : list [Callable ] = []
611+ self .on_validate_callback : list [Callable ] = []
600612
601613 def remove_uod (self , uod_filename : str ):
602614 raise NotImplementedError
@@ -618,7 +630,7 @@ def insert_item(self, filename: str = "", status: str = "Not running",):
618630 self .treeview .insert ("" , tk .END , text = filename , values = (status ,))
619631 self .rebuild_engine_name_to_row_id ()
620632
621- def set_status_for_item (self , status : str , item : Dict [str , str ]):
633+ def set_status_for_item (self , status : str , item : dict [str , str ]):
622634 self .treeview .item (
623635 self .engine_name_to_row_id [item ["engine_name" ]],
624636 values = (status ,)
@@ -646,7 +658,7 @@ def set_tag_for_engine_name(self,
646658 )
647659 self .engine_name_to_tag [engine_name ] = tag
648660
649- def get_all_items (self ) -> List [ Dict [str , str ]]:
661+ def get_all_items (self ) -> list [ dict [str , str ]]:
650662 items = []
651663 for row_id in self .treeview .get_children ():
652664 items .append (self ._get_item_by_id (row_id ))
@@ -691,7 +703,7 @@ def rebuild_engine_name_to_row_id(self):
691703 def load_engine (self ):
692704 raise NotImplementedError
693705
694- def _get_item_by_id (self , row_id : str ) -> Dict [str , str ]:
706+ def _get_item_by_id (self , row_id : str ) -> dict [str , str ]:
695707 treeview_item = self .treeview .item (row_id )
696708 return dict (
697709 filename = treeview_item ["text" ],
@@ -701,7 +713,7 @@ def _get_item_by_id(self, row_id: str) -> Dict[str, str]:
701713 )[0 ],
702714 )
703715
704- def _on_select_item (self , event : tk .Event ) -> Union [ None , Dict [ str , str ]] :
716+ def _on_select_item (self , event : tk .Event ):
705717 """Callback attached to up/down key and left/right click."""
706718 # Check if source of event is keyboard
707719 if event .keycode == "??" :
@@ -723,7 +735,6 @@ def _on_select_item(self, event: tk.Event) -> Union[None, Dict[str, str]]:
723735 self .engine_name_to_tag [item ["engine_name" ]] = "INFO"
724736 for fn in self .select_item_callback :
725737 fn (item )
726- return item
727738
728739 def _on_delete (self , event : tk .Event ):
729740 for tree_view_item_id in self .treeview .selection ():
@@ -733,11 +744,12 @@ def _on_delete(self, event: tk.Event):
733744 self .remove_uod (item ["filename" ])
734745 self .rebuild_engine_name_to_row_id ()
735746
736- def _populate_right_click_menu (self , items : List [ Dict [str , str ]]) -> Dict [str , Callable ]:
747+ def _populate_right_click_menu (self , items : list [ dict [str , str ]]) -> dict [str , Callable ]:
737748 """Creates menu items in right click menu."""
738749 if len (items ) > 1 :
739750 if all ([item ["status" ] == "Running" for item in items ]):
740751 return {
752+ "Restart engines" : lambda : self ._right_click_menu_restart_engine (items ),
741753 "Stop engines" : lambda : self ._right_click_menu_stop_engine (items ),
742754 }
743755 elif all ([item ["status" ] == "Not running" for item in items ]):
@@ -750,6 +762,7 @@ def _populate_right_click_menu(self, items: List[Dict[str, str]]) -> Dict[str, C
750762 item = items [0 ]
751763 if item ["status" ] == "Running" :
752764 return {
765+ f"Restart { item ['engine_name' ]} " : lambda : self ._right_click_menu_restart_engine (items ),
753766 f"Stop { item ['engine_name' ]} " : lambda : self ._right_click_menu_stop_engine (items ),
754767 }
755768 elif item ["status" ] == "Not running" :
@@ -805,6 +818,19 @@ def _right_click_menu_stop_engine(self, items):
805818 for fn in self .on_stop_callback :
806819 fn (item )
807820
821+ def _right_click_menu_restart_engine (self , items ):
822+ self ._right_click_menu_stop_engine (items )
823+ self .master .after (100 , self ._attempt_restart , items )
824+
825+ def _attempt_restart (self , selected_items ):
826+ selected_filenames = [item ["filename" ] for item in selected_items ]
827+ selected_items_updated = [item for item in self .get_all_items () if item ["filename" ] in selected_filenames ]
828+
829+ if all (item ["status" ] == "Not running" for item in selected_items_updated ):
830+ self ._right_click_menu_start_engine (selected_items_updated )
831+ else :
832+ self .master .after (100 , self ._attempt_restart , selected_items_updated )
833+
808834
809835class EngineOutput (tk .LabelFrame ):
810836 """Text area with coloring of log statements
@@ -823,10 +849,10 @@ def __init__(self, master, *args, **kwargs):
823849 # Internal state
824850 self .engine_name = None
825851 self .text = dict ()
826- self .text_area = dict ()
852+ self .text_areas : dict [ str | None , VerticalScrolledZoomableLockedText ] = dict ()
827853
828854 self .create_text_area (self .engine_name )
829- self .text_area [self .engine_name ].grid (
855+ self .text_areas [self .engine_name ].grid (
830856 row = 0 ,
831857 column = 0 ,
832858 sticky = tk .N + tk .S + tk .E + tk .W
@@ -839,9 +865,9 @@ def __init__(self, master, *args, **kwargs):
839865
840866 def create_text_area (self , engine_name : None | str ):
841867 if engine_name in self .text :
842- assert engine_name in self .text_area
868+ assert engine_name in self .text_areas
843869 return
844- text_area = VerticalScrolledText (
870+ text_area = VerticalScrolledZoomableLockedText (
845871 self ,
846872 tk .Text ,
847873 height = 30 ,
@@ -858,28 +884,28 @@ def create_text_area(self, engine_name: None | str):
858884 text_area .text .tag_config ("CRITICAL" , foreground = "red" , underline = 1 )
859885
860886 self .text [engine_name ] = text_area .text
861- self .text_area [engine_name ] = text_area
887+ self .text_areas [engine_name ] = text_area
862888
863- def clear_text (self , item : Dict [str , str ]):
889+ def clear_text (self , item : dict [str , str ]):
864890 self .text [item ["engine_name" ]].delete ("1.0" , tk .END )
865891
866- def set_engine (self , engine_item : None | Dict [str , str ]):
892+ def set_engine (self , engine_item : None | dict [str , str ]):
867893 if engine_item is None :
868894 self .engine_name = None
869895 self .configure (text = "Engine Output" )
870896 else :
871897 if engine_item ["engine_name" ] == self .engine_name :
872898 return
873899 # Hide current text area
874- for text_area in self .text_area .values ():
900+ for text_area in self .text_areas .values ():
875901 text_area .grid_remove ()
876902 # Set label
877903 self .engine_name = engine_item ["engine_name" ]
878904 self .configure (text = f"Engine Output: { self .engine_name } " )
879905 # Create text area if it doesn't exist
880906 self .create_text_area (self .engine_name )
881907 # Show the "new" text area
882- self .text_area [self .engine_name ].grid (
908+ self .text_areas [self .engine_name ].grid (
883909 row = 0 ,
884910 column = 0 ,
885911 sticky = tk .N + tk .S + tk .E + tk .W
@@ -927,7 +953,7 @@ def __init__(self, persistent_data):
927953
928954 # Create system tray icon
929955 self .protocol ("WM_DELETE_WINDOW" , self ._close_window )
930- menu = (
956+ tray_menu = (
931957 pystray .MenuItem ("Show" , self ._show_from_tray , default = True ),
932958 pystray .MenuItem ("Exit" , self ._exit ),
933959 )
@@ -936,7 +962,7 @@ def __init__(self, persistent_data):
936962 "name" ,
937963 Image .open (icon_path ),
938964 "Open Pectus Engine Manager" ,
939- menu
965+ tray_menu ,
940966 )
941967 self .icon .run_detached ()
942968
@@ -999,8 +1025,8 @@ def __init__(self, persistent_data):
9991025 paned_window_right .add (engine_output , stretch = "always" )
10001026
10011027 # Callback endpoints
1002- self .add_engine_callback : List [Callable ] = []
1003- self .remove_engine_callback : List [Callable ] = []
1028+ self .add_engine_callback : list [Callable ] = []
1029+ self .remove_engine_callback : list [Callable ] = []
10041030
10051031 # Bind callbacks
10061032 engine_list .select_item_callback .append (engine_output .set_engine )
@@ -1016,7 +1042,7 @@ def __init__(self, persistent_data):
10161042 self .deiconify ()
10171043
10181044 # Internal state
1019- self ._notification_message_time = 0
1045+ self ._notification_message_time : float = 0. 0
10201046 self .engine_list = engine_list
10211047 self .engine_output = engine_output
10221048
@@ -1027,6 +1053,9 @@ def ask_before_exit(self) -> bool:
10271053 """Override this method."""
10281054 return False
10291055
1056+ def stop_all_running_engines (self ):
1057+ """Override this method."""
1058+
10301059 def load_uod_file (self , uod_filename : str ) -> bool :
10311060 log .info (f"Loading engine UOD { uod_filename } ." )
10321061 loaded_uods = [item ["filename" ] for item in self .engine_list .get_all_items ()]
@@ -1056,6 +1085,13 @@ def _show_from_tray(self, icon, item):
10561085 self .icon .remove_notification ()
10571086 self .after (0 , self .deiconify )
10581087
1088+ def _exit_when_all_stopped (self ):
1089+ if self .ask_before_exit ():
1090+ self .after (10 , self ._exit_when_all_stopped )
1091+ else :
1092+ self .icon .stop ()
1093+ self .after (0 , self .destroy )
1094+
10591095 def _exit (self , * args ):
10601096 if self .ask_before_exit ():
10611097 answer = messagebox .askquestion (
@@ -1064,6 +1100,10 @@ def _exit(self, *args):
10641100 )
10651101 if answer == "no" :
10661102 return
1103+ # Do this if it's important to stop engines before closing the Python process
1104+ # self.stop_all_running_engines()
1105+ # self._exit_when_all_stopped()
1106+ # Do this if it's OK to just quit
10671107 self .icon .stop ()
10681108 self .after (0 , self .destroy )
10691109
@@ -1082,7 +1122,7 @@ def _open_aggregator(self):
10821122 webbrowser .open (url )
10831123
10841124 def _close_window (self ):
1085- if False and self .ask_before_exit ():
1125+ if self .ask_before_exit ():
10861126 self .withdraw ()
10871127 # Avoid showing the notification many times in a short timespan
10881128 if (time .time ()- self ._notification_message_time ) > 300 :
@@ -1136,6 +1176,7 @@ def assemble_gui() -> OpenPectusEngineManagerGui:
11361176 # Override methods
11371177 engine_manager .set_status_for_item = gui .engine_list .set_status_for_item
11381178 gui .ask_before_exit = lambda : len (engine_manager .get_running_engines ()) > 0
1179+ gui .stop_all_running_engines = engine_manager .stop_all_running_engines
11391180 gui .engine_list .load_engine = gui ._load_engines
11401181 # Populate GUI with persistent data
11411182 for uod_filename in persistent_data ["uods" ]:
0 commit comments