I have QLineEdit in which I wanted to add a clear button at the end of it. I enabled clear button in QLineEdit, it was working fine. I need to add a custom clear button at the end of the QLineEdit, so I used addAction() of QLineEdit and added my custom icon. The problem is that I can't find a solution to increase the size, I tried increasing the image size and it's not working.
class TextBox(QFrame):
def __init__(self, parent):
super(TextBox, self).__init__(parent=parent)
self.setObjectName("textBox")
self.isActive = False
self.lineEdit = QLineEdit()
self.lineEdit.addAction(QIcon("assets/icons/clear#3x.png"), QLineEdit.TrailingPosition)
A QIcon does not have a specific size, as it's only "decided" by the widget that uses it. While most widgets that use icons have a iconSize property, the icons of actions in a QLineEdit are shown in a different way.
Up until Qt 5.11 (excluded), the size was hardcoded to 16 pixels if the line edit was smaller than 34 pixels or 32 if it was taller.
Starting from Qt 5.11 the size is retrieved using the style (through pixelMetric()), and this can be overridden using a proxy style:
class Proxy(QtWidgets.QProxyStyle):
def pixelMetric(self, metric, opt=None, widget=None):
if (metric == self.PM_SmallIconSize and
isinstance(widget, QtWidgets.QLineEdit)):
size = widget.property('iconSize')
if size is not None:
return size
return widget.fontMetrics().height()
return super().pixelMetric(metric, opt, widget)
class LineEdit(QtWidgets.QLineEdit):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setProperty('iconSize', 64)
# ...
For previous versions of Qt, though, things are a bit tricky. The only solution I came up with is to install event filters on all QToolButton that are children of the line edit (every action uses an internal QToolButton, including the clear action), manually set their geometry (required for correct click actions) and paint it in the event filter.
The following includes the proxystyle implementation in case the current version correctly supports it as explained before:
from PyQt5 import QtWidgets, QtCore, QtGui
if int(QtCore.QT_VERSION_STR.split('.')[1]) > 11:
IconSizeFix = False
else:
IconSizeFix = True
class Proxy(QtWidgets.QProxyStyle):
def pixelMetric(self, metric, opt=None, widget=None):
if (metric == self.PM_SmallIconSize and
isinstance(widget, QtWidgets.QLineEdit)):
size = widget.property('iconSize')
if size is not None:
return size
return widget.fontMetrics().height()
return super().pixelMetric(metric, opt, widget)
class LineEdit(QtWidgets.QLineEdit):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setProperty('iconSize', 64)
self.setClearButtonEnabled(True)
self.addAction(QtGui.QIcon("icon.png"), self.TrailingPosition)
font = self.font()
font.setPointSize(48)
self.setFont(font)
def checkButtons(self):
for button in self.findChildren(QtWidgets.QToolButton):
button.installEventFilter(self)
def actionEvent(self, event):
super().actionEvent(event)
if IconSizeFix:
self.checkButtons()
def eventFilter(self, source, event):
if event.type() == QtCore.QEvent.Paint:
if (source.defaultAction().objectName() == '_q_qlineeditclearaction' and
not self.text()):
return True
qp = QtGui.QPainter(source)
state = QtGui.QIcon.Disabled
if source.isEnabled():
state = QtGui.QIcon.Active if source.isDown() else QtGui.QIcon.Normal
iconSize = QtCore.QSize(*[self.property('iconSize')] * 2)
qp.drawPixmap(source.rect(), source.icon().pixmap(
self.windowHandle(), iconSize, state, QtGui.QIcon.Off))
return True
return super().eventFilter(source, event)
def resizeEvent(self, event):
if not IconSizeFix:
return
self.checkButtons()
buttons = self.findChildren(QtWidgets.QToolButton)
if not buttons:
return
left = []
right = []
center = self.rect().center().x()
for button in buttons:
geo = button.geometry()
if geo.center().x() < center:
left.append(button)
else:
right.append(button)
left.sort(key=lambda x: x.geometry().x())
right.sort(key=lambda x: x.geometry().x())
iconSize = self.property('iconSize')
margin = iconSize / 4
top = (self.height() - iconSize) / 2
leftMargin = rightMargin = 0
if left:
x = margin
leftEdge = left[-1].geometry().right()
for button in left:
geo = QtCore.QRect(x, top, iconSize, iconSize)
button.setGeometry(geo)
x += iconSize + margin
leftMargin = x - leftEdge - margin
if right:
rightEdge = self.width() - margin
x = rightEdge - len(right) * iconSize - (len(right) - 1) * margin
rightMargin = self.width() - rightEdge + margin
for button in right:
geo = QtCore.QRect(x, top, iconSize, iconSize)
button.setGeometry(geo)
x += iconSize + margin
self.setTextMargins(leftMargin, 0, rightMargin, 0)
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
app.setStyle(Proxy())
w = LineEdit()
w.show()
sys.exit(app.exec_())
Consider the following:
using the pre-5.11 workaround the positioning is not pixel-perfect, I tried to mimic what QLineEdit does to keep the code as simple as possible;
the painting is not exactly the same, most importantly the icon is missing the "highlight" shade when clicked, and if the style uses fade in/out effects for the clear button those effects won't be available;
the QProxyStyle method also affects the sizeHint of the QLineEdit, so it can not be smaller than the icon size, so use it with care;
Related
I have something like this:
I can't show you more, but this is a simple QTreeView with QStandardItems in it. The items in the figure have a parent item which has a parent item as well.
When I activate the breakpoint on a item I have this:
which is ok but I also would like to add a circle next it as the majority of IDEs do (I took as example PyCharm):
The problem is that I have no idea how to do it. Anyone can help?
A possible solution is to override the drawRow method of QTreeView and use the information from the QModelIndex to do the painting:
import sys
from PySide2.QtCore import Qt, QRect
from PySide2.QtGui import QColor, QStandardItem, QStandardItemModel
from PySide2.QtWidgets import QAbstractItemView, QApplication, QTreeView
IS_BREAKPOINT_ROLE = Qt.UserRole + 1
class TreeView(QTreeView):
def drawRow(self, painter, option, index):
super().drawRow(painter, option, index)
if index.column() == 0:
if not index.data(IS_BREAKPOINT_ROLE):
return
rect = self.visualRect(index)
if not rect.isNull():
margin = 4
r = QRect(0, rect.top(), rect.height(), rect.height()).adjusted(
margin, margin, -margin, -margin
)
painter.setBrush(QColor("red"))
painter.drawEllipse(r)
def main(args):
app = QApplication(args)
view = TreeView()
view.setSelectionBehavior(QAbstractItemView.SelectRows)
model = QStandardItemModel()
model.setHorizontalHeaderLabels(["col1", "col2"])
view.setModel(model)
counter = 0
for i in range(10):
item1 = QStandardItem("Child 1-{}".format(i))
item2 = QStandardItem("Child 2-{}".format(i))
for j in range(10):
child1 = QStandardItem("Child {}-1".format(counter))
child2 = QStandardItem("Child {}-2".format(counter))
child1.setData(counter % 2 == 0, IS_BREAKPOINT_ROLE)
item1.appendRow([child1, child2])
counter += 1
model.appendRow([item1, item2])
view.show()
view.resize(320, 240)
view.expandAll()
sys.exit(app.exec_())
if __name__ == "__main__":
main(sys.argv)
I'd like to propose an alternate solution based on the answer by eyllanesc, which adds a left margin to the viewport, avoiding painting over the hierarchy lines (which could hide expanding decoration arrows for parent items that still need to show the circle).
Some important notes:
the left margin is created using setViewportMargins(), but all item views automatically reset those margins when calling updateGeometries() (which happens almost everytime the layout is changed), so that method needs overriding;
painting on the margins means that painting does not happen in the viewport, so we cannot implement paintEvent() (which by default is called for updates on the viewport); this results in implementing the drawing in the event() instead;
updates must be explicitly called when the scroll bar change or items are expanded/collapsed, but Qt only updates the region interested by items that have been actually "changed" (thus possibly excluding other "shifted" items); in order to request the update on the full extent we need to call the base implementation of QWidget (not that of the view, as that method is overridden);
class TreeView(QTreeView):
leftMargin = 14
def __init__(self, *args, **kwargs):
super().__init__()
self.leftMargin = self.fontMetrics().height()
self.verticalScrollBar().valueChanged.connect(self.updateLeftMargin)
self.expanded.connect(self.updateLeftMargin)
self.collapsed.connect(self.updateLeftMargin)
def updateLeftMargin(self):
QWidget.update(self,
QRect(0, 0, self.leftMargin + self.frameWidth(), self.height()))
def setModel(self, model):
if self.model() != model:
if self.model():
self.model().dataChanged.disconnect(self.updateLeftMargin)
super().setModel(model)
model.dataChanged.connect(self.updateLeftMargin)
def updateGeometries(self):
super().updateGeometries()
margins = self.viewportMargins()
if margins.left() < self.leftMargin:
margins.setLeft(margins.left() + self.leftMargin)
self.setViewportMargins(margins)
def event(self, event):
if event.type() == event.Paint:
pos = QPoint()
index = self.indexAt(pos)
qp = QPainter(self)
border = self.frameWidth()
bottom = self.height() - border * 2
qp.setClipRect(QRect(border, border, self.leftMargin, bottom))
top = .5
if self.header().isVisible():
top += self.header().height()
qp.translate(.5, top)
qp.setBrush(Qt.red)
qp.setRenderHints(qp.Antialiasing)
deltaY = self.leftMargin / 2 - border
circle = QRect(
border + 1, 0, self.leftMargin - 2, self.leftMargin - 2)
row = 0
while index.isValid():
rect = self.visualRect(index)
if index.data(IS_BREAKPOINT_ROLE):
circle.moveTop(rect.center().y() - deltaY)
qp.drawEllipse(circle)
row += 1
pos.setY(rect.bottom() + 2)
if pos.y() > bottom:
break
index = self.indexAt(pos)
return super().event(event)
Please consider the following code:
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
class Gallery(QScrollArea):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedWidth(175)
self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
# Set widget and layout
self._scroll_widget = QWidget()
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(25)
self._scroll_widget.setLayout(self._layout)
self.setWidget(self._scroll_widget)
self.setWidgetResizable(True)
# Stretch
self._layout.addStretch(1) # Stretch above widgets
self._layout.addStretch(1) # Stretch below widgets
# Initialize ---------------------------------|
for _ in range(10):
self.add_item()
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
# Calculate Margins --------------------|
children = self._scroll_widget.findChildren(QLabel)
first_widget = children[0]
last_widget = children[-1]
self._layout.setContentsMargins(
0,
int(event.size().height() / 2 - first_widget.size().height() / 2),
0,
int(event.size().height() / 2 - last_widget.size().height() / 2)
)
def add_item(self) -> None:
widget = QLabel()
widget.setStyleSheet('background: #22FF88')
widget.setFixedSize(90, 125)
child_count = len(
self._scroll_widget.findChildren(QLabel)
)
self._layout.insertWidget(1 + child_count, widget,
alignment=Qt.AlignCenter)
if __name__ == '__main__':
app = QApplication([])
window = Gallery()
window.show()
app.exec()
Currently, the margins of the layout are dynamically set, so that, no matter the size of the window, the first and last item are always vertically centered:
What I want to achieve now is that whenever I scroll (either by mousewheel or with the arrow-keys, as the scrollbars are disabled) the next widget should take the position in the vertical center, i.e. I want to switch the scrolling mode from a per pixel to a per-widget-basis, so that no matter how far I scroll, I will never land between two widgets.
How can this be done?
I found that QAbstractItemView provides the option the switch the ScrollMode to ScrollPerItem, though I'm not sure if that's what I need because I was a bit overwhelmed when trying to subclass QAbstractItemView.
Edit:
This shows the delay I noticed after adapting #musicamante's answer:
It's not really disrupting, but I don't see it in larger projects, so I suppose something is not working as it should.
Since most of the features QScrollArea provides are actually going to be ignored, subclassing from it doesn't give lots of benefits. On the contrary, it could make things much more complex.
Also, using a layout isn't very useful: the "container" of the widget is not constrained by the scroll area, and all features for size hinting and resizing are almost useless in this case.
A solution could be to just set all items as children of the "scroll area", that could be even a basic QWidget or QFrame, but for better styling support I chose to use a QAbstractScrollArea.
The trick is to compute the correct position of each child widget, based on its geometry. Note that I'm assuming all widgets have a fixed size, otherwise you might need to use their sizeHint, minimumSizeHint or minimum sizes, and check for their size policies.
Here's a possible implementation (I changed the item creation in order to correctly show the result):
from random import randrange
class Gallery(QAbstractScrollArea):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedWidth(175)
self.items = []
self.currentIndex = -1
for _ in range(10):
widget = QLabel(str(len(self.items) + 1),
self, alignment=Qt.AlignCenter)
widget.setStyleSheet('background: #{:02x}{:02x}{:02x}'.format(
randrange(255), randrange(255), randrange(255)))
widget.setFixedSize(randrange(60, 100), randrange(50, 200))
self.addItem(widget)
def addItem(self, widget):
self.insertItem(len(self.items), widget)
def insertItem(self, index, widget):
widget.setParent(self.viewport())
widget.show()
self.items.insert(index, widget)
if len(self.items) == 1:
self.currentIndex = 0
self.updateGeometry()
def setCurrentIndex(self, index):
if not self.items:
self.currentIndex = -1
return
self.currentIndex = max(0, min(index, len(self.items) - 1))
self.updateGeometry()
def stepBy(self, step):
self.setCurrentIndex(self.currentIndex + step)
def updateGeometry(self):
super().updateGeometry()
if not self.items:
return
rects = []
y = 0
for i, widget in enumerate(self.items):
rect = widget.rect()
rect.moveTop(y)
rects.append(rect)
if i == self.currentIndex:
centerY = rect.center().y()
y = rect.bottom() + 25
centerY -= self.height() / 2
centerX = self.width() / 2
for widget, rect in zip(self.items, rects):
widget.setGeometry(rect.translated(centerX - rect.width() / 2, -centerY))
def sizeHint(self):
return QSize(175, 400)
def resizeEvent(self, event: QResizeEvent):
self.updateGeometry()
def wheelEvent(self, event):
if event.angleDelta().y() < 0:
self.stepBy(1)
else:
self.stepBy(-1)
Can you suggest me a way to make hyperlink in PyQt5 tooltip clickable? Tried like this:
from PyQt5 import QtWidgets
app = QtWidgets.QApplication([])
w = QtWidgets.QMainWindow()
QtWidgets.QLabel(parent = w, text = 'Hover mouse here', toolTip = 'Unclickable link')
w.show()
app.exec_()
Link is visible, but not clickable, unfortunatelly.
This is not an easy task to achieve.
One of the most important aspects is that users are accustomed to the conventional behavior of tool tips: if the mouse cursor hovers them (or they are clicked), they would probably disappear; this is to avoid any possibility that some important part of the widget they refer to becomes hidden (imagine a table that shows a big tooltip for a cell and hides the values of other cells that are near the first one).
Qt follows the same concepts; so, not only you can't interactively click on a tooltip, but it's usually almost impossible to hover a tooltip at all.
The only solution is to create your own tooltip.
In the following (rather complex) example, I'm going to show how to achieve that.
Note that this implementation is not perfect: I could test it under Linux only, but, most importantly, it's not application-wide (it could theoretically be possible, though).
The basic concept is to install an event filter on all widgets that could potentially have a clickable url, intercept every QEvent that is of QEvent.ToolTip type, and create a widget that behaves like it afterwards.
I tried to implement it as much as similar to the standard QToolTip object (which is only accessible almost by static methods). The only difference here is that the static method returns the instance, which allows to connect to the linkActivated signal.
class ClickableTooltip(QtWidgets.QLabel):
__instance = None
refWidget = None
refPos = None
menuShowing = False
def __init__(self):
super().__init__(flags=QtCore.Qt.ToolTip)
margin = self.style().pixelMetric(
QtWidgets.QStyle.PM_ToolTipLabelFrameWidth, None, self)
self.setMargin(margin + 1)
self.setForegroundRole(QtGui.QPalette.ToolTipText)
self.setWordWrap(True)
self.mouseTimer = QtCore.QTimer(interval=250, timeout=self.checkCursor)
self.hideTimer = QtCore.QTimer(singleShot=True, timeout=self.hide)
def checkCursor(self):
# ignore if the link context menu is visible
for menu in self.findChildren(
QtWidgets.QMenu, options=QtCore.Qt.FindDirectChildrenOnly):
if menu.isVisible():
return
# an arbitrary check for mouse position; since we have to be able to move
# inside the tooltip margins (standard QToolTip hides itself on hover),
# let's add some margins just for safety
region = QtGui.QRegion(self.geometry().adjusted(-10, -10, 10, 10))
if self.refWidget:
rect = self.refWidget.rect()
rect.moveTopLeft(self.refWidget.mapToGlobal(QtCore.QPoint()))
region |= QtGui.QRegion(rect)
else:
# add a circular region for the mouse cursor possible range
rect = QtCore.QRect(0, 0, 16, 16)
rect.moveCenter(self.refPos)
region |= QtGui.QRegion(rect, QtGui.QRegion.Ellipse)
if QtGui.QCursor.pos() not in region:
self.hide()
def show(self):
super().show()
QtWidgets.QApplication.instance().installEventFilter(self)
def event(self, event):
# just for safety...
if event.type() == QtCore.QEvent.WindowDeactivate:
self.hide()
return super().event(event)
def eventFilter(self, source, event):
# if we detect a mouse button or key press that's not originated from the
# label, assume that the tooltip should be closed; note that widgets that
# have been just mapped ("shown") might return events for their QWindow
# instead of the actual QWidget
if source not in (self, self.windowHandle()) and event.type() in (
QtCore.QEvent.MouseButtonPress, QtCore.QEvent.KeyPress):
self.hide()
return super().eventFilter(source, event)
def move(self, pos):
# ensure that the style has "polished" the widget (font, palette, etc.)
self.ensurePolished()
# ensure that the tooltip is shown within the available screen area
geo = QtCore.QRect(pos, self.sizeHint())
try:
screen = QtWidgets.QApplication.screenAt(pos)
except:
# support for Qt < 5.10
for screen in QtWidgets.QApplication.screens():
if pos in screen.geometry():
break
else:
screen = None
if not screen:
screen = QtWidgets.QApplication.primaryScreen()
screenGeo = screen.availableGeometry()
# screen geometry correction should always consider the top-left corners
# *last* so that at least their beginning text is always visible (that's
# why I used pairs of "if" instead of "if/else"); also note that this
# doesn't take into account right-to-left languages, but that can be
# accounted for by checking QGuiApplication.layoutDirection()
if geo.bottom() > screenGeo.bottom():
geo.moveBottom(screenGeo.bottom())
if geo.top() < screenGeo.top():
geo.moveTop(screenGeo.top())
if geo.right() > screenGeo.right():
geo.moveRight(screenGeo.right())
if geo.left() < screenGeo.left():
geo.moveLeft(screenGeo.left())
super().move(geo.topLeft())
def contextMenuEvent(self, event):
# check the children QMenu objects before showing the menu (which could
# potentially hide the label)
knownChildMenus = set(self.findChildren(
QtWidgets.QMenu, options=QtCore.Qt.FindDirectChildrenOnly))
self.menuShowing = True
super().contextMenuEvent(event)
newMenus = set(self.findChildren(
QtWidgets.QMenu, options=QtCore.Qt.FindDirectChildrenOnly))
if knownChildMenus == newMenus:
# no new context menu? hide!
self.hide()
else:
# hide ourselves as soon as the (new) menus close
for m in knownChildMenus ^ newMenus:
m.aboutToHide.connect(self.hide)
m.aboutToHide.connect(lambda m=m: m.aboutToHide.disconnect())
self.menuShowing = False
def mouseReleaseEvent(self, event):
# click events on link are delivered on button release!
super().mouseReleaseEvent(event)
self.hide()
def hide(self):
if not self.menuShowing:
super().hide()
def hideEvent(self, event):
super().hideEvent(event)
QtWidgets.QApplication.instance().removeEventFilter(self)
self.refWidget.window().removeEventFilter(self)
self.refWidget = self.refPos = None
self.mouseTimer.stop()
self.hideTimer.stop()
def resizeEvent(self, event):
super().resizeEvent(event)
# on some systems the tooltip is not a rectangle, let's "mask" the label
# according to the system defaults
opt = QtWidgets.QStyleOption()
opt.initFrom(self)
mask = QtWidgets.QStyleHintReturnMask()
if self.style().styleHint(
QtWidgets.QStyle.SH_ToolTip_Mask, opt, self, mask):
self.setMask(mask.region)
def paintEvent(self, event):
# we cannot directly draw the label, since a tooltip could have an inner
# border, so let's draw the "background" before that
qp = QtGui.QPainter(self)
opt = QtWidgets.QStyleOption()
opt.initFrom(self)
style = self.style()
style.drawPrimitive(style.PE_PanelTipLabel, opt, qp, self)
# now we paint the label contents
super().paintEvent(event)
#staticmethod
def showText(pos, text:str, parent=None, rect=None, delay=0):
# this is a method similar to QToolTip.showText;
# it reuses an existent instance, but also returns the tooltip so that
# its linkActivated signal can be connected
if ClickableTooltip.__instance is None:
if not text:
return
ClickableTooltip.__instance = ClickableTooltip()
toolTip = ClickableTooltip.__instance
toolTip.mouseTimer.stop()
toolTip.hideTimer.stop()
# disconnect all previously connected signals, if any
try:
toolTip.linkActivated.disconnect()
except:
pass
if not text:
toolTip.hide()
return
toolTip.setText(text)
if parent:
toolTip.refRect = rect
else:
delay = 0
pos += QtCore.QPoint(16, 16)
# adjust the tooltip position if necessary (based on arbitrary margins)
if not toolTip.isVisible() or parent != toolTip.refWidget or (
not parent and toolTip.refPos and
(toolTip.refPos - pos).manhattanLength() > 10):
toolTip.move(pos)
# we assume that, if no parent argument is given, the current activeWindow
# is what we should use as a reference for mouse detection
toolTip.refWidget = parent or QtWidgets.QApplication.activeWindow()
toolTip.refPos = pos
toolTip.show()
toolTip.mouseTimer.start()
if delay:
toolTip.hideTimer.start(delay)
return toolTip
class ToolTipTest(QtWidgets.QWidget):
def __init__(self):
super().__init__()
layout = QtWidgets.QGridLayout(self)
count = 1
tip = 'This is link {c}'
for row in range(4):
for col in range(4):
button = QtWidgets.QPushButton('Hello {}'.format(count))
layout.addWidget(button, row, col)
button.setToolTip(tip.format(c=count))
button.installEventFilter(self)
count += 1
def toolTipLinkClicked(self, url):
print(url)
def eventFilter(self, source, event):
if event.type() == QtCore.QEvent.ToolTip and source.toolTip():
toolTip = ClickableTooltip.showText(
QtGui.QCursor.pos(), source.toolTip(), source)
toolTip.linkActivated.connect(self.toolTipLinkClicked)
return True
return super().eventFilter(source, event)
I want to add a QPushButton to the tree view that ends with .pdf and when I click it I want to return the path for that Index it's assigned at.
This might not even be possible with the Native QTreeView but if anyone could guide me in the right direction that would be awesome!
To conclude more of what I would want is to have a QPushButton appear where that red square is below.
Current code for the "Tree View":
from PyQt5.QtMultimediaWidgets import *
from PyQt5.QtMultimedia import *
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5 import *
import os, sys
class MainMenu(QWidget):
def __init__(self, parent = None):
super(MainMenu, self).__init__(parent)
self.model = QFileSystemModel()
self.model.setRootPath(QDir.rootPath())
self.model.setFilter(QDir.NoDotAndDotDot | QDir.AllEntries | QDir.Dirs | QDir.Files)
self.proxy_model = QSortFilterProxyModel(recursiveFilteringEnabled = True, filterRole = QFileSystemModel.FileNameRole)
self.proxy_model.setSourceModel(self.model)
self.model.setReadOnly(False)
self.model.setNameFilterDisables(False)
self.indexRoot = self.model.index(self.model.rootPath())
self.treeView = QTreeView(self)
self.treeView.setModel(self.proxy_model)
self.treeView.setRootIndex(self.indexRoot)
self.treeView.setAnimated(True)
self.treeView.setIndentation(20)
self.treeView.setSortingEnabled(True)
self.treeView.setDragEnabled(False)
self.treeView.setAcceptDrops(False)
self.treeView.setDropIndicatorShown(True)
self.treeView.setEditTriggers(QTreeView.NoEditTriggers)
for i in range(1, self.treeView.model().columnCount()):
self.treeView.header().hideSection(i)
if __name__ == '__main__':
app = QApplication(sys.argv)
main = MainMenu()
main.show()
sys.exit(app.exec_())
For this you'll probably need an item delegate.
The idea is that you are going to leave basic item painting to the base class paint() function, and then paint a virtual button over it.
To achieve that, QStyleOptionButton is used against the view style (obtained from the option argument): you create a style option, init it from the view (option.widget, which will apply the basic rectangle of the widget, the font, palette, etc.), adjust the rectangle to suit your needs and finally paint it.
To better implement drawing (mouse hover effects, but also to ensure correct painting update), you'll also need to set mouse tracking to True for the tree view. This, amongst other checks explained in the code, allows you to draw the virtual button, including its hover or pressed states.
Finally, when the button is released and the mouse is within its boundaries, a buttonClicked signal is emitted, with the current index as argument.
class TreeButtonDelegate(QtWidgets.QStyledItemDelegate):
buttonClicked = QtCore.pyqtSignal(QtCore.QModelIndex, int)
def __init__(self, fsModel, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fsModel = fsModel
self.clickedPaths = {}
self._mousePos = None
self._pressed = False
self.minimumButtonWidth = 32
def getOption(self, option, index):
btnOption = QtWidgets.QStyleOptionButton()
# initialize the basic options with the view
btnOption.initFrom(option.widget)
clickedCount = self.clickedPaths.get(self.fsModel.filePath(index), 0)
if clickedCount:
btnOption.text = '{}'.format(clickedCount)
else:
btnOption.text = 'NO'
# the original option properties should never be touched, so we can't
# directly use it's "rect"; let's create a new one from it
btnOption.rect = QtCore.QRect(option.rect)
# adjust it to the minimum size
btnOption.rect.setLeft(option.rect.right() - self.minimumButtonWidth)
style = option.widget.style()
# get the available space for the contents of the button
textRect = style.subElementRect(
QtWidgets.QStyle.SE_PushButtonContents, btnOption)
# get the margins between the contents and the border, multiplied by 2
# since they're used for both the left and right side
margin = style.pixelMetric(
QtWidgets.QStyle.PM_ButtonMargin, btnOption) * 2
# the width of the current button text
textWidth = btnOption.fontMetrics.width(btnOption.text)
if textRect.width() < textWidth + margin:
# if the width is too small, adjust the *whole* button rect size
# to fit the contents
btnOption.rect.setLeft(btnOption.rect.left() - (
textWidth - textRect.width() + margin))
return btnOption
def editorEvent(self, event, model, option, index):
# map the proxy index to the fsModel
srcIndex = index.model().mapToSource(index)
# I'm just checking if it's a file, if you want to check the extension
# you might need to use fsModel.fileName(srcIndex)
if not self.fsModel.isDir(srcIndex):
if event.type() in (QtCore.QEvent.Enter, QtCore.QEvent.MouseMove):
self._mousePos = event.pos()
# request an update of the current index
option.widget.update(index)
elif event.type() == QtCore.QEvent.Leave:
self._mousePos = None
elif (event.type() in (QtCore.QEvent.MouseButtonPress, QtCore.QEvent.MouseButtonDblClick)
and event.button() == QtCore.Qt.LeftButton):
# check that the click is within the virtual button rectangle
if event.pos() in self.getOption(option, srcIndex).rect:
self._pressed = True
option.widget.update(index)
if event.type() == QtCore.QEvent.MouseButtonDblClick:
# do not send double click events
return True
elif event.type() == QtCore.QEvent.MouseButtonRelease:
if self._pressed and event.button() == QtCore.Qt.LeftButton:
# emit the click only if the release is within the button rect
if event.pos() in self.getOption(option, srcIndex).rect:
filePath = self.fsModel.filePath(srcIndex)
count = self.clickedPaths.setdefault(filePath, 0)
self.buttonClicked.emit(index, count + 1)
self.clickedPaths[filePath] += 1
self._pressed = False
option.widget.update(index)
return super().editorEvent(event, model, option, index)
def paint(self, painter, option, index):
super().paint(painter, option, index)
srcIndex = index.model().mapToSource(index)
if not self.fsModel.isDir(srcIndex):
btnOption = self.getOption(option, srcIndex)
# remove the focus rectangle, as it will be inherited from the view
btnOption.state &= ~QtWidgets.QStyle.State_HasFocus
if self._mousePos is not None and self._mousePos in btnOption.rect:
# if the style supports it, some kind of "glowing" border
# will be shown on the button
btnOption.state |= QtWidgets.QStyle.State_MouseOver
if self._pressed == QtCore.Qt.LeftButton:
# set the button pressed state
btnOption.state |= QtWidgets.QStyle.State_On
else:
# ensure that there's no mouse over state (see above)
btnOption.state &= ~QtWidgets.QStyle.State_MouseOver
# finally, draw the virtual button
option.widget.style().drawControl(
QtWidgets.QStyle.CE_PushButton, btnOption, painter)
class MainMenu(QWidget):
def __init__(self, parent = None):
super(MainMenu, self).__init__(parent)
# ...
self.treeView = QTreeView(self)
self.treeView.setMouseTracking(True)
# ...
self.treeDelegate = TreeDelegate(self.model)
self.treeView.setItemDelegateForColumn(0, self.treeDelegate)
self.treeDelegate.buttonClicked.connect(self.treeButtonClicked)
# ...
def treeButtonClicked(self, index, count):
print('{} clicked {} times'.format(index.data(), count))
Note: I implemented the click counter as you asked in the comments (and used an helper function to accomodate the longer function that computes the button size accordingly), just remember that this doesn't take into account the possibility of files renamed, removed and/or recreated (or files renamed overwriting an existing one). To obtain that you'll need to use a more complex approach than a simple path-based dictionary, possibly by implementing QFileSystemWatcher and checking for files removed/renamed.
Also note that to speed up things a bit I'm adding the source filesystem model to the init of the delegate so that it doesn't need to be found each time it's required for painting or mouse tracking.
This code runs a little window with a toolbar and a QTextEdit area.
If you highlight 'bananas' and change the font size, then using zoom from either the toolbar buttons or CTRL + mouse wheel will only resize 'apples'. Anyone know why?
from PySide import QtGui, QtCore
class MainWindow(QtGui.QMainWindow):
def __init__(self, parent=None):
super(MainWindow, self).__init__(parent)
self.textEdit = Editor(self)
self.toolBar = QtGui.QToolBar(self)
self.addToolBar(self.toolBar)
self.setCentralWidget(self.textEdit)
self.textEdit.setHtml('<font color=blue>apples bananas</font>')
# Zoom
self.actionZoomIn = QtGui.QAction('Zoom In', self)
self.actionZoomOut = QtGui.QAction('Zoom Out', self)
self.toolBar.addAction(self.actionZoomIn)
self.toolBar.addAction(self.actionZoomOut)
self.actionZoomIn.triggered.connect(self.onZoomInClicked)
self.actionZoomOut.triggered.connect(self.onZoomOutClicked)
# Font Size
self.comboSize = QtGui.QComboBox(self.toolBar)
self.toolBar.addWidget(self.comboSize)
self.comboSize.addItem('0')
self.comboSize.addItem('10')
self.comboSize.addItem('18')
self.comboSize.addItem('30')
self.comboSize.addItem('48')
self.comboSize.setCurrentIndex(1)
self.comboSize.activated[str].connect(self.textSize)
def textSize(self, pointSize):
pointSize = int(pointSize)
if pointSize > 0:
fmt = QtGui.QTextCharFormat()
fmt.setFontPointSize(pointSize)
self.mergeFormatOnWordOrSelection(fmt)
def mergeFormatOnWordOrSelection(self, format):
cursor = self.textEdit.textCursor()
if not cursor.hasSelection():
cursor.select(QtGui.QTextCursor.WordUnderCursor)
cursor.mergeCharFormat(format)
self.textEdit.mergeCurrentCharFormat(format)
def onZoomInClicked(self):
self.textEdit.zoom(+1)
def onZoomOutClicked(self):
self.textEdit.zoom(-1)
class Editor(QtGui.QTextEdit):
def __init__(self, parent=None):
super(Editor, self).__init__(parent)
self.zoomValue = 0
def zoom(self, delta):
zoomIncrement = 3
if delta < 0:
zoomIncrement = 0 - zoomIncrement
self.zoomIn(zoomIncrement)
self.zoomValue = self.zoomValue + zoomIncrement
print "self.zoomValue", self.zoomValue
def wheelEvent(self, event):
if (event.modifiers() & QtCore.Qt.ControlModifier):
self.zoom(event.delta())
if __name__ == '__main__':
app = QtGui.QApplication([])
window = MainWindow()
window.resize(400, 180)
window.show()
app.exec_()
The default implementation of QTextEdit.zoomIn/Out simply changes the pointSize of the base font for the text-edit.
The method used in the example to change font size wraps the selected word in a span tag and uses inline css to set the font-size property to a fixed value. This means that when the text-edit is subsequently zoomed, only the unchanged text will be affected.
It would be possible overcome this problem by using relative font sizes. However, it looks like only a limted subset of css properties is supported, so its only possible to set imprecise values like small, large, etc.
This can be implemented in the example by making the following changes:
# Font Size
self.comboSize = QtGui.QComboBox(self.toolBar)
self.toolBar.addWidget(self.comboSize)
self.comboSize.addItem('small')
self.comboSize.addItem('medium')
self.comboSize.addItem('large')
self.comboSize.addItem('x-large')
self.comboSize.addItem('xx-large')
self.comboSize.setCurrentIndex(1)
self.comboSize.activated[int].connect(self.textSize)
def textSize(self, size):
fmt = QtGui.QTextCharFormat()
fmt.setProperty(QtGui.QTextFormat.FontSizeAdjustment, size - 1)
self.mergeFormatOnWordOrSelection(fmt)