diff --git a/docs/source/guide/examples/steps_dialog.py b/docs/source/guide/examples/steps_dialog.py new file mode 100644 index 00000000..42f3de8e --- /dev/null +++ b/docs/source/guide/examples/steps_dialog.py @@ -0,0 +1,181 @@ +# (C) Copyright 2018-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Qt implementation of a Pyface Dialog that listens to a StepsFuture. +""" + +from pyface.api import Dialog +from pyface.qt import QtCore, QtGui +from traits.api import Any, Bool, Instance, Int, observe, Property, Str +from traits_futures.api import EXECUTING, StepsFuture + +# XXX Fix behaviour on dialog close button. Should match pressing the +# "cancelling" button, and also pressing ESC. (What do users want?) +# XXX What should behaviour be on a Ctrl-C? +# XXX Two possible desirable behaviours: (1) cancel then close immediately +# (2) prevent closing, close when cancel completes +# (1) is more likely to be important with a modal dialog +# All three of (a) pressing the cancel button, (b) using the dialog close +# button, (c) hitting ESC should have the same behaviour. +# For the preventing closing behaviour, the dialog itself needs some state. + + +# XXX Diagnose and fix errors seen when launching a non-modal dialog and then +# clicking its close button: +# Exception occurred in traits notification handler for event object: +# TraitChangeEvent(object=, name='message', old=, new='executing: +# processing item 10 of 10') +# Traceback (most recent call last): +# File "/Users/mdickinson/.venvs/traits-futures/lib/python3.9/site-packages/ +# traits/observation/_trait_event_notifier.py", line 122, in __call__ +# self.dispatcher(handler, event) +# File "/Users/mdickinson/.venvs/traits-futures/lib/python3.9/site-packages/ +# traits/observation/observe.py", line 26, in dispatch_same +# handler(event) +# File "/Users/mdickinson/Enthought/Projects/traits-futures/docs/source/ +# guide/examples/background_progress_dialog.py", line 137, in +# _update_message_in_message_control +# self._message_control.setText(event.new) +# AttributeError: 'NoneType' object has no attribute 'setText' + + +class StepsDialog(Dialog): + """Show a cancellable progress dialog listening to a progress manager.""" + + #: Text to show for cancellation label. + cancel_label = "Cancel" + + #: Text to show for the ok label. (Not used here.) + ok_label = "" + + #: Whether to show a 'Cancel' button or not. + cancellable = Bool(True) + + #: Whether to show the percentage complete or not. + show_percent = Bool(True) + + #: The traited ``Future`` representing the state of the background call. + future = Instance(StepsFuture) + + #: The message to display. + message = Property(Str(), observe="future:[state,message]") + + #: The maximum for the dialog. + maximum = Property(Int(), observe="future:total") + + def cancel(self): + """Cancel the job. + + Users of the dialog should call this instead of reaching + down to the progress manager since this method will prevent + the job from starting if it has not already. + """ + self.future.cancel() + self._cancel_button_control.setEnabled(False) + + # Private implementation ################################################## + + _progress_bar = Any() + _message_control = Any() + _cancel_button_control = Any() + + def _create_contents(self, parent): + layout = QtGui.QVBoxLayout() + + if not self.resizeable: + layout.setSizeConstraint(QtGui.QLayout.SetFixedSize) + + layout.addWidget(self._create_message(parent, layout)) + layout.addWidget(self._create_gauge(parent, layout)) + if self.cancellable: + layout.addWidget(self._create_buttons(parent)) + + parent.setLayout(layout) + + def _create_buttons(self, parent): + buttons = QtGui.QDialogButtonBox() + + # 'Cancel' button. + btn = buttons.addButton( + self.cancel_label, QtGui.QDialogButtonBox.RejectRole + ) + btn.setDefault(True) + # The QueuedConnection here appears to be necessary to avoid a + # Qt/macOS bug where the dialog widgets are only partially updated + # after a button click. + # xref: https://github.com/enthought/traitsui/issues/1308 + # xref: https://bugreports.qt.io/browse/QTBUG-68067 + buttons.rejected.connect(self.cancel, type=QtCore.Qt.QueuedConnection) + self._cancel_button_control = btn + return buttons + + def _create_message(self, dialog, layout): + self._message_control = QtGui.QLabel(self.message, dialog) + self._message_control.setAlignment( + QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft + ) + return self._message_control + + def _create_gauge(self, dialog, layout): + self._progress_bar = QtGui.QProgressBar(dialog) + self._progress_bar.setRange(0, self.maximum) + self._progress_bar.setValue(self.future.complete) + if self.show_percent: + self._progress_bar.setFormat("%p%") + else: + self._progress_bar.setFormat("%v") + return self._progress_bar + + @observe("closing") + def _destroy_traits_on_dialog_closing(self, event): + self._message_control = None + self._progress_bar = None + self._cancel_button_control = None + + @observe("maximum") + def _update_max_on_progress_bar(self, event): + maximum = event.new + if self._progress_bar is not None: + self._progress_bar.setRange(0, maximum) + + @observe("message") + def _update_message_in_message_control(self, event): + self._message_control.setText(event.new) + + def _get_message(self): + """ + Property getter for the 'message' trait. + """ + future = self.future + if future.state == EXECUTING and future.message is not None: + return f"{future.state}: {future.message}" + else: + return f"{future.state}" + + def _get_maximum(self): + """ + Property getter for the 'maximum' trait. + """ + future = self.future + if future.total is None: + return 0 + else: + return max(future.total, 0) + + @observe("future:complete") + def _update_value(self, event): + complete = event.new + if self._progress_bar is not None: + self._progress_bar.setValue(complete) + + @observe("future:done") + def _on_end(self, event): + self.close() diff --git a/docs/source/guide/examples/steps_dialogs.py b/docs/source/guide/examples/steps_dialogs.py new file mode 100644 index 00000000..8910c6d5 --- /dev/null +++ b/docs/source/guide/examples/steps_dialogs.py @@ -0,0 +1,119 @@ +# (C) Copyright 2018-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" +Demo script for modal progress dialog. +""" + +# XXX To do list (including for StepsFutures) +# * Add NullReporter implementation of IStepsReporter, for convenience in +# testing progress-reporting tasks. +# * Add IStepsReporter "check" method that simply checks for cancellation. + +import concurrent.futures +import time + +from traits.api import ( + Any, + Button, + HasStrictTraits, + Instance, + List, + observe, + Range, +) +from traits_futures.api import submit_steps, TraitsExecutor +from traitsui.api import Item, View + +from steps_dialog import StepsDialog + + +def count(target, *, sleep=1.0, reporter): + """ + Parameters + ---------- + target : int + Value to count to. + sleep : float + Time to sleep between each count. + reporter : IStepsReporter + Object used to report progress. + """ + for i in range(target): + reporter.step(f"processing item {i+1} of {target}") + time.sleep(sleep) + + +class MyView(HasStrictTraits): + + calculate = Button() + + nonmodal = Button() + + target = Range(0, 100, 10) + + executor = Instance(TraitsExecutor) + + def _executor_default(self): + return TraitsExecutor() # worker_pool=self.cf_executor) + + dialogs = List(StepsDialog) + + cf_executor = Any() + + @observe("calculate") + def _open_modal_dialog(self, event): + target = self.target + future = submit_steps( + self.executor, + target, + "Starting processing...", + count, + target=target, + ) + dialog = StepsDialog( + future=future, + style="modal", # this is the default + title=f"Counting to {target}", + ) + dialog.open() + + @observe("nonmodal") + def _open_nonmodal_dialog(self, event): + target = self.target + future = submit_steps( + self.executor, target, "Starting processing...", count, target + ) + dialog = StepsDialog( + future=future, + style="nonmodal", # this is the default + title=f"Counting to {target}", + ) + dialog.open() + # Keep a reference to open dialogs, else they'll + # mysteriously disappear. A better solution might be to + # parent them appropriately. + self.dialogs.append(dialog) + + view = View( + Item("calculate", label="Count modal"), + Item("nonmodal", label="Count nonmodal"), + Item("target"), + ) + + +def main(): + with concurrent.futures.ThreadPoolExecutor() as executor: + view = MyView(cf_executor=executor) + view.configure_traits() + + +if __name__ == "__main__": + main()