Αντρω δι Νεξυνω

[Tkinter] un combo box "interattivo"

« Older   Newer »
  Share  
view post Posted on 2/4/2023, 17:27
Avatar

Nubbio x Sempre

Group:
Moderazione globale
Posts:
7,225

Status:


I miei saluti ad eventuali, improbabili, lettori

Nel corso di una costruzione di prototipi di interfacce utente grafiche per desktop mi son trovato con la necessità di riportare, per selezione da parte degli user, una gran numero di di dati in uno spazio "ristretto", naturalmente, la scelta di un controllo di tipo Combo-Box viene da sola in tale frangente.
Realizzato un primo prototipo della applicazione con python ed il framework tkinter mi son reso conto che le funzionalità di navigazione del widget ttk.Combobox fornito con la versione 8.6 di detto framework mal si presta alla navigazione di grosse quantità di dati, disponendo di sole opzioni "visuali" di scorrimento tra i dati, non disponendo delle funzioni di completamento automatico comunemente presenti in framework più evoluti.

Essendo non difficile realizzare un "qualcosa" che interagisca con l'input utente lo valuti e provveda, in qualche modo, a spostarsi sui primi dati che soddisfano l'inserimento fatto, è stato semplice sub-classare la classe ttk.Combobox e realizzare quello che ho chiamato:

InteractiveComboBox


che ho deciso di esporre qui nel caso fosse utile a qualcuno.
È richiesta una versione python >= 3.10.x per il funzionamento del codice sottostante ma potete facilmente adattarlo a versioni precedenti eliminando di type-hints e sostituendo i costrutti "match-case" con una serie di costrutti "if-elif"

Intanto il codice della classe (98 righe) :

CODICE
#-*- coding: utf-8 -*-

import tkinter as tk
from tkinter import ttk


class InteractiveCombobox(ttk.Combobox):
   '''Una combobox per intercettare e completare l'input utente'''
   key_accepted = 'qwertyuiopasdfghjklzxcvbnmàèéìòù1234567890'
   def __init__(self, parent, *args, **kwargs) -> None:
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self._user_val = []
       self.bind('<KeyPress>', self._on_keypress)
       self.bind('<KeyRelease>', self._on_key)
       self.bind('<Button-1>', self._on_mouse)
   
   def _on_keypress(self, evt: callable) -> None:
       if evt.keysym == 'Down':
           self._user_val = []
           self.icursor(0)
           self.select_range(tk.INSERT, tk.INSERT)
       
   def _on_key(self, evt: callable) -> None:
       if not self['values']: return
       match evt.keysym:
           case 'BackSpace':
               if len(self._user_val):
                   self._user_val = self._user_val[:-1]
                   self.icursor(0)
               self._intercept()
               return 'break'
           case 'Left':
               if len(self._user_val):
                   self._user_val = self._user_val[:-1]
               self._intercept()
               return 'break'
           case 'Right':
               value = self.get()
               if len(value) > len(self._user_val):
                   self._user_val.append(value[len(self._user_val)])
               self._intercept()
               return 'break'
           case 'Escape':
               self._user_val = []
               self.icursor(0)
               self.select_range(0, tk.END)
           case 'KP_Enter':
               if len(self._user_val):
                   self._complete()
           case 'Return':
               if len(self._user_val):
                   self._complete()
           case _:
               if evt.keysym.lower() in self.key_accepted:
                   self._user_val.append(evt.keysym.lower())
                   self._intercept()
                   return 'break'
   
   def _on_mouse(self, evt: callable) -> None:
       self._user_val = []

   def _complete(self) -> None:
       tok = ''.join(self._user_val)
       data = self['values']
       index = 0
       if tok:
           for i in range(len(data)):
               if data[i].lower().startswith(tok):
                   index = i
                   self._user_val = []
                   self.icursor(0)
                   self.select_range(0, tk.END)
                   break
           self.current(index)
           self.event_generate('<<ComboboxSelected>>')
   
   def _intercept(self) -> None:
       self.delete(len(self._user_val), tk.END)
       tok = ''.join(self._user_val)
       if not tok:
           self.select_range(tk.INSERT, tk.INSERT)
           return
       data = self['values']
       index = 0
       is_match = False
       for i in range(len(data)):
           if data[i].lower().startswith(tok):
               index = i
               is_match = True
               break
       if is_match:
           self.current(index)
           self.select_range(len(self._user_val), tk.END)
       else:
           self.delete(len(self._user_val)-1, tk.END)
           self._user_val = self._user_val[:-1]

Se si ritiene di voler fare una prova con la finestra di test che proporrò a fine post, copiate il codice, stando attenti alla indentazione dello stesso (l'editor dei post potrebbe creare degli artefatti) e salvatelo in un file denominato "mytktools.py" nella stessa directory del file-applicazione.

Il funzionamento della classe si basa su di una doppia intercettazione di ogni singolo tasto premuto, il motivo di tale doppia intercettazione è dato dalla natura del widget ttk.Combobox che essenzialmente è un sub-classamento del widget "Entry" con la aggiunta di una "area - dati", detta "area" intercetta e gestisce in esclusiva alcuni tasti di navigazione (essenzialmente quelli di movimento verticale ("down". "Up", etc.) la cui pressione non viene rilevata dalla parte "Entry" del widget.

png


L'intercettazione dei tasti di navigazione "verticale" è effettuata all'avvio della pressione di un tasto della tastiera (evento "<keypress>") e leggendo il valore di simulazione del tasto (proprietà "keysym" dell'evento) e viene gestito il solo valore "Down" (preposto all'apertura della area-dati) provvedendo allo azzeramento di un eventuale user-input e rilascio alla gestione propria originaria degli eventi.

Un pochino più articolata è l'intercettazione effettuata al rilascio del pulsante (evento "<keyrelease>") che verifica il caso siano stati premuti alcuni tasti "particolari" e nel caso non sia così, allora, controlla se il keysym del tasto sia contenuto in una variabile di classe di controllo
CODICE
key_accepted = 'qwertyuiopasdfghjklzxcvbnmàèéìòù1234567890'

Che altro non è che una stringa contenente i caratteri che prevedo possano servire, la cui modifica ed adattamento ad altre situazioni è, comunque, facile.
L'intercettazione di un carattere "accettato" memorizzerà lo stesso carattere in una variabile di istanza ("self._user_val"), di tipo lista, ed invocherà il metodo di classe "_intercept(self)" che, magari da una condizione iniziale vuota

png


provvederà a posizionarsi sul primo dato che inizia con i valori inseriti dall'utente

png


e continuerà ad aggiornarsi dinamicamente man mano che l'utente prosegue nel suo inserimento

png


La pressione del tasto return, ordinario o del tastierino numerico (rispettivi keysym "Return" e "KP_Enter") i quali invocheranno il metodo di classe "_complete(self)" che cercherà il primo dato corrispondente all'input utente, si posizionerà su di esso, azzereranno l'user-input immesso e genererà un evento si selezione ("<<comboboxselected>>") che potrà essere elaborato

png


Naturalmente, i metodi di classe "_intercept" e "_complete" agiscono solo in presenza di input utente, non agiscono altrimenti, ed in ogni caso se alla aggiunta di nuovo input non corrisponde alcun dato il nuovo input viene eliminato, il percorso degli eventi viene, quindi, lasciato proseguire normalmente.

Oltre ai tasti Return viene gestito anche il tasto Esc (keysym "Escape") che semplice annulla ogni inserimento utente e riposiziona il cursore, selezionando un eventuale dato corrente ma senza scatenare un evento di selezione.
I tasti backspace e freccia sinistra (keysym "BackSpace" e "Left") decrementano l'user input ed il punto di inserimento di un valore mentre il tasto freccia destra (keysym "Right") incrementa l'input utente aggiungendo un ulteriore carattere da eventuali dati presenti.
Tutti e tre questi ultimi tasti interrompono il ciclo degli eventi di tkinter facendo cessare ulteriori azioni (restituiscono un "break" al ciclo degli eventi)

Per altro, alnche l0azione del tasto sinistro del mouse viene intercettata, azzerando ogni eventuale input dell'utente, nell'ottica che si voglia procedere alla selezione visivamente.

Tutto qua, se si vuol testare con pappa già pronta, il codice sotto produce la finestra in figura sopra ed estrae le famiglie di caratteri registrate nel sistema che vengono presentate nel combo-box "Fonts", oggetto di classe "InteractiveComboBox", dovrebbe funzionare anche su windows (i keysym sono una astrazione indipendente dal sistema) e sul mio linux e macchina vecchiotta funziona in maniera soddisfacente.

Se qualcuno mai lo trovasse utile mi farà piacere, anche se non lo saprò ;)

Il codice di test:

CODICE
#-*- coding: utf-8 -*-

import tkinter as tk
from tkinter import font
from tkinter import ttk
from mytktools import InteractiveCombobox as ICB


class MyApp(tk.Tk):
   def __init__(self) -> None:
       super().__init__()
       self.title("Esempio d'uso")
       lbl = tk.Label(self, text='Font :')
       lbl.grid(row=0, column=0, padx=5, pady=5, sticky='w')
       self.cmb_fonts = ICB(self)
       self.cmb_fonts.grid(row=0, column=1, padx=5, pady=5, sticky='ew')
       lbl = tk.Label(self, text='Dim. :')
       lbl.grid(row=0, column=2, padx=5, pady=5, sticky='w')
       self.cmb_sizes = ttk.Combobox(self, width=5)
       self.cmb_sizes.grid(row=0, column=3, padx=5, pady=5, sticky='w')
       self.dida = tk.Label(self, text='')
       self.dida.grid(row=1, column=0, columnspan=4,
                      padx=5, pady=5, sticky='w')
       bt_end = tk.Button(self, text='Chiudi programma', command=self.destroy)
       bt_end.grid(row=2, column=0, columnspan=4,
                      padx=5, pady=5, sticky='ew')
       self.grid_columnconfigure(1, weight=1)
       self._set_fonts()
       self._set_fnt_sizes()
       self.cmb_fonts.bind('<<ComboboxSelected>>', self._on_fonts)
       self.cmb_sizes.bind('<<ComboboxSelected>>', self._on_fonts)
       self.update()
       self.minsize(self.winfo_reqwidth(), self.winfo_reqheight())

   def _set_fonts(self) -> None:
       ''' Carica le famiglie di font raggiungibili in tkinter '''
       self.cmb_fonts.delete(0, tk.END)
       fnt = sorted(set(list(font.families())))
       self.cmb_fonts['values'] = fnt
       self.dida.configure(text=f'Trovate {len(fnt)} famiglie di fonts')

   def _set_fnt_sizes(self) -> None:
       ''' Imposta le dimensioni dei caratteri '''
       sizes = [x for x in range(6, 29, 2)]
       sizes += [x for x in range(32, 48, 4)]
       self.cmb_sizes.delete(0, tk.END)
       self.cmb_sizes['values'] = sizes
       self.cmb_sizes.current(3)

   def _on_fonts(self, evt: callable) -> None:
       fnt = self.cmb_fonts.get()
       dim = self.cmb_sizes.get()
       msg = f'Selezionato font: {fnt} {dim}pt.'
       self.dida.configure(text=msg)

if __name__ == '__main__':
   app = MyApp()
   app.mainloop()
 
Web  Top
0 replies since 2/4/2023, 17:27   43 views
  Share