1# (C) Copyright 2005-2020 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 under
6# the conditions described in the aforementioned license. The license
7# is also available online at http://www.enthought.com/licenses/BSD.txt
8#
9# Thanks for using Enthought open source!
10# (C) Copyright 2008 Riverbank Computing Limited
11# All rights reserved.
12#
13# This software is provided without warranty under the terms of the BSD license.
14# However, when used with the GPL version of PyQt the additional terms described in the PyQt GPL exception also apply
15
16
17import logging
18
19
20from pyface.qt import QtCore, QtGui
21
22
23from traits.api import Instance, on_trait_change
24
25
26from pyface.message_dialog import error
27from pyface.workbench.i_workbench_window_layout import MWorkbenchWindowLayout
28from .split_tab_widget import SplitTabWidget
29
30
31# Logging.
32logger = logging.getLogger(__name__)
33
34
35# For mapping positions relative to the editor area.
36_EDIT_AREA_MAP = {
37    "left": QtCore.Qt.LeftDockWidgetArea,
38    "right": QtCore.Qt.RightDockWidgetArea,
39    "top": QtCore.Qt.TopDockWidgetArea,
40    "bottom": QtCore.Qt.BottomDockWidgetArea,
41}
42
43# For mapping positions relative to another view.
44_VIEW_AREA_MAP = {
45    "left": (QtCore.Qt.Horizontal, True),
46    "right": (QtCore.Qt.Horizontal, False),
47    "top": (QtCore.Qt.Vertical, True),
48    "bottom": (QtCore.Qt.Vertical, False),
49}
50
51
52class WorkbenchWindowLayout(MWorkbenchWindowLayout):
53    """ The Qt4 implementation of the workbench window layout interface.
54
55    See the 'IWorkbenchWindowLayout' interface for the API documentation.
56
57    """
58
59    # Private interface ----------------------------------------------------
60
61    # The widget that provides the editor area.  We keep (and use) this
62    # separate reference because we can't always assume that it has been set to
63    # be the main window's central widget.
64    _qt4_editor_area = Instance(SplitTabWidget)
65
66    # ------------------------------------------------------------------------
67    # 'IWorkbenchWindowLayout' interface.
68    # ------------------------------------------------------------------------
69
70    def activate_editor(self, editor):
71        if editor.control is not None:
72            editor.control.show()
73            self._qt4_editor_area.setCurrentWidget(editor.control)
74            editor.set_focus()
75
76        return editor
77
78    def activate_view(self, view):
79        # FIXME v3: This probably doesn't work as expected.
80        view.control.raise_()
81        view.set_focus()
82
83        return view
84
85    def add_editor(self, editor, title):
86        if editor is None:
87            return None
88
89        try:
90            self._qt4_editor_area.addTab(
91                self._qt4_get_editor_control(editor), title
92            )
93
94            if editor._loading_on_open:
95                self._qt4_editor_tab_spinner(editor, "", True)
96        except Exception:
97            logger.exception("error creating editor control [%s]", editor.id)
98
99        return editor
100
101    def add_view(self, view, position=None, relative_to=None, size=(-1, -1)):
102        if view is None:
103            return None
104
105        try:
106            self._qt4_add_view(view, position, relative_to, size)
107            view.visible = True
108        except Exception:
109            logger.exception("error creating view control [%s]", view.id)
110
111            # Even though we caught the exception, it sometimes happens that
112            # the view's control has been created as a child of the application
113            # window (or maybe even the dock control).  We should destroy the
114            # control to avoid bad UI effects.
115            view.destroy_control()
116
117            # Additionally, display an error message to the user.
118            error(
119                self.window.control,
120                "Unable to add view [%s]" % view.id,
121                "Workbench Plugin Error",
122            )
123
124        return view
125
126    def close_editor(self, editor):
127        if editor.control is not None:
128            editor.control.close()
129
130        return editor
131
132    def close_view(self, view):
133        self.hide_view(view)
134
135        return view
136
137    def close(self):
138        # Don't fire signals for editors that have destroyed their controls.
139        self._qt4_editor_area.editor_has_focus.disconnect(
140            self._qt4_editor_focus
141        )
142
143        self._qt4_editor_area.clear()
144
145        # Delete all dock widgets.
146        for v in self.window.views:
147            if self.contains_view(v):
148                self._qt4_delete_view_dock_widget(v)
149
150    def create_initial_layout(self, parent):
151        self._qt4_editor_area = editor_area = SplitTabWidget(parent)
152
153        editor_area.editor_has_focus.connect(self._qt4_editor_focus)
154
155        # We are interested in focus changes but we get them from the editor
156        # area rather than qApp to allow the editor area to restrict them when
157        # needed.
158        editor_area.focus_changed.connect(self._qt4_view_focus_changed)
159
160        editor_area.tabTextChanged.connect(self._qt4_editor_title_changed)
161        editor_area.new_window_request.connect(self._qt4_new_window_request)
162        editor_area.tab_close_request.connect(self._qt4_tab_close_request)
163        editor_area.tab_window_changed.connect(self._qt4_tab_window_changed)
164
165        return editor_area
166
167    def contains_view(self, view):
168        return hasattr(view, "_qt4_dock")
169
170    def hide_editor_area(self):
171        self._qt4_editor_area.hide()
172
173    def hide_view(self, view):
174        view._qt4_dock.hide()
175        view.visible = False
176
177        return view
178
179    def refresh(self):
180        # Nothing to do.
181        pass
182
183    def reset_editors(self):
184        self._qt4_editor_area.setCurrentIndex(0)
185
186    def reset_views(self):
187        # Qt doesn't provide information about the order of dock widgets in a
188        # dock area.
189        pass
190
191    def show_editor_area(self):
192        self._qt4_editor_area.show()
193
194    def show_view(self, view):
195        view._qt4_dock.show()
196        view.visible = True
197
198    # Methods for saving and restoring the layout -------------------------#
199
200    def get_view_memento(self):
201        # Get the IDs of the views in the main window.  This information is
202        # also in the QMainWindow state, but that is opaque.
203        view_ids = [v.id for v in self.window.views if self.contains_view(v)]
204
205        # Everything else is provided by QMainWindow.
206        state = self.window.control.saveState()
207
208        return (0, (view_ids, state))
209
210    def set_view_memento(self, memento):
211        version, mdata = memento
212
213        # There has only ever been version 0 so far so check with an assert.
214        assert version == 0
215
216        # Now we know the structure of the memento we can "parse" it.
217        view_ids, state = mdata
218
219        # Get a list of all views that have dock widgets and mark them.
220        dock_views = [v for v in self.window.views if self.contains_view(v)]
221
222        for v in dock_views:
223            v._qt4_gone = True
224
225        # Create a dock window for all views that had one last time.
226        for v in self.window.views:
227            # Make sure this is in a known state.
228            v.visible = False
229
230            for vid in view_ids:
231                if vid == v.id:
232                    # Create the dock widget if needed and make sure that it is
233                    # invisible so that it matches the state of the visible
234                    # trait.  Things will all come right when the main window
235                    # state is restored below.
236                    self._qt4_create_view_dock_widget(v).setVisible(False)
237
238                    if v in dock_views:
239                        delattr(v, "_qt4_gone")
240
241                    break
242
243        # Remove any remain unused dock widgets.
244        for v in dock_views:
245            try:
246                delattr(v, "_qt4_gone")
247            except AttributeError:
248                pass
249            else:
250                self._qt4_delete_view_dock_widget(v)
251
252        # Restore the state.  This will update the view's visible trait through
253        # the dock window's toggle action.
254        self.window.control.restoreState(state)
255
256    def get_editor_memento(self):
257        # Get the layout of the editors.
258        editor_layout = self._qt4_editor_area.saveState()
259
260        # Get a memento for each editor that describes its contents.
261        editor_references = self._get_editor_references()
262
263        return (0, (editor_layout, editor_references))
264
265    def set_editor_memento(self, memento):
266        version, mdata = memento
267
268        # There has only ever been version 0 so far so check with an assert.
269        assert version == 0
270
271        # Now we know the structure of the memento we can "parse" it.
272        editor_layout, editor_references = mdata
273
274        def resolve_id(id):
275            # Get the memento for the editor contents (if any).
276            editor_memento = editor_references.get(id)
277
278            if editor_memento is None:
279                return None
280
281            # Create the restored editor.
282            editor = self.window.editor_manager.set_editor_memento(
283                editor_memento
284            )
285            if editor is None:
286                return None
287
288            # Save the editor.
289            self.window.editors.append(editor)
290
291            # Create the control if needed and return it.
292            return self._qt4_get_editor_control(editor)
293
294        self._qt4_editor_area.restoreState(editor_layout, resolve_id)
295
296    def get_toolkit_memento(self):
297        return (0, {"geometry": self.window.control.saveGeometry()})
298
299    def set_toolkit_memento(self, memento):
300        if hasattr(memento, "toolkit_data"):
301            data = memento.toolkit_data
302            if isinstance(data, tuple) and len(data) == 2:
303                version, datadict = data
304                if version == 0:
305                    geometry = datadict.pop("geometry", None)
306                    if geometry is not None:
307                        self.window.control.restoreGeometry(geometry)
308
309    def is_editor_area_visible(self):
310        return self._qt4_editor_area.isVisible()
311
312    # ------------------------------------------------------------------------
313    # Private interface.
314    # ------------------------------------------------------------------------
315
316    def _qt4_editor_focus(self, new):
317        """ Handle an editor getting the focus. """
318
319        for editor in self.window.editors:
320            control = editor.control
321            editor.has_focus = control is new or (
322                control is not None and new in control.children()
323            )
324
325    def _qt4_editor_title_changed(self, control, title):
326        """ Handle the title being changed """
327        for editor in self.window.editors:
328            if editor.control == control:
329                editor.name = str(title)
330
331    def _qt4_editor_tab_spinner(self, editor, name, new):
332        # Do we need to do this verification?
333        tw, tidx = self._qt4_editor_area._tab_widget(editor.control)
334
335        if new:
336            tw.show_button(tidx)
337        else:
338            tw.hide_button(tidx)
339
340        if not new and not editor == self.window.active_editor:
341            self._qt4_editor_area.setTabTextColor(
342                editor.control, QtCore.Qt.red
343            )
344
345    @on_trait_change("window:active_editor")
346    def _qt4_active_editor_changed(self, old, new):
347        """ Handle change of active editor """
348        # Reset tab title to foreground color
349        if new is not None:
350            self._qt4_editor_area.setTabTextColor(new.control)
351
352    def _qt4_view_focus_changed(self, old, new):
353        """ Handle the change of focus for a view. """
354
355        focus_part = None
356
357        if new is not None:
358            # Handle focus changes to views.
359            for view in self.window.views:
360                if view.control is not None and view.control.isAncestorOf(new):
361                    view.has_focus = True
362                    focus_part = view
363                    break
364
365        if old is not None:
366            # Handle focus changes from views.
367            for view in self.window.views:
368                if (
369                    view is not focus_part
370                    and view.control is not None
371                    and view.control.isAncestorOf(old)
372                ):
373                    view.has_focus = False
374                    break
375
376    def _qt4_new_window_request(self, pos, control):
377        """ Handle a tab tear-out request from the splitter widget. """
378
379        editor = self._qt4_remove_editor_with_control(control)
380        kind = self.window.editor_manager.get_editor_kind(editor)
381
382        window = self.window.workbench.create_window()
383        window.open()
384        window.add_editor(editor)
385        window.editor_manager.add_editor(editor, kind)
386        window.position = (pos.x(), pos.y())
387        window.size = self.window.size
388        window.activate_editor(editor)
389        editor.window = window
390
391    def _qt4_tab_close_request(self, control):
392        """ Handle a tabCloseRequest from the splitter widget. """
393
394        for editor in self.window.editors:
395            if editor.control == control:
396                editor.close()
397                break
398
399    def _qt4_tab_window_changed(self, control):
400        """ Handle a tab drag to a different WorkbenchWindow. """
401
402        editor = self._qt4_remove_editor_with_control(control)
403        kind = self.window.editor_manager.get_editor_kind(editor)
404
405        while not control.isWindow():
406            control = control.parent()
407        for window in self.window.workbench.windows:
408            if window.control == control:
409                window.editors.append(editor)
410                window.editor_manager.add_editor(editor, kind)
411                window.layout._qt4_get_editor_control(editor)
412                window.activate_editor(editor)
413                editor.window = window
414                break
415
416    def _qt4_remove_editor_with_control(self, control):
417        """ Finds the editor associated with 'control' and removes it. Returns
418            the editor, or None if no editor was found.
419        """
420        for editor in self.window.editors:
421            if editor.control == control:
422                self.editor_closing = editor
423                control.removeEventFilter(self._qt4_mon)
424                self.editor_closed = editor
425
426                # Make sure that focus events get fired if this editor is
427                # subsequently added to another window.
428                editor.has_focus = False
429
430                return editor
431
432    def _qt4_get_editor_control(self, editor):
433        """ Create the editor control if it hasn't already been done. """
434
435        if editor.control is None:
436            self.editor_opening = editor
437
438            # We must provide a parent (because TraitsUI checks for it when
439            # deciding what sort of panel to create) but it can't be the editor
440            # area (because it will be automatically added to the base
441            # QSplitter).
442            editor.control = editor.create_control(self.window.control)
443            editor.control.setObjectName(editor.id)
444
445            editor.on_trait_change(self._qt4_editor_tab_spinner, "_loading")
446
447            self.editor_opened = editor
448
449        def on_name_changed(editor, trait_name, old, new):
450            self._qt4_editor_area.setWidgetTitle(editor.control, editor.name)
451
452        editor.on_trait_change(on_name_changed, "name")
453
454        self._qt4_monitor(editor.control)
455
456        return editor.control
457
458    def _qt4_add_view(self, view, position, relative_to, size):
459        """ Add a view. """
460
461        # If no specific position is specified then use the view's default
462        # position.
463        if position is None:
464            position = view.position
465
466        dw = self._qt4_create_view_dock_widget(view, size)
467        mw = self.window.control
468
469        try:
470            rel_dw = relative_to._qt4_dock
471        except AttributeError:
472            rel_dw = None
473
474        if rel_dw is None:
475            # If we are trying to add a view with a non-existent item, then
476            # just default to the left of the editor area.
477            if position == "with":
478                position = "left"
479
480            # Position the view relative to the editor area.
481            try:
482                dwa = _EDIT_AREA_MAP[position]
483            except KeyError:
484                raise ValueError("unknown view position: %s" % position)
485
486            mw.addDockWidget(dwa, dw)
487        elif position == "with":
488            # FIXME v3: The Qt documentation says that the second should be
489            # placed above the first, but it always seems to be underneath (ie.
490            # hidden) which is not what the user is expecting.
491            mw.tabifyDockWidget(rel_dw, dw)
492        else:
493            try:
494                orient, swap = _VIEW_AREA_MAP[position]
495            except KeyError:
496                raise ValueError("unknown view position: %s" % position)
497
498            mw.splitDockWidget(rel_dw, dw, orient)
499
500            # The Qt documentation implies that the layout direction can be
501            # used to position the new dock widget relative to the existing one
502            # but I could only get the button positions to change.  Instead we
503            # move things around afterwards if required.
504            if swap:
505                mw.removeDockWidget(rel_dw)
506                mw.splitDockWidget(dw, rel_dw, orient)
507                rel_dw.show()
508
509    def _qt4_create_view_dock_widget(self, view, size=(-1, -1)):
510        """ Create a dock widget that wraps a view. """
511
512        # See if it has already been created.
513        try:
514            dw = view._qt4_dock
515        except AttributeError:
516            dw = QtGui.QDockWidget(view.name, self.window.control)
517            dw.setWidget(_ViewContainer(size, self.window.control))
518            dw.setObjectName(view.id)
519            dw.toggleViewAction().toggled.connect(
520                self._qt4_handle_dock_visibility
521            )
522            dw.visibilityChanged.connect(self._qt4_handle_dock_visibility)
523
524            # Save the dock window.
525            view._qt4_dock = dw
526
527            def on_name_changed():
528                view._qt4_dock.setWindowTitle(view.name)
529
530            view.on_trait_change(on_name_changed, "name")
531
532        # Make sure the view control exists.
533        if view.control is None:
534            # Make sure that the view knows which window it is in.
535            view.window = self.window
536
537            try:
538                view.control = view.create_control(dw.widget())
539            except:
540                # Tidy up if the view couldn't be created.
541                delattr(view, "_qt4_dock")
542                self.window.control.removeDockWidget(dw)
543                dw.deleteLater()
544                del dw
545                raise
546
547        dw.widget().setCentralWidget(view.control)
548
549        return dw
550
551    def _qt4_delete_view_dock_widget(self, view):
552        """ Delete a view's dock widget. """
553
554        dw = view._qt4_dock
555
556        # Disassociate the view from the dock.
557        if view.control is not None:
558            view.control.setParent(None)
559
560        delattr(view, "_qt4_dock")
561
562        # Delete the dock (and the view container).
563        self.window.control.removeDockWidget(dw)
564        dw.deleteLater()
565
566    def _qt4_handle_dock_visibility(self, checked):
567        """ Handle the visibility of a dock window changing. """
568
569        # Find the dock window by its toggle action.
570        for v in self.window.views:
571            try:
572                dw = v._qt4_dock
573            except AttributeError:
574                continue
575
576            sender = dw.sender()
577            if sender is dw.toggleViewAction() or sender in dw.children():
578                # Toggling the action or pressing the close button on
579                # the view
580                v.visible = checked
581
582    def _qt4_monitor(self, control):
583        """ Install an event filter for a view or editor control to keep an eye
584        on certain events.
585        """
586
587        # Create the monitoring object if needed.
588        try:
589            mon = self._qt4_mon
590        except AttributeError:
591            mon = self._qt4_mon = _Monitor(self)
592
593        control.installEventFilter(mon)
594
595
596class _Monitor(QtCore.QObject):
597    """ This class monitors a view or editor control. """
598
599    def __init__(self, layout):
600        QtCore.QObject.__init__(self, layout.window.control)
601
602        self._layout = layout
603
604    def eventFilter(self, obj, e):
605        if isinstance(e, QtGui.QCloseEvent):
606            for editor in self._layout.window.editors:
607                if editor.control is obj:
608                    self._layout.editor_closing = editor
609                    editor.destroy_control()
610                    self._layout.editor_closed = editor
611
612                    break
613
614        return False
615
616
617class _ViewContainer(QtGui.QMainWindow):
618    """ This class is a container for a view that allows an initial size
619    (specified as a tuple) to be set.
620    """
621
622    def __init__(self, size, main_window):
623        """ Initialise the object. """
624
625        QtGui.QMainWindow.__init__(self)
626
627        # Save the size and main window.
628        self._width, self._height = size
629        self._main_window = main_window
630
631    def sizeHint(self):
632        """ Reimplemented to return the initial size or the view's current
633        size.
634        """
635
636        sh = self.centralWidget().sizeHint()
637
638        if self._width > 0:
639            if self._width > 1:
640                w = self._width
641            else:
642                w = self._main_window.width() * self._width
643
644            sh.setWidth(int(w))
645
646        if self._height > 0:
647            if self._height > 1:
648                h = self._height
649            else:
650                h = self._main_window.height() * self._height
651
652            sh.setHeight(int(h))
653
654        return sh
655
656    def showEvent(self, e):
657        """ Reimplemented to use the view's current size once shown. """
658
659        self._width = self._height = -1
660
661        QtGui.QMainWindow.showEvent(self, e)
662