3
\$\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.

\$\endgroup\$
1
\$\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.

\$\endgroup\$
  • \$\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\$ – Daniel Aug 27 '17 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\$ – Ashwini Chaudhary Aug 27 '17 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\$ – Daniel Aug 27 '17 at 18:48
3
\$\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]
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.