diff --git a/.travis.yml b/.travis.yml index e2cd0cad1..89f102b4b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -39,6 +39,7 @@ cache: before_install: - mkdir -p "${HOME}/.cache/download" - if [[ ${TRAVIS_OS_NAME} == 'linux' ]]; then ./install-edm-linux.sh; export PATH="${HOME}/edm/bin:${PATH}"; fi + - if [[ ${TRAVIS_OS_NAME} == 'linux' ]]; then sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0; fi - if [[ ${TRAVIS_OS_NAME} == 'osx' ]]; then ./install-edm-osx.sh; export PATH="${PATH}:/usr/local/bin"; fi - edm install -y wheel click coverage install: diff --git a/etstool.py b/etstool.py index 43ba9a3db..744276416 100644 --- a/etstool.py +++ b/etstool.py @@ -180,7 +180,7 @@ def install(runtime, toolkit, environment): commands.append("edm run -e {environment} -- pip install pyqt5==5.9.2") elif toolkit == 'pyside2': commands.append( - "edm run -e {environment} -- pip install pyside2==5.11.1" + "edm run -e {environment} -- pip install pyside2 shiboken2" ) click.echo("Creating environment '{environment}'".format(**parameters)) diff --git a/pyface/__init__.py b/pyface/__init__.py index 6081a4817..ede457603 100644 --- a/pyface/__init__.py +++ b/pyface/__init__.py @@ -23,8 +23,9 @@ __requires__ = ['traits'] __extras_require__ = { - 'wx': ['wxpython>=2.8.10', 'numpy'], - 'pyqt': ['pyqt>=4.10', 'pygments'], - 'pyqt5': ['pyqt>=5', 'pygments'], - 'pyside': ['pyside>=1.2', 'pygments'], + 'wx': ['wxpython>=2.8.10,<4.0.0', 'numpy'], + 'pyqt': ['pyqt4>=4.10', 'pygments'], + 'pyqt5': ['pyqt5', 'pygments'], + 'pyside': ['pyside>=1.2', 'pygments', 'shiboken'], + 'pyside2': ['pyside2', 'pygments', 'shiboken2'], } diff --git a/pyface/i_gui.py b/pyface/i_gui.py index aa6eca281..953a2d650 100644 --- a/pyface/i_gui.py +++ b/pyface/i_gui.py @@ -1,5 +1,5 @@ #------------------------------------------------------------------------------ -# Copyright (c) 2005, Enthought, Inc. +# Copyright (c) 2005-19, Enthought, Inc. # All rights reserved. # # This software is provided without warranty under the terms of the BSD @@ -20,7 +20,7 @@ # Enthought library imports. from traits.etsconfig.api import ETSConfig -from traits.api import Bool, Interface, Unicode +from traits.api import Any, Bool, Interface, Property, Unicode # Logging. @@ -32,6 +32,9 @@ class IGUI(Interface): #### 'GUI' interface ###################################################### + #: A reference to the toolkit application singleton. + app = Property + #: Is the GUI busy (i.e. should the busy cursor, often an hourglass, be #: displayed)? busy = Bool(False) @@ -39,6 +42,9 @@ class IGUI(Interface): #: Has the GUI's event loop been started? started = Bool(False) + #: Whether the GUI quits on last window close. + quit_on_last_window_close = Property(Bool) + #: A directory on the local file system that we can read and write to at #: will. This is used to persist layout information etc. Note that #: individual toolkits will have their own directory. @@ -162,6 +168,19 @@ def start_event_loop(self): def stop_event_loop(self): """ Stop the GUI event loop. """ + def top_level_windows(self): + """ Return all top-level windows. + + This does not include windows which are children of other + windows. + """ + + def close_all(self): + """ Close all top-level windows. + + This may or may not exit the application, depending on other settings. + """ + class MGUI(object): """ The mixin class that contains common code for toolkit specific diff --git a/pyface/i_window.py b/pyface/i_window.py index 4c9d1c6db..4a723fd45 100644 --- a/pyface/i_window.py +++ b/pyface/i_window.py @@ -103,6 +103,16 @@ def close(self, force=False): Whether or not the window is closed. """ + def activate(self, should_raise=True): + """ Activate the Window + + Parameters + ---------- + should_raise : bool + Whether or not the window should be raised to the front + of the z-order as well as being given user focus. + """ + def confirm(self, message, title=None, cancel=False, default=NO): """ Convenience method to show a confirmation dialog. diff --git a/pyface/qt/__init__.py b/pyface/qt/__init__.py index 6aa63f8d5..c906a1a11 100644 --- a/pyface/qt/__init__.py +++ b/pyface/qt/__init__.py @@ -94,3 +94,5 @@ def prepare_pyqt4(): # useful constants is_qt4 = (qt_api in {'pyqt', 'pyside'}) is_qt5 = (qt_api in {'pyqt5', 'pyside2'}) +is_pyqt = (qt_api in {'pyqt', 'pyqt5'}) +is_pyside = (qt_api in {'pyside', 'pyside2'}) diff --git a/pyface/testing/__init__.py b/pyface/testing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyface/testing/event_loop_helper.py b/pyface/testing/event_loop_helper.py new file mode 100644 index 000000000..a3a8b4892 --- /dev/null +++ b/pyface/testing/event_loop_helper.py @@ -0,0 +1,127 @@ +# (C) Copyright 2019 Enthought, Inc., Austin, TX +# All right reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! + + +import contextlib + +from traits.api import HasStrictTraits, Instance + +from pyface.gui import GUI +from pyface.i_gui import IGUI +from pyface.timer.api import CallbackTimer, EventTimer + + +class ConditionTimeoutError(RuntimeError): + pass + + +class EventLoopHelper(HasStrictTraits): + """ Toolkit-independent methods for running event loops in tests. + """ + + #: A reference to the GUI object + gui = Instance(IGUI, factory=GUI) + + @contextlib.contextmanager + def dont_quit_when_last_window_closed(self): + """ Suppress exit of the application when the last window is closed. + """ + flag = self.gui.quit_on_last_window_close + self.gui.quit_on_last_window_close = False + try: + yield + finally: + self.gui.quit_on_last_window_close = flag + + def event_loop(self, repeat=1, allow_user_events=True): + """ Emulate an event loop running ``repeat`` times. + + Parameters + ---------- + repeat : positive int + The number of times to call process events. Default is 1. + allow_user_events : bool + Whether to process user-generated events. + """ + for i in range(repeat): + self.gui.process_events(allow_user_events) + + def event_loop_until_condition(self, condition, timeout=10.0): + """ Run the event loop until condition returns true, or timeout. + + This runs the real event loop, rather than emulating it with + :meth:`GUI.process_events`. Conditions and timeouts are tracked + using timers. + + Parameters + ---------- + condition : callable + A callable to determine if the stop criteria have been met. This + should accept no arguments. + + timeout : float + Number of seconds to run the event loop in the case that the trait + change does not occur. + + Raises + ------ + ConditionTimeoutError + If the timeout occurs before the condition is True. + """ + + with self.dont_quit_when_last_window_closed(): + condition_timer = CallbackTimer.timer( + stop_condition=condition, + interval=0.05, + expire=timeout, + ) + condition_timer.on_trait_change(self._on_stop, 'active') + + try: + self.gui.start_event_loop() + if not condition(): + raise ConditionTimeoutError( + 'Timed out waiting for condition') + finally: + condition_timer.on_trait_change( + self._on_stop, 'active', remove=True) + condition_timer.stop() + + def event_loop_with_timeout(self, repeat=2, timeout=10): + """ Run the event loop for timeout seconds. + + Parameters + ---------- + timeout: float, optional, keyword only + Number of seconds to run the event loop. Default value is 10.0. + """ + with self.dont_quit_when_last_window_closed(): + repeat_timer = EventTimer.timer( + repeat=repeat, + interval=0.05, + expire=timeout, + ) + repeat_timer.on_trait_change(self._on_stop, 'active') + + try: + self.gui.start_event_loop() + if repeat_timer.repeat > 0: + msg = 'Timed out waiting for repetition, {} remaining' + raise ConditionTimeoutError( + msg.format(repeat_timer.repeat) + ) + finally: + repeat_timer.on_trait_change( + self._on_stop, 'active', remove=True) + repeat_timer.stop() + + def _on_stop(self, active): + """ Trait handler that stops event loop. """ + if not active: + self.gui.stop_event_loop() diff --git a/pyface/testing/gui_test_case.py b/pyface/testing/gui_test_case.py new file mode 100644 index 000000000..f7d99d7a9 --- /dev/null +++ b/pyface/testing/gui_test_case.py @@ -0,0 +1,443 @@ +# (C) Copyright 2014-2019 Enthought, Inc., Austin, TX +# All right reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! + +import gc +import os +import threading +from unittest import TestCase + +import six + +from traits.testing.unittest_tools import UnittestTools + +from pyface.gui import GUI +from pyface.timer.timer import CallbackTimer +from pyface.window import Window +from pyface.toolkit_utils import destroy_later, is_destroyed +from .event_loop_helper import ConditionTimeoutError, EventLoopHelper + +if six.PY2: + import mock +else: + import unittest.mock as mock + + +class GuiTestCase(UnittestTools, TestCase): + """ Base TestCase class for GUI test cases. """ + + # ------------------------------------------------------------------------ + # 'GuiTestTools' protocol + # ------------------------------------------------------------------------ + + # -- New Test "assert" methods ------------------------------------------ + + def assertEventuallyTrueInGui(self, condition, timeout=10.0): + """ + Assert that the given condition becomes true if we run the GUI + event loop for long enough. + + This assertion runs the real GUI event loop, polling the condition + and returning as soon as the condition becomes true. If the condition + does not become true within the given timeout, the assertion fails. + + Parameters + ---------- + condition : callable() -> bool + Callable accepting no arguments and returning a bool. + timeout : float + Maximum length of time to wait for the condition to become + true, in seconds. + + Raises + ------ + self.failureException + If the condition does not become true within the given timeout. + """ + try: + self.event_loop_until_condition(condition, timeout) + except ConditionTimeoutError: + self.fail("Timed out waiting for condition to become true.") + + def assertTraitsChangeInGui(self, object, *traits, **kw): + """Run the real application event loop until a change notification for + all of the specified traits is received. + + Paramaters + ---------- + object : HasTraits instance + The object on which to listen for a trait events + traits : one or more str + The names of the traits to listen to for events + timeout : float, optional, keyword only + Number of seconds to run the event loop in the case that the trait + change does not occur. Default value is 10.0. + """ + try: + self.event_loop_until_traits_change(object, *traits, **kw) + except ConditionTimeoutError: + self.fail("Timed out waiting for traits to change.") + + def assertTraitValueInGui(self, object, trait, value, timeout=10.0): + """ + Assert that the given trait assumes the specified value if we run the + GUI event loop for long enough. + + Parameters + ---------- + object : HasTraits instance + The Traits object that holds the trait. + trait : str + The name of the trait being tested. + value : any + The value that the trait holds. + timeout : float + Maximum length of time to wait for the condition to become + true, in seconds. + + Raises + ------ + self.failureException + If the condition does not become true within the given timeout. + """ + def condition(): + return getattr(object, trait) == value + + try: + self.event_loop_until_trait_value(object, trait, value, timeout) + except ConditionTimeoutError: + self.fail("Timed out waiting for trait to assume value.") + + def assertToolkitControlDestroyedInGui(self, control, timeout=1.0): + """ + Assert that the given toolkit control is destroyed if we run the GUI + event loop for long enough. + + Parameters + ---------- + control : toolkit control + The toolkit control being watched. + timeout : float + Maximum length of time to wait for the control to be destroyed, + in seconds. + + Raises + ------ + self.failureException + If the control is not destroyed within the given timeout. + """ + if control is None: + return + + try: + self.event_loop_until_control_destroyed(control, timeout) + except ConditionTimeoutError: + self.fail("Timed out waiting for control to be destroyed.") + + # -- Event loop methods ------------------------------------------------- + + def set_trait_in_event_loop(self, object, trait, value, condition=None, + timeout=10): + """ Start an event loop and set a trait to a value. + + By default this will stop the event loop when the trait is set to + the value, but an optional condition can be used as a test + instead. A timeout is also used if the condition does not become + True. + + This is a blocking function. + + Parameters + ---------- + object : HasTraits instance + The object holding the trait value. + trait : str + The name of the trait. + value : any + The value being set on the trait. + condition : callable or None + A function that returns True when the event loop should stop. + If None, then the event loop will stop when the value of the + trait equals the supplied value. + timeout : float + The time in seconds before timing out. + + Raises + ------ + ConditionTimeoutError + If the timeout occurs before the condition is True. + """ + if condition is None: + def condition(): + return getattr(object, trait) == value + + self.gui.set_trait_later(object, trait, value) + self.event_loop_until_condition(condition, timeout) + + def invoke_in_event_loop(self, callable, condition, timeout=10): + """ Start an event loop and call a function, stopping on condition. + + A timeout is used if the condition does not become True. + + Parameters + ---------- + callable : callable + The function to call. It must expect no arguments, and any + return value is ignored. + condition : callable + A function that returns True when the event loop should stop. + timeout : float + The time in seconds before timing out. + + Raises + ------ + ConditionTimeoutError + If the timeout occurs before the condition is True. + """ + self.gui.invoke_later(callable) + self.event_loop_until_condition(condition, timeout) + + def event_loop_until_trait_value(self, object, trait, value, timeout=10.0): + """Run the real application event loop until a change notification for + all of the specified traits is received. + + Paramaters + ---------- + traits_object : HasTraits instance + The object on which to listen for a trait events + traits : one or more str + The names of the traits to listen to for events + timeout : float, optional, keyword only + Number of seconds to run the event loop in the case that the trait + change does not occur. Default value is 10.0. + """ + def condition(): + return getattr(object, trait) == value + + self.event_loop_until_condition(condition, timeout) + + def event_loop_until_traits_change(self, object, *traits, **kw): + """Run the real application event loop until a change notification for + all of the specified traits is received. + + Paramaters + ---------- + traits_object : HasTraits instance + The object on which to listen for a trait events + traits : one or more str + The names of the traits to listen to for events + timeout : float, optional, keyword only + Number of seconds to run the event loop in the case that the trait + change does not occur. Default value is 10.0. + """ + timeout = kw.pop('timeout', 10.0) + condition = threading.Event() + + traits = set(traits) + recorded_changes = set() + + # Correctly handle the corner case where there are no traits. + if not traits: + condition.set() + + def set_event(trait): + recorded_changes.add(trait) + if recorded_changes == traits: + condition.set() + + def make_handler(trait): + def handler(): + set_event(trait) + return handler + + handlers = {trait: make_handler(trait) for trait in traits} + + for trait, handler in handlers.items(): + object.on_trait_change(handler, trait) + try: + self.event_loop_until_condition(condition.is_set, timeout) + finally: + for trait, handler in handlers.items(): + object.on_trait_change(handler, trait, remove=True) + + def event_loop_until_control_destroyed(self, control, timeout=10.0): + """ Run the event loop until a control is destroyed. + + This doesn't actually delete the underlying control, just tests + whether the widget still holds a reference to it. + + Parameters + ---------- + control : toolkit control + The widget to ensure is destroyed. + timeout : float + The number of seconds to run the event loop in the event that the + toolkit control is not deleted. + + Raises + ------ + ConditionTimeoutError + If the timeout occurs before the control is deleted. + """ + def condition(): + return is_destroyed(control) + + self.event_loop_until_condition(condition, timeout) + + def event_loop_until_condition(self, condition, timeout=10.0): + """ Run the event loop until condition returns true, or timeout. + + This runs the real event loop, rather than emulating it with + :meth:`GUI.process_events`. Conditions and timeouts are tracked + using timers. + + Parameters + ---------- + condition : callable + A callable to determine if the stop criteria have been met. This + should accept no arguments. + + timeout : float + Number of seconds to run the event loop in the case that the + condition does not occur. + + Raises + ------ + ConditionTimeoutError + If the timeout occurs before the condition is True. + """ + self.event_loop_helper.event_loop_until_condition(condition, timeout) + + def event_loop_with_timeout(self, repeat=1, timeout=10): + """ Run the event loop for timeout seconds. + """ + self.event_loop_helper.event_loop_with_timeout(repeat, timeout) + + def destroy_control(self, control, timeout=1.0): + """ Schedule a toolkit control for destruction and run the event loop. + + Parameters + ---------- + control : toolkit control + The control to destroy. + timeout : float + The number of seconds to run the event loop in the event that the + control is not destroyed. + + Raises + ------ + ConditionTimeoutError + If the timeout occurs before the widget is destroyed. + """ + destroy_later(control) + self.event_loop_until_control_destroyed(control, timeout) + + def destroy_widget(self, widget, timeout=1.0): + """ Schedule a Widget for destruction and run the event loop. + + Parameters + ---------- + control : IWidget + The widget to destroy. + timeout : float + The number of seconds to run the event loop in the event that the + widget is not destroyed. + + Raises + ------ + ConditionTimeoutError + If the timeout occurs before the widget is destroyed. + """ + if widget.control is not None: + control = widget.control + widget.destroy() + self.event_loop_until_control_destroyed(control, timeout) + + # ------------------------------------------------------------------------ + # 'TestCase' protocol + # ------------------------------------------------------------------------ + + def setUp(self): + """ Setup the test case for GUI interactions. + """ + self.gui = GUI() + self.app = self.gui.app + self.event_loop_helper = EventLoopHelper(gui=self.gui) + + if not os.environ.get('PYFACE_PATCH_ACTIVATE', False): + original_activated = Window.activate + + def activate_patch(self, should_raise=True): + return original_activated(False) + + self._raise_patch = mock.patch.object( + Window, 'activate', activate_patch) + self._raise_patch.start() + else: + self._raise_patch = None + + self.gui.quit_on_last_window_close = False + + # clean-up actions (LIFO) + self.addCleanup(self._delete_attrs, "gui", "app", "event_loop_helper") + self.addCleanup(self._restore_window_activate) + self.addCleanup(self._restore_quit_on_last_window_close) + self.addCleanup(self.gui.clear_event_queue) + self.addCleanup(self.gui.process_events) + self.addCleanup(self._close_top_level_windows) + self.addCleanup(self._gc_collect) + self.addCleanup(self.event_loop_helper.event_loop, 5, False) + + super(GuiTestCase, self).setUp() + + def tearDown(self): + """ Tear down the test case. + + This method attempts to ensure that there are no windows that + remain open after the test case has run, and that there are no + events in the event queue to minimize the likelihood of one test + interfering with another. + """ + + super(GuiTestCase, self).tearDown() + + def _gc_collect(self): + # Some top-level widgets may only be present due to cyclic garbage not + # having been collected; force a garbage collection before we decide to + # close windows. This may need several rounds. + for _ in range(10): + if not gc.collect(): + break + + def _close_top_level_windows(self): + # clean + if self.gui.top_level_windows(): + def on_stop(active): + if not active: + self.gui.stop_event_loop() + + repeat_timer = CallbackTimer( + repeat=5, + callback=self.gui.close_all, + kwargs={'force': True} + ) + repeat_timer.start() + self.event_loop_helper.event_loop_with_timeout(timeout=1) + del repeat_timer + + def _restore_quit_on_last_window_close(self): + self.gui.quit_on_last_window_close = True + + def _restore_window_activate(self): + if self._raise_patch is not None: + self._raise_patch.stop() + + def _delete_attrs(self, *attrs): + # clean up objects to GC any remaining state + for attr in attrs: + delattr(self, attr) diff --git a/pyface/testing/util.py b/pyface/testing/util.py new file mode 100644 index 000000000..a3609cbaf --- /dev/null +++ b/pyface/testing/util.py @@ -0,0 +1,49 @@ +# Copyright (c) 2013-19 by Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! + +from __future__ import absolute_import, print_function + +from contextlib import contextmanager +import os +import sys + + +@contextmanager +def silence_output(out=None, err=None): + """ Re-direct the stderr and stdout streams while in the block. """ + + with _convert_none_to_null_handle(out) as out: + with _convert_none_to_null_handle(err) as err: + _old_stderr = sys.stderr + _old_stderr.flush() + + _old_stdout = sys.stdout + _old_stdout.flush() + + try: + sys.stdout = out + sys.stderr = err + yield + finally: + sys.stdout = _old_stdout + sys.stderr = _old_stderr + + +@contextmanager +def _convert_none_to_null_handle(stream): + """ If 'stream' is None, provide a temporary handle to /dev/null. """ + + if stream is None: + out = open(os.devnull, 'w') + try: + yield out + finally: + out.close() + else: + yield stream diff --git a/pyface/tests/test_window.py b/pyface/tests/test_window.py index 152307fb5..d44e4ae88 100644 --- a/pyface/tests/test_window.py +++ b/pyface/tests/test_window.py @@ -1,8 +1,10 @@ from __future__ import absolute_import +import os import platform import unittest +from pyface.testing.gui_test_case import GuiTestCase from ..constant import CANCEL, NO, OK, YES from ..toolkit import toolkit_object from ..window import Window @@ -11,139 +13,186 @@ if is_qt: from pyface.qt import qt_api -GuiTestAssistant = toolkit_object('util.gui_test_assistant:GuiTestAssistant') -no_gui_test_assistant = (GuiTestAssistant.__name__ == 'Unimplemented') - ModalDialogTester = toolkit_object( 'util.modal_dialog_tester:ModalDialogTester' ) no_modal_dialog_tester = (ModalDialogTester.__name__ == 'Unimplemented') +# XXX Since this is experimenting with new GuiTester API, +# turn off modal dialog testing +no_modal_dialog_tester = True + is_pyqt5 = (is_qt and qt_api == 'pyqt5') is_pyqt4_linux = (is_qt and qt_api == 'pyqt' and platform.system() == 'Linux') +is_qt_windows = (is_qt and platform.system() == 'Windows') -@unittest.skipIf(no_gui_test_assistant, 'No GuiTestAssistant') -class TestWindow(unittest.TestCase, GuiTestAssistant): +class TestWindow(GuiTestCase): def setUp(self): - GuiTestAssistant.setUp(self) + super(TestWindow, self).setUp() self.window = Window() def tearDown(self): - if self.window.control is not None: - with self.delete_widget(self.window.control): - self.window.destroy() - self.window = None - GuiTestAssistant.tearDown(self) + self.destroy_widget(self.window) + del self.window + + super(TestWindow, self).tearDown() def test_destroy(self): + # test that destroy works + self.window.open() + control = self.window.control + self.event_loop_until_condition(lambda: self.window.visible) + + self.window.destroy() + self.assertIsNone(self.window.control) + self.assertToolkitControlDestroyedInGui(control) + + def test_destroy_no_control(self): # test that destroy works even when no control - with self.event_loop(): - self.window.destroy() + self.window.destroy() + self.assertIsNone(self.window.control) def test_open_close(self): # test that opening and closing works as expected with self.assertTraitChanges(self.window, 'opening', count=1): with self.assertTraitChanges(self.window, 'opened', count=1): - with self.event_loop(): - self.window.open() + result = self.window.open() + + self.assertTrue(result) + + control = self.window.control + self.assertIsNotNone(control) + self.assertTraitValueInGui(self.window, 'visible', True) with self.assertTraitChanges(self.window, 'closing', count=1): with self.assertTraitChanges(self.window, 'closed', count=1): - with self.event_loop(): - self.window.close() + result = self.window.close() + + self.assertTrue(result) + self.assertToolkitControlDestroyedInGui(control) def test_show(self): # test that showing works as expected - with self.event_loop(): - self.window._create() - with self.event_loop(): - self.window.show(True) - with self.event_loop(): - self.window.show(False) - with self.event_loop(): - self.window.destroy() + self.window.open() + self.event_loop_until_trait_value(self.window, 'visible', True) + self.gui.invoke_later(self.window.show, False) + self.assertTraitsChangeInGui(self.window, 'visible') + self.assertFalse(self.window.visible) + + self.gui.invoke_later(self.window.show, True) + self.assertTraitsChangeInGui(self.window, 'visible') + self.assertTrue(self.window.visible) + + @unittest.skipIf(not os.environ.get('PYFACE_PATCH_ACTIVATE', False), "Activate is patched.") def test_activate(self): # test that activation works as expected - with self.event_loop(): - self.window.open() - with self.event_loop(): - self.window.activate() - with self.event_loop(): - self.window.close() + self.window.open() + self.event_loop_until_trait_value(self.window, 'visible', True) + + other_window = Window() + other_window.open() + try: + self.event_loop_until_trait_value(other_window, 'visible', True) + + self.gui.invoke_later(self.window.activate) + self.assertTraitsChangeInGui(self.window, 'activated') + + self.gui.invoke_later(self.window.activate, False) + self.assertTraitsChangeInGui(self.window, 'deactivated') + finally: + self.destroy_widget(other_window) + + @unittest.skipIf(not os.environ.get('PYFACE_PATCH_ACTIVATE', False), "Activate is patched.") + def test_activate_no_raise(self): + # test that activation works as expected + self.window.open() + self.event_loop_until_trait_value(self.window, 'visible', True) + + other_window = Window() + other_window.open() + try: + self.event_loop_until_trait_value(other_window, 'visible', True) + + self.gui.invoke_later(self.window.activate, False) + self.assertTraitsChangeInGui(self.window, 'activated') + finally: + self.destroy_widget(other_window) def test_position(self): # test that default position works as expected self.window.position = (100, 100) - with self.event_loop(): - self.window.open() - with self.event_loop(): - self.window.close() + + self.window.open() + self.event_loop_until_trait_value(self.window, 'visible', True) + + self.assertEqual(self.window.position, (100, 100)) def test_reposition(self): # test that changing position works as expected - with self.event_loop(): - self.window.open() - with self.event_loop(): - self.window.position = (100, 100) - with self.event_loop(): - self.window.close() + self.window.open() + self.event_loop_until_trait_value(self.window, 'visible', True) + + self.window.position = (100, 100) + + self.assertTraitValueInGui(self.window, "position", (100, 100)) + @unittest.skipIf(is_qt_windows, "Sizing problematic on qt and windows") def test_size(self): # test that default size works as expected self.window.size = (100, 100) - with self.event_loop(): - self.window.open() - with self.event_loop(): - self.window.close() + + self.window.open() + self.event_loop_until_trait_value(self.window, 'visible', True) + + self.assertEqual(self.window.size, (100, 100)) def test_resize(self): # test that changing size works as expected - with self.event_loop(): - self.window.open() - with self.event_loop(): - self.window.size = (100, 100) - with self.event_loop(): - self.window.close() + self.window.open() + self.event_loop_until_trait_value(self.window, 'visible', True) + + self.set_trait_in_event_loop(self.window, 'size', (100, 100)) + + self.assertEqual(self.window.size, (100, 100)) def test_title(self): # test that default title works as expected self.window.title = "Test Title" - with self.event_loop(): - self.window.open() - with self.event_loop(): - self.window.close() + + self.window.open() + self.event_loop_until_trait_value(self.window, 'visible', True) + + self.assertEqual(self.window.title, "Test Title") def test_retitle(self): # test that changing title works as expected - with self.event_loop(): - self.window.open() - with self.event_loop(): - self.window.title = "Test Title" - with self.event_loop(): - self.window.close() + self.window.open() + self.event_loop_until_trait_value(self.window, 'visible', True) + + self.set_trait_in_event_loop(self.window, 'title', "Test Title") + + self.assertEqual(self.window.title, "Test Title") def test_show_event(self): - with self.event_loop(): - self.window.open() - with self.event_loop(): - self.window.visible = False + self.window.open() + self.event_loop_until_trait_value(self.window, 'visible', True) + self.window.show(False) + self.event_loop_until_trait_value(self.window, 'visible', False) - with self.assertTraitChanges(self.window, 'visible', count=1): - with self.event_loop(): - self.window.control.show() + self.gui.invoke_later(self.window.show, True) + self.assertTraitsChangeInGui(self.window, 'visible') self.assertTrue(self.window.visible) def test_hide_event(self): - with self.event_loop(): - self.window.open() + self.window.open() + self.event_loop_until_trait_value(self.window, 'visible', True) - with self.assertTraitChanges(self.window, 'visible', count=1): - with self.event_loop(): - self.window.control.hide() + self.gui.invoke_later(self.window.show, False) + self.assertTraitsChangeInGui(self.window, 'visible') self.assertFalse(self.window.visible) @unittest.skipIf(no_modal_dialog_tester, 'ModalDialogTester unavailable') diff --git a/pyface/timer/i_timer.py b/pyface/timer/i_timer.py index b7eaf2e5f..74ce520cf 100644 --- a/pyface/timer/i_timer.py +++ b/pyface/timer/i_timer.py @@ -50,6 +50,9 @@ class ITimer(Interface): #: The maximum length of time to run in seconds, or None if no limit. expire = Either(None, Float) + #: A callable that returns True if the timer should stop. + stop_condition = Callable + #: Whether or not the timer is currently running. active = Bool @@ -131,6 +134,9 @@ class BaseTimer(ABCHasTraits): #: The maximum length of time to run in seconds, or None if no limit. expire = Either(None, Float) + #: A callable that returns True if the timer should stop. + stop_condition = Callable + #: Property that controls the state of the timer. active = Property(Bool, depends_on='_active') @@ -183,6 +189,10 @@ def perform(self): The timer will stop if repeats is not None and less than 1, or if the `_perform` method raises StopIteration. """ + if self.stop_condition is not None and self.stop_condition(): + self.stop() + return + if self.expire is not None: if perf_counter() - self._start_time > self.expire: self.stop() @@ -195,13 +205,14 @@ def perform(self): self._perform() except StopIteration: self.stop() - except: + return + except BaseException: self.stop() raise - else: - if self.repeat is not None and self.repeat <= 0: - self.stop() - self.repeat = 0 + + if self.repeat is not None and self.repeat <= 0: + self.stop() + self.repeat = 0 # BaseTimer Protected methods diff --git a/pyface/toolkit_utils.py b/pyface/toolkit_utils.py new file mode 100644 index 000000000..f2d37ef63 --- /dev/null +++ b/pyface/toolkit_utils.py @@ -0,0 +1,26 @@ +# Copyright (c) 2019, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! + +""" Toolkit-specific utilities. """ + +# Import the toolkit specific version. +from __future__ import absolute_import + +from .toolkit import toolkit_object + + +# ---------------------------------------------------------------------------- +# Toolkit utility functions +# ---------------------------------------------------------------------------- + +#: Schedule destruction of a toolkit control at a future point. +destroy_later = toolkit_object('toolkit_utils:destroy_later') + +#: Checks if a toolkit control has had its underlying object deleted. +is_destroyed = toolkit_object('toolkit_utils:is_destroyed') diff --git a/pyface/ui/qt4/confirmation_dialog.py b/pyface/ui/qt4/confirmation_dialog.py index 113ea86bd..996ab9d9b 100644 --- a/pyface/ui/qt4/confirmation_dialog.py +++ b/pyface/ui/qt4/confirmation_dialog.py @@ -18,10 +18,13 @@ from traits.api import Bool, Dict, Enum, Instance, provides, Unicode # Local imports. -from pyface.i_confirmation_dialog import IConfirmationDialog, MConfirmationDialog +from pyface.i_confirmation_dialog import ( + IConfirmationDialog, MConfirmationDialog +) from pyface.constant import CANCEL, YES, NO from pyface.image_resource import ImageResource from .dialog import Dialog, _RESULT_MAP +from .toolkit_utils import is_destroyed @provides(IConfirmationDialog) @@ -124,7 +127,7 @@ def _create_control(self, parent): def _show_modal(self): self.control.setWindowModality(QtCore.Qt.ApplicationModal) retval = self.control.exec_() - if self.control is None: + if self.control is None or is_destroyed(self.control): # dialog window closed if self.cancel: # if cancel is available, close is Cancel diff --git a/pyface/ui/qt4/gui.py b/pyface/ui/qt4/gui.py index ee87a4656..95ca9edc5 100644 --- a/pyface/ui/qt4/gui.py +++ b/pyface/ui/qt4/gui.py @@ -18,7 +18,7 @@ from pyface.qt import QtCore, QtGui # Enthought library imports. -from traits.api import Bool, HasTraits, provides, Unicode +from traits.api import Bool, HasTraits, Property, Unicode, provides from pyface.util.guisupport import start_event_loop_qt4 # Local imports. @@ -38,10 +38,22 @@ class GUI(MGUI, HasTraits): #### 'GUI' interface ###################################################### + #: A reference to the toolkit application singleton. + app = Property + + #: Is the GUI busy (i.e. should the busy cursor, often an hourglass, be + #: displayed)? busy = Bool(False) + #: Has the GUI's event loop been started? started = Bool(False) + #: Whether the GUI quits on last window close. + quit_on_last_window_close = Property(Bool) + + #: A directory on the local file system that we can read and write to at + #: will. This is used to persist layout information etc. Note that + #: individual toolkits will have their own directory. state_location = Unicode ########################################################################### @@ -77,11 +89,15 @@ def set_trait_later(cls, obj, trait_name, new): @staticmethod def process_events(allow_user_events=True): + # process events posted via postEvent() + QtCore.QCoreApplication.sendPostedEvents() + if allow_user_events: events = QtCore.QEventLoop.AllEvents else: events = QtCore.QEventLoop.ExcludeUserInputEvents + # process events from the window system/OS QtCore.QCoreApplication.processEvents(events) @staticmethod @@ -112,6 +128,19 @@ def stop_event_loop(self): logger.debug("---------- stopping GUI event loop ----------") QtGui.QApplication.quit() + def clear_event_queue(self): + self.process_events() + + def top_level_windows(self): + return self.app.topLevelWidgets() + + def close_all(self, force=False): + if force: + for window in self.top_level_windows(): + window.deleteLater() + else: + self.app.closeAllWindows() + ########################################################################### # Trait handlers. ########################################################################### @@ -129,6 +158,20 @@ def _busy_changed(self, new): else: QtGui.QApplication.restoreOverrideCursor() + # Property handlers ----------------------------------------------------- + + def _get_app(self): + app = QtCore.QCoreApplication.instance() + if app is None: + app = QtGui.QApplication() + return app + + def _get_quit_on_last_window_close(self): + return self.app.quitOnLastWindowClosed() + + def _set_quit_on_last_window_close(self, value): + return self.app.setQuitOnLastWindowClosed(value) + class _FutureCall(QtCore.QObject): """ This is a helper class that is similar to the wx FutureCall class. """ diff --git a/pyface/ui/qt4/toolkit_utils.py b/pyface/ui/qt4/toolkit_utils.py new file mode 100644 index 000000000..7ab2aa89f --- /dev/null +++ b/pyface/ui/qt4/toolkit_utils.py @@ -0,0 +1,49 @@ +# Copyright (c) 2019, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! + +""" Toolkit-specific utilities. """ + +# Import the toolkit specific version. +from __future__ import absolute_import + +from pyface.qt import is_pyqt, qt_api + + +# ---------------------------------------------------------------------------- +# Toolkit utility functions +# ---------------------------------------------------------------------------- + +def destroy_later(control): + """ Schedule a toolkit control for later destruction. + + Parameters + ---------- + control : QObject subclass + The object that is to be destroyed. + """ + control.deleteLater() + + +def is_destroyed(control): + """ Checks if a control has had the underlying C++ object destroyed. + + Parameters + ---------- + control : QObject subclass + The control that is being tested. + """ + if is_pyqt: + import sip + return sip.isdeleted(control) + elif qt_api == 'pyside2': + import shiboken2 + return not shiboken2.isValid(control) + else: + import shiboken + return not shiboken.isValid(control) diff --git a/pyface/ui/qt4/widget.py b/pyface/ui/qt4/widget.py index 82c4dda3a..d86feb9ea 100644 --- a/pyface/ui/qt4/widget.py +++ b/pyface/ui/qt4/widget.py @@ -11,13 +11,14 @@ # Major package imports. -from pyface.qt import QtCore, QtGui +from pyface.qt import QtCore # Enthought library imports. from traits.api import Any, Bool, HasTraits, Instance, provides # Local imports. from pyface.i_widget import IWidget, MWidget +from .toolkit_utils import is_destroyed @provides(IWidget) @@ -76,8 +77,9 @@ def enable(self, enabled): def destroy(self): self._remove_event_listeners() if self.control is not None: - self.control.hide() - self.control.deleteLater() + if not is_destroyed(self.control): + self.control.hide() + self.control.deleteLater() self.control = None def _add_event_listeners(self): @@ -85,7 +87,7 @@ def _add_event_listeners(self): def _remove_event_listeners(self): if self._event_filter is not None: - if self.control is not None: + if self.control is not None and not is_destroyed(self.control): self.control.removeEventFilter(self._event_filter) self._event_filter = None diff --git a/pyface/ui/qt4/window.py b/pyface/ui/qt4/window.py index 10e43fcc7..275ad1384 100644 --- a/pyface/ui/qt4/window.py +++ b/pyface/ui/qt4/window.py @@ -22,6 +22,7 @@ from pyface.i_window import IWindow, MWindow from pyface.key_pressed_event import KeyPressedEvent from .gui import GUI +from .toolkit_utils import is_destroyed from .widget import Widget @@ -61,6 +62,11 @@ class Window(MWindow, Widget): #: The window has been deactivated. deactivated = Event + # 'IWidget' interface ---------------------------------------------------- + + #: Windows should be hidden until explicitly shown. + visible = False + # Private interface ------------------------------------------------------ #: Shadow trait for position. @@ -73,9 +79,10 @@ class Window(MWindow, Widget): # 'IWindow' interface. # ------------------------------------------------------------------------- - def activate(self): + def activate(self, should_raise=True): self.control.activateWindow() - self.control.raise_() + if should_raise: + self.control.raise_() # explicitly fire activated trait as signal doesn't create Qt event self.activated = self @@ -106,8 +113,6 @@ def _create_control(self, parent): # ------------------------------------------------------------------------- def destroy(self): - self._remove_event_listeners() - if self.control is not None: # Avoid problems with recursive calls. # Widget.destroy() sets self.control to None, @@ -121,7 +126,8 @@ def destroy(self): # which can take a long time and may also attempt to recursively # destroy the window again. super(Window, self).destroy() - control.close() + if not is_destroyed(control): + control.close() # ------------------------------------------------------------------------- # Private interface. diff --git a/pyface/ui/wx/gui.py b/pyface/ui/wx/gui.py index 9bdc26df1..f7bdbba5f 100644 --- a/pyface/ui/wx/gui.py +++ b/pyface/ui/wx/gui.py @@ -25,7 +25,7 @@ import wx # Enthought library imports. -from traits.api import Bool, HasTraits, provides, Unicode +from traits.api import Bool, HasTraits, Property, provides, Unicode from pyface.util.guisupport import start_event_loop_wx # Local imports. @@ -42,10 +42,22 @@ class GUI(MGUI, HasTraits): #### 'GUI' interface ###################################################### + #: A reference to the toolkit application singleton. + app = Property + + #: Is the GUI busy (i.e. should the busy cursor, often an hourglass, be + #: displayed)? busy = Bool(False) + #: Has the GUI's event loop been started? started = Bool(False) + #: Whether the GUI quits on last window close. + quit_on_last_window_close = Property(Bool) + + #: A directory on the local file system that we can read and write to at + #: will. This is used to persist layout information etc. Note that + #: individual toolkits will have their own directory. state_location = Unicode ########################################################################### @@ -103,12 +115,15 @@ def start_event_loop(self): if self._splash_screen is not None: self._splash_screen.close() + if self.app.IsMainLoopRunning(): + raise RuntimeError('double call') + # Make sure that we only set the 'started' trait after the main loop # has really started. self.set_trait_after(10, self, "started", True) # A hack to force menus to appear for applications run on Mac OS X. - if sys.platform == 'darwin': + if sys.platform == 'darwin' and not self.top_level_windows(): def _mac_os_x_hack(): f = wx.Frame(None, -1) f.Show(True) @@ -116,7 +131,7 @@ def _mac_os_x_hack(): self.invoke_later(_mac_os_x_hack) logger.debug("---------- starting GUI event loop ----------") - start_event_loop_wx() + self.app.MainLoop() self.started = False @@ -124,7 +139,21 @@ def stop_event_loop(self): """ Stop the GUI event loop. """ logger.debug("---------- stopping GUI event loop ----------") - wx.GetApp().ExitMainLoop() + self.app.ExitMainLoop() + # XXX this feels wrong, but seems to be needed in some cases + self.process_events(False) + + def clear_event_queue(self): + self.app.DeletePendingEvents() + + def top_level_windows(self): + return wx.GetTopLevelWindows() + + def close_all(self, force=False): + for window in self.top_level_windows(): + closed = window.Close(force) + if not closed: + break ########################################################################### # Trait handlers. @@ -145,4 +174,16 @@ def _busy_changed(self, new): return -#### EOF ###################################################################### + # Property handlers ----------------------------------------------------- + + def _get_app(self): + app = wx.GetApp() + if app is None: + app = wx.App() + return app + + def _get_quit_on_last_window_close(self): + return self.app.GetExitOnFrameDelete() + + def _set_quit_on_last_window_close(self, value): + return self.app.SetExitOnFrameDelete(value) diff --git a/pyface/ui/wx/timer/timer.py b/pyface/ui/wx/timer/timer.py index 2d1bae46d..102bb79fd 100644 --- a/pyface/ui/wx/timer/timer.py +++ b/pyface/ui/wx/timer/timer.py @@ -12,21 +12,29 @@ """A `wx.Timer` subclass that invokes a specified callback periodically. """ +import logging + import wx -from traits.api import Bool, Instance, Property +from traits.api import Instance from pyface.timer.i_timer import BaseTimer +logger = logging.getLogger(__name__) + + class CallbackTimer(wx.Timer): def __init__(self, timer): super(CallbackTimer, self).__init__() self.timer = timer def Notify(self): - self.timer.perform() - wx.GetApp().Yield(True) + try: + self.timer.perform() + except Exception: + self.Stop() + logger.exception("Error in timer.peform") class PyfaceTimer(BaseTimer): diff --git a/pyface/ui/wx/toolkit_utils.py b/pyface/ui/wx/toolkit_utils.py new file mode 100644 index 000000000..fb76a0283 --- /dev/null +++ b/pyface/ui/wx/toolkit_utils.py @@ -0,0 +1,40 @@ +# Copyright (c) 2019, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! + +""" Toolkit-specific utilities. """ + +# Import the toolkit specific version. +from __future__ import absolute_import + + +# ---------------------------------------------------------------------------- +# Toolkit utility functions +# ---------------------------------------------------------------------------- + +def destroy_later(control): + """ Schedule a toolkit control for later destruction. + + Parameters + ---------- + control : WxWindow subclass + The object that is to be destroyed. + """ + if control: + control.DestroyLater() + + +def is_destroyed(control): + """ Checks if a control has had the underlying C++ object destroyed. + + Parameters + ---------- + control : WxWindow subclass + The control that is being tested. + """ + return not bool(control) diff --git a/pyface/ui/wx/window.py b/pyface/ui/wx/window.py index b6e091a96..042d702d3 100644 --- a/pyface/ui/wx/window.py +++ b/pyface/ui/wx/window.py @@ -74,9 +74,13 @@ class Window(MWindow, Widget): # 'IWindow' interface. # ------------------------------------------------------------------------- - def activate(self): + def activate(self, should_raise=True): self.control.Iconize(False) - self.control.Raise() + if should_raise: + self.control.Raise() + else: + evt = wx.ActivateEvent(active=True) + wx.PostEvent(self.control, evt) def show(self, visible): self.control.Show(visible) @@ -165,7 +169,6 @@ def _title_changed(self, title): def _wx_on_activate(self, event): """ Called when the frame is being activated or deactivated. """ - if event.GetActive(): self.activated = self else: @@ -175,7 +178,6 @@ def _wx_on_activate(self, event): def _wx_on_show(self, event): """ Called when the frame is being activated or deactivated. """ - self.visible = event.IsShown() event.Skip()