Can anyone point me to where I can find info on making a listbox with the ability to drag and drop items for re-arranging? I've found some related to Perl, but I know nothing of that language and I'm pretty new to tkinter, so it was pretty confusing. I know how to generate listboxes, but I'm not sure how to re-order it through drag and drop.
Here is the code from Recipe 11.4:
import Tkinter
class DragDropListbox(Tkinter.Listbox):
""" A Tkinter listbox with drag'n'drop reordering of entries. """
def __init__(self, master, **kw):
kw['selectmode'] = Tkinter.SINGLE
Tkinter.Listbox.__init__(self, master, kw)
self.bind('<Button-1>', self.setCurrent)
self.bind('<B1-Motion>', self.shiftSelection)
self.curIndex = None
def setCurrent(self, event):
self.curIndex = self.nearest(event.y)
def shiftSelection(self, event):
i = self.nearest(event.y)
if i < self.curIndex:
x = self.get(i)
self.delete(i)
self.insert(i+1, x)
self.curIndex = i
elif i > self.curIndex:
x = self.get(i)
self.delete(i)
self.insert(i-1, x)
self.curIndex = i
Here's a modified recipe if you're dealing with MULTIPLE as the selectmode (as opposed to SINGLE).
Changes made:
When dragging over an already selected item, it would deselect it which was a bad user-experience.
When clicking an item that was selected, it would become unselected from the click. So I added a self.curState bit that kept track of whether the clicked-on item's state was initially selected or not. When you drag it around, it doesn't lose its state.
I also bound two events to the Button-1 event using add='+' but that might avoidable by simply keeping it all under setCurrent.
I prefer activestyle equals 'none'.
Made this Listbox tk.MULTIPLE instead of tk.SINGLE.
Here is the code:
class Drag_and_Drop_Listbox(tk.Listbox):
""" A tk listbox with drag'n'drop reordering of entries. """
def __init__(self, master, **kw):
kw['selectmode'] = tk.MULTIPLE
kw['activestyle'] = 'none'
tk.Listbox.__init__(self, master, kw)
self.bind('<Button-1>', self.getState, add='+')
self.bind('<Button-1>', self.setCurrent, add='+')
self.bind('<B1-Motion>', self.shiftSelection)
self.curIndex = None
self.curState = None
def setCurrent(self, event):
''' gets the current index of the clicked item in the listbox '''
self.curIndex = self.nearest(event.y)
def getState(self, event):
''' checks if the clicked item in listbox is selected '''
i = self.nearest(event.y)
self.curState = self.selection_includes(i)
def shiftSelection(self, event):
''' shifts item up or down in listbox '''
i = self.nearest(event.y)
if self.curState == 1:
self.selection_set(self.curIndex)
else:
self.selection_clear(self.curIndex)
if i < self.curIndex:
# Moves up
x = self.get(i)
selected = self.selection_includes(i)
self.delete(i)
self.insert(i+1, x)
if selected:
self.selection_set(i+1)
self.curIndex = i
elif i > self.curIndex:
# Moves down
x = self.get(i)
selected = self.selection_includes(i)
self.delete(i)
self.insert(i-1, x)
if selected:
self.selection_set(i-1)
self.curIndex = i
Example demo:
root = tk.Tk()
listbox = Drag_and_Drop_Listbox(root)
for i,name in enumerate(['name'+str(i) for i in range(10)]):
listbox.insert(tk.END, name)
if i % 2 == 0:
listbox.selection_set(i)
listbox.pack(fill=tk.BOTH, expand=True)
root.mainloop()
The following class is a Listbox with EXTENDED selection mode that enables dragging around multiple selected items.
Default selecting mechanisms are preserved (by dragging and clicking, including holding down Ctrl or Shift), with the exception of dragging an already selected item without holding Ctrl.
To drag the selection, drag one of the selected items below the last selected item or above the first selected item.
To scroll the listbox while dragging selection, use the mousewheel or move the cursor near or beyond the top or bottom of the listbox. => This could be improved: since it's bound to the B1‑Motion event, extra movement of the mouse is needed to continue the scroll. Feels buggy in longer listboxes.
If the selection is discontinuous, dragging will make it continuous by moving the unselected items up or down, respectively.
The above means that to drag just one item, it needs to be selected first, then clicked again and dragged.
import tkinter as tk;
class ReorderableListbox(tk.Listbox):
""" A Tkinter listbox with drag & drop reordering of lines """
def __init__(self, master, **kw):
kw['selectmode'] = tk.EXTENDED
tk.Listbox.__init__(self, master, kw)
self.bind('<Button-1>', self.setCurrent)
self.bind('<Control-1>', self.toggleSelection)
self.bind('<B1-Motion>', self.shiftSelection)
self.bind('<Leave>', self.onLeave)
self.bind('<Enter>', self.onEnter)
self.selectionClicked = False
self.left = False
self.unlockShifting()
self.ctrlClicked = False
def orderChangedEventHandler(self):
pass
def onLeave(self, event):
# prevents changing selection when dragging
# already selected items beyond the edge of the listbox
if self.selectionClicked:
self.left = True
return 'break'
def onEnter(self, event):
#TODO
self.left = False
def setCurrent(self, event):
self.ctrlClicked = False
i = self.nearest(event.y)
self.selectionClicked = self.selection_includes(i)
if (self.selectionClicked):
return 'break'
def toggleSelection(self, event):
self.ctrlClicked = True
def moveElement(self, source, target):
if not self.ctrlClicked:
element = self.get(source)
self.delete(source)
self.insert(target, element)
def unlockShifting(self):
self.shifting = False
def lockShifting(self):
# prevent moving processes from disturbing each other
# and prevent scrolling too fast
# when dragged to the top/bottom of visible area
self.shifting = True
def shiftSelection(self, event):
if self.ctrlClicked:
return
selection = self.curselection()
if not self.selectionClicked or len(selection) == 0:
return
selectionRange = range(min(selection), max(selection))
currentIndex = self.nearest(event.y)
if self.shifting:
return 'break'
lineHeight = 15
bottomY = self.winfo_height()
if event.y >= bottomY - lineHeight:
self.lockShifting()
self.see(self.nearest(bottomY - lineHeight) + 1)
self.master.after(500, self.unlockShifting)
if event.y <= lineHeight:
self.lockShifting()
self.see(self.nearest(lineHeight) - 1)
self.master.after(500, self.unlockShifting)
if currentIndex < min(selection):
self.lockShifting()
notInSelectionIndex = 0
for i in selectionRange[::-1]:
if not self.selection_includes(i):
self.moveElement(i, max(selection)-notInSelectionIndex)
notInSelectionIndex += 1
currentIndex = min(selection)-1
self.moveElement(currentIndex, currentIndex + len(selection))
self.orderChangedEventHandler()
elif currentIndex > max(selection):
self.lockShifting()
notInSelectionIndex = 0
for i in selectionRange:
if not self.selection_includes(i):
self.moveElement(i, min(selection)+notInSelectionIndex)
notInSelectionIndex += 1
currentIndex = max(selection)+1
self.moveElement(currentIndex, currentIndex - len(selection))
self.orderChangedEventHandler()
self.unlockShifting()
return 'break'
Related
I would like to have a QTabBar with customised painting in the paintEvent(self,event) method, whilst maintaining the moving tabs animations / mechanics. I posted a question the other day about something similar, but it wasn't worded too well so I have heavily simplified the question with the following code:
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from PyQt5.QtTest import QTest
import sys
class MainWindow(QMainWindow):
def __init__(self,parent=None,*args,**kwargs):
QMainWindow.__init__(self,parent,*args,**kwargs)
self.tabs = QTabWidget(self)
self.tabs.setTabBar(TabBar(self.tabs))
self.tabs.setMovable(True)
for color in ["red","orange","yellow","lime","green","cyan","blue","purple","violet","magenta"]:
title = color
widget = QWidget(styleSheet="background-color:%s" % color)
pixmap = QPixmap(8,8)
pixmap.fill(QColor(color))
icon = QIcon(pixmap)
self.tabs.addTab(widget,icon,title)
self.setCentralWidget(self.tabs)
self.showMaximized()
class TabBar(QTabBar):
def __init__(self,parent,*args,**kwargs):
QTabBar.__init__(self,parent,*args,**kwargs)
def paintEvent(self,event):
painter = QStylePainter(self)
option = QStyleOptionTab()
for i in range(self.count()):
self.initStyleOption(option,i)
#Customise 'option' here
painter.drawControl(QStyle.CE_TabBarTab,option)
def tabSizeHint(self,index):
return QSize(112,48)
def exceptHook(e,v,t):
sys.__excepthook__(e,v,t)
if __name__ == "__main__":
sys.excepthook = exceptHook
application = QApplication(sys.argv)
mainwindow = MainWindow()
application.exec_()
there are some clear problems:
Dragging the tab to 'slide' it in the QTabBar is not smooth (it doens't glide) - it jumps to the next index.
The background tabs (non-selected tabs) don't glide into place once displaced - they jump into position.
When the tab is slid to the end of the tab bar (past the most right tab) and then let go of it doesn't glide back to the last index - it jumps there.
When sliding a tab, it stays in its original place and at the mouse cursor (in its dragging position) at the same time, and only when the mouse is released does the tab only show at the correct place (up until then it is also showing at the index it is originally from).
How can I modify the painting of a QTabBar with a QStyleOptionTab whilst maintaining all of the moving mechanics / animations of the tabs?
While it might seem a slightly simple widget, QTabBar is not, at least if you want to provide all of its features.
If you closely look at its source code, you'll find out that within the mouseMoveEvent() a private QMovableTabWidget is created whenever the drag distance is wide enough. That QWidget is a child of QTabBar that shows a QPixmap grab of the "moving" tab using the tab style option and following the mouse movements, while at the same moment that tab becomes invisible.
While your implementation might seem reasonable (note that I'm also referring to your original, now deleted, question), there are some important issues:
it doesn't account for the above "moving" child widget (in fact, with your code I can still see the original tab, even if that is that moving widget that's not actually moving since no call to the base implementation of mouseMoveEvent() is called);
it doesn't actually tabs;
it doesn't correctly process mouse events;
This is a complete implementation partially based on the C++ sources (I've tested it even with vertical tabs, and it seems to behave as it should):
class TabBar(QTabBar):
class MovingTab(QWidget):
'''
A private QWidget that paints the current moving tab
'''
def setPixmap(self, pixmap):
self.pixmap = pixmap
self.update()
def paintEvent(self, event):
qp = QPainter(self)
qp.drawPixmap(0, 0, self.pixmap)
def __init__(self,parent, *args, **kwargs):
QTabBar.__init__(self,parent, *args, **kwargs)
self.movingTab = None
self.isMoving = False
self.animations = {}
self.pressedIndex = -1
def isVertical(self):
return self.shape() in (
self.RoundedWest,
self.RoundedEast,
self.TriangularWest,
self.TriangularEast)
def createAnimation(self, start, stop):
animation = QVariantAnimation()
animation.setStartValue(start)
animation.setEndValue(stop)
animation.setEasingCurve(QEasingCurve.InOutQuad)
def removeAni():
for k, v in self.animations.items():
if v == animation:
self.animations.pop(k)
animation.deleteLater()
break
animation.finished.connect(removeAni)
animation.valueChanged.connect(self.update)
animation.start()
return animation
def layoutTab(self, overIndex):
oldIndex = self.pressedIndex
self.pressedIndex = overIndex
if overIndex in self.animations:
# if the animation exists, move its key to the swapped index value
self.animations[oldIndex] = self.animations.pop(overIndex)
else:
start = self.tabRect(overIndex).topLeft()
stop = self.tabRect(oldIndex).topLeft()
self.animations[oldIndex] = self.createAnimation(start, stop)
self.moveTab(oldIndex, overIndex)
def finishedMovingTab(self):
self.movingTab.deleteLater()
self.movingTab = None
self.pressedIndex = -1
self.update()
# reimplemented functions
def tabSizeHint(self, i):
return QSize(112, 48)
def mousePressEvent(self, event):
super().mousePressEvent(event)
if event.button() == Qt.LeftButton:
self.pressedIndex = self.tabAt(event.pos())
if self.pressedIndex < 0:
return
self.startPos = event.pos()
def mouseMoveEvent(self,event):
if not event.buttons() & Qt.LeftButton or self.pressedIndex < 0:
super().mouseMoveEvent(event)
else:
delta = event.pos() - self.startPos
if not self.isMoving and delta.manhattanLength() < QApplication.startDragDistance():
# ignore the movement as it's too small to be considered a drag
return
if not self.movingTab:
# create a private widget that appears as the current (moving) tab
tabRect = self.tabRect(self.pressedIndex)
overlap = self.style().pixelMetric(
QStyle.PM_TabBarTabOverlap, None, self)
tabRect.adjust(-overlap, 0, overlap, 0)
pm = QPixmap(tabRect.size())
pm.fill(Qt.transparent)
qp = QStylePainter(pm, self)
opt = QStyleOptionTab()
self.initStyleOption(opt, self.pressedIndex)
if self.isVertical():
opt.rect.moveTopLeft(QPoint(0, overlap))
else:
opt.rect.moveTopLeft(QPoint(overlap, 0))
opt.position = opt.OnlyOneTab
qp.drawControl(QStyle.CE_TabBarTab, opt)
qp.end()
self.movingTab = self.MovingTab(self)
self.movingTab.setPixmap(pm)
self.movingTab.setGeometry(tabRect)
self.movingTab.show()
self.isMoving = True
self.startPos = event.pos()
isVertical = self.isVertical()
startRect = self.tabRect(self.pressedIndex)
if isVertical:
delta = delta.y()
translate = QPoint(0, delta)
startRect.moveTop(startRect.y() + delta)
else:
delta = delta.x()
translate = QPoint(delta, 0)
startRect.moveLeft(startRect.x() + delta)
movingRect = self.movingTab.geometry()
movingRect.translate(translate)
self.movingTab.setGeometry(movingRect)
if delta < 0:
overIndex = self.tabAt(startRect.topLeft())
else:
if isVertical:
overIndex = self.tabAt(startRect.bottomLeft())
else:
overIndex = self.tabAt(startRect.topRight())
if overIndex < 0:
return
# if the target tab is valid, move the current whenever its position
# is over the half of its size
overRect = self.tabRect(overIndex)
if isVertical:
if ((overIndex < self.pressedIndex and movingRect.top() < overRect.center().y()) or
(overIndex > self.pressedIndex and movingRect.bottom() > overRect.center().y())):
self.layoutTab(overIndex)
elif ((overIndex < self.pressedIndex and movingRect.left() < overRect.center().x()) or
(overIndex > self.pressedIndex and movingRect.right() > overRect.center().x())):
self.layoutTab(overIndex)
def mouseReleaseEvent(self,event):
super().mouseReleaseEvent(event)
if self.movingTab:
if self.pressedIndex > 0:
animation = self.createAnimation(
self.movingTab.geometry().topLeft(),
self.tabRect(self.pressedIndex).topLeft()
)
# restore the position faster than the default 250ms
animation.setDuration(80)
animation.finished.connect(self.finishedMovingTab)
animation.valueChanged.connect(self.movingTab.move)
else:
self.finishedMovingTab()
else:
self.pressedIndex = -1
self.isMoving = False
self.update()
def paintEvent(self, event):
if self.pressedIndex < 0:
super().paintEvent(event)
return
painter = QStylePainter(self)
tabOption = QStyleOptionTab()
for i in range(self.count()):
if i == self.pressedIndex and self.isMoving:
continue
self.initStyleOption(tabOption, i)
if i in self.animations:
tabOption.rect.moveTopLeft(self.animations[i].currentValue())
painter.drawControl(QStyle.CE_TabBarTab, tabOption)
I strongly suggest you to carefully read and try to understand the above code (along with the source code), as I didn't comment everything I've done, and it's very important to understand what's happening if you really need to do further subclassing in the future.
Update
If you need to alter the appearance of the dragged tab while moving it, you need to update its pixmap. You can just store the QStyleOptionTab when you create it, and then update when necessary. In the following example the WindowText (note that QPalette.Foreground is obsolete) color is changed whenever the index of the tab is changed:
def mouseMoveEvent(self,event):
# ...
if not self.movingTab:
# ...
self.movingOption = opt
def layoutTab(self, overIndex):
# ...
self.moveTab(oldIndex, overIndex)
pm = QPixmap(self.movingTab.pixmap.size())
pm.fill(Qt.transparent)
qp = QStylePainter(pm, self)
self.movingOption.palette.setColor(QPalette.WindowText, <someColor>)
qp.drawControl(QStyle.CE_TabBarTab, self.movingOption)
qp.end()
self.movingTab.setPixmap(pm)
Another small suggestion: while you can obviously use the indentation style you like, when sharing your code on public spaces like StackOverflow it's always better to stick to common conventions, so I suggest you to always provide your code with 4-spaces indentations; also, remember that there should always be a space after each comma separated variable, as it dramatically improves readability.
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'm following along with Effbot's Tkinter page here:
http://effbot.org/zone/editing-canvas-text-items.htm
The trouble I'm having is that after inserting the icursor, I can't seem to get it to go away!
How do I stop editing altogether?
As for examples, the one from the linked page will work:
# File: canvas-editing-example-1.py
#
# editing canvas items
#
# fredrik lundh, december 1998
#
# fredrik#pythonware.com
# http://www.pythonware.com
#
from tkinter import *
#Change to Tkinter to use python 2.x series
class MyCanvas(Frame):
def __init__(self, root):
Frame.__init__(self, root)
self.canvas = Canvas(self)
self.canvas.pack(fill=BOTH, expand=1)
# standard bindings
self.canvas.bind("<Double-Button-1>", self.set_focus)
self.canvas.bind("<Button-1>", self.set_cursor)
self.canvas.bind("<Key>", self.handle_key)
# add a few items to the canvas
self.canvas.create_text(50, 50, text="hello")
self.canvas.create_text(50, 100, text="world")
def highlight(self, item):
# mark focused item. note that this code recreates the
# rectangle for each update, but that's fast enough for
# this case.
bbox = self.canvas.bbox(item)
self.canvas.delete("highlight")
if bbox:
i = self.canvas.create_rectangle(
bbox, fill="white",
tag="highlight"
)
self.canvas.lower(i, item)
def has_focus(self):
return self.canvas.focus()
def has_selection(self):
# hack to work around bug in Tkinter 1.101 (Python 1.5.1)
return self.canvas.tk.call(self.canvas._w, 'select', 'item')
def set_focus(self, event):
if self.canvas.type(CURRENT) != "text":
return
self.highlight(CURRENT)
# move focus to item
self.canvas.focus_set() # move focus to canvas
self.canvas.focus(CURRENT) # set focus to text item
self.canvas.select_from(CURRENT, 0)
self.canvas.select_to(CURRENT, END)
def set_cursor(self, event):
# move insertion cursor
item = self.has_focus()
if not item:
return # or do something else
# translate to the canvas coordinate system
x = self.canvas.canvasx(event.x)
y = self.canvas.canvasy(event.y)
self.canvas.icursor(item, "#%d,%d" % (x, y))
self.canvas.select_clear()
def handle_key(self, event):
# widget-wide key dispatcher
item = self.has_focus()
if not item:
return
insert = self.canvas.index(item, INSERT)
if event.char >= " ":
# printable character
if self.has_selection():
self.canvas.dchars(item, SEL_FIRST, SEL_LAST)
self.canvas.select_clear()
self.canvas.insert(item, "insert", event.char)
self.highlight(item)
elif event.keysym == "BackSpace":
if self.has_selection():
self.canvas.dchars(item, SEL_FIRST, SEL_LAST)
self.canvas.select_clear()
else:
if insert > 0:
self.canvas.dchars(item, insert-1, insert)
self.highlight(item)
# navigation
elif event.keysym == "Home":
self.canvas.icursor(item, 0)
self.canvas.select_clear()
elif event.keysym == "End":
self.canvas.icursor(item, END)
self.canvas.select_clear()
elif event.keysym == "Right":
self.canvas.icursor(item, insert+1)
self.canvas.select_clear()
elif event.keysym == "Left":
self.canvas.icursor(item, insert-1)
self.canvas.select_clear()
else:
pass # print event.keysym
# try it out (double-click on a text to enable editing)
c = MyCanvas(Tk())
c.pack()
mainloop()
After you double click on one of the items to be edited, I cannot make the cursor go away; I've tried moving focus and setting the index to -1, but neither seems to work.
self.canvas.focus("")
To remove focus from the item, call this method with an empty string. Reference
you can add the following
self.canvas.focus_set() # move focus to canvas window
self.canvas.focus("") # remove focus from the current item that has it
To
def set_focus(self, event):
if self.canvas.type(CURRENT) != "text":
#Here
return
So when the user double-clicks on any part or item of the canvas that isn't created by canvas.create_text the focus is removed from the current "text" item, hence stopping the edit.
Plus you can add
self.canvas.delete("highlight")
to remove the white rectangle around the text, when focus is removed.
I'm making simple game - there're 8 ovals, they should be clickable and moveable. After click on an oval, the oval is following cursor. The target is to get the oval into rectangle, if you release mouse button in rectangle, the oval disappears. If you release mouse button outside rectangle, the oval should appear on it's initial position. I made this program and it works, but only for one oval. I need it works for all ovals. There's my code, any idea what to change, please?
import tkinter, random
class Desktop:
array = [(50,50,70,70),(100,50,120,70),(150,50,170,70),(150,100,170,120),
(150,150,170,170),(100,150,120,170),(50,150,70,170),(50,100,70,120)]
def __init__(self):
self.canvas = tkinter.Canvas(width=400,height=400)
self.canvas.pack()
self.canvas.create_rectangle(100,250,300,350)
for i in range(len(self.array)):
self.__dict__[f'oval{i}'] = self.canvas.create_oval(self.array[i], fill='brown',tags='id')
self.canvas.tag_bind('id','<B1-Motion>',self.move)
self.canvas.tag_bind('id','<ButtonRelease-1>',self.release)
def move(self, event):
self.canvas.coords(self.oval0,event.x-10,event.y-10,event.x+10,event.y+10)
def release(self, event):
if event.x>100 and event.x<300 and event.y>250 and event.y<350:
self.canvas.delete(self.oval0)
else:
self.canvas.coords(self.oval0,self.array[0])
d = Desktop()
I bind three methods
<ButtonPress-1> to get ID of clicked item and assign to self.selected
<B1-Motion> to move item using self.selected
<ButtonRelease-1> to release or delete moved item using self.selected
Code
import tkinter as tk
import random
class Desktop:
array = [(50,50,70,70),(100,50,120,70),(150,50,170,70),(150,100,170,120),
(150,150,170,170),(100,150,120,170),(50,150,70,170),(50,100,70,120)]
def __init__(self, master):
self.canvas = tk.Canvas(master, width=400, height=400)
self.canvas.pack()
self.canvas.create_rectangle(100, 250, 300, 350)
# to keep all IDs and its start position
self.ovals = {}
for item in self.array:
# create oval and get its ID
item_id = self.canvas.create_oval(item, fill='brown', tags='id')
# remember ID and its start position
self.ovals[item_id] = item
self.canvas.tag_bind('id', '<ButtonPress-1>', self.start_move)
self.canvas.tag_bind('id', '<B1-Motion>', self.move)
self.canvas.tag_bind('id', '<ButtonRelease-1>', self.stop_move)
# to remember selected item
self.selected = None
def start_move(self, event):
# find all clicked items
self.selected = self.canvas.find_overlapping(event.x, event.y, event.x, event.y)
# get first selected item
self.selected = self.selected[0]
def move(self, event):
# move selected item
self.canvas.coords(self.selected, event.x-10, event.y-10, event.x+10,event.y+10)
def stop_move(self, event):
# delete or release selected item
if 100 < event.x < 300 and 250 < event.y < 350:
self.canvas.delete(self.selected)
del self.ovals[self.selected]
else:
self.canvas.coords(self.selected, self.ovals[self.selected])
# clear it so you can use it to check if you are draging item
self.selected = None
root = tk.Tk()
d = Desktop(root)
root.mainloop()
EDIT: using event.widget.find_withtag("current")[0] I can get first selected item, and I can skip <ButtonPress-1>.
import tkinter as tk
import random
class Desktop:
array = [(50,50,70,70),(100,50,120,70),(150,50,170,70),(150,100,170,120),
(150,150,170,170),(100,150,120,170),(50,150,70,170),(50,100,70,120)]
def __init__(self, master):
self.canvas = tk.Canvas(master, width=400, height=400)
self.canvas.pack()
self.canvas.create_rectangle(100, 250, 300, 350)
# to keep all IDs and its start position
self.ovals = {}
for item in self.array:
# create oval and get its ID
item_id = self.canvas.create_oval(item, fill='brown', tags='id')
# remember ID and its start position
self.ovals[item_id] = item
self.canvas.tag_bind('id', '<B1-Motion>', self.move)
self.canvas.tag_bind('id', '<ButtonRelease-1>', self.stop_move)
def move(self, event):
# get selected item
selected = event.widget.find_withtag("current")[0]
# move selected item
self.canvas.coords(selected, event.x-10, event.y-10, event.x+10,event.y+10)
def stop_move(self, event):
# get selected item
selected = event.widget.find_withtag("current")[0]
# delete or release selected item
if 100 < event.x < 300 and 250 < event.y < 350:
self.canvas.delete(selected)
del self.ovals[selected]
else:
self.canvas.coords(selected, self.ovals[selected])
root = tk.Tk()
d = Desktop(root)
root.mainloop()
EDIT: added del self.ovals[selected]
It is possible to create events for when the mouse pointer enters/leaves the entire Listbox using <Enter>/<Leave>. How can I track when the mouse enters or leaves a specific entry (row) in the Listbox?
I want to color in different color the background of the entry over which the mouse pointer is currently located.
Here's an (half) attempt to do what you want by binding to the <Motion> event instead of the pair <Enter> and <Leave>. This because <Enter> is raised only when we enter the Listbox from outside it, but once we are inside a Listbox with the mouse, no other <Enter> event will be raised, and we cannot keep track of which item the mouse is above.
Calling a function every time the mouse moves might result in an overload of work, so I don't think this feature is worthing doing it (in this way).
The program does not still work perfectly, and I still have to understand why: basically, sometimes the item's background and font color are not changed properly, there's some kind of delay or something.
from tkinter import *
class CustomListBox(Listbox):
def __init__(self, master=None, *args, **kwargs):
Listbox.__init__(self, master, *args, **kwargs)
self.bg = "white"
self.fg = "black"
self.h_bg = "#eee8aa"
self.h_fg = "blue"
self.current = -1 # current highlighted item
self.fill()
self.bind("<Motion>", self.on_motion)
self.bind("<Leave>", self.on_leave)
def fill(self, number=15):
"""Fills the listbox with some numbers"""
for i in range(number):
self.insert(END, i)
self.itemconfig(i, {"bg": self.bg})
self.itemconfig(i, {"fg": self.fg})
def reset_colors(self):
"""Resets the colors of the items"""
for item in self.get(0, END):
self.itemconfig(item, {"bg": self.bg})
self.itemconfig(item, {"fg": self.fg})
def set_highlighted_item(self, index):
"""Set the item at index with the highlighted colors"""
self.itemconfig(index, {"bg": self.h_bg})
self.itemconfig(index, {"fg": self.h_fg})
def on_motion(self, event):
"""Calls everytime there's a motion of the mouse"""
print(self.current)
index = self.index("#%s,%s" % (event.x, event.y))
if self.current != -1 and self.current != index:
self.reset_colors()
self.set_highlighted_item(index)
elif self.current == -1:
self.set_highlighted_item(index)
self.current = index
def on_leave(self, event):
self.reset_colors()
self.current = -1
if __name__ == "__main__":
root = Tk()
CustomListBox(root).pack()
root.mainloop()
Note that I have used from tkinter import * for typing faster, but I recommend you to use import tkinter as tk.
No, you cannot track when it enters/leaves a specific row. However, you can track when it enters/leaves the widget, and you can compute which item the mouse is over by using the index method of the listbox. If you give an index of the form "#x,y", it will return the numerical index.
For example:
self.listbox.bind("<Enter>", self.on_enter)
...
def on_enter(self, event):
index = self.listbox.index("#%s,%s" % (event.x, event.y))
...