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

[Python] TK : un controllo "personalizzato", Pensieri e riscontri nella creazione di un oggetto

« Older   Newer »
  Share  
nuzzopippo
view post Posted on 4/2/2021, 11:21 by: nuzzopippo
Avatar

Nubbio x Sempre

Group:
Moderazione globale
Posts:
7,226

Status:


Ogni tanto mi capita di venire qui e trovare qualcuno che legge questo post ... evento che, invariabilmente, mi fa sentire in colpa dato che avrei potuto completarlo ma non lo ho fatto per problemi di tempo in primo luogo, poi anche perché, avendolo risolto, altre problematiche hanno deviato la mia attenzione ed, infine, perché acquisendo ulteriori conoscenze ho intravisto alcune pecche nel design del controllo, che non contempla i pattern MVC ed observer, a me sconosciuti all'epoca.

Chiedo scusa ad eventuali (ed improbabili) interessati. Cercherò di rimediare ora.

È giunto, quindi, il momento di parlare della

Versione con Scale



Come già descritto nei post precedenti, tre sono le esigenze che ho ritenuto fondamentali per la implementazione del controllo di che trattasi :
  1. limitare il numero di immagini da elaborare per singolo processo di refresh;
  2. fornire un efficace sistema di navigazione;
  3. identificare correttamente i dati selezionati.

Come già detto nel post sulla "Prima Bozza" i dati altro non sono che una lista di dizionari ove il singolo "dizionario" costituente elemento della lista contiene un riferimento al file immagine da visualizzare, oltre ad una eventuale associazione di una didascalia e di un testo.
Le due varianti di controllo precedentemente esposte altro non sono che delle metodologie per tener traccia dell'indice che i singoli dati-immagine occupano nella lista.
I primi due tentativi sopra (non dimentichiamo che il post è stato un work-in-progress) pur se funzionanti (a modo loro nel caso della versione con scroll) mi lasciavano insoddisfatto, cercando "alternative" trovai il widget "Scale"

Tale widget non è altro che uno slider, un cursore su di una scala graduata in sostanza, che può essere esposto, a scelta, tanto in modalità orizzontale quanto in modalità verticale.
Tra le proprietà impostabili di questo widget ve ne sono alcune particolarmente interessanti :
  • from_ - permette di definire un numero intero inizio della scala;
  • to - permette di definire un numero intero termine della scala;
  • tickinterval - permette di definire un intervallo di graduazione della scala.

Per altro, il widget scale espone il metodo "get()" che restituisce un numero intero che è il valore corrente del cursore nella scala di valori impostata e scatena un evento al riposizionamento del cursore.
Queste due ultime caratteristiche sono esattamente ciò che serve per tracciare in modo semplice ed efficace la posizione di un indice nella lista dati, potendo intercettare l'evento di riposizionamento del cursore in una funzione/metodo di callback che provvederà a leggere il valore del cursore ed agire di conseguenza, potremo, dunque, implementare il widget in questione nella classe che espone la nostra "collezione" di immagini definendo direttamente anche il callback correlato, con questo stralcio di codice :

CODICE
self.pivot_scale = tk.Scale(self,
                                   from_=0,
                                   to=0,
                                   orient=tk.VERTICAL,
                                   command=self._scale_event)

ovviamente, "self.pivot_scale" è il nostro widget mentre "self._scale_event" è il metodo di callback che gestisce il posizionamento del cursore, assegnato al widget scale tramite la proprietà "command".
È banale osservare che in fase di inizializzazione le proprietà "from_" e "to" sono inizializzate a valori nulli, dato che il controllo non possiede nativamente una lista dati. Detta lista sarà fornita dall'esterno tramite il metodo "set_data(self, dati)" della classe per la collezione di immagini, è alla ricezione di detti dati (ovviamente di formato ben specifico) che saranno impostati i valori necessari alla navigazione tra i dati :

CODICE
def set_data(self, dati):
       self.dati = dati
       self.pivot = 0
       for riga in self.rows:
           riga.clear()
       self.pivot_scale.configure(from_=1,
                                  to=len(self.dati),
                                  tickinterval=len(self.dati)//5
                                  )
       self._refresh_rows()

(scusate la parziale mancata aderenza alla Pep 8)

Vi è da notare un particolare : "from_" è inizializzato ad "1" mentre "to" è pari al numero dei dati, ciò contrasta con l'indicizzazione di una lista che va da zero al numero dei dati meno uno, cosa di cui si dovrà, necessariamente, tenere conto nella "navigazione" tra i dati, semplicemente diminuendo di "1" il valore corrente del controllo scale.
da notarsi l'impostazione del parametro "tickinterval", esso controlla la rappresentazione delle etichette di valore nella scala graduata del controllo Scale, impostata ad ogni quinto del numero complessivo dei dati.

In merito alla "navigazione", tra i dati, essa è gestita leggendo il valore corrente di Scale tramite il suo metodo ".get()", decrementandolo ed assegnando il valore calcolato alla variabile di istanza "pivot", nella funzione di callback "_scale_event" collegata al controllo in fase di inizializzazione, per quindi richiamare il metodo di classe "_refresh_rows()", per il resto funziona in perfetta analogia a quanto esposto per le altre varianti.

Questo è l'aspetto del controllo riveniente (con grid)

png



Un piccolo accorgimento...


Come detto nei precedenti post, per questa versione del controllo ho voluto definire due varianti, una utilizzante il gestore di geometria "pack", l'altra utilizzante il gestore di geometria "grid".
Tale scelta è giusto un vezzo, si riesce tranquillamente a far coesistere oggetti utilizzanti i due diversi gestori, magari con qualche piccola attenzione, per implementare la "differenziazione" ho semplicemente definito in "FRMTumbnail" ed in "FRMTmbCollection" un metodo "_popola(self)" che provvede ad istanziare i widget necessari e definirne le caratteristiche, ho quindi sub-classato le due classi riscrivento i metodi utilizzando il gestore di geometria "grid" invece di "pack" ... in FRMTmbCollection ho riscritto anche il metodo "_crea_righe(self)" che provvede ad una singola riga dati.


Altre cosette ...


In linea di massima, le funzionalità del controllo sono analoghe nelle tre varianti, la differenza fondamentale consiste nella modalità di controllo del "pivot".
Un piccolo particolare di tale differenza è il controllo che il pivot non possa essere posizionato in maniera tale da far presentare righe dati "vuote", effetto giudicato sgradevole in fase di uso operativo del controllo (si, ho realizzato "fotodiario", magari, se ho tempo, posto anche quello). Banalmente, ho utilizzato direttamente il metodo di callback di scale per implementare la verifica

CODICE
def _scale_event(self, evt):
       '''Gestisce lo scroll del widget scale associato.'''
       if not self.dati: return
       value = int(self.pivot_scale.get()) - 1
       if value + self.righe >= len(self.dati):
           self.pivot  = len(self.dati) - self.righe
       else:
           self.pivot = value
       self._refresh_rows()

che si limita a controllare che il valore restituito da controllo scale, incrementato dal numero di righe dati rappresentate non superi i dati memorizzati, nel caso si calcola il massimo pivot "possibile" e lo assegna, altrimenti assegna direttamente il valore restituito.

Altra faccenda, trovata "sgradevole" è lo spazio vuoto che può venire a formarsi ridimensionando la finestra, ciò nasce dalla scelta di NON intercettare gli eventi di ridimensionamento del frame, e quindi ricalcolare il controllo e ridisegnare ogni sua parte, immagini comprese, dato che l'elevatissimo numero di eventi che sarebbero intercettati porterebbe tkinter al crash ... Operativamente ho "rimediato" facendo si che l'adattamento fosse una scelta dell'utente, creando un metodo pubblico (non compreso nel codice corrente del controllo) che può essere invocato e provvede a calcolare il numero di righe rappresentabili e ridefinire il controllo, la espongo qui, nel caso interessi qualcuno

CODICE
def adegua_righe(self):
       '''
       Ricalcola, sulla base della prima riga, quante sono le righe dati
       rappresentabili con le dimensioni correnti e ricalibra il controllo.
       '''
       h_rif = self.rows[0].winfo_height()
       h_disp = self.winfo_height()
       new_righe = h_disp // h_rif
       # se il numero di righe disponibili è diverso dal numero di righe
       # corrente distrugge i controlli presenti, ridefinisce il numero di
       # righe, ricrea un numero di controlli adeguato e visualizza
       if new_righe != self.righe:
           for riga in reversed(self.rows):
               riga.rimuovi()
               riga.destroy()
               self.rows.pop()
           self.pivot_scale.destroy()
           self.righe = new_righe
           self._popola()
           if self.dati:
               self.pivot_scale.configure(from_=1,
                                          to=len(self.dati),
                                          tickinterval=len(self.dati)//5
                                          )
           self.update()


Conclusione


Per concludere, il sorgente completo della terza versione del "controllo personalizzato" a suo tempo realizzato, 228 righe

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

import tkinter as tk
from PIL import Image
from PIL import ImageTk



# *** CLASSI ***


class FRMTumbnail(tk.Frame):
   '''Pannello per singola miniatura (tumbnail) con descrizione.'''
   def __init__(self, master, mater, imgdim=60):
       self.mater = mater
       self.f_img = ''
       super().__init__(master)
       self.dim_tmb = imgdim
       self._curr_img = None
       self.indice = None
       self._popola()
   
   def _popola(self):
       self.default_bc = self.cget('bg')
       self.configure(relief='sunken', border=2, padx=2, pady=2)
       self.cnv_tmb = tk.Canvas(self,
                                width=self.dim_tmb,
                                height=self.dim_tmb,
                                bg='#ffffc0',
                                relief='raised',
                                border=2
                             )
       self.cnv_tmb.pack(side='left')
       self.cnv_tmb.bind('<Button-1>', self._scelto)
       f = tk.Frame(self, height=self.dim_tmb+4)
       f.pack(side='left', expand=True, fill='x')
       self.lbl_dida = tk.Label(f,
                               text='...',
                               justify='left',
                               )
       self.lbl_dida.pack(expand=True, fill='both')
       self.lbl_dida.bind('<Button-1>', self._scelto)
       
       
   def set_fileimg(self, filename):
       ''' Impostazione diretta del file immagine da miniaturizzare. '''
       self.f_img = filename
       self._display()
   
   def _display(self):
       ''' Ridimensiona e visualizza l'immagine da mostrare. '''
       # Import l'immagine tramite PIL (per i file *.jpg)
       img = Image.open(self.f_img)
       # proporzionamento immagine all'area disponibile
       img_x, img_y = img.size
       if img_x >= img_y:
           f_scala = self.cnv_tmb.winfo_width() / img_x
       else:
           f_scala = self.cnv_tmb.winfo_height() / img_y
       fx = int(img_x * f_scala)
       fy = int(img_y * f_scala)
       img = img.resize((fx, fy), Image.ANTIALIAS)
       # converto per il canvas
       self._curr_img = ImageTk.PhotoImage(img)
       x = self.cnv_tmb.winfo_width() // 2
       y = self.cnv_tmb.winfo_height() // 2
       self.cnv_tmb.create_image(x, y, image=self._curr_img, anchor='center')
   
   def set_dida(self, testo):
       self.lbl_dida.configure(text=testo)
   
   def set_indice(self, indice):
       self.indice = indice
   
   def get_indice(self):
       return self.indice
       
   def clear(self):
       self.cnv_tmb.delete('all')
       self.lbl_dida.configure(text='...')
       self.set_default_col()
   
   def _scelto(self, evt):
       if not self.mater: return
       self.set_selected_col()
       self.mater.set_selezione(self.indice)
       return 'breack'
   
   def set_default_col(self):
       self.configure(bg=self.default_bc)
       self.lbl_dida.configure(bg=self.default_bc)
   
   def set_selected_col(self):
       self.configure(bg='#ffffc0')
       self.lbl_dida.configure(bg='#ffffc0')



class FRMTmbCollection(tk.Frame):
   '''Contenitore/gestore di un insieme di oggetti FRMTumbnail.'''
   def __init__(self, master, righe=2, dim=60, mater=None):
       super().__init__(master)
       self.master = master
       self.mater = mater
       self.righe = righe
       self.dim_tmb = dim
       self.pivot = 0
       self.rows = []
       self.dati = None
       self.sel_index = None
       self._popola()
   
   def _popola(self):
       self.sf_tmb = tk.Frame(self)
       self.sf_tmb.pack(side='left', expand=True, fill='both')
       self._crea_righe()
       self.pivot_scale = tk.Scale(self,
                                   from_=0,
                                   to=0,
                                   orient=tk.VERTICAL,
                                   command=self._scale_event)
       self.pivot_scale.pack(side='right', fill='y')
   
   def _crea_righe(self):
       for i in range(self.righe):
           riga = FRMTumbnail(self.sf_tmb, self, self.dim_tmb)
           riga.pack(fill='x')
           riga.set_indice(i)
           self.rows.append(riga)
   
   def set_data(self, dati):
       self.dati = dati
       self.pivot = 0
       for riga in self.rows:
           riga.clear()
       self.pivot_scale.configure(from_=1,
                                  to=len(self.dati),
                                  tickinterval=len(self.dati)//5
                                  )
       self._refresh_rows()
   
   def set_selezione(self, indice):
       if not self.dati: return
       for riga in self.rows:
           if riga.get_indice() != indice:
               riga.set_default_col()
       self.sel_index = self.pivot + indice
       if self.mater:
           self.mater.set_selezione(self.sel_index)
   
   def _refresh_rows(self):
       if len(self.dati) == 0 : return
       i = 0
       for riga in self.rows:
           riga.clear()
           dati_ind = self.pivot + i
           if dati_ind <= len(self.dati) - 1:
               riga.set_fileimg(self.dati[dati_ind]['img'])
               riga.set_dida(self.dati[dati_ind]['dida'])
           i += 1
       if self.sel_index:
           der_index = self.sel_index - self.pivot
           if der_index >= 0 and der_index < self.righe:
               self.rows[der_index].set_selected_col()
   
   def _scale_event(self, evt):
       '''Gestisce lo scroll del widget scale associato.'''
       if not self.dati: return
       value = int(self.pivot_scale.get()) - 1
       if value + self.righe >= len(self.dati):
           self.pivot  = len(self.dati) - self.righe
       else:
           self.pivot = value
       self._refresh_rows()


# *** VERSIONE COSTRUITA CON GRID ***

class GriFRMTmb(FRMTumbnail):
   '''Pannello per singola miniatura (tumbnail) con descrizione.'''
   def __init__(self, master, mater, imgdim=60):
       super().__init__(master, mater, imgdim)
   
   def _popola(self):
       self.default_bc = self.cget('bg')
       self.configure(relief='sunken', border=2, padx=2, pady=2)
       self.cnv_tmb = tk.Canvas(self,
                                width=self.dim_tmb,
                                height=self.dim_tmb,
                                bg='#ffffc0',
                                relief='raised',
                                border=2
                                )
       self.cnv_tmb.bind('<Button-1>', self._scelto)
       self.lbl_dida = tk.Label(self,
                               text='...',
                               justify='left',
                               )
       self.lbl_dida.bind('<Button-1>', self._scelto)
       self.cnv_tmb.grid(row=0, column=0, sticky='w')
       self.lbl_dida.grid(row=0, column=1, sticky='ew')
       self.columnconfigure(1, minsize=3*self.dim_tmb, weight=1)
       



class GriFRMTmbColl(FRMTmbCollection):
   '''Contenitore/gestore di un insieme di oggetti GriFRMTmb.'''
   def __init__(self, master, righe=2, dim=60, mater=None):
       super().__init__(master, righe, dim, mater)
   
   def _popola(self):
       self._crea_righe()
       self.pivot_scale = tk.Scale(self,
                                   from_=0,
                                   to=0,
                                   orient=tk.VERTICAL,
                                   command=self._scale_event)
       self.pivot_scale.grid(row=0, column=1, rowspan=self.righe, sticky='ns')
       self.columnconfigure(0, weight=1)
       self.columnconfigure(1, weight=0)      
   
   def _crea_righe(self):
       for i in range(self.righe):
           riga = FRMTumbnail(self, self, self.dim_tmb)
           riga.grid(row=i, column=0, sticky='ew')
           riga.set_indice(i)
           self.rows.append(riga)


ed un esempio di "come viene" in sede di test

png



... che dire, è stato il mio primo "esperimento" circa fare qualcosa "ad interfaccia grafica" con python, probabilmente ora lo farei un po' diverso, o forse non lo rifarei proprio, dato che sto cercando di abbandonare tkinter a favore di wxPython, comunque ogni tanto qualcuno legge questo post, è bene, quindi, completarlo seppur così in ritardo, magari a quel qualcuno potrebbe essere utile.

Per altro, nei forum, vedo che molti iniziandi si interessano a tkinter ... qualche esempietto e qualche piccola applicazione "carina" la ho realizzata con tale framework, se mi riesce magari ne posto qualcuna, anche se nessuna di esse adotta i patterns MVC/Observer (basilari per un buon design di interfacce grafiche)

Edited by nuzzopippo - 13/2/2021, 10:10
 
Web  Top
4 replies since 31/3/2019, 12:09   1066 views
  Share