Since tkinter doesn't provide easy autocompletion settings for tkinter.Entry
widgets, I decided to write a widget with autocompletion functionality myself. I've released it on GitHub here and it includes a slightly more comprehensive guide.
I am especially worried about ease-of-use. Since the functionality depends on both a tkinter.Entry
and a tkinter.Listbox
widget, the Autocomplete
class doesn't derive from tkinter.Entry
widget but from a tkinter.Frame
, which, to me, seems unusual.
If possible, I'd love to get feedback on my documentation as well. I think the docstrings are a bit too personal, as though you're reading a tutorial, and not very straightforward.
Here's my code:
try:
import tkinter as tk
from tkinter import ttk
except ImportError:
# Python 2
import Tkinter as tk
import ttk
__all__ = ["Autocomplete"]
NO_RESULTS_MESSAGE = "No results found for '{}'"
def _longest_common_substring(s1, s2):
"""Get the longest common substring for two strings.
Source: [1]
"""
m = [[0] * (1 + len(s2)) for i in range(1 + len(s1))]
longest, x_longest = 0, 0
for x in range(1, 1 + len(s1)):
for y in range(1, 1 + len(s2)):
if s1[x - 1] == s2[y - 1]:
m[x][y] = m[x - 1][y - 1] + 1
if m[x][y] > longest:
longest = m[x][y]
x_longest = x
else:
m[x][y] = 0
return s1[x_longest - longest: x_longest]
class Autocomplete(tk.Frame, object):
"""An autocomplete object is a container for tk.Entry and tk.Listbox
widgets. Together, these widgets can provide end users with relevant
results (autocomplete entries).
Methods defined here:
__init__(): The init method initializes a new tk.Frame object, as
well as the tk.Entry and tk.Listbox widgets. These can
be modified by accessing respectively
`Autocomplete.entry_widget` and
`Autocomplete.listbox_widget`.
build(): The build method sets up the autocompletion settings for
the tk.Entry widget. It is mandatory to call build()
to be able to display the frame.
_update_autocomplete(): The _update_autocomplete method evaluates
whatever the tk.Entry widget contains and
updates the tk.Listbox widget to display
relevant matches. It is called on
<KeyRelease> and should never be called
explicitly.
_select_entry(): The _select_entry method replaces the textvariable
connected to the tk.Entry widget with the current
listbox selection. It is called on
<<ListboxSelect>> and should never be called
explicitly.
Constants defined here:
DEFAULT_LISTBOX_HEIGHT: The default 'height' attribute for the
tk.Listbox widget. This value directly
corresponds to the maximum amount of results
shown in the tk.Listbox widget at once.
Note that the user may view more results
by scrolling vertically.
--- DEFAULT = 5 ---
DEFAULT_LISTBOX_WIDTH: The default 'width' attribute for the
tk.Listbox widget. This value directly
corresponds to the maximum amount of
characters shown per result at once.
Note that the user may view more characters
by scrolling horizontally.
--- DEFAULT = 25 ---
DEFAULT_ENTRY_WIDTH: The default 'width' attribute for the tk.Entry
widget.
--- DEFAULT = 25 ---
"""
DEFAULT_LISTBOX_HEIGHT = 5
DEFAULT_LISTBOX_WIDTH = 25
DEFAULT_ENTRY_WIDTH = 25
def __init__(self, *args, **kwargs):
"""Constructor.
Initialize a new tk.Frame object and create a tk.Entry and
tk.Listbox widget for later configuration.
---
Arguments:
All arguments passed here will be directly passed to a new
tk.Frame instance. For further help:
>>> help(tk.Frame)
---
Example:
>>> autocomplete = Autocomplete(tk.Tk())
>>> autocomplete["width"] = 50
>>> # Corresponds to tk.Frame["width"]
---
Returns:
None
"""
super(Autocomplete, self).__init__(*args, **kwargs)
self.text = tk.StringVar()
self.entry_widget = tk.Entry(
self,
textvariable=self.text,
width=self.DEFAULT_ENTRY_WIDTH
)
self.listbox_widget = tk.Listbox(
self,
height=self.DEFAULT_LISTBOX_HEIGHT,
width=self.DEFAULT_LISTBOX_WIDTH
)
def build(self, entries, match_exact=False, case_sensitive=False,
no_results_message=NO_RESULTS_MESSAGE):
"""Set up the tk.Entry and tk.Listbox widgets.
---
Arguments:
* entries: [iterable] Autocompletion entries.
* match_exact: [bool] Treat only entries that start with
the current entry as matches.
If False, select the most relevant results
based on the length of the longest common
substring (LCS).
Defaults to False.
* case_senstive: [bool] Treat only entries with the exact same
characters as matches. If False, allow
capitalization to be mixed.
Defaults to False.
* no_results_message: The message to display if no matches
could be found for the current entry.
May include a formatting key to display
the current entry. If None, the tk.Listbox
widget will be hidden until the next
<KeyRelease> event.
---
Example:
>>> autocomplete = Autocomplete(tk.Root())
>>> autocomplete.build(
... entries=["Foo", "Bar"],
... case_sensitive=True,
... no_results_message="<No results for '{}'>"
... )
---
Returns:
None
"""
if not case_sensitive:
entries = list(map(
lambda entry: entry.lower(), entries
))
self._case_sensitive = case_sensitive
self._entries = entries
self._match_exact = match_exact
self._no_results_message = no_results_message
self.entry_widget.bind("<KeyRelease>", self._update_autocomplete)
self.entry_widget.focus_set()
self.entry_widget.grid(column=0, row=0)
self.listbox_widget.bind("<<ListboxSelect>>", self._select_entry)
self.listbox_widget.grid(column=0, row=1)
self.listbox_widget.grid_forget()
def _update_autocomplete(self, event):
"""Update the tk.Listbox widget to display new matches.
Do not call explicitly.
"""
self.listbox_widget.delete(0, tk.END)
self.listbox_widget["height"] = self.DEFAULT_LISTBOX_HEIGHT
text = self.text.get()
if not self._case_sensitive:
text = text.lower()
if not text:
self.listbox_widget.grid_forget()
elif not self._match_exact:
matches = {}
for entry in self._entries:
lcs = len(_longest_common_substring(text, entry))
if lcs:
matches[entry] = lcs
sorted_items = sorted(list(matches.items()),
key=lambda match: match[1])
for item in sorted_items[::-1]:
self.listbox_widget.insert(tk.END, item[0])
else:
for entry in self._entries:
if entry.strip().startswith(text):
self.listbox_widget.insert(tk.END, entry)
listbox_size = self.listbox_widget.size()
if not listbox_size:
if self._no_results_message is None:
self.listbox_widget.grid_forget()
else:
try:
self.listbox_widget.insert(
tk.END,
self._no_results_message.format(text)
)
except UnicodeEncodeError:
self.listbox_widget.insert(
tk.END,
self._no_results_message.format(
text.encode("utf-8")
)
)
if listbox_size <= self.listbox_widget["height"]:
# In case there's less entries than the maximum
# amount of entries allowed, resize the listbox.
self.listbox_widget["height"] = listbox_size
self.listbox_widget.grid()
else:
if listbox_size <= self.listbox_widget["height"]:
self.listbox_widget["height"] = listbox_size
self.listbox_widget.grid()
def _select_entry(self, event):
"""Set the textvariable corresponding to self.entry_widget
to the value currently selected.
Do not call explicitly.
"""
widget = event.widget
value = widget.get(int(widget.curselection()[0]))
self.text.set(value)
If you're wondering, the source actually points to a Wikibooks entry on the LCS problem, but I've censored it because URL shorteners are not allowed here.
2 Answers 2
Use a list-comprehension(LC) instead of a
map
with alambda
. Another advantage of using LC is that it would work as is in Python 2 and 3, while in 2 you would need alist()
call on it. In terms of performance LC is almost always faster thanmap
unless we are usingmap
with a builtin function. Check: Performance Tips: Loops.Hence replace
entries = list(map( lambda entry: entry.lower(), entries ))
with:
entries = [entry.lower() for entry in entries]
Instead of sorting a list and then reversing it, you can reverse sort it by passing
reverse=True
to sorted orlist.sort()
. Also considering we are only interested in the keys and values are being used only for sorting we can sort by keys.Hence we can replace:
sorted_items = sorted(list(matches.items()), key=lambda match: match[1]) for item in sorted_items[::-1]: self.listbox_widget.insert(tk.END, item[0])
with:
for item in sorted(matches, key=matches.get, reverse=True): self.listbox_widget.insert(tk.END, item)
If you want to make your code 2/3 compatible then you could use
six
as well, though adds an extra dependency which may not make sense for a small codebase.
-
\$\begingroup\$ Thank you for your review. The
reverse
argument can't be combined with a custom key 'lambda', but your solution seems to work due to thedict.get
method instead, good one! As for making it backwards compatible, it already is fully compatible with Python 2.7.1 and I assume it works on all Python 3.X versions. Can you explain how it is not compatible, if at all, with certain Python versions? \$\endgroup\$Daniel– Daniel2017年08月27日 18:14:26 +00:00Commented Aug 27, 2017 at 18:14 -
\$\begingroup\$ @Coal_
reverse
used the value returned by the key function, it doesn't care about howkey
function was implemented, so it will work withlambda
as well. Regarding 2/3 compatibility I didn't mean it is not compatible right now, but we can improve it a bit by usingxrange()
instead ofrange()
in Python 2. Usingsix.range
you willxrange()
in 2 andrange()
in 3. \$\endgroup\$Ashwini Chaudhary– Ashwini Chaudhary2017年08月27日 18:23:53 +00:00Commented Aug 27, 2017 at 18:23 -
\$\begingroup\$ Thanks for clarifying. Regarding
reverse
, I just checked it out and that is indeed quite interesting. I'm confident when I tried to usesorted(list(matches.items()), key=lambda match: match[1], reverse=True)
yesterday, it failed. Oh well. \$\endgroup\$Daniel– Daniel2017年08月27日 18:48:17 +00:00Commented Aug 27, 2017 at 18:48
Very, very, very nice code! It's very difficult to find something for an improvement. So only some not very important things:
In your list comprehension
m = [[0] * (1 + len(s2)) for i in range(1 + len(s1))]
you never use the variable i
- so it may be replaced by the double underline symbol (__
)
m = [[0] * (1 + len(s2)) for __ in range(1 + len(s1))]
This appear to be a little complicated:
if not case_sensitive:
entries = list(map(
lambda entry: entry.lower(), entries
))
Maybe it would be replaced by
if not case_sensitive:
entries = [entry.lower() for entry in entries]