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# ------------------------------------------------------------------------------
17
18
19import sys
20
21
22from pyface.qt import QtCore, QtGui, qt_api
23
24from pyface.image_resource import ImageResource
25
26
27class SplitTabWidget(QtGui.QSplitter):
28    """ The SplitTabWidget class is a hierarchy of QSplitters the leaves of
29    which are QTabWidgets.  Any tab may be moved around with the hierarchy
30    automatically extended and reduced as required.
31    """
32
33    # Signals for WorkbenchWindowLayout to handle
34    new_window_request = QtCore.Signal(QtCore.QPoint, QtGui.QWidget)
35    tab_close_request = QtCore.Signal(QtGui.QWidget)
36    tab_window_changed = QtCore.Signal(QtGui.QWidget)
37    editor_has_focus = QtCore.Signal(QtGui.QWidget)
38    focus_changed = QtCore.Signal(QtGui.QWidget, QtGui.QWidget)
39
40    # The different hotspots of a QTabWidget.  An non-negative value is a tab
41    # index and the hotspot is to the left of it.
42
43    tabTextChanged = QtCore.Signal(QtGui.QWidget, str)
44    _HS_NONE = -1
45    _HS_AFTER_LAST_TAB = -2
46    _HS_NORTH = -3
47    _HS_SOUTH = -4
48    _HS_EAST = -5
49    _HS_WEST = -6
50    _HS_OUTSIDE = -7
51
52    def __init__(self, *args):
53        """ Initialise the instance. """
54
55        QtGui.QSplitter.__init__(self, *args)
56
57        self.clear()
58
59        QtGui.QApplication.instance().focusChanged.connect(self._focus_changed)
60
61    def clear(self):
62        """ Restore the widget to its pristine state. """
63
64        w = None
65        for i in range(self.count()):
66            w = self.widget(i)
67            w.hide()
68            w.deleteLater()
69        del w
70
71        self._repeat_focus_changes = True
72        self._rband = None
73        self._selected_tab_widget = None
74        self._selected_hotspot = self._HS_NONE
75
76        self._current_tab_w = None
77        self._current_tab_idx = -1
78
79    def saveState(self):
80        """ Returns a Python object containing the saved state of the widget.
81        Widgets are saved only by their object name.
82        """
83
84        return self._save_qsplitter(self)
85
86    def _save_qsplitter(self, qsplitter):
87        # A splitter state is a tuple of the QSplitter state (as a string) and
88        # the list of child states.
89        sp_ch_states = []
90
91        # Save the children.
92        for i in range(qsplitter.count()):
93            ch = qsplitter.widget(i)
94
95            if isinstance(ch, _TabWidget):
96                # A tab widget state is a tuple of the current tab index and
97                # the list of individual tab states.
98                tab_states = []
99
100                for t in range(ch.count()):
101                    # A tab state is a tuple of the widget's object name and
102                    # the title.
103                    name = str(ch.widget(t).objectName())
104                    title = str(ch.tabText(t))
105
106                    tab_states.append((name, title))
107
108                ch_state = (ch.currentIndex(), tab_states)
109            else:
110                # Recurse down the tree of splitters.
111                ch_state = self._save_qsplitter(ch)
112
113            sp_ch_states.append(ch_state)
114
115        return (QtGui.QSplitter.saveState(qsplitter).data(), sp_ch_states)
116
117    def restoreState(self, state, factory):
118        """ Restore the contents from the given state (returned by a previous
119        call to saveState()).  factory is a callable that is passed the object
120        name of the widget that is in the state and needs to be restored.  The
121        callable returns the restored widget.
122        """
123
124        # Ensure we are not restoring to a non-empty widget.
125        assert self.count() == 0
126
127        self._restore_qsplitter(state, factory, self)
128
129    def _restore_qsplitter(self, state, factory, qsplitter):
130        sp_qstate, sp_ch_states = state
131
132        # Go through each child state which will consist of a tuple of two
133        # objects.  We use the type of the first to determine if the child is a
134        # tab widget or another splitter.
135        for ch_state in sp_ch_states:
136            if isinstance(ch_state[0], int):
137                current_idx, tabs = ch_state
138
139                new_tab = _TabWidget(self)
140
141                # Go through each tab and use the factory to restore the page.
142                for name, title in tabs:
143                    page = factory(name)
144
145                    if page is not None:
146                        new_tab.addTab(page, title)
147
148                # Only add the new tab widget if it is used.
149                if new_tab.count() > 0:
150                    qsplitter.addWidget(new_tab)
151
152                    # Set the correct tab as the current one.
153                    new_tab.setCurrentIndex(current_idx)
154                else:
155                    del new_tab
156            else:
157                new_qsp = QtGui.QSplitter()
158
159                # Recurse down the tree of splitters.
160                self._restore_qsplitter(ch_state, factory, new_qsp)
161
162                # Only add the new splitter if it is used.
163                if new_qsp.count() > 0:
164                    qsplitter.addWidget(new_qsp)
165                else:
166                    del new_qsp
167
168        # Restore the QSplitter state (being careful to get the right
169        # implementation).
170        QtGui.QSplitter.restoreState(qsplitter, sp_qstate)
171
172    def addTab(self, w, text):
173        """ Add a new tab to the main tab widget. """
174
175        # Find the first tab widget going down the left of the hierarchy.  This
176        # will be the one in the top left corner.
177        if self.count() > 0:
178            ch = self.widget(0)
179
180            while not isinstance(ch, _TabWidget):
181                assert isinstance(ch, QtGui.QSplitter)
182                ch = ch.widget(0)
183        else:
184            # There is no tab widget so create one.
185            ch = _TabWidget(self)
186            self.addWidget(ch)
187
188        idx = ch.insertTab(self._current_tab_idx + 1, w, text)
189
190        # If the tab has been added to the current tab widget then make it the
191        # current tab.
192        if ch is not self._current_tab_w:
193            self._set_current_tab(ch, idx)
194            ch.tabBar().setFocus()
195
196    def _close_tab_request(self, w):
197        """ A close button was clicked in one of out _TabWidgets """
198
199        self.tab_close_request.emit(w)
200
201    def setCurrentWidget(self, w):
202        """ Make the given widget current. """
203
204        tw, tidx = self._tab_widget(w)
205
206        if tw is not None:
207            self._set_current_tab(tw, tidx)
208
209    def setActiveIcon(self, w, icon=None):
210        """ Set the active icon on a widget. """
211
212        tw, tidx = self._tab_widget(w)
213
214        if tw is not None:
215            if icon is None:
216                icon = tw.active_icon()
217
218            tw.setTabIcon(tidx, icon)
219
220    def setTabTextColor(self, w, color=None):
221        """ Set the tab text color on a particular widget w
222        """
223        tw, tidx = self._tab_widget(w)
224
225        if tw is not None:
226            if color is None:
227                # null color reverts to foreground role color
228                color = QtGui.QColor()
229            tw.tabBar().setTabTextColor(tidx, color)
230
231    def setWidgetTitle(self, w, title):
232        """ Set the title for the given widget. """
233
234        tw, idx = self._tab_widget(w)
235
236        if tw is not None:
237            tw.setTabText(idx, title)
238
239    def _tab_widget(self, w):
240        """ Return the tab widget and index containing the given widget. """
241
242        for tw in self.findChildren(_TabWidget, None):
243            idx = tw.indexOf(w)
244
245            if idx >= 0:
246                return (tw, idx)
247
248        return (None, None)
249
250    def _set_current_tab(self, tw, tidx):
251        """ Set the new current tab. """
252
253        # Handle the trivial case.
254        if self._current_tab_w is tw and self._current_tab_idx == tidx:
255            return
256
257        if tw is not None:
258            tw.setCurrentIndex(tidx)
259
260        # Save the new current widget.
261        self._current_tab_w = tw
262        self._current_tab_idx = tidx
263
264    def _set_focus(self):
265        """ Set the focus to an appropriate widget in the current tab. """
266
267        # Only try and change the focus if the current focus isn't already a
268        # child of the widget.
269        w = self._current_tab_w.widget(self._current_tab_idx)
270        fw = self.window().focusWidget()
271
272        if fw is not None and not w.isAncestorOf(fw):
273            # Find a widget to focus using the same method as
274            # QStackedLayout::setCurrentIndex().  First try the last widget
275            # with the focus.
276            nfw = w.focusWidget()
277
278            if nfw is None:
279                # Next, try the first child widget in the focus chain.
280                nfw = fw.nextInFocusChain()
281
282                while nfw is not fw:
283                    if (
284                        nfw.focusPolicy() & QtCore.Qt.TabFocus
285                        and nfw.focusProxy() is None
286                        and nfw.isVisibleTo(w)
287                        and nfw.isEnabled()
288                        and w.isAncestorOf(nfw)
289                    ):
290                        break
291
292                    nfw = nfw.nextInFocusChain()
293                else:
294                    # Fallback to the tab page widget.
295                    nfw = w
296
297            nfw.setFocus()
298
299    def _focus_changed(self, old, new):
300        """ Handle a change in focus that affects the current tab. """
301
302        # It is possible for the C++ layer of this object to be deleted between
303        # the time when the focus change signal is emitted and time when the
304        # slots are dispatched by the Qt event loop. This may be a bug in PyQt4.
305        if qt_api == "pyqt":
306            import sip
307
308            if sip.isdeleted(self):
309                return
310
311        if self._repeat_focus_changes:
312            self.focus_changed.emit(old, new)
313
314        if new is None:
315            return
316        elif isinstance(new, _DragableTabBar):
317            ntw = new.parent()
318            ntidx = ntw.currentIndex()
319        else:
320            ntw, ntidx = self._tab_widget_of(new)
321
322        if ntw is not None:
323            self._set_current_tab(ntw, ntidx)
324
325        # See if the widget that has lost the focus is ours.
326        otw, _ = self._tab_widget_of(old)
327
328        if otw is not None or ntw is not None:
329            if ntw is None:
330                nw = None
331            else:
332                nw = ntw.widget(ntidx)
333
334            self.editor_has_focus.emit(nw)
335
336    def _tab_widget_of(self, target):
337        """ Return the tab widget and index of the widget that contains the
338        given widget.
339        """
340
341        for tw in self.findChildren(_TabWidget, None):
342            for tidx in range(tw.count()):
343                w = tw.widget(tidx)
344
345                if w is not None and w.isAncestorOf(target):
346                    return (tw, tidx)
347
348        return (None, None)
349
350    def _move_left(self, tw, tidx):
351        """ Move the current tab to the one logically to the left. """
352
353        tidx -= 1
354
355        if tidx < 0:
356            # Find the tab widget logically to the left.
357            twlist = self.findChildren(_TabWidget, None)
358            i = twlist.index(tw)
359            i -= 1
360
361            if i < 0:
362                i = len(twlist) - 1
363
364            tw = twlist[i]
365
366            # Move the to right most tab.
367            tidx = tw.count() - 1
368
369        self._set_current_tab(tw, tidx)
370        tw.setFocus()
371
372    def _move_right(self, tw, tidx):
373        """ Move the current tab to the one logically to the right. """
374
375        tidx += 1
376
377        if tidx >= tw.count():
378            # Find the tab widget logically to the right.
379            twlist = self.findChildren(_TabWidget, None)
380            i = twlist.index(tw)
381            i += 1
382
383            if i >= len(twlist):
384                i = 0
385
386            tw = twlist[i]
387
388            # Move the to left most tab.
389            tidx = 0
390
391        self._set_current_tab(tw, tidx)
392        tw.setFocus()
393
394    def _select(self, pos):
395        tw, hs, hs_geom = self._hotspot(pos)
396
397        # See if the hotspot has changed.
398        if self._selected_tab_widget is not tw or self._selected_hotspot != hs:
399            if self._selected_tab_widget is not None:
400                self._rband.hide()
401
402            if tw is not None and hs != self._HS_NONE:
403                if self._rband:
404                    self._rband.deleteLater()
405                position = QtCore.QPoint(*hs_geom[0:2])
406                window = tw.window()
407                self._rband = QtGui.QRubberBand(
408                    QtGui.QRubberBand.Rectangle, window
409                )
410                self._rband.move(window.mapFromGlobal(position))
411                self._rband.resize(*hs_geom[2:4])
412                self._rband.show()
413
414            self._selected_tab_widget = tw
415            self._selected_hotspot = hs
416
417    def _drop(self, pos, stab_w, stab):
418        self._rband.hide()
419
420        # Get the destination locations.
421        dtab_w = self._selected_tab_widget
422        dhs = self._selected_hotspot
423        if dhs == self._HS_NONE:
424            return
425        elif dhs != self._HS_OUTSIDE:
426            dsplit_w = dtab_w.parent()
427            while not isinstance(dsplit_w, SplitTabWidget):
428                dsplit_w = dsplit_w.parent()
429
430        self._selected_tab_widget = None
431        self._selected_hotspot = self._HS_NONE
432
433        # See if the tab is being moved to a new window.
434        if dhs == self._HS_OUTSIDE:
435            # Disable tab tear-out for now. It works, but this is something that
436            # should be turned on manually. We need an interface for this.
437            # ticon, ttext, ttextcolor, tbuttn, twidg = self._remove_tab(stab_w, stab)
438            # self.new_window_request.emit(pos, twidg)
439            return
440
441        # See if the tab is being moved to an existing tab widget.
442        if dhs >= 0 or dhs == self._HS_AFTER_LAST_TAB:
443            # Make sure it really is being moved.
444            if stab_w is dtab_w:
445                if stab == dhs:
446                    return
447
448                if (
449                    dhs == self._HS_AFTER_LAST_TAB
450                    and stab == stab_w.count() - 1
451                ):
452                    return
453
454            QtGui.QApplication.instance().blockSignals(True)
455
456            ticon, ttext, ttextcolor, tbuttn, twidg = self._remove_tab(
457                stab_w, stab
458            )
459
460            if dhs == self._HS_AFTER_LAST_TAB:
461                idx = dtab_w.addTab(twidg, ticon, ttext)
462                dtab_w.tabBar().setTabTextColor(idx, ttextcolor)
463            elif dtab_w is stab_w:
464                # Adjust the index if necessary in case the removal of the tab
465                # from its old position has skewed things.
466                dst = dhs
467
468                if dhs > stab:
469                    dst -= 1
470
471                idx = dtab_w.insertTab(dst, twidg, ticon, ttext)
472                dtab_w.tabBar().setTabTextColor(idx, ttextcolor)
473            else:
474                idx = dtab_w.insertTab(dhs, twidg, ticon, ttext)
475                dtab_w.tabBar().setTabTextColor(idx, ttextcolor)
476
477            if tbuttn:
478                dtab_w.show_button(idx)
479            dsplit_w._set_current_tab(dtab_w, idx)
480
481        else:
482            # Ignore drops to the same tab widget when it only has one tab.
483            if stab_w is dtab_w and stab_w.count() == 1:
484                return
485
486            QtGui.QApplication.instance().blockSignals(True)
487
488            # Remove the tab from its current tab widget and create a new one
489            # for it.
490            ticon, ttext, ttextcolor, tbuttn, twidg = self._remove_tab(
491                stab_w, stab
492            )
493            new_tw = _TabWidget(dsplit_w)
494            idx = new_tw.addTab(twidg, ticon, ttext)
495            new_tw.tabBar().setTabTextColor(0, ttextcolor)
496            if tbuttn:
497                new_tw.show_button(idx)
498
499            # Get the splitter containing the destination tab widget.
500            dspl = dtab_w.parent()
501            dspl_idx = dspl.indexOf(dtab_w)
502
503            if dhs in (self._HS_NORTH, self._HS_SOUTH):
504                dspl, dspl_idx = dsplit_w._horizontal_split(
505                    dspl, dspl_idx, dhs
506                )
507            else:
508                dspl, dspl_idx = dsplit_w._vertical_split(dspl, dspl_idx, dhs)
509
510            # Add the new tab widget in the right place.
511            dspl.insertWidget(dspl_idx, new_tw)
512
513            dsplit_w._set_current_tab(new_tw, 0)
514
515        dsplit_w._set_focus()
516
517        # Signal that the tab's SplitTabWidget has changed, if necessary.
518        if dsplit_w != self:
519            self.tab_window_changed.emit(twidg)
520
521        QtGui.QApplication.instance().blockSignals(False)
522
523    def _horizontal_split(self, spl, idx, hs):
524        """ Returns a tuple of the splitter and index where the new tab widget
525        should be put.
526        """
527
528        if spl.orientation() == QtCore.Qt.Vertical:
529            if hs == self._HS_SOUTH:
530                idx += 1
531        elif spl is self and spl.count() == 1:
532            # The splitter is the root and only has one child so we can just
533            # change its orientation.
534            spl.setOrientation(QtCore.Qt.Vertical)
535
536            if hs == self._HS_SOUTH:
537                idx = -1
538        else:
539            new_spl = QtGui.QSplitter(QtCore.Qt.Vertical)
540            new_spl.addWidget(spl.widget(idx))
541            spl.insertWidget(idx, new_spl)
542
543            if hs == self._HS_SOUTH:
544                idx = -1
545            else:
546                idx = 0
547
548            spl = new_spl
549
550        return (spl, idx)
551
552    def _vertical_split(self, spl, idx, hs):
553        """ Returns a tuple of the splitter and index where the new tab widget
554        should be put.
555        """
556
557        if spl.orientation() == QtCore.Qt.Horizontal:
558            if hs == self._HS_EAST:
559                idx += 1
560        elif spl is self and spl.count() == 1:
561            # The splitter is the root and only has one child so we can just
562            # change its orientation.
563            spl.setOrientation(QtCore.Qt.Horizontal)
564
565            if hs == self._HS_EAST:
566                idx = -1
567        else:
568            new_spl = QtGui.QSplitter(QtCore.Qt.Horizontal)
569            new_spl.addWidget(spl.widget(idx))
570            spl.insertWidget(idx, new_spl)
571
572            if hs == self._HS_EAST:
573                idx = -1
574            else:
575                idx = 0
576
577            spl = new_spl
578
579        return (spl, idx)
580
581    def _remove_tab(self, tab_w, tab):
582        """ Remove a tab from a tab widget and return a tuple of the icon,
583        label text and the widget so that it can be recreated.
584        """
585
586        icon = tab_w.tabIcon(tab)
587        text = tab_w.tabText(tab)
588        text_color = tab_w.tabBar().tabTextColor(tab)
589        button = tab_w.tabBar().tabButton(tab, QtGui.QTabBar.LeftSide)
590        w = tab_w.widget(tab)
591        tab_w.removeTab(tab)
592
593        return (icon, text, text_color, button, w)
594
595    def _hotspot(self, pos):
596        """ Return a tuple of the tab widget, hotspot and hostspot geometry (as
597        a tuple) at the given position.
598        """
599        global_pos = self.mapToGlobal(pos)
600        miss = (None, self._HS_NONE, None)
601
602        # Get the bounding rect of the cloned QTbarBar.
603        top_widget = QtGui.QApplication.instance().topLevelAt(global_pos)
604        if isinstance(top_widget, QtGui.QTabBar):
605            cloned_rect = top_widget.frameGeometry()
606        else:
607            cloned_rect = None
608
609        # Determine which visible SplitTabWidget, if any, is under the cursor
610        # (compensating for the cloned QTabBar that may be rendered over it).
611        split_widget = None
612        for top_widget in QtGui.QApplication.instance().topLevelWidgets():
613            for split_widget in top_widget.findChildren(SplitTabWidget, None):
614                visible_region = split_widget.visibleRegion()
615                widget_pos = split_widget.mapFromGlobal(global_pos)
616                if cloned_rect and split_widget.geometry().contains(
617                    widget_pos
618                ):
619                    visible_rect = visible_region.boundingRect()
620                    widget_rect = QtCore.QRect(
621                        split_widget.mapFromGlobal(cloned_rect.topLeft()),
622                        split_widget.mapFromGlobal(cloned_rect.bottomRight()),
623                    )
624                    if not visible_rect.intersected(widget_rect).isEmpty():
625                        break
626                elif visible_region.contains(widget_pos):
627                    break
628            else:
629                split_widget = None
630            if split_widget:
631                break
632
633        # Handle a drag outside of any split tab widget.
634        if not split_widget:
635            if self.window().frameGeometry().contains(global_pos):
636                return miss
637            else:
638                return (None, self._HS_OUTSIDE, None)
639
640        # Go through each tab widget.
641        pos = split_widget.mapFromGlobal(global_pos)
642        for tw in split_widget.findChildren(_TabWidget, None):
643            if tw.geometry().contains(tw.parent().mapFrom(split_widget, pos)):
644                break
645        else:
646            return miss
647
648        # See if the hotspot is in the widget area.
649        widg = tw.currentWidget()
650        if widg is not None:
651
652            # Get the widget's position relative to its parent.
653            wpos = widg.parent().mapFrom(split_widget, pos)
654
655            if widg.geometry().contains(wpos):
656                # Get the position of the widget relative to itself (ie. the
657                # top left corner is (0, 0)).
658                p = widg.mapFromParent(wpos)
659                x = p.x()
660                y = p.y()
661                h = widg.height()
662                w = widg.width()
663
664                # Get the global position of the widget.
665                gpos = widg.mapToGlobal(widg.pos())
666                gx = gpos.x()
667                gy = gpos.y()
668
669                # The corners of the widget belong to the north and south
670                # sides.
671                if y < h / 4:
672                    return (tw, self._HS_NORTH, (gx, gy, w, h / 4))
673
674                if y >= (3 * h) / 4:
675                    return (
676                        tw,
677                        self._HS_SOUTH,
678                        (gx, gy + (3 * h) / 4, w, h / 4),
679                    )
680
681                if x < w / 4:
682                    return (tw, self._HS_WEST, (gx, gy, w / 4, h))
683
684                if x >= (3 * w) / 4:
685                    return (
686                        tw,
687                        self._HS_EAST,
688                        (gx + (3 * w) / 4, gy, w / 4, h),
689                    )
690
691                return miss
692
693        # See if the hotspot is in the tab area.
694        tpos = tw.mapFrom(split_widget, pos)
695        tab_bar = tw.tabBar()
696        top_bottom = tw.tabPosition() in (
697            QtGui.QTabWidget.North,
698            QtGui.QTabWidget.South,
699        )
700        for i in range(tw.count()):
701            rect = tab_bar.tabRect(i)
702
703            if rect.contains(tpos):
704                w = rect.width()
705                h = rect.height()
706
707                # Get the global position.
708                gpos = tab_bar.mapToGlobal(rect.topLeft())
709                gx = gpos.x()
710                gy = gpos.y()
711
712                if top_bottom:
713                    off = pos.x() - rect.x()
714                    ext = w
715                    gx -= w / 2
716                else:
717                    off = pos.y() - rect.y()
718                    ext = h
719                    gy -= h / 2
720
721                # See if it is in the left (or top) half or the right (or
722                # bottom) half.
723                if off < ext / 2:
724                    return (tw, i, (gx, gy, w, h))
725
726                if top_bottom:
727                    gx += w
728                else:
729                    gy += h
730
731                if i + 1 == tw.count():
732                    return (tw, self._HS_AFTER_LAST_TAB, (gx, gy, w, h))
733
734                return (tw, i + 1, (gx, gy, w, h))
735        else:
736            rect = tab_bar.rect()
737            if rect.contains(tpos):
738                gpos = tab_bar.mapToGlobal(rect.topLeft())
739                gx = gpos.x()
740                gy = gpos.y()
741                w = rect.width()
742                h = rect.height()
743                if top_bottom:
744                    tab_widths = sum(
745                        tab_bar.tabRect(i).width()
746                        for i in range(tab_bar.count())
747                    )
748                    w -= tab_widths
749                    gx += tab_widths
750                else:
751                    tab_heights = sum(
752                        tab_bar.tabRect(i).height()
753                        for i in range(tab_bar.count())
754                    )
755                    h -= tab_heights
756                    gy -= tab_heights
757                return (tw, self._HS_AFTER_LAST_TAB, (gx, gy, w, h))
758
759        return miss
760
761
762active_style = """QTabWidget::pane { /* The tab widget frame */
763     border: 2px solid #00FF00;
764 }
765"""
766inactive_style = """QTabWidget::pane { /* The tab widget frame */
767     border: 2px solid #C2C7CB;
768     margin: 0px;
769 }
770"""
771
772
773class _TabWidget(QtGui.QTabWidget):
774    """ The _TabWidget class is a QTabWidget with a dragable tab bar. """
775
776    # The active icon.  It is created when it is first needed.
777    _active_icon = None
778
779    _spinner_data = None
780
781    def __init__(self, root, *args):
782        """ Initialise the instance. """
783
784        QtGui.QTabWidget.__init__(self, *args)
785
786        # XXX this requires Qt > 4.5
787        if sys.platform == "darwin":
788            self.setDocumentMode(True)
789        # self.setStyleSheet(inactive_style)
790
791        self._root = root
792
793        # We explicitly pass the parent to the tab bar ctor to work round a bug
794        # in PyQt v4.2 and earlier.
795        self.setTabBar(_DragableTabBar(self._root, self))
796
797        self.setTabsClosable(True)
798        self.tabCloseRequested.connect(self._close_tab)
799
800        if not (_TabWidget._spinner_data):
801            _TabWidget._spinner_data = ImageResource("spinner.gif")
802
803    def show_button(self, index):
804        lbl = QtGui.QLabel(self)
805        movie = QtGui.QMovie(
806            _TabWidget._spinner_data.absolute_path, parent=lbl
807        )
808        movie.setCacheMode(QtGui.QMovie.CacheAll)
809        movie.setScaledSize(QtCore.QSize(16, 16))
810        lbl.setMovie(movie)
811        movie.start()
812        self.tabBar().setTabButton(index, QtGui.QTabBar.LeftSide, lbl)
813
814    def hide_button(self, index):
815        curr = self.tabBar().tabButton(index, QtGui.QTabBar.LeftSide)
816        if curr:
817            curr.close()
818            self.tabBar().setTabButton(index, QtGui.QTabBar.LeftSide, None)
819
820    def active_icon(self):
821        """ Return the QIcon to be used to indicate an active tab page. """
822
823        if _TabWidget._active_icon is None:
824            # The gradient start and stop colours.
825            start = QtGui.QColor(0, 255, 0)
826            stop = QtGui.QColor(0, 63, 0)
827
828            size = self.iconSize()
829            width = size.width()
830            height = size.height()
831
832            pm = QtGui.QPixmap(size)
833
834            p = QtGui.QPainter()
835            p.begin(pm)
836
837            # Fill the image background from the tab background.
838            p.initFrom(self.tabBar())
839            p.fillRect(0, 0, width, height, p.background())
840
841            # Create the colour gradient.
842            rg = QtGui.QRadialGradient(width / 2, height / 2, width)
843            rg.setColorAt(0.0, start)
844            rg.setColorAt(1.0, stop)
845
846            # Draw the circle.
847            p.setBrush(rg)
848            p.setPen(QtCore.Qt.NoPen)
849            p.setRenderHint(QtGui.QPainter.Antialiasing)
850            p.drawEllipse(0, 0, width, height)
851
852            p.end()
853
854            _TabWidget._active_icon = QtGui.QIcon(pm)
855
856        return _TabWidget._active_icon
857
858    def _still_needed(self):
859        """ Delete the tab widget (and any relevant parent splitters) if it is
860        no longer needed.
861        """
862
863        if self.count() == 0:
864            prune = self
865            parent = prune.parent()
866
867            # Go up the QSplitter hierarchy until we find one with at least one
868            # sibling.
869            while parent is not self._root and parent.count() == 1:
870                prune = parent
871                parent = prune.parent()
872
873            prune.hide()
874            prune.deleteLater()
875
876    def tabRemoved(self, idx):
877        """ Reimplemented to update the record of the current tab if it is
878        removed.
879        """
880
881        self._still_needed()
882
883        if (
884            self._root._current_tab_w is self
885            and self._root._current_tab_idx == idx
886        ):
887            self._root._current_tab_w = None
888
889    def _close_tab(self, index):
890        """ Close the current tab. """
891
892        self._root._close_tab_request(self.widget(index))
893
894
895class _IndependentLineEdit(QtGui.QLineEdit):
896    def keyPressEvent(self, e):
897        QtGui.QLineEdit.keyPressEvent(self, e)
898        if e.key() == QtCore.Qt.Key_Escape:
899            self.hide()
900
901
902class _DragableTabBar(QtGui.QTabBar):
903    """ The _DragableTabBar class is a QTabBar that can be dragged around. """
904
905    def __init__(self, root, parent):
906        """ Initialise the instance. """
907
908        QtGui.QTabBar.__init__(self, parent)
909
910        # XXX this requires Qt > 4.5
911        if sys.platform == "darwin":
912            self.setDocumentMode(True)
913
914        self._root = root
915        self._drag_state = None
916        # LineEdit to change tab bar title
917        te = _IndependentLineEdit("", self)
918        te.hide()
919        te.editingFinished.connect(te.hide)
920        te.returnPressed.connect(self._setCurrentTabText)
921        self._title_edit = te
922
923    def resizeEvent(self, e):
924        # resize edit tab
925        if self._title_edit.isVisible():
926            self._resize_title_edit_to_current_tab()
927        QtGui.QTabBar.resizeEvent(self, e)
928
929    def keyPressEvent(self, e):
930        """ Reimplemented to handle traversal across different tab widgets. """
931
932        if e.key() == QtCore.Qt.Key_Left:
933            self._root._move_left(self.parent(), self.currentIndex())
934        elif e.key() == QtCore.Qt.Key_Right:
935            self._root._move_right(self.parent(), self.currentIndex())
936        else:
937            e.ignore()
938
939    def mouseDoubleClickEvent(self, e):
940        self._resize_title_edit_to_current_tab()
941        te = self._title_edit
942        te.setText(self.tabText(self.currentIndex())[1:])
943        te.setFocus()
944        te.selectAll()
945        te.show()
946
947    def mousePressEvent(self, e):
948        """ Reimplemented to handle mouse press events. """
949
950        # There is something odd in the focus handling where focus temporarily
951        # moves elsewhere (actually to a View) when switching to a different
952        # tab page.  We suppress the notification so that the workbench doesn't
953        # temporarily make the View active.
954        self._root._repeat_focus_changes = False
955        QtGui.QTabBar.mousePressEvent(self, e)
956        self._root._repeat_focus_changes = True
957
958        # Update the current tab.
959        self._root._set_current_tab(self.parent(), self.currentIndex())
960        self._root._set_focus()
961
962        if e.button() != QtCore.Qt.LeftButton:
963            return
964
965        if self._drag_state is not None:
966            return
967
968        # Potentially start dragging if the tab under the mouse is the current
969        # one (which will eliminate disabled tabs).
970        tab = self._tab_at(e.pos())
971
972        if tab < 0 or tab != self.currentIndex():
973            return
974
975        self._drag_state = _DragState(self._root, self, tab, e.pos())
976
977    def mouseMoveEvent(self, e):
978        """ Reimplemented to handle mouse move events. """
979
980        QtGui.QTabBar.mouseMoveEvent(self, e)
981
982        if self._drag_state is None:
983            return
984
985        if self._drag_state.dragging:
986            self._drag_state.drag(e.pos())
987        else:
988            self._drag_state.start_dragging(e.pos())
989
990            # If the mouse has moved far enough that dragging has started then
991            # tell the user.
992            if self._drag_state.dragging:
993                QtGui.QApplication.setOverrideCursor(QtCore.Qt.OpenHandCursor)
994
995    def mouseReleaseEvent(self, e):
996        """ Reimplemented to handle mouse release events. """
997
998        QtGui.QTabBar.mouseReleaseEvent(self, e)
999
1000        if e.button() != QtCore.Qt.LeftButton:
1001            if e.button() == QtCore.Qt.MidButton:
1002                self.tabCloseRequested.emit(self.tabAt(e.pos()))
1003            return
1004
1005        if self._drag_state is not None and self._drag_state.dragging:
1006            QtGui.QApplication.restoreOverrideCursor()
1007            self._drag_state.drop(e.pos())
1008
1009        self._drag_state = None
1010
1011    def _tab_at(self, pos):
1012        """ Return the index of the tab at the given point. """
1013
1014        for i in range(self.count()):
1015            if self.tabRect(i).contains(pos):
1016                return i
1017
1018        return -1
1019
1020    def _setCurrentTabText(self):
1021        idx = self.currentIndex()
1022        text = self._title_edit.text()
1023        self.setTabText(idx, "\u25b6" + text)
1024        self._root.tabTextChanged.emit(self.parent().widget(idx), text)
1025
1026    def _resize_title_edit_to_current_tab(self):
1027        idx = self.currentIndex()
1028        tab = QtGui.QStyleOptionTabV3()
1029        self.initStyleOption(tab, idx)
1030        rect = self.style().subElementRect(QtGui.QStyle.SE_TabBarTabText, tab)
1031        self._title_edit.setGeometry(rect.adjusted(0, 8, 0, -8))
1032
1033
1034class _DragState(object):
1035    """ The _DragState class handles most of the work when dragging a tab. """
1036
1037    def __init__(self, root, tab_bar, tab, start_pos):
1038        """ Initialise the instance. """
1039
1040        self.dragging = False
1041
1042        self._root = root
1043        self._tab_bar = tab_bar
1044        self._tab = tab
1045        self._start_pos = QtCore.QPoint(start_pos)
1046        self._clone = None
1047
1048    def start_dragging(self, pos):
1049        """ Start dragging a tab. """
1050
1051        if (
1052            pos - self._start_pos
1053        ).manhattanLength() <= QtGui.QApplication.startDragDistance():
1054            return
1055
1056        self.dragging = True
1057
1058        # Create a clone of the tab being moved (except for its icon).
1059        otb = self._tab_bar
1060        tab = self._tab
1061
1062        ctb = self._clone = QtGui.QTabBar()
1063        if sys.platform == "darwin" and QtCore.QT_VERSION >= 0x40500:
1064            ctb.setDocumentMode(True)
1065
1066        ctb.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
1067        ctb.setWindowFlags(
1068            QtCore.Qt.FramelessWindowHint
1069            | QtCore.Qt.Tool
1070            | QtCore.Qt.X11BypassWindowManagerHint
1071        )
1072        ctb.setWindowOpacity(0.5)
1073        ctb.setElideMode(otb.elideMode())
1074        ctb.setShape(otb.shape())
1075
1076        ctb.addTab(otb.tabText(tab))
1077        ctb.setTabTextColor(0, otb.tabTextColor(tab))
1078
1079        # The clone offset is the position of the clone relative to the mouse.
1080        trect = otb.tabRect(tab)
1081        self._clone_offset = trect.topLeft() - pos
1082
1083        # The centre offset is the position of the center of the clone relative
1084        # to the mouse.  The center of the clone determines the hotspot, not
1085        # the position of the mouse.
1086        self._centre_offset = trect.center() - pos
1087
1088        self.drag(pos)
1089
1090        ctb.show()
1091
1092    def drag(self, pos):
1093        """ Handle the movement of the cloned tab during dragging. """
1094
1095        self._clone.move(self._tab_bar.mapToGlobal(pos) + self._clone_offset)
1096        self._root._select(
1097            self._tab_bar.mapTo(self._root, pos + self._centre_offset)
1098        )
1099
1100    def drop(self, pos):
1101        """ Handle the drop of the cloned tab. """
1102
1103        self.drag(pos)
1104        self._clone = None
1105
1106        global_pos = self._tab_bar.mapToGlobal(pos)
1107        self._root._drop(global_pos, self._tab_bar.parent(), self._tab)
1108
1109        self.dragging = False
1110