How to remove icursor from Tkinter canvas text item? - python

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.

Related

tkinter Loop Through List On Key Press

Im am trying to create a command history with the tkinter Entry widget and Up/Down arrow keys. It is for a very basic MUD client that I am trying to come up with in my spare time.
# The list that hold the last entered commands.
self.previousCommands = []
# Binding the up arrow key to the Entry widget.
self.view.Tabs.tab1.E.bind('<Up>', self.checkCommandHistory)
# The function that should cycle through the commands.
def checkCommandHistory(self, event):
comm = self.previousCommands[-1]
self.view.Tabs.tab1.E.delete(0, END)
self.view.Tabs.tab1.E.insert(0, comm)
Basically what I am trying to do is cycle through a list that contains the history of the last entered commands by using the up and down arrow keys. This behavior is common in most MUD clients but I cannot see exactly how this is achieved.
Using the code above I am able to bind the Up arrow key press to the Entry widget and on pressed it does insert the last entered command. If I were to keep pressing the up arrow key I would like it to keep cycling through the last entered commands in the list.
Question: cycle the elements in a list by pressing Up/Down arrow key bound to Entry widget
Create a class object inherited from tk.Entry.
import tkinter as tk
class EntryHistory(tk.Entry):
def __init__(self, parent, init_history=None):
super().__init__(parent)
self.bind('<Return>', self.add)
self.bind('<Up>', self.up_down)
self.bind('<Down>', self.up_down)
self.history = []
self.last_idx = None
if init_history:
for e in init_history:
self.history.append(e)
self.last_idx = len(self.history)
def up_down(self, event):
if not self.last_idx is None:
self.delete(0, tk.END)
if event.keysym == 'Up':
if self.last_idx > 0:
self.last_idx -= 1
else:
self.last_idx = len(self.history) -1
elif event.keysym == 'Down':
if self.last_idx < len(self.history) -1:
self.last_idx += 1
else:
self.last_idx = 0
self.insert(0, self.history[self.last_idx])
def add(self, event):
self.history.append(self.get())
self.last_idx = len(self.history) - 1
if __name__ == "__main__":
root = tk.Tk()
entry = EntryHistory(root, init_history=['test 1', 'test 2', 'test 3'])
entry.grid(row=0, column=0)
root.mainloop()
Tested with Python: 3.5
Here is a non-OOP based version.
import tkinter as tk
root = tk.Tk()
history = []
history_index = -1
def runCommand(event):
command = cmd.get()
print("Running command: {}".format(command))
cmd.set("")
history.append(command)
history_index = -1
print(history)
def cycleHistory(event):
global history_index
if len(history):
try:
comm = history[history_index]
history_index -= 1
except IndexError:
history_index = -1
comm = history[history_index]
cmd.set(comm)
cmd = tk.StringVar(root)
e = tk.Entry(root,textvariable=cmd)
e.grid()
e.bind("<Return>",runCommand)
e.bind("<Up>",cycleHistory)
e.focus()
root.mainloop()
Basically, you just need to keep an record of which item from history you should show next time the user presses the up arrow. I use the history_index field to do this. history_index is set to -1 initially and each time it is accessed it is decremented by 1.
I use the except IndexError exception to reset the index to -1 once there is no more history to read from the list and to start from the beginning again.
Pressing the return key, runs the command, adds it to the history and resets the index to -1.

Moving more objects in Python tkinter

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]

Tkinter how to catch this particular key event

Problem Description
I have an application in Tkinter that uses a Listbox that displays search results. When I press command + down arrow key, I am putting the focus from the search field to the first item in the Listbox. This is exactly how I want the behaviour but instead for just the down arrow.
However, I am already binding the down arrow to this Listbox by self.bind("<Down>", self.moveDown). I can not understand why command + down works while simply down (to which I literally bind'ed it to) does not. Specifically the result of pressing the down arrow is the following
While pressing command + down gives the intended result:
How can I let down behave just like command + down, and what is the reason why command is required at all?
Code snippets
def matches(fieldValue, acListEntry):
pattern = re.compile(re.escape(fieldValue) + '.*', re.IGNORECASE)
return re.match(pattern, acListEntry)
root = Tk()
img = ImageTk.PhotoImage(Image.open('imgs/giphy.gif'))
panel = Label(root, image=img)
panel.grid(row=1, column=0)
entry = AutocompleteEntry(autocompleteList, panel, root, matchesFunction=matches)
entry.grid(row=0, column=0)
root.mainloop()
With AutocompleteEntry being:
class AutocompleteEntry(Tkinter.Entry):
def __init__(self, autocompleteList, df, panel, rdi, *args, **kwargs):
self.df = df
self.product_row_lookup = {key:value for value, key in enumerate(autocompleteList)}
temp = df.columns.insert(0, 'Product_omschrijving')
temp = temp.insert(1, 'grams')
self.result_list = pd.DataFrame(columns=temp)
self.panel = panel
self.rdi = rdi
# self.bind('<Down>', self.handle_keyrelease)
# Listbox length
if 'listboxLength' in kwargs:
self.listboxLength = kwargs['listboxLength']
del kwargs['listboxLength']
else:
self.listboxLength = 8
# Custom matches function
if 'matchesFunction' in kwargs:
self.matchesFunction = kwargs['matchesFunction']
del kwargs['matchesFunction']
else:
def matches(fieldValue, acListEntry):
pattern = re.compile('.*' + re.escape(fieldValue) + '.*', re.IGNORECASE)
return re.match(pattern, acListEntry)
self.matchesFunction = matches
Entry.__init__(self, *args, **kwargs)
self.focus()
self.autocompleteList = autocompleteList
self.var = self["textvariable"]
if self.var == '':
self.var = self["textvariable"] = StringVar()
self.var.trace('w', self.changed)
self.bind("<Right>", self.selection)
self.bind("<Up>", self.moveUp)
self.bind("<Down>", self.moveDown)
self.bind("<Return>", self.selection)
self.listboxUp = False
self._digits = re.compile('\d')
def changed(self, name, index, mode):
if self.var.get() == '':
if self.listboxUp:
self.listbox.destroy()
self.listboxUp = False
else:
words = self.comparison()
if words:
if not self.listboxUp:
self.listbox = Listbox(width=self["width"], height=self.listboxLength)
self.listbox.bind("<Button-1>", self.selection)
self.listbox.bind("<Right>", self.selection)
self.listbox.bind("<Down>", self.moveDown)
self.listbox.bind("<Tab>", self.selection)
self.listbox.place(x=self.winfo_x(), y=self.winfo_y() + self.winfo_height())
self.listboxUp = True
self.listbox.delete(0, END)
for w in words:
self.listbox.insert(END, w)
else:
if self.listboxUp:
self.listbox.destroy()
self.listboxUp = False
else:
string = self.get()
if '.' in string:
write_to_file(self, string)
def contains_digits(self, d):
return bool(self._digits.search(d))
def selection(self, event):
if self.listboxUp:
string = self.listbox.get(ACTIVE)
self.var.set(string + ' ')
self.listbox.destroy()
self.listboxUp = False
self.icursor(END)
def moveDown(self, event):
self.focus()
if self.listboxUp:
if self.listbox.curselection() == ():
index = '0'
print "ok"
else:
index = self.listbox.curselection()[0]
print "blah"
if index != END:
self.listbox.selection_clear(first=index)
print "noo"
if index != '0':
index = str(int(index) + 1)
self.listbox.see(index) # Scroll!
self.listbox.selection_set(first=index)
self.listbox.activate(index)
else:
print "not up"
def comparison(self):
return [w for w in self.autocompleteList if self.matchesFunction(self.var.get(), w)]
Both command+down and down should produce the same output excepted that down also types question mark  onto the entry which made the last letter typed is the question mark box.
This is because pressing command, your computer checks the option menu to see if there's a shortcut with that key, if there isn't any, it will not do anything. While tkinter registered the down button as being pressed, so the event was triggered.
In contrast, With out pressing command, the Entry first displays the value of "down", which there isn't any, then executes the event binding, what you can do is, in the event, remove the last letter of the Entry. You can do so by self.delete(len(self.get())-1) in your event. Or add a return 'break' at the end of your event to prevent it from being typed.
Unfortunately, it's really hard to understand your real problem because you've posted too much unrelated code and not enough related code. It seems to me that what you're trying to accomplish is to let the user press the down or up arrow while an entry has focus, and have that cause the selection in a listbox to move down or up. Further, it seems that part of the problem is that you're seeing characters in the entry widget that you do not want to see when you press down or up.
If that is the problem, the solution is fairly simple. All you need to do is have your binding return the string "break" to prevent the default binding from being processed. It is the default binding that is inserting the character.
Here is an example. Run the example, and press up and down to move the selection of the listbox. I've left out all of the code related to autocomplete so you can focus on how the event binding works.
import Tkinter as tk
class Example(object):
def __init__(self):
self.root = tk.Tk()
self.entry = tk.Entry(self.root)
self.listbox = tk.Listbox(self.root, exportselection=False)
for i in range(30):
self.listbox.insert("end", "Item #%s" % i)
self.entry.pack(side="top", fill="x")
self.listbox.pack(side="top", fill="both", expand=True)
self.entry.bind("<Down>", self.handle_updown)
self.entry.bind("<Up>", self.handle_updown)
def start(self):
self.root.mainloop()
def handle_updown(self, event):
delta = -1 if event.keysym == "Up" else 1
curselection = self.listbox.curselection()
if len(curselection) == 0:
index = 0
else:
index = max(int(curselection[0]) + delta, 0)
self.listbox.selection_clear(0, "end")
self.listbox.selection_set(index, index)
return "break"
if __name__ == "__main__":
Example().start()
For a fairly thorough explanation of what happens when an event is triggered, see this answer: https://stackoverflow.com/a/11542200/7432
Again, leaving aside the autocomplete requirement, I came up with a solution that uses that standard available commands and events for Listbox and Scrollbar. <<ListboxSelect>> lets you capture changes in selection from any of the lists and align the others. In addition, the Scrollbar and Listbox callbacks are directed to a routing function that passes things on to all of the listboxes.
# updownmultilistbox.py
# 7/24/2020
#
# incorporates vsb to propagate scrolling across lists
#
import tkinter as tk
class Example(object):
def __init__(self):
self.root = tk.Tk()
self.listOfListboxes = []
# self.active_lb = None
self.vsb = tk.Scrollbar(orient='vertical', command=self.OnVsb)
self.vsb.pack(side='right', fill='y')
self.lb1 = tk.Listbox(self.root, exportselection=0,
selectmode= tk.SINGLE, yscrollcommand=self.vsb_set)
self.lb2 = tk.Listbox(self.root, exportselection=0,
selectmode=tk.SINGLE, yscrollcommand=self.vsb_set)
self.lb3 = tk.Listbox(self.root, exportselection=0,
selectmode=tk.SINGLE, yscrollcommand=self.vsb_set)
self.listOfListboxes.append(self.lb1)
self.listOfListboxes.append(self.lb2)
self.listOfListboxes.append(self.lb3)
for i in range(30):
self.lb1.insert("end", "lb1 Item #%s" % i)
self.lb2.insert("end", "lb2 Item #%s" % i)
self.lb3.insert("end", "lb3 Item #%s" % i)
self.lb1.pack(side="left", fill="both", expand=True)
self.lb2.pack(side="left", fill="both", expand=True)
self.lb3.pack(side="left", fill="both", expand=True)
for lb in self.listOfListboxes:
lb.bind('<<ListboxSelect>>', self.handle_select)
for lb in self.listOfListboxes:
lb.selection_set(0)
lb.activate(0)
self.listOfListboxes[0].focus_force()
def start(self):
self.root.title('updownmultilistbox')
self.root.mainloop()
def OnVsb(self, *args):
for lb in self.listOfListboxes:
lb.yview(*args)
def vsb_set(self, *args):
print ('vsb_set args: ', *args)
self.vsb.set(*args)
for lb in self.listOfListboxes:
lb.yview_moveto(args[0])
def handle_select(self, event):
# set evey list to the same selection
print ('select handler: ', event, event.widget.curselection())
# self.active_lb = event.widget
for lb in self.listOfListboxes:
if lb != event.widget:
lb.selection_clear(0, 'end') # have to avoid this for the current widget
lb.selection_set(event.widget.curselection())
lb.activate(event.widget.curselection())
if __name__ == "__main__":
Example().start()

Python Tkinter: Listbox mouse enter event for a specific entry

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))
...

tkinter listbox drag and drop with python

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'

Categories

Resources