1# (C) Copyright 2007-2019 Enthought, Inc., Austin, TX
2# All rights reserved.
3#
4# This software is provided without warranty under the terms of the BSD
5# license included in LICENSE.txt and may be redistributed only
6# under the conditions described in the aforementioned license.  The license
7# is also available online at http://www.enthought.com/licenses/BSD.txt
8# Thanks for using Enthought open source!
9# Standard library imports.
10import logging
11import os.path
12
13# Enthought library imports.
14from envisage.api import Application, ExtensionPoint
15from traits.api import (
16    Bool, Callable, Directory, Event, HasStrictTraits,
17    Instance, Int, List, Unicode, Vetoable)
18from traits.etsconfig.api import ETSConfig
19
20# Local imports
21from envisage._compat import pickle, STRING_BASE_CLASS
22
23
24# Logging.
25logger = logging.getLogger(__name__)
26
27#: Default filename for saving layout information
28DEFAULT_STATE_FILENAME = "application_memento"
29
30
31class TasksApplication(Application):
32    """The entry point for an Envisage Tasks application.
33
34    This class handles the common case for Tasks applications and is
35    intended to be subclassed to modify its start/stop behavior, etc.
36
37    """
38
39    # Extension point IDs.
40    TASK_FACTORIES = 'envisage.ui.tasks.tasks'
41    TASK_EXTENSIONS = 'envisage.ui.tasks.task_extensions'
42
43    # Pickle protocol to use for persisting layout information. Subclasses may
44    # want to increase this, depending on their compatibility needs. Protocol
45    # version 2 is safe for Python >= 2.3. Protocol version 4 is safe for
46    # Python >= 3.4.
47    layout_save_protocol = Int(2)
48
49    #### 'TasksApplication' interface #########################################
50
51    # The active task window (the last one to get focus).
52    active_window = Instance('envisage.ui.tasks.task_window.TaskWindow')
53
54    # The Pyface GUI for the application.
55    gui = Instance('pyface.gui.GUI')
56
57    # Icon for the whole application. Will be used to override all taskWindows
58    # icons to have the same.
59    icon = Instance('pyface.image_resource.ImageResource', allow_none=True)
60
61    # The name of the application (also used on window title bars).
62    name = Unicode
63
64    # The splash screen for the application. By default, there is no splash
65    # screen.
66    splash_screen = Instance('pyface.splash_screen.SplashScreen')
67
68    # The directory on the local file system used to persist window layout
69    # information.
70    state_location = Directory
71
72    # The filename that the application uses to persist window layout
73    # information.
74    state_filename = Unicode(DEFAULT_STATE_FILENAME)
75
76    # Contributed task factories. This attribute is primarily for run-time
77    # inspection; to instantiate a task, use the 'create_task' method.
78    task_factories = ExtensionPoint(id=TASK_FACTORIES)
79
80    # Contributed task extensions.
81    task_extensions = ExtensionPoint(id=TASK_EXTENSIONS)
82
83    # The list of task windows created by the application.
84    windows = List(Instance('envisage.ui.tasks.task_window.TaskWindow'))
85
86    # The factory for creating task windows.
87    window_factory = Callable
88
89    #### Application layout ###################################################
90
91    # The default layout for the application. If not specified, a single window
92    # will be created with the first available task factory.
93    default_layout = List(
94        Instance('pyface.tasks.task_window_layout.TaskWindowLayout'))
95
96    # Whether to always apply the default *application level* layout when the
97    # application is started. Even if this is True, the layout state of
98    # individual tasks will be restored.
99    always_use_default_layout = Bool(False)
100
101    #### Application lifecycle events #########################################
102
103    # Fired after the initial windows have been created and the GUI event loop
104    # has been started.
105    application_initialized = Event
106
107    # Fired immediately before the extant windows are destroyed and the GUI
108    # event loop is terminated.
109    application_exiting = Event
110
111    # Fired when a task window has been created.
112    window_created = Event(
113        Instance('envisage.ui.tasks.task_window_event.TaskWindowEvent'))
114
115    # Fired when a task window is opening.
116    window_opening = Event(
117        Instance(
118            'envisage.ui.tasks.task_window_event.VetoableTaskWindowEvent'))
119
120    # Fired when a task window has been opened.
121    window_opened = Event(
122        Instance('envisage.ui.tasks.task_window_event.TaskWindowEvent'))
123
124    # Fired when a task window is closing.
125    window_closing = Event(
126        Instance(
127            'envisage.ui.tasks.task_window_event.VetoableTaskWindowEvent'))
128
129    # Fired when a task window has been closed.
130    window_closed = Event(
131        Instance('envisage.ui.tasks.task_window_event.TaskWindowEvent'))
132
133    #### Protected interface ##################################################
134
135    # An 'explicit' exit is when the the 'exit' method is called.
136    # An 'implicit' exit is when the user closes the last open window.
137    _explicit_exit = Bool(False)
138
139    # Application state.
140    _state = Instance(
141        'envisage.ui.tasks.tasks_application.TasksApplicationState')
142
143    ###########################################################################
144    # 'IApplication' interface.
145    ###########################################################################
146
147    def run(self):
148        """ Run the application.
149
150        Returns
151        -------
152        bool
153            Whether the application started successfully (i.e., without a
154            veto).
155        """
156        # Make sure the GUI has been created (so that, if required, the splash
157        # screen is shown).
158        gui = self.gui
159
160        started = self.start()
161        if started:
162            # Create windows from the default or saved application layout.
163            self._create_windows()
164
165            # Start the GUI event loop.
166            gui.set_trait_later(self, 'application_initialized', self)
167            gui.start_event_loop()
168
169        return started
170
171    ###########################################################################
172    # 'TasksApplication' interface.
173    ###########################################################################
174
175    def create_task(self, id):
176        """ Creates the Task with the specified ID.
177
178        Returns
179        -------
180        pyface.tasks.task.Task
181            The new Task, or None if there is not a suitable TaskFactory.
182        """
183        # Get the factory for the task.
184        factory = self._get_task_factory(id)
185        if factory is None:
186            return None
187
188        # Create the task using suitable task extensions.
189        extensions = [ext for ext in self.task_extensions
190                      if ext.task_id == id or not ext.task_id]
191        task = factory.create_with_extensions(extensions)
192        task.id = factory.id
193        return task
194
195    def create_window(self, layout=None, restore=True, **traits):
196        """Creates a new TaskWindow, possibly with some Tasks.
197
198        Parameters
199        ----------
200        layout : TaskWindowLayout, optional
201             The layout to use for the window. The tasks described in
202             the layout will be created and added to the window
203             automatically. If not specified, the window will contain
204             no tasks.
205
206        restore : bool, optional (default True)
207             If set, the application will restore old size and
208             positions for the window and its panes, if possible. If a
209             layout is not provided, this parameter has no effect.
210
211        **traits : dict, optional
212             Additional parameters to pass to ``window_factory()``
213             when creating the TaskWindow.
214
215        Returns
216        -------
217        envisage.ui.tasks.task_window.TaskWindow
218            The new TaskWindow.
219
220        """
221        from .task_window_event import TaskWindowEvent
222        from pyface.tasks.task_window_layout import TaskWindowLayout
223
224        window = self.window_factory(application=self, **traits)
225
226        # Listen for the window events.
227        window.on_trait_change(self._on_window_activated, 'activated')
228        window.on_trait_change(self._on_window_opening, 'opening')
229        window.on_trait_change(self._on_window_opened, 'opened')
230        window.on_trait_change(self._on_window_closing, 'closing')
231        window.on_trait_change(self._on_window_closed, 'closed')
232
233        # Event notification.
234        self.window_created = TaskWindowEvent(window=window)
235
236        if layout:
237            # Create and add tasks.
238            for task_id in layout.get_tasks():
239                task = self.create_task(task_id)
240                if task:
241                    window.add_task(task)
242                else:
243                    logger.error(
244                        'Missing factory for task with ID %r', task_id)
245
246            # Apply a suitable layout.
247            if restore:
248                layout = self._restore_layout_from_state(layout)
249        else:
250            # Create an empty layout to set default size and position only
251            layout = TaskWindowLayout()
252
253        window.set_window_layout(layout)
254
255        return window
256
257    def exit(self, force=False):
258        """Exits the application, closing all open task windows.
259
260        Each window is sent a veto-able closing event. If any window vetoes the
261        close request, no window will be closed. Otherwise, all windows will be
262        closed and the GUI event loop will terminate.
263
264        This method is not called when the user clicks the close
265        button on a window or otherwise closes a window through his or
266        her window manager. It is only called via the File->Exit menu
267        item. It can also, of course, be called programatically.
268
269        Parameters
270        ----------
271        force : bool, optional (default False)
272            If set, windows will receive no closing events and will be
273            destroyed unconditionally. This can be useful for reliably
274            tearing down regression tests, but should be used with
275            caution.
276
277        Returns
278        -------
279        bool
280            A boolean indicating whether the application exited.
281
282        """
283        self._explicit_exit = True
284        try:
285            if not force:
286                for window in reversed(self.windows):
287                    window.closing = event = Vetoable()
288                    if event.veto:
289                        return False
290
291            self._prepare_exit()
292            for window in reversed(self.windows):
293                window.destroy()
294                window.closed = True
295        finally:
296            self._explicit_exit = False
297        return True
298
299    ###########################################################################
300    # Protected interface.
301    ###########################################################################
302
303    def _create_windows(self):
304        """ Called at startup to create TaskWindows from the default or saved
305            application layout.
306        """
307        # Build a list of TaskWindowLayouts.
308        self._load_state()
309        if (self.always_use_default_layout or
310                not self._state.previous_window_layouts):
311            window_layouts = self.default_layout
312        else:
313            # Choose the stored TaskWindowLayouts, but only if all the task IDs
314            # are still valid.
315            window_layouts = self._state.previous_window_layouts
316            for layout in window_layouts:
317                for task_id in layout.get_tasks():
318                    if not self._get_task_factory(task_id):
319                        logger.warning('Saved application layout references '
320                                       'non-existent task %r. Falling back to '
321                                       'default application layout.' % task_id)
322                        window_layouts = self.default_layout
323                        break
324                else:
325                    continue
326                break
327
328        # Create a TaskWindow for each TaskWindowLayout.
329        for window_layout in window_layouts:
330            if self.always_use_default_layout:
331                window = self.create_window(window_layout, restore=False)
332            else:
333                window = self.create_window(window_layout, restore=True)
334            window.open()
335
336    def _get_task_factory(self, id):
337        """ Returns the TaskFactory with the specified ID, or None.
338        """
339        for factory in self.task_factories:
340            if factory.id == id:
341                return factory
342        return None
343
344    def _prepare_exit(self):
345        """ Called immediately before the extant windows are destroyed and the
346            GUI event loop is terminated.
347        """
348        self.application_exiting = self
349        self._save_state()
350
351    def _load_state(self):
352        """ Loads saved application state, if possible.
353        """
354        state = TasksApplicationState()
355        filename = os.path.join(self.state_location, self.state_filename)
356        if os.path.exists(filename):
357            # Attempt to unpickle the saved application state.
358            logger.debug('Loading application state from %s', filename)
359            try:
360                with open(filename, 'rb') as f:
361                    restored_state = pickle.load(f)
362            except Exception:
363                # If anything goes wrong, log the error and continue.
364                logger.exception('Error while restoring application state')
365            else:
366                if state.version == restored_state.version:
367                    state = restored_state
368                    logger.debug('Application state successfully restored')
369                else:
370                    logger.warning(
371                        'Discarding outdated application state: '
372                        'expected version %s, got version %s',
373                        state.version, restored_state.version)
374        else:
375            logger.debug("No saved application state found at %s", filename)
376
377        self._state = state
378
379    def _restore_layout_from_state(self, layout):
380        """ Restores an equivalent layout from saved application state.
381        """
382        # First, see if a window layout matches exactly.
383        match = self._state.get_equivalent_window_layout(layout)
384        if match:
385            # The active task is not part of the equivalency relation, so we
386            # ensure that it is correct.
387            match.active_task = layout.get_active_task()
388            layout = match
389
390        # If that fails, at least try to restore the layout of
391        # individual tasks.
392        else:
393            layout = layout.clone_traits()
394            for i, item in enumerate(layout.items):
395                id = item if isinstance(item, STRING_BASE_CLASS) else item.id
396                match = self._state.get_task_layout(id)
397                if match:
398                    layout.items[i] = match
399
400        return layout
401
402    def _save_state(self):
403        """ Saves the application state.
404        """
405        # Grab the current window layouts.
406        window_layouts = [w.get_window_layout() for w in self.windows]
407        self._state.previous_window_layouts = window_layouts
408
409        # Attempt to pickle the application state.
410        filename = os.path.join(self.state_location, self.state_filename)
411        logger.debug('Saving application state to %s', filename)
412        try:
413            with open(filename, 'wb') as f:
414                pickle.dump(self._state, f, protocol=self.layout_save_protocol)
415        except Exception:
416            # If anything goes wrong, log the error and continue.
417            logger.exception('Error while saving application state')
418        else:
419            logger.debug('Application state successfully saved')
420
421    #### Trait initializers ###################################################
422
423    def _window_factory_default(self):
424        from envisage.ui.tasks.task_window import TaskWindow
425        return TaskWindow
426
427    def _default_layout_default(self):
428        from pyface.tasks.task_window_layout import TaskWindowLayout
429        window_layout = TaskWindowLayout()
430        if self.task_factories:
431            window_layout.items = [self.task_factories[0].id]
432        return [window_layout]
433
434    def _gui_default(self):
435        from pyface.gui import GUI
436        return GUI(splash_screen=self.splash_screen)
437
438    def _state_location_default(self):
439        state_location = os.path.join(ETSConfig.application_home,
440                                      'tasks', ETSConfig.toolkit)
441        if not os.path.exists(state_location):
442            os.makedirs(state_location)
443
444        logger.debug('Tasks state location is %s', state_location)
445
446        return state_location
447
448    #### Trait change handlers ################################################
449
450    def _on_window_activated(self, window, trait_name, event):
451        self.active_window = window
452
453    def _on_window_opening(self, window, trait_name, event):
454        from .task_window_event import VetoableTaskWindowEvent
455        # Event notification.
456        self.window_opening = window_event = VetoableTaskWindowEvent(
457            window=window)
458
459        if window_event.veto:
460            event.veto = True
461
462    def _on_window_opened(self, window, trait_name, event):
463        from .task_window_event import TaskWindowEvent
464        self.windows.append(window)
465
466        # Event notification.
467        self.window_opened = TaskWindowEvent(window=window)
468
469    def _on_window_closing(self, window, trait_name, event):
470        from .task_window_event import VetoableTaskWindowEvent
471        # Event notification.
472        self.window_closing = window_event = VetoableTaskWindowEvent(
473            window=window)
474
475        if window_event.veto:
476            event.veto = True
477        else:
478            # Store the layout of the window.
479            window_layout = window.get_window_layout()
480            self._state.push_window_layout(window_layout)
481
482            # If we're exiting implicitly and this is the last window, save
483            # state, because we won't get another chance.
484            if len(self.windows) == 1 and not self._explicit_exit:
485                self._prepare_exit()
486
487    def _on_window_closed(self, window, trait_name, event):
488        from .task_window_event import TaskWindowEvent
489        self.windows.remove(window)
490
491        # Event notification.
492        self.window_closed = TaskWindowEvent(window=window)
493
494        # Was this the last window?
495        if len(self.windows) == 0:
496            self.stop()
497
498
499class TasksApplicationState(HasStrictTraits):
500    """ A class used internally by TasksApplication for saving and restoring
501        application state.
502    """
503
504    # TaskWindowLayouts for the windows extant at application
505    # exit. Only used if 'always_use_default_layout' is disabled.
506    previous_window_layouts = List(
507        Instance('pyface.tasks.task_window_layout.TaskWindowLayout'))
508
509    # A list of TaskWindowLayouts accumulated throughout the application's
510    # lifecycle.
511    window_layouts = List(
512        Instance('pyface.tasks.task_window_layout.TaskWindowLayout'))
513
514    # The "version" for the state data. This should be incremented whenever a
515    # backwards incompatible change is made to this class or any of the layout
516    # classes. This ensures that loading application state is always safe.
517    version = Int(1)
518
519    def get_equivalent_window_layout(self, window_layout):
520        """ Gets an equivalent TaskWindowLayout, if there is one.
521        """
522        for layout in self.window_layouts:
523            if layout.is_equivalent_to(window_layout):
524                return layout
525        return None
526
527    def get_task_layout(self, task_id):
528        """ Gets a TaskLayout with the specified ID, there is one.
529        """
530        for window_layout in self.window_layouts:
531            for layout in window_layout.items:
532                if layout.id == task_id:
533                    return layout
534        return None
535
536    def push_window_layout(self, window_layout):
537        """ Merge a TaskWindowLayout into the accumulated list.
538        """
539        self.window_layouts = [layout for layout in self.window_layouts
540                               if not layout.is_equivalent_to(window_layout)]
541        self.window_layouts.insert(0, window_layout)
542