diff --git a/game/sim/gameloop.py b/game/sim/gameloop.py index 483b22c8..0299bf88 100644 --- a/game/sim/gameloop.py +++ b/game/sim/gameloop.py @@ -1,9 +1,10 @@ from __future__ import annotations import logging +from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path -from typing import TYPE_CHECKING +from typing import Iterator, TYPE_CHECKING from .gamelooptimer import GameLoopTimer from .gameupdatecallbacks import GameUpdateCallbacks @@ -52,6 +53,11 @@ class GameLoop: self.start() self.timer.set_speed(simulation_speed) + @contextmanager + def paused_sim(self) -> Iterator[None]: + with self.timer.locked_pause(): + yield + def run_to_first_contact(self) -> None: self.pause() if not self.started: diff --git a/game/sim/gamelooptimer.py b/game/sim/gamelooptimer.py index b089f5e7..f52f0835 100644 --- a/game/sim/gamelooptimer.py +++ b/game/sim/gamelooptimer.py @@ -1,4 +1,6 @@ -from threading import Lock, Timer +from collections.abc import Iterator +from contextlib import contextmanager +from threading import RLock, Timer from typing import Callable, Optional from .simspeedsetting import SimSpeedSetting @@ -9,18 +11,37 @@ class GameLoopTimer: self.callback = callback self.simulation_speed = SimSpeedSetting.PAUSED self._timer: Optional[Timer] = None - self._timer_lock = Lock() + # Reentrant to allow a single thread nested use of `locked_pause`. + self._timer_lock = RLock() def set_speed(self, simulation_speed: SimSpeedSetting) -> None: with self._timer_lock: - self._stop() - self.simulation_speed = simulation_speed - self._recreate_timer() + self._set_speed(simulation_speed) def stop(self) -> None: with self._timer_lock: self._stop() + @contextmanager + def locked_pause(self) -> Iterator[None]: + # NB: This must be a locked _pause_ and not a locked speed, because nested use + # of this method is allowed. That's okay if all nested callers set the same + # speed (paused), but not okay if a parent locks a speed and a child locks + # another speed. That's okay though, because we're unlikely to ever want to lock + # any speed but paused. + with self._timer_lock: + old_speed = self.simulation_speed + self._stop() + try: + yield + finally: + self._set_speed(old_speed) + + def _set_speed(self, simulation_speed: SimSpeedSetting) -> None: + self._stop() + self.simulation_speed = simulation_speed + self._recreate_timer() + def _stop(self) -> None: if self._timer is not None: self._timer.cancel() diff --git a/qt_ui/models.py b/qt_ui/models.py index 9a425a4c..fd5ce200 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -152,52 +152,58 @@ class PackageModel(QAbstractListModel): def add_flight(self, flight: Flight) -> None: """Adds the given flight to the package.""" - self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) - self.package.add_flight(flight) - # update_tot is not called here because the new flight does not have a - # flight plan yet. Will be called manually by the caller. - self.endInsertRows() + with self.game_model.sim_controller.paused_sim(): + self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) + self.package.add_flight(flight) + # update_tot is not called here because the new flight does not have a + # flight plan yet. Will be called manually by the caller. + self.endInsertRows() def cancel_or_abort_flight_at_index(self, index: QModelIndex) -> None: """Removes the flight at the given index from the package.""" self.cancel_or_abort_flight(self.flight_at_index(index)) def cancel_or_abort_flight(self, flight: Flight) -> None: - if flight.state.cancelable: - self.delete_flight(flight) - EventStream.put_nowait(GameUpdateEvents().delete_flight(flight)) - else: - flight.abort() - EventStream.put_nowait(GameUpdateEvents().update_flight(flight)) + with self.game_model.sim_controller.paused_sim(): + if flight.state.cancelable: + self.delete_flight(flight) + EventStream.put_nowait(GameUpdateEvents().delete_flight(flight)) + else: + flight.abort() + EventStream.put_nowait(GameUpdateEvents().update_flight(flight)) def delete_flight(self, flight: Flight) -> None: """Removes the given flight from the package.""" - index = self.package.flights.index(flight) - self.beginRemoveRows(QModelIndex(), index, index) - self.package.remove_flight(flight) - self.endRemoveRows() - self.update_tot() + with self.game_model.sim_controller.paused_sim(): + index = self.package.flights.index(flight) + self.beginRemoveRows(QModelIndex(), index, index) + self.package.remove_flight(flight) + self.endRemoveRows() + self.update_tot() def flight_at_index(self, index: QModelIndex) -> Flight: """Returns the flight located at the given index.""" return self.package.flights[index.row()] def set_tot(self, tot: datetime.datetime) -> None: - self.package.time_over_target = tot - self.update_tot() + with self.game_model.sim_controller.paused_sim(): + self.package.time_over_target = tot + self.update_tot() def set_asap(self, asap: bool) -> None: - self.package.auto_asap = asap - self.update_tot() + with self.game_model.sim_controller.paused_sim(): + self.package.auto_asap = asap + self.update_tot() def update_tot(self) -> None: - if self.package.auto_asap: - self.package.set_tot_asap( - self.game_model.sim_controller.current_time_in_sim - ) - self.tot_changed.emit() - # For some reason this is needed to make the UI update quickly. - self.layoutChanged.emit() + with self.game_model.sim_controller.paused_sim(): + if self.package.auto_asap: + self.package.set_tot_asap( + self.game_model.sim_controller.current_time_in_sim + ) + self.tot_changed.emit() + # For some reason this is needed to make the UI update quickly. + self.layoutChanged.emit() @property def mission_target(self) -> MissionTarget: @@ -255,34 +261,36 @@ class AtoModel(QAbstractListModel): def add_package(self, package: Package) -> None: """Adds a package to the ATO.""" - self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) - self.ato.add_package(package) - # We do not need to send events for new flights in the package here. Events were - # already sent when the flights were added to the in-progress package. - self.endInsertRows() - # noinspection PyUnresolvedReferences - self.client_slots_changed.emit() - self.on_packages_changed() + with self.game_model.sim_controller.paused_sim(): + self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) + self.ato.add_package(package) + # We do not need to send events for new flights in the package here. Events + # were already sent when the flights were added to the in-progress package. + self.endInsertRows() + # noinspection PyUnresolvedReferences + self.client_slots_changed.emit() + self.on_packages_changed() def cancel_or_abort_package_at_index(self, index: QModelIndex) -> None: """Removes the package at the given index from the ATO.""" self.cancel_or_abort_package(self.package_at_index(index)) def cancel_or_abort_package(self, package: Package) -> None: - with EventStream.event_context() as events: - if all(f.state.cancelable for f in package.flights): - events.delete_flights_in_package(package) - self._delete_package(package) - return + with self.game_model.sim_controller.paused_sim(): + with EventStream.event_context() as events: + if all(f.state.cancelable for f in package.flights): + events.delete_flights_in_package(package) + self._delete_package(package) + return - package_model = self.find_matching_package_model(package) - for flight in package.flights: - if flight.state.cancelable: - package_model.delete_flight(flight) - events.delete_flight(flight) - else: - flight.abort() - events.update_flight(flight) + package_model = self.find_matching_package_model(package) + for flight in package.flights: + if flight.state.cancelable: + package_model.delete_flight(flight) + events.delete_flight(flight) + else: + flight.abort() + events.update_flight(flight) def _delete_package(self, package: Package) -> None: """Removes the given package from the ATO.""" diff --git a/qt_ui/simcontroller.py b/qt_ui/simcontroller.py index a7cc163e..4bf9be05 100644 --- a/qt_ui/simcontroller.py +++ b/qt_ui/simcontroller.py @@ -1,6 +1,8 @@ from __future__ import annotations import logging +from collections.abc import Iterator +from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path from typing import Callable, Optional, TYPE_CHECKING @@ -76,6 +78,11 @@ class SimController(QObject): self.started = True self.game_loop.set_simulation_speed(simulation_speed) + @contextmanager + def paused_sim(self) -> Iterator[None]: + with self.game_loop.paused_sim(): + yield + def run_to_first_contact(self) -> None: self.game_loop.run_to_first_contact()