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