3

I am trying to populate a Tkinter combobox with pre-defined values to select from. It is populating and I am able to type in and get suggestions. However, in order to do this I have to definitely know the first few characters. If I know some text in the middle or end of the string, its of no use because the combobox does only a 'LIKE%' search and not a '%LIKE%' search.

Expected Output (Typing the word "Ceramic" fetches all names containing the string. Note: This is not a Tkinter screenshot):

enter image description here

This is my adaptation of the code till now, if anyone can suggest how to modify the AutocompleteCombobox class to do a LIKE search, it would be great.

The below working piece of code, as an example, has values "Cranberry" and "Strawberry" , my requirement is to type "berry" and get suggestions of both fruits.

import Tkinter
import ttk
import sqlite3


class AutocompleteCombobox(ttk.Combobox):

        def set_completion_list(self, completion_list):
                """Use our completion list as our drop down selection menu, arrows move through menu."""
                self._completion_list = sorted(completion_list, key=str.lower) # Work with a sorted list
                self._hits = []
                self._hit_index = 0
                self.position = 0
                self.bind('<KeyRelease>', self.handle_keyrelease)
                self['values'] = self._completion_list  # Setup our popup menu

        def autocomplete(self, delta=0):
                """autocomplete the Combobox, delta may be 0/1/-1 to cycle through possible hits"""
                if delta: # need to delete selection otherwise we would fix the current position
                        self.delete(self.position, Tkinter.END)
                else: # set position to end so selection starts where textentry ended
                        self.position = len(self.get())
                # collect hits
                _hits = []
                for element in self._completion_list:
                        if element.lower().startswith(self.get().lower()): # Match case insensitively
                                _hits.append(element)
                # if we have a new hit list, keep this in mind
                if _hits != self._hits:
                        self._hit_index = 0
                        self._hits=_hits
                # only allow cycling if we are in a known hit list
                if _hits == self._hits and self._hits:
                        self._hit_index = (self._hit_index + delta) % len(self._hits)
                # now finally perform the auto completion
                if self._hits:
                        self.delete(0,Tkinter.END)
                        self.insert(0,self._hits[self._hit_index])
                        self.select_range(self.position,Tkinter.END)

        def handle_keyrelease(self, event):
                """event handler for the keyrelease event on this widget"""
                if event.keysym == "BackSpace":
                        self.delete(self.index(Tkinter.INSERT), Tkinter.END)
                        self.position = self.index(Tkinter.END)
                if event.keysym == "Left":
                        if self.position < self.index(Tkinter.END): # delete the selection
                                self.delete(self.position, Tkinter.END)
                        else:
                                self.position = self.position-1 # delete one character
                                self.delete(self.position, Tkinter.END)
                if event.keysym == "Right":
                        self.position = self.index(Tkinter.END) # go to end (no selection)
                if len(event.keysym) == 1:
                        self.autocomplete()
                # No need for up/down, we'll jump to the popup
                # list at the position of the autocompletion


def test(test_list):
        """Run a mini application to test the AutocompleteEntry Widget."""
        root = Tkinter.Tk(className='AutocompleteCombobox')

        combo = AutocompleteCombobox(root)
        combo.set_completion_list(test_list)
        combo.pack()
        combo.focus_set()
        # I used a tiling WM with no controls, added a shortcut to quit
        root.bind('<Control-Q>', lambda event=None: root.destroy())
        root.bind('<Control-q>', lambda event=None: root.destroy())
        root.mainloop()

if __name__ == '__main__':
        test_list = ('apple', 'banana', 'Cranberry', 'dogwood', 'alpha', 'Acorn', 'Anise', 'Strawberry' )
        test(test_list)
mdabdullah
  • 596
  • 1
  • 9
  • 25
  • Please make a [mcve], iow with example data included and without a SQLite call that we can't use. – Novel Dec 15 '17 at 21:05
  • @Novel Apologies, I have made several edits and you will also be able to run the code now. – mdabdullah Dec 15 '17 at 21:27
  • How do you envision the user experience? You can't have selected text before *and* after what the user is typing. – Novel Dec 15 '17 at 21:38
  • It is quite possible and as an end user, I have used the same feature in another product and trying to replicate the same. Assume there are 100 types of berries in the inventory and need to select one, all I need to do is type "berry" and scroll through the 100 and select which one I want. Note that the dropdown list will have smaller and smaller subsets of results each for "b" , "be", "ber", "berr", "berry" as I keep typing. – mdabdullah Dec 15 '17 at 21:51
  • can't you use `if "berry" in "Cranberry"` (`if "berry" in "Strawberry"`). In your code it will be `if self.get().lower() in element.lower()` – furas Dec 15 '17 at 22:40

1 Answers1

10

I suspect you need

 if self.get().lower() in element.lower():

instead of

 if element.lower().startswith(self.get().lower()):

to get data like with %LIKE% in database


But I don't know if you get good effect because this Combobox replaces text with suggestion so if you type be then it finds Cranberry and put in place be and you can't write ber.

Maybe you should display Cranberry as separated (dropdown) list, or popup tip.

Or maybe you will have to use string.find() to highlight correct place in Cranberry and continue to type ber in correct place.


EDIT: example how to use Entry and Listbox to display filtered list

In listbox_update I added sorting list (comparing lower case strings)

#!/usr/bin/env python3

import tkinter as tk

def on_keyrelease(event):
    
    # get text from entry
    value = event.widget.get()
    value = value.strip().lower()
    
    # get data from test_list
    if value == '':
        data = test_list
    else:
        data = []
        for item in test_list:
            if value in item.lower():
                data.append(item)                

    # update data in listbox
    listbox_update(data)
    
    
def listbox_update(data):
    # delete previous data
    listbox.delete(0, 'end')
    
    # sorting data 
    data = sorted(data, key=str.lower)

    # put new data
    for item in data:
        listbox.insert('end', item)


def on_select(event):
    # display element selected on list
    print('(event) previous:', event.widget.get('active'))
    print('(event)  current:', event.widget.get(event.widget.curselection()))
    print('---')


# --- main ---

test_list = ('apple', 'banana', 'Cranberry', 'dogwood', 'alpha', 'Acorn', 'Anise', 'Strawberry' )

root = tk.Tk()

entry = tk.Entry(root)
entry.pack()
entry.bind('<KeyRelease>', on_keyrelease)

listbox = tk.Listbox(root)
listbox.pack()
#listbox.bind('<Double-Button-1>', on_select)
listbox.bind('<<ListboxSelect>>', on_select)
listbox_update(test_list)

root.mainloop()

At start with full list

enter image description here

Later only with filtered items

enter image description here


EDIT: 2020.07.21

If you want to use <KeyPress> then you have to change on_keyrelease and use event.char, event.keysym and/or event.keycode because KeyPress is executed before tkinter update text in Entry and you have to add event.char to text in Entry (or remove last char when you press backspace)

if event.keysym == 'BackSpace':
    value = event.widget.get()[:-1]  # remove last char
else:
    value = event.widget.get() + event.char  # add new char at the end

It may need other changes for other special keys Ctrl+A, Ctrl+X, Ctrl+C, Ctrl+E, etc. and it makes big problem.

#!/usr/bin/env python3

import tkinter as tk

def on_keypress(event):

    print(event)
    print(event.state & 4) # Control
    print(event.keysym == 'a')
    # get text from entry
    if event.keysym == 'BackSpace':
        # remove last char
        value = event.widget.get()[:-1]
    elif (event.state & 4): # and (event.keysym in ('a', 'c', 'x', 'e')):
        value = event.widget.get()
    else:
        # add new char at the end        
        value = event.widget.get() + event.char
    #TODO: other special keys

    value = value.strip().lower()

    # get data from test_list
    if value == '':
        data = test_list
    else:
        data = []
        for item in test_list:
            if value in item.lower():
                data.append(item)                

    # update data in listbox
    listbox_update(data)


def listbox_update(data):
    # delete previous data
    listbox.delete(0, 'end')

    # sorting data 
    data = sorted(data, key=str.lower)

    # put new data
    for item in data:
        listbox.insert('end', item)


def on_select(event):
    # display element selected on list
    print('(event) previous:', event.widget.get('active'))
    print('(event)  current:', event.widget.get(event.widget.curselection()))
    print('---')


# --- main ---

test_list = ('apple', 'banana', 'Cranberry', 'dogwood', 'alpha', 'Acorn', 'Anise', 'Strawberry' )

root = tk.Tk()

entry = tk.Entry(root)
entry.pack()
entry.bind('<KeyPress>', on_keypress)

listbox = tk.Listbox(root)
listbox.pack()
#listbox.bind('<Double-Button-1>', on_select)
listbox.bind('<<ListboxSelect>>', on_select)
listbox_update(test_list)

root.mainloop()

BTW:

You can also use textvariable in Entry with StringVar and trace which executes function when StringVar changes content.

var_text = tk.StringVar()
var_text.trace('w', on_change)

entry = tk.Entry(root, textvariable=var_text)
entry.pack()

#!/usr/bin/env python3

import tkinter as tk

def on_change(*args):
    #print(args)
          
    value = var_text.get()
    value = value.strip().lower()

    # get data from test_list
    if value == '':
        data = test_list
    else:
        data = []
        for item in test_list:
            if value in item.lower():
                data.append(item)                

    # update data in listbox
    listbox_update(data)


def listbox_update(data):
    # delete previous data
    listbox.delete(0, 'end')

    # sorting data 
    data = sorted(data, key=str.lower)

    # put new data
    for item in data:
        listbox.insert('end', item)


def on_select(event):
    # display element selected on list
    print('(event) previous:', event.widget.get('active'))
    print('(event)  current:', event.widget.get(event.widget.curselection()))
    print('---')

# --- main ---

test_list = ('apple', 'banana', 'Cranberry', 'dogwood', 'alpha', 'Acorn', 'Anise', 'Strawberry' )

root = tk.Tk()

var_text = tk.StringVar()
var_text.trace('w', on_change)

entry = tk.Entry(root, textvariable=var_text)
entry.pack()

listbox = tk.Listbox(root)
listbox.pack()
#listbox.bind('<Double-Button-1>', on_select)
listbox.bind('<<ListboxSelect>>', on_select)
listbox_update(test_list)

root.mainloop()
furas
  • 134,197
  • 12
  • 106
  • 148
  • I tried using `if self.get().lower() in element.lower(): ` as you say the response is getting filled automatically with the closest match and subsequently scrolling with up/down arrows may or may not fetch the expected values. I am not sure how to use `string.find()` , I am adding a screenshot of the actual functionality to the original question for better self explanation. – mdabdullah Dec 16 '17 at 14:01
  • command `"Cranberry".find("be")` gives position of `"be"` in `"Cranberry"` and you will know which part to highlight and where to put cursor when yoou type text. – furas Dec 16 '17 at 15:17
  • Hmm, I tried to incorporate it into the existing program but not sure how to. I am relatively new to python. However I did run it on the Python shell and see that it returns the position of the text as a numerical value as follows: `>>> "Cranberry".find("be") 4 >>> "Cranberry".find("b") 4` I am not sure of how to process this numerical values further, so looks like I will have to live with `element.lower().startswith` for now. – mdabdullah Dec 16 '17 at 18:58
  • frankly it would be easier if you use `Entry` only for entering text, and `Listbox` for displaying matching elements - without all this autocompletion and highlighting. – furas Dec 16 '17 at 19:12
  • I am new to this, can you give me a sample with the same variables? It does sound like a good idea and I am able to envision it but not clear how to bind the values between `Entry` and `Listbox` – mdabdullah Dec 16 '17 at 19:24
  • one moment, I thought I have example on GitHub but I have to create new one. – furas Dec 16 '17 at 19:34
  • Thanks for the solution, it is working like a charm! I also found this on GitHub https://gist.github.com/Nikola-K/10364096/060bedf9e34eb4d753ba72b53163aadd9bb2769e – mdabdullah Dec 16 '17 at 19:47
  • I have 2 lists with different values, I want to update code in such a way that on_keyRelease should be accepting listname as parameter so that autocomplete feature will sort items in corresponding lists. How do i pass listname here entry2.bind('', on_keyrelease) – RSB Oct 18 '19 at 09:32
  • Great solution! Any way to do this on KeyPress rather than KeyRelease? I tried replacing the entry bind but then the list seems to lag by a keypress – evn Jul 21 '20 at 17:20
  • @evn you may not be able to use the above solution directly but combine the above with this https://stackoverflow.com/questions/24072790/detect-key-press-in-python – mdabdullah Jul 21 '20 at 18:29
  • @evn why do you want to use `KeyPress` ? There is `` but it is executed before it changes value in `Entry` so it may need totally different code in `on_keyrelease`. It may need to use `event.keycode`, `event.char` or `event.keysym` and it may need to block `KeyRelease` in `Entry` - so it will be much more complicated. – furas Jul 21 '20 at 20:25
  • I added code with `KeyPress` but it doesn't resolve problem with special keys like `Ctrl+X` which removes text but only when text is selected, etc. `KeyPress` runs function before `Entry` changes text (and process chars like `Ctrl+X` and selection) so you have to process special keys on your own - and it makes big problem. – furas Jul 21 '20 at 21:23
  • 1
    @mbadullah The reason I wanted to use KeyPress is to avoid the feeling of delay while waiting for KeyRelease. I was able to run the operation on change of the stringvar by combining the above solution with the following https://stackoverflow.com/a/6549535/ – evn Jul 22 '20 at 18:19
  • @evn I don't know what system you use but I don't feel delay on Linux Mint. Maybe because I release key at one - when I keep little long then it starts repeating key. Using `SrtingVar` I also don't feel delay - and it doesn't use `KeyPress` – furas Jul 23 '20 at 07:26
  • @furas As a user, I expect to see my keystrokes recognized on `KeyPress`, as this is the functionality I am accustomed to while typing. There is no actual delay, however I _perceive_ a delay due to the discrepancy between the moment I _expect_ my strokes to be reflected (`KeyPress`), and the moment my strokes are _actually_ reflected (`KeyRelease`). This perceived delay is remedied by updating listbox when `StringVar` is changed, as that happens almost immediately after the key is pressed. – evn Jul 23 '20 at 20:50
  • @furas I am running the code on a Windows 10 server through Microsoft Remote Desktop on MacOS Mojave – evn Jul 23 '20 at 20:54