Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions Lib/tkinter/auto_complete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
try:
import tkinter as tk
from tkinter import ttk
except ImportError:
# Python 2
import Tkinter as tk
import ttk
Comment on lines +4 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module will only ever be on 3.10+ so this is redundant

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will never be run on Python 2, so a check like this is not needed


__all__ = ["AutocompleteEntry"]

NO_RESULTS_MESSAGE = "No results found for '{}'"


class AutocompleteEntry(tk.Frame, object):
Comment thread
RajvirSingh1313 marked this conversation as resolved.
"""A container for `tk.Entry` and `tk.Listbox` widgets.
An instance of AutocompleteEntry is actually a `tk.Frame`,
containing the `tk.Entry` and `tk.Listbox` widgets needed
to display autocompletion entries. Thus, you can initialize
it with the usual arguments to `tk.Frame`.
Constants:
LISTBOX_HEIGHT -- Default height for the `tk.Listbox` widget
LISTBOX_WIDTH -- Default width for the `tk.Listbox` widget
ENTRY_WIDTH -- Default width for the `tk.Entry` widget
Methods:
__init__ -- Set up the `tk.Listbox` and `tk.Entry` widgets
build -- Build a list of autocompletion entries
_update_autocomplete -- Internal method
_select_entry -- Internal method
_cycle_up -- Internal method
_cycle_down -- Internal method
Comment on lines +27 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't bother listing internal methods in the docstring (users are almost always just interested in the public API)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just don't mention _update_autocomplete, _select_entry, _cycle_up & _cycle_down here

Other attributes:
text -- StringVar object associated with the `tk.Entry` widget

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, this should be private

entry -- The `tk.Entry` widget (access this directly if you
need to change styling)
listbox -- The `tk.Listbox` widget (access this directly if
you need to change styling)
"""
LISTBOX_HEIGHT = 5
LISTBOX_WIDTH = 25
ENTRY_WIDTH = 25
Comment on lines +38 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to specify these in the kwargs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user should be able to give these as keyword arguments to __init__ rather than having to change this code or doing something like AutocompleteEntry.LISTBOX_HEIGHT = 6


def __init__(self, master, *args, **kwargs):
"""Constructor.
Create the `self.entry` and `self.listbox` widgets.
Note that these widgets are not yet displayed and will only
be visible when you call `self.build`.
Arguments:
master -- The master tkinter widget
Returns:
None
"""
super(AutocompleteEntry, self).__init__(*args, **kwargs)
Comment thread
RajvirSingh1313 marked this conversation as resolved.
self.text = tk.StringVar()
self.entry = tk.Entry(
Comment thread
RajvirSingh1313 marked this conversation as resolved.
self,
textvariable=self.text,
width=self.ENTRY_WIDTH
)
self.listbox = tk.Listbox(
Comment thread
RajvirSingh1313 marked this conversation as resolved.
self,
height=self.LISTBOX_HEIGHT,
width=self.LISTBOX_WIDTH
)

def build(
self,
entries,
Comment thread
RajvirSingh1313 marked this conversation as resolved.
max_entries=5,
case_sensitive=False,
no_results_message=NO_RESULTS_MESSAGE
):
"""Set up the autocompletion settings.
Binds <KeyRelease>, <<ListboxSelect>>, <Down> and <Up> for
smooth cycling between autocompletion entries.
Arguments:
entries -- An iterable containg autocompletion entries (strings)
max_entries -- [int] The maximum number of entries to display
case_sensitive -- [bool] Set to `True` to make autocompletion
case-sensitive
no_results_message -- [str] Message to display when no entries
match the current entry; you can use a
formatting identifier '{}' which will be
replaced with the entry at runtime
Returns:
None
"""
if not case_sensitive:
entries = [entry.lower() for entry in entries]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we sorted the list of entries and used bisect.bisect_left, we could massively increase performance for a large list of elements. This would mean we could only check if it started with X but that is what I initially expected the behaviour to be.


self._case_sensitive = case_sensitive
self._entries = entries
self._no_results_message = no_results_message
self._listbox_height = max_entries
Comment on lines +90 to +93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add some checks to ensure these values are what we want (e.g. case_sensitive is a bool). We should also check that A) no_results_message is an str and B) no_results_message can be formatted correctly (i.e. not raise an IndexError)


self.entry.bind("<KeyRelease>", self._update_autocomplete)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not trace self.text so we don't waste our time updating for an arrow key (for example)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.entry.bind("<KeyRelease>", self._update_autocomplete)
self.text.trace_add("write", self._update_autocomplete)

_update_autocomplete will need updating because there will now be 3 arguments rather than just 'event'

self.entry.focus()
self.entry.grid(column=0, row=0)

self.listbox.bind("<<ListboxSelect>>", self._select_entry)
self.listbox.grid(column=0, row=1)
Comment thread
RajvirSingh1313 marked this conversation as resolved.
self.listbox.grid_forget()
Comment thread
RajvirSingh1313 marked this conversation as resolved.
# Initially, the listbox widget doesn't show up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment should be above grid_forget

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this line one up


def _update_autocomplete(self, event):
"""Internal method.
Update `self.listbox` to display new matches.
"""
self.listbox.delete(0, tk.END)
self.listbox["height"] = self._listbox_height

text = self.text.get()
if not self._case_sensitive:
text = text.lower()
if not text:
self.listbox.grid_forget()
Comment thread
RajvirSingh1313 marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just return at this point?

else:
for entry in self._entries:
if text in entry.strip():
self.listbox.insert(tk.END, entry)

listbox_size = self.listbox.size()
if not listbox_size:
if self._no_results_message is None:
self.listbox.grid_forget()
Comment thread
RajvirSingh1313 marked this conversation as resolved.
else:
try:
self.listbox.insert(
tk.END,
self._no_results_message.format(text)
)
except UnicodeEncodeError:
self.listbox.insert(
tk.END,
self._no_results_message.format(
text.encode("utf-8")
)
)
Comment on lines +131 to +137

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the above comment about validating _no_results_message

if listbox_size <= self.listbox["height"]:
Comment thread
RajvirSingh1313 marked this conversation as resolved.
# In case there's less entries than the maximum
# amount of entries allowed, resize the listbox.
self.listbox["height"] = listbox_size
Comment on lines +138 to +141

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about this. Personally, if I were using this widget, I would prefer it to stay the same size (so my window or other widgets didn't keep resizing). If you think this is helpful, I would make it an option.

self.listbox.grid()
else:
if listbox_size <= self.listbox["height"]:
self.listbox["height"] = listbox_size
self.listbox.grid()
Comment on lines +144 to +146

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reduce code duplication, please make this a new method (I think bringing it out of the if would also be a viable solution, and possibly neater, if we added a return after the grid_forget on line 124).


def _select_entry(self, event):
"""Internal method.
Set the textvariable corresponding to `self.entry`
to the value currently selected.
"""
widget = event.widget

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will only ever be self.listbox won't it?

value = widget.get(int(widget.curselection()[0]))
self.text.set(value)
103 changes: 103 additions & 0 deletions Misc/NEWS.d/next/Library/2020-11-10-12-33-07.bpo-42305.5WWGOu.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
Tkinter Autocomplete
====================

Text autocompletion provides relevant real-time results to users.
Because tkinter does not provide a widget for adding autocompletion to
GUIs out of the box, I decided to make one myself. This utility is
compatible with and has been tested on Python 2.7.1 and Python 3.6.0.

Structure
~~~~~~~~~

NOTE: The ``Tkinter`` library for Python 2 and ``tkinter`` library for Python 3 will from now on be referred to as ``tk``.


The ``AutocompleteEntry`` class (which can be found
`here <https://github.com/RajvirSingh1313/Tkinter_Autocomplete_DropBox/blob/master/main.py>`__)
derives from ``tk.Frame`` and is a container used to group a
``tk.Entry`` and ``tk.Listbox`` widget. Should you need to modify the
widgets, they can be accessed as (respectively) ``AutocompleteEntry`` s
``entry`` and ``listbox`` attributes.

The entry widget acts like a normal textbox. When activated, it binds
``<KeyRelease>`` to a private method which will update the list of
suggestions. The listbox widget contains the suggestions themselves.
When activated, it binds ``<<ListboxSelect>>`` to a private method which
sets the entry widget to whatever value was selected.

Since an instance of ``AutocompleteEntry`` is a ``tk.Frame`` instance
too, you can place it by calling its ``pack`` or ``grid`` methods with
their respective arguments.

Quickstart
~~~~~~~~~~

NOTE: These examples will only run under Python 3. To make them Python 2-compatible, replace ``tkinter`` with ``Tkinter``.


To add a new autocompletion frame to our interface, first initialize
one:

::

import tkinter as tk

from tkinter import auto_complete

root = tk.Tk()

frame = tk.Frame(root)
frame.pack()

entry = auto_complete.AutocompleteEntry(frame)
# You can pass additional parameters to further customize the window;
# all parameters that you can pass to tk.Frame, are valid here too.

Now you need to configure the instance by passing it an iterable
containing all autocompletion entries. Do this by calling its ``build``
method:

::

ENTRIES = (
"Foo",
"Bar"
)

entry.build(ENTRIES)

You can pass additional arguments to ``build``:

* ``max_entries`` (integer): The maximum number of entries to display
at once. This value directly corresponds to the listbox widget's
``height`` attribute. Defaults to ``5``.

* ``case_sensitive`` (boolean): If ``True``, only autocomplete entries
that enforce the same capitalization as the current entry will be
displayed. If ``False``, all autocomplete entries that match with the
current entry will be displayed. Defaults to ``False``.

* ``no_results_message`` (string or ``None``): The message to display
if no suggestions could be found for the current entry. This argument
may include a formatting identifier (``{}``) which, at runtime, gets
formatted as the current entry. If ``None`` is specified, the listbox
will instead be hidden until the next ``<KeyRelease>`` event.

Let's play around with these arguments:

::

entry.build(
entries=ENTRIES,
no_results_message="< No results found for '{}' >"
# Note that this is formatted at runtime
)

NOTE: You may call the ``build`` method multiple times on an instance of ``AutocompleteEntry``, to dynamically change the available suggestions.


With that out of the way, you can display ``entry``:

::

entry.pack()