Autocomplete fails when multiple keys are pressed at once

Viewed 91

I am trying to make a autocomplete functionality for selecting fonts where the rest of the text must be selected apart from the text inserted by the user.

I have successfully written a working code but fails when I press two keys simultaneously. When I press two keys simultaneously then the first matched value gets inserted in Entry widget without selecting the rest letters.

How can I select the rest of the letters which are to be autocomplete text even if two keys are pressed simultaneously?

CODE

import string
import tkinter as tk
from tkinter import font

class AutoComplete:
    def __init__(self):
        self.keys = []
        self.function_keys = ['Ctrl_L', 'Ctrl_R', 'Shift_L', 'Shift_R', 'Alt_L', 'Alt_R']

        self.root = tk.Tk()
        self.test_list = self.non_duplicate_fonts()

        self.entry_var = tk.StringVar()
        self.entry = tk.Entry(self.root, textvariable=self.entry_var)
        self.entry.pack()
        self.entry.focus_set()

        self.lis_var = tk.Variable(self.root, value=self.test_list)
        self.listbox = tk.Listbox(self.root, listvariable=self.lis_var)
        self.listbox.pack()

        self.entry.bind('<KeyPress>', self.key_pressed)
        self.entry.bind('<KeyRelease>', self.on_keyrelease)
        self.root.mainloop()

    def is_function_key_available(self):
        for key in self.keys:
            if key in self.function_keys:
                return True

        return False

    def non_duplicate_fonts(self):
        '''Filter fonts starting with same name'''

        fonts = list(font.families())
        fonts.sort()
        fonts = fonts[26:]

        prev_family = ' '
        _fonts = []

        for family in fonts:
            if not family.startswith(prev_family):
                _fonts.append(family)
                prev_family = family

        return _fonts

    def on_keyrelease(self, event):
        if self.is_function_key_available():
            self.keys = []
            return

        elif event.keysym in string.printable:
            value = self.entry_var.get().strip().lower()
            lowered_list = [k.lower() for k in self.test_list]
            matched_value = [f for f in lowered_list if f.startswith(value)]

            if matched_value:
                index = lowered_list.index(matched_value[0])
                self.entry_var.set(self.test_list[index])
                self.entry.selection_range(len(value), 'end')

        self.keys = []

    def key_pressed(self, event):
        key = event.keysym

        if key not in self.keys:
            self.keys.append(key)


if __name__ == '__main__':
    AutoComplete()
1 Answers

Imports

import string
from tkinter import *
from tkinter import font

Setting up the widgets

    class AutoComplete:
        def __init__(self):
            self.root = Tk()
            self.root.title('AutoComplete')

            self.ent_var = StringVar()
            self.entry = Entry(self.root, textvariable=self.ent_var, width=50)
            self.entry.pack()

            self.list_var = Variable(self.entry)
            self.listbox = Listbox(self.root, listvariable=self.list_var, width=50)
            self.listbox.pack()

            self.all_fonts = list(font.families())
            self.all_fonts.sort()
            self.all_fonts = self.all_fonts[26:]
            self.non_duplicates_fonts()
            self.lower_case = [f.lower() for f in self.all_fonts]

            self.list_var.set(self.all_fonts)
            self.entry.focus()

            self.root.resizable(0, 0)
            self.root.mainloop()

        def non_duplicates_fonts(self):
            '''Filter fonts starting with same name'''

            prev_family = ' '
            font_families = []

            for family in self.all_fonts:
                if not family.startswith(prev_family):
                    font_families.append(family)
                    prev_family = family

            self.all_fonts = font_families

Setting default values

def default(self):
    '''Set default values in entry widget and listbox'''

    self.listbox.selection_set(3)
    self.ent_var.set('Arial')

    self.entry.config(insertofftime=1000000, insertontime=0)
    self.set_selection()

To make a auto-complete functionality in tkinter, you need to bind your text widget with bindtags <KeyPress> to detect if any keys are pressed down.

When keys are pressed down then you need to check:

  • Convert space to " "

  • Store each pressed key to a variable only if that key is printable

  • If selection is present in Entry widget then remove the selected text and insert the keys pressed along with the text present in Entry widget and shift the CURSOR to one step forward. Also store the starting index by increasing by 1 so that we can use it to select the remaining text.

    If selection is absent then increase the starting index by 1 and repeat the remaining.

     def key_pressed(self, event=None):
         key = event.keysym
    
         if key == 'space':
             key = ' '
    
         if key in string.printable:
             if self.entry.selection_present():
                 self.sel = self.entry.index('sel.first') + 1
                 self.entry.delete('sel.first', 'sel.last')
    
             else:
                 self.sel = self.entry.index('insert') + 1
    
             value = self.ent_var.get() + key
             self.ent_var.set(value)
             self.ent_index += 1
    
             self.entry.icursor(self.ent_index)
             self.auto_complete()  # Explained below
    
         return 'break'
    
  • Bind Entry widget with bindtag <KeyPress>

      self.entry.bind('<KeyPress>', self.key_pressed)
    

When backspace key is pressed

  • If cursor position has not reached to the beginning of the Entry widget.

    • If selection is present then remove the selected value and set the cursor index to the length of the remaining text and set blinking time back to default.

    • If selection is not present then just remove the last value from the Entry widget and insert rest of to the Entry widget and decrease the cursor index by 1.

        def backspace(self, event=None):
            value = self.entry.get()[:-1]
            self.ent_var.set(value)
      
            if self.ent_index != 0:
                if self.entry.selection_present():
                    self.entry.delete('sel.first', 'sel.last')
                    self.ent_index = len(self.ent_var.get())
      
                    if self.entry['insertofftime'] == 1000000:  # Restore time of blinking to default
                        self.entry.config(insertofftime=300, insertontime=600)
      
                else:
                    self.ent_index -= 1
      
            return 'break'
      
  • Bind Entry widget with bindtag <BackSpace>

      self.entry.bind('<BackSpace>', self.backspace)
    

When tab key is pressed

  • Select all text in Entry widget

  • Set cursor to the end of the Entry widget and remove blinking

  • Remove previous selection from the listbox and select the new value in listbox

      def tab_completion(self, event=None):
          '''Select all text in entry widget of matched one.
             Also select the same value in listbox'''
    
          value = self.ent_var.get()
    
          self.entry.selection_range(0, 'end')
          self.entry.icursor('end')
    
          index = self.all_fonts.index(value)
          self.listbox.selection_clear(0, 'end')
          self.listbox.selection_set(index)
    
          self.entry.config(insertofftime=1000000, insertontime=0)  # Removing blinking of cursor.
          return 'break'
    
  • Bind Entry widget with bindtag <Tab>

      self.entry.bind('<Tab>', self.tab_completion)
    

When UP key is pressed

  • Select value above the selection in listbox until the index reaches to 0

  • Insert just selected value to entry widget from listbox and select all text in entry widget

      def up_direction(self, event=None):
          '''Move selection in upwards direction in listbox'''
    
          index = self.listbox.curselection()[0]
    
          if index > 0:
              index -= 1
    
              self.listbox.selection_clear(0, 'end')
              self.listbox.selection_set(index)
              self.listbox.see(index)
    
              self.ent_var.set(self.all_fonts[index])
              self.entry.selection_range(0, 'end')
    
          return 'break'
    
  • Bind Entry widget with bindtag <Up>

      self.entry.bind('<Up>', self.up_direction)
    

When DOWN button is pressed

  • Select value below the selection in listbox until the index reaches to the total number of values in listbox.

  • Insert just selected value to entry widget from listbox and select all text in entry widget

      def down_direction(self, event=None):
          '''Move selection in downwards direction in listbox'''
    
          index = self.listbox.curselection()[0]
    
          if index < len(self.all_fonts) - 1:
              index += 1
    
              self.listbox.selection_clear(0, 'end')
              self.listbox.selection_set(index)
              self.listbox.see(index)
    
              self.ent_var.set(self.all_fonts[index])
              self.entry.selection_range(0, 'end')
    
          return 'break'
    
  • Bind Entry widget with bindtag <Down>

      self.entry.bind('<Down>', self.down_direction)
    

Here, return 'break' forbids tkinter to execute its default bindings

When selection is made by clicking value in listbox

  • Insert the selected value from listbox in entry widget

      def button_click(self, event=None):
          '''When selection is made by clicking'''
    
          index = self.listbox.curselection()[0]
    
          self.ent_var.set(self.all_fonts[index])
          self.root.after(10, self.set_selection)
    
  • Select all text in entry widget

      def set_selection(self):
          '''Select all text in entry widget'''
    
          self.entry.select_range(0, 'end')
          self.entry.focus()
    
  • Bind ListBox widget with bindtag <<ListboxSelect>>

      self.listbox.bind('<<ListboxSelect>>', self.button_click)
    

Auto-Complete function explanation

  • Grab value from the Entry widget

  • Get matched value from the list of list-box with respect to the text entered in Entry widget

  • If matched is non-empty then grab its first value and get its index and insert that value in Entry widget and if the cursor index is equal to the first matched value then select all text in Entry widget else select from the recent inserted value index to the end and scroll the listbox to the index of the first matched value

      def auto_complete(self):
          value = self.ent_var.get().strip().lower()
          matched = [f for f in self.lower_case if f.startswith(value)]
    
          if matched:
              matched = matched[0]
              index = self.lower_case.index(matched)
    
              self.ent_var.set(self.all_fonts[index])
    
              if self.entry.index('insert') == len(matched):
                  self.entry.selection_range(0, 'end')
    
              else:
                  self.entry.selection_range(self.sel, 'end')
    
              self.listbox.see(index)
    

Putting everything together

import string
from tkinter import *
from tkinter import font


class AutoComplete:
    def __init__(self):
        self.ent_index = 0

        self.root = Tk()
        self.root.title('AutoComplete')

        self.ent_var = StringVar()
        self.entry = Entry(self.root, textvariable=self.ent_var, width=50)
        self.entry.pack()

        self.list_var = Variable(self.entry)
        self.listbox = Listbox(self.root, listvariable=self.list_var, exportselection=False, activestyle='none', selectmode=SINGLE, width=50)
        self.listbox.pack()

        self.all_fonts = list(font.families())
        self.all_fonts.sort()
        self.all_fonts = self.all_fonts[26:]
        self.non_duplicates_fonts()
        self.lower_case = [f.lower() for f in self.all_fonts]

        self.list_var.set(self.all_fonts)
        self.entry.focus()

        self.entry.bind('<Up>', self.up_direction)
        self.entry.bind('<Tab>', self.tab_completion)
        self.entry.bind('<BackSpace>', self.backspace)
        self.entry.bind('<Down>', self.down_direction)
        self.entry.bind('<KeyPress>', self.key_pressed)
        self.listbox.bind('<<ListboxSelect>>', self.button_click)

        self.default()

        self.root.resizable(0, 0)
        self.root.mainloop()

    def non_duplicates_fonts(self):
        '''Filter fonts starting with same name'''

        prev_family = ' '
        font_families = []

        for family in self.all_fonts:
            if not family.startswith(prev_family):
                font_families.append(family)
                prev_family = family

        self.all_fonts = font_families

    def default(self):
        '''Set default values in entry widget and listbox'''

        self.listbox.selection_set(3)
        self.ent_var.set('Arial')

        self.entry.config(insertofftime=1000000, insertontime=0)
        self.set_selection()

    def key_pressed(self, event=None):
        key = event.keysym

        if key == 'space':
            key = ' '

        if key in string.printable:
            if self.entry.selection_present():
                self.sel = self.entry.index('sel.first') + 1
                self.entry.delete('sel.first', 'sel.last')

            else:
                self.sel = self.entry.index('insert') + 1

            value = self.ent_var.get() + key
            self.ent_var.set(value)
            self.ent_index += 1

            self.entry.icursor(self.ent_index)
            self.auto_complete()

        return 'break'

    def backspace(self, event=None):
        value = self.entry.get()[:-1]
        self.ent_var.set(value)

        if self.ent_index != 0:
            if self.entry.selection_present():
                self.entry.delete('sel.first', 'sel.last')
                self.ent_index = len(self.ent_var.get())

                if self.entry['insertofftime'] == 1000000:  # Restore time of blinking to default
                    self.entry.config(insertofftime=300, insertontime=600)

            else:
                self.ent_index -= 1

        return 'break'

    def tab_completion(self, event=None):
        '''Select all text in entry widget of matched one.
           Also select the same value in listbox'''

        value = self.ent_var.get()

        self.entry.selection_range(0, 'end')
        self.entry.icursor('end')

        index = self.all_fonts.index(value)
        self.listbox.selection_clear(0, 'end')
        self.listbox.selection_set(index)

        self.entry.config(insertofftime=1000000, insertontime=0)
        return 'break'

    def auto_complete(self):
        value = self.ent_var.get().strip().lower()
        matched = [f for f in self.lower_case if f.startswith(value)]

        if matched:
            matched = matched[0]
            index = self.lower_case.index(matched)

            self.ent_var.set(self.all_fonts[index])

            if self.entry.index('insert') == len(matched):
                self.entry.selection_range(0, 'end')
                self.listbox.selection_clear(0, 'end')
                self.listbox.selection_set(index)

            else:
                self.entry.selection_range(self.sel, 'end')

            self.listbox.see(index)

    def down_direction(self, event=None):
        '''Move selection in downwards direction in listbox'''

        index = self.listbox.curselection()[0]

        if index < len(self.all_fonts) - 1:
            index += 1

            self.listbox.selection_clear(0, 'end')
            self.listbox.selection_set(index)
            self.listbox.see(index)

            self.ent_var.set(self.all_fonts[index])
            self.entry.selection_range(0, 'end')

        return 'break'

    def up_direction(self, event=None):
        '''Move selection in upwards direction in listbox'''

        index = self.listbox.curselection()[0]

        if index > 0:
            index -= 1

            self.listbox.selection_clear(0, 'end')
            self.listbox.selection_set(index)
            self.listbox.see(index)

            self.ent_var.set(self.all_fonts[index])
            self.entry.selection_range(0, 'end')

        return 'break'

    def button_click(self, event=None):
        '''When selection is made by clicking'''

        index = self.listbox.curselection()[0]

        self.ent_var.set(self.all_fonts[index])
        self.root.after(10, self.set_selection)

    def set_selection(self):
        '''Select all text in entry widget'''

        self.entry.select_range(0, 'end')
        self.entry.focus()


if __name__ == '__main__':
    AutoComplete()
Related