Skip to content

Commit 2879167

Browse files
committed
Add restart button. Improve typing.
1 parent e5fdf86 commit 2879167

1 file changed

Lines changed: 83 additions & 42 deletions

File tree

openpectus_engine_manager_gui/__init__.py

Lines changed: 83 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@
1717
from tkinter import messagebox
1818
from tkinter import ttk
1919
import tkinter.font
20-
from typing import Callable, Dict, List, Union
20+
from typing import Callable
2121
import webbrowser
22+
from concurrent.futures import Future
2223

2324
from filelock import FileLock
2425
import httpx
2526
import pystray
2627
import multiprocess
2728
import 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

809835
class 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

Comments
 (0)