Autocomplete fails when multiple keys are pressed at once

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

Answer

Imports

import string
from tkinter import *
from tkinter import font

Setting up the widgets

    self.master = Tk()
    self.ent_var = StringVar()
    self.entry = Entry(self.root, textvariable=self.ent_var)
    self.entry.pack()

    self.list_var = Variable(self.entry)
    self.listbox = Listbox(self.root, listvariable=self.list_var)
    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('<BackSpace>', self.backspace)
    self.entry.bind('<KeyPress>', self.key_pressed)
    self.entry.bind('<KeyRelease>', self.key_released)
    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

To make a auto-complete functionality in tkinter, you need to consider following things:

  • Is any key is pressed down?
  • Is pressed key is released?

When key is pressed down then you need to then you need to check:

  • Convert space to ” “

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

    Here I named variable to self.keys

  • If selection is present in Entry widget then remove the selected text and insert the keys pressed that are stored in self.keys 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.

     key = event.keysym
    
     if key == 'space':
         key = ' '
    
     if key in string.printable:
         self.keys.append(key)
    
         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'
    

When pressed key is released

  • Convert ” ” to space

  • Remove all pressed keys from self.keys if present in self.keys

     key = event.keysym
    
     if key == ' ':
         key = 'space'
    
     if key in self.keys:
         self.keys.pop(self.keys.index(key))
    
     return 'break'
    

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.

    • If selection 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.

        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())
      
            else:
                self.ent_index -= 1
      
        return 'break'
      

When tab key is pressed

  • Select all text in Entry widget

  • Set cursor to the end of the Entry widget

  • Remove previous selection from the listbox and select the new 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)
      return 'break'
    

Here, return ‘break’ forbids tkinter to execute its default bindings

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

      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.keys = []
        self.ent_var = StringVar()
        self.entry = Entry(self.root, textvariable=self.ent_var)
        self.entry.pack()

        self.list_var = Variable(self.entry)
        self.listbox = Listbox(self.root, listvariable=self.list_var)
        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('<Tab>', self.tab_completion)
        self.entry.bind('<BackSpace>', self.backspace)
        self.entry.bind('<KeyPress>', self.key_pressed)
        self.entry.bind('<KeyRelease>', self.key_released)
        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 key_pressed(self, event=None):
        key = event.keysym

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

        if key in string.printable:
            self.keys.append(key)

            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 key_released(self, event=None):
        key = event.keysym

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

        if key in self.keys:
            self.keys.pop(self.keys.index(key))

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

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


if __name__ == '__main__':
    AutoComplete()

Leave a Reply

Your email address will not be published. Required fields are marked *