Autocomplete for tkinter Entry widgets

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"]

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

• Use a list-comprehension(LC) instead of a map with a lambda. Another advantage of using LC is that it would work as is in Python 2 and 3, while in 2 you would need a list() call on it. In terms of performance LC is almost always faster than map unless we are using map 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 or list.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.

• 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 the dict.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? – Daniel Aug 27 '17 at 18:14
• @Coal_ reverse used the value returned by the key function, it doesn't care about how key function was implemented, so it will work with lambda as well. Regarding 2/3 compatibility I didn't mean it is not compatible right now, but we can improve it a bit by using xrange() instead of range() in Python 2. Using six.range you will xrange() in 2 and range() in 3. – Ashwini Chaudhary Aug 27 '17 at 18:23
• Thanks for clarifying. Regarding reverse, I just checked it out and that is indeed quite interesting. I'm confident when I tried to use sorted(list(matches.items()), key=lambda match: match[1], reverse=True) yesterday, it failed. Oh well. – Daniel Aug 27 '17 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:

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]