Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
-->

# Version History
- 0.2.207 - Close splash screen from the Tk main thread to avoid
`Tcl_AsyncDelete` errors from cross-thread callbacks.
- 0.2.206 - Cancel splash screen callbacks before destroying the Tk root to
prevent `Tcl_AsyncDelete` errors on shutdown.
- 0.2.205 - Launch AutoML core through service manager and allow non-daemon
service threads with join support.
- 0.2.204 - Introduce threaded service manager to lazily load services,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 0.2.205
version: 0.2.207
Author: Miguel Marina <karel.capek.robotics@gmail.com> - [LinkedIn](https://www.linkedin.com/in/progman32/)
# AutoML

Expand Down
18 changes: 18 additions & 0 deletions gui/windows/splash_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ def __init__(
font=("Helvetica", 9),
fill="white",
)
# Track scheduled callbacks so they can be cancelled on close
self._anim_after = None
self._fade_in_after = None
self._fade_out_after = None
self._close_after = None
# Start animation and fade-in effect
self._anim_after = self.after(10, self._animate)
self._fade_in_after = self.after(10, self._fade_in)
Expand Down Expand Up @@ -489,6 +494,19 @@ def _animate(self):

def _close(self):
"""Destroy splash screen and accompanying shadow window."""
for handle_name in (
"_anim_after",
"_fade_in_after",
"_fade_out_after",
"_close_after",
):
handle = getattr(self, handle_name, None)
if handle:
try:
self.after_cancel(handle)
except Exception:
pass
setattr(self, handle_name, None)
try:
self.shadow.destroy()
except Exception:
Expand Down
2 changes: 1 addition & 1 deletion mainappsrc/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@

"""Project version information."""

VERSION = "0.2.205"
VERSION = "0.2.207"

__all__ = ["VERSION"]
34 changes: 34 additions & 0 deletions tests/test_splash_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
import importlib
import builtins
import sys
import threading
import tkinter as tk
import types

import pytest
import tools.splash_launcher as splash_module


Expand Down Expand Up @@ -51,3 +55,33 @@ def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
importlib.reload(splash_module)

assert splash_module.VERSION == "0.0.0"

def _make_loader(self, calls):
def loader() -> types.SimpleNamespace:
calls.append(("loader", threading.current_thread().name))
return types.SimpleNamespace(
main=lambda: calls.append(("main", threading.current_thread().name))
)

return loader

def test_loader_runs_in_background_thread(self):
calls: list[tuple[str, str]] = []
loader = self._make_loader(calls)
try:
launcher = splash_module.SplashLauncher(loader=loader, post_delay=0)
except tk.TclError:
pytest.skip("Tk not available")
launcher.launch()
assert calls[0][1] != threading.current_thread().name
assert calls[1][0] == "main"

def test_main_called_after_launch(self):
calls: list[tuple[str, str]] = []
loader = self._make_loader(calls)
try:
launcher = splash_module.SplashLauncher(loader=loader, post_delay=0)
except tk.TclError:
pytest.skip("Tk not available")
launcher.launch()
assert ("main", threading.current_thread().name) in calls
8 changes: 8 additions & 0 deletions tests/test_splash_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ def test_close_fades_to_invisible(self):
self.assertAlmostEqual(float(self.splash.attributes("-alpha")), 0.0)
self.assertTrue(self._closed)

def test_close_cancels_after_events(self):
scheduled = set(self.root.tk.call("after", "info").split())
self.assertIn(self.splash._anim_after, scheduled)
self.splash._close()
scheduled_after = set(self.root.tk.call("after", "info").split())
self.assertNotIn(self.splash._anim_after, scheduled_after)
self.assertIsNone(self.splash._anim_after)


if __name__ == "__main__":
unittest.main()
12 changes: 10 additions & 2 deletions tools/splash_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,22 @@ def __init__(
self.module_name = module_name
self.post_delay = post_delay
self._module: Optional[ModuleType] = None
self._loaded = threading.Event()

def _load_module(self) -> None:
"""Initialise the application in a background thread."""
if self.loader:
self._module = self.loader()
else:
self._module = importlib.import_module(self.module_name)
# Once loading is complete, close the splash screen on the main thread
self._root.after(self.post_delay, self._splash.close)
self._loaded.set()

def _poll(self) -> None:
"""Check whether the background load finished and close the splash."""
if self._loaded.is_set():
self._root.after(self.post_delay, self._splash.close)
else:
self._root.after(50, self._poll)

def launch(self) -> None:
"""Display the splash screen and run the application's main function."""
Expand All @@ -109,6 +116,7 @@ def launch(self) -> None:
on_close=self._root.destroy,
)
threading.Thread(target=self._load_module, daemon=True).start()
self._poll()
self._root.mainloop()
if self._module and hasattr(self._module, "main"):
self._module.main()