Signori, i miei saluti.
Premetto che il presente post non è una richiesta di supporto; dato che vedo molte new-entry approcciarsi a tkinter e che ho appena "realizzato" un giocattolo per tale ambiente che sembra funzionare benino ed in qualche caso potrebbe essere utile ho deciso di renderlo disponibile ad eventuali interessati.
Naturalmente, eventuali critiche, suggerimenti migliorativi e/o feedback, p.e. sulla funzionalità nel s.o. windows sul quale quanto segue "penso" funzioni ma non è stato testato, saranno graditi ma non sono motivo di questo post.
Veniamo al sodo.
Problematica affrontataLa necessità di dover presentare e selezionare un singolo elemento in una finestra grafica ed in un ridotto spazio una lista di circa 1500 stringhe mi ha fatto optare per l'utilizzo di un ttk.Combobox.
Non me ne ero reso conto in passato, per i ridotti set di dati normalmente utilizzati con tale controllo, ma il ttk.Combobox fornisce unicamente metodologie "visuali" per la navigazione tra i dati e non possiede funzioni di autocompletamento dell'immissione utente, presenti in altri framework, ho deciso, quindi, di sub-classare il ttk.Combobox ed implementare una sorta di auto-completamento che ricerchi ed esponga il primo dato che inizi con un eventuale input dell'utente e si aggiorni interattivamente alla pressione di un tasto.
Da tener presente* il codice che seguirà utilizza notazioni che compaiono dalla versione 3.10 di python, se si utilizzano versioni antecedenti eliminare i type-hints e sostituire i costrutti match-case con una successione di if/elif;
* ho previsto di acquisire come "input utente" solo numeri e caratteri ordinari ed accentati, omettendo di accogliere caratteri speciali o altro, in caso di particolari necessità si integri la variabile di classe "key_accepted" includendo gli ulteriori caratteri necessari nella stringa ad essa assegnata;
* nella intercettazione della tastiera ho utilizzato i soli valori "keysym", ciò per astrarre dagli effetivi valori dei tasti assegnati nei vari sistemi operativi, ciò "credo" che "dovrebbe" rendere il codice funzionale nei vari s.o. ma non è testato.
Funzionamento visuale del widgetSupposto si parta da una casella di immissione vuota, man mano che l'utente premerà un tasto il cui keysym ricada nella stringa di accettazione vedrà comparire il primo valore che inizia con la seguenza di tasti premuta, tale seguenza avrà il colore di sfondo ordinario mentre il resto del valore avrà i colori del testo selezionato, la pressione di un tasto che dia luogo ad una seguenza senza riscontro verrà ignorata.
La pressione di "Invio", principale o da tastierino numerico, darà luogo alla selezione del valore corrente con generazione di un evento di selezione.
La pressione del tasto "Esc" od un click del tasto sinistro del mouse annullerà in processo, lasciando l'ultimo valore intercettato quale corrente nel Combobox; per altro vengono anche gestiti i tasti freccia, sinistra e destra, ed il backspace.
Funzionamento programmatico del widgetL'implementazione si basa sui metodi di selezione testo delle tk.Entry (di cui il ttk.Combobox è sub-classe) ed utilizza una doppia intercettazione degli eventi di tastiera. Tale doppia intercettazzione è motivata dalla circostanza che i tasti di navigazione "verticale" (frecce su, giù, pagsu, paggiù) vengono intercettate e gestite dall'area di presentazione dati e non disponibili alla Entry costituente la casella di immissione del combobox, tali tasti di navigazione "verticale" vengono intercettati con un binding sull'evento "<KeyPress>" il cui callback ("self._on_keypress") intercetta la sola pressione della freccia in basso ("Down") che interpreta quale volontà di effettuare una navigazione visiva tra i dati e, quindi, annullerà la progressione di input dell'utente.
Più articolata è la gestione dell'evento "<KeyRelease>" di rilascio di un tasto, nel cui callback ("self._on_key") intercettati e memorizzati in una lista ("self._user_val") i tasti con keysym ricadente nei valori ammessi oltre che di "BackSpace", "Left" e "Right" che intervengono sull'input dell'utente, l'intercettazione di tutti detti tasti invoca il metodo di classe "_intercept()" che provvede a valutare il complesso dell'immissione utente, reperire il primo dato soddisfacente la ricerca (o scartare l'ultimo inserimento) e rappresentarlo nella entry con la dovuta evidenziazione, in fine viente interrotto il propagarsi dell'evento (istruzioni "return break").
Vengono altresì gestiti, nel callback self._on_key, il tasto "Escape" che, semplicemente annulla l'operazione lasciando com'è un eventuale dato presente nella entry, oltre che i tasti Invio ("KP_Enter" e "Return") che, se presente dell'input utente, invocano il metodo di classe "self._complete", che provvede a seleziona il valore trovato ed a generare un evento di selezione "<<ComboboxSelected>>", tutti e tre questi tasti lasciano procedere il propagarsi dell'evento.
In ultimo viene anche intercettata la pressione del tasto sinistro del mouse "<Button-1>" sul controllo, il cui callback ("self._on_mouse") si limita a prendere atto della volontà di navigazione visuale, annullando una eventuale immissione dell'utente.
Il 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]
Nel caso si voglia visualizzare senza provare,
ho scritto un appunto per mia futura memoria corredato di immagini.
Ciao