4
\$\begingroup\$

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.

asked Aug 27, 2017 at 12:54
\$\endgroup\$
0

2 Answers 2

2
\$\begingroup\$
  • 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.

answered Aug 27, 2017 at 16:25
\$\endgroup\$
3
  • \$\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 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? \$\endgroup\$ Commented Aug 27, 2017 at 18:14
  • \$\begingroup\$ @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. \$\endgroup\$ Commented 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 use sorted(list(matches.items()), key=lambda match: match[1], reverse=True) yesterday, it failed. Oh well. \$\endgroup\$ Commented Aug 27, 2017 at 18:48
4
\$\begingroup\$

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]
answered Aug 27, 2017 at 14:12
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.