Programmare in Python/Qt - parte 2
posted in python, programmazione, qt |Nella prima parte abbiamo definito un modello contenente la logica per una semplice calcolatrice statistica. Il modello consiste in un contenitore di valori (la lista nativa del Python) e una serie di funzioni che calcolano alcuni indici statistici, come il valore medio e la deviazione standard. In questo articolo getteremo le basi per costruire l’interfaccia utente.
Le librerie Qt costituiscono un framework per creare interfacce utente. Scrivendo dei componenti (detti widget) e combinandoli opportunamente si possono creare interfacce complesse in modo semplice e pulito. Inoltre le Qt dispongono di uno strumento visuale per la creazione delle interfacce davvero eccezionale, il Qt Designer. Il modello di programmazione delle Qt si basa su un’architettura ad eventi chiamata Signal/Slot grazie alla quale si ottengono componenti debolmente accoppiati. Inoltre l’architettura model/view gestisce le relazioni fra i dati e la loro presentazione all’utente.
Signal e Slot
Un’architettura ad eventi è la generalizzazione di un classico design pattern chiamato Observer. In un tale contesto gli osservatori sono registrati agli eventi innescati dai soggetti osservati, ed agiscono di conseguenza. Non è il soggetto che, al verificarsi di qualche condizione, prende le opportune iniziative chiamando in sequenza delle procedure. Il soggetto si limita a notificare la sopraggiunta condizione agli osservatori, i quali decidono autonomamente le azioni da intraprendere. Grazie a questo concetto, noto come Inversion of Control, soggetti e osservatori non sono consapevoli gli uni degli altri e non sono quindi legati ad una conoscenza delle rispettive implementazioni. Ad un soggetto possono essere registrati diversi osservatori senza che ne sia consapevole; così come ciascun osservatore non è cosciente della presenza degli altri. Un così basso accoppiamento fra componenti è la chiave per ottenere codice di qualità e gestire situazioni complesse, come nel caso delle interfacce utente.
Nel gergo Qt gli eventi generati dai soggetti sono detti signal e sono ricevuti dagli slot degli osservatori. I soggetti quindi emettono dei segnali che gli osservatori sono in grado di recepire attraverso i propri slot.
Model/View
Un altro pattern classico, il Model/View/Controller (MVC), è alla base dell’architettura Model/View. Questa distingue due componenti fondamentali, il modello (model) e la presentazione (view). Nel framework Model/View delle Qt il modello definisce le modalità di accesso ai dati e la presentazione si occupa di mostrarli all’utente. Model e view comunicano tramite il meccanismo Signal/Slot: i segnali emessi dal model informano la view sui cambiamenti ai dati; i segnali emessi dalla view forniscono informazioni sull’interazione degli utenti.
Un editor per le sequenze
Vediamo come utilizzare i concetti appena esposti per creare il primo componente dell’interfaccia grafica. Abbiamo innanzitutto bisogno di un editor che ci permetta di manipolare i valori delle sequenze, in modo da poterne aggiungere di nuovi, modificare quelli esistenti ed eliminare i superflui.
La View
L’editor è un ottimo soggetto per l’architettura model/view delle Qt4. Il model avrà la responsabilità di leggere e scrivere sul contenitore di valori, mentre la view mostrerà i valori e fornirà all’utente l’interfaccia per manipolarli. Occupiamoci di quest’ultima e apriamo il designer, scegliamo di creare un nuovo widget nel dialogo di apertura e componiamo un’interfaccia simile a quella in figura.

Il componente principale è una List View (QListView), che mostrerà i valori della sequenza. Nella parte inferiore del widget ci sono altri 4 elementi: una Label, un Line Edit per inserire i nuovi valori e due Push Button. Assegniamo a questi widget dei nomi significativi (esclusa la label per la quale è superfluo), impostando la proprietà objectName di ciascun widget rispettivamente a list_view, edit_valore, add_button e del_button. Questi sono i nomi con cui ci potremo riferire ai vari widget nel codice.
Il designer ci permette di collegare visualmente signal e slot dei vari widget, passando all’apposita modalità con il tasto F4. Facendo click sul Line Edit e trascinando la freccia sul primo Push Button (l’add_button) si apre una finestra dalla quale possiamo connettere il segnale returnPressed() del Line Edit allo slot click() del Push Button. Il segnale returnPressed() viene emesso quando l’utente preme Invio, mentre lo slot click() esegue un click sul pulsante, come se l’avesse fatto l’utente con il mouse. L’effetto di tutto questo è che premere Invio o fare click sul pulsante è la stessa cosa e perciò il codice per eseguire l’operazione è unico; inoltre né l’edit né il pulsante sono consapevoli di questo e rimangono perciò disaccoppiati.
Non ci rimane che salvare la form in un file (p.es. sequenceeditor.ui). Il designer salva le form in un formato XML: con gli appositi strumenti trasformiamo il documento XML nel codice che genera la form vera e propria. Tale strumento, in PyQt4, si chiama pyuic4; per generare il codice lanciamo il comando
pyuic4 -o sequenceeditor_ui.py sequenceeditor.ui
Questo comando genera il codice Python che costruisce la form, e lo salva nel file sequenceeditor_ui.py. Questo codice è la base su cui sviluppare l’editor, ma non scriveremo le nostre modifiche sullo stesso file, in quanto modificando la form nel designer e rigenerando il codice le perderemmo; bensì metteremo il widget in una classe autonoma, ereditando da Ui_SequenceEditor (la classe generata). In ambiente Qt ogni widget deve sempre discendere da QWidget; ispezionando il codice di Ui_SequenceEditor noteremo però che eredita semplicemente da object; ciò indica chiaramente che Ui_SequenceEditor non è un widget, ma solo una helper class che facilita la creazione della form. Dovremo quindi ereditare esplicitamente da entrambe le classi. In Python non è un problema in quanto supporta l’ereditarietà multipla, e questo con le Qt torna molto utile. L’intestazione della classe sarà quindi la seguente:
class SequenceEditor(QtGui.QWidget, Ui_SequenceEditor): “”"Componente per la modifica delle sequenze.”"” def __init__(self, parent=None): super(SequenceEditor, self).__init__(parent) # Set up the user interface from Designer. self.setupUi(self) edit_validator = QDoubleValidator(self) edit_validator.setDecimals(2) # associa il validatore al campo edit self.edit_valore.setValidator(edit_validator)
La classe SequenceEditor discende sia da QWidget che da Ui_SequenceEditor. In questo modo sarà un widget a tutti gli effetti e avrà accesso diretto agli widget della form. La prima operazione da fare è inizializzare il widget chiamando il costruttore della superclasse. Non invochiamo il costruttore di Ui_SequenceEditor semplicemente perché non ha un metodo __init__() esplicito, in quanto la classe non è destinata ad essere istanziata, ma serve solo a fornirci gli strumenti per costruire e gestire la form. Uno di questi strumenti è il metodo setupUi, che prende come parametro l’istanza di un vero widget: per questo gli viene passato self, cioè lo stesso widget che stiamo sviluppando. setupUi imposta le proprietà della form, istanzia i componenti, gestisce il layout, connette signal e slot.
Nelle righe successive aggiungiamo al Line Edit un validatore. Infatti vorremmo che nel campo si potessero inserire soltanto valori in virgola mobile, e non lettere o altri caratteri. Il QDoubleValidator assicura proprio questo, per cui ne creiamo un’istanza, impostiamo a 2 il numero massimo di cifre decimali e lo assegnamo a edit_valore. Così facendo il Line Edit accetterà solo l’input corretto.
Ora lasciamo per un momento il costruttore __init__() (che integreremo fra poco) e aggiungiamo alla classe gli slot che forniranno all’interfaccia il comportamento atteso. In PyQt ogni metodo può essere uno slot, per cui in realtà dovremo soltanto aggiungere due metodi:
def addValue(self): “”"Aggiunge il valore del campo nella sequenza.”"” value = self.edit_valore.text() if value: self._model.appendValue(value) def delValues(self): “”"Rimuove i valori selezionati.”"” indexes = self.list_view.selectionModel().selectedIndexes() self._model.removeValues(indexes)
Il primo slot risponde alla richiesta di aggiungere un valore alla sequenza, mentre il secondo si occupa di rimuovere gli elementi selezionati. In realtà nessuno dei due slot opera direttamente sulla sequenza, ma delega le operazioni al model. Non è responsabilità della view manipolare i dati, ma soltanto portare avanti la logica di presentazione ed interazione con l’utente.
Rimangono da definire alcuni dettagli tecnici, come un metodo per collegare il model alla view:
def setModel(self, sequence_model): “”"Imposta il SequenceModel per l’editor.”"” self._model = sequence_model self.list_view.setModel(self._model)
Poiché il widget principale - quello che si occupa di mostrare i dati - è la List View, la cosa più naturale è creare l’implementazione di un List Model e associarlo a list_view. Quando i dati in self._model cambieranno la list_view ne verrà informata e provvederà ad aggiornarsi.
Ora possiamo completare il costruttore aggiungendo a __init__() il codice che connette i segnali emessi dagli elementi dell’interfaccia agli slot che sviluppano il comportamento della stessa:
QObject.connect(self.add_button, SIGNAL(“clicked()”), self.addValue) QObject.connect(self.del_button, SIGNAL(“clicked()”), self.delValues)
Cliccando sui bottoni verranno invocati gli slot per aggiungere o rimuovere elementi. Non solo: poiché nel designer abbiamo connesso edit_valore all’add_button, verrà eseguito lo slot addValue anche premendo Invio nel Line Edit.
Il Model
Come è stato accennato, il model deve da una parte interagire con il modello dati e dall’altra fornire alla view l’interfaccia per accedere ai dati stessi. Il model si frappone perciò fra la view e il modello applicativo, fungendo da adapter.
In questo modo le view possono essere utilizzate con qualsiasi modello applicativo, basta fornire un adapter (cioè un model) adeguato. L’interfaccia per i modelli è definita da QAbstractItemModel, ma poiché dobbiamo mostrare all’utente una lista di elementi tramite una List View, possiamo partire dall’implementazione parziale fornita da QAbstractListModel.
class SequenceModel(QAbstractListModel): “”"Modello che rappresenta liste di float nel pattern model/view Qt.”"” def __init__(self, sequence): super(SequenceModel).__init__() self._sequence = sequence def rowCount(self, parent): “”"Restituisce la dimensione della lista sottostante.”"” return len(self._sequence) def flags(self, index): “”"Restituisce le modalita’ di azione sull’elemento della lista di posizione index.”"” if not index.isValid(): return Qt.ItemIsEnabled return QAbstractListModel.flags(self, index) | Qt.ItemIsEditable
Senza dilungarci troppo nei dettagli (che invece trovate qui) notiamo alcune cose. Innanzitutto il nostro costruttore prende come parametro la lista di valori che rappresenta il modello applicativo. Gli altri due metodi forniscono alla view informazioni (o metadati) su tale modello, in particolare quanti elementi contiene e quali elementi sono modificabili. La prima informazione è ottenuta con la funzione len() applicata alla lista. La seconda informazione viene fornita aggiungendo il flag ItemIsEditable al risultato del metodo ereditato; la view viene cioè informata che tutti gli elementi sono modificabili. Vanno quindi implementati i metodi che forniscono l’accesso effettivo ai dati.
def data(self, index, role): “”"Restituisce l’elemento della lista sottostante con la posizione indicata da index.”"” if not index.isValid(): return QVariant() row = index.row() size = len(self._sequence) if row >= size: return QVariant() if role in (Qt.DisplayRole, Qt.EditRole): return QVariant(self._sequence[row]) else: return QVariant() def setData(self, index, value, role=Qt.EditRole): “”"Modifica l’elemento di posizione index.”"” data, success = value.toDouble() if index.isValid() and role==Qt.EditRole and success: self._sequence[index.row()] = data self.emit(SIGNAL(“dataChanged()”), index, index) return True else: return False
Il metodo data() è di lettura e restituisce l’elemento indicizzato. Il valore non viene fornito direttamente, ma incapsulato in una struttura che la view si aspetta di ricevere, chiamata QVariant. Il metodo che scrive è setData() e serve a modificare i valori contenuti nella sequenza. Per questo prima converte la stringa ricevuta in input in un double e poi scrive il nuovo valore sulla lista nella posizione indicata da index. E’ necessario avvisare eventuali ascoltatori (in primis la view) della modifica e per questo viene emesso il segnale dataChanged(); questo segnale porta con sé anche gli indici di inzio e fine del range di elementi modificati, così che la view possa aggiornare la loro visualizzazione; in questo caso possiamo modificare solo un elemento per volta, quindi i due indici coincidono con quello dell’unico valore cambiato.
Ci mancano ancora due metodi, non previsti dall’interfaccia, ma specifici del nostro particolare modello applicativo. Sono i metodi per aggiungere ed eliminare elementi dalla sequenza.
def appendValue(self, value): “”"Aggiunge un valore alla sequenza sottostante.”"” actual = locale.atof(value) self._sequence.append(actual) self.reset() def removeValues(self, indexes): “”"Rimuove i valori con gli indici in indexes dalla sequenze dalla sequenza sottostante.”"” indexed_sequence = dict(enumerate(self._sequence)) for idx in [i.row() for i in indexes]: del indexed_sequence[idx] self._sequence[:] = indexed_sequence.values() self.reset()
Il metodo per aggiungere un elemento è appendValue(), che prima di aggiungerlo alla lista (lo riceve infatti come stringa) lo converte in numero secondo le convenzioni locali dell’utente. Questa volta il segnale emesso non è dataChanged(): il nuovo elemento non fa ancora parte della struttura dati e di conseguenza non ha un indice. Viene invece invocato il metodo reset(), che si occupa di emettere un segnale appropriato per indicare che la struttura del modello è cambiata e necessita di una rilettura completa.
Il metodo che rimuove i valori, removeValues(), merita un piccolo approfondimento. Nel designer la List View può essere configurata per selezionare più elementi alla volta, anche in blocchi non contigui, impostando la proprietà selectionMode a ExtendedSelection. La List View quindi fornisce una lista con gli indici degli elementi selezionati. Di primo acchito il codice per rimuovere gli elementi potrebbe quindi essere semplicemente questo:
for idx in [i.row() for i in indexes]: del self._sequence[idx]
Dopo la prima iterazione, in cui viene rimosso l’elemento indicato dal primo indice nella lista, si ha una situazione in cui la sequenza è cambiata mentre la lista degli indici di selezione è rimasta invariata: gli indici degli elementi selezionati sono cioè “sfalsati” rispetto agli elementi della lista modificata. Nemmeno a dirlo, le strutture di alto livello del Python permettono di risolvere il problema in modo semplice ed elegante. La soluzione consiste nel rendere espliciti gli indici degli elementi della lista ed usarli come chiave in un dizionario indice/elemento. La funzione enumerate() restituisce uno speciale iteratore agli elementi della lista; un iteratore permette di scandire uno per volta gli elementi e può essere passato alle funzioni che si aspettano una sequenza. L’iteratore creato da enumerate() non restituisce uno per volta ciscun elemento della lista, bensì una tupla (indice, elemento); in pratica enumerate(['a', 'b', 'c']) costruisce un iteratore per scandire la sequenza [(0, 'a'), (1, 'b'), (2, 'c')]. La funzione dict() applicata all’enumeratore crea il dizionario indice/elemento, da cui possiamo facilmente rimuovere gli elementi per indice. Con l’ultimo passo sostituiamo i valori rimasti nel dizionario a quelli della sequenza originale. Si presti attenzione alla sintassi: self._sequence = indexed_sequence.values() sostituirebbe la lista originale con quella del dizionario; la lista non sarebbe più quella impostata come modello dall’esterno, ma una lista completamente nuova che non ha più niente in comune con quella originale. Per questo motivo dobbiamo usare la notazione, detta slice, con cui si possono indirizzare porzioni di sequenza indicando il primo e l’ultimo elemento; sequenza[1:3] indica gli elementi di sequenza dal secondo (gli indici partono da 0) al quarto. La notazione self._sequence[:] rappresenta gli elementi dal primo all’ultimo; sono questi ad essere sostituiti dagli elementi del dizionario nella lista originale, e non la lista stessa da una nuova istanza.
Un rapido test
Una volta completati gli elementi che costituiscono l’editor, vorremmo vederlo in azione. Aggiungiamo queste righe al modulo che contiene il widget:
if __name__ == ‘__main__’: import sys import locale from ui.sequencemodel import SequenceModel # istanzia l’applicazione app = QtGui.QApplication(sys.argv) # allinea il locale dell’applicazione a quello dell’utente locale.setlocale(locale.LC_ALL, ”) # widget editor = SequenceEditor() # modello values = [1.0, 2.1, 3.2] sequence_model = SequenceModel(values) # assegna il modello all’editor editor.setModel(sequence_model) # mostra il widget editor.show() app.exec_()
L’idioma if __name__ == '__main__' è un “trucco” Python per eseguire un blocco di codice solo se lo script viene eseguito direttamente. Un semplice import non eseguirà il test, ma si limiterà ad importare la classe. Il test crea l’editor, gli assegna il modello e lo mostra, così possiamo provarlo ed interagire con lui alla ricerca di eventuali bug o migliorie all’usabilità.
Conclusioni
L’architettura Model/View è in un certo senso frattale. Abbiamo detto che SequenceModel è il modello per la List View, che presenta i dati all’utente finale. Se però cambiamo punto di vista e scendiamo di livello, il SequenceModel non è altro che una vista del modello applicativo, quello che consiste nel contenitore per i valori e le funzioni che vi si applicano: il SequenceModel è quindi la rappresentazione del modello per un utente particolare, la List View.

Se davvero la realtà in cui viviamo è frattale, allora abbiamo modellato correttamente questa realtà!
Nell’archivio trovate i sorgenti. Nel prossimo articolo ci occuperemo di fare un po’ di calcoli e mostrarne i risultati.
