python - 带有可点击超链接的 PyQt5 工具提示?

标签 python pyqt5

你能建议我一种在 PyQt5 工具提示中制作超链接的方法吗?像这样试过:

from PyQt5 import QtWidgets

app = QtWidgets.QApplication([])
w = QtWidgets.QMainWindow()
QtWidgets.QLabel(parent = w, text = 'Hover mouse here', toolTip = '<a href="http://google.com">Unclickable link</a>')
w.show()
app.exec_()

不幸的是,链接是可见的,但不可点击。

最佳答案

这不是一项容易实现的任务。

最重要的方面之一是用户习惯于工具提示的常规行为:如果鼠标光标悬停在它们上(或单击它们),它们可能会消失;这是为了避免它们所引用的小部件的某些重要部分被隐藏的任何可能性(想象一个表格,它显示了一个单元格的大工具提示并隐藏了靠近第一个单元格的其他单元格的值)。

Qt 遵循相同的概念;因此,您不仅不能交互式地单击工具提示,而且通常几乎不可能悬停工具提示。
唯一的解决方案是创建您自己的工具提示。

在以下(相当复杂的)示例中,我将展示如何实现这一点。
请注意,此实现并不完美:我只能在 Linux 下对其进行测试,但最重要的是,它不是应用程序范围的(尽管理论上是可能的)。

基本概念是在所有可能具有可点击 url 的小部件上安装事件过滤器,拦截每个属于 QEvent.ToolTip 的 QEvent。键入,然后创建一个行为类似于它的小部件。

我试图实现它与标准 QToolTip 对象(几乎只能通过静态方法访问)类似的程度。这里唯一的区别是静态方法返回实例,它允许连接到 linkActivated信号。

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 <a href="http://test.com/{c}">link {c}</a>'
        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)

关于python - 带有可点击超链接的 PyQt5 工具提示?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/59902049/

相关文章:

python - Qt 带有圆角的消息

python - 使用 Python 导入 - 将多个 excel 文件导入到数据框中

python - 特征选择

python - 带列表的数据帧的多索引切片

python - 进度条从停止处恢复

python - 使用 pyuic 运行时,PyQt5 对话框小部件看起来有问题

python - PyQt - 在运行时创建 QLabels 并随后更改文本

python - PyQt5 中的 Matplotlib : How to remove the small space along the edge

python - t-SNE 的计算瓶颈是内存复杂度吗?

python - Ctypes linux 行为在 py2 和 py3 上不同