Skip to content

Instantly share code, notes, and snippets.

@juancarlospaco
Last active April 6, 2018 19:30
Show Gist options
  • Save juancarlospaco/5f89454441c80ded2820 to your computer and use it in GitHub Desktop.
Save juancarlospaco/5f89454441c80ded2820 to your computer and use it in GitHub Desktop.
Tabs like ChromeOS for Python3 Qt5 with Extras like UnDock/ReDock Tabs, Pin/UnPin Tabs, On Mouse Hover Previews for all Tabs except current Tab, Colored Tabs, Change Position, Change Shape, Mouse Hover Tracking, Add Tab Plus Button, and more.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Custom TabBar and TabWidget.
Tabs like ChromeOS for Python3 Qt5 with Extras like UnDock / ReDock Tabs,
Pin / UnPin Tabs, On Mouse Hover Previews for all Tabs except current Tab,
Colored Tabs, Change Position, Change Shape, Fading Transition effect,
Close all Tabs to the Right, Close all Tabs to the Left, Close all other Tabs,
Mouse Hover Tracking, Add Tab Plus Button, Limit Maximum of Tabs, and more.
"""
from PyQt5.QtCore import QEvent, QTimeLine, QTimer
from PyQt5.QtGui import QBrush, QColor, QCursor, QPainter, QRadialGradient
from PyQt5.QtWidgets import (QColorDialog, QDialog, QInputDialog, QLabel,
QMainWindow, QMenu, QMessageBox, QTabBar,
QTabWidget, QToolButton, QVBoxLayout)
##############################################################################
class FaderWidget(QLabel):
"""Custom Placeholder Fading Widget for tabs on TabWidget."""
def __init__(self, parent):
"""Init class."""
super(FaderWidget, self).__init__(parent)
self.timeline, self.opacity, self.old_pic = QTimeLine(), 1.0, None
self.timeline.valueChanged.connect(self.animate)
self.timeline.finished.connect(self.close)
self.timeline.setDuration(750) # 500 ~ 750 Ms is Ok, Not more.
def paintEvent(self, event):
"""Overloaded paintEvent to set opacity and pic."""
painter = QPainter(self)
painter.setOpacity(self.opacity)
if self.old_pic:
painter.drawPixmap(0, 0, self.old_pic)
def animate(self, value):
"""Animation of Opacity."""
self.opacity = 1.0 - value
return self.hide() if self.opacity < 0.1 else self.repaint()
def fade(self, old_pic, old_geometry, move_to):
"""Fade from previous tab to new tab."""
if self.isVisible():
self.close()
if self.timeline.state():
self.timeline.stop()
self.setGeometry(old_geometry)
self.move(1, move_to)
self.old_pic = old_pic
self.timeline.start()
self.show()
class TabBar(QTabBar):
"""Custom tab bar."""
def __init__(self, parent=None, *args, **kwargs):
"""Init class custom tab bar."""
super(TabBar, self).__init__(parent=None, *args, **kwargs)
self.parent, self.limit = parent, self.count() * 2
self.menu, self.submenu = QMenu("Tab Options"), QMenu("Tabs")
self.tab_previews = True
self.menu.addAction("Tab Menu").setDisabled(True)
self.menu.addSeparator()
self.menu.addAction("Set Tab Text", self.set_text)
self.menu.addAction("Set Tab Color", self.set_color)
self.menu.addAction("Set Limits", self.set_limit)
self.menu.addSeparator()
self.menu.addAction("Change Tab Shape", self.set_shape)
self.menu.addAction("Top or Bottom Position", self.set_position)
self.menu.addAction("Pin or Unpin Tab", self.set_pinned)
self.menu.addAction("Undock Tab", self.make_undock)
self.menu.addAction("Toggle Tabs Previews", self.set_tab_previews)
self.menu.addSeparator()
self.menu.addAction("Close this Tab",
lambda: self.removeTab(self.currentIndex()))
self.menu.addAction("Close all Tabs to the Right",
self.close_all_tabs_to_the_right)
self.menu.addAction("Close all Tabs to the Left",
self.close_all_tabs_to_the_left)
self.menu.addAction("Close all other Tabs", self.close_all_other_tabs)
self.menu.addSeparator()
self.menu.addMenu(self.submenu)
self.menu.aboutToShow.connect(self.build_submenu)
self.tabCloseRequested.connect(
lambda: self.removeTab(self.currentIndex()))
self.setMouseTracking(True)
self.installEventFilter(self)
def eventFilter(self, obj, event):
"""Custom Events Filder for detecting clicks on Tabs."""
if obj == self:
if event.type() == QEvent.MouseMove:
index = self.tabAt(event.pos())
self.setCurrentIndex(index)
return True
else:
return QTabBar.eventFilter(self, obj, event) # False
else:
return QMainWindow.eventFilter(self, obj, event)
def mouseDoubleClickEvent(self, event):
"""Handle double click."""
self.menu.exec_(QCursor.pos())
def set_tab_previews(self):
"""Toggle On/Off the Tabs Previews."""
self.tab_previews = not self.tab_previews
return self.tab_previews
def close_all_tabs_to_the_right(self):
"""Close all tabs to the Right."""
for i in range(self.currentIndex() + 1, self.count()):
if self.count() > 2:
self.removeTab(self.count() - 1)
def close_all_tabs_to_the_left(self):
"""Close all tabs to the Left."""
for i in range(self.currentIndex()):
if self.count() > 2:
self.removeTab(0)
def close_all_other_tabs(self):
"""Close all other tabs."""
self.close_all_tabs_to_the_right()
self.close_all_tabs_to_the_left()
def make_undock(self):
"""Undock Tab from TabWidget to a Dialog,if theres more than 2 Tabs."""
msg = "<b>Needs more than 2 Tabs to allow Un-Dock Tabs !."
return self.parent.make_undock() if self.count(
) > 2 else QMessageBox.warning(self, "Error", msg)
def set_shape(self):
"""Handle set Shape on Tabs."""
self.parent.setTabShape(0 if self.parent.tabShape() else 1)
def set_position(self):
"""Handle set Position on Tabs."""
self.parent.setTabPosition(0 if self.parent.tabPosition() else 1)
def set_text(self):
"""Handle set Text on Tabs."""
text = str(QInputDialog.getText(
self, "Tab Options Dialog", "<b>Type Tab Text:",
text=self.tabText(self.currentIndex()))[0]).strip()[:50]
if text:
self.setTabText(self.currentIndex(), text)
def set_color(self):
"""Handle set Colors on Tabs."""
color = QColorDialog.getColor()
if color:
self.setTabTextColor(self.currentIndex(), color)
def set_pinned(self):
"""Handle Pin and Unpin Tabs."""
index = self.currentIndex()
if self.tabText(index) == "":
self.setTabText(index, self.tabToolTip(index))
self.tabButton(index, 1).show()
else:
self.setTabToolTip(index, self.tabText(index))
self.setTabText(index, "")
self.tabButton(index, 1).hide()
def build_submenu(self):
"""Handle build a sub-menu on the fly with the list of tabs."""
self.submenu.clear()
self.submenu.addAction("Tab list").setDisabled(True)
for index in tuple(range(self.count())):
action = self.submenu.addAction("Tab {0}".format(index + 1))
action.triggered.connect(
lambda _, index=index: self.setCurrentIndex(index))
def set_limit(self):
"""Limit the Maximum number of Tabs that can coexist, TBD by Dev."""
limit = int(QInputDialog.getInt(
self, "Tab Options Dialog", "<b>How many Tabs is the Maximum ?:",
self.count() * 2, self.count() * 2, 99)[0])
if limit:
self.limit = limit
return limit
class TabWidget(QTabWidget):
"""Custom tab widget."""
def __init__(self, parent=None, *args, **kwargs):
"""Init class custom tab widget."""
super(TabWidget, self).__init__(parent=None, *args, **kwargs)
self.parent, self.previews, self.timer = parent, [], QTimer(self)
self.fader, self.previous_pic = FaderWidget(self), None
self.timer.setSingleShot(True)
self.timer.timeout.connect(lambda: [_.close() for _ in self.previews])
self.setTabBar(TabBar(self))
self.setMovable(False)
self.setTabsClosable(True)
self.setTabShape(QTabWidget.Triangular)
self.addtab, self.menu_0 = QToolButton(self), QToolButton(self)
self.addtab.setText(" + ")
self.addtab.setToolTip("<b>Add Tabs")
self.menu_0.setText(" ? ")
self.menu_0.setToolTip("<b>Menu")
font = self.addtab.font()
font.setBold(True)
self.addtab.setFont(font)
self.menu_0.setFont(font)
# self.addtab.clicked.connect(self.addTab)
# self.menu_0.clicked.connect(self.show_menu)
self.setCornerWidget(self.addtab, 1)
self.setCornerWidget(self.menu_0, 0)
self.currentChanged.connect(self.make_tabs_previews)
self.currentChanged.connect(self.make_tabs_fade)
def make_tabs_fade(self, index):
"""Make tabs fading transitions."""
self.fader.fade(
self.previous_pic, self.widget(index).geometry(),
1 if self.tabPosition() else self.tabBar().tabRect(0).height())
self.previous_pic = self.currentWidget().grab()
def make_undock(self):
"""Undock a Tab from TabWidget and promote to a Dialog."""
dialog, index = QDialog(self), self.currentIndex()
widget_from_tab = self.widget(index)
dialog_layout = QVBoxLayout(dialog)
dialog.setWindowTitle(self.tabText(index))
dialog.setToolTip(self.tabToolTip(index))
dialog.setWhatsThis(self.tabWhatsThis(index))
dialog.setWindowIcon(self.tabIcon(index))
dialog.setFont(widget_from_tab.font())
dialog.setStyleSheet(widget_from_tab.styleSheet())
dialog.setMinimumSize(widget_from_tab.minimumSize())
dialog.setMaximumSize(widget_from_tab.maximumSize())
dialog.setGeometry(widget_from_tab.geometry())
def closeEvent_override(event):
"""Re-dock back from Dialog to a new Tab."""
msg = "<b>Close this Floating Tab Window and Re-Dock as a new Tab?"
conditional = QMessageBox.question(
self, "Undocked Tab", msg, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No) == QMessageBox.Yes
if conditional:
index_plus_1 = self.count() + 1
self.insertTab(index_plus_1, widget_from_tab,
dialog.windowIcon(), dialog.windowTitle())
self.setTabToolTip(index_plus_1, dialog.toolTip())
self.setTabWhatsThis(index_plus_1, dialog.whatsThis())
return event.accept()
else:
return event.ignore()
dialog.closeEvent = closeEvent_override
self.removeTab(index)
widget_from_tab.setParent(self.parent if self.parent else dialog)
dialog_layout.addWidget(widget_from_tab)
dialog.setLayout(dialog_layout)
widget_from_tab.show()
dialog.show() # exec_() for modal dialog, show() for non-modal dialog
dialog.move(QCursor.pos())
def make_tabs_previews(self, index):
"""Make Tabs Previews for all tabs except current, if > 3 Tabs."""
if self.count() < 5 or not self.tabBar().tab_previews:
return False # At least 4Tabs to use preview,and should be Enabled
if self.timer.isActive(): # Be Race Condition Safe
self.timer.stop()
for old_widget in self.previews:
old_widget.close() # Visually Hide the Previews closing it
old_widget.setParent(None) # Orphan the old previews
old_widget.destroy() # Destroy to Free Resources
self.previews = [QLabel(self) for i in range(self.count())] # New Ones
y_pos = self.size().height() - self.tabBar().tabRect(0).size().height()
for i, widget in enumerate(self.previews): # Iterate,set QPixmaps,Show
if i != index: # Dont make a pointless preview for the current Tab
widget.setScaledContents(True) # Auto-Scale QPixmap contents
tabwidth = self.tabBar().tabRect(i).size().width()
tabwidth = 200 if tabwidth > 200 else tabwidth # Limit sizes
widget.setPixmap(self.widget(i).grab().scaledToWidth(tabwidth))
widget.resize(tabwidth - 1, tabwidth)
if self.tabPosition(): # Move based on Top / Bottom positions
widget.move(self.tabBar().tabRect(i).left() * 1.1,
y_pos - tabwidth - 3)
else:
widget.move(self.tabBar().tabRect(i).bottomLeft() * 1.1)
widget.show()
self.timer.start(1000) # how many time display the previews
return True
##############################################################################
if __name__ in '__main__':
from PyQt5.QtWidgets import QApplication, QCalendarWidget
app = QApplication([])
gui = TabWidget()
for i in range(9):
gui.addTab(QLabel("<center><h1 style='color:red'>Tab {0} !".format(i))
if i % 2 else QCalendarWidget(), " Tab {0} ! ".format(i))
gui.show()
exit(app.exec_())
@juancarlospaco
Copy link
Author

😾

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment