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

[Python] Messaggi per un ttk.Notebook

« Older   Newer »
  Share  
nuzzopippo
view post Posted on 3/10/2021, 11:00 by: nuzzopippo
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
1 replies since 3/10/2021, 11:00   224 views
  Share