Search code examples
python-2.7tkintercomboboxsqlitetk-toolkit

Python Tkinter Autocomplete combobox with LIKE search?


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)

Solution

  • 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()