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

[Python] Messaggi per un ttk.Notebook

« Older   Newer »
  Share  
view post Posted on 3/10/2021, 11:00
Avatar

Nubbio x Sempre

Group:
Moderazione globale
Posts:
7,226

Status:


I miei saluti ad eventuali, improbabili, lettori che inciampino in questo scritto. Come sta diventando "solito" nei rari argomenti che tratto qui, in seguito viene realizzata una idea nata in un pos, in forumpython.it, nel quale sono intervenuto e che mi ha intrigato abbastanza da farmi rimandare un altro post che ho appeso per altro argomento.

La problematica posta


Sostanzialmente lo OP del post origine dell'idea, diceva di aver realizzato una applicazione per mostrare dei micetti in un tab-panel tkinter (ovvero un tkinter.ttk.Notebook), riusciva a visualizzarne qualcuno da codice e voleva sapere :
1° - come aggiungere qualche gatto senza riscrivere il codice;
2° - come presentare, all'avvio dell'applicazione, le schede presenti nella precedente sessione.
Lo OP voleva sapere se fosse possibile o dovesse rinunciare all'idea.

Naturalmente, che tali banali operazioni fossero possibili è stato subito detto al proponente e, dato che quest'ultimo non ha proposto neanche una sola riga di codice, sono state proposti, da utenti più esperti di me, vari suggerimenti relativi alla persistenza dei dati ed indicazioni circa il possibile utilizzo del pattern MVC nel design della applicazione.

La mia idea di base


Come detto, la problematica mi ha intrigato ma non certo per l'applicazione, di per se semplice, bensì, inizialmente, per il pattern MVC proposto nel corso dei post, solitamente non utilizzo tale pattern nel mio sviluppo.
La semplicità del modello dati della discussione, una immagine e del testo, e della applicazione stessa, è una buona "occasione" per sperimentare una implementazione "tipo" MVC, dico "tipo" perché non mi è riuscito ad inquadrare per bene l'aspetto "controller", dato che interviene una notevole sovrapposizione di funzionalità della GUI.

Pur se inafficace, riferendo al pattern MVC, per il quale NON mi è riuscito ad implementare un "Controller" degno del suo nome, realizzare il giocattolo sotto è stato comunque un esperimento per me interessante, magari potrebbe essere anche utile qualcuno perciò lo posto.

Considerazioni preliminari


Come innanzi esposto, l'applicazione da realizzarsi deve esporre delle immagini (nei tabs di un notebook) e deve essere in grado di memorizzare alla sua chiusura le immagini correntemente visualizzate e ricaricarle al suo successivo avvio.
Ovviamente, ho escluso a priori il metodo adottato dallo OP di definire le immagini direttamente nello script, in presenza di numerose immagini l'esposizione sarebbe problematica. Ho scelto, quindi, di realizzare una applicazione in grado di definire, selezionare, modificare ed eliminare gli elementi (mici) da conservare oltre che aprire e chiudere la visualizzazione di alcuni degli elementi memorizzati.

Riguardo alla memorizzazione dei mici, data la semplicità strutturale dei dati, ho deciso di registrali in un semplice file di testo formattato con notazine di tipo CSV senza indicazione del nome dei campi (name, image, description) per la cui definizione ho scelto una selezione posizionale. La "posizione" del file di testo è stabilita in una direttrice nella home utente dedicata alla applicazione.

Una problematica riveniente dalla decisa metodologia di definizione, modifica e cancellazione dei dati, e l'iterazione tra eventuali operazioni di modifica/cancellazione dati con le scelte di visualizzazione effettuate dall'utente e l'elenco dei dati necessario per la consultazione utente, per tale evenienza ho deciso inizialmente di sperimentare l'utilizzo del pattern "Observer", per poi passare al pattern "Publish/subscriber" trovando che ben si presta alla casistica.

Una ulteriore problematica è data dalla "dimensione" della immagine, dalle tipologie supportate e dalla loro "posizione", nel caso derivino da supporti rimovibili.

Il venv minimale necessario


Le considerazioni su esposte mi hanno portato ad utilizzare il modulo "appdirs" per stabilire una directory per lo storage dei dati ed una directory di caching delle immagini selezionate, perché siano comunque disponibili, per quanto possibile in modalità standardizzata per il s.o. in uso.
Altro modulo utilizzato per l'implementazione del pattern "Publish/subscriber" è "pypubsub", da me scoperto nell'ottimo testo "Capire wxpython" del Poligneri.
In ultimo, ho scelto di utilizzare "pillow" per la "gestione" delle immagini.
Il tutto installato in un venv con questa configurazione minimale
CODICE
(test_mvc) PS C:\Users\DESKTOP-User\venvs\test_mvc> python -m pip list
Package    Version
---------- -------
appdirs    1.4.4
Pillow     8.3.2
pip        21.2.4
Pypubsub   4.0.3
setuptools 56.0.0
(test_mvc) PS C:\Users\DESKTOP-User\venvs\test_mvc>

oltre alcune librerie di base, presenti di default.

Testando, per la prima volta, il codice contemporaneamente nei sistemi operativi Linux e Windows (10) ho rilevato alcune lievi differenze funzionali nei due sistemi, in particolare la differente resa del modulo appdirs per ottenere le directory "di default" per le esigenza applicative ... particolare che ritengo utile da tener presente.

i brevi test sottostanti
CODICE
test_mvc) NzP:~$ python
Python 3.8.10 (default, Jun  2 2021, 10:49:15)
[GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import appdirs
>>> default_dirs = {}
>>> appname = 'micetti'
>>> appauthor = 'nuzzopippo'
>>> default_dirs['data'] = appdirs.user_data_dir(appname, appauthor)
>>> default_dirs['cache'] = appdirs.user_cache_dir(appname, appauthor)
>>> default_dirs['log'] = appdirs.user_log_dir(appname, appauthor)
>>> for key in default_dirs.keys():
...     print(key, ' - ', default_dirs[key])
...
data  -  /home/nuzzopippo/.local/share/micetti
cache  -  /home/nuzzopippo/.cache/micetti
log  -  /home/nuzzopippo/.cache/micetti/log
>>>



(test_mvc) PS C:\Users\DESKTOP-User\venvs\test_mvc> python
Python 3.9.6 (tags/v3.9.6:db3ff76, Jun 28 2021, 15:26:21) [MSC v.1929 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> import appdirs
>>> default_dirs = {}
>>> appname = 'micetti'
>>> appauthor = 'nuzzopippo'
>>> default_dirs['data'] = appdirs.user_data_dir(appname, appauthor)
>>> default_dirs['cache'] = appdirs.user_cache_dir(appname, appauthor)
>>> default_dirs['log'] = appdirs.user_log_dir(appname, appauthor)
>>> for key in default_dirs.keys():
...     print(key, ' - ', default_dirs[key])
...
data  -  C:\Users\DESKTOP-User\AppData\Local\nuzzopippo\micetti
cache  -  C:\Users\DESKTOP-User\AppData\Local\nuzzopippo\micetti\Cache
log  -  C:\Users\DESKTOP-User\AppData\Local\nuzzopippo\micetti\Logs
>>>

rendono evidente una differenza sostanziale : Windows raccoglie per "autore" le applicazioni mentre linux ignora allegramente l'autore e considera le applicazioni di per se ... dopo tutto il softare "libero" è la natura stessa di Linux, no?

Comunque, non considerare questa piccola differenza, da me completamente ignorata in prima stesura, comporterebbe la non-funzionalità del codice scritto per linux in windows, dato che in linux bisognerebbe in sostanza creare un singolo livello di directory o, al più, fare attenzione all'ordine per la direttrice di log, mentre in windows, se l'autore non esiste, bisogna creare sistematicamente due ... una volta accortomi del problema, lo ho affrontato creando ricorsivamente le directory dalla più esterna alla più interna; il codice :
CODICE
def make_recursive_dir(pathname):
   dir_name = os.path.basename(pathname)
   dir_mater = os.path.dirname(pathname)
   if os.path.exists(pathname) and os.path.isdir(pathname):
       return
   else:
       make_recursive_dir(dir_mater)
       os.mkdir(pathname)    
   
def def_dirs():
   '''
Definisce le directory da utilizzare nella applicazione,
eventualmente non esistano le crea.'''
   default_dirs = {}
   appname = 'micetti'
   appauthor = 'nuzzopippo'

   default_dirs['data'] = appdirs.user_data_dir(appname, appauthor)
   default_dirs['cache'] = appdirs.user_cache_dir(appname, appauthor)
   default_dirs['log'] = appdirs.user_log_dir(appname, appauthor)
   try:
       for key in default_dirs.keys():
           make_recursive_dir(default_dirs[key])
   except OSError as e:
       exit(1)
   return default_dirs

Il cuore della "soluzione" è la funzione "make_recursive_dir(pathname)" il cui algoritmo è semplicissimo : se il "pathname" esiste ritorna senza far niente, altrimenti richiama se stessa sulla direttrice madre ed al ritorno crea la directory di competenza.
La funzione "def_dirs()" provvede a definire il nome dell'autore (io, naturalmente) e della applicazione da dare in pasto ad appdirs per ricavare le direttrici applicative interessanti da dare in pasto a make_recursive_dir.
Entrambe le funzioni sono definite nel modulo "model.py", che fa un uso molto maggiore del file-sistem rispetto ai processi grafici.

Il "Modello" dati


beh ... chiamiamolo modello, sostanzialmente sono due classi : "Micio" (mi perdonino gli anglofoni) e "Cats".

Micio è una unità elementare di informazione, un record, se vogliamo vederlo così, con tre proprietà pubbliche "name", "image" e "description" definite con i relativi decoratori "@property e @setter" e dei metodi per lo storage dei dati e per la restituzione degli stessi in formato stile csv o lista, l'invocazione di detti utilimi due metodi su dati incompleti provoca un "ValueError".
Lo storage dei dati avviene per invocazione esterna alla classe, l'operazione effettuata è un "append" di una stringa dati in formato CSV ad un file passato nella invocazione.

Cats è la vera unità funzionale del "modello", provvede a storage e lettura dei dati ed al caching delle immagini associate, intercetta e provvede alle richieste di inserimeto, modifica, cancellazione e visualizzazione dati.

Per le particolarità funzionali "lato modello" si rimanda al prosieguo del post ed al codice, nell'insieme piuttosto semplice, le poche cose che potrebbero essere interessanti è la modalità di storage dei dati, che è un semplice file di testo in formato CSV improprio, non essendo codificato in ASCII e non avendo riga delle intestazioni e la modalità di caching delle immagini : "Cats" provvede a copiare il file immagine collegato ai dati ricevuti nella directory di caching definita per l'applicazione e modificare il collegamento nei dati, provvede, inoltre ad eliminare le copie di immagini sostituite o appartenenti a dati eliminati. Inoltre la classe controlla non vi siano gue "gatti" con nome uguale, mantenendo l'ordine di inserimento effettuato.

La "View"


La "View" è di per se strutturalmente semplice, come potete vedere in figura

png



Sub-classamento di tkinter.Tk, sostanzialmente, al suo avvio non è altro che un ttk.Notebook vuoto ed alcuni bottoni, per la maggior parte disabilitati.
Dato che uno dei quesiti posti dallo OP riguardava il "come inserire un nuovo tab" in un ttk.Notebook e che personalmente non ho mai utilizzato in precedenza tale controllo (vado sempre sul "semplice", se posso) ho voluto far si che tutte le operazioni "grafiche" riguardassero elementi sostanzialmente appartenenti a tale controllo, dato che ho testato lo script anche in ambiente windows, ecco come appare al primo avvio in tale sistema operativo

png



Come potete vedere nelle immagini soprastanti, la finestra in se dispone di un totale di otto pulsanti di comando, attivi o meno secondo il contesto corrente della finestra, a parte l'ultimo comando "Esci" che rimane comunque attivo ed esegue la chiusura della finestra e dell'applicazione, senza chiedere conferme ANCHE se è in corso una definizione/modifica dati ... ovviamente, le variazioni inserite e non salvate, nel caso, andranno perse.

Gli altri sei pulsanti di comando avranno effetto o direttamente sul notebook ovvero su uno dei suoi "pannelli" e, tramite alcune proprietà di questi ultimi, sui dati.
Per definire i pannelli ho implementato due classi una per presentare una lista dei "Mici" (cioè il nome dei gatti o che si vuole dare ad una immagine) ed una per "Presentare" un gatto, ossia una immagine ed un commento dedicato.

Il pannello "Lista"


Richiamabile tramite la pressione del tato "Elenco" presenta un elenco dei nomi dei mici (o immagini) registrati e qualche comando

png


Può essere presente solo un oggetto di questo tipo in una sessione, penso che sia giusto il momento di presentare il codice che definisce tale pannello
CODICE
class ListPanel(ttk.Frame):
   '''Un pannello per elencare gatti... e gattine.'''
   def __init__(self, mater, parent, *args, **kwargs):
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self.mater = mater
       self.data = []
       self.cat_list = tk.Listbox(self)
       self.cat_list.grid(row=0, column=0, rowspan=5,
                          padx=(5,2), pady=5, sticky='nsew')
       v_scroll = tk.Scrollbar(self, orient='vertical',
                               command=self.cat_list.yview)
       v_scroll.grid(row=0, column=1, rowspan=5,
                     padx=(2,5), pady=5, sticky='ns')
       self.cat_list.config(yscrollcommand=v_scroll.set)
       bt_open = tk.Button(self, text='Visualizza', command=self._on_open)
       bt_open.grid(row=0, column=2, padx=10, pady=5, sticky='ew')
       bt_del = tk.Button(self, text='Elimina', command=self._on_del)
       bt_del.grid(row=1, column=2, padx=10, pady=5, sticky='ew')
       self.vision = tk.BooleanVar()
       self.ck_vis = tk.Checkbutton(self, text='Anteprima', onvalue=True,
                                    offvalue=False, variable=self.vision,
                                    command=self._on_image)
       self.ck_vis.grid(row=2, column=2, padx=10, pady=10, sticky='w')
       self.lbl_tbn = tk.Label(self)
       self.lbl_tbn.grid(row=3, column=2, padx=10, pady=10, sticky='nsew')
       self.lbl_dida = tk.Label(self, justify='left')
       self.lbl_dida.grid(row=4, column=2, padx=10, pady=10, sticky='w')

       self.cat_list.bind('<ButtonRelease-1>', self._on_image)
       self.cat_list.bind('<Return>', self._on_image)

       self.grid_columnconfigure(2, weight=1)
       self.grid_rowconfigure(3, weight=1)

       self.img = None

   def _on_open(self):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       for m in self.data:
           if m.name == sel_name:
               micio = m
       self.mater.view_cat(micio.name)
   
   def _on_del(self):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       self.mater.cat_deleted(sel_name)
       msg = ['CATDELETE', sel_name]
       try:
           pub.sendMessage('REQUEST', message=msg)
       except ValueError as e:
           msgb.showerror('Avvenuto errore', repr(e))

   def _on_image(self, evt=None):
       if not self.vision.get():
           self.img = None
           self.lbl_tbn.configure(image='')
           self.lbl_dida.configure(text='')
           return
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       for m in self.data:
           if m.name == sel_name:
               micio = m
       self.lbl_dida.configure(text=micio.description)
       self.img = get_tk_image(micio.image, self.lbl_tbn)
       self.lbl_tbn.configure(image=self.img)
   
   def update_values(self, message):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name:
           sel_name = None
       self.cat_list.delete(0, 'end')
       self.data = message
       for e in [x.name for x in self.data]:
           self.cat_list.insert('end', e)
       if sel_name is None: return
       index = 0
       for item in self.data:
           if item.name == sel_name:
               self.cat_list.index(index)
               break
           index += 1

Parliamo un po' di questa classe. Noterete che per definire il pannello ho sub-classato un ttk.Frame invece di un normale frame tkinter, tale scelta è stata fatta senza motivazioni precise, solo perché ttk.Frame, come il ttk.Notebook, supporta la proprietà "style" che però NON utilizzo nel codice, per il resto l'ho trattato come un normale Frame, applicando i metodi relativi, che sono supportati.

Per altro, l'oggetto "ListPanel" è fornito di alcune capacità proprie di azione nei riguardi di se stesso, della finestra-madre e dei dati stessi, tali "capacità" ruotano attorno al contenuto di "self.cat_list", un contenitore dei "nomi" dei gatti registrati (o meglio delle immagini registratte) che al primo avvio della applicazione è vuoto.
Quando viene sezionato un "nome" nella cat_list può :
  • Invocare la visualizzazione dei dati a "self.mater", la finestra-madre (non il Notebook), che è un parametro di istanza obbligatorio, ciò viene effettuato tramite la pressione del tasto "Visualizza" (bt_open);
  • pubblicare una richiesta di cancellazione dei dati riferiti al "nome", di cui viene notiziata self.mater, questa azione viene effettuata tramice il pulsante "Elimina" (bt_del);
  • ottenersi una anteprima dei dati se la checkbox "self.ck_vis" è selezionata.
Per i dettagli "operativi" si guardino i metodi di callback nel codice sopra, intanto, una immagine con un ListPanel in piena funzionalità in ambiente windows

png
Il micione nella foto è il mio Honey ;)



Si accenna, ora, alla "strana" modalità implementata per la "richiesta" di cancellazione dati effettuata con questo stralcio di codice del callback "_on_del(self)"
CODICE
msg = ['CATDELETE', sel_name]
       try:
           pub.sendMessage('REQUEST', message=msg)
       except ValueError as e:
           msgb.showerror('Avvenuto errore', repr(e))

Ne parlerò più diffusamente nella parte terminale del post ma intanto notate che viene definita una lista "msg" con una stringa in maiuscolo ed un oggetto sel_name che altri non è che il nome corrente selezionato nell'elenco del gatti, viene quindi invocato un metodo "sendMessage(...)" di un fantomatico "pub" che si importa da pubsub (quel pypubsub all'inizio del post) : tale metodica serve a comunicare a chi è in ascolto l'intenzione di cancellare un dato "gatto", ossia un record, ed ha la sua controparte nel metodo di classe "update_values(self, message)" che viene registrato per ricevere un "message" sulla cui base provvede ad aggiornare l'elenco dei gatti, si legga il codice della classe per dettagli.

Al momento, è più interessante la funzione di callback del pulsante "Elenco" della finestra principale:
CODICE
def _on_list(self):
       pnl = ListPanel(self, self.book)
       pub.subscribe(pnl.update_values, 'LISTUPDATED')
       self.book.add(pnl, text='Elenco gatti')
       pub.sendMessage('REQUEST', message=['GETLIST'])
       self.book.update()
       self._evaluate_self()

Vi sono varie cose in queste sette righe di codice, in primo luogo osserviamo che istanziamo un oggetto "ListPanel" dandogli in pasto la finestra stessa quale "mater" ed il ttk.Notebook quale "parent", o meglio master, quindi si fa il refresh del notebook e si riesamina lo stato della finestra.
Si noti il parametro "text='Elenco gatti'" del metodo add() del notebook, con tale parametro si definisce l'etichetta di un tab (un sub-pannello del notebook), proprietà che, nell'ambito del programma, ho costruttivamente fatto in modo sia unica per utilizzarla quale discriminante.

... riprenderemo poi le due righe con "pub.etc" al momento è sufficiente sapere che con la prima delle righe iscrive in un gruppo (LISTUPDATED) il metodo "update_value" del ListPanel istanziato quale observer mentre il secondo pubblica una richiesta di dati.
Invito, però a leggere il codice del metodo "update_values(self, message)" di ListPanel, si noterà che "message" viene assegnato così com'è alla variabile di istanza "self.data" e dalle manipolazioni successive, ed ancor di più da quelle nel callback "_on_image(self, evt=None)", credo sarà evidente che il messaggio altro non è che una lista di oggetti "Micio" dei quali vengono utilizzate le proprietà definite pur se nel modulo tali classi non sono conosciute.

Per altro, agendo sul pulsante "Visualizza" il suo callback "_on_open(self)" invierà direttamente alla finestra madre (self.mater) una richiesta di visualizzare il gatto correntemente selezionato.

Il pannello del micio


Ricevendo la richiesta di visualizzazione di un gatto, la finestra madre verificherà, in base al nome, che il gatto non sia già visualizzato, in caso contrario provvede ad istanziare un "CatPanel" ed a mostrarlo

png


Nel caso in specie la mia, disastrata, attuale dispobilità hardware, al momento non ho molte immagini per esemplificare :D

CatPanel ha funzionalità ben diverse dal precedente, essendo mirato alla definizione, rappresentazione e modifica di un singolo record (gatto - Micio), intanto il codice della classe :
CODICE
class CatPanel(ttk.Frame):
   '''Un pannello per mostrare un gatto.'''
   def __init__(self, parent, *args, **kwargs):
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self.lbl_img = tk.Label(self, justify='center')
       self.lbl_img.grid(row=0, column=0, padx=10, pady=10, sticky='nsew')
       self.e_descr = tk.Entry(self, state='disabled')
       self.e_descr.grid(row=1, column=0, padx=10, pady=10, sticky='ew')

       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(0, weight=1)

       self.bind('<Configure>', self._on_resize)

       self.state = ''
       self.name = ''
       self.file = ''
       self.img = None

   def _on_resize(self, evt=None):
       self.update()
       self._show_image()
       
   def set_state(self, state):
       self.state = state
       if self.state == 'modify' or self.state == 'new':
           self.e_descr.configure(state='normal')
       else:
           self.e_descr.configure(state='disabled')

   def set_name(self, name):
       self.name = name

   def set_file(self, fname):
       self.file = fname
       self._show_image()

   def update_values(self, message):
       self.name = message[0]
       self.file = message[1]
       if self.state == '': self.e_descr.configure(state='normal')
       self.e_descr.delete(0, 'end')
       self.e_descr.insert('end', message[2])
       if self.state == '': self.e_descr.configure(state='disabled')
       self._show_image()
       self.update()

   def _show_image(self):
       if not self.file: return
       try:
           self.img = get_tk_image(self.file, self.lbl_img)
       except Exception as e:
           msgb.showerror('Caricamento immagine', repr(e))
           self.img = None
       self.lbl_img.configure(image=self.img)
       
   def get_data(self):
       descr = self.e_descr.get().replace('|', '')
       return [self.name, self.file, descr]


Questo "pannello" è l'elemento dedicato alla definizione di parte dei dati, essenzialmente della descrizione accompagnante una immagine assegnata dall'esterno, inseribile e modificabile in una entry (self.e_descr) accessibile a seconda dello stato impostato per il controllo tramite il metodo "set_state(self, state)", credo che leggere il codice sia sufficiente per i particolri.

png



CatPanel è un oggetto piuttosto semplice, credo che leggere il codice sopra (e magari un po' di docs base, se proprio serve) sia sufficiente a chiarirsi le idee circa la sua funzionalità. Forse, un pochino più interessante per gli iniziandi è guardarsi :

La visualizzazione delle immagini


Tanto in CatPanel quanto in ListPanel utilizzano una semplice tinker.Label, utilizzando la proprietà "image" di questo widget.
Le immagini vengono riscalate alla dimensione della label, nel caso di "CatPanel" anche al ridimensionamento della finestra e trasformate in PhotoImage. Per tali operazioni ho utilizzato pillow ed il suo modulo ImageTk. Naturalmente, dato che più di un elemento necessita di tali operazioni, ho ritenuto di definire una funzione apposita che ricevento il nome del file e la label provvede ad elaborare il tutto e restituire la PhotoImage occorrente : get_tk_image(fname, wdg), il codice:
CODICE
def get_tk_image(fname, wdg):
   image = Image.open(fname)
   img_x, img_y = image.size
   r_img = img_x / img_y
   r_wdg = wdg.winfo_width() / wdg.winfo_height()
   if r_wdg <= r_img:
       f_scala = (wdg.winfo_width()-10) / img_x
   else:
       f_scala = (wdg.winfo_height()-10) / img_y
   fx = int(img_x * f_scala)
   fy = int(img_y * f_scala)
   image = image.resize((fx, fy))
   return ImageTk.PhotoImage(image)


... il post sta diventando molto lungo, lo spezzo qui, nel prossimo post si tratterà la gestione dei dati e dei tabs del notebook.

I miei saluti ad improbabili lettori :)

Edited by nuzzopippo - 13/10/2021, 11:16
 
Web  Top
view post Posted on 14/10/2021, 16:33
Avatar

Nubbio x Sempre

Group:
Moderazione globale
Posts:
7,226

Status:


Riflettendo un po' mi son reso conto che è opportuno affrontare la modalità adottata nella gestione dei mici PRIMA di affrontare il discorso della gestione a livello di GUI utente. Parliamo ora della interazione GUI=>Modell implementata.

pypubsub: funzionamento elementare


Il modulo pubsub è una implementazione python dei pattern Publisher, in sostanza fornisce una API per lo scambio di messaggi tra elementi "registrati". Il modulo implementa il pattern "Singleton", il che vuol significare che esiste una ed una sola istanza del modulo attiva nella applicazione ... il che rende questo modulo molto comodo*, istanze diverse di pubsub effettuate in moduli diversi agiranno sullo stesso oggetto.

* rende anche disponibili delle possibilità difficilmente "pensabili", tipo istanziare il modello dati completamente al di fuori del mainloop tkinter, cosa testata apportando leggerissime modifiche a quanto qui esposto

Il funzionamento generale è concettualmente piuttosto semplice :
  1. si iscrive un metodo di un oggetto in un "gruppo";
  2. si invia un messaggio a pubsub indirizzato ad un dato "gruppo", pubsub provvederà a smistare il messaggio a tutti gli iscritti del "gruppo";
  3. si rimuove l'iscrizione del metodo di un oggetto quando non è più utile.
i "gruppi" possono essere più di uno, possono avere "sotto-gruppi e non necessariamente devono essere conosciuti a priori; inoltre, i "messaggi" possono essere oggetti qualsiasi.

Già l'utilizzo nella forma più elementare (per le avanzate vedere la docs) di pypubsub, ossia l'utilizzo dei metodi "subscribe(obj.method, group)", "sendMessage(group, message)" e "unsubscribe(obj.method, group)" di pub permette di definire un protocollo di gestione dati sufficientemente efficace per il nostro semplice "caso".

I Gruppi


Il passo preliminare è definire i gruppi di sottoscrizione, valutando le necessità della applicazione ho optato per la definizione di due gruppi, diciamo così, "fissi" e di un gruppo "composto", sotto descritti
  1. gruppo REQUEST : fisso, ascolta una "richiesta", unico iscritto il metodo "_requests(self, message)" della classe model.Cats, iscrizione permanente per tutta la durata della applicazione;
  2. gruppo LISTUPDATED : fisso, ascolta una comunicazione di aggiornamento dell'elenco dei gatti, unico iscritto il metodo "update_values(self, message)" della classe view.ListPanel, la sottoscrizione avviene ad instanziamento di un ListPanel e cancellata prima della sua distruzione;
  3. gruppo CATUPDATED.cat_name : composto, la parte variabile è "cat_name" ed individua un aggiornamento dati di uno specifico "gatto", unico iscritto il metodo "update_values(self, message)" della classe view.CatPanel cui viene assegnato il "cat_name", la sottoscrizione avviene ad instanziamento di un CatPanel e cancellata prima della sua distruzione.
Pur se nulla avrebbe impedito di effettuarle nelle classi Micio, ListPanel e CatPanel, si è scelto, per facilità di controllo, di effettuare quasi tutta la produzione dei messaggi nelle sole classi, diciamo principali, "Cats" e "MyApp", quest'ultima provvede anche alla sottoscrizione e cancellazione dei pannelli dai gruppi di sottoscrizione.

Anche se non ce ne sarebbe bisogno, si precisa che le sottoscrizioni possono essere multiple per ogni singolo gruppo, anche se nella nostra applicazione ciò non avviene.

Nello stralcio di codice sottostante, tratto dal metodo "view_cat(self, name)" della classe MyApp mostra la creazione e registrazione di un pannello per la visualizzazione dei dati di un dato "gatto" già esistente
CODICE
pnl = CatPanel(self.book)
       pnl.set_state('')
       self.book.add(pnl, text=name)
       self.book.select(pnl)
       pub.subscribe(pnl.update_values,
                     'CATUPDATED.' + '_'.join(name.split()))
       msg = ['SHOWCAT', name]
       pub.sendMessage('REQUEST', message=msg)

Mentre lo stralcio del callback di MyApp che segue mostra la cancellazione della sottoscrizione, eliminazione dal notebook e distruzione del pannello correntemente visualizzato e chiuso dall'utente
CODICE
def _on_close(self):
       label = self.book.tab(self.book.select(), 'text')
       item = self.book.nametowidget(self.book.select())
       self.book.forget(self.book.select())
       if label == 'Elenco gatti':
           pub.unsubscribe(item.update_values, 'LISTUPDATED')
       else:
           data = item.get_data()
           try:
               pub.unsubscribe(item.update_values, 'CATUPDATED.' +
                               '_'.join(data[0].split()))
           except:
               pass
       item.destroy()
       self.update()
       self._evaluate_self()


Il "Vocabolario" di comunicazione


Naturalmente, quando oggetti "diversi" devono scambiarsi messaggi deve essere stabilita una forma di dialogo conosciuta da tutti i soggetti interessati, contenente, per altro, i dati necessari per soddisfare le azioni opportune al messaggio ricevuto.
Nella implementazione realizzata, un elemento che abbia bisogno sia effettuata una certa azione invia al gruppo "REQUEST" un messaggio contenente il tipo di azione richiesta e gli eventuali dati necessari alla sua realizzazione.
Come detto in precedenza, nella nostra applicazione l'unico sottoscrittore al gruppo REQUEST è il metodo "_requests(self, message)" di Cats, il codice di tale metodo evidenzierà il "vocabolario" comune tra la view ed il modello dati
CODICE
def _requests(self, message):
       request = message[0]
       if request == 'GETLIST':
           self._pub_list()
       elif request == 'ADDCAT':
           self._add(message[1:])
       elif request == 'SHOWCAT':
           self._show(message[1])
       elif request == 'UPDATECAT':
           self._update(message[1:])
       elif request == 'CATDELETE':
           self._delete(message[1])


I Messaggi


dal codice sin qui esposto si evince facilmente che i messaggi costituenti le richieste sono delle liste, di uno o più oggetti, nel quale il primo degli elementi identifica l'azione richiesta, i restanti sono i dati indispensabili ad effettuare l'azione.
Segue ora una disamina di detto "vocabolario"

GETLIST
  • struttura :['GETLIST']
  • Azione : emissione messaggio con la lista di oggetti "Micio"
  • messaggio conclusivo emesso : ('LISTUPDATED', self.cats)

ADDCAT
  • struttura : ['ADDCAT', nome, immagine, descrizione]
  • Azione : aggiunge un nuovo gatto, memorizzando l'immagine nella directory di cache, aggiorna la persistenza dati. Se il gatto esiste già emette un ValueError
  • messaggio conclusivo emesso : ('LISTUPDATED', self.cats)

SHOWCAT
  • struttura : ['SHOWCAT', nome]
  • Azione : invia i dati di uno specifico gatto
  • messaggio conclusivo emesso : ('CATUPDATED._nome', [nome, immagine, descrizione])

UPDATECAT
  • struttura : ['UPDATECAT', nome, immagine, descrizione]
  • Azione : sostituisce i dati esistenti di uno specifico gatto con i nuovi, eventualmente elimina la vecchia immagine dalla cache e ci memorizza la nuova. Se non viene rintracciato il gatto emette un ValueError
  • messaggio conclusivo emesso : ('CATUPDATED._nome', [nome, immagine, descrizione]) e ('LISTUPDATED', self.cats)

CATDELETE
  • struttura : ['CATDELETE', nome]
  • Azione : elimina un gatto esistente ed aggiorna la persistenza dati. Se il gatto non viene trovato emette un ValueError
  • messaggio conclusivo emesso : ('LISTUPDATED', self.cats)

Naturalmente, i messaggi al gruppo "LISTUPDATE" vengono intercettati dal metodo "update_values" dell'oggetto "view.ListPanel", se un tale pannello è aperto, mentre i messaggi di gruppo "CATUPDATED._nome" verranno intercettati dal metodo "update_values" dell'istanza di "view.CatPanel" registrata al gruppo, i pannelli provvederanno ad aggiornare la loro rappresentazione.

Cosa avviene nella view


Credo sia ora di esaminare per sommi capi (complessivamente siamo a circa 650 righe di codice ;) ) come funzionano le cose nella view, con particolare attenzione sulla gestione del notebook.

Premettiamo che il codice in esame basa la sua funzionalità sullo scambio di messaggi e sul protocollo appena descritti, non avendo quasi per nulla rapporti diretti con le classi del modulo "model" (in una ulteriore versione qui non discussa neanche importato), si aspetta solo di ricevere degli oggetti che abbiano delle determinate proprietà, cosa siano non interessa, ed invia degli oggetti con certe caratteristiche, che fine facciano non riguarda la view. I punti focali sono i metodi "update_values(self, message)" definiti nei pannelli visti nel post precedente, per la ricezione dati, ed il sistema di messaggistica gestito dai callback dei pulsanti disponibili nella finestra, che danno all'utente la possibilità di decidere cosa vuole fare.

Un ulteriore elemento "importante" è la label dei tabs presenti nel notebook, utilizzata quale discriminante in diversi processi, di norma essa è il "nome" del "gatto" ed in ogni caso DEVE essere unica, non è consentito, in sostanza, avere due tabs con lo stesso testo ... potrebbe essere interessante lo stralcio di codice sottostante
CODICE
if name == 'Elenco gatti':
           msgb.showwarning('Nuovo micio', 'Nome gatto inaccettabile.',
                            parent=self)
           return
       labels = [self.book.tab(x, 'text') for x in
                 self.book.winfo_children()]
       if name in labels:
           msgb.showwarning('Nuovo micio', 'Micio già presente e visualizzato',
                            parent=self)
           return

esso è tratto dal callback "_on_new(self)" collegato al pulsante "Nuovo" di MyApp, che da all'user la possibilità di definire un nuovo gatto.
Si noti come il nome (name) richiesto all'utente nel callback venga verificato sia diverso dalla stringa "Elenco gatti", riservata al solo ListPanel, e come sia controllato non sia presente nella lista labels estratta tramite la list comprehension nel codice.
Riguardo la comprehension è banale che "self.book.tab(x, 'text')" legge la label del singolo tab dell'insieme dei tabs aperti nel notebook ed estratti con "self.book.winfo_children()".

A questo punto e forse il caso di esaminare qualcosa del resto del callback, la parte
CODICE
pnl = CatPanel(self.book)
       pnl.set_name(name)
       pnl.set_state(self.state)
       self.book.add(pnl, text=name)
       self.book.select(pnl)

definisce un nuovo CatPanel con assegnato il notebook quale suo parent, gli assegna alcune proprietà, lo aggiunge al notebook tramite il metodo add di quest'ultimo e lo rende quale corrente (self.book.select(pnl)).
Per altro, nella condizione di nuovo inserimento ('new') od anche in quella di modifica ('modify') sono disponibili dei comandi che possono modificare delle proprietà (solo l'immagine ;) ) del pannello corrente ... non è il caso che possa venir selezionato un'altro, questo stralcio di codice impedisce possa essere fatto
CODICE
for item in self.book.winfo_children():
           if str(item) != self.book.select():
               self.book.tab(item, state='disabled')

Noterete che qui utilizziamo la stessa istruzione self.book.winfo_children() per esaminarne ogni "item", che non è proprio il nostro pannello ma un oggetto, chiamato "window" nella documentazione del quale estrae la stringa descrittiva comparandola con quella restituita dal tab correntemente selezionato e se diversa disabilitando lo item

Quest'ultimo procedimento viene utilizzato anche in caso l'utente voglia modificare l'immagine o la descrizione del "gatto" e prema il pulsante "Modifica"
CODICE
def _on_modify(self):
       for item in self.book.winfo_children():
           if str(item) != self.book.select():
               self.book.tab(item, state='disabled')
       item = self.book.nametowidget(self.book.select())
       self.state = 'modify'
       item.set_state(self.state)
       self._evaluate_self()

nel cui callback vediamo come possiamo ottenere il nostro CatPanel, tramite l'istruzione "item = self.book.nametowidget(self.book.select())", ed operarci su.
Metodo "importante" nametowidget, utilizzata ogni volta si è dovuto operare con un CatPanel ... ovviamente, il pulsante "Modifica" non è disponibile se è selezionato un ListPanel.

Una volta entrati in modalità di inserimento o modifica dati, saranno disponibili i comandi "Immagine", "Salva" ed "Annulla" che permetteranno, rispettivamente, di inserire/cambiare l'immagine del gatto, salvare i dati oppure annullare il processo corrente, distruggendo il tab ed il CatPanel se nuovo inserimento. Si rimanda ai callback "_on_image(self)", "_on_save(self)" e "_on_cancel(self)" di MyApp per i relativi dettagli.

Lo OP del post da cui è scaturito il presente intendeva rappresentare una scheda per ogni suo gatto esistente ... discorso che va bene se si hanno solo pochi gatti ma nella macchina su cui sviluppo non ho gatti, ho però gattine tipo questa

png


e sono molte :lol: ho ritenuto opportuno, quindi, dare l'opportunità di aprire e chiudere la visualizzazione di una micetta ... ops, gatto a volontà dell'utente.

Nel contesto attuale dell'esposizione, diamo la precedenza alla chiusura della visualizzazione, ottenibile utilizzando il pulsante "Chiudi" che provocherà la chiusura del tab corrente ed il cui callback è interessante
CODICE
def _on_close(self):
       label = self.book.tab(self.book.select(), 'text')
       item = self.book.nametowidget(self.book.select())
       self.book.forget(self.book.select())
       if label == 'Elenco gatti':
           pub.unsubscribe(item.update_values, 'LISTUPDATED')
       else:
           data = item.get_data()
           try:
               pub.unsubscribe(item.update_values, 'CATUPDATED.' +
                               '_'.join(data[0].split()))
           except:
               pass
       item.destroy()
       self.update()
       self._evaluate_self()

nel codice vedete utilizzare metodi già noti per estrarre la label ed il pannello dal tag corrente e quindi eliminarlo dal notebook tramite il suo metodo (nuovo) "forget(item)", quindi distruggere il pannello ed aggiornare la finestra.
... invito, però, a porre molta attenzione alle istruzioni "unbsubscribe" di pub è necessario farle e prima di distruggere il pannello o avrete dei tentativi di notifica ad oggetti inesistenti che daranno errori non gestibili nella view o che necessiterebbero di un sistema intercettazione più complesso ed integrato nel model.

Per altro, la sottoscrizione dei pannelli avviene ad ogni loro apertura su dati esitenti, ovvero alla registrazione di nuovi dati con istruzioni tipo sotto
CODICE
pub.subscribe(pnl.update_values, 'LISTUPDATED')
       ...
       pub.subscribe(pnl.update_values,
                     'CATUPDATED.' + '_'.join(name.split()))

si vedano il callback "_on_list(self)" e "_on_save(self)" oltre ai metodi "view_cat(self, name)" e "cat_deleted(self, name)" di MyApp per dettagli in merito.

Memorizzazione della finestra


Una ulteriore richiesta del più volte citato OP era "salvare le schede aperte alla chiusura e riaprirle al nuovo avvio" ... beh, perché non farlo?
Ho quindi deciso di salvare le dimensioni della finestra e gli eventuali tabs aperti al momento della chiusura.

Salvataggio


per salvare lo "stato" della finestra nella sua inizializzazione ho dirottato l'handler del protocollo su di un apposito callback che interviene in fase di chiusura
CODICE
self.protocol('WM_DELETE_WINDOW', self._on_destroy)


e nel callback memorizzo in un dizionario i dati interessanti che salvo poi in un file json localizzato nella direttrice predefinita per i dati, chiudendo quindi la finestra
CODICE
def _on_destroy(self, evt=None):
       '''Memorizza lo stato corrente e chiude,'''
       conf_current = {}
       conf_current['width'] = self.winfo_reqwidth()
       conf_current['height'] = self.winfo_reqheight()
       labels = [self.book.tab(x, 'text') for x in
                 self.book.winfo_children()]
       conf_current['tabs'] = labels
       dirs = model.def_dirs()
       f_name = os.path.join(dirs['data'], 'appstate.json')
       try:
           with open(f_name, mode='w', encoding='utf-8') as f:
               json.dump(conf_current, f)
       except OSError:
           pass
       self.destroy()


Caricamento


per ricaricare i dati, verifico esista il file di salvataggio e nel caso apro il file e ricarico il dizionario con json, quindi ridefinisco le dimensioni della finestra e leggo le eventuali labels dei tag memorizzate, invocando il callback "_on_list()" od il metodo "view_cat(...)" di MyApp a seconda del caso.
CODICE
def _load_conf(self):
       dirs = model.def_dirs()
       f_name = os.path.join(dirs['data'], 'appstate.json')
       prec_conf = None
       if not os.path.exists(f_name) or not os.path.isfile(f_name):
           return
       try:
           with open(f_name, mode='r', encoding='utf-8') as f:
               prec_conf = json.load(f)
       except OSError:
           pass
       if prec_conf is None: return
       # ridimensiona la finestra e la centra
       l = self.winfo_screenwidth()
       a = self.winfo_screenheight()
       wx = prec_conf['width']
       wy = prec_conf['height']
       self.geometry('{}x{}+{}+{}'.format(wx, wy, (l-wx)//2, (a-wy)//2))
       # ricrea gli eventuali tabs
       labels = prec_conf['tabs']
       if not labels: return
       for t in labels:
           if t == 'Elenco gatti':
               self._on_list()
           else:
               self.view_cat(t)
       self.update()


... FINITO! :D

Giocattolo magari un po' rozzo, essendo la prima volta che sperimenti pypubsub e ttk.Notebbok, ma che ho trovato divertente realizzare, nel post successivo troverete il codice completo per provare quanto su esposto.

Ciao

Ed ora il codice completo.

Per poterlo vedere in azione dovrete creare un venv importando i moduli indicati nel primo post, quindi attivarlo e lanciare da terminale il comando
CODICE
python view.py


i moduli sono due e devono essere posizionati nella stessa directory (che, ovviamente, è la stessa in cui darete il comando di lacio)

model.py
CODICE
# -*- coding: utf-8 -*-

import appdirs
import os
from pubsub import pub
import sys
import shutil


def make_recursive_dir(pathname):
   dir_name = os.path.basename(pathname)
   dir_mater = os.path.dirname(pathname)
   if os.path.exists(pathname) and os.path.isdir(pathname):
       return
   else:
       make_recursive_dir(dir_mater)
       os.mkdir(pathname)    
   
def def_dirs():
   '''
Definisce le directory da utilizzare nella applicazione,
eventualmente non esistano le crea.'''
   default_dirs = {}
   appname = 'micetti'
   appauthor = 'nuzzopippo'

   default_dirs['data'] = appdirs.user_data_dir(appname, appauthor)
   default_dirs['cache'] = appdirs.user_cache_dir(appname, appauthor)
   default_dirs['log'] = appdirs.user_log_dir(appname, appauthor)
   try:
       for key in default_dirs.keys():
           make_recursive_dir(default_dirs[key])
   except OSError as e:
       exit(1)
   return default_dirs


class Micio:
   '''Un "Model" per un micio.'''
   def __init__(self, name='', image='', descr=''):
       self._name = name
       self._image = image
       self._description = descr

   @property
   def name(self):
       return self._name

   @name.setter
   def name(self, value):
       self._name = value

   @property
   def image(self):
       return self._image
   
   @image.setter
   def image(self, value):
       self._image = value

   @property
   def description(self):
       return self._description

   @description.setter
   def description(self, value):
       self._description = value

   def get_csv_data(self):
       if not self._name or not self._image or not self._description:
           raise ValueError('I dati del micio sono incompleti')
       return '{}|{}|{}'.format(self._name, self._image, self._description)

   def append_to_csv(self, file_name):
       if not self._name or not self._image or not self._description:
           raise ValueError('I dati del micio sono incompleti')
       try:
           f_exists = os.path.exists(file_name) and os.path.isfile(file_name)
           with open(file_name, 'a') as f:
               if f_exists:
                   f.write('\n')
               f.write(self.get_csv_data())
       except OSError as e:
           raise OSError(e)

   def get_cat(self):
       if not self._name or not self._image or not self._description:
           raise ValueError('I dati del micio sono incompleti')
       return [self._name, self._image, self._description]


class Cats:
   def __init__(self):
       dirs = def_dirs()
       if not dirs:
           raise RuntimeError('Errore di configurazione ambiente operativo')
       self.data = os.path.join(dirs['data'], 'cat.csv')
       self.d_cache = dirs['cache']
       self.cats = []
       self._load_data()
       pub.subscribe(self._requests, 'REQUEST')

   def _load_data(self):
       if os.path.exists(self.data) and os.path.isfile(self.data):
           with open(self.data, 'r') as f:
               self.cats = [Micio(*x.rstrip('\n').split('|')) for x in f.readlines()
                            if x!='\n']
   
   def _requests(self, message):
       request = message[0]
       if request == 'GETLIST':
           self._pub_list()
       elif request == 'ADDCAT':
           self._add(message[1:])
       elif request == 'SHOWCAT':
           self._show(message[1])
       elif request == 'UPDATECAT':
           self._update(message[1:])
       elif request == 'CATDELETE':
           self._delete(message[1])

   def _pub_list(self):
       pub.sendMessage('LISTUPDATED', message=self.cats)

   def _add(self, data):
       # verifica nome micio
       if data[0] in [x.name for x in self.cats]:
           raise ValueError('Nome micio già presente')
       # cheching del file immagine
       try:
           f_o = data[1]
           f_name = os.path.basename(f_o)
           f_d = os.path.join(self.d_cache, f_name)
           shutil.copy(f_o, f_d)
           data[1] = f_d
       except OSError as e:
           msg = 'Errore caching file immagine:\n' + repr(e)
           raise ValueError(msg)
       new = Micio(*data)
       new.append_to_csv(self.data)
       self.cats.append(new)
       self._pub_list()

   def _show(self, name):
       for item in self.cats:
           if item.name == name:
               group = 'CATUPDATED.' + '_'.join(name.split())
               msg = item.get_cat()
               pub.sendMessage(group, message=msg)
               break

   def _update(self, data):
       cat = Micio(*data)
       if not cat.name or not cat.image or not cat.description:
           raise ValueError('Gatto %s ha dati incompleti' % data[0])
       match = False
       for i in range(len(self.cats)):
           if self.cats[i].name == cat.name:
               # verifica e sostituzione dell'immagine
               if self.cats[i].image != cat.image:
                   try:
                       os.unlink(self.cats[i].image)
                       f_o = cat.image
                       f_name = os.path.basename(f_o)
                       f_d = os.path.join(self.d_cache, f_name)
                       shutil.copy(f_o, f_d)
                       cat.image = f_d
                   except OSError as e:
                       msg = 'Errore caching file immagine:<br>' + repr(e)
                       raise ValueError(msg)
               self.cats[i] = cat
               match = True
               break
       if not match:
           raise ValueError('Gatto %s inesistente' % data[0])
       with open(self.data, 'w') as f:
           f.writelines([x.get_csv_data()+'\n' for x in self.cats])
       self._show(cat.name)
       self._pub_list()
   
   def _delete(self, data):
       cat = None
       for item in self.cats:
           if item.name == data:
               cat = item
               break
       if cat is None:
           raise ValueError('Gatto %s inesistente' % data[0])
       self.cats.remove(cat)
       if not self.cats:
           os.unlink(self.data)
       else:
           with open(self.data, 'w') as f:
               f.writelines([x.get_csv_data()+'\n' for x in self.cats])
       self._pub_list()


view.py
CODICE
#-*- coding: utf-8 -*-

import tkinter as tk
from tkinter import ttk
from pubsub import pub
import model
import tkinter.messagebox as msgb
import tkinter.filedialog as fdlg
import tkinter.simpledialog as sdlg
from PIL import Image
from PIL import ImageTk
import os
import json


def get_tk_image(fname, wdg):
   image = Image.open(fname)
   img_x, img_y = image.size
   r_img = img_x / img_y
   r_wdg = wdg.winfo_width() / wdg.winfo_height()
   if r_wdg <= r_img:
       f_scala = (wdg.winfo_width()-10) / img_x
   else:
       f_scala = (wdg.winfo_height()-10) / img_y
   fx = int(img_x * f_scala)
   fy = int(img_y * f_scala)
   image = image.resize((fx, fy))
   return ImageTk.PhotoImage(image)
   

class CatPanel(ttk.Frame):
   '''Un pannello per mostrare un gatto.'''
   def __init__(self, parent, *args, **kwargs):
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self.lbl_img = tk.Label(self, justify='center')
       self.lbl_img.grid(row=0, column=0, padx=10, pady=10, sticky='nsew')
       self.e_descr = tk.Entry(self, state='disabled')
       self.e_descr.grid(row=1, column=0, padx=10, pady=10, sticky='ew')

       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(0, weight=1)

       self.bind('<Configure>', self._on_resize)

       self.state = ''
       self.name = ''
       self.file = ''
       self.img = None

   def _on_resize(self, evt=None):
       self.update()
       self._show_image()
       
   def set_state(self, state):
       self.state = state
       if self.state == 'modify' or self.state == 'new':
           self.e_descr.configure(state='normal')
       else:
           self.e_descr.configure(state='disabled')

   def set_name(self, name):
       self.name = name

   def set_file(self, fname):
       self.file = fname
       self._show_image()

   def update_values(self, message):
       self.name = message[0]
       self.file = message[1]
       if self.state == '': self.e_descr.configure(state='normal')
       self.e_descr.delete(0, 'end')
       self.e_descr.insert('end', message[2])
       if self.state == '': self.e_descr.configure(state='disabled')
       self._show_image()
       self.update()

   def _show_image(self):
       if not self.file: return
       try:
           self.img = get_tk_image(self.file, self.lbl_img)
       except Exception as e:
           msgb.showerror('Caricamento immagine', repr(e))
           self.img = None
       self.lbl_img.configure(image=self.img)
       
   def get_data(self):
       descr = self.e_descr.get().replace('|', '')
       return [self.name, self.file, descr]

   
class ListPanel(ttk.Frame):
   '''Un pannello per elencare gatti... e gattine.'''
   def __init__(self, mater, parent, *args, **kwargs):
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self.mater = mater
       self.data = []
       self.cat_list = tk.Listbox(self)
       self.cat_list.grid(row=0, column=0, rowspan=5,
                          padx=(5,2), pady=5, sticky='nsew')
       v_scroll = tk.Scrollbar(self, orient='vertical',
                               command=self.cat_list.yview)
       v_scroll.grid(row=0, column=1, rowspan=5,
                     padx=(2,5), pady=5, sticky='ns')
       self.cat_list.config(yscrollcommand=v_scroll.set)
       bt_open = tk.Button(self, text='Visualizza', command=self._on_open)
       bt_open.grid(row=0, column=2, padx=10, pady=5, sticky='ew')
       bt_del = tk.Button(self, text='Elimina', command=self._on_del)
       bt_del.grid(row=1, column=2, padx=10, pady=5, sticky='ew')
       self.vision = tk.BooleanVar()
       self.ck_vis = tk.Checkbutton(self, text='Anteprima', onvalue=True,
                                    offvalue=False, variable=self.vision,
                                    command=self._on_image)
       self.ck_vis.grid(row=2, column=2, padx=10, pady=10, sticky='w')
       self.lbl_tbn = tk.Label(self)
       self.lbl_tbn.grid(row=3, column=2, padx=10, pady=10, sticky='nsew')
       self.lbl_dida = tk.Label(self, justify='left')
       self.lbl_dida.grid(row=4, column=2, padx=10, pady=10, sticky='w')

       self.cat_list.bind('<ButtonRelease-1>', self._on_image)
       self.cat_list.bind('<Return>', self._on_image)

       self.grid_columnconfigure(2, weight=1)
       self.grid_rowconfigure(3, weight=1)

       self.img = None

   def _on_open(self):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       for m in self.data:
           if m.name == sel_name:
               micio = m
       self.mater.view_cat(micio.name)
   
   def _on_del(self):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       self.mater.cat_deleted(sel_name)
       msg = ['CATDELETE', sel_name]
       try:
           pub.sendMessage('REQUEST', message=msg)
       except ValueError as e:
           msgb.showerror('Avvenuto errore', repr(e))

   def _on_image(self, evt=None):
       if not self.vision.get():
           self.img = None
           self.lbl_tbn.configure(image='')
           self.lbl_dida.configure(text='')
           return
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       for m in self.data:
           if m.name == sel_name:
               micio = m
       self.lbl_dida.configure(text=micio.description)
       self.img = get_tk_image(micio.image, self.lbl_tbn)
       self.lbl_tbn.configure(image=self.img)
   
   def update_values(self, message):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name:
           sel_name = None
       self.cat_list.delete(0, 'end')
       self.data = message
       for e in [x.name for x in self.data]:
           self.cat_list.insert('end', e)
       if sel_name is None: return
       index = 0
       for item in self.data:
           if item.name == sel_name:
               self.cat_list.index(index)
               break
           index += 1
   

class MyApp(tk.Tk):
   def __init__(self):
       super().__init__()
       self.protocol('WM_DELETE_WINDOW', self._on_destroy)
       self.title('Ron-Ron Land')
       try:
           self.cat_mng = model.Cats()
       except (RuntimeError, OSError) as e:
           msgb.showerror('Avvenuto errore', repr(e))
           self.destroy()
           exit(1)
       self.init_dir = os.path.expanduser('~')
       self.book = ttk.Notebook(self)
       self.book.grid(row=0, column=0, padx=10, pady=10, sticky='nsew')
       cmdnbp = tk.Frame(self)
       cmdnbp.grid(row=1, column=0, padx=5, pady=5, sticky='ew')
       self.bt_new = tk.Button(cmdnbp, text='Nuovo', command=self._on_new)
       self.bt_new.grid(row=0, column=0, padx=5, pady=5, sticky='ew')
       self.bt_change = tk.Button(cmdnbp, text='Modifica', command=self._on_modify)
       self.bt_change.grid(row=0, column=1, padx=5, pady=5, sticky='ew')
       self.bt_img = tk.Button(cmdnbp, text='Immagine', command=self._on_image)
       self.bt_img.grid(row=0, column=2 ,padx=5, pady=5, sticky='ew')
       self.bt_save = tk.Button(cmdnbp, text='Salva', command=self._on_save)
       self.bt_save.grid(row=0, column=3 ,padx=5, pady=5, sticky='ew')
       self.bt_cancel = tk.Button(cmdnbp, text='Annulla', command=self._on_cancel)
       self.bt_cancel.grid(row=0, column=4 ,padx=5, pady=5, sticky='ew')
       self.bt_close = tk.Button(cmdnbp, text='Chiudi', command=self._on_close)
       self.bt_close.grid(row=0, column=5 ,padx=5, pady=5, sticky='ew')
       for i in range(5):
           cmdnbp.grid_columnconfigure(i, weight=1, uniform='a')
       cmdp = tk.Frame(self)
       cmdp.grid(row=2, column=0, padx=5, pady=5, sticky='ew')
       self.bt_list = tk.Button(cmdp, text='Elenco', command=self._on_list)
       self.bt_list.grid(row=0, column=0, padx=5, pady=5, sticky='ew')
       bt_end = tk.Button(cmdp, text='Esci', command=self._on_destroy)
       bt_end.grid(row=0, column=1 ,padx=5, pady=5, sticky='ew')
       cmdp.grid_columnconfigure(0, weight=1, uniform='b')
       cmdp.grid_columnconfigure(1, weight=1, uniform='b')
       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(0, weight=1)
       self.book.bind('<<NotebookTabChanged>>', self._on_tab)
       self.update()
       l = self.winfo_reqwidth()
       self.minsize(l, l)

       self.state = ''
       self.init_dir = os.path.expanduser('~')
       self._load_conf()
       self._evaluate_self()

   def _on_tab(self, evt=None):
       if self.state: return
       if not self.book.tabs(): return
       text = self.book.tab(self.book.select(), 'text')
       if not text:
           self.bt_change.configure(state='disabled')
       elif text == 'Elenco gatti':
           self.bt_change.configure(state='disabled')
       else:
           self.bt_change.configure(state='normal')
       
   def _on_list(self):
       pnl = ListPanel(self, self.book)
       pub.subscribe(pnl.update_values, 'LISTUPDATED')
       self.book.add(pnl, text='Elenco gatti')
       pub.sendMessage('REQUEST', message=['GETLIST'])
       self.book.update()
       self._evaluate_self()

   def _on_new(self):
       msg = 'Inserire nome del micio'
       name = sdlg.askstring('Nuovo micio', msg, parent=self)
       if name is None: return
       if name == 'Elenco gatti':
           msgb.showwarning('Nuovo micio', 'Nome gatto inaccettabile.',
                            parent=self)
           return
       labels = [self.book.tab(x, 'text') for x in
                 self.book.winfo_children()]
       if name in labels:
           msgb.showwarning('Nuovo micio', 'Micio già presente e visualizzato',
                            parent=self)
           return            
       self.state = 'new'
       pnl = CatPanel(self.book)
       pnl.set_name(name)
       pnl.set_state(self.state)
       self.book.add(pnl, text=name)
       self.book.select(pnl)
       for item in self.book.winfo_children():
           if str(item) != self.book.select():
               self.book.tab(item, state='disabled')
       self._evaluate_self()

   def _on_modify(self):
       for item in self.book.winfo_children():
           if str(item) != self.book.select():
               self.book.tab(item, state='disabled')
       item = self.book.nametowidget(self.book.select())
       self.state = 'modify'
       item.set_state(self.state)
       self._evaluate_self()

   def _on_image(self):
       f_name = fdlg.askopenfilename(parent=self,
                                     initialdir=self.init_dir,
                                     title='Seleziona immagine')
       if not f_name: return
       # memorizza la direttrice del file quale corrente
       if os.path.isfile(f_name):
           self.init_dir = os.path.dirname(f_name)
       item = self.book.nametowidget(self.book.select())
       item.set_file(f_name)

   def _on_save(self):
       item = self.book.nametowidget(self.book.select())
       data = item.get_data()
       if self.state == 'new':
           msg = ['ADDCAT'] + data
       elif self.state == 'modify':
           msg = ['UPDATECAT'] + data
       else:
           return
       try:
           pub.sendMessage('REQUEST', message=msg)
       except (ValueError, OSError) as e:
           msgb.showerror('Avvenuto errore', repr(e))
           return
       item.set_state('')
       if self.state == 'new':    
           pub.subscribe(item.update_values, 'CATUPDATED.' +
                         '_'.join(data[0].split()))
       for item in self.book.winfo_children():
           self.book.tab(item, state='normal')
       self.state = ''
       self._evaluate_self()

   def _on_cancel(self):
       if self.state == 'new':
           item = self.book.nametowidget(self.book.select())
           self.book.forget(self.book.select())
           item.destroy()
           self.update()
       else:
           item = self.book.nametowidget(self.book.select())
           item.set_state('')
           data = item.get_data()
           msg = ['SHOWCAT', data[0]]
           pub.sendMessage('REQUEST', message=msg)
       for item in self.book.winfo_children():
           self.book.tab(item, state='normal')
       self.book.select(self.book.winfo_children()[-1])
       self.state = ''
       self._evaluate_self()

   def _on_close(self):
       label = self.book.tab(self.book.select(), 'text')
       item = self.book.nametowidget(self.book.select())
       self.book.forget(self.book.select())
       if label == 'Elenco gatti':
           pub.unsubscribe(item.update_values, 'LISTUPDATED')
       else:
           data = item.get_data()
           try:
               pub.unsubscribe(item.update_values, 'CATUPDATED.' +
                               '_'.join(data[0].split()))
           except:
               pass
       item.destroy()
       self.update()
       self._evaluate_self()

   def _on_destroy(self, evt=None):
       '''Memorizza lo stato corrente e chiude,'''
       conf_current = {}
       conf_current['width'] = self.winfo_reqwidth()
       conf_current['height'] = self.winfo_reqheight()
       labels = [self.book.tab(x, 'text') for x in
                 self.book.winfo_children()]
       conf_current['tabs'] = labels
       dirs = model.def_dirs()
       f_name = os.path.join(dirs['data'], 'appstate.json')
       try:
           with open(f_name, mode='w', encoding='utf-8') as f:
               json.dump(conf_current, f)
       except OSError:
           pass
       self.destroy()
           
   def _evaluate_self(self):
       self.bt_new.configure(state='disabled')
       self.bt_change.configure(state='disabled')
       self.bt_img.configure(state='disabled')
       self.bt_save.configure(state='disabled')
       self.bt_cancel.configure(state='disabled')
       self.bt_close.configure(state='disabled')
       self.bt_list.configure(state='disabled')
       if self.state == 'modify' or self.state == 'new':
           self.bt_img.configure(state='normal')
           self.bt_save.configure(state='normal')
           self.bt_cancel.configure(state='normal')
       elif self.state == '':
           self.bt_new.configure(state='normal')
           if self.book.tabs():
               self.bt_close.configure(state='normal')
           labels = [self.book.tab(x, 'text') for x in
                     self.book.winfo_children()]
           if 'Elenco gatti' not in labels:
               self.bt_list.configure(state='normal')

   def view_cat(self, name):
       labels = [self.book.tab(x, 'text') for x in
                 self.book.winfo_children()]
       if name in labels:
           msgb.showinfo('Attento', 'Micio già visualizzato')
           return
       pnl = CatPanel(self.book)
       pnl.set_state('')
       self.book.add(pnl, text=name)
       self.book.select(pnl)
       pub.subscribe(pnl.update_values,
                     'CATUPDATED.' + '_'.join(name.split()))
       msg = ['SHOWCAT', name]
       pub.sendMessage('REQUEST', message=msg)
       self._evaluate_self()
   
   def cat_deleted(self, name):
       labelitem = None
       for i in self.book.winfo_children():
           label = self.book.tab(i, 'text')
           if label == name:
               labelitem = i
               break
       if labelitem is None: return
       item = self.book.nametowidget(labelitem)
       self.book.forget(labelitem)
       data = item.get_data()
       pub.unsubscribe(item.update_values, 'CATUPDATED.' +
                       '_'.join(data[0].split()))
       item.destroy()

   def _load_conf(self):
       dirs = model.def_dirs()
       f_name = os.path.join(dirs['data'], 'appstate.json')
       prec_conf = None
       if not os.path.exists(f_name) or not os.path.isfile(f_name):
           return
       try:
           with open(f_name, mode='r', encoding='utf-8') as f:
               prec_conf = json.load(f)
       except OSError:
           pass
       if prec_conf is None: return
       # ridimensiona la finestra e la centra
       l = self.winfo_screenwidth()
       a = self.winfo_screenheight()
       wx = prec_conf['width']
       wy = prec_conf['height']
       self.geometry('{}x{}+{}+{}'.format(wx, wy, (l-wx)//2, (a-wy)//2))
       # ricrea gli eventuali tabs
       labels = prec_conf['tabs']
       if not labels: return
       for t in labels:
           if t == 'Elenco gatti':
               self._on_list()
           else:
               self.view_cat(t)
       self.update()



if __name__ == '__main__':
   app = MyApp()
   app.mainloop()


Mi ci son divertito, provando diverse varianti, tra cui una in cui model non era neanche importato da view ... magari interesserà qualcuno.

Ciao :)

Edited by nuzzopippo - 23/10/2021, 08:25
 
Web  Top
1 replies since 3/10/2021, 11:00   222 views
  Share