Arato IngegneriaCode OnlyItaliano

From the documentation of RAVAL, the MIT library of the books: the same as in its repository.

This page is available in Italian only for now.

Solo in codice, con RAVAL

Programmare interfacce grafiche in C# con Avalonia®, senza XAML: dal primo programma ai grafici disegnati a mano

Ing. Roberto Arato

RAVAL 2.10.5 · Avalonia 12.1.4 · .NET 10 · novembre 2026


Indice

Prima di cominciare

Parte I — Il linguaggio C#

  1. Visual Studio Code e il primo programma
  2. Tipi, variabili ed espressioni
  3. Decisioni, cicli e raccolte
  4. Metodi
  5. Classi e oggetti
  6. Ereditarietà, interfacce e polimorfismo
  7. Delegati ed eventi
  8. Generici, null ed estensioni
  9. Errori ed eccezioni
  10. File, JSON e XML
  11. Organizzare un programma
  12. Più lavori insieme: thread, Task, async e await

Parte II — Avalonia in codice

  1. Avalonia e il primo progetto senza XAML
  2. Come è fatto un programma Avalonia
  3. Eventi, proprietà e alberi
  4. Layout e pannelli, dimensioni, allineamenti, DPI
  5. Risorse, stili e temi chiaro e scuro
  6. I controlli di tutti i giorni: testo, pulsanti, scelte, tabelle
  7. Menu, schede, dialoghi e file
  8. Responsive, trascinare, tastiera e mouse, accessibilità, lingue
  9. Architettura: strati, modello, viste, MVC e MVVM
  10. Comandi, servizi, configurazione e prove
  11. Il thread dell'interfaccia: Dispatcher, await, timer, dati che arrivano
  12. Il cruscotto in tempo reale

Parte III — Disegnare

  1. Come disegna Avalonia
  2. Colori, pennelli, trasformazioni e ritagli
  3. Il controllo che disegna e i controlli personalizzati
  4. Immagini e testo grafico
  5. Coordinate, tele interattive e grafici
  6. Esportare e disegnare veloce
  7. Animazioni

Parte IV — I componenti di RAVAL

  1. Le estensioni di RAva
  2. Il progetto finale: un cruscotto
  3. Un server di API JSON
  4. Strumenti di misura: grafici, tabelle e stili pronti
  5. Il log e le impostazioni
  6. Gli alberi: JSON, XML e cartelle
  7. Dialoghi e file in una riga
  8. Appunti e trascinamento
  9. Tabelle grandi e oggetti da modificare
  10. Altri grafici: barre, mappe, carta di Smith e polare
  11. Gli inciampi di Avalonia 12

Appendici

Come leggere questo libro

Questo libro è per chi sa accendere un computer e ha voglia di imparare, ma non ha mai costruito un programma con le finestre, e forse non ha mai scritto una riga di C#. Alla fine saprai scrivere un'applicazione grafica vera, che gira uguale su Windows, macOS e Linux, con i suoi menu, i suoi elenchi, i temi chiaro e scuro e i grafici disegnati da te.

Usiamo tre ingredienti:

  • C# su .NET 10: il linguaggio e la piattaforma, gratuiti e aperti;
  • Avalonia: la libreria che disegna le finestre e i controlli. È gratuita, aperta, e non dipende dai controlli del sistema operativo: disegna tutto da sé con Skia, lo stesso motore grafico di Chrome;
  • RAVAL, la libreria di questo tutorial (RAVAL.dll): aggiunge le estensioni fluenti che rendono breve il codice delle interfacce scritte senza XAML, e i pezzi pronti che quasi ogni programma tecnico si riscrive: grafici, tabelle grandi, alberi, dialoghi in una riga, log, impostazioni, temi e lingue, un server di API JSON.

Le quattro parti

Il libro sale un gradino alla volta, e ogni parte usa solo quello che le parti prima hanno spiegato.

  • Parte I — Il linguaggio C#. Si comincia dall'ambiente di lavoro: come si installano .NET e Visual Studio Code, come si configura l'editor, come si compila, si esegue e si fa il debug di un programma. Poi il linguaggio, dalle variabili ai thread: tipi, decisioni e cicli, metodi, classi, interfacce, delegati ed eventi, eccezioni, file, JSON e XML, e i lavori che corrono insieme con async e await. Tutto in console, senza finestre, perché ogni idea si veda da sola.
  • Parte II — Avalonia in codice. Come è fatto un programma Avalonia: l'applicazione, le finestre, i controlli, i pannelli che li dispongono, le proprietà e gli eventi. Poi i controlli di tutti i giorni, i menu e i dialoghi, i temi, e il modo di organizzare un programma grande: strati, MVC, MVVM, servizi, prove, il thread dell'interfaccia. Si chiude con un cruscotto in tempo reale.
  • Parte III — Disegnare. Quando i controlli pronti non bastano si disegna: linee, figure, curve, colori, trasformazioni, testo e immagini, il controllo che si disegna da sé, i grafici, le animazioni.
  • Parte IV — I componenti di RAVAL. Quello che RAVAL aggiunge ad Avalonia per scrivere meno codice, con un capitolo per ogni gruppo di componenti e i progetti che li mettono al lavoro.

Chi conosce già il C# può saltare la parte I e tornarci quando un rimando lo chiede; chi conosce già Avalonia può cominciare dalla parte IV. Le parti I, II e III sono il riassunto dei primi tre volumi della collana «Solo in codice», dello stesso autore: alla fine di ogni capitolo una riga dice dove trovare l'argomento per intero.

Perché senza XAML

Quasi tutti i tutorial di Avalonia descrivono le finestre in XAML, un linguaggio a marcatori simile all'HTML, e collegano i dati con i binding. Funziona, ma per chi comincia vuol dire imparare due linguaggi, un compilatore che li unisce e una serie di errori che compaiono solo a programma avviato. In questo libro tutto è C#: la finestra si costruisce con new, le proprietà si assegnano, gli eventi si collegano a metodi. Il compilatore controlla ogni riga, l'editor completa i nomi, il debugger si ferma dove vuoi. È lo stesso modo in cui sono scritti i programmi veri dell'autore.

Le regole della casa

Il codice degli esempi segue le regole dell'autore, e le spiegheremo strada facendo:

  • niente lambda (x => ...): gli eventi si collegano a metodi con un nome, i cicli si scrivono con foreach. Il codice è un po' più lungo ma si legge come un testo, ogni pezzo ha un nome che dice cosa fa, e nel debugger lo stack mostra nomi veri invece di <Main>b__0_1;
  • un commento su ogni funzione, che spiega cosa fa e perché;
  • i nomi in italiano, quando sono nostri; quelli di RAVAL sono in inglese, come quelli di Avalonia.

Gli esempi e le figure sono veri

Ogni figura che mostra una finestra o l'uscita di un programma è stata disegnata dal codice che la descrive: i programmi degli esempi si compilano e si eseguono davvero, le finestre si aprono senza schermo e se ne salva l'immagine. Le figure delle parti I, II e III vengono dai volumi della collana; quelle della parte IV dal progetto ESEMPI_RAVAL. Se una figura non corrisponde al codice, è il codice che ha ragione (appendice C).

RAVAL si scarica compilata, come DLL con i commenti per l'editor, da https://raval.aratoingegneria.it (pagina Download, insieme agli esempi dei libri) ed è sotto licenza MIT: puoi usarla nei tuoi programmi, anche commerciali, conservando la nota di copyright.

Alla fine di ogni capitolo ci sono alcuni esercizi: falli, è lì che si impara davvero.

Parte I — Il linguaggio C#

1. Visual Studio Code e il primo programma

Questo capitolo prepara tutto quello che serve per il resto del libro. Alla fine avrai sul tuo computer gli stessi strumenti che usano i programmatori di professione, e un programma che hai scritto, compilato, eseguito e osservato dall'interno con il debugger. Si va piano, un clic alla volta.

1.1 Dal testo al programma

Un programma è una lista di istruzioni che il computer esegue una dopo l'altra, come una ricetta, ma alla lettera: non indovina che cosa intendevi. Le istruzioni le scriviamo in C# (si legge «si sharp»), in un file di testo con l'estensione .cs. Il processore però capisce solo numeri, e in mezzo c'è un traduttore, il compilatore. Prima di tradurre, il compilatore controlla tutto: se sbagli un nome, se metti un testo dove serve un numero, se dimentichi un punto e virgola, te lo dice subito, con il file, la riga e la colonna.

Per sviluppare serve l'SDK di .NET (Software Development Kit, la cassetta degli attrezzi): il comando dotnet, il compilatore, i modelli dei progetti. Chi usa i tuoi programmi non ne ha bisogno. Questo libro usa .NET 10.

1.2 Installare l'SDK di .NET 10

L'SDK si scarica gratis da https://dotnet.microsoft.com/download/dotnet/10.0. Nella tabella della pagina ti serve la colonna SDK, che contiene anche il runtime.

Windows. In Impostazioni → Sistema → Informazioni, alla voce Tipo di sistema, quasi tutti i PC dicono «processore basato su x64». Nella colonna SDK, riga Windows, fai clic su x64 (o Arm64) accanto a Installer. Apri il file scaricato, dotnet-sdk-10.0.xxx-win-x64.exe, fai clic su Installa e conferma quando Windows chiede il permesso. In alternativa basta un comando: winget install Microsoft.DotNet.SDK.10.

macOS. Nel menu Apple, Informazioni su questo Mac: Chip Apple vuol dire Arm64, Processore Intel vuol dire x64. Scarica il .pkg corrispondente, aprilo e segui i passi. Linux. Su Ubuntu basta sudo apt install dotnet-sdk-10.0; per le altre distribuzioni le istruzioni sono in https://learn.microsoft.com/dotnet/core/install/linux.

Verificare. Apri un terminale nuovo, perché quello già aperto non sa ancora che il comando esiste: su Windows premi il tasto Windows, scrivi Terminale e premi Invio; su macOS Cmd+Spazio e Terminale; su Ubuntu Ctrl+Alt+T. Scrivi dotnet --version e premi Invio:

Il comando dotnet --version: l'SDK è installato
Il comando dotnet --version: l'SDK è installato

Il numero è la versione dell'SDK. Se compare invece «dotnet non è riconosciuto», chiudi il terminale e aprine un altro.

1.3 Installare Visual Studio Code

Un editor per programmatori colora il codice, completa i nomi mentre scrivi, segna gli errori prima ancora di compilare e contiene il debugger. Usiamo Visual Studio Code, gratuito e uguale su Windows, macOS e Linux.

  1. Apri https://code.visualstudio.com e fai clic sul grande pulsante blu, Download for Windows (o for macOS, for Linux).
  2. Su Windows apri VSCodeUserSetup-x64-...exe, accetta il contratto e vai Avanti; nella pagina Selezione attività aggiuntive lascia la spunta su Aggiungi a PATH e metti quelle su Apri con Code. Poi Installa. Su macOS trascina l'applicazione nella cartella Applicazioni; su Linux installa il pacchetto .deb o .rpm con un doppio clic.
  3. Avvia Visual Studio Code dal menu Start, da Applicazioni o dall'elenco dei programmi.

Nelle figure di questo capitolo i cerchietti rossi numerati segnano dove fare clic, nell'ordine, e nel testo gli stessi numeri dicono che cosa fare. Se il tuo editor è scuro, è solo un altro tema: zone e menu sono al loro posto.

L'editor in italiano. Al primo avvio Visual Studio Code parla inglese, e se il sistema è in italiano propone di cambiare lingua:

Il primo avvio: l'editor propone l'italiano
Il primo avvio: l'editor propone l'italiano

Fai clic su Install and Restart. Se il riquadro non compare, premi Ctrl+Maiusc+P (su macOS Cmd+Maiusc+P), scrivi display language, scegli Configure Display Language e poi Italiano.

L'estensione per il C#. Un'estensione è un pezzo che si aggiunge all'editor; senza quella giusta, Visual Studio Code non sa niente del C#:

Installare C# Dev Kit dalla vista Estensioni
Installare C# Dev Kit dalla vista Estensioni
  1. Nella barra di icone a sinistra fai clic su quella delle Estensioni, i quattro quadratini (Ctrl+Maiusc+X).
  2. Nella casella in alto scrivi C# Dev Kit.
  3. Controlla che l'editore sia Microsoft, con la spunta blu, e fai clic su Installa. Arrivano da sole anche l'estensione C# e il .NET Install Tool.

Se poi compare un avviso che chiede di accedere con un account Microsoft, chiudilo: non serve.

1.4 Il primo progetto

Un programma C# vive in un progetto: una cartella con un file .csproj, che dice come compilarlo, e i file .cs con il codice. Nel terminale crea una cartella di lavoro, entraci e crea il progetto con dotnet new:

mkdir Esempi
cd Esempi
dotnet new console -n Raddoppia
dotnet new console crea il progetto Raddoppia
dotnet new console crea il progetto Raddoppia

Il modello console fa un programma che scrive righe di testo e legge quello che scrivi alla tastiera; -n Raddoppia dà il nome al progetto e crea la cartella. Dentro ci sono Program.cs, con una riga di prova che sostituiremo, e Raddoppia.csproj. Nel progetto conviene riconoscere due righe: OutputType Exe dice che si produce un programma da avviare, TargetFramework net10.0 la versione di .NET. I file .cs non si elencano: fanno parte del progetto tutti quelli della sua cartella.

1.5 Aprire la cartella

In Visual Studio Code non si apre un file alla volta, ma la cartella del progetto: così l'editor vede il .csproj e capisce che è un progetto C#.

File → Apri cartella...
File → Apri cartella...
  1. Fai clic su File, la prima voce della barra in alto.
  2. Fai clic su Apri cartella..., entra in Esempi, fai un clic sulla cartella Raddoppia, senza entrarci, e poi su Seleziona cartella (su macOS Apri).

La prima volta l'editor chiede se ti fidi di chi ha scritto i file:

La domanda sulla fiducia
La domanda sulla fiducia

Un progetto può contenere istruzioni che l'editor esegue da solo, e una cartella scaricata da internet potrebbe nasconderne di dannose. Questa l'hai creata tu: fai clic su Sì, considero attendibili gli autori. Se rispondi di no, il C# Dev Kit non parte; si rimedia con File → Chiudi cartella e riaprendola.

1.6 Scrivere e salvare

Il progetto aperto: Program.cs nell'editor
Il progetto aperto: Program.cs nell'editor
  1. Nella barra a sinistra fai clic su Program.cs: il file si apre, con il suo nome in una scheda in alto.
  2. Fai clic nell'editor, seleziona tutto con Ctrl+A e cancella. Poi scrivi il programma qui sotto, maiuscole e punti e virgola compresi. Per ora ignora i completamenti che l'editor propone.
  3. Al posto della × nella scheda c'è un pallino: ci sono modifiche non salvate. Ctrl+S salva.
namespace Raddoppia {

  internal static class Program {

    /// <summary>Il punto di partenza: 0 se va bene, 1 se no.</summary>
    public static int Main(string[] args) {
      Console.Write("Scrivi un numero intero: ");
      string? riga = Console.ReadLine();
      if (!int.TryParse(riga, out int numero)) {
        Console.WriteLine($"Non è un numero intero: \"{riga}\".");
        return 1;
      }
      int doppio = Doppio(numero);
      Console.WriteLine($"Il doppio di {numero} fa {doppio}.");
      return 0;
    }

    /// <summary>Il doppio di un numero intero.</summary>
    private static int Doppio(int n) => n * 2;

  } // class
} // namespace

Ogni idea tornerà nei prossimi capitoli; per ora basta riconoscere i pezzi. namespace raggruppa le classi sotto un nome comune, e ogni metodo deve stare dentro una classe, qui Program. Main è il metodo che .NET chiama quando il programma parte; l'int davanti dice che restituisce il codice di uscita, 0 se tutto va bene, un altro numero se no. Console.Write scrive una riga senza andare a capo, Console.ReadLine aspetta che l'utente scriva e prema Invio. int.TryParse prova a leggere un numero nel testo: se ci riesce lo mette in numero, se no (il ! è il «non») il programma lo dice ed esce con return 1. Doppio è un metodo che restituisce n * 2, e il $ davanti alla stringa permette di scrivere valori fra graffe dentro il testo. Il testo dopo // e /// è un commento, per chi legge. Ogni istruzione finisce con ;: dimenticarlo è l'errore più comune in assoluto.

1.7 Compilare dal terminale integrato

Compilare vuol dire far tradurre il programma, con dotnet build. Visual Studio Code ha un terminale suo, già nella cartella del progetto:

Terminale → Nuovo terminale
Terminale → Nuovo terminale
  1. Fai clic su Terminale, nella barra in alto.
  2. Fai clic su Nuovo terminale: in basso si apre il pannello con la scheda Terminale.
dotnet build nel terminale dell'editor
dotnet build nel terminale dell'editor
  1. La scheda Terminale: si scrive qui. Ctrl+J chiude e riapre il pannello.
  2. Scrivi dotnet build e premi Invio.
  3. Avvisi: 0 ed Errori: 0: il programma è pronto, nella nuova cartella bin.

Un errore ferma la compilazione. Un avviso no, ma quasi sempre segnala un difetto vero: gli esempi di questo libro compilano sempre con zero avvisi, ed è un'abitudine da prendere subito.

1.8 Leggere un errore

Togli il punto e virgola in fondo a int doppio = Doppio(numero); e salva. Il C# Dev Kit fa lavorare il compilatore mentre scrivi, e l'errore compare in tre punti:

Lo stesso errore nell'editor
Lo stesso errore nell'editor
  1. L'ondina rossa sotto il punto sbagliato: passa il mouse sopra e leggi il messaggio.
  2. Il pannello Problemi (Ctrl+Maiusc+M) elenca gli errori con riga e colonna; un clic su una riga porta il cursore nel punto da correggere.
  3. Il conto nella barra di stato: la × cerchiata sono gli errori, il triangolo gli avvisi.

Con dotnet build lo stesso errore compare nel terminale, sempre nella stessa forma:

Come si legge un messaggio d'errore
Come si legge un messaggio d'errore

Si legge da sinistra a destra: il file, la riga e la colonna, la gravità (error o warning), il codice, come CS1002, che cercato in rete porta alla pagina che lo spiega, e la descrizione. Correggi sempre il primo errore e ricompila: spesso gli altri sono sue conseguenze. E se la riga indicata sembra giusta, guarda quella prima. Ora rimetti il punto e virgola, salva, e guarda l'ondina sparire.

1.9 Eseguire

dotnet run nel terminale dell'editor
dotnet run nel terminale dell'editor
  1. Nel terminale scrivi dotnet run e premi Invio: il programma compila se serve, parte e fa la sua domanda.
  2. Scrivi 21 e premi Invio: il programma risponde e finisce. Freccia su ripropone l'ultimo comando.

Prova anche a rispondere ventuno: il programma dice che non è un numero ed esce con il codice 1.

1.10 Il debugger

Il debugger ferma il programma a una riga, mostra il valore delle variabili e lo fa proseguire un passo alla volta. È il modo di capire che cosa fa davvero il codice, invece di indovinarlo.

Il punto d'interruzione e la scelta del debugger
Il punto d'interruzione e la scelta del debugger
  1. Fai clic a sinistra del numero della riga int doppio = Doppio(numero);: compare un pallino rosso, il punto d'interruzione. Un secondo clic lo toglie; con il cursore sulla riga, lo stesso fa F9.
  2. Premi F5. La prima volta l'editor chiede quale debugger usare: scegli C#, e poi il progetto Raddoppia.

La barra di stato diventa arancione e nel terminale compare la domanda. Scrivi 21 e premi Invio: il programma si ferma sul punto d'interruzione, prima di eseguire la riga.

Il debugger fermo su un punto d'interruzione
Il debugger fermo su un punto d'interruzione
  1. La riga gialla è la prossima istruzione che il programma eseguirà.
  2. Le variabili: numero vale 21, doppio ancora 0, perché la riga che lo calcola non è stata eseguita.
  3. Lo stack delle chiamate: in quale metodo si è fermi, e chi l'ha chiamato.
  4. La barra del debugger, con i pulsanti per andare avanti.

F10 esegue la riga e si ferma alla successiva; F11 entra nel metodo chiamato, e lo stack delle chiamate cresce di una riga; Maiusc+F11 ne esce; F5 continua fino al prossimo punto d'interruzione; Maiusc+F5 ferma tutto. Passando il mouse su una variabile se ne vede il valore.

1.11 Le zone della finestra, la tavolozza e il terminale

Ora che hai usato l'editor, conviene dare un nome alle sue parti:

Le zone di Visual Studio Code
Le zone di Visual Studio Code
  1. La barra delle attività: le icone Esplora (i file), Cerca, Controllo del codice sorgente (Git), Esegui e debug, Estensioni. Un clic sull'icona già accesa nasconde la barra laterale.
  2. La barra laterale, con i file. Sotto, Esplora soluzioni mostra i progetti come li vede .NET.
  3. Le schede dei file aperti, e sotto la riga che dice dove sei nel codice.
  4. L'editor, con a destra la minimappa, il file in miniatura.
  5. Il pannello: Problemi, Output, Console di debug, Terminale.
  6. La barra di stato: errori e avvisi a sinistra, riga e colonna del cursore a destra.

La tavolozza dei comandi, Ctrl+Maiusc+P, è la porta d'ingresso a tutto: si scrive il nome di un comando, anche solo in parte, e la tavolozza lo trova. Per esempio, un progetto nuovo senza il terminale:

.NET: Nuovo progetto dalla tavolozza dei comandi
.NET: Nuovo progetto dalla tavolozza dei comandi
  1. Premi Ctrl+Maiusc+P e scrivi .net nuovo: l'elenco si restringe ai comandi che contengono quelle parole.
  2. Fai clic su .NET: Nuovo progetto...: l'editor chiede il modello (App console), il nome e la cartella.

Il terminale integrato è il terminale del sistema, già aperto nella cartella di lavoro: tutti i comandi dotnet del libro si scrivono lì. Il menu a tendina del pannello ne apre più d'uno e sceglie fra PowerShell e bash.

1.12 Le estensioni consigliate e gli aiuti dell'editor

Per questo libro bastano poche estensioni, tutte di Microsoft salvo una, e si installano come il C# Dev Kit:

EstensioneA cosa serve
C# Dev Kitprogetti, Esplora soluzioni, debugger
C#colori, completamento, errori mentre scrivi
.NET Install Tooltrova l'SDK per le altre estensioni
EditorConfig for VS Coderegole di stile comuni (editore EditorConfig)
Italian Language Packl'interfaccia in italiano

Quella di Avalonia invece non serve: mostra l'anteprima dei file XAML, che in questo libro non scriviamo.

Con le estensioni a posto, l'editor aiuta mentre scrivi. Il più prezioso è il completamento:

Il completamento mentre si scrive
Il completamento mentre si scrive
  1. Scrivi Console.Wr: compare l'elenco dei nomi possibili. Le frecce scelgono WriteLine, Tab lo scrive.
  2. Accanto, la spiegazione del nome scelto: che cosa vuole e che cosa fa.

Poi Ctrl+. su una riga sottolineata propone le correzioni rapide, F2 su un nome lo rinomina ovunque, F12 porta dove un nome è definito. Le scorciatoie da imparare subito (su macOS Cmd invece di Ctrl):

TastiChe cosa fa
Ctrl+Maiusc+Ptavolozza dei comandi
Ctrl+Ssalva
F5 / Ctrl+F5avvia con / senza debugger
F9, F10, F11punto d'interruzione, passo sopra, passo dentro
Ctrl+Maiusc+Mpannello Problemi
Ctrl+Jmostra o nasconde il pannello

Il lavoro di ogni giorno è sempre lo stesso giro: scrivi, salvi, guardi che il pannello Problemi sia vuoto, compili, provi con il debugger. Sono pochi secondi, e sono quelli che separano chi programma con calma da chi passa le serate a cercare un errore.

Esercizi

  1. Aggiungi un metodo Triplo accanto a Doppio e fai scrivere anche il triplo del numero.
  2. Togli apposta una graffa di chiusura: quale codice d'errore compare nel pannello Problemi? Dove ti porta il clic?
  3. Metti un punto d'interruzione dentro Doppio, avvia con F5 ed entra con F11. Che cosa mostra lo stack delle chiamate? Quanto vale n?
  4. Crea con la tavolozza dei comandi un progetto Saluto che chiede il nome e risponde «Ciao, ...!».

Per approfondire: «Solo in codice», volume 1, capitoli I.1 e I.2.

2. Tipi, variabili ed espressioni

Il primo programma conteneva già una variabile (numero), un tipo (int), un'espressione (n * 2) e una stringa interpolata. Qui riprendiamo quei pezzi uno per uno, perché sono il materiale di cui è fatto ogni programma. Alla fine scriveremo un preventivo con lo sconto e l'IVA, giusto al centesimo.

2.1 Variabili e tipi

Un programma lavora su dati: un numero di pezzi, un prezzo, il nome di un cliente. Mentre il programma gira, ogni dato sta in uno spazio della memoria, e una variabile è il nome che diamo a quello spazio. In C# ogni variabile ha un tipo, che dice che cosa può contenere e che cosa ci si può fare: due numeri si sommano, ma un testo non si divide per tre, e il compilatore se ne accorge prima ancora di eseguire.

Dalla dichiarazione alla memoria
Dalla dichiarazione alla memoria

Una variabile nasce con una dichiarazione: il tipo, il nome e di solito un valore iniziale.

int pezzi = 12;
decimal prezzo = 8.50m;
bool urgente = true;
string cliente = "Officine Rossi";

Il segno = non vuol dire «è uguale a», ma assegna: calcola quello che sta a destra e lo mette nella variabile a sinistra. Per questo pezzi = pezzi + 1; ha senso. Una variabile vive dentro il blocco fra le graffe in cui è dichiarata, e fuori non si vede. Maiuscole e minuscole contano: Pezzi e pezzi sono due variabili diverse. Nel libro le variabili si scrivono in italiano e in camelCase (prezzoUnitario), classi e metodi in PascalCase (Doppio).

2.2 I numeri

Per i numeri interi il tipo di tutti i giorni è int, che arriva a circa 2,1 miliardi; per i numeri più grandi, come i millisecondi o le dimensioni dei file, c'è long. Se un int vale già il massimo e gli aggiungi uno, non dà errore: gira, come un vecchio contachilometri, e ricomincia dal numero più negativo. Si chiama overflow, ed è un errore silenzioso: se un valore può diventare grande, si sceglie long.

Per i numeri con la virgola ci sono due tipi da conoscere:

TipoSi scriveQuando
double3.5misure: temperature, distanze, statistiche
decimal3.5msoldi, e tutto ciò che deve tornare al centesimo

Nel codice il separatore dei decimali è sempre il punto. La m finale dice che il numero è un decimal. Perché due tipi? Perché double lavora in binario, e in binario 0,1 non si scrive esattamente, come un terzo non si scrive esattamente in base dieci:

double, float e decimal a confronto
double, float e decimal a confronto

Con i double 0,1 + 0,2 fa 0,30000000000000004. Per una misura non importa, ma su una fattura un centesimo sbagliato è un errore vero. decimal lavora in base dieci, e 0,1 + 0,2 fa esattamente 0,3: gli importi vanno sempre in decimal.

2.3 Operatori ed espressioni

Un'espressione è un pezzo di codice che produce un valore: 12, pezzi * prezzo, totale >= 100m. Si costruisce con gli operatori: le quattro operazioni + - * /; il resto della divisione %; i confronti == != < > <= >=, che danno vero o falso; &&, || e !, cioè «e», «o», «non»; e +=, ++, che cambiano una variabile.

La divisione merita attenzione: fra due interi il risultato è intero, e la parte decimale viene buttata via. 7 / 2 fa 3, e il resto 7 % 2 fa 1; per avere 3,5 basta che uno dei due non sia intero, 7.0 / 2. Gli operatori hanno una precedenza, come in matematica: 2 + 3 * 4 fa 14, (2 + 3) * 4 fa 20. Il compilatore vede l'espressione come un albero, e la calcola dal basso:

L'albero di un'espressione
L'albero di un'espressione

La figura mostra anche il cortocircuito: se la parte sinistra di && è falsa, quella destra non viene nemmeno calcolata. Così divisore != 0 && totale / divisore > 5 non divide mai per zero. Nel dubbio sulle precedenze, usa le parentesi: chi rilegge il codice ti ringrazia.

2.4 Conversioni e testo

Un int diventa long o double da solo, perché ci sta sempre: è una conversione implicita. Il viaggio inverso può perdere qualcosa, e il compilatore vuole che tu lo dica con un cast, il tipo fra parentesi: (int)3.99 fa 3, perché la parte decimale si tronca. Per arrotondare c'è Math.Round, con una sorpresa: quando un numero sta esattamente a metà, .NET arrotonda verso il pari, e Math.Round(2.5) fa 2. L'arrotondamento della scuola si chiede così: Math.Round(2.5, MidpointRounding.AwayFromZero) fa 3.

Quello che l'utente scrive è sempre testo. Per farne un numero si usa TryParse, che prova e dice se ci è riuscito, come nel primo programma; int.Parse invece ferma il programma se il testo non è un numero. I numeri si scrivono diversamente da paese a paese (4,2 in Italia, 4.2 in Inghilterra), e .NET chiama cultura questo insieme di regole. I programmi del libro la fissano all'italiana nella prima riga di Main:

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("it-IT");

Un bool vale true o false, nasce da un confronto e serve a decidere; conviene dargli un nome, come bool spedizioneGratis = totale >= 100m;. Una string è un testo fra virgolette, e la stringa interpolata, con il $ davanti, scrive i valori fra graffe al loro posto. Dopo i due punti si dà un formato, dopo la virgola una larghezza per allineare le colonne:

Si scriveRisultato
{importo:N2}1.234,50
{importo:C}1.234,50 €
{data:d}24/10/2026
{"pane",-8}pane (larga 8, a sinistra)

Una stringa non cambia mai: nome.ToUpper() ne restituisce una nuova, e si scrive nome = nome.ToUpper();.

Infine due parole che si leggono spesso. var lascia dedurre il tipo al compilatore quando si capisce dalla destra (var pezzi = 12; è un int, per sempre). const dà un nome a un valore fisso, scritto in un posto solo: private const decimal AliquotaIva = 22m;.

2.5 Tipi valore e tipi riferimento

Questa è la distinzione più importante del capitolo, e tornerà in ogni capitolo. I tipi valore contengono il dato: i numeri, bool, le date. Assegnare vuol dire copiare il valore. I tipi riferimento contengono una freccia verso un oggetto che sta altrove nella memoria: gli elenchi, le stringhe, le classi del capitolo 5. Assegnare vuol dire copiare la freccia, e le due variabili indicano lo stesso oggetto.

int a = 5;
int b = a;          // si copia il valore
b = 6;              // a vale ancora 5
int[] x = { 1, 2, 3 };
int[] y = x;        // si copia la freccia: stesso elenco
y[0] = 9;           // anche x[0] vale 9

int[] è un array, un elenco di interi numerati da zero, che vedremo nel capitolo 3.

Che cosa si copia quando si assegna
Che cosa si copia quando si assegna

Una variabile di tipo riferimento può anche non indicare niente: vale null. Il compilatore distingue string, che non può essere null, da string?, che può esserlo: per questo Console.ReadLine() restituisce un string?. Il capitolo 8 tratta null per intero.

2.6 Il programma del capitolo: un preventivo

Il programma chiede quanti pezzi, il prezzo e lo sconto, ognuno con TryParse e i suoi limiti; se un dato non va, lo dice ed esce con il codice 1. Poi fa i conti:

decimal lordo = pezzi * prezzo;
decimal sconto = Centesimi(lordo * percentuale / 100);
decimal imponibile = lordo - sconto;
decimal iva = Centesimi(imponibile * AliquotaIva / 100);
decimal totale = imponibile + iva;
bool spedizioneGratis = totale >= 100m;
/// <summary>L'importo ai centesimi, la metà in su.</summary>
private static decimal Centesimi(decimal importo) {
  return Math.Round(importo, 2, MidpointRounding.AwayFromZero);
}

Tutti gli importi sono decimal, e l'unico int, i pezzi, diventa decimal da solo nella prima moltiplicazione. Si arrotonda ogni volta che nasce un importo con più di due decimali, lo sconto e l'IVA, in un metodo solo. L'aliquota è una costante. Le righe si allineano con i formati: {voce,-14} a sinistra, {importo,12:C} a destra.

Il preventivo eseguito due volte
Il preventivo eseguito due volte

Controlla i conti a mano una volta: 12 × 8,50 fa 102,00; lo sconto del 10% è 10,20; l'imponibile 91,80; l'IVA al 22% sarebbe 20,196, cioè 20,20; il totale 112,00. Il programma ha fatto lo stesso, al centesimo.

Esercizi

  1. La media. Chiedi tre voti interi e scrivi la media con un decimale. Con 7, 8 e 8, se esce 7,0 invece di 7,7 hai fatto una divisione fra interi: correggila.
  2. I secondi. Chiedi un numero di secondi e scrivilo come giorni, ore, minuti e secondi, solo con / e %.
  3. La spedizione. Aggiungi al preventivo una spesa di spedizione di 9,90 euro sotto i 100 euro, come costante.
  4. Valore o riferimento? Senza eseguirlo, che cosa scrive questo codice?
   int[] voti = { 6, 7, 8 };
   int[] copia = voti;
   int primo = voti[0];
   copia[0] = 10;
   primo = 3;
   Console.WriteLine($"{voti[0]} {copia[0]} {primo}");

Per approfondire: «Solo in codice», volume 1, capitolo I.3.

3. Decisioni, cicli e raccolte

Un programma vero sceglie, ripete e tiene elenchi: le righe di una fattura, i clienti in attesa, le parole di un testo. Con queste tre cose e le espressioni del capitolo 2 si scrivono già programmi utili, come la lista della spesa che chiude il capitolo.

3.1 Scegliere: if ed else

L'istruzione if esegue un blocco solo se una condizione è vera; else dice che cosa fare altrimenti, e else if mette in fila più domande:

private static string Descrivi(int gradi) {
  if (gradi < 0) {
    return "gela";
  } else if (gradi < 15) {
    return "fa freddo";
  } else if (gradi < 28) {
    return "si sta bene";
  } else {
    return "fa caldo";
  }
}

Le domande si fanno dall'alto, e la prima che risponde sì decide; le altre non si guardano nemmeno. Per questo l'ordine conta: se la prima domanda fosse gradi < 28, anche a −3 gradi «si starebbe bene».

Una catena di if, domanda dopo domanda
Una catena di if, domanda dopo domanda

Due consigli. Metti sempre le graffe, anche per una riga sola: è facile aggiungere una seconda riga convinti che faccia parte del blocco. E nei metodi controlla per primi i casi sbagliati ed esci subito con return, come faceva il preventivo: il resto del metodo lavora solo con dati buoni.

3.2 Scegliere fra molti casi: switch

Quando la domanda è sempre «quanto vale questa variabile?» e le risposte sono tante, si usa switch. Ogni case è un valore e finisce con break, che esce dallo switch; default vale per tutto il resto:

switch (comando) {
  case "lista": Elenca(spesa); break;
  case "svuota": spesa.Clear(); break;
  default: Console.WriteLine("Non conosco il comando."); break;
}

Quando lo switch serve solo a scegliere un valore, c'è una forma più breve, l'espressione switch:

private static string Giudizio(int voto) => voto switch {
  < 1 or > 10 => "non valido",
  < 6 => "insufficiente",
  6 => "sufficiente",
  7 or 8 => "buono",
  _ => "ottimo",
};

Si legge «il voto, a seconda di com'è». A sinistra di ogni freccia c'è un pattern, la forma che il valore può avere: un valore, un confronto, una combinazione con and, or, not. Il trattino basso _ vale per tutto il resto, e vince il primo braccio che corrisponde.

Quando una variabile può valere solo poche cose fisse, come le stagioni o lo stato di un ordine, si dichiara un enum, un tipo con i soli valori elencati:

internal enum Stagione { Primavera, Estate, Autunno, Inverno }

Si scrive Stagione.Autunno, il compilatore rifiuta Stagione.Monsone, e in una stringa interpolata compare il nome.

3.3 Ripetere un numero noto di volte: for

Il ciclo for ha tre parti fra parentesi: l'inizio, la condizione e il passo. Qui scrive la tabellina del 7:

for (int i = 1; i <= 10; i++) {
  Console.Write($" {7 * i}");
}
Le quattro parti di un for
Le quattro parti di un for

L'inizio si esegue una volta sola. A ogni giro si controlla la condizione: se è vera si esegue il corpo e poi il passo (i++ aggiunge uno), se è falsa il ciclo finisce. L'errore classico è sbagliare di uno: partendo da 1, i < 10 fa 9 giri e i <= 10 ne fa 10. Con le posizioni di un elenco, che partono da 0, si scrive sempre for (int i = 0; i < elenco.Count; i++).

3.4 Ripetere finché serve: while

Quando non si sa quante volte ripetere, si usa while: «finché la condizione è vera, ripeti». Qui si conta quante volte si dimezza mille prima di scendere sotto uno:

double quantita = 1000;
int volte = 0;
while (quantita >= 1) {
  quantita /= 2;
  volte++;
}

Il pericolo è il ciclo infinito: se nel corpo niente cambia la condizione, il programma non finisce più. Prima di scrivere un while chiediti che cosa avvicina la fine a ogni giro; se il programma si blocca, Ctrl+C nel terminale lo ferma. Dentro un ciclo, break esce subito e continue passa al giro dopo. Attenzione: in uno switch dentro un ciclo, break esce solo dallo switch.

3.5 Tenere tanti valori: le raccolte

Per tenere tanti valori dello stesso tipo si usa una raccolta. .NET ne ha molte, ognuna per un lavoro:

Le sei raccolte più usate
Le sei raccolte più usate

L'array ha un numero fisso di posti, numerati da 0: in int[] voti = { 7, 8, 6, 9 };, voti[0] è il primo, voti.Length dice quanti sono, e l'ultimo è a Length - 1. Per scorrerli tutti c'è foreach, «per ogni voto nei voti», che non ha bisogno di indici e non può sbagliare di uno:

int somma = 0;
foreach (int voto in voti) {
  somma += voto;
}

List<T> è un array che cresce e si accorcia, la raccolta che userai più spesso. Il tipo fra parentesi angolari dice che cosa contiene:

var nomi = new List<string> { "Marta", "Luca" };
nomi.Add("Anna");          // in fondo
nomi.Remove("Luca");
nomi.Sort();               // in ordine alfabetico

Count dice quanti sono, Contains se un valore c'è, nomi[0] legge o cambia un elemento.

Dictionary<TChiave, TValore> lega una chiave a un valore, come una rubrica, e trova una chiave in un attimo anche fra milioni:

var prezzi = new Dictionary<string, decimal>();
prezzi["pane"] = 2.40m;    // crea la voce, o la sostituisce
if (prezzi.TryGetValue("burro", out decimal burro)) {
  Console.WriteLine($"burro {burro:C}");
}

Leggere con le quadre una chiave che non c'è ferma il programma: quando non si è sicuri si usa TryGetValue, che funziona come TryParse. Per gli altri lavori:

Se ti serveUsa
un elenco in ordine, che cresceList<T>
trovare un valore da una chiaveDictionary<TChiave, TValore>
sapere se un valore c'è, senza doppioniHashSet<T>
servire nell'ordine di arrivoQueue<T>
tornare indietro, dall'ultimo al primoStack<T>

Le raccolte sono tipi riferimento (§2.5): assegnare una lista a un'altra variabile non la copia. Una copia vera si fa con new List<int>(originale).

3.6 Il programma del capitolo: la lista della spesa

Il programma legge un comando per riga finché non arriva fine: aggiungi pane 2, togli pane, lista, svuota. La lista è un Dictionary<string, int>, dalla cosa da comprare al numero di pezzi, e il cuore è un while con uno switch:

bool finito = false;
while (!finito) {
  Console.Write("> ");
  string? riga = Console.ReadLine();
  if (riga == null) break;
  string[] parole = riga.Trim().ToLower().Split(' ',
    StringSplitOptions.RemoveEmptyEntries);
  if (parole.Length == 0) continue;
  switch (parole[0]) {
    case "aggiungi": Aggiungi(spesa, parole); break;
    case "togli": Togli(spesa, parole); break;
    case "lista": Elenca(spesa); break;
    case "svuota": spesa.Clear(); break;
    case "fine": finito = true; break;
    default: Console.WriteLine("Non conosco il comando."); break;
  }
}

Il ciclo finisce in due modi: con fine, che mette finito a true, o quando l'ingresso finisce e ReadLine restituisce null. Il break di quel caso esce dal while, quelli dentro lo switch solo dallo switch: per questo fine usa la variabile. Split divide la riga in parole e ToLower la porta in minuscolo, così Pane e pane sono la stessa voce. Ogni comando ha il suo metodo, e lo switch si legge come un elenco di comandi.

La lista della spesa al lavoro
La lista della spesa al lavoro

Nella sessione ci sono errori fatti apposta, uova zero e compra: il programma risponde a tutto e non si ferma mai.

Esercizi

  1. FizzBuzz. Scrivi i numeri da 1 a 30, ma «Fizz» al posto dei multipli di 3, «Buzz» dei multipli di 5, «FizzBuzz» dei multipli di tutti e due. In che ordine vanno le domande?
  2. Il numero segreto. Il programma sceglie un numero con Random.Shared.Next(1, 101) e l'utente prova a indovinarlo, sentendosi dire «più alto» o «più basso». Che ciclo usi?
  3. La media dei voti. Leggi voti fino a una riga vuota, tienili in una List<int> e scrivi media, massimo e minimo.
  4. Le parole. Conta quante volte compare ogni parola di una frase con un Dictionary<string, int>.

Per approfondire: «Solo in codice», volume 1, capitolo I.4.

4. Metodi

Un metodo è un pezzo di codice con un nome, che si scrive una volta e si chiama quante volte serve. Ne hai già scritto uno, Doppio, e ne hai usati tanti: Console.WriteLine, int.TryParse, List.Add. È il primo strumento per mettere ordine: invece di trecento righe una dopo l'altra, tanti metodi brevi, ognuno con un compito che si legge già dal nome.

4.1 Dichiarare e chiamare un metodo

Un metodo ha una firma, la prima riga, e un corpo fra graffe:

private static double Area(double baseR, double altezza) {
  double risultato = baseR * altezza;
  return risultato;
}
Le parti di un metodo
Le parti di un metodo

private dice che lo può chiamare solo il codice della stessa classe; static che si chiama senza creare un oggetto (fino al capitolo 5 i nostri metodi sono tutti così). Poi viene il tipo restituito, double, o void se il metodo non restituisce niente; il nome, un verbo per i metodi che fanno (Scambia) e un nome per quelli che calcolano (Area); e i parametri, ognuno con il suo tipo. return esce e restituisce il valore. Quando il corpo è una sola espressione si può scrivere in una riga con la freccia: => baseR * altezza vuol dire esattamente { return baseR * altezza; }. Era la forma di Doppio.

Per chiamare un metodo si scrive il nome con gli argomenti fra parentesi, e la chiamata si usa come un valore qualunque: double a = Area(3, 4);. Che cosa succede, passo per passo:

Che cosa succede in una chiamata
Che cosa succede in una chiamata

Chi chiama si ferma; gli argomenti vengono calcolati e copiati nei parametri; il metodo esegue il suo corpo; al return il valore torna indietro, e chi chiamava riprende. Le variabili del metodo esistono solo durante la chiamata.

4.2 Copie, ref e out

Gli argomenti sono copiati, quindi il metodo lavora sulla sua copia:

private static void Raddoppia(int n) {
  n *= 2;   // cambia solo la copia
}

Se numero vale 10, dopo Raddoppia(numero) vale ancora 10. Con un array sembra diverso: un metodo che ne azzera gli elementi li azzera anche per chi chiama. È la stessa regola applicata a un tipo riferimento (§2.5): si copia la freccia, e con la sua copia il metodo arriva allo stesso array.

Quando si vuole proprio che il metodo cambi una variabile di chi chiama, si usa ref: il parametro diventa un altro nome per la variabile stessa. Con out il metodo restituisce un valore in più attraverso un parametro, ed è il meccanismo di int.TryParse:

private static bool ProvaVoto(string testo, out int voto) {
  return int.TryParse(testo, out voto) && voto >= 1 && voto <= 10;
}

ref e out si scrivono anche nella chiamata, come in ProvaVoto(riga, out int voto), così chi legge vede che quella variabile può cambiare. Usali con parsimonia: per restituire più valori ci sono le tuple.

4.3 Parametri facoltativi, params e sovraccarichi

Un parametro può avere un valore predefinito, che vale quando chi chiama non lo scrive. I facoltativi vanno in fondo, e si possono passare con il nome:

private static void Scrivi(string voce, double valore,
                           int decimali = 2) { ... }

Scrivi("Media", 7.25);
Scrivi("Numeri", 5, decimali: 0);

Con il nome, la seconda chiamata si capisce; Scrivi("Numeri", 5, 0) no. Con params davanti all'ultimo parametro, un array, il metodo accetta quanti argomenti si vuole: Somma(1, 2, 3) e Somma() funzionano tutte e due.

Più metodi possono avere lo stesso nome, purché i parametri siano diversi per numero o per tipo: sono i sovraccarichi, e il compilatore sceglie quello giusto guardando gli argomenti. Console.WriteLine ne ha una ventina, uno per int, uno per double, uno per string. Vanno bene quando i metodi fanno la stessa cosa su tipi diversi; se fanno cose diverse, meritano nomi diversi.

4.4 Più risultati: le tuple

Una tupla è un gruppo di valori fra parentesi, ed è il modo più semplice di restituire due o tre risultati:

private static (int Minimo, int Massimo) Estremi(int[] valori) {
  int minimo = valori[0], massimo = valori[0];
  foreach (int v in valori) {
    if (v < minimo) minimo = v;
    if (v > massimo) massimo = v;
  }
  return (minimo, massimo);
}

Chi la riceve legge i valori per nome, estremi.Minimo, oppure la scompone subito: var (piuBassa, piuAlta) = Estremi(temperature);. Le tuple vanno bene per risultati piccoli e di passaggio; quando gli stessi valori viaggiano sempre insieme, meritano una classe o un record (capitolo 5).

4.5 La ricorsione

Un metodo può chiamare se stesso: è la ricorsione. Il fattoriale di 5 è 5 × 4 × 3 × 2 × 1, cioè 5 per il fattoriale di 4, e il fattoriale di 1 è 1:

private static long Fattoriale(int n) {
  if (n <= 1) return 1;          // il caso base
  return n * Fattoriale(n - 1);  // il passo ricorsivo
}

Il caso base si risolve senza richiamarsi, il passo ricorsivo si richiama su un problema più piccolo. Ogni chiamata aspetta la risposta di quella sotto, nella pila che il debugger mostrava nel §1.10:

La pila delle chiamate nella ricorsione
La pila delle chiamate nella ricorsione

Senza caso base la pila cresce finché la memoria non finisce, e il programma si chiude. La ricorsione dà il meglio con le strutture che si ramificano, come le cartelle che contengono cartelle, o l'albero dei controlli di una finestra (capitolo 15).

4.6 Il programma del capitolo: le statistiche

Il programma legge numeri fino a una riga vuota e scrive quanti sono, media, mediana, minimo, massimo e deviazione standard. Il punto è la forma: Main dice che cosa fare, e ogni altro metodo fa una cosa sola.

List<double> numeri = LeggiNumeri();
if (numeri.Count < 2) {
  Console.WriteLine("Servono almeno due numeri.");
  return 1;
}
double media = Media(numeri);
(double minimo, double massimo) = Estremi(numeri);
Scrivi("Numeri", numeri.Count, decimali: 0);
Scrivi("Media", media);
Scrivi("Mediana", Mediana(numeri));
Scrivi("Minimo", minimo);
Scrivi("Massimo", massimo);
Scrivi("Deviazione standard", Deviazione(numeri, media), 3);
return 0;

Main si legge come una ricetta; il come sta nei metodi, che si provano uno per uno. La media serve anche alla deviazione, e gliela si passa invece di ricalcolarla. Mediana mette i numeri in ordine su una copia, perché ordinare la lista originale cambierebbe i dati di Main (§4.2). Estremi restituisce una tupla, e Scrivi ha un parametro facoltativo che si scrive con il nome quando serve diverso.

Le statistiche di cinque numeri
Le statistiche di cinque numeri

Quanto deve essere lungo un metodo? Un segnale: se per spiegare che cosa fa serve la parola «e» («legge i numeri e calcola la media e scrive»), probabilmente sono due o tre metodi.

Esercizi

  1. Il massimo. Scrivi Massimo(int a, int b), poi Massimo(int a, int b, int c) che usa il primo, poi una versione con params.
  2. Le ore. Scrivi un metodo che riceve dei minuti e restituisce la tupla (int Ore, int Minuti); poi lo stesso con due parametri out. Quale chiamata si legge meglio?
  3. Le cifre. Scrivi SommaCifre(int n) con un ciclo e con la ricorsione: per 2026 fa 10.
  4. La moda. Aggiungi alle statistiche il valore che compare più volte, con un Dictionary<double, int>.

Per approfondire: «Solo in codice», volume 1, capitolo I.5.

5. Classi e oggetti

Fino ad ora i dati erano variabili sparse, e le regole che li legano (il saldo non scende sotto il fido) erano controlli sparsi in Main. In un programma di cinquemila righe, qualcuno prima o poi dimentica un controllo. Le classi mettono i dati e le loro regole nello stesso posto. È l'idea su cui si regge tutto il resto del libro: ogni finestra, ogni pulsante di Avalonia è un oggetto di una classe.

5.1 Pensare a oggetti

Nella programmazione a oggetti il programma è fatto di oggetti, e ognuno ha uno stato (i dati: un conto ha 350 euro), un comportamento (le cose che sa fare: versare, prelevare) e un'identità (due conti con lo stesso saldo restano due conti). Gli oggetti non si toccano i dati a vicenda: si chiedono le cose. Il programma non scrive «saldo = saldo − 850», chiede al conto «preleva 850», ed è il conto che decide se si può. Ogni regola sta dentro l'oggetto che ha i dati per controllarla, in un posto solo, e nessuno la può aggirare.

Una classe descrive un tipo di cosa; un oggetto, o istanza, è una cosa vera di quel tipo, con i suoi dati. La classe è lo stampo, gli oggetti sono i biscotti:

Una classe, tre oggetti
Una classe, tre oggetti

Un oggetto si crea con new, e i suoi membri si raggiungono con il punto:

var tavolo = new Rettangolo(4, 3);
var foglio = new Rettangolo(2.1, 2.97);
Console.WriteLine($"{tavolo.Area} e {foglio.Area}");

Il codice è lo stesso per tutti, i dati sono di ognuno. Ogni classe sta in un file con il suo nome, Rettangolo.cs. Per trovare le classi di un problema c'è un trucco che funziona: scrivere il problema in poche frasi e sottolineare le parole. In «la biblioteca presta un libro a un socio» i nomi suggeriscono le classi (Biblioteca, Libro, Socio) e i verbi i metodi (Presta).

5.2 Campi privati e proprietà

I dati di un oggetto stanno nei campi, variabili dichiarate dentro la classe e fuori dai metodi. Si fanno sempre private, visibili solo alla classe, e per convenzione cominciano con il trattino basso: _celsius. Se il campo fosse pubblico, chiunque potrebbe scrivere cucina._celsius = -1000;. Nascondere i dati e lasciarli toccare solo attraverso la classe si chiama incapsulamento, ed è la ragione per cui esistono le classi.

Da fuori si passa da una proprietà, che si usa come un campo ma dentro è una coppia di metodi: get restituisce il valore, set lo riceve nella variabile value e può rifiutarlo:

private const double ZeroAssoluto = -273.15;
private double _celsius;

public double Celsius {
  get => _celsius;
  set {
    if (value < ZeroAssoluto) {
      throw new ArgumentOutOfRangeException(nameof(value),
        "Sotto lo zero assoluto non si va.");
    }
    _celsius = value;
  }
}

cucina.Celsius = 21.5; chiama set, e cucina.Celsius in un'espressione chiama get. Se il valore è impossibile, throw lancia un'eccezione: l'oggetto dice «questo no», e il programma si ferma a meno che qualcuno non la gestisca (capitolo 9).

Che cosa fa una proprietà con il suo campo
Che cosa fa una proprietà con il suo campo

Quasi sempre però basta una forma breve:

FormaChe cosa fa
{ get; set; }il campo lo crea il compilatore
{ get; }di sola lettura: si scrive nel costruttore
{ get; private set; }si legge da fuori, si scrive nella classe
=> espressionecalcolata: non tiene un dato, lo calcola

Le proprietà calcolate sono preziose. public double Fahrenheit => _celsius * 9 / 5 + 32; non può mai essere in disaccordo con Celsius, perché non è un dato, è un calcolo. Una proprietà per quello che l'oggetto è o ha (Area, Saldo), un metodo per quello che fa (Versa, Preleva).

5.3 I costruttori

Il costruttore prepara un oggetto quando nasce: ha il nome della classe e nessun tipo restituito.

public Rettangolo(double larghezza, double altezza) {
  if (larghezza <= 0 || altezza <= 0) {
    throw new ArgumentOutOfRangeException(nameof(larghezza),
      "I lati devono essere maggiori di zero.");
  }
  Larghezza = larghezza;
  Altezza = altezza;
}

È il posto giusto per i controlli: se un oggetto non può nascere con dati sbagliati, nessun metodo dovrà mai preoccuparsene. Una classe può avere più costruttori, e uno può chiamare l'altro con : this(...): public Rettangolo(double lato) : this(lato, lato) { } fa un quadrato, con il controllo scritto una volta sola.

Per gli oggetti con tante proprietà c'è l'inizializzatore, che le scrive per nome dopo il new: new Libro { Titolo = "Solo in codice", Pagine = 520 }. Con le proprietà init è l'unico momento in cui si possono scrivere.

5.4 static, readonly, sealed

Un membro static appartiene alla classe, non agli oggetti: ne esiste uno solo, condiviso. Un contatore statico, per esempio, può dare a ogni biglietto un numero progressivo, mentre il numero di ciascun biglietto è un membro d'istanza:

Un campo per la classe, uno per ogni oggetto
Un campo per la classe, uno per ogni oggetto

Un membro statico si raggiunge con il nome della classe, come Math.Round; uno d'istanza dall'oggetto, come tavolo.Area. Una classe con soli membri statici è una static class, come Program e Console. È comoda per le costanti e per i valori scritti una volta all'avvio; per i dati che cambiano va usata con prudenza, perché diventano stato globale, e se un totale è sbagliato il colpevole può essere qualunque riga (capitolo 11).

readonly è un campo che si scrive solo nel costruttore; blocca la variabile, non l'oggetto, e una lista readonly può crescere. sealed è una classe da cui nessuno può ereditare (capitolo 6): finché una classe non è pensata per essere estesa, la si dichiara così.

5.5 Oggetti, riferimenti e record

Le classi sono tipi riferimento (§2.5): due variabili possono indicare lo stesso oggetto, e == confronta le frecce, non i dati. Due termometri distinti con la stessa temperatura non sono «uguali». Un oggetto vive finché qualcosa lo indica; quando nessuno lo indica più, il garbage collector prima o poi libera la memoria. In C# non si distrugge mai un oggetto a mano.

A volte un oggetto non ha regole: è solo un gruppo di dati che non cambia, come un punto del piano o un movimento bancario. Per questi c'è il record, che si dichiara in una riga:

internal sealed record Punto(double X, double Y);

Il compilatore crea il costruttore e le proprietà, e in più l'uguaglianza per valore (due punti con le stesse coordinate sono uguali), un ToString leggibile, Punto { X = 3, Y = 4 }, e l'espressione with, che fa una copia con qualche valore cambiato: a with { X = 6 }.

Record e classe a confronto
Record e classe a confronto

Una classe quando l'oggetto ha un'identità e regole da far rispettare, come un conto; un record quando l'oggetto è i suoi dati, come un punto.

5.6 Il programma del capitolo: un conto corrente

Il programma tiene un conto con 100 euro di fido e accetta comandi come versa 1200 stipendio, preleva 850 affitto, saldo, estratto. È diviso in tre file. Movimento.cs è un record, perché un movimento, una volta scritto, non cambia:

internal sealed record Movimento(int Numero, string Causale,
                                 decimal Importo, decimal SaldoDopo);

Conto.cs è la classe con il saldo e le regole. Eccone il cuore:

internal sealed class Conto {

  private readonly List<Movimento> _movimenti = new();

  public decimal Fido { get; }
  public decimal Saldo { get; private set; }
  public IReadOnlyList<Movimento> Movimenti => _movimenti;

  /// <summary>Un prelievo; false se andrebbe oltre il fido.</summary>
  public bool Preleva(decimal importo, string causale) {
    Controlla(importo);
    if (Saldo - importo < -Fido) return false;
    Registra(causale, -importo);
    return true;
  }

  // il saldo nuovo e il movimento, sempre insieme
  private void Registra(string causale, decimal importo) {
    Saldo += importo;
    int numero = _movimenti.Count + 1;
    _movimenti.Add(new Movimento(numero, causale, importo, Saldo));
  }
}

Il saldo ha private set: si legge da fuori, ma cambia solo con Versa e Preleva, che controllano. Un punto solo, Registra, aggiorna il saldo e aggiunge il movimento, così i due non possono mai essere in disaccordo. I movimenti si vedono da fuori come IReadOnlyList, che si legge ma non ha Add. E Preleva restituisce false quando andrebbe oltre il fido: decide il conto, chi chiama sa solo com'è andata.

Il terzo file, Program.cs, parla con l'utente: legge il comando, controlla l'importo, chiama il conto, scrive il risultato. Non sa come si calcola il saldo. Lo stesso Conto funzionerebbe senza cambiare una riga dietro una finestra Avalonia, ed è il primo passo verso le architetture della parte II.

Il conto corrente al lavoro
Il conto corrente al lavoro

Un inciampo da conoscere: il progetto si chiama Conto, ma il suo spazio dei nomi si chiama Banca. Uno spazio dei nomi con lo stesso nome di una sua classe (Conto.Conto) compila, ma confonde il compilatore e chi legge.

Esercizi

  1. Il cerchio. Scrivi una classe Cerchio con il raggio, controllato dal costruttore, e le proprietà calcolate Diametro e Area (con Math.PI).
  2. Il termometro con il massimo. Aggiungi a Termometro la proprietà Massima, di sola lettura da fuori, aggiornata a ogni lettura. Dove va il codice che la aggiorna?
  3. Il record dell'orario. Scrivi un record Orario(int Ore, int Minuti) con un metodo Aggiungi(int minuti) che restituisce un orario nuovo: new Orario(23, 50).Aggiungi(20) dà 0:10.
  4. Due conti. Fai gestire al programma due conti, con un bonifico dall'uno all'altro. Che cosa deve succedere se il prelievo dal primo è rifiutato?

Per approfondire: «Solo in codice», volume 1, capitolo I.6.

6. Ereditarietà, interfacce e polimorfismo

I tipi di un programma vero sono spesso imparentati. Un cerchio e un rettangolo sono entrambi forme, con un'area e un perimetro. Un libro e un paio di cuffie sono entrambi articoli, con un'IVA diversa. In Avalonia un pulsante e una casella di testo sono entrambi controlli. Questo capitolo spiega come C# esprime queste parentele, e come si scrive codice che lavora con «una forma qualunque» senza sapere quale.

6.1 Ereditare

Una classe può derivare da un'altra, scrivendone il nome dopo i due punti: ha tutto quello che ha la base, e ci aggiunge il suo.

internal sealed class Cerchio : Forma {

  public double Raggio { get; }

  public Cerchio(string nome, double raggio) : base(nome) {
    Raggio = raggio;
  }
}

Cerchio eredita da Forma la proprietà Nome e aggiunge Raggio. Il costruttore passa il nome alla base con : base(nome), e la base viene costruita per prima: prima l'oggetto diventa una forma, poi un cerchio. La relazione si legge «è un»: un cerchio è una forma. Se la frase suona falsa («un conto è un movimento»), ereditare è sbagliato.

La gerarchia delle forme
La gerarchia delle forme

Una classe ha una sola base, e tutto deriva da object, che dà a tutti ToString: per questo qualunque valore si può scrivere in una stringa interpolata. La catena può essere lunga: un quadrato è un rettangolo, una forma e un object.

6.2 virtual, override, abstract

Un cerchio calcola l'area in un modo, un rettangolo in un altro. Una derivata può cambiare un membro della base, se la base lo permette: virtual vuol dire «c'è un comportamento, si può cambiare», abstract «non c'è, ogni derivata deve scriverlo», e la derivata lo cambia con override. Ecco la base delle forme:

internal abstract class Forma {

  public string Nome { get; }

  protected Forma(string nome) {
    Nome = nome;
  }

  public abstract double Area { get; }
  public virtual string Tipo => "forma";

  public override string ToString() => $"{Tipo} {Nome}: area {Area:0.##}";
}

Area è astratta: una forma generica non ha un'area, ce l'hanno il cerchio e il rettangolo. Una classe con membri astratti dev'essere abstract, e non si crea con new: non esiste una forma che non sia di un tipo preciso. Tipo è virtuale, e ogni derivata la cambia. ToString, scritto una volta nella base, dà il risultato giusto per ogni forma, perché usa Tipo e Area dell'oggetto vero. Il cerchio completa così:

public override double Area => Math.PI * Raggio * Raggio;
public override string Tipo => "cerchio";

protected è a metà fra public e private: lo vedono la classe e le sue derivate. Il costruttore di Forma lo è, perché lo chiamano solo le derivate.

6.3 Il polimorfismo

Ecco il punto di tutto il capitolo. Una List<Forma> può contenere cerchi, rettangoli e quadrati insieme, e il codice che la scorre chiede l'area senza sapere che forma ha davanti:

var forme = new List<Forma> {
  new Cerchio("moneta", 1.5),
  new Rettangolo("tavolo", 4, 3),
  new Quadrato("piastrella", 2),
};
foreach (Forma f in forme) {
  Console.WriteLine(f);
}
Lo stesso codice, il metodo giusto per ogni oggetto
Lo stesso codice, il metodo giusto per ogni oggetto

Si chiama polimorfismo, «molte forme»: la stessa chiamata esegue il metodo del tipo vero dell'oggetto, scelto mentre il programma gira. Domani si aggiunge un Triangolo senza cambiare una riga del ciclo. In Avalonia è dappertutto: il pannello che mette in fila i controlli non sa se ha davanti un pulsante o un'immagine, e ogni controllo si misura e si disegna a modo suo con metodi virtuali (capitolo 16).

Quando serve sapere se una forma è proprio un cerchio, is controlla il tipo e mette l'oggetto in una variabile del tipo giusto: if (f is Cerchio c) Console.WriteLine(c.Raggio);. Ma se ti trovi a scrivere molti controlli sul tipo, chiediti se non manchi un metodo virtuale.

6.4 Le interfacce

Un'interfaccia è un contratto: un elenco di membri senza codice, che una classe promette di avere. Il nome comincia con la I:

internal interface IScalabile {
  Forma Scala(double fattore);
}

Una classe la implementa scrivendola dopo i due punti, accanto alla base, class Cerchio : Forma, IScalabile, e deve avere tutti i membri promessi. Una classe ha una base sola, ma tante interfacce. La differenza: una classe astratta dice che cosa una cosa è, e dà codice comune; un'interfaccia dice che cosa una cosa sa fare, anche fra classi che non hanno nient'altro in comune.

.NET usa le interfacce dappertutto. List.Sort() mette in ordine le forme se le forme implementano IComparable<Forma>, il cui unico metodo dice quale viene prima:

public int CompareTo(Forma? altra) {
  return altra is null ? 1 : Area.CompareTo(altra.Area);
}

6.5 Comporre invece di ereditare

L'ereditarietà si usa meno di quanto sembri. Uno scontrino può avere delle promozioni, il tre per due, lo sconto oltre una soglia. Con l'ereditarietà avremmo ScontrinoConTrePerDue, ScontrinoConSoglia, ScontrinoConTrePerDueESoglia: una classe per ogni combinazione.

Ereditare o comporre
Ereditare o comporre

La soluzione è comporre: lo scontrino non è una promozione, ha delle promozioni, in una lista di oggetti che implementano un'interfaccia. Una promozione nuova è una classe nuova, e lo scontrino non si tocca. La regola: eredita quando la relazione è davvero «è un» e la gerarchia è piccola e stabile; componi in tutti gli altri casi.

6.6 Il programma del capitolo: lo scontrino

Gli articoli sono una gerarchia, perché un libro è un articolo. La classe astratta Articolo tiene nome, prezzo e quantità, calcola il totale, e lascia alle derivate l'aliquota IVA, astratta, e un dettaglio sotto la riga, virtuale. Le derivate sono corte:

internal sealed class Elettronica : Articolo {

  public int MesiGaranzia { get; }

  public Elettronica(string nome, decimal prezzo, int mesiGaranzia = 24)
    : base(nome, prezzo, 1) {
    MesiGaranzia = mesiGaranzia;
  }

  public override decimal AliquotaIva => 22m;
  public override string? Dettaglio => $"garanzia {MesiGaranzia} mesi";
}

Le promozioni implementano un'interfaccia con il nome e il calcolo dello sconto:

internal interface IPromozione {
  string Nome { get; }
  decimal Sconto(IReadOnlyList<Articolo> articoli);
}

Lo scontrino compone gli uni e le altre: chiede a ogni articolo Totale e Dettaglio, a ogni promozione Sconto, e ognuno risponde a modo suo. Le classi concrete le conosce solo Program, che le crea:

var scontrino = new Scontrino();
scontrino.Aggiungi(new Alimentare("pane", 1.20m, 3,
                                  primaNecessita: true));
scontrino.Aggiungi(new Libro("Solo in codice", 39.90m));
scontrino.Aggiungi(new Elettronica("cuffie", 59.00m));
scontrino.Aggiungi(new TrePerDue("pane"));
scontrino.Aggiungi(new ScontoSoglia(100m, 5m));
scontrino.Stampa();
Lo scontrino stampato
Lo scontrino stampato

Per aggiungere domani una promozione nuova o un articolo venduto a peso basta una classe nuova: lo scontrino non cambia di una riga.

Esercizi

  1. Il triangolo. Aggiungi una classe Triangolo con i tre lati e l'area con la formula di Erone. Che cosa devi cambiare nel ciclo che scorre le forme?
  2. Per perimetro. Aggiungi a Forma un perimetro astratto e scrivilo nelle derivate.
  3. La tessera. Scrivi una promozione che toglie il 10% dai soli alimentari (is Alimentare). Quante righe dello scontrino hai cambiato?
  4. Ereditare o comporre? Decidi se è «è un» o «ha un»: un'auto e un motore; un'auto e un veicolo; una finestra e un pulsante.

Per approfondire: «Solo in codice», volume 1, capitolo I.7.

7. Delegati ed eventi

Fino ad ora un metodo si chiamava scrivendone il nome: Raddoppia(21). Ma a volte chi scrive il codice non sa ancora quale metodo dovrà essere chiamato. List.Sort deve confrontare due elementi, ma il confronto lo decide chi ordina. Un pulsante deve fare qualcosa quando viene premuto, ma che cosa lo decide chi lo mette nella finestra. Per questi casi C# permette di trattare un metodo come un valore: metterlo in una variabile, passarlo a un altro metodo, tenerne una lista.

È l'idea su cui si reggono tutte le interfacce grafiche, Avalonia compresa: ogni clic, ogni tasto, ogni cambio di testo è un evento, e gli eventi sono fatti di delegati. Questo capitolo è il primo dei due fili che porteremo nella parte II; l'altro è await, nel capitolo 12.

7.1 Un metodo in una variabile

Un delegato è un tipo che descrive una firma di metodo: quali parametri riceve e che cosa restituisce. Una variabile di quel tipo può contenere qualunque metodo con quella firma.

private delegate double Trasformazione(double x);

Trasformazione t = Raddoppia;
Console.WriteLine(t(21));        // 42
t = AlQuadrato;
Console.WriteLine(t(21));        // 441

La prima riga dichiara il tipo: «un metodo che riceve un double e restituisce un double». Poi t = Raddoppia; mette nella variabile il metodo, senza parentesi: non lo chiama, lo indica. Il nome di un metodo usato così si chiama gruppo di metodi. Scrivere t(21) esegue il metodo a cui t punta in quel momento. Un metodo con una firma diversa non ci sta: il compilatore controlla i delegati come ogni altro tipo.

Un delegato: una variabile che contiene un metodo
Un delegato: una variabile che contiene un metodo

Dichiarare un tipo delegato per ogni firma sarebbe noioso, e .NET ne ha di pronti. Action<T> contiene un metodo che non restituisce niente; Func<T, TRisultato> uno che restituisce un valore, sempre del tipo scritto per ultimo; Predicate<T> uno che risponde sì o no. Così si scrivono Func<double, double> radice = Math.Sqrt; o Predicate<int> pari = EPari;. Le liste li usano: numeri.FindAll(EPari) dà una lista nuova con i soli numeri pari, RemoveAll toglie quelli che vanno bene, Sort(PerLunghezza) ordina con il confronto che gli dai. numeri.FindAll(EPari) si legge quasi in italiano: è il vantaggio dei metodi con un buon nome.

7.2 Un metodo che riceve un metodo

Con un parametro di tipo delegato anche i nostri metodi possono ricevere un comportamento da fuori:

private static double[] Applica(double[] valori, Func<double, double> f) {
  var risultato = new double[valori.Length];
  for (int i = 0; i < valori.Length; i++) risultato[i] = f(valori[i]);
  return risultato;
}

Applica sa scorrere un array; che cosa fare a ogni valore glielo dice chi chiama: Applica(valori, Raddoppia), Applica(valori, Math.Sqrt). Il ciclo è scritto una volta, e le variazioni sono metodi piccoli, ognuno con il suo nome.

E se il metodo da passare avesse bisogno di un dato? Vogliamo moltiplicare per 3, poi per 10, ma Applica accetta solo metodi da double a double, senza posto per il fattore. La soluzione è un piccolo oggetto che ricorda il fattore:

internal sealed class Moltiplicatore {
  private readonly double _fattore;

  public Moltiplicatore(double fattore) {
    _fattore = fattore;
  }

  public double Applica(double x) => x * _fattore;
}
var perTre = new Moltiplicatore(3);
double[] tripli = Applica(valori, perTre.Applica);

perTre.Applica ha la firma giusta, ma è il metodo di un oggetto: il delegato porta con sé l'oggetto, e quindi il suo campo _fattore. Quando Applica chiama f(4), viene eseguito perTre.Applica(4).

Il metodo di un oggetto porta con sé il contesto
Il metodo di un oggetto porta con sé il contesto

Tieni a mente questa piccola classe, perché nella parte II ne scriverai molte: un gestore di clic che deve sapere su quale riga di un elenco ha cliccato l'utente è esattamente un oggetto con un campo e un metodo. Nel codice di altri troverai una forma breve, l'espressione lambda, che scrive un metodo senza nome dentro la chiamata, con una freccia =>. Fa la stessa cosa; in questo libro i metodi hanno sempre un nome, perché così si trovano, si provano e si riconoscono nel debugger.

7.3 Gli eventi

Un delegato può contenere più metodi: con += se ne aggiunge uno, con -= se ne toglie uno, e chiamarlo li esegue tutti, in ordine. Un evento è un delegato così, con due regole in più: da fuori della classe ci si può solo iscrivere (+=) e disiscrivere (-=), e solo la classe che lo dichiara può lanciarlo. Nessuno può svuotare la lista degli iscritti o fingere un evento che non è successo.

Ecco un termostato che avvisa quando la temperatura supera una soglia:

internal sealed class Termostato {
  private readonly double _soglia;
  private double _gradi;

  public event EventHandler<SogliaEventArgs>? SogliaSuperata;

  public Termostato(double soglia) {
    _soglia = soglia;
  }

  public double Gradi {
    get => _gradi;
    set {
      bool primaSotto = _gradi <= _soglia;
      _gradi = value;
      if (primaSotto && value > _soglia) {
        SogliaSuperata?.Invoke(this, new SogliaEventArgs(value));
      }
    }
  }
}

Gli eventi di .NET hanno una forma fissa, che conviene rispettare perché tutti la conoscono. Il tipo è EventHandler<T>, dove T deriva da EventArgs e porta i dati dell'evento (SogliaEventArgs ha la Temperatura). Il gestore riceve due parametri: il mittente, l'oggetto che ha lanciato l'evento, e i dati. Se nessuno è iscritto l'evento vale null, e per questo si lancia con ?.Invoke, che chiama solo se c'è qualcuno (capitolo 8). Chi ascolta scrive un metodo con quella firma e lo iscrive:

public void Suona(object? mittente, SogliaEventArgs e) {
  Console.WriteLine($"ALLARME {Stanza}: {e.Temperatura} gradi");
}
...
termostato.SogliaSuperata += allarme.Suona;
termostato.SogliaSuperata += registro.Annota;
Chi lancia, chi ascolta
Chi lancia, chi ascolta

Il termostato non sa niente dell'allarme e del registro: lancia l'evento, e chi si è iscritto lo riceve. Si può aggiungere domani una mail o una luce rossa senza cambiare una riga del termostato. Nota anche che l'allarme suona una volta sola, quando la temperatura passa la soglia: un evento che scatta di continuo è un evento che si impara a ignorare.

Un inciampo da conoscere. Un oggetto iscritto a un evento resta vivo finché l'evento lo tiene. Se una finestra che dura poco si iscrive all'evento di un oggetto che dura tanto, e non si disiscrive con -= quando si chiude, il garbage collector non può liberarla.

7.4 Il magazzino che avvisa

Il programma del capitolo tiene le giacenze di alcuni articoli, ognuno con una scorta minima. Quando un prelievo porta un articolo sotto il minimo, il magazzino lancia l'evento ScortaBassa; due ascoltatori indipendenti rispondono, Avviso con un messaggio per il magazziniere e Riordino con l'ordine al fornitore.

public bool Preleva(string articolo, int quanti) {
  int prima = Pezzi(articolo);
  if (quanti < 1 || quanti > prima) return false;
  int dopo = prima - quanti;
  _pezzi[articolo] = dopo;
  int minimo = _minimi[articolo];
  if (prima >= minimo && dopo < minimo) {
    OnScortaBassa(new ScortaBassaEventArgs(articolo, dopo, minimo));
  }
  return true;
}

OnScortaBassa è il nome tradizionale del metodo che lancia l'evento: On più il nome dell'evento. Main è l'unico punto che conosce tutti i pezzi, e li collega: magazzino.ScortaBassa += new Avviso().Scrivi; e magazzino.ScortaBassa += riordino.Prepara;.

Il magazzino al lavoro
Il magazzino al lavoro

Il magazzino non sa che cosa fare quando la scorta è bassa, e non deve saperlo: le regole del riordino possono cambiare senza toccarlo. Questa è la forma di quasi tutti i programmi con le finestre: oggetti che fanno il loro lavoro e lanciano eventi, e altri che ascoltano e rispondono. Nella parte II un pulsante sarà un termostato che non hai scritto tu: ha un evento Click, e scriverai

salva.Click += SalvaClic;

private void SalvaClic(object? sender, RoutedEventArgs e) {
  stato.Text = "salvato";
}

con la stessa forma che hai appena imparato: un mittente, i dati dell'evento, un metodo con un nome iscritto con +=. Il capitolo 15 riprende da qui.

Esercizi

  1. La tabella. Scrivi void Tabella(Func<double, double> f, double da, double a, double passo) che scrive i valori di f da da ad a. Provala con Math.Sin e Math.Sqrt.
  2. Il filtro fra due valori. Scrivi una classe Fra con il minimo e il massimo in due campi e un metodo Vero che dice se un valore sta fra i due. Usala con FindAll su una lista di temperature.
  3. Il termostato con il freddo. Aggiungi un evento SogliaScesa, che scatta quando la temperatura torna sotto la soglia, e un ascoltatore che scrive «tutto a posto».
  4. Iscritto due volte. Iscrivi due volte allarme.Suona all'evento. Che cosa succede? Che cosa fa un solo -=?

Per approfondire: «Solo in codice», volume 1, capitolo I.8.

8. Generici, null ed estensioni

Hai già usato i generici senza chiamarli così: List<int>, Dictionary<string, int>, Func<double, double>. Il tipo fra parentesi angolari lo sceglievi tu. In questo capitolo passi dall'altra parte e scrivi tu un tipo con il segnaposto T. Poi affronti il valore più insidioso di C#, null, «niente». E infine i metodi d'estensione, il trucco che rende il codice delle interfacce leggibile come una frase.

8.1 Un tipo con un segnaposto

Supponi di volere una classe che contiene un valore: un intero, un testo, una data. Senza generici avresti tre classi quasi uguali, oppure una classe con un campo object, che contiene qualunque cosa ma va convertito ogni volta e sfugge ai controlli del compilatore. Con i generici la scrivi una volta:

internal sealed class Scatola<T> {
  public T Contenuto { get; }

  public Scatola(T contenuto) {
    Contenuto = contenuto;
  }
}

T è un parametro di tipo: un nome per un tipo che ancora non si conosce. Quando qualcuno scrive new Scatola<int>(42), il compilatore ricava dalla classe generica un tipo vero, Scatola<int>, in cui T è int dappertutto. Così int doppio = numero.Contenuto * 2; compila senza conversioni, e numero.Contenuto.ToUpper() no, perché un int non ha ToUpper: il controllo dei tipi resta intatto.

Un tipo generico e i tipi che il compilatore ne ricava
Un tipo generico e i tipi che il compilatore ne ricava

Anche un metodo può avere un parametro di tipo. Uno che trova il massimo di due valori deve però confrontarli, e con un T qualunque il compilatore non sa se T sa confrontarsi. Si risolve con un vincolo, scritto con where:

public static T Massimo<T>(T a, T b) where T : IComparable<T> {
  return a.CompareTo(b) >= 0 ? a : b;
}

where T : IComparable<T> vuol dire «T deve implementare l'interfaccia con CompareTo» (capitolo 6). Chi chiama di solito non scrive il tipo: Massimo(3, 7) fa dedurre al compilatore che T è int. E chi prova a chiamarlo con un tipo che non sa confrontarsi riceve un errore dal compilatore, non una sorpresa durante l'esecuzione. Altri vincoli comuni sono where T : class, where T : new() e where T : Elemento, cioè «Elemento o una sua derivata».

8.2 null: il valore che manca

Una variabile di un tipo riferimento (una classe, string) non contiene l'oggetto, ma un riferimento a dove l'oggetto sta. E un riferimento può non puntare a niente: vale null. Leggere una proprietà attraverso un riferimento null dà l'errore più comune di tutti, la NullReferenceException.

C# aiuta a evitarlo con <Nullable>enable</Nullable>, attivo in tutti i progetti nuovi. Con questa impostazione string nome è un testo che non deve essere null, e string? email è un testo che può esserlo. Se usi il secondo senza controllare, il compilatore ti avverte. Sono avvisi, non errori, ma ognuno è un'eccezione possibile: vanno presi sul serio. Quattro operatori rendono il lavoro breve:

OperatoreSi leggeChe cosa fa
x?.Nome«se x c'è, Nome»se x è null dà null invece dell'eccezione
x ?? ripiego«x, oppure ripiego»se x è null dà ripiego
x ??= valore«se x manca, valore»assegna solo se x è null
x is not null«x c'è»il controllo, che il compilatore capisce

Si combinano: trovato?.Email ?? "(nessuna email)" vale l'email del contatto se c'è il contatto e c'è l'email, altrimenti il ripiego. È lo stesso ?. con cui il capitolo 7 lanciava un evento solo se qualcuno ascoltava.

?. e ?? in fila
?. e ?? in fila

Anche un numero può mancare davvero: il voto di un compito non ancora corretto, una temperatura non letta. Un int contiene sempre un numero, ma int? (e double?, bool?, DateTime?) può valere null. voto.HasValue dice se il valore c'è, voto ?? 6 dà il valore o un ripiego. Da int? a int non si passa da soli: int b = voto; non compila, perché bisogna dire che cosa fare quando manca, int b = voto ?? 0;.

8.3 Metodi d'estensione

string è un tipo di .NET: non puoi aggiungergli metodi. Eppure sarebbe comodo scrivere titolo.Tagliato(20). Un metodo d'estensione permette proprio questo: è un metodo statico, in una classe statica, con this davanti al primo parametro.

internal static class TestoEstensioni {
  public static string Tagliato(this string testo, int massimo) {
    if (testo.Length <= massimo) return testo;
    return testo[..(massimo - 1)] + "…";
  }
}

Con quel this il metodo si chiama come se fosse di string: titolo.Tagliato(20). Non c'è magia: il compilatore traduce la chiamata in TestoEstensioni.Tagliato(titolo, 20). L'estensione non entra nel tipo e ne vede solo la parte pubblica, e vale solo dove il suo namespace è visibile con un using. È per questo che in un programma Avalonia c'è using Avalonia.Controls;: molti metodi che sembrano dei controlli sono estensioni.

Un'estensione che, fatto il suo lavoro, restituisce l'oggetto stesso permette di chiamarne subito un'altra, in catena. Un codice così si chiama fluente:

internal static class Fluenti {
  public static T Chiamato<T>(this T e, string nome) where T : Elemento {
    e.Nome = nome;
    return e;
  }

  public static Pulsante Scritta(this Pulsante p, string testo) {
    p.Testo = testo;
    return p;
  }
}
Pulsante ok = new Pulsante().Chiamato("ok").Scritta("Salva");

Il punto delicato è il tipo restituito. Se Chiamato restituisse Elemento, dopo .Chiamato("ok") il compilatore vedrebbe solo un Elemento, e .Scritta("Salva"), che vale solo per i pulsanti, non compilerebbe più. Per questo è generica: riceve un T e restituisce lo stesso T. Chiamata su un Pulsante, T è Pulsante, e la catena continua.

Una catena fluente
Una catena fluente

Questo schema ti accompagnerà per tutto il libro: RAVAL è fatta di estensioni generiche come queste, e un'interfaccia si scrive così:

var salva = new Button().Content("Salva").Margin(8).OnClick(Salva);

Ogni anello imposta una proprietà del pulsante di Avalonia e lo restituisce, con il suo tipo vero.

Esercizi

  1. La coda. Scrivi Coda<T>, in cui il primo entrato è il primo a uscire, con Accoda, Togli e Count. Provala con dei testi e con dei numeri.
  2. Il ripiego. Scrivi string Saluto(string? nome) che dà «Ciao, Anna!» o «Ciao, sconosciuto!» in una riga sola, con ??.
  3. Un nuovo anello. Aggiungi a Fluenti un Largo<T> e mettilo in mezzo a una catena. Poi togli il generico e fai restituire Elemento: quale riga non compila più?

Per approfondire: «Solo in codice», volume 1, capitolo I.9.

9. Errori ed eccezioni

Fino ad ora gli errori li hai evitati con i controlli: TryParse invece di Parse, un if prima di un prelievo. È la strada giusta per gli errori che ti aspetti. Ma ci sono errori che il codice che li scopre non sa gestire: un metodo che legge un file non sa se, quando il file manca, deve chiedere un altro nome o fermare tutto. Lo sa chi l'ha chiamato. Le eccezioni portano la notizia di un errore dal punto in cui accade al punto in cui si sa che cosa farne, attraversando quanti metodi serve.

9.1 Che cos'è un'eccezione

int pezzi = int.Parse("dodici");
Console.WriteLine(pezzi);

Questo programma compila, perché il compilatore non può sapere che cosa ci sarà nel testo. Ma eseguito si ferma così:

Unhandled exception. System.FormatException: The input string 'dodici'
was not in a correct format.
   at System.Int32.Parse(String s)
   at Program.<Main>$(String[] args) in C:\...\Program.cs:line 1

int.Parse ha scoperto l'errore e ha lanciato un'eccezione, un oggetto FormatException con dentro la descrizione del problema. Nessuno l'ha catturata, e il programma si è fermato. Il messaggio dice il tipo, il testo e la traccia dello stack: la catena dei metodi in corso, dal più interno a Main, con il file e la riga. È la prima cosa da leggere quando un programma si ferma così.

9.2 try, catch e finally

Il codice che può fallire va in un blocco try, quello da eseguire in caso di errore in un catch, con il tipo dell'eccezione da catturare:

try {
  int n = int.Parse("dodici");
  Console.WriteLine($"letto {n}");
} catch (FormatException ex) {
  Console.WriteLine($"non è un numero: {ex.Message}");
}
Console.WriteLine("il programma continua");

Quando Parse lancia, il resto del try viene saltato, si esegue il catch e poi il programma continua dopo il blocco. L'eccezione non deve per forza essere catturata nel metodo in cui nasce: se un metodo non ha un catch adatto, l'eccezione risale a chi l'ha chiamato, e poi più su, finché trova un catch del tipo giusto. Se arriva in cima a Main senza trovarne, il programma si ferma.

L'eccezione risale la pila delle chiamate
L'eccezione risale la pila delle chiamate

I metodi nel mezzo non devono fare niente per far passare l'errore. Un try può avere più catch, controllati in ordine: dal più preciso al più generale, perché un catch della classe base cattura anche le derivate. Catturare Exception, cioè tutto, va bene in un punto solo, in alto, dove si annota l'errore e si avvisa l'utente. In mezzo al codice nasconde gli errori veri: un catch (Exception) { } vuoto è un difetto che non troverai più.

Un blocco finally dopo il try e i catch si esegue sempre: se tutto va bene, se c'è un errore catturato, se l'errore continua a risalire, e perfino se il try esce con un return. Serve a rimettere le cose a posto: chiudere un file, rilasciare una porta, riattivare un pulsante che era stato spento durante un lavoro.

try, catch, finally: le due strade
try, catch, finally: le due strade

9.3 Lanciare un'eccezione

Si lancia un'eccezione con throw quando un metodo riceve qualcosa con cui non può fare il suo lavoro, e non spetta a lui decidere come rimediare. Per gli argomenti sbagliati .NET ha le eccezioni pronte (ArgumentException, ArgumentNullException, ArgumentOutOfRangeException), e per un oggetto nello stato sbagliato InvalidOperationException. Quando non bastano se ne scrive una, che deriva da Exception e può portare con sé i dati dell'errore:

internal sealed class SaldoInsufficienteException : Exception {
  public decimal Mancano { get; }

  public SaldoInsufficienteException(decimal mancano)
    : base($"Saldo insufficiente: mancano {mancano:C}.") {
    Mancano = mancano;
  }
}
public void Preleva(decimal importo) {
  if (importo <= 0) {
    throw new ArgumentOutOfRangeException(nameof(importo));
  }
  if (importo > Saldo) {
    throw new SaldoInsufficienteException(importo - Saldo);
  }
  Saldo -= importo;
}

nameof(importo) dà il nome del parametro come testo, e se un giorno lo rinomini il compilatore lo aggiorna con lui. Chi cattura la SaldoInsufficienteException può usare Mancano per proporre un prelievo più piccolo, senza ricavare il numero dal messaggio.

Un'eccezione però non è un modo di restituire un risultato. Se un caso è normale (un contatto non trovato, una riga vuota) restituisci null, un bool o un valore apposito: le eccezioni sono per ciò che non dovrebbe succedere, e costano molto più di un if. Quando catturi un'eccezione solo per annotarla, rilanciala con throw; da solo, che ne conserva la traccia; throw ex; la farebbe ripartire da quella riga, e il punto in cui l'errore era nato si perderebbe.

9.4 using: chiudere comunque vada

Molti oggetti tengono occupato qualcosa che va restituito: un file aperto, una connessione, una porta seriale. Per questi .NET ha l'interfaccia IDisposable, con un metodo solo, Dispose, che chiude. E C# ha using, che chiama Dispose da solo all'uscita dal blocco, in qualunque modo ci si esca:

using (var a = new Risorsa("porta A")) {
  a.Usa();
}

// senza blocco: chiude alla fine del blocco che contiene b
using var b = new Risorsa("porta B");
b.Usa();

Anche se Usa lancia un'eccezione, la porta viene chiusa prima che il catch più in alto si esegua. Il compilatore trasforma using in un try/finally. Regola pratica: se un oggetto implementa IDisposable, crealo con using. Nel capitolo 10 lo farai con i file, nel capitolo 12 con l'annullamento dei lavori.

using è un try/finally scritto per te
using è un try/finally scritto per te

Esercizi

  1. La divisione. Chiedi due numeri e scrivi il quoziente intero, catturando FormatException e DivideByZeroException con due messaggi diversi.
  2. Il conto chiuso. Aggiungi a Conto un metodo Chiudi e fai lanciare InvalidOperationException a Preleva quando il conto è chiuso.
  3. Il cronometro. Scrivi una classe Cronometro : IDisposable che nel costruttore avvia uno Stopwatch e in Dispose scrive quanti millisecondi sono passati. Usala con using attorno a un ciclo lungo.

Per approfondire: «Solo in codice», volume 1, capitolo I.10.

10. File, JSON e XML

Un programma che dimentica tutto quando lo chiudi serve a poco. Le impostazioni dell'utente, i documenti, la lista degli ultimi file aperti devono finire su disco e tornare indietro al prossimo avvio. .NET ha quello che serve nel namespace System.IO: Path per i percorsi, File e Directory per i file e le cartelle, i flussi per leggere e scrivere un pezzo per volta. Poi, per salvare oggetti interi, due formati fatti apposta: il JSON e l'XML.

10.1 Percorsi e file

Il separatore fra le cartelle è \ su Windows e / su Linux e macOS: un programma che scrive i percorsi a mano funziona su un sistema solo. La classe Path li compone e li scompone per te, senza toccare il disco:

string file = Path.Join(cartella, "documenti", "lettera.txt");
Console.WriteLine(Path.GetFileName(file));        // lettera.txt
Console.WriteLine(Path.GetExtension(file));       // .txt
Console.WriteLine(Path.ChangeExtension(file, ".pdf"));

La regola che evita molti guai: componi sempre i percorsi con Path.Join, mai con + e le barre a mano. Per i file piccoli, quasi tutti quelli di un programma normale, la classe File fa tutto in una riga:

File.WriteAllText(file, "pane\nlatte\n");   // sostituisce il file
File.AppendAllText(file, "uova\n");         // aggiunge in fondo
string tutto = File.ReadAllText(file);
string[] righe = File.ReadAllLines(file);

Ogni metodo apre il file, fa il suo lavoro e lo chiude, ma tiene il file intero in memoria: per un registro di due gigabyte è troppo. Lì servono i flussi, StreamWriter e StreamReader, che leggono o scrivono una riga per volta. Sono IDisposable, e si creano con using (capitolo 9):

using (var lettore = new StreamReader(file)) {
  string? riga;
  while ((riga = lettore.ReadLine()) != null) {
    Console.WriteLine(riga);
  }
}

ReadLine dà null quando il file è finito. Il using qui non è un dettaglio: StreamWriter accumula in un buffer e scrive a blocchi, ed è Dispose che scrive l'ultimo blocco e chiude il file. Le cartelle si gestiscono con Directory: CreateDirectory crea la cartella e quelle che mancano, GetFiles(cartella, "*.txt") elenca i file. File.Delete non passa dal cestino: prima di cancellare qualcosa dell'utente, chiediglielo.

Un file contiene byte, non lettere, e una codifica è la regola che li trasforma. Quella di .NET, e quella da usare, è UTF-8. Se un testo scritto in UTF-8 viene letto con un'altra codifica, la «à» diventa «Ã »: se vedi questi segni, due programmi non erano d'accordo sulla codifica.

10.2 Dove tenere i dati, e come salvarli

Un programma non deve scrivere nella sua cartella: quando è installato, spesso è protetta. Ogni sistema ha delle cartelle per utente, e Environment.GetFolderPath le trova:

string utente = Environment.GetFolderPath(
  Environment.SpecialFolder.ApplicationData);
string cartella = Path.Join(utente, "MioProgramma");
Directory.CreateDirectory(cartella);

ApplicationData è AppData\Roaming su Windows e ~/.config su Linux e macOS. I file possono mancare, essere aperti da un altro programma, stare dove non si ha il permesso di scrivere: ogni caso ha la sua eccezione (FileNotFoundException, UnauthorizedAccessException, e la loro base IOException), e il codice che legge un file importante sta in un try.

Salvare ha un pericolo più sottile. Se il programma scrivesse direttamente sul file e si interrompesse a metà, il file resterebbe rovinato, con i dati vecchi già cancellati e i nuovi scritti a metà. La cura: scrivere un file temporaneo e poi spostarlo al posto del vecchio.

string temporaneo = _file + ".tmp";
File.WriteAllText(temporaneo, testo);
File.Move(temporaneo, _file, overwrite: true);
Il salvataggio che non lascia un file a metà
Il salvataggio che non lascia un file a metà

In ogni istante su disco c'è un file intero, quello vecchio o quello nuovo.

10.3 Il JSON

Un formato inventato da noi, come righe con un punto e virgola, funziona finché i dati sono semplici. Ma ogni caso strano va pensato a mano: un punto e virgola dentro una nota, un a capo, un campo in più nella versione successiva. Il JSON (si legge «gèison») risolve il problema una volta per tutte. È il formato di dati più usato al mondo, e .NET lo conosce con System.Text.Json, già compreso nel runtime.

Un testo JSON è fatto di sei tipi di valore: stringhe fra virgolette doppie, numeri con il punto, true e false, null, oggetti (coppie "nome": valore fra graffe) e array (valori in fila fra quadre). Gli ultimi due contengono altri valori, e con loro si descrive qualunque cosa:

{
  "Tema": "scuro",
  "Dimensione": 14,
  "Recenti": ["lettera.txt", "conti.csv"]
}

In C# un oggetto JSON diventa una classe o un record, un array diventa un array o una List<T>. Serializzare vuol dire trasformare un oggetto in testo, deserializzare il contrario, e JsonSerializer fa le due cose con un metodo ciascuno:

internal sealed class Impostazioni {
  public string Tema { get; set; } = "chiaro";
  public int Dimensione { get; set; } = 14;
  public List<string> Recenti { get; set; } = new();
}
var opzioni = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(file, JsonSerializer.Serialize(imp, opzioni));

string letto = File.ReadAllText(file);
Impostazioni imp2 =
  JsonSerializer.Deserialize<Impostazioni>(letto) ?? new Impostazioni();
Un oggetto e il suo JSON
Un oggetto e il suo JSON

Serialize scrive le proprietà pubbliche, con il loro nome; WriteIndented va a capo e rientra, per un file che una persona possa leggere. Deserialize<T> crea l'oggetto e ne imposta le proprietà; restituisce T?, e il ?? del capitolo 8 dà un oggetto nuovo se il testo era null. Una proprietà che manca nel file resta al suo valore iniziale: se un giorno aggiungi una Lingua, i file vecchi si rileggono lo stesso. Un testo rovinato fa lanciare una JsonException, che dice anche dove si è rotto: si cattura, si mette da parte il file e si riparte dai valori predefiniti.

10.4 L'XML

Prima del JSON c'era l'XML, e c'è ancora dappertutto: un documento di Word è una cartella di file XML, le fatture elettroniche italiane sono XML, e lo sono i .csproj dei tuoi progetti.

<?xml version="1.0" encoding="utf-8"?>
<biblioteca>
  <libro anno="1827">
    <titolo>I promessi sposi</titolo>
    <genere>romanzo</genere>
  </libro>
  <libro anno="1972">
    <titolo>Le città invisibili</titolo>
  </libro>
</biblioteca>
Le parti di un documento XML
Le parti di un documento XML

Un elemento si apre con <libro> e si chiude con </libro>, e fra i due c'è il suo contenuto. Un attributo è una coppia nome="valore" dentro il tag di apertura. C'è una sola radice, e gli elementi si chiudono nell'ordine inverso a quello in cui si sono aperti: un solo errore e il documento non si legge. LINQ to XML (System.Xml.Linq) tiene il documento come un albero di oggetti, XDocument, XElement, XAttribute, in cui si scende per nome:

XElement? radice = XDocument.Load(file).Root;
if (radice is null) return;
foreach (XElement libro in radice.Elements("libro")) {
  string titolo = (string?)libro.Element("titolo") ?? "?";
  int? anno = (int?)libro.Attribute("anno");
  string genere = (string?)libro.Element("genere") ?? "nessuno";
  Console.WriteLine($"{anno} {titolo} ({genere})");
}

(string?)libro.Element("genere") dà il testo dell'elemento, o null se non c'è, e (int?) converte un attributo in numero: con ?? il secondo libro, che non ha un genere, diventa «nessuno» invece di fermare il programma. Si costruisce allo stesso modo, new XElement("libro", new XAttribute("anno", 1827), ...), si cambia con SetAttributeValue, Add e Remove, e si salva con Save.

Un inciampo da conoscere: se la radice ha un attributo xmlns="...", gli elementi hanno uno spazio dei nomi, ed Elements("libro") non trova niente, senza nessun errore. Si cercano con un XNamespace.

XML o JSON? Per i dati e le impostazioni di un programma nuovo il JSON: è più corto, più semplice, ha i numeri e i true/false. L'XML quando il formato lo decide qualcun altro (un ente, un cliente, un programma che esiste già) e per i documenti, dove il testo si mescola agli elementi come in una pagina.

Esercizi

  1. Il diario. A ogni avvio aggiungi a diario.txt una riga con la data e l'ora (DateTime.Now), poi scrivi tutte le righe.
  2. Le impostazioni che crescono. Salva le Impostazioni in JSON. Poi aggiungi una proprietà Lingua con valore iniziale "it" e rileggi il file vecchio: che cosa vale Lingua?
  3. Lo spazio dei nomi. Scrivi film.xml con tre film e rileggili con LINQ to XML. Poi aggiungi xmlns="http://esempi.it/film" alla radice: che cosa succede? Correggilo con XNamespace.

Per approfondire: «Solo in codice», volume 1, capitoli I.11–I.13.

11. Organizzare un programma

Fino a qui i programmi erano piccoli: una classe che fa i conti, un Main che parla con l'utente. Un programma vero cresce, e tre mesi dopo la domanda non è più «come si scrive questa riga», ma «dove si mette». Se domani al posto della console c'è una finestra, che cosa resta da buttare?

Il volume risponde con un esempio solo, scritto quattro volte: un magazzino in cui si caricano e si scaricano gli articoli, non si scarica più di quello che c'è, e sotto i dieci pezzi l'articolo è da riordinare. Le quattro versioni danno la stessa uscita, riga per riga: cambia solo dove sta ogni pezzo di codice, ed è proprio quello che conta.

11.1 Un mestiere per classe

La prima versione è tutta in Main, e funziona. Ma quel metodo legge le righe, controlla quello che ha scritto l'utente, decide con le regole del magazzino, ricorda le scorte, mostra il risultato: cinque mestieri mescolati. La regola «non si scarica più di quello che c'è» sta fra un ReadLine e un WriteLine, e per usarla in una finestra bisogna riscriverla; per provarla bisogna avviare il programma e scrivere i comandi a mano.

Organizzare un programma vuol dire dare a ogni classe un mestiere solo: una classe dovrebbe avere un motivo solo per cambiare. Si vuole alta coesione, cioè le parti di una classe che servono tutte allo stesso scopo, e basso accoppiamento, cioè classi che dipendono poco l'una dall'altra. Gli strumenti li conosci già: i membri privati (capitolo 5), le interfacce (capitolo 6) e gli eventi (capitolo 7).

Il primo modo di dividere è per strati: la presentazione parla con l'utente; il modello contiene le regole e non legge e non scrive niente sullo schermo; i dati conservano le cose, nei file del capitolo 10.

Gli strati
Gli strati

La regola d'oro: le dipendenze vanno solo verso il basso. Il modello non sa se sopra di lui c'è una console o una finestra, e per questo resta com'è quando la presentazione cambia. Quando deve avvisare che qualcosa è cambiato non chiama la presentazione: lancia un evento, come il magazzino del capitolo 7, e chi sta sopra ascolta. Gli strati si vedono anche nelle cartelle del progetto (Modello, Vista), ognuna con il suo namespace.

11.2 MVC, MVP e MVVM

I tre modelli più famosi dividono la presentazione in due: un pezzo che mostra e un pezzo che decide.

In MVC (modello, vista, controllore) il controllore riceve i comandi dell'utente e li traduce in richieste al modello; la vista ascolta l'evento del modello e si ridisegna. Main crea i pezzi dal basso verso l'alto e li passa nei costruttori: new VistaMagazzino(magazzino). Dare a ogni oggetto, nel costruttore, gli oggetti che gli servono si chiama iniezione delle dipendenze: dal costruttore si legge di che cosa ha bisogno.

MVC: il giro di un comando
MVC: il giro di un comando

In MVP (modello, vista, presentatore) tutte le decisioni passano nel presentatore, e la vista si limita a leggere e scrivere. Il presentatore non conosce la vista vera, ma un'interfaccia, IVistaMagazzino; e accanto alla vista sulla console si può scrivere una vista finta, che non mostra niente e ricorda quello che le si chiede. Con lei il presentatore si prova in un istante, senza console: è l'idea delle prove automatiche del capitolo 22.

In MVVM (modello, vista, ViewModel) il ViewModel è «il modello della vista»: contiene, in proprietà già pronte, tutto quello che la vista mostra, e quando una cambia lo annuncia con l'evento PropertyChanged dell'interfaccia INotifyPropertyChanged. Non conosce la vista: annuncia, e chi guarda mostra.

internal sealed class MagazzinoViewModel : INotifyPropertyChanged {
  private string _messaggio = "";

  public event PropertyChangedEventHandler? PropertyChanged;

  public string Messaggio {
    get => _messaggio;
    private set {
      if (_messaggio == value) return;
      _messaggio = value;
      Avvisa(nameof(Messaggio));
    }
  }

  // avvisa chi ascolta che una proprietà è cambiata
  private void Avvisa(string proprieta) {
    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(proprieta));
  }
}

nameof(Messaggio) dà il testo "Messaggio", controllato dal compilatore: se la proprietà cambia nome, cambia con lei. Sulla console la vista deve ascoltare PropertyChanged e ridisegnare a mano. In una finestra Avalonia questo lavoro sparisce: si dice che il testo di un controllo è legato (binding) a Messaggio, e il controllo ascolta l'evento da solo. Riconosci la forma? È un evento del capitolo 7, con un mittente e dei dati.

MVVM
MVVM
MVCMVPMVVM
chi decide come mostrarela vistail presentatoreil ViewModel
come si aggiorna la vistaascolta il modellogliela aggiorna il presentatoreascolta il ViewModel
che cosa si prova senza schermoil modellomodello e presentatoremodello e ViewModel

Con Avalonia, che ha i binding, MVVM è la scelta naturale, e il capitolo 21 la riprende. Ma l'idea comune ai tre conta più delle differenze: il modello non sa niente della presentazione.

11.3 Gli altri modelli di progetto

Un modello di progetto (design pattern) è una soluzione con un nome per un problema che torna sempre. Il Singleton garantisce un oggetto solo in tutto il programma, come un registro dei messaggi. La Strategia rende sostituibile un pezzo di comportamento: ogni sconto è una classe di un'interfaccia ISconto, e il carrello riceve quella da usare. La Fabbrica tiene in un posto solo il switch che sceglie quale classe creare. L'Archivio nasconde dietro un'interfaccia dove stanno i dati: oggi una lista, domani un file JSON. L'Osservatore, in C#, sono gli eventi. Il Comando fa di ogni azione un oggetto con Esegui e Annulla, ed è così che gli editor fanno Annulla e Ripeti.

I modelli sono strumenti, non regole. Un programma di cinquanta righe con modello, vista, presentatore, fabbrica e archivio non è organizzato: è complicato. Comincia semplice, e dividi quando il programma diventa difficile da leggere, da cambiare o da provare. Il primo passo è sempre lo stesso: separare le regole del problema dalla console.

Esercizi

  1. Una regola nuova. Un articolo non può superare i mille pezzi. In quante classi devi scrivere nella versione tutta in Main? E in quella con MVC?
  2. Un'altra vista. Nella versione MVC, scrivi una seconda vista che a ogni cambiamento scrive solo il numero totale di pezzi, e collegala allo stesso modello. Che cosa devi cambiare nel modello?
  3. Il ViewModel con il totale. Aggiungi al ViewModel una proprietà Totale, con il suo avviso.

Per approfondire: «Solo in codice», volume 1, capitolo I.14.

12. Più lavori insieme: thread, Task, async e await

Tutti i programmi scritti finora fanno una cosa alla volta. Se un'istruzione aspetta (un file lento, la rete), tutto il programma aspetta con lei. Ma un programma con una finestra non può bloccarsi mentre scarica un file: l'utente deve poter spostare la finestra, premere Annulla, vedere la barra che avanza. C# offre tre strumenti, nati in epoche diverse: i thread, i più vecchi e i più semplici da capire; i Task, il lavoro mandato a un gruppo di thread già pronti; async e await, il modo moderno di aspettare senza bloccare. Chi ha capito i thread capisce che cosa fanno davvero gli altri due.

È il capitolo più importante della parte I, insieme al capitolo 7: tutta la parte II si regge su questi due fili. Le uscite dei programmi con i thread cambiano un poco da un'esecuzione all'altra: è normale, e capirai presto perché.

12.1 Il primo thread

Quando lanci un programma, il sistema operativo crea un processo, con la sua memoria. Dentro, il codice è eseguito da un thread: una sequenza di istruzioni una dopo l'altra, con la sua pila delle chiamate, dove stanno le variabili locali. Ogni programma ha almeno un thread, quello che esegue Main, e può crearne altri. La cosa da fissare in mente: ogni thread ha la sua pila, ma gli oggetti sono di tutti. Qualunque thread abbia un riferimento a un oggetto lo può leggere e cambiare. Da qui vengono tutte le comodità dei thread e tutti i loro problemi. Chi decide quale thread lavora in un dato momento è il sistema operativo, e l'ordine cambia da un'esecuzione all'altra.

Un thread si crea con la classe Thread, dandole un metodo void senza parametri (la firma del delegato ThreadStart, capitolo 7). I dati che gli servono stanno nei campi del suo oggetto, come nel Moltiplicatore del §7.2:

// un lavoro con un nome: tre passi da 100 ms
internal sealed class Operaio {
  private readonly string _nome;

  public Operaio(string nome) {
    _nome = nome;
  }

  public void Lavora() {
    for (int passo = 1; passo <= 3; passo++) {
      Thread.Sleep(100);
      int filo = Environment.CurrentManagedThreadId;
      Console.WriteLine($"   {_nome}{passo}  (thread {filo})");
    }
  }
}

Thread.Sleep(100) ferma il thread che lo chiama per 100 millisecondi: qui finge un lavoro. Due operai sullo stesso thread lavorano uno dopo l'altro, sei passi in tutto. Ognuno nel suo thread:

var a = new Thread(new Operaio("A").Lavora);
var b = new Thread(new Operaio("B").Lavora);
a.Start();
b.Start();
a.Join();
b.Join();
Un thread contro due thread
Un thread contro due thread

Start() avvia il thread e ritorna subito: il nuovo thread comincia a lavorare, e intanto Main va avanti. Join() ferma il thread che lo chiama finché l'altro ha finito. Il tempo è dimezzato, perché i due lavori sono andati avanti insieme, e le righe di A e di B si mescolano in un ordine che cambia ogni volta. Lo schema da ricordare: prima si avviano tutti, poi si aspettano tutti. Un Join subito dopo ogni Start riporterebbe tutto in fila.

Un thread non restituisce niente: il risultato si lascia in un campo dell'oggetto, e si legge dopo il Join. Un thread con IsBackground = true è di sfondo: non tiene vivo il programma, che si chiude quando finisce Main. I thread al servizio di una finestra, come uno che legge di continuo da una macchina, conviene crearli così.

12.2 Dati condivisi: il conto sbagliato e il lock

Due thread che scrivono lo stesso dato si danno fastidio. Proviamo: due thread aumentano lo stesso numero un milione di volte ciascuno.

internal sealed class Contatore {
  private readonly object _lucchetto = new();
  private int _valore;

  public int Valore => _valore;

  // sbagliato: _valore++ è leggi, somma, scrivi
  public void SenzaLock() {
    for (int i = 0; i < 1_000_000; i++) _valore++;
  }

  // giusto: dentro il lock entra un thread alla volta
  public void ConLock() {
    for (int i = 0; i < 1_000_000; i++) {
      lock (_lucchetto) {
        _valore++;
      }
    }
  }
}
   senza lock:  1.044.811  (dovrebbe essere 2.000.000)
   con lock:    2.000.000

Senza protezione si perde quasi metà degli aumenti, e il numero cambia ogni volta, senza errori né eccezioni. _valore++ sembra un'istruzione sola, ma per il processore sono tre passi: leggere, sommare, scrivere. Se i due thread leggono lo stesso 41 prima che uno dei due abbia scritto, tutti e due scrivono 42: un aumento è perso.

Due thread, un aumento perso
Due thread, un aumento perso

Si chiama condizione di gara (race condition), ed è il difetto più insidioso dei thread: i conti sbagliano solo ogni tanto, e quasi mai mentre si prova. La regola: se più thread toccano gli stessi dati, e almeno uno li cambia, servono protezioni. Con lock nel blocco entra un thread alla volta; la chiave è un oggetto privato creato apposta, tutti gli accessi ai dati passano da lì, anche le letture, e dentro si fa il meno possibile. Per un'operazione semplice su un numero, Interlocked.Increment(ref _valore) la fa in un passo che nessuno può interrompere. Ma il rimedio migliore è non condividere: ogni thread lavora sui suoi dati e consegna il risultato alla fine.

Il lock ha un rischio suo. Se un thread prende il lucchetto A e vuole il B, mentre un altro ha preso il B e vuole l'A, ognuno aspetta l'altro per sempre: è un deadlock, e il programma si ferma senza errori. Si evita prendendo i lucchetti sempre nello stesso ordine, o usandone uno solo.

12.3 Il Task: il lavoro come oggetto

I thread chiedono molto codice di contorno, e creare un thread costa: memoria per la sua pila, tempo per avviarlo. Per questo .NET tiene un pool, alcuni thread già pronti che prendono i lavori da una coda. Un task (Task) rappresenta un lavoro mandato al pool. La differenza da tenere a mente: un thread è un lavoratore, un task è un lavoro. Un Task è come lo scontrino al bancone: lo ricevi subito, il piatto arriva dopo.

Task<long> scontrino = Task.Run(new Pezzo(1, 100_000_000).Somma);
Console.WriteLine("il Task è partito; intanto faccio altro");
long somma = scontrino.Result;
Task.Run e lo scontrino
Task.Run e lo scontrino

Task.Run mette il lavoro nel pool e lo avvia subito. Dato un metodo che restituisce long, dà un Task<long>; dato un metodo void, un Task senza risultato. Result dà il risultato, e se il lavoro non è finito aspetta: fa da Join e da lettura del campo insieme. Più task si aspettano con Task.WaitAll, oppure si raccolgono con Task.WhenAll, che invece non aspetta: dà subito un task nuovo, che finirà quando saranno finiti tutti. Tieni a mente la differenza, perché fra poco diventa fondamentale: Wait... blocca, When... dà un task.

Un task porta con sé anche il suo esito. Un'eccezione lanciata dentro un task non ferma il programma, come succederebbe in un thread: resta conservata nel task, e viene rilanciata a chi chiede il risultato, dentro un'AggregateException. Il rovescio della medaglia è che un task fallito che nessuno guarda non dà nessun segno: ogni task che avvii, qualcuno deve aspettarlo o guardarne l'esito.

12.4 Bloccare o aspettare: await

Molti lavori consistono soprattutto nell'aspettare: la risposta di un server, i dati dal disco, un timer. Che cosa fa intanto il thread? Con Thread.Sleep, con Wait, con Result, resta fermo: occupa la sua pila e nessun altro lo può usare. Con await, invece, il metodo si sospende, il thread torna libero e va a fare altro (in una finestra, ridisegnare e rispondere al mouse), e quando l'attesa finisce il resto del metodo riprende. Durante l'attesa nessun thread è occupato: la conta il sistema operativo, o il disco, o la scheda di rete.

Un metodo che usa await si dichiara async e restituisce un Task, o un Task<T> se ha un risultato. Il suo nome, per convenzione, finisce in Async:

private static async Task<double> LeggiTemperaturaAsync(string stanza) {
  await Task.Delay(200);
  return stanza == "cucina" ? 22.5 : 20.0;
}
double gradi = await LeggiTemperaturaAsync("cucina");

Task.Delay(200) dà un task che finirà fra 200 millisecondi, e await lo aspetta senza bloccare. Il metodo scrive return 22.5, ma è il compilatore che mette il valore nel Task<double>; e await lo tira fuori, come Result, ma senza fermare nessuno. Anche Main può essere async Task. Ecco che cosa succede, passo per passo:

  1. il metodo esegue normalmente fino al primo await;
  2. se il task non è ancora finito, il compilatore divide il metodo in due: registra «il resto del metodo» come cosa da fare quando il task finisce, e torna al chiamante. Il thread è libero;
  3. quando il task finisce, il resto del metodo riprende, con tutte le sue variabili. In un programma da console riprende su un thread qualunque del pool; in uno con le finestre, sul thread dell'interfaccia (§12.6).
await divide il metodo in due
await divide il metodo in due

Per chi viene dai thread la chiave è questa: await è un Join che non blocca. Il codice resta in fila e si legge dall'alto in basso, come quello con il thread. E le eccezioni tornano a funzionare come nel capitolo 9: await rilancia l'eccezione così com'è, e il try/catch attorno a un await si scrive come per una chiamata normale.

12.5 In fila o insieme

Due await di seguito sono due attese in fila: la seconda parte quando la prima è finita. Se i lavori sono indipendenti conviene avviarli tutti e poi aspettarli tutti, come con i thread. Chiamare un metodo async senza await lo avvia, e await Task.WhenAll aspetta tutti i task:

Task caffe = CaffeAsync();
Task pane = PaneAsync();
Task uova = UovaAsync();
await Task.WhenAll(caffe, pane, uova);

È la colazione del volume: caffè, pane tostato e uova sono soprattutto attese, e un cuoco sensato non sta a guardare la macchina del caffè prima di accendere il tostapane.

La colazione sulla linea del tempo
La colazione sulla linea del tempo

In fila ci vuole la somma dei tempi. Con tre thread, il tempo del più lungo, ma con tre thread fermi ad aspettare. Con await, lo stesso tempo e nessun thread fermo. Il volume lo misura con cento attese da 50 millisecondi: con Thread.Sleep nei task ci vuole più di un secondo e mezzo, perché il pool resta senza thread; con await Task.Delay poco più di 50 millisecondi.

Da qui la regola più importante del capitolo: nel codice asincrono non si blocca mai. Niente Thread.Sleep (si usa await Task.Delay), niente .Wait() né .Result (si usa await), e per i file le versioni Async: File.ReadAllTextAsync, WriteAllTextAsync, JsonSerializer.SerializeAsync. Una volta che un metodo è async, lo diventano di solito anche quelli che lo chiamano, fino a Main o al gestore di un evento: si dice che async «contagia» il codice verso l'alto, ed è giusto così.

Un metodo async può anche essere void, ma allora nessuno lo può aspettare, e se lancia un'eccezione nessuno la può catturare: di solito il programma si ferma. La regola è async Task, mai async void, con una sola eccezione: i gestori degli eventi, che devono essere void per la firma del delegato (capitolo 7). Ed è lì che i due fili di questa parte si incontrano.

12.6 Il thread dell'interfaccia

In un programma Avalonia il thread di Main diventa il thread dell'interfaccia: è l'unico che può toccare i controlli, e fra un evento e l'altro ridisegna la finestra. Se un gestore del clic lavora per cinque secondi, per cinque secondi la finestra non risponde. Qui await fa una cosa in più, quella che lo rende comodo: dopo l'await il metodo riprende sul thread dell'interfaccia, e quindi può toccare i controlli.

// il gestore del clic: async void, l'unico caso in cui va bene
private async void CaricaClic(object? sender, RoutedEventArgs e) {
  stato.Text = "carico...";
  // la finestra resta viva mentre si aspetta
  List<string> dati = await CaricaAsync();
  // di nuovo sul thread dell'interfaccia
  elenco.ItemsSource = dati;
  stato.Text = dati.Count + " righe";
}

Come fa? Prima di dividere il metodo, await guarda il contesto di sincronizzazione del thread. Quello dell'interfaccia sa mettere un lavoro nella coda del Dispatcher: «il resto del metodo» va lì, e il thread dell'interfaccia lo esegue appena è libero, come un evento qualsiasi. Il thread di una console non ha un contesto, e per questo lì il resto del metodo riprende sul pool.

Dopo l'await il codice torna sul thread dell'interfaccia
Dopo l'await il codice torna sul thread dell'interfaccia

Ora il difetto più famoso. Nel gestore del clic, invece di await, scriviamo Result:

private void CaricaClic(object? sender, RoutedEventArgs e) {
  // NO: la finestra si blocca per sempre
  List<string> dati = CaricaAsync().Result;
  elenco.ItemsSource = dati;
}

Il thread dell'interfaccia è fermo in Result, ad aspettare CaricaAsync. Ma CaricaAsync contiene un await, e dopo l'await vuole riprendere sul thread dell'interfaccia, che è fermo. Ognuno aspetta l'altro: è un deadlock, come quello dei due lucchetti.

Il blocco mortale
Il blocco mortale

La cura è await fino in cima, cioè fino al gestore async void; e dentro quel gestore un try/catch attorno agli await, perché un'eccezione sfuggita lì chiuderebbe il programma. Il capitolo 23 riprende tutto questo con le finestre vere.

12.7 Annullare e dire a che punto si è

Un lavoro lungo deve potersi fermare. Ma fermare un thread da fuori, nel mezzo di quello che sta facendo, è pericoloso: potrebbe lasciare un file a metà. Per questo .NET usa un modello cooperativo: chi vuole fermare il lavoro lo chiede, e il lavoro controlla e smette dove è sicuro farlo. Il CancellationTokenSource è di chi decide, e ha Cancel() e CancelAfter(tempo); il CancellationToken è il biglietto che si passa ai lavori, che dice se è stato chiesto l'annullamento ma non lo può chiedere.

public static async Task DieciPassiAsync(CancellationToken token) {
  for (int i = 0; i < 10; i++) {
    await Task.Delay(100, token);
  }
}
using var fonte = new CancellationTokenSource();
fonte.CancelAfter(250);
try {
  await DieciPassiAsync(fonte.Token);
} catch (OperationCanceledException) {
  Console.WriteLine("annullato");
}

Il token va passato più giù, a ogni metodo che lo accetta, e per convenzione è l'ultimo parametro. Task.Delay(100, token) si interrompe da solo lanciando un'OperationCanceledException, che await rilancia a chi aspettava. Un ciclo di calcolo controlla ogni tanto con token.ThrowIfCancellationRequested(), un thread vero guarda token.IsCancellationRequested. Nella parte II il CancellationTokenSource sarà del pulsante Annulla.

Per dire a che punto è, un lavoro riceve un IProgress<T>, un'interfaccia con un metodo solo, Report(valore), e lo chiama senza sapere chi c'è dall'altra parte: avanzamento.Report(pezzo * 20);.

IProgress: chi lavora e chi mostra non si conoscono
IProgress: chi lavora e chi mostra non si conoscono

La classe pronta Progress<T> lancia un evento a ogni Report, e se è creata nel thread di una finestra l'evento arriva sul thread della finestra, dove si può muovere la barra: di nuovo il contesto del §12.6, al lavoro per te. Il volume aggiunge altri strumenti che incontrerai: SemaphoreSlim, per limitare quanti lavori sono in corso insieme, PeriodicTimer, per fare qualcosa a intervalli regolari, e Channel<T>, una coda fra chi produce dati e chi li consuma.

12.8 Tre modi per la stessa cosa

ThreadTask, senza awaitasync e await
far partirenew Thread(M).Start()Task.Run(M)Task<T> t = MAsync();
aspettareJoin()Wait(), Resultawait t
più lavoriun Join per ognunoTask.WaitAll(...)await Task.WhenAll(...)
gli erroritry nel threadAggregateExceptiontry attorno all'await
il thread che aspettafermofermolibero

In breve: un thread per chi lavora sempre, dall'inizio alla fine del programma, come la lettura di una macchina; Task.Run per spostare un calcolo fuori dal thread dell'interfaccia; await per aspettare qualunque cosa senza fermare nessuno. E in tutti e tre i casi i controlli si toccano solo dal thread dell'interfaccia, e i dati condivisi si proteggono con lock o, meglio, non si condividono.

Esercizi

  1. In fila o insieme. Nell'esempio dei due operai sposta il Join subito dopo lo Start di ogni thread. Quanto ci mette adesso? Perché?
  2. Il contatore per thread. Nel Contatore togli il lock ma usa un contatore per ogni thread, sommati alla fine dopo i Join. Il conto torna?
  3. La colazione completa. Aggiungi il burro sul pane, che si può fare solo dopo il pane tostato (100 ms). Scrivi la colazione in modo che tutto il possibile vada insieme.
  4. Sleep nel posto sbagliato. In DieciPassiAsync sostituisci await Task.Delay(100, token) con Thread.Sleep(100) e token.ThrowIfCancellationRequested(). L'annullamento funziona ancora? Quando arriva?

Per approfondire: «Solo in codice», volume 1, capitoli I.15–I.18.

Parte II — Avalonia in codice

La prima parte ti ha dato il linguaggio; da qui in poi lo userai per costruire programmi con le finestre. Questa parte comincia dalla forma generale di Avalonia, cioè da che cosa c'è sotto una finestra, e poi la percorre un pezzo alla volta, con un esempio piccolo per ogni idea. Tutto è scritto in C#, senza XAML. Dove il codice diventerebbe lungo e ripetitivo si usano le estensioni fluenti di RAVAL, che il capitolo 14 presenta e il capitolo 32 elenca per intero.

13. Avalonia e il primo progetto senza XAML

Cosa imparerai. Che cosa cambia quando un programma ha una finestra invece della console. Come è fatto Avalonia: l'applicazione, le finestre, i controlli, i pannelli, l'albero, le proprietà, gli eventi, il tema e il motore che disegna. Come si prepara un progetto con i pacchetti di Avalonia e la DLL di RAVAL. Alla fine scriverai il primo programma con la finestra: un contatore di clic.

13.1 Dalla console alla finestra

Un programma da console è una storia raccontata dall'alto in basso: chiede un numero, aspetta che l'utente lo scriva, calcola, scrive il risultato, finisce. L'ordine lo decide il programma. Un'interfaccia grafica (GUI) rovescia le parti: il programma apre una finestra e poi aspetta. Che cosa succede dopo lo decide l'utente: può scrivere nel primo campo o nel secondo, premere Salva o Annulla, ridimensionare la finestra.

Un programma da console va dall'alto in basso; una finestra aspetta
Un programma da console va dall'alto in basso; una finestra aspetta

Il cuore di ogni programma con le finestre è il ciclo degli eventi (event loop): un ciclo, nascosto nella libreria, che prende dal sistema operativo il prossimo avvenimento (un clic, un tasto, la finestra da ridisegnare) e lo consegna al controllo giusto. Il controllo lancia un evento, come quelli del capitolo 7, e il tuo metodo iscritto a quell'evento risponde. Poi il ciclo torna ad aspettare. Ne vengono tre conseguenze:

  • Il tuo codice è fatto di risposte. Invece di un Main che fa tutto, scrivi tanti piccoli metodi, ognuno legato a un evento: il clic su Salva, una lettera scritta nel campo di ricerca, la chiusura.
  • Una risposta lenta ferma tutto. Il ciclo gira su un thread solo, il thread dell'interfaccia: se il tuo metodo impiega dieci secondi, per dieci secondi la finestra non si ridisegna e non risponde. Per questo serve il capitolo 12.
  • Lo stato vive negli oggetti. Fra un evento e l'altro il programma non è fermo dentro un metodo: quello che deve ricordare (il documento aperto, il numero di clic) sta nei campi degli oggetti.

13.2 Come è fatto Avalonia

Prima di scrivere una riga conviene avere in testa la mappa. Un programma Avalonia è fatto di pochi pezzi, sempre gli stessi, che si incastrano uno nell'altro.

L'applicazione è un oggetto solo, di una classe che deriva da Application: sa quale tema usare, tiene i colori comuni a tutte le finestre, decide quale finestra aprire per prima. Una finestra deriva da Window: ha un titolo, delle misure, una posizione, e un contenuto solo, nella proprietà Content.

I controlli stanno dentro la finestra: TextBlock per un testo, Button per un pulsante, TextBox per scrivere, CheckBox, ListBox e così via. Ogni controllo è un oggetto C# come quelli del capitolo 5: lo crei con new, ne imposti le proprietà, ti iscrivi ai suoi eventi. Siccome una finestra ha un contenuto solo, per metterci dieci controlli serve un contenitore, un pannello, che ha molti figli e decide dove va ognuno: lo StackPanel li mette in colonna, la Grid in righe e colonne. I pannelli si mettono dentro altri pannelli.

Il risultato è un albero: la finestra contiene un pannello, che contiene altri pannelli e controlli, fino alle foglie. Avalonia ne tiene due versioni: il logical tree, quello che costruisci tu, e il visual tree, quello che si disegna davvero, con i pezzi che il tema aggiunge dentro ogni controllo (capitolo 15).

Le proprietà come Width e Text hanno dietro un registro che avvisa quando il valore cambia: per questo non scriverai mai «ridisegna», cambi una proprietà e il resto lo fa Avalonia. Gli eventi sono il modo in cui i controlli avvisano: Click, TextChanged, Closing. Molti attraversano l'albero, e anche i contenitori li possono ascoltare.

Il tema dà l'aspetto ai controlli, che di loro non ne hanno: è un grande insieme di regole su colori, bordi, angoli. Userai Fluent, che ricorda Windows 11 e ha una versione chiara e una scura. Infine il motore che disegna: Avalonia chiede al sistema operativo soltanto una finestra vuota, e dentro disegna tutto da sé con Skia, la libreria grafica di Google usata da Chrome e da Android.

Gli strati di Avalonia, dal tuo codice al monitor
Gli strati di Avalonia, dal tuo codice al monitor

Il vantaggio di disegnare da sé è che un pulsante di Avalonia è lo stesso pulsante ovunque: quello che provi su Windows funziona su macOS e su Linux come lo hai visto. Riassumendo:

PezzoClasse di AvaloniaChe cosa fa
applicazioneApplicationtema, risorse comuni, finestra principale
finestraWindowtitolo, misure, un solo Content
vistaUserControlun pezzo di finestra in una classe sua
controlloButton, TextBox...mostra qualcosa, risponde all'utente
pannelloStackPanel, Grid...dispone i figli
temaFluentThemedà l'aspetto a tutti i controlli

13.3 Perché Avalonia, e perché senza XAML

Windows Forms e WPF sono maturi, ma legati a Windows; .NET MAUI non arriva su Linux. Avalonia è un erede di WPF pensato per tutti i sistemi: funziona su Windows, macOS e Linux con lo stesso codice, e il suo cuore è software libero con licenza MIT. Ecco i suoi controlli con il tema Fluent; quando il sistema passa al tema scuro cambiano colore da soli:

La galleria dei controlli nel tema chiaro
La galleria dei controlli nel tema chiaro

Quasi tutta la documentazione di Avalonia descrive le finestre in XAML, un linguaggio basato su XML:

<StackPanel Spacing="8">
  <TextBox x:Name="Nome"/>
  <Button Content="Saluta" Click="Saluta"/>
</StackPanel>

Ma XAML non è obbligatorio: ogni elemento è un oggetto C# creato con new, ogni attributo una proprietà, ogni elemento dentro un altro un figlio. Questo tutorial scrive tutto in C#: un linguaggio solo, e un compilatore che segna in rosso un nome sbagliato mentre scrivi. Il costo è tradurre a mente i tutorial in XAML che trovi in rete.

13.4 Il progetto: i pacchetti e RAVAL

Avalonia si scarica da NuGet, il catalogo dei pacchetti. Un programma per il desktop ne chiede tre: Avalonia, il cuore; Avalonia.Desktop, le finestre dei tre sistemi e il disegno con Skia; Avalonia.Themes.Fluent, il tema. Gli altri che servono li porta NuGet da solo. Si parte da un progetto console e si aggiungono dal terminale di Visual Studio Code (capitolo 1):

dotnet new console -n Contatore
cd Contatore
dotnet add package Avalonia --version 12.1.4
dotnet add package Avalonia.Desktop --version 12.1.4
dotnet add package Avalonia.Themes.Fluent --version 12.1.4

L'opzione --version fissa la versione, così il programma che compili oggi resta uguale fra un anno; e i pacchetti di Avalonia vogliono tutti la stessa versione.

RAVAL invece si usa come DLL già compilata: RAVAL.dll sta nella cartella lib del pacchetto degli esempi, che si scarica dalla pagina Download di https://raval.aratoingegneria.it. Conviene tenere lib accanto alla cartella dei tuoi programmi, e scrivere una volta sola dove sta, in un file Directory.Build.props messo nella cartella dei programmi:

Lavoro/
  lib/RAVAL.dll
  Esempi/
    Directory.Build.props
    Contatore/Contatore.csproj
<Project>
  <PropertyGroup>
    <RAVAL>$(MSBuildThisFileDirectory)..\lib\RAVAL.dll</RAVAL>
  </PropertyGroup>
</Project>

dotnet build legge quel file da solo per ogni progetto delle sottocartelle, e $(MSBuildThisFileDirectory) è la cartella del file stesso. Il .csproj del contatore, dopo i tre comandi e due ritocchi a mano, è questo:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Avalonia" Version="12.1.4" />
    <PackageReference Include="Avalonia.Desktop" Version="12.1.4" />
    <PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.4" />
  </ItemGroup>

  <ItemGroup>
    <Reference Include="$(RAVAL)" />
  </ItemGroup>

</Project>

OutputType diventa WinExe: un programma con le finestre, che su Windows si avvia senza la finestra nera del terminale. Reference è il riferimento a una DLL che non viene da NuGet: il compilatore legge le classi di RAVAL da RAVAL.dll e la copia accanto al programma. Il contatore, in realtà, RAVAL non la usa ancora: è scritto con le sole classi di Avalonia, per vedere com'è fatto un programma senza niente in mezzo.

13.5 Il contatore

Il primo programma è un contatore: un testo e un pulsante che conta i clic. È piccolo, ma ha già tutto quello che hanno i programmi grandi: quattro file con i loro ruoli, un evento, uno stato che dura fra un clic e l'altro.

I file del contatore e chi crea chi
I file del contatore e chi crea chi

L'ordine in cui si leggono è l'ordine in cui il programma parte. Program.cs avvia Avalonia e aspetta:

using Avalonia;

namespace Contatore {

  internal static class Program {

    /// <summary>Il punto di partenza: Avalonia e la finestra.</summary>
    [STAThread]
    public static int Main(string[] args) {
      return AppBuilder.Configure<App>()
        .UsePlatformDetect()
        .StartWithClassicDesktopLifetime(args);
    }

  } // class
} // namespace

Le tre chiamate sono fluenti, come le estensioni del capitolo 8: ognuna restituisce lo stesso AppBuilder, e la successiva continua da lì. Configure<App>() dice quale classe fa da applicazione; UsePlatformDetect() carica la parte giusta per Windows, macOS o Linux; StartWithClassicDesktopLifetime(args) crea l'applicazione, apre la finestra e fa girare il ciclo degli eventi. Questa chiamata non ritorna finché il programma è aperto; quando l'utente chiude l'ultima finestra, restituisce il codice di uscita. [STAThread] è un attributo, un'etichetta fra parentesi quadre: dice a Windows che il thread principale è del tipo che vuole per le interfacce, e senza, gli appunti e il trascinamento non funzionano. Va messo sempre.

Negli altri tre file, per stare nella pagina, si vedono solo gli using e la classe: il namespace Contatore che le racchiude è lo stesso. App.cs è l'applicazione:

using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Themes.Fluent;

internal sealed class App : Application {

  /// <summary>Il tema Fluent: senza, i controlli non hanno forma.</summary>
  public override void Initialize() {
    Styles.Add(new FluentTheme());
  }

  /// <summary>Avalonia e' pronta: si apre la finestra.</summary>
  public override void OnFrameworkInitializationCompleted() {
    if (ApplicationLifetime is
        IClassicDesktopStyleApplicationLifetime desktop) {
      desktop.MainWindow = new FinestraContatore();
    }
    base.OnFrameworkInitializationCompleted();
  }

} // class

App la crea Avalonia, perché gliel'hai indicata con Configure<App>(), e chiama i suoi due metodi ridefiniti con override (capitolo 6): Initialize per primo, che aggiunge il tema, e OnFrameworkInitializationCompleted quando è pronta, che crea la finestra principale. Il controllo con is chiede se il programma gira sul desktop classico, quello delle finestre; il capitolo 14 spiega perché.

FinestraContatore.cs è la finestra:

using Avalonia.Controls;

internal sealed class FinestraContatore : Window {

  /// <summary>Titolo, misure, posizione e contenuto.</summary>
  public FinestraContatore() {
    Title = "Contatore";
    Width = 360;
    Height = 200;
    MinWidth = 300;
    MinHeight = 160;
    WindowStartupLocation = WindowStartupLocation.CenterScreen;
    Content = new VistaContatore();
  }

} // class

MinWidth e MinHeight sono le misure sotto le quali l'utente non la può stringere. Le misure sono in pixel indipendenti dal dispositivo: con lo zoom al 150% la finestra larga 360 occupa 540 pixel veri, e ha lo stesso aspetto (capitolo 16). Il contenuto è una vista, un pezzo di finestra in una classe sua: così la stessa vista può stare in una finestra, in una scheda o in un dialogo, e il file della finestra resta corto.

VistaContatore.cs deriva da UserControl, il controllo fatto apposta per contenerne altri:

using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;

internal sealed class VistaContatore : UserControl {

  private readonly TextBlock _messaggio;
  private int _volte;

  /// <summary>Il messaggio e il pulsante, uno sotto l'altro.</summary>
  public VistaContatore() {
    _messaggio = new TextBlock {
      Text = "Non hai ancora premuto.",
      FontSize = 18,
    };
    var pulsante = new Button { Content = "Premi qui" };
    pulsante.Classes.Add("accent");
    pulsante.Click += Premuto;

    var colonna = new StackPanel {
      Spacing = 16,
      Margin = new Thickness(24),
    };
    colonna.Children.Add(_messaggio);
    colonna.Children.Add(pulsante);
    Content = colonna;
  }

  // a ogni clic: una volta in piu', e il messaggio nuovo
  private void Premuto(object? mittente, RoutedEventArgs e) {
    _volte++;
    _messaggio.Text = _volte == 1
      ? "Hai premuto una volta."
      : $"Hai premuto {_volte} volte.";
  }

} // class

Il costruttore segue lo schema che ritroverai in ogni vista: crea i controlli, iscrive i metodi agli eventi, crea il pannello e ci aggiunge i figli nell'ordine in cui devono comparire, mette il pannello nel contenuto. La classe di stile accent dà al pulsante il colore pieno del tema. pulsante.Click += Premuto iscrive il metodo all'evento, come nel capitolo 7, con la firma che l'evento vuole: chi l'ha lanciato e i dettagli.

Il TextBlock e il numero di clic stanno nei campi: il primo perché Premuto lo deve cambiare, il secondo perché deve durare da un clic all'altro. È lo stato della vista. E in Premuto non c'è nessuna istruzione per ridisegnare: basta cambiare Text, e Avalonia rimisura il testo e ridisegna la finestra.

13.6 Avviarlo

Dalla cartella del programma, dotnet run. La prima volta dotnet scarica i pacchetti, e dopo qualche secondo la finestra si apre al centro dello schermo. Ecco il contatore dopo tre clic:

Il contatore dopo tre clic
Il contatore dopo tre clic

Dall'editor si avvia con F5, e il debugger funziona come nella prima parte. Metti un punto d'interruzione dentro Premuto e premi il pulsante: mentre il programma è fermo lì, la finestra non risponde e non si ridisegna, perché il thread dell'interfaccia è fermo. È la seconda conseguenza di §13.1, vista dal vivo.

Se qualcosa non va, i sintomi più comuni sono tre. Il pulsante è solo una scritta, senza sfondo né bordo: manca il tema. Il programma parte, non apre niente e non finisce: manca MainWindow (lo fermi con Ctrl+C). Il programma si ferma subito con No runtime platform services configured: manca UsePlatformDetect().

Esercizi

  1. Un pulsante in più. Aggiungi un pulsante «Azzera» che riporta il contatore a zero. Mettilo accanto al primo, in uno StackPanel con Orientation = Orientation.Horizontal.
  2. Il codice di uscita. In Main, salva in una variabile il valore di StartWithClassicDesktopLifetime, scrivilo con Console.WriteLine e restituiscilo. Avvia con dotnet run, chiudi la finestra e guarda il terminale.
  3. Il thread fermo. Metti in Premuto un Thread.Sleep(3000) prima di cambiare il testo, premi e prova a spostare la finestra. Poi usa await Task.Delay(3000), con il metodo async void: che cosa cambia?

Per approfondire: «Solo in codice», volume 2, capitoli II.1–II.3.

14. Come è fatto un programma Avalonia

Cosa imparerai. Che cosa succede, in ordine, da quando parte Main a quando finisce. Come una finestra si apre e si chiude, e come la chiusura si può fermare. I modi di impostare un controllo, e le estensioni fluenti di RAVAL. Come si apre una finestra di dialogo e se ne aspetta la risposta. Come si dividono i file di un programma che cresce.

14.1 Il ciclo di vita

Che cosa succede mentre Main aspetta lo si vede bene con un programma che, a ogni passo, scrive una riga in un diario: il numero del passo, che cosa sta succedendo e su quale thread. Ecco un'esecuzione vera, dall'avvio alla chiusura:

Dall'avvio alla chiusura, passo per passo
Dall'avvio alla chiusura, passo per passo

Tutto avviene sullo stesso thread, quello dell'interfaccia. La finestra si crea prima di vedersi: il suo costruttore viene chiamato in OnFrameworkInitializationCompleted, ma compare sullo schermo solo con l'evento Opened. E Main riprende per ultimo, quando tutto è chiuso, con il codice di uscita.

Da qui la regola su dove scrivere le cose. In Initialize va quello che vale per tutto il programma e deve esserci prima di qualunque finestra: il tema, gli stili e i colori comuni (capitolo 17). In OnFrameworkInitializationCompleted va la finestra principale, e come finisce il programma. Nessuno dei due deve fare lavori lunghi: tre secondi spesi a leggere un file sono tre secondi in cui l'utente non vede niente. I lavori lunghi si fanno dopo, con await.

Il controllo con is su ApplicationLifetime del capitolo 13 serve perché Avalonia ha più modi di vivere: sul desktop ci sono le finestre, sul telefono e nel browser una vista sola che occupa lo schermo. È un'altra ragione per mettere il contenuto in una vista: un UserControl va bene dappertutto.

14.2 La vita di una finestra

Anche la finestra ha i suoi eventi: Opened quando è sullo schermo, Activated e Deactivated quando diventa la finestra attiva o smette di esserlo, Closing quando è stata chiesta la chiusura, Closed quando non c'è più. Closing è il più utile: il suo argomento ha Cancel, e se lo metti a true la finestra resta aperta. È il modo classico per chiedere «Vuoi salvare le modifiche?»:

// nel costruttore della finestra
Closing += InChiusura;

// la chiusura e' chiesta: si puo' ancora annullare
private void InChiusura(object? mittente, WindowClosingEventArgs e) {
  if (_modificato) e.Cancel = true;
}

Le domande vanno in Closing: quando arriva Closed la finestra non c'è più. Quando finisce il programma lo decide desktop.ShutdownMode, in OnFrameworkInitializationCompleted:

I tre modi di chiudere un programma
I tre modi di chiudere un programma

Dal codice, Close() chiude una finestra come la X, e desktop.Shutdown(codice) chiude tutto il programma con quel codice di uscita. Environment.Exit, invece, chiude il processo all'istante, senza Closing né Closed: niente ha il tempo di salvare, e in un programma con le finestre non si usa.

14.3 Impostare un controllo: graffe ed estensioni

Le proprietà di un controllo si impostano come quelle di qualunque oggetto: con un'assegnazione per riga, o con l'inizializzatore di oggetto del capitolo 5, le assegnazioni fra graffe. Esiste un terzo modo, le estensioni fluenti di RAVAL, che si mettono in fila:

var titolo = new TextBlock {
  Text = "Iscriviti al corso",
  FontSize = 20,
  FontWeight = FontWeight.Bold,
};
var titolo = new TextBlock().Text("Iscriviti al corso").FontSize(20).Bold();

Un'estensione di RAVAL è un metodo di estensione come quelli del capitolo 8: imposta una proprietà e restituisce il controllo stesso, così dopo se ne può chiamare un'altra. Non c'è niente di nascosto: new TextBlock().Margin(8) è esattamente new TextBlock { Margin = new Thickness(8) }. Ogni estensione ha il nome della proprietà che imposta: pulsante.Margin senza parentesi è la proprietà di Avalonia, pulsante.Margin(8) è il metodo di RAVAL. Per usarle basta using RAVAL;.

La differenza si vede quando i controlli diventano tanti. Ecco il contenuto del contatore, che nel capitolo 13 occupava dieci righe fra new, Children.Add e +=, scritto con RAVAL:

Content = RAva.VStack(16,
    _messaggio,
    new Button().Content("Premi qui").Classes("accent")
      .OnClick(Premuto))
  .Margin(24);

RAva.VStack(16, ...) crea uno StackPanel con spaziatura 16 e ci mette i controlli che riceve, nell'ordine in cui li riceve. OnClick(Premuto) iscrive un metodo senza argomenti, perché quasi mai serve sapere chi ha lanciato l'evento. Il vantaggio vero non è scrivere meno: è che la forma del codice è la forma della finestra. Con dieci pannelli uno dentro l'altro, la versione con Children.Add diventa difficile da seguire; quella fluente resta una figura rientrata, che si legge dall'alto in basso come appare. Sullo schermo i due modi sono indistinguibili:

Lo stesso modulo scritto a mano e con RAVAL
Lo stesso modulo scritto a mano e con RAVAL

I modi si mescolano: per una proprietà che RAVAL non copre si usano le graffe, come in new TextBox { PlaceholderText = "Nome" }.Width(200); e quando il contenuto dipende da un calcolo, come un pulsante per ogni elemento di una lista, si crea il pannello e i figli si aggiungono in un foreach con Children.Add.

Una regola vale sempre: un controllo ha un solo genitore. Aggiungerlo a un secondo pannello ferma il programma con «already has a visual parent»; per averne due, se ne creano due.

Una finestra che non è la principale si crea come ogni oggetto. Show() la apre e ritorna subito: le due finestre vivono insieme. ShowDialog<T>(proprietaria) la apre come finestra di dialogo: sta sopra la proprietaria, che intanto non risponde ai clic, e quando si chiude restituisce il valore passato a Close(valore). Ecco una domanda Sì/No:

internal sealed class FinestraConferma : Window {

  /// <summary>Una finestra piccola, grande quanto il contenuto.</summary>
  public FinestraConferma(string domanda) {
    Title = "Conferma";
    SizeToContent = SizeToContent.WidthAndHeight;
    CanResize = false;
    WindowStartupLocation = WindowStartupLocation.CenterOwner;
    Content = RAva.VStack(16,
        new TextBlock().Text(domanda),
        RAva.HStack(8,
            new Button().Content("No").OnClick(No),
            new Button().Content("Sì").Classes("accent").OnClick(Si)))
      .Margin(20);
  }

  private void Si() => Close(true);
  private void No() => Close(false);

} // class

E il metodo che la apre e aspetta la risposta:

public static async Task<bool> Chiedi(Control da, string domanda) {
  if (TopLevel.GetTopLevel(da) is not Window proprietaria) return false;
  var finestra = new FinestraConferma(domanda);
  return await finestra.ShowDialog<bool>(proprietaria);
}

SizeToContent fa prendere alla finestra le misure del contenuto, CenterOwner la apre al centro della proprietaria, e TopLevel.GetTopLevel(da) trova la finestra che contiene il controllo che chiede. await aspetta la risposta senza bloccare il thread dell'interfaccia: intanto le finestre si ridisegnano. Se l'utente chiude con la X, arriva il valore predefinito di bool, false.

La finestra di dialogo sopra il modulo
La finestra di dialogo sopra il modulo

Il metodo del clic che chiama Chiedi diventa async void, perché chi lo chiama è Avalonia, che non aspetta un Task. È l'unico caso in cui async void va bene; il capitolo 15 dice come proteggerlo dagli errori.

14.5 Organizzare i file

Un programma vero ha decine di file, e conviene dividerli per tipo, come fa una piccola agenda di appuntamenti: in Modello i dati e le loro regole, in Servizi i lavori che non si vedono (leggere e scrivere il file), in Viste i pezzi di finestra, in Finestre le finestre, e in cima Program.cs e App.cs. Il compilatore non guarda le cartelle: servono a chi legge. E il namespace segue la cartella: Viste/VistaElenco.cs sta in Agenda.Viste.

Il vero ordine, però, sta nelle dipendenze, cioè in chi usa chi:

Chi usa chi, dall'alto in basso
Chi usa chi, dall'alto in basso

Le frecce vanno in una direzione sola, e il modello sta in fondo e non conosce nessuno: nella cartella Modello non c'è un solo using di Avalonia. Così il modello si prova da solo, con un programma da console, e un cambio nell'aspetto di una vista non lo tocca. Ma allora come fa l'elenco ad aggiornarsi quando si aggiunge un appuntamento? Con un evento, come nel capitolo 7: il modello lancia Cambiato dopo ogni cambio, senza sapere chi lo ascolta, e la vista dell'elenco, che riceve il modello nel costruttore, si iscrive (_elenco.Cambiato += AlCambio). Due viste che non si conoscono restano d'accordo perché guardano lo stesso modello. Il capitolo 21 riprende l'idea per i programmi grandi.

Esercizi

  1. Attiva e disattiva. Aggiungi alla finestra del contatore gli eventi Activated e Deactivated, che scrivono una riga con Console.WriteLine, e passa più volte a un'altra finestra e ritorno.
  2. Non lasciarmi chiudere. Aggiungi al contatore un Closing che annulla la chiusura finché il contatore è sotto 3.
  3. A mano. Riscrivi la FinestraConferma senza RAVAL, con StackPanel, Children.Add e Click +=. Quale delle due si legge meglio?
  4. Tre risposte. Fai restituire alla conferma una stringa, «sì», «no» o «più tardi», con ShowDialog<string?>. Che cosa arriva se l'utente la chiude con la X?

Per approfondire: «Solo in codice», volume 2, capitoli II.4–II.6.

15. Eventi, proprietà e alberi

Cosa imparerai. Che cosa sono gli eventi instradati, che attraversano l'albero dei controlli in due fasi, e perché PointerPressed su un pulsante non arriva. Come si scrive un gestore async senza che un errore chiuda il programma. Perché le proprietà dei controlli hanno un registro dietro, e da dove viene il valore che vedi. Che cosa sono il logical tree e il visual tree.

15.1 Gestori con un nome

Un gestore (handler) è il metodo che risponde a un evento. In questo tutorial ha sempre un nome che dice che cosa fa, e si iscrive con +=, come nel capitolo 7. La firma la decide l'evento: chi l'ha lanciato (il mittente) e i dettagli, un RoutedEventArgs per Click, un KeyEventArgs con il tasto per KeyDown. Con -= si toglie l'iscrizione: serve quando una vista si iscrive all'evento di un oggetto che vive più a lungo di lei, come il modello del capitolo 14, che altrimenti la terrebbe in memoria anche dopo che l'hai tolta dalla finestra.

Le estensioni di RAVAL iscrivono un metodo come +=, ma il metodo riceve già il valore che serve:

Evento di AvaloniaCon RAVALIl metodo riceve
Button.ClickOnClick(Salva)niente
TextBox.TextChangedOnTextChanged(Filtra)il testo
KeyDown con InvioOnEnter(Cerca)niente
IsCheckedChangedOnChecked(Attiva)lo stato, bool
SelectionChangedOnSelectionChanged(Mostra)l'elemento scelto

15.2 Gli eventi instradati

Un evento normale di C#, come il Cambiato del modello, lo ricevono solo i metodi iscritti a quell'oggetto. Molti eventi di Avalonia sono invece eventi instradati (routed events): partono da un controllo e attraversano l'albero, così anche i contenitori li possono ascoltare.

Il percorso di un evento instradato
Il percorso di un evento instradato

Nella fase Tunnel l'evento scende dalla finestra verso il controllo, e un contenitore lo vede prima del controllo. Nella fase Bubble risale dal controllo verso la finestra, come una bolla. Gli eventi del mouse e della tastiera, come PointerPressed e KeyDown, hanno tutte e due le fasi; Click solo la seconda. Il += iscrive alla fase Bubble; per scegliere la fase si usa AddHandler:

bordo.AddHandler(PointerPressedEvent, Premuto,
  RoutingStrategies.Tunnel | RoutingStrategies.Bubble);

L'argomento di un evento instradato ha la proprietà Handled (gestito): un gestore la mette a true per dire «ci ho pensato io», e da lì in poi i gestori iscritti normalmente non vengono più chiamati. Ecco un clic vero su un pulsante dentro un pannello, dentro un bordo, dentro una vista, ognuno con un gestore che scrive una riga:

Il registro dopo un clic vero sul pulsante
Il registro dopo un clic vero sul pulsante

PointerPressed scende dalla vista al pulsante nella fase Tunnel. Poi il pulsante lo gestisce, perché deve ricordare di essere stato premuto, e nella fase Bubble nessun gestore normale lo vede più. Al rilascio del mouse il pulsante lancia Click, che sale fino al bordo. Per questo PointerPressed su un pulsante, iscritto con +=, non arriva mai: per i pulsanti si usa Click. E un contenitore può ascoltare il Click di tutti i pulsanti che contiene con una riga sola, AddHandler(Button.ClickEvent, Cliccato), e sapere da e.Source quale è stato premuto.

15.3 Gestori async

Un gestore che deve aspettare (un file, la rete, una finestra di dialogo) diventa async void. Ma un'eccezione che esce da un async void non la prende nessuno, e chiude il programma. Per questo un gestore async ha sempre il suo try:

private async void Salva() {
  _salva.IsEnabled = false;
  try {
    await File.WriteAllTextAsync(_percorso, _testo.Text);
    _esito.Text = "Salvato.";
  } catch (IOException ex) {
    _esito.Text = "Non riesco a salvare: " + ex.Message;
  } finally {
    _salva.IsEnabled = true;
  }
}

Il try prende gli errori che ci si aspetta (capitolo 9) e li mostra all'utente. Il pulsante si disabilita durante l'attesa e si riabilita nel finally: altrimenti un utente impaziente preme tre volte, e partono tre salvataggi.

15.4 Le proprietà di Avalonia

Le proprietà dei controlli si usano come quelle del capitolo 5 (testo.FontSize = 30), ma dietro non c'è un campo: c'è un registro di valori che sa tenere più valori insieme, da parti diverse, e decidere quale vale; avvisare quando il valore cambia, così il controllo si ridisegna; ereditare, per cui un FontSize messo su un contenitore vale per i testi che contiene; legare una proprietà a un'altra. Ogni proprietà ha anche un campo statico che la rappresenta, come TextBlock.FontSizeProperty: testo.FontSize = 30 è testo.SetValue(TextBlock.FontSizeProperty, 30.0), e ClearValue toglie il valore.

Le proprietà associate appartengono a una classe ma si mettono sugli oggetti di un'altra: Grid.Row è della griglia, ma si mette sul pulsante che ci sta dentro, con Grid.SetRow(pulsante, 2) (con RAVAL, .Row(2)).

Quando più parti propongono un valore, vince quella con la priorità più alta:

Le priorità dei valori di una proprietà
Le priorità dei valori di una proprietà

La conseguenza pratica è importante: un valore scritto nel codice nasconde lo stile per sempre, finché non lo togli con ClearValue. Se scrivi Background = ... su un controllo, il tema scuro non potrà più cambiarlo, perché il tema passa dagli stili. Per questo i colori che devono seguire il tema si danno con le risorse (capitolo 17).

Un controllo nuovo può avere proprietà sue. Ecco il Valore di una piccola Barra, da 0 a 100: il campo statico la registra, con un valore predefinito e un metodo che corregge ogni valore prima che entri nel registro.

public static readonly StyledProperty<double> ValoreProperty =
  AvaloniaProperty.Register<Barra, double>(
    nameof(Valore), defaultValue: 0, coerce: Limita);

public double Valore {
  get => GetValue(ValoreProperty);
  set => SetValue(ValoreProperty, value);
}

private static double Limita(AvaloniaObject barra, double valore) =>
  Math.Clamp(valore, 0, 100);

La proprietà C# legge e scrive nel registro e basta: quando il valore arriva da uno stile, Avalonia non passa dal set. Quello che deve succedere a ogni cambio va nel metodo OnPropertyChanged, che Avalonia chiama per ogni proprietà che cambia. E ogni proprietà si può legare a un'altra con Bind: una riga, e la barra segue il cursore, senza nessun gestore di eventi.

_barra.Bind(Barra.ValoreProperty,
            _cursore.GetObservable(RangeBase.ValueProperty));
La barra legata al cursore
La barra legata al cursore

È la forma più semplice del binding, che il capitolo 21 userà per legare le viste a un modello.

15.5 Il logical tree e il visual tree

Il logical tree è l'albero che hai costruito tu. Il visual tree contiene tutto quello che si disegna, compresi i pezzi che il tema mette dentro ogni controllo con il suo modello (template). Ecco lo stesso pulsante nei due alberi:

Lo stesso pulsante nel logical tree e nel visual tree
Lo stesso pulsante nel logical tree e nel visual tree

Fra il pulsante e il suo testo c'è un ContentPresenter che nel tuo codice non c'è: per il tema Fluent, un pulsante è un ContentPresenter con sfondo, bordo e angoli arrotondati. E il testo è un AccessText, che sa delle scorciatoie: in "_Salva" la lettera dopo il trattino basso si sottolinea quando premi Alt, e Alt+S preme il pulsante. Questi pezzi spiegano perché un controllo cambia aspetto con il tema senza cambiare codice, e perché il visual tree non va usato per trovare i tuoi controlli, che stanno nei campi della vista.

Da un controllo si risale verso l'alto: Parent dà il genitore, e TopLevel.GetTopLevel(controllo) la radice, cioè la finestra, che dà ai controlli i servizi del sistema: gli appunti, le finestre per aprire e salvare un file, la scala dello schermo. Ma nel costruttore di una vista l'albero non c'è ancora, e GetTopLevel(this) restituisce null. Quello che ha bisogno della finestra si fa nell'evento Loaded, che arriva quando la vista è nella finestra ed è pronta.

Esercizi

  1. Il contenitore sente il clic. Metti tre pulsanti in uno StackPanel e iscrivi un solo gestore al pannello con AddHandler(Button.ClickEvent, ...). Scrivi in un TextBlock il contenuto del pulsante premuto, da e.Source.
  2. Un gestore async. Aggiungi al contatore un pulsante Aspetta che si disabilita, aspetta due secondi con await Task.Delay(2000) e si riabilita. Prova a premerlo più volte di fila.
  3. La scorciatoia. Cambia il testo del pulsante del contatore in "_Premi qui" e, con la finestra aperta, premi Alt+P.
  4. Il colore che torna. Dai a un TextBlock un Foreground rosso nel codice, poi chiama ClearValue con TextBlock.ForegroundProperty. Che colore torna, e da dove viene?

Per approfondire: «Solo in codice», volume 2, capitoli II.7–II.9.

16. Layout e pannelli, dimensioni, allineamenti, DPI

Cosa imparerai. Come Avalonia decide dove va ogni controllo e quanto è grande: il layout, in due giri. Che cosa fa ciascun pannello, e quando usarlo. La differenza fra margine e imbottitura, gli allineamenti, le misure fisse, minime e massime. Come si fa una finestra che resta in ordine quando la si ridimensiona, e perché una misura di 120 non è 120 pixel veri.

16.1 Measure e Arrange

Finora hai messo i controlli in colonna e in riga senza mai dire dove andava ognuno in pixel: lo ha deciso Avalonia, attraverso i pannelli. Il calcolo di posizioni e misure si chiama layout, e si fa in due giri sull'albero, dalla finestra verso il basso:

I due giri del layout
I due giri del layout

Nel giro Measure ogni contenitore dice ai figli quanto spazio c'è, e ognuno risponde quanto vorrebbe, la sua misura desiderata: un testo vuole lo spazio delle sue lettere, un pulsante quello del contenuto più l'imbottitura. Nel giro Arrange ogni contenitore, che ora sa quanto vogliono i figli e quanto spazio ha davvero, dà a ciascuno un rettangolo: posizione e misure finali. Il primo giro chiede, il secondo decide. Tutti i pannelli fanno così; cambia solo la regola. Avalonia rifà i giri solo quando serve: se la finestra cambia misura, se arriva un figlio, se cambia un testo. Cambiare un colore non rifà il layout.

16.2 I pannelli

Per vedere che cosa fa ogni pannello, le figure lo riempiono di blocchi colorati e numerati: dei Border con un TextBlock dentro, fabbricati da un metodo Blocco(testo) che li restituisce già pronti.

StackPanel mette i figli uno dopo l'altro, in colonna o in riga (Orientation = Orientation.Horizontal), con Spacing fra l'uno e l'altro. Con RAVAL: RAva.VStack(spazio, ...) e RAva.HStack(spazio, ...).

StackPanel in colonna e in riga
StackPanel in colonna e in riga

Nella direzione della pila ogni figlio ha la misura che vuole; nell'altra, tutto lo spazio. Attenzione: uno StackPanel dà ai figli spazio senza limiti nella sua direzione, e un elenco lungo esce dalla finestra invece di scorrere. Per gli elenchi lunghi c'è ListBox, che ha la barra di scorrimento.

Grid divide lo spazio in righe e colonne, e ogni figlio dice dove va con le proprietà associate Grid.Row e Grid.Column. Le misure si scrivono in tre modi: un numero (100, pixel fissi), Auto (quanto serve al figlio più grande), le stelle (*, 2*: lo spazio che resta, diviso in parti).

var griglia = new Grid {
  ColumnDefinitions = new ColumnDefinitions("100,*,2*"),
  RowDefinitions = new RowDefinitions("Auto,*"),
};
var blocco = Blocco("2*");
Grid.SetColumn(blocco, 2);
griglia.Children.Add(blocco);

Con RAVAL la griglia è RAva.Grid("100,*,2*", "Auto,*"), e ogni figlio dice il suo posto con .Row(1).Column(2).

Una griglia con colonne di 100, * e 2*
Una griglia con colonne di 100, * e 2*

La griglia è il pannello dei moduli: le etichette in una colonna Auto, i campi in una colonna *.

DockPanel appoggia ogni figlio a un lato (DockPanel.Dock: Top, Bottom, Left, Right), e l'ultimo occupa tutto quello che resta. L'ordine conta: ogni figlio prende il suo lato da quello che i precedenti hanno lasciato. È il pannello delle finestre: una barra in alto, una riga di stato in basso, il contenuto in mezzo, aggiunto per ultimo.

DockPanel: quattro lati e il resto
DockPanel: quattro lati e il resto

Gli altri si spiegano in una riga. WrapPanel mette i figli in fila come le parole di un testo, e va a capo quando non c'è posto. UniformGrid è una griglia con tutte le celle uguali, come la tastiera di una calcolatrice. Canvas mette ogni figlio dove dicono Canvas.Left e Canvas.Top, in pixel: serve per i disegni, non per le finestre, perché quando la finestra cambia misura i figli non si spostano. Border non è un pannello, perché ha un figlio solo (Child), ma è il contenitore che userai più spesso: dà al figlio bordo, angoli arrotondati, sfondo e imbottitura, che i pannelli non hanno.

PerIl pannello
controlli in colonna o in rigaStackPanel
un modulo: etichette e campiGrid con colonne Auto,*
barre ai lati e contenuto in mezzoDockPanel
elementi che vanno a capoWrapPanel
celle tutte ugualiUniformGrid
un disegno, coordinate preciseCanvas
bordo, sfondo, angoli attorno a qualcosaBorder

Le finestre vere sono pannelli dentro pannelli, e ognuno fa bene una cosa sola.

16.3 Margine, imbottitura, allineamenti

Intorno al contenuto di un controllo ci sono due spazi:

Margine, bordo, imbottitura, contenuto
Margine, bordo, imbottitura, contenuto

Margin è lo spazio fuori dal bordo, fra il controllo e quello che gli sta intorno; è trasparente, e ce l'hanno tutti. Padding è lo spazio dentro il bordo, fra il bordo e il contenuto; ha lo sfondo del controllo, e ce l'hanno Border e i controlli come Button e TextBox, ma non i pannelli. Tutti e due sono un Thickness: new Thickness(8) su tutti i lati, new Thickness(16, 8) 16 ai lati e 8 sopra e sotto, new Thickness(0, 0, 0, 12) (sinistra, alto, destra, basso) solo sotto. Il margine separa un controllo dagli altri; l'imbottitura dà respiro al contenuto dentro la sua cornice.

Un contenitore dà a ogni figlio uno spazio, come una cella della griglia, e il figlio decide come starci con HorizontalAlignment e VerticalAlignment. Stretch, il valore normale, riempie lo spazio; Left, Center e Right (e Top, Bottom) danno al controllo la sua misura desiderata e lo mettono da quella parte. Per questo un TextBox in una cella è largo quanto la cella, e con Right è stretto e a destra. Alcuni controlli hanno nel tema Left di natura, come Button, CheckBox e ComboBox: in una colonna si vedono stretti.

Le misure si danno con Width e Height fisse, MinWidth e MinHeight sotto cui non si scende, MaxWidth e MaxHeight sopra cui non si sale. Insieme agli allineamenti danno risultati che conviene vedere:

Larghezze fisse, minime e massime
Larghezze fisse, minime e massime

La misura viene stretta fra il minimo e il massimo, e se il minimo è più grande del massimo vince il minimo. Le righe 3 e 4 della figura fanno inciampare tutti: un controllo Stretch fermato da una Width o da un MaxWidth non può riempire lo spazio, e Avalonia lo mette al centro. Un campo di testo con MaxWidth = 360, in una finestra larga, si ritrova staccato dalla sua etichetta, nel mezzo della colonna.

16.4 Una finestra che si ridimensiona

L'utente cambierà la misura della finestra, e la finestra deve restare in ordine. Un modulo ben fatto non ha bisogno di nessuna riga che dica «se la finestra è larga fai così»: lo fanno le colonne. Per esempio una Grid con colonne Auto,2*,* e righe Auto,Auto,*,Auto: le etichette nella colonna Auto, larga quanto la più lunga; i campi nella 2*, che cresce con la finestra senza diventare lunghissima; le note nella riga *, che prende l'altezza che resta, con un MinHeight; i pulsanti nell'ultima riga, a destra.

Il modulo in una finestra larga
Il modulo in una finestra larga

Le regole sono quattro: * per quello che deve crescere, Auto per quello che resta della sua misura; misure fisse solo dove servono davvero; minimi dove sotto una certa misura un controllo non si usa più, anche sulla finestra; allineamenti per mettere a posto quello che non riempie.

16.5 Schermi diversi: i pixel indipendenti

Gli schermi di oggi sono molto diversi: un monitor da ufficio con i pixel grandi, un portatile con lo zoom al 150%, un monitor 4K al 200%. Se un pulsante largo 120 fosse largo 120 pixel veri, sul 4K sarebbe minuscolo. Per questo il sistema operativo ha una scala, che l'utente sceglie, e Avalonia lavora in pixel indipendenti dal dispositivo: unità che valgono un pixel vero al 100% e di più alle scale alte. Tutte le misure che hai scritto finora sono di questo tipo, e Avalonia le moltiplica per la scala quando disegna:

Lo stesso pulsante a tre scale
Lo stesso pulsante a tre scale

Il pulsante ha la stessa misura in centimetri su tutti gli schermi, e su quelli densi è disegnato con più pixel, quindi più nitido. Non devi fare niente; anzi, l'errore è calcolare a mano «al 150% deve essere 180» e scrivere Width = 180: Avalonia moltiplica di nuovo, e il pulsante diventa largo 270 pixel veri. La scala la sa il TopLevel, in RenderScaling, ma quasi nessun programma ne ha bisogno.

Dove la scala conta davvero è nelle immagini. Un PNG ha un numero fisso di pixel: mostrato più grande, i pixel si allargano. Un disegno vettoriale, fatto di linee e curve, si ridisegna invece nitido a qualunque misura. Ecco la stessa icona di 48 pixel indipendenti, fatta con un'immagine da 16 pixel, con una da 96 e con un disegno vettoriale, al 100% e al 200%:

Tre icone al 100% e al 200%, ingrandite
Tre icone al 100% e al 200%, ingrandite

L'immagine da 16 è sfocata sempre, quella da 96 è nitida al 200%, il disegno è nitido ovunque. Per le icone si usano quindi i vettori (la parte III mostra come disegnarli); per le fotografie, un file grande almeno quanto la misura più grande a cui verrà mostrato.

Esercizi

  1. Il modulo. Rifai il modulo d'iscrizione del capitolo 14 con una Grid Auto,*: le etichette Nome ed Email a sinistra, i campi a destra, il pulsante sotto, a destra.
  2. L'ordine del DockPanel. In un DockPanel con quattro blocchi ai lati e uno in mezzo, metti Left e Right prima di Top e Bottom. Che cosa cambia, e perché?
  3. Una calcolatrice. Con una UniformGrid di quattro colonne fai la tastiera di una calcolatrice. Non serve che calcoli.
  4. Tre pulsanti. In uno StackPanel verticale metti tre pulsanti: uno con Left, uno con Stretch, uno con Center e MinWidth = 200. Spiega le tre larghezze.

Per approfondire: «Solo in codice», volume 2, capitoli II.10–II.12.

17. Risorse, stili e temi chiaro e scuro

Cosa imparerai. Che cosa sono le risorse e dove Avalonia le cerca. Come si dà a un colore un valore per il tema chiaro e uno per lo scuro, e come un controllo li segue da solo. Che cosa sono gli stili, i selettori e le pseudoclassi. Come si sceglie il tema del programma, e come lo stato del programma cambia l'aspetto mentre gira, senza scrivere un colore nel codice.

17.1 Le risorse

Fin dal capitolo 13 la galleria cambiava colori da sola fra il tema chiaro e lo scuro: era il tema Fluent a farlo, con i suoi colori. Qui fai lo stesso con i tuoi.

Una risorsa è un valore con un nome, messo in un dizionario perché lo usino in tanti: un colore, un pennello, una misura. Ogni controllo, ogni finestra e l'applicazione hanno il loro dizionario, Resources. Un controllo che vuole il colore Accento non dice qual è: dice il nome. Così il colore si cambia in un posto solo, e con i temi lo stesso nome può valere due colori. Avalonia cerca una risorsa risalendo il logical tree, e si ferma al primo dizionario che ha quel nome:

Dove Avalonia cerca una risorsa
Dove Avalonia cerca una risorsa

Un buon nome dice a che cosa serve il colore, non com'è: Sfondo, Testo, Accento, e non Bianco. Nel tema scuro lo Sfondo è quasi nero, e Bianco sarebbe una bugia.

17.2 I colori a coppie

Ogni dizionario di risorse ha anche i ThemeDictionaries: un dizionario per tema, con le chiavi che cambiano fra chiaro e scuro. Scritti a mano sono due elenchi da tenere allineati; la classe RThemes di RAVAL li scrive a coppie, un colore per riga, e Apply li mette nell'applicazione. Il posto giusto è App.Initialize, dopo il tema Fluent:

new RThemes()
  .Color("Sfondo",  light: "#F3F5F8", dark: "#1B1D21")
  .Color("Scheda",  light: "#FFFFFF", dark: "#26292E")
  .Color("Bordo",   light: "#D0D7DE", dark: "#3B4048")
  .Color("Testo",   light: "#1F2328", dark: "#E6E6E6")
  .Color("Accento", light: "#46708F", dark: "#8DB4D2")
  .Apply(this);

Per usare una risorsa che cambia con il tema, la proprietà va legata alla risorsa, non copiata. Con RAVAL:

_titolo.Resource(TextBlock.ForegroundProperty, "Accento");

Resource lega la proprietà alla risorsa con Bind (capitolo 15): quando il tema cambia, la proprietà segue. Il contrario è leggere la risorsa una volta, con TryFindResource, e assegnarne il valore. La differenza si vede quando il tema cambia a finestra aperta. Ecco una finestra aperta nel tema chiaro e passata allo scuro, con due testi, il primo legato e il secondo letto:

Aperta nel tema chiaro, poi passata allo scuro
Aperta nel tema chiaro, poi passata allo scuro

Il primo testo ha seguito il tema; il secondo è rimasto del blu scuro del tema chiaro, che sullo sfondo scuro quasi non si legge. Ha un valore locale, che non cambia da solo (capitolo 15). È il difetto più comune dei programmi con il tema scuro: funziona all'avvio, e si rompe quando l'utente cambia il tema con la finestra aperta.

17.3 Gli stili e le pseudoclassi

Uno stile è una regola: «a questi controlli dai queste proprietà». Ha un selettore, che dice a quali controlli si applica, e dei setter, ognuno una proprietà con il suo valore. Gli stili stanno nella raccolta Styles dell'applicazione, di una finestra o di un controllo, e valgono per quello che contengono; il tema Fluent, in fondo, è una grande raccolta di stili. Ecco uno stile che dà a tutte le schede lo stesso sfondo e gli stessi angoli:

var scheda = new Style {
  Selector = Selectors.Class(
    Selectors.OfType(null, typeof(Border)), "scheda"),
};
scheda.Setters.Add(new Setter(Border.BackgroundProperty,
  new DynamicResourceExtension("Scheda")));
scheda.Setters.Add(new Setter(Border.CornerRadiusProperty,
  new CornerRadius(8)));
Styles.Add(scheda);

Il selettore sceglie i Border che hanno la classe di stile scheda: un'etichetta che metti tu sul controllo, con bordo.Classes.Add("scheda") o, con RAVAL, .Classes("scheda"). Chi scrive una scheda nuova scrive solo il contenuto. Il colore viene da una risorsa dinamica, DynamicResourceExtension, che segue la risorsa come fa Resource: il nome viene da XAML, ma è una classe C# come le altre. Per i valori che non cambiano con il tema, come gli angoli, basta il valore.

Le classi che cominciano con i due punti sono pseudoclassi: non le metti tu, le mette Avalonia secondo lo stato del controllo. :pointerover quando il mouse è sopra, :pressed quando un pulsante è premuto, :focus, :disabled, :checked. Uno stile con il selettore Border.scheda:pointerover (in C#, un altro Selectors.Class sopra il primo, con ":pointerover") vale solo quando il mouse è sopra la scheda, e le dà un altro sfondo:

La prima scheda con il mouse sopra
La prima scheda con il mouse sopra

Nessun gestore di eventi: entrata e uscita del mouse le gestiscono lo stile e la pseudoclasse.

17.4 Il tema del programma

Il tema si sceglie sull'applicazione, e vale per tutte le finestre:

Application.Current!.RequestedThemeVariant = ThemeVariant.Dark;

I valori sono tre: Light, Dark, e Default, che vuol dire «come il sistema». Un programma che non sceglie è in Default. Il tema in uso lo dice invece ActualThemeVariant, che vale sempre Light o Dark, e quando cambia arriva l'evento ActualThemeVariantChanged.

Il tema Fluent ha anche un colore d'accento, per i pulsanti accent, i cursori, le caselle spuntate: di partenza è blu. Si cambia con le tavolozze del FluentTheme, assegnando una ColorPaletteResources con il nuovo Accent sia a Palettes[ThemeVariant.Light] sia a Palettes[ThemeVariant.Dark]: se ne metti una sola, al primo cambio di tema torna il blu.

17.5 Lo stato diventa una classe

Le classi di stile si possono anche mettere e togliere mentre il programma gira, ed è il modo giusto per far cambiare aspetto a un controllo secondo lo stato del programma. Ecco il pannello di una serra con tre misure: oltre una soglia la scheda passa ad attenzione, oltre un'altra ad allarme.

Attenzione e allarme nella serra
Attenzione e allarme nella serra

Il codice che controlla le soglie non scrive nessun colore: cambia le classi della scheda.

string livello = _misura.Livello(valore);
Bordo.Classes.Set("attenzione", livello == "attenzione");
Bordo.Classes.Set("allarme", livello == "allarme");

Classes.Set mette la classe se la condizione è vera e la toglie se è falsa. Appena la classe c'è, lo stile che la chiede vale, e appena se ne va non vale più. Gli stili hanno selettori come Border.scheda.allarme, cioè i Border con tutte e due le classi, e prendono i colori da risorse con un valore per tema.

Così ognuna delle parti sa una cosa sola: la misura sa le soglie, la scheda quale classe va con il livello, gli stili che aspetto hanno le classi, le risorse i colori di ogni tema. Cambiare il rosso dell'allarme è una riga. L'errore da evitare è il contrario, Bordo.BorderBrush = Brushes.Red nel metodo delle soglie: non segue il tema, ed è un valore locale che vince su ogni stile. Lo stato va in una classe, il colore in uno stile.

Esercizi

  1. Un colore nuovo. Aggiungi la risorsa Avviso, arancio chiaro e arancio scuro, e usala per lo sfondo di una scheda con la classe avviso, con uno stile Border.avviso.
  2. L'interruttore del tema. Aggiungi al contatore un ToggleSwitch che passa l'applicazione dal tema chiaro allo scuro e ritorno.
  3. Premuto. Aggiungi uno stile Border.scheda:pressed e premi una scheda: non cambia niente, perché un Border non riceve :pressed. Rifai la scheda come un Button con la classe scheda: ora funziona.

Per approfondire: «Solo in codice», volume 2, capitoli II.13 e II.20.

18. I controlli di tutti i giorni: testo, pulsanti, scelte, tabelle

Cosa imparerai. I controlli che usi in ogni finestra, con le proprietà che contano: TextBlock e il testo che non ci sta, Label, TextBox e NumericUpDown per scrivere, i pulsanti che rispondono a Invio ed Esc. Poi i controlli per scegliere, e un ListBox che mostra gli oggetti del programma. Infine le tabelle, con il DataGrid.

18.1 Il testo

TextBlock è il controllo del testo da leggere. Le proprietà di tutti i giorni le conosci (Text, FontSize, FontWeight, Foreground); quelle che fanno la differenza dicono che cosa fare quando il testo non ci sta:

Le forme del testo quando non ci sta
Le forme del testo quando non ci sta

Senza niente, il testo resta su una riga ed esce dallo spazio, tagliato. Con TextWrapping = TextWrapping.Wrap (con RAVAL .Wrap()) va a capo fra le parole; con TextTrimming = TextTrimming.CharacterEllipsis finisce con i puntini. In una finestra che si ridimensiona, decidi sempre che cosa deve succedere.

Un testo può avere parti diverse con gli Inlines: Run è un pezzo di testo semplice, Bold e Italic contengono altri pezzi e li cambiano. È il modo per scrivere «costa 4,50 €» senza tre TextBlock in fila, che andrebbero a capo male:

var prezzo = new TextBlock { TextWrapping = TextWrapping.Wrap };
prezzo.Inlines!.Add(new Run("Il miele millefiori costa "));
prezzo.Inlines.Add(new Bold { Inlines = { new Run("4,50 €") } });

SelectableTextBlock è un TextBlock che l'utente può selezionare e copiare: per un codice d'ordine, o un messaggio d'errore da mandare all'assistenza.

18.2 Scrivere e premere

L'esempio è un modulo d'ordine: nome, codice cliente, quantità, note, un totale che si aggiorna, e i pulsanti Annulla e Ordina.

Il modulo d'ordine compilato con la tastiera
Il modulo d'ordine compilato con la tastiera

Label è un'etichetta che sa a quale controllo appartiene: ha un testo con una lettera preceduta da _ e un Target, il controllo a cui dare il fuoco. Con new Label { Content = "_Nome", Target = _nome }, Alt+N porta il cursore nel campo del nome: per chi usa la tastiera è la differenza fra un modulo veloce e uno faticoso.

TextBox è il campo per scrivere. Text è il testo, e TextChanged (con RAVAL OnTextChanged) arriva a ogni cambio. PlaceholderText è il suggerimento grigio quando il campo è vuoto, MaxLength il numero massimo di caratteri, PasswordChar nasconde i caratteri dietro un pallino, e con AcceptsReturn Invio va a capo e il campo diventa un'area di più righe:

private readonly TextBox _note = new() {
  AcceptsReturn = true, TextWrapping = TextWrapping.Wrap,
  Height = 70, PlaceholderText = "Note per la consegna" };

Per un numero c'è NumericUpDown: accetta solo numeri, ha due frecce, e rispetta Minimum, Maximum e Increment. Il suo Value è un decimal?, che è null se l'utente cancella tutto, e si legge con (int)(_quantita.Value ?? 1). L'evento ValueChanged arriva a ogni cambio, e il modulo lo usa per rifare il totale.

I pulsanti hanno due proprietà che rendono un modulo comodo con la tastiera. Un Button con IsDefault = true è il pulsante predefinito: Invio lo preme, da qualunque campo di una riga. Uno con IsCancel = true lo preme Esc:

RAva.HStack(8,
    new Button { IsCancel = true }.Content("Annulla").OnClick(Annulla),
    new Button { IsDefault = true }.Content("Ordina")
      .Classes("accent").OnClick(Ordina))

La figura di prima viene da una prova vera: freccia su nella quantità, «Anna Bianchi» scritto nel nome, e poi Invio, senza nessun clic su Ordina. Il metodo Ordina controlla prima che il nome ci sia; se manca, lo dice e riporta il fuoco nel campo con _nome.Focus(), perché l'utente lo scriva senza cercarlo.

18.3 Scegliere

L'esempio sono le preferenze di un notiziario: a sinistra gli argomenti, a destra le notifiche, la frequenza, la lingua e la data da cui cominciare, e sotto una frase che riassume le scelte.

Le preferenze dopo qualche scelta
Le preferenze dopo qualche scelta

Una CheckBox dice sì o no. Il suo stato è IsChecked, un bool?, che con IsThreeState può valere anche null, «in parte». Per questo if (casella.IsChecked) non compila: si scrive casella.IsChecked == true. Un ToggleSwitch è la stessa cosa con la forma di un interruttore, per un'impostazione che vale subito.

I RadioButton vanno in gruppo: se ne sceglie uno solo, e gli altri si spengono da soli. Il gruppo lo fa GroupName: tutti i pulsanti con lo stesso nome. Vanno bene fino a quattro o cinque scelte; oltre, si usa un ComboBox, l'elenco a discesa, con gli elementi in ItemsSource e la scelta in SelectedIndex o SelectedItem.

Un ListBox mostra un elenco sempre aperto, che scorre quando non ci sta. Gli elementi, in ItemsSource, non sono controlli ma oggetti del programma, qui dei record Argomento con un nome e una descrizione. Per mostrarli l'elenco ha bisogno di un modello degli elementi, ItemTemplate, che dice quali controlli usare per ognuno:

private readonly ListBox _argomenti = new() {
  ItemsSource = Argomento.Tutti,
  SelectionMode = SelectionMode.Multiple | SelectionMode.Toggle,
  ItemTemplate = new FuncDataTemplate<Argomento>(Elemento),
};

// come si mostra un argomento: il nome, e sotto la descrizione
private static Control Elemento(Argomento a, INameScope nomi) =>
  RAva.VStack(0,
    new TextBlock().Text(a.Nome).Bold(),
    new TextBlock().Text(a.Descrizione).FontSize(12).Opacity(0.7));

FuncDataTemplate<Argomento>(Elemento) dice: per ogni argomento chiama il metodo Elemento, che restituisce i controlli che lo mostrano. Senza modello, l'elenco scriverebbe il ToString() dell'oggetto. SelectionMode decide la scelta: Single un elemento, Multiple più d'uno, e con Toggle ogni clic aggiunge o toglie.

Quando due controlli si aggiornano a vicenda c'è un tranello. La casella Tutti gli argomenti sceglie tutto l'elenco; ma una scelta nell'elenco aggiorna la casella, che lancia il suo evento come se l'avesse toccata l'utente, che sceglie di nuovo tutto l'elenco, e così via: un giro senza fine. Lo si evita con un campo _aggiornando, messo a true mentre il codice cambia la casella: il gestore della casella lo controlla, e se è vero non fa niente. Il capitolo 21 mostra come un modello in mezzo risolva il problema in modo più ordinato.

Due ultime cose. Quando le notifiche si spengono, il pannello delle frequenze riceve IsEnabled = false, e i tre pulsanti che contiene si fanno grigi: disabilitare è meglio che nascondere, perché l'utente vede che la scelta esiste. E per le date c'è CalendarDatePicker, con il calendario che si apre al clic e la data in SelectedDate.

18.4 Le tabelle: il DataGrid

Per tanti oggetti con molte proprietà da confrontare, ordinare e modificare c'è il DataGrid. L'esempio è un piccolo magazzino:

Il magazzino: filtro, tabella, totali
Il magazzino: filtro, tabella, totali

Il DataGrid ha un pacchetto suo, con una numerazione sua (con Avalonia 12.1.4 si usa la 12.1.2), e un tema suo, che il tema Fluent non contiene. Senza il tema la tabella c'è ma non si vede, e nessun errore lo dice. Il tema è un file compilato nella DLL del pacchetto, e si carica in App.Initialize, dopo Fluent:

dotnet add package Avalonia.Controls.DataGrid --version 12.1.2
Styles.Add(new StyleInclude(new Uri("avares://Magazzino/")) {
  Source = new Uri(
    "avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"),
});

Come il ListBox, il DataGrid mostra oggetti presi da ItemsSource: ogni riga è un oggetto, ogni colonna una sua proprietà. Le colonne si scrivono a mano, ognuna con un'intestazione e un legame (Binding) con il nome della proprietà, scritto con nameof perché il compilatore controlli che esista:

_griglia.Columns.Add(new DataGridTextColumn {
  Header = "Prezzo",
  Binding = new Binding(nameof(Prodotto.Prezzo)) {
    StringFormat = "{0:N2}" },
});

DataGridTextColumn mostra un testo; DataGridCheckBoxColumn una casella, per una proprietà bool. Con CanUserSortColumns = true un clic sull'intestazione ordina la tabella; una cella si modifica con un doppio clic o con F2, e la tabella scrive il valore nella proprietà dell'oggetto e lancia CellEditEnded, che il magazzino usa per rifare i totali.

Una tabella non è sempre la scelta giusta: per un elenco da cui scegliere basta un ListBox con il suo modello, e per pochi campi fissi una Grid.

Esercizi

  1. Il limite. Mostra sotto il campo delle note quanti caratteri restano su un massimo di 200, e aggiorna il numero a ogni lettera con TextChanged.
  2. Il fuoco. Fai in modo che, all'apertura del modulo, il cursore sia già nel campo del nome (suggerimento: Focus() nell'evento Loaded).
  3. La lingua con i pulsanti. Rifai la scelta della lingua con tre RadioButton invece del ComboBox. Quale delle due preferisci?
  4. La conferma. Nel magazzino, prima di togliere un prodotto, chiedi conferma con la finestra di dialogo del capitolo 14.

Per approfondire: «Solo in codice», volume 2, capitoli II.14–II.16.

19. Menu, schede, dialoghi e file

Un programma da scrivania vero ha qualcosa in più di una fila di controlli: una cornice, con il menu in alto, una barra di pulsanti e una barra di stato in fondo; dei modi per tenere molte cose in una finestra sola; e delle finestre che compaiono sopra la principale per chiedere qualcosa, come «salvare le modifiche?» o «quale file vuoi aprire?». Questo capitolo le mette insieme con tre piccoli programmi: un taccuino, la sua guida e uno scrittoio.

19.1 La cornice: menu, barra degli strumenti, barra di stato

Il taccuino è un foglio di testo con il menu File, Modifica e Vista, una fila di tasti sotto il menu, e in fondo la posizione del cursore, i caratteri e se il testo è salvato.

Il taccuino: menu, barra degli strumenti, barra di stato
Il taccuino: menu, barra degli strumenti, barra di stato

Menu in alto, barra sotto, stato in fondo e il testo in mezzo: è il lavoro per cui è nato il DockPanel del capitolo 16. I figli con Dock.Top si impilano dall'alto nell'ordine in cui li si aggiunge, e l'ultimo prende lo spazio che resta:

Content = new DockPanel().Children(
  MenuPrincipale().Dock(Dock.Top),
  _barra.Dock(Dock.Top),
  _barraStato.Dock(Dock.Bottom),
  _testo);

Il menu. Un menu è un Menu che contiene delle MenuItem, e ogni MenuItem può contenerne altre: quelle della barra aprono una tendina, quelle della tendina fanno qualcosa o aprono un sottomenu. Si aggiungono con Items.Add:

var file = new MenuItem { Header = "_File" };
file.Items.Add(new MenuItem { Header = "_Nuovo" }
  .HotKey("Ctrl+N").OnClick(Nuovo));
file.Items.Add(_salva.HotKey("Ctrl+S").OnClick(Salva));
file.Items.Add(new Separator());
file.Items.Add(new MenuItem { Header = "_Esci" }.OnClick(Esci));
var menu = new Menu();
menu.Items.Add(file);

Header è il testo della voce, e il trattino basso segna la lettera da premere con Alt: "_File" si apre con Alt+F. Separator divide le voci in gruppi. OnClick di RAVAL collega la voce a un metodo senza argomenti.

Alt+F: il menu File, con le lettere sottolineate
Alt+F: il menu File, con le lettere sottolineate

I tasti rapidi. Qui c'è il primo inciampo. InputGesture scrive il tasto accanto alla voce, e basta; HotKey lo fa funzionare, anche a menu chiuso. HotKey("Ctrl+S") di RAVAL fa le due cose insieme. I tasti di Taglia, Copia e Incolla li gestisce già il TextBox: nel menu vanno solo scritti.

Spunte e scelte. Una voce che si accende e si spegne ha ToggleType = MenuItemToggleType.CheckBox, e il suo stato è IsChecked. Il clic cambia la spunta da sé, prima di chiamare il metodo: il metodo legge lo stato, non lo inverte. Le voci a scelta unica hanno MenuItemToggleType.Radio e lo stesso GroupName.

La barra degli strumenti è uno StackPanel orizzontale di pulsanti con Focusable = false, perché un clic sulla barra non tolga il fuoco al testo. La barra di stato è un Border con tre TextBlock, scritti da un metodo solo, Aggiorna: un'idea che tornerà nel capitolo 21, lo stato in un posto solo e un metodo che lo porta su tutti i controlli. Il menu contestuale, quello del clic destro, è un ContextMenu nella proprietà omonima del controllo.

La selezione che sparisce. Si sceglie una parola, si apre Modifica con il mouse, si sceglie Taglia, e non succede niente. Aprendo il menu il fuoco passa al menu, e un TextBox che perde il fuoco cancella la sua selezione: quando arriva il clic su Taglia non c'è più niente da tagliare. La cura è una piccola classe che ricorda la selezione finché il testo ha il fuoco, e la rimette prima di Cut.

19.2 Schede, sezioni, pagine

La guida del taccuino mostra tre modi di mettere molte cose in una finestra sola.

La guida: tre schede
La guida: tre schede

Un TabControl contiene delle TabItem, ognuna con un Header (il testo della linguetta) e un Content; la scheda scelta è SelectedIndex. Le schede non scelte non perdono quello che contengono: il loro contenuto esce dall'albero visivo (Unloaded) e ci rientra quando la scheda torna (Loaded), e un lavoro fatto in Loaded si ripete quindi a ogni ritorno. Un Expander ha una testata sempre visibile e un contenuto che si apre e si chiude con un clic (IsExpanded). Le pagine, infine, si sostituiscono una all'altra come in un browser: la pagina è il Content di un ContentControl, e cambiare pagina vuol dire costruire la nuova e metterla al posto della vecchia.

PerIl controllo
parti indipendenti, scelte dall'utenteTabControl
molte sezioni brevi, una o due per voltaExpander
un percorso, con la strada per tornarepagine in un ContentControl

19.3 Dialoghi che restituiscono una risposta

Lo scrittoio apre e salva documenti, e prima di lasciare un testo modificato chiede se salvarlo. Nel capitolo 14 si è vista la prima finestra di dialogo: ShowDialog<bool>(proprietaria) la apre sopra la finestra che la possiede e restituisce un Task<bool>, che finisce quando la finestra chiama Close(true) o Close(false).

Davanti a un documento modificato, però, le risposte sono tre, e la risposta diventa un enum:

internal enum Risposta {
  Annulla,
  Salva,
  NonSalvare,
}

Ogni pulsante della domanda chiama Close con la sua risposta, per esempio Close(Risposta.Salva).

La domanda prima di lasciare il documento
La domanda prima di lasciare il documento

Annulla sta per primo, ed è voluto. Chi chiude la domanda con la X del titolo non sceglie niente, e ShowDialog restituisce il valore predefinito del tipo: per un enum, il primo valore. Con Annulla per primo la X vale Annulla, la risposta prudente; con Salva per primo, chiudere con la X salverebbe. Per la stessa ragione una finestra che chiede un numero restituisce un int?: la X dà null, che non si confonde con lo zero.

La domanda serve in tre posti (Nuovo, Apri e la chiusura), e sta in un metodo solo:

public async Task<bool> PuoLasciare() {
  if (!Modificato) return true;
  if (TopLevel.GetTopLevel(this) is not Window finestra) {
    return true;
  }
  Risposta r = await new FinestraDomanda(Nome)
    .ShowDialog<Risposta>(finestra);
  return r switch {
    Risposta.Salva => await SalvaOra(),
    Risposta.NonSalvare => true,
    _ => false,
  };
}

Salvare può non riuscire (l'utente annulla la scelta del file, il disco è pieno), e allora non si va avanti. I comandi del menu cominciano tutti con if (!await PuoLasciare()) return;, e sono async void perché sono gestori di evento (capitolo 12).

Chiedere prima di chiudere. La X, Alt+F4 e Esci passano tutti per l'evento Closing, dove si ferma la chiusura con e.Cancel = true. Ma la finestra guarda e.Cancel appena il gestore ritorna, e un gestore async ritorna al primo await, prima della risposta. Si fa quindi in due tempi: si annulla subito, si chiede con calma, e si richiude.

private void Chiusura(object? mittente,
                      WindowClosingEventArgs e) {
  if (_chiudi || !_vista.Modificato) return;
  // prima di ogni await: dopo sarebbe tardi
  e.Cancel = true;
  Chiedi();
}

private async void Chiedi() {
  if (!await _vista.PuoLasciare()) return;
  _chiudi = true;
  Close();
}

19.4 Scegliere un file, e le notifiche

Le finestre per scegliere un file sono quelle del sistema, e Avalonia le apre con lo StorageProvider della finestra:

IReadOnlyList<IStorageFile> scelti =
  await finestra.StorageProvider.OpenFilePickerAsync(
    new FilePickerOpenOptions {
      Title = "Apri un documento",
      FileTypeFilter = [FilePickerFileTypes.TextPlain,
                        FilePickerFileTypes.All],
    });
if (scelti.Count == 0) return;

OpenFilePickerAsync restituisce un elenco vuoto se l'utente annulla; SaveFilePickerAsync restituisce il file scelto o null, e ha SuggestedFileName per il nome proposto. Quello che arriva non è un percorso ma un IStorageFile, perché su certi sistemi (macOS con la sandbox, Android) un percorso può non esserci. Il modo che funziona dappertutto sono i suoi flussi: OpenReadAsync e OpenWriteAsync danno uno Stream, da leggere e scrivere con StreamReader e StreamWriter (capitolo 10).

Un messaggio come Salvato non merita una finestra da chiudere: basta una notifica, che compare in un angolo e se ne va da sola. Le mostra un WindowNotificationManager, creato in OnLoaded perché vuole la finestra (capitolo 14). Ogni notifica ha un titolo, un testo, un tipo (Success, Error, Information) e una durata.

Ctrl+S: la notifica «Salvato»
Ctrl+S: la notifica «Salvato»

Il testo di una notifica d'errore non è il messaggio dell'eccezione, scritto per il programmatore e spesso in inglese: all'utente si dice una frase sua. E una notifica può non essere vista: va bene per un esito, non per una domanda.

Esercizi

  1. Annulla e Ripeti. Aggiungi al taccuino Annulla e Ripeti, con Ctrl+Z e Ctrl+Y solo scritti: il TextBox ha già i metodi Undo e Redo.
  2. Il tema dal menu. Aggiungi in Vista le voci Chiaro e Scuro, a scelta unica, che cambiano RequestedThemeVariant dell'applicazione (capitolo 17).
  3. Una domanda per tutti. Fai una finestra che riceve la domanda e i testi dei pulsanti, e restituisce l'indice del pulsante premuto, con null per la X (ShowDialog<int?>).
  4. Il titolo. Scrivi nel titolo della finestra il nome del documento, con un pallino se è modificato.

Per approfondire: «Solo in codice», volume 2, capitoli II.17–II.19.

20. Responsive, trascinare, tastiera e mouse, accessibilità, lingue

Cinque argomenti che rendono un programma più gradevole e più aperto: una finestra che si adatta alla larghezza, il trascinamento e gli appunti, la tastiera e il puntatore, il programma usato senza vederlo, e il programma in più lingue. Di ognuno, l'essenziale per cominciare.

20.1 Una vista che si adatta alla larghezza

Le misure in stelle del capitolo 16 bastano finché una vista deve solo allargarsi. Ma sotto una certa larghezza due colonne affiancate non ci stanno più, e la vista deve cambiare forma. L'esempio è una rubrica: larga, mostra l'elenco e la scheda del contatto affiancati; stretta, una cosa alla volta, con ‹ Elenco per tornare.

La rubrica stretta: dopo il clic, la scheda
La rubrica stretta: dopo il clic, la scheda

Ogni controllo, quando cambia misura, lancia SizeChanged. La rubrica ascolta quello della vista, perché le conta lo spazio che ha lei, non la finestra:

private void Dimensioni(object? mittente,
                        SizeChangedEventArgs e) {
  if (!e.WidthChanged) return;
  bool larga = e.NewSize.Width >= Soglia;
  // stessa disposizione: niente da rifare
  if (larga == _larga) return;
  _larga = larga;
  Disponi();
}

Mentre l'utente trascina il bordo, SizeChanged arriva a ogni pixel: il metodo rifà la disposizione solo quando la soglia dei 600 pixel viene passata. Per i cambi più piccoli dentro una parte, Avalonia ha le container query: stili che valgono solo quando un contenitore ha una certa misura. Il codice decide la struttura, gli stili la disposizione dentro una parte.

20.2 Trascinare e incollare

Un trascinamento ha una sorgente, da cui parte, e una destinazione, dove arriva, spesso in programmi diversi. Fra le due viaggia un DataTransfer, con uno o più formati (un testo, un file, un oggetto del programma), e ogni destinazione prende quello che capisce. In una bacheca di foglietti, il foglietto è la sorgente e comincia a trascinare solo quando il mouse si è allontanato di qualche pixel, altrimenti ogni clic diventerebbe un trascinamento:

var dato = new DataTransferItem();
dato.Set(Formato, Attivita);
dato.SetText(Attivita.Testo);
var trasporto = new DataTransfer();
trasporto.Add(dato);
await DragDrop.DoDragDropAsync(premuto, trasporto,
                               DragDropEffects.Move);

La colonna è la destinazione: accetta con DragDrop.SetAllowDrop(colonna, true), e ascolta DragEnter, DragOver, DragLeave e Drop. In DragEnter e DragOver mette in e.DragEffects che cosa succederebbe lasciando lì, o None per rifiutare; in Drop legge con TryGetValue, TryGetFiles o TryGetText. Lo stesso Drop riceve un foglietto, un file da Esplora risorse o un testo dal browser.

Un foglietto trascinato sopra «Fatto»
Un foglietto trascinato sopra «Fatto»

Gli appunti si raggiungono con TopLevel.Clipboard, e si usano con SetTextAsync e TryGetTextAsync. I tasti per copiare e incollare non vanno scritti a mano (su macOS sono Cmd+C e Cmd+V): Avalonia li dà in PlatformSettings.HotkeyConfiguration.

20.3 Tastiera, mouse e fuoco

Per mouse, penna e dito Avalonia ha un solo puntatore, con PointerPressed, PointerMoved e PointerReleased. e.GetCurrentPoint(controllo) dà la posizione e i tasti premuti, ed e.Pointer.Type dice lo strumento. Il controllo premuto tiene il puntatore da solo fino al rilascio, anche fuori dai suoi bordi. La rotella lancia PointerWheelChanged.

Un tasto lancia KeyDown, e se scrive un carattere arriva anche TextInput. Il tasto si legge in tre modi: Key (il tasto secondo la disposizione della tastiera), PhysicalKey (la sua posizione) e KeySymbol (il carattere che scrive).

La tastiera arriva a un controllo alla volta, quello che ha il fuoco: lo prende con un clic se è Focusable, con Focus() dal codice, o con Tab, che segue l'ordine dei controlli o quello di TabIndex. Il rettangolo attorno al controllo con il fuoco lo disegna il tema, quando il fuoco arriva dalla tastiera, e non va mai nascosto: chi usa la tastiera ha bisogno di vedere dov'è arrivato.

20.4 Un programma che si usa senza vederlo

Un lettore di schermo (l'Assistente vocale di Windows, VoiceOver di macOS, Orca su Linux) legge ad alta voce quello che c'è nella finestra. Non guarda i pixel: chiede al programma il tipo e il nome di ogni controllo. Avalonia li dà da sé quasi sempre, ma le prove su un modulo di prenotazione hanno trovato tre buchi:

  • un campo non ha nome: una Label con Target dà la lettera per Alt, non il nome. Il nome lo dà AutomationProperties.SetLabeledBy(campo, etichetta);
  • un pulsante con la sola icona si chiama come il carattere che mostra: la ✕ diventa «moltiplicazione». Il nome giusto si dà con AutomationProperties.Name, o con AutomationName di RAVAL;
  • un errore solo a colori non si sente: ci vuole una frase, annunciata subito (LiveSetting Assertive), e il fuoco al campo da correggere.
Invio a modulo vuoto: la frase d'errore e il fuoco
Invio a modulo vuoto: la frase d'errore e il fuoco

Il resto è contrasto: le linee guida WCAG chiedono che un testo normale abbia un rapporto di almeno 4,5 a 1 con il suo sfondo. Un testo «tenue» con Opacity 0,7 va ancora bene; a 0,4 no.

20.5 Più lingue

In .NET le abitudini di un paese stanno in una CultureInfo, che sa come si scrivono numeri, monete e date, e si passa a ToString:

var it = CultureInfo.GetCultureInfo("it-IT");
var us = CultureInfo.GetCultureInfo("en-US");
string a = 1234.5m.ToString("N2", it);   // 1.234,50
string b = 1234.5m.ToString("N2", us);   // 1,234.50

La moneta di "C" è quella del paese: una ricevuta in euro con en-US scriverebbe «$». Per i file si usa sempre CultureInfo.InvariantCulture, uguale ovunque. Le frasi stanno in una tabella per lingua con le stesse chiavi, e il codice scrive T("titolo"), mai la frase; i plurali stanno in un metodo, perché ogni lingua ha la sua regola. Per le lingue scritte da destra a sinistra basta FlowDirection.RightToLeft, che rovescia tutta la finestra.

La ricevuta in arabo: la finestra rovesciata
La ricevuta in arabo: la finestra rovesciata

Esercizi

  1. Una soglia con margine. Nella rubrica usa due soglie, larga sopra 620 e stretta sotto 580, perché una finestra larga proprio 600 non cambi forma a ogni pixel.
  2. L'ordine del Tab. In una finestra con quattro campi, cambia l'ordine con TabIndex e togli un campo dal giro con IsTabStop = false.
  3. Il lettore vero. Accendi l'Assistente vocale (Ctrl+Win+Invio) e usa una tua finestra senza guardare: che cosa dice per ogni campo?
  4. Una lingua in più. Scrivi una data e un prezzo con fr-FR, de-DE e en-GB, e confronta i separatori.

Per approfondire: «Solo in codice», volume 2, capitoli II.21–II.25.

21. Architettura: strati, modello, viste, MVC e MVVM

Fin qui ogni programma aveva una vista che faceva quasi tutto: leggeva i controlli, decideva, scriveva i file, aggiornava la finestra. Per un programma di una finestra va bene. Ma un programma cresce: le regole si moltiplicano, i dati vanno salvati, serve una seconda finestra, o un programma console che faccia lo stesso lavoro di notte. Allora conta dove sta ogni cosa, e chi conosce chi. È questo che si chiama architettura.

Il capitolo la costruisce un passo alla volta. Si parte da un programma scritto tutto in un posto, lo si divide in strati, si dà un nome al modello dei dati; poi si prende una piccola rubrica e la si scrive tre volte, uguale per l'utente e diversa dentro: come vista-classe, come MVC e come MVVM. Alla fine si vedrà perché con Avalonia il modello più usato è l'ultimo.

21.1 Tutto in un posto

Il primo programma è un registro delle spese di casa: una descrizione, una categoria, un importo, e sotto l'elenco con i totali per categoria. Nella versione «come viene» tutto il lavoro sta nel clic su Aggiungi:

private void Aggiungi() {
  string d = (_descrizione.Text ?? "").Trim();
  decimal importo = _importo.Value ?? 0;
  if (d.Length == 0) {
    _errore.Text = "Manca la descrizione.";
    return;
  }
  _spese.Add(new Voce(DateTime.Today.ToString("dd/MM"), d,
                      (string)_categoria.SelectedItem!, importo));
  File.WriteAllText(percorso, JsonSerializer.Serialize(_spese));
  // ... poi i totali per categoria, e i controlli aggiornati
}

Funziona. Ma in un metodo solo ci sono cinque lavori diversi: leggere i controlli, controllare le regole, scrivere il file, fare i conti, aggiornare la finestra. Ognuno ha un motivo diverso per cambiare, e ogni cambio tocca lo stesso metodo. Una seconda finestra che mostra i totali deve ricopiare i conti; un programma console che faccia il riepilogo la sera non può usare niente, perché tutto è attaccato a un TextBox. E provare una regola vuol dire aprire la finestra e scrivere nei campi, ogni volta.

Due parole aiutano a dire che cosa non va. L'accoppiamento è quanto una parte dipende da un'altra: qui il conto dei totali è legato alla vista, e senza di lei non si usa. La coesione è quanto le cose che stanno insieme hanno a che fare fra loro: Aggiungi ne ha poca, cinque lavori che c'entrano poco. La regola pratica è accoppiamento basso, coesione alta: ogni parte fa un lavoro, e sa il meno possibile delle altre.

21.2 Gli strati

Il modo più semplice di arrivarci sono gli strati: il programma diviso in livelli, uno sopra l'altro.

Vista, logica e dati: le frecce vanno verso il basso
Vista, logica e dati: le frecce vanno verso il basso
  • La vista mostra i dati e raccoglie quello che l'utente fa. Non decide niente.
  • La logica contiene le regole e i conti. Non sa che esiste una finestra.
  • I dati salvano e rileggono. Non sanno che esistono delle regole.

La regola che tiene in piedi tutto è la direzione delle frecce: ogni strato conosce solo quello sotto. Nella seconda versione delle spese la regola dell'importo sta nella logica, in una classe Bilancio:

public string? Aggiungi(DateOnly giorno, string descrizione,
                        string categoria, decimal importo) {
  descrizione = descrizione.Trim();
  if (descrizione.Length == 0) return "Manca la descrizione.";
  if (importo <= 0) return "L'importo deve essere più di zero.";
  _spese.Add(new Spesa(giorno, descrizione, categoria, importo));
  _archivio.Scrivi(_spese);
  return null;
}

Aggiungi restituisce null se va tutto bene, o la frase che spiega che cosa non va. Il Bilancio non sa dove finirà quella frase (un TextBlock, una notifica, la console), e il file non lo scrive lui: lo fa scrivere all'archivio, lo strato dei dati. La vista chiede al Bilancio, scrive la frase che riceve, e basta.

Uno strato, un progetto. Le tre parti potrebbero stare in tre cartelle, e la regola delle frecce resterebbe una buona intenzione. Qui sono invece tre progetti di una soluzione (capitolo 1), e ognuno dice di quali altri ha bisogno con un ProjectReference:

Spese/
  Dati/        Dati.csproj         Spesa, ArchivioSpese
  Logica/      Logica.csproj       Bilancio         (usa Dati)
  Vista/       Vista.csproj        la finestra      (usa Logica)
  ProvaLogica/ ProvaLogica.csproj  un programma console

Il progetto della logica non ha Avalonia fra i suoi riferimenti: per lei la finestra non esiste, e il compilatore lo fa rispettare. Un using Avalonia.Controls; nella logica non compila, e un riferimento dai dati alla logica, cioè una freccia verso l'alto, ferma la compilazione con una dipendenza circolare. Chi rompe la regola se ne accorge subito.

Qualcuno deve pure creare l'archivio, il bilancio sopra l'archivio e la vista sopra il bilancio: è l'unico punto che conosce tutti, il montaggio. Qui è la finestra:

string file = Path.Join(Environment.GetFolderPath(
  Environment.SpecialFolder.MyDocuments), "spese.json");
var bilancio = new Bilancio(new ArchivioSpese(file));
Content = new VistaSpese(bilancio, DateOnly.FromDateTime(DateTime.Today));

Il nome del file sta qui, e da nessun'altra parte. Il guadagno più concreto si vede con ProvaLogica, un programma console che usa lo stesso Bilancio della finestra:

La stessa logica, senza finestre
La stessa logica, senza finestre

La spesa a zero è stata rifiutata con la stessa frase della finestra, e nessuna finestra si è aperta. Gli strati però costano: più file, più progetti. Convengono appena ci sono delle regole da provare, dei dati che potrebbero cambiare posto, o più di un'interfaccia per la stessa logica.

21.3 Il modello: dati senza grafica

La Spesa, usata da tutti e tre gli strati, sta in quello più in basso. Quel posto ha un nome: il modello. Sono le cose di cui il programma parla (una spesa, un ordine, un contatto), con le regole che valgono sempre, qualunque finestra le mostri.

Nel modello si usano quasi sempre i record del capitolo 5, e meglio se immutabili: una volta creati non cambiano più, e per «cambiarli» se ne fa uno nuovo con with. Un oggetto che non cambia si può passare a chiunque, senza temere che qualcuno lo modifichi alle spalle di chi lo sta mostrando.

Il modello ha anche dei valori che si controllano da soli. Un prezzo potrebbe essere un decimal, ma un decimal può essere negativo o avere tre decimali. Un tipo Euro sa invece una cosa sola, e la sa sempre:

public readonly record struct Euro {
  public decimal Valore { get; }

  public Euro(decimal valore) {
    if (valore < 0) {
      throw new ArgumentOutOfRangeException(nameof(valore),
        "Una somma in euro non può essere negativa.");
    }
    Valore = Math.Round(valore, 2,
                        MidpointRounding.AwayFromZero);
  }
}

Il costruttore è l'unico modo di avere un Euro, e fa rispettare le regole lì: chi riceve un Euro non deve ricontrollare niente. Le regole che riguardano l'oggetto intero (un ordine senza righe) il modello non le impedisce: le dice, con un metodo che restituisce l'elenco delle frasi che dicono che cosa manca.

Nel modelloFuori dal modello
i dati e le loro regolecome si mostrano
i calcoli che li riguardanodove si salvano
i messaggi d'errore, come frasiil bordo rosso, la notifica

21.4 La rubrica, prima versione: la vista come classe

Da qui l'esempio è una rubrica: un nome e un telefono, un elenco in ordine di nome, Aggiungi e Togli. Il modello è lo stesso per tutte e tre le versioni:

public sealed record Contatto(string Nome, string Telefono);

public sealed class Rubrica {
  private readonly List<Contatto> _contatti = new();

  public IReadOnlyList<Contatto> Contatti => _contatti;

  public event EventHandler? Cambiata;

  // null se va bene, o la frase dell'errore
  public string? Aggiungi(string nome, string telefono) {
    // ... controlla nome, telefono e doppioni, poi inserisce
    Cambiata?.Invoke(this, EventArgs.Empty);
    return null;
  }

  public void Togli(Contatto c) {
    if (_contatti.Remove(c)) {
      Cambiata?.Invoke(this, EventArgs.Empty);
    }
  }
}

La Rubrica controlla che il nome ci sia, che il telefono non sia troppo corto e che il nome non sia già presente, e quando cambia lo dice con l'evento Cambiata (capitolo 7), a chiunque ascolti. Non sa che esistono finestre.

La prima vista segue lo schema che quasi tutti i programmi di questo libro hanno usato senza dirlo, con quattro regole:

  1. lo stato in un campo: qui la rubrica. Non nei controlli: il testo di un TextBlock è il modo in cui lo stato si vede, non lo stato;
  2. i controlli in campi, creati una volta;
  3. ogni evento, un metodo con un nome, che cambia lo stato e basta;
  4. un solo Mostra(), l'unico posto che scrive sui controlli, e li scrive tutti, ogni volta, dallo stato.
private void Aggiungi() {
  string? errore = _rubrica.Aggiungi(_nome.Text ?? "",
                                     _telefono.Text ?? "");
  _errore.Text = errore ?? "";
  if (errore != null) return;
  _nome.Text = "";
  _telefono.Text = "";
  Mostra();
}

private void Mostra() {
  _elenco.ItemsSource = new List<Contatto>(_rubrica.Contatti);
  _togli.IsEnabled = _elenco.SelectedItem != null;
  _conteggio.Text = $"{_rubrica.Contatti.Count} contatti.";
}

Perché Mostra riscrive tutto e non solo quello che è cambiato? Perché così è impossibile dimenticare qualcosa: una vista che aggiorna «a pezzi» prima o poi dimentica un pezzo, e il conteggio dice una cosa mentre l'elenco ne dice un'altra. Due inciampi: se cambiare l'elenco lancia un evento che richiama Mostra, si gira in tondo, e lo impedisce una guardia (un campo _mostrando acceso durante Mostra); e Mostra non riscrive i campi in cui l'utente sta scrivendo, perché cancellerebbe le lettere a metà.

Lo schema basta finché lo stato si mostra in una vista e la logica vera sta nel modello. Diventa stretto quando lo stesso stato va mostrato in più viste che devono restare d'accordo.

21.5 Le macchine a stati

Prima delle altre versioni della rubrica, un attrezzo che serve a tutte. Molti oggetti si comportano in modo diverso secondo il momento: un semaforo rosso, a un segnale, diventa verde; una connessione non si può usare finché non è pronta. Scritti con dei bool sparsi (acceso, connesso, inErrore), portano a combinazioni impossibili: acceso e in errore e connesso.

Una macchina a stati dice invece in che stati si può essere e come si passa dall'uno all'altro. Si disegna con un diagramma di stato: i riquadri sono gli stati, le frecce le transizioni, e su ogni freccia c'è l'evento che la fa scattare.

Il diagramma di stato del semaforo
Il diagramma di stato del semaforo

In C# il modo più corto è uno switch sulla coppia (stato, evento), che si legge come il diagramma:

public void Gestisci(Segnale s) {
  Stato = (Stato, s) switch {
    (Luce.Rosso, Segnale.Avanti) => Luce.Verde,
    (Luce.Verde, Segnale.Avanti) => Luce.Giallo,
    (Luce.Giallo, Segnale.Avanti) => Luce.Rosso,
    (Luce.Lampeggiante, Segnale.Riparato) => Luce.Rosso,
    (_, Segnale.Guasto) => Luce.Lampeggiante,
    _ => Stato,
  };
}

L'ultima riga è quella degli eventi che nello stato attuale non valgono: non fanno niente. Una riga può avere anche una guardia, una condizione con when, come «si va in Errore al terzo tentativo fallito». Il guadagno per l'interfaccia è grande: ogni pulsante sa qual è il suo evento e chiede alla macchina se adesso vale, così un pulsante acceso fa sempre qualcosa. E una macchina si prova tutta, stato per stato ed evento per evento, senza aprire finestre. La ritroveremo nel cruscotto del capitolo 24.

21.6 La rubrica, seconda versione: MVC

MVC (Model-View-Controller) è il più vecchio dei modelli d'architettura per le interfacce. Divide il lavoro in tre ruoli:

  • il modello (la Rubrica) tiene i dati e le regole, e quando cambia lo dice con un evento;
  • la vista mostra quello che le si dice, con dei metodi, e avvisa di quello che fa l'utente, con dei propri eventi. Non conosce il modello e non decide niente: è una vista passiva;
  • il controllore conosce tutti: riceve gli avvisi della vista, chiama il modello, e quando il modello cambia dice a tutte le viste che cosa mostrare.
Il flusso di un'azione in MVC
Il flusso di un'azione in MVC

Nella vista passiva il clic su Aggiungi non fa niente di suo: lancia l'evento AggiungiChiesto. Il controllore, nel costruttore, si iscrive agli eventi della vista e a quello del modello:

_vista.AggiungiChiesto += Aggiungi;
_rubrica.Cambiata += RubricaCambiata;

private void Aggiungi(object? mittente, EventArgs e) {
  string? errore = _rubrica.Aggiungi(_vista.NomeScritto,
                                     _vista.TelefonoScritto);
  if (errore != null) {
    _vista.MostraErrore(errore);
    return;
  }
  _vista.Svuota();
}

private void RubricaCambiata(object? mittente, EventArgs e) {
  _vista.MostraContatti(_rubrica.Contatti);
  _conteggio.Mostra(_rubrica.Contatti.Count);
}

Il controllore non aggiorna l'elenco dopo Aggiungi: lo fa quando il modello avvisa che è cambiato. È qui la forza di MVC: un posto che, a ogni cambio del modello, aggiorna tutte le viste, che non si conoscono fra loro. Il prezzo è il codice: la stessa rubrica come vista-classe ha 65 righe, vista passiva, riepilogo e controllore quasi il doppio. L'errore tipico è il controllore che diventa enorme, perché tutto quello che non sta altrove finisce lì: la cura è tenere le regole nel modello.

21.7 La rubrica, terza versione: MVVM

Avalonia ha i binding: un collegamento fra una proprietà di un controllo e una proprietà di un altro oggetto, che si aggiorna da solo. Fanno da soli il lavoro di «quando il modello cambia, aggiorna la vista», che in MVC faceva il controllore. MVVM (Model-View-ViewModel) è il modo di organizzare un programma attorno ai binding, ed è quello che usano la documentazione e i modelli di progetto di Avalonia.

  • Il modello è la Rubrica di sempre, senza cambiare una riga.
  • Il ViewModel è lo stato della vista, senza la vista: quello che è scritto nei campi, il contatto scelto, l'errore, se Togli vale. Sono proprietà che avvisano quando cambiano. Non ha controlli, né un using Avalonia.
  • La vista lega ogni controllo a una proprietà del ViewModel, e non ha un Mostra.
Lo stesso clic nelle tre architetture
Lo stesso clic nelle tre architetture

Il modello è lo stesso; cambia chi aggiorna i controlli: la vista con Mostra, il controllore, o i binding. In MVVM nessuno scrive codice per aggiornare la vista.

Le proprietà che avvisano. Un binding si aggiorna quando la proprietà a cui è legato dice di essere cambiata, con l'interfaccia di .NET INotifyPropertyChanged e il suo evento PropertyChanged. Basta una piccola base per tutti i ViewModel:

public abstract class Osservabile : INotifyPropertyChanged {
  public event PropertyChangedEventHandler? PropertyChanged;

  // mette il valore nel campo e, se e' cambiato, avvisa
  protected bool Imposta<T>(ref T campo, T valore,
      [CallerMemberName] string nome = "") {
    if (EqualityComparer<T>.Default.Equals(campo, valore)) {
      return false;
    }
    campo = valore;
    Avvisa(nome);
    return true;
  }

  protected void Avvisa(string nome) =>
    PropertyChanged?.Invoke(this,
                            new PropertyChangedEventArgs(nome));
}

[CallerMemberName] fa mettere al compilatore il nome della proprietà che chiama. Il ViewModel della rubrica ha le proprietà e le azioni:

public string Nome {
  get => _nome;
  set => Imposta(ref _nome, value);
}

public Contatto? Scelto {
  get => _scelto;
  set {
    if (Imposta(ref _scelto, value)) Avvisa(nameof(PuoTogliere));
  }
}

public bool PuoTogliere => _scelto != null;

public void Aggiungi() {
  string? errore = _rubrica.Aggiungi(Nome, Telefono);
  Errore = errore ?? "";
  if (errore != null) return;
  Nome = "";
  Telefono = "";
}

Aggiungi chiede al modello come prima, ma poi non tocca nessun controllo: cambia delle proprietà. Nome = "", e il TextBox legato a Nome si svuota. PuoTogliere è calcolata da Scelto, e chi cambia Scelto avvisa anche per lei.

I binding in C#. La vista dice qual è la sorgente dei binding con DataContext, che i figli ereditano, e lega ogni controllo con Bind:

DataContext = vm;
_nome.Bind(TextBox.TextProperty,
           new Binding(nameof(VmRubrica.Nome)));
elenco.Bind(ListBox.ItemsSourceProperty,
            new Binding(nameof(VmRubrica.Contatti)));
elenco.Bind(ListBox.SelectedItemProperty,
            new Binding(nameof(VmRubrica.Scelto)));
togli.Bind(IsEnabledProperty,
           new Binding(nameof(VmRubrica.PuoTogliere)));

Un binding va a una via (dal ViewModel al controllo) o a due vie (anche all'indietro). TextBox.Text e ListBox.SelectedItem vanno a due vie da sé: scrivere nel campo cambia Nome, scegliere un contatto cambia Scelto, senza una riga di codice. Un nome sbagliato non dà errori: con new Binding("Nom") il campo resta com'era e il programma va avanti come se niente fosse. Per questo si scrive sempre nameof, che fa controllare il nome al compilatore.

La rubrica in MVVM
La rubrica in MVVM

Nella vista restano solo le cose di vista, come il fuoco che torna al campo del nome dopo Aggiungi: il ViewModel non sa che esiste un fuoco. Librerie come CommunityToolkit.Mvvm scrivono al posto nostro le proprietà che avvisano, ma l'idea è questa, e si capisce meglio dopo averla scritta a mano.

21.8 Quale scegliere

SituazioneArchitettura
una finestra, poche regole, non cresceràvista-classe con un solo Mostra
lo stesso modello in più viste che non si conosconoMVC, o meglio MVVM
tanto stato che si vede in molti posti, da provareMVVM

In tutti i casi la divisione dei compiti è la stessa. Le regole stanno nel modello: la Rubrica decide se un contatto va bene. La logica di presentazione (quale errore mostrare, se un pulsante vale) sta nel ViewModel o nel controllore. La vista non decide niente. Un ViewModel che controlla da sé la lunghezza del telefono ha rubato una regola al modello, e una seconda vista sullo stesso modello non la troverebbe.

Esercizi

  1. Un'altra regola. Aggiungi al Bilancio delle spese la regola «al massimo 10 spese al giorno», e provala con il programma console prima di aprire la finestra.
  2. Un valore controllato. Scrivi un record Telefono con un metodo statico Prova(string) che restituisce un Telefono ripulito, o null se il testo non ha almeno sei cifre.
  3. Il pedone. Aggiungi al semaforo un evento Pedone che dal verde porta al giallo.
  4. Una quarta vista. Nella rubrica MVC aggiungi una vista con le iniziali dei cognomi presenti. Quali file tocchi?
  5. Il titolo legato. Nella rubrica MVVM lega il titolo della finestra a una proprietà del ViewModel, «Rubrica (3)».

Per approfondire: «Solo in codice», volume 2, capitoli II.26–II.31.

22. Comandi, servizi, configurazione e prove

Il capitolo 21 ha dato al programma una forma: un modello, un ViewModel, una vista legata con i binding. Questo capitolo aggiunge gli attrezzi che ne fanno un programma vero: le liste che avvisano da sole, i comandi, gli errori accanto ai campi, i servizi che si scambiano, le parti che si parlano senza conoscersi, le impostazioni, il registro e le prove automatiche.

22.1 Liste che avvisano, comandi, errori dei campi

L'esempio è l'iscrizione a un corso con sei posti: nome, posta ed età, Iscrivi, e l'elenco degli iscritti.

La lista che avvisa. Nella rubrica MVVM il ViewModel faceva una lista nuova a ogni cambio, perché un ItemsSource legato alla stessa lista, cambiata dentro, non se ne accorge. Una ObservableCollection<T> risolve il problema: lancia un evento a ogni Add, Remove e Clear, e l'elenco legato si aggiorna riga per riga. Il ViewModel la tiene per tutta la sua vita e la cambia dentro. È di .NET, non di Avalonia.

I comandi. Un comando è un oggetto ICommand: un'azione (Execute) e la sua condizione (CanExecute). Un Button legato a un comando con la proprietà Command lo esegue quando è premuto, e si accende e si spegne da solo secondo CanExecute. Il Comando del programma riceve due metodi:

public sealed class Comando : ICommand {
  private readonly Action _esegui;
  private readonly Func<bool> _puo;

  public Comando(Action esegui, Func<bool> puo) {
    _esegui = esegui;
    _puo = puo;
  }

  public event EventHandler? CanExecuteChanged;

  public bool CanExecute(object? parametro) => _puo();

  public void Execute(object? parametro) {
    if (_puo()) _esegui();
  }

  // la condizione e' cambiata: il pulsante la richiede
  public void Rivaluta() =>
    CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

Il ViewModel ne crea uno per azione, con due metodi con un nome: Iscrivi = new Comando(FaiIscrivi, PuoIscrivere);, e la vista lo lega come una proprietà qualunque, con Bind(Button.CommandProperty, ...). Il pulsante richiede CanExecute solo quando arriva CanExecuteChanged: per questo il ViewModel chiama Rivaluta ogni volta che cambia qualcosa da cui il comando dipende. Un dettaglio che confonde: con il comando non eseguibile il pulsante ha IsEnabled vero e IsEffectivelyEnabled falso.

Gli errori dei campi. Con l'interfaccia INotifyDataErrorInfo il ViewModel dice quali campi sono sbagliati, e il tema li mostra da sé: bordo rosso e frase sotto il campo, senza una riga nella vista.

Gli errori mostrati sotto i campi
Gli errori mostrati sotto i campi

Il ViewModel mostra gli errori solo per i campi che l'utente ha già toccato: un modulo che si apre tutto rosso dice «hai sbagliato» a chi non ha ancora scritto niente. E controlla solo che i campi siano ben scritti; le regole vere (l'età minima, i posti liberi) restano nel modello del corso.

22.2 Servizi e iniezione delle dipendenze

Nelle spese del capitolo 21 il Bilancio conosceva ancora la classe ArchivioSpese, e per cambiare archivio bisognava toccarlo. Si toglie anche questa dipendenza con un'interfaccia (capitolo 6), che dice che cosa sa fare un servizio:

public interface IArchivio {
  List<Spesa> Leggi();
  void Scrivi(IReadOnlyList<Spesa> spese);
}

Un archivio è qualunque cosa sappia leggere e scrivere un elenco di spese: un file JSON, un file CSV, un archivio in memoria per le prove. E il Bilancio non ne crea nessuno: dice nel costruttore di che cosa ha bisogno, e lo riceve:

public Bilancio(IArchivio archivio, IOrologio orologio) {
  _archivio = archivio;
  _orologio = orologio;
  _spese = archivio.Leggi();
}

È tutta qui l'iniezione delle dipendenze: una classe non crea le sue dipendenze, le riceve, e chi la crea decide quali.

Chi dipende da che cosa
Chi dipende da che cosa

Il secondo servizio è l'orologio. Una regola come «una spesa non può essere nel futuro» deve sapere che giorno è oggi, e con DateTime.Today dentro il Bilancio non si può provare: la prova scritta oggi fallirebbe domani. Con un IOrologio che ha due versioni, quella del computer e una ferma su un giorno, la prova dà sempre lo stesso risultato. È la regola generale: tutto quello che cambia da solo (il tempo, un numero a caso, la rete) si chiede a un servizio, perché una prova lo possa fermare. Le classi vere si scelgono nel montaggio, in un punto solo all'avvio, magari con l'aiuto di un contenitore che tiene per ogni servizio la fabbrica che lo crea.

22.3 Parti che si parlano senza conoscersi

In un magazzino con tre pannelli (le scorte, gli ordini, gli avvisi), le scorte vendono un pezzo, gli ordini devono sapere che un articolo sta finendo, e gli avvisi devono scriverlo. Ci sono tre modi di farli parlare.

Diretto, con gli eventi, con un postino
Diretto, con gli eventi, con un postino

Il modo diretto (le scorte chiamano gli altri due) è il più chiaro finché le parti sono poche. Con gli eventi le scorte non sanno chi ascolta, ma chi ascolta deve conoscere le scorte. Con un postino (event aggregator) tutti conoscono un oggetto solo: chi ha qualcosa da dire pubblica un messaggio, chi vuole riceverlo si iscrive al suo tipo. Il messaggio è un record con un nome al passato, perché è una notizia e non un ordine:

public sealed record Venduto(string Articolo, int Restano);
public sealed record ScortaBassa(string Articolo, int Restano);

L'iscrizione va chiusa quando non serve più: il postino tiene il metodo, e il metodo tiene vivo l'oggetto. Ogni pannello chiude quindi le sue iscrizioni nell'evento Unloaded. Il postino serve fra parti lontane; per le vicine bastano la chiamata diretta e il binding.

22.4 Impostazioni, registro, errori

Le impostazioni sono un record con un valore di partenza per ogni proprietà, salvato in JSON (capitolo 10):

public sealed record Impostazioni {
  public string CartellaDati { get; init; } = "dati";
  public int Soglia { get; init; } = 40;
}

La regola è una sola: leggere le impostazioni non fallisce mai. Se il file manca o è rotto si usano i valori di partenza: un programma che non parte più perché qualcuno ha toccato a mano un file, l'utente non sa come riaprirlo. Per salvare senza rischio si scrive in un file a parte e lo si sposta sopra il vecchio con File.Move(..., overwrite: true): sul disco c'è sempre un file intero.

Il registro (log) è un file in cui il programma scrive, riga per riga, quello che fa, con l'ora e un livello (Info, Avviso, Errore): lo legge chi deve capire che cosa è successo, giorni dopo. Non deve mai lanciare, e RAVAL ne ha già uno, nella parte IV.

Gli errori sono di due specie. Quelli previsti (il file non c'è, il disco rifiuta) si prendono vicino a dove succedono, con un catch per quel tipo, e si spiegano con una frase. Mai catch (Exception) nel gestore di un pulsante: prenderebbe anche i difetti del programma e li spiegherebbe all'utente come se fossero colpa sua. Quelli imprevisti sono i difetti, e per loro Avalonia ha una rete di sicurezza, l'evento Dispatcher.UIThread.UnhandledException:

// in partenza:
// Dispatcher.UIThread.UnhandledException += Imprevisto;
private void Imprevisto(object mittente,
    DispatcherUnhandledExceptionEventArgs e) {
  _registro.Scrivi(Livello.Errore,
                   "errore imprevisto: " + e.Exception);
  _mostra("Qualcosa non ha funzionato. Il programma continua; "
          + "i dettagli sono nel registro.");
  e.Handled = true;
}

Con e.Handled = true il programma continua. L'utente legge una frase che non lo accusa di niente, e il registro ha tutta la traccia dello stack, con il file e la riga in cui l'errore è nato.

22.5 Le prove automatiche

Una prova automatica (unit test) è un piccolo metodo che usa il codice e controlla da solo se fa quello che deve. Si scrive una volta, e da quel momento ogni cambio si controlla in un secondo, con dotnet test. Le prove stanno in un progetto a parte, con il pacchetto xUnit e un riferimento al progetto da provare.

Una prova è un metodo con l'attributo [Fact], in tre parti: prepara, fai una cosa sola, controlla con un Assert. Ecco il ViewModel della rubrica, provato senza finestre:

[Fact]
public void UnContattoGiustoEntraESvuotaICampi() {
  var vm = new VmRubrica(new Rubrica());
  vm.Nome = "Anna Rossi";
  vm.Telefono = "333 1234567";

  vm.Aggiungi();

  Assert.Single(vm.Contatti);
  Assert.Equal("", vm.Nome);
}

In Assert.Equal(atteso, ottenuto) l'ordine conta, perché il messaggio d'errore dica giusto che cosa si aspettava e che cosa ha ottenuto. Il nome del metodo è una frase: quando una prova fallisce, è la prima cosa che si legge. Per la stessa prova con dati diversi c'è [Theory], con una riga [InlineData] per caso: è il modo naturale di provare una macchina a stati, una riga per transizione. Le viste si provano con Avalonia.Headless, senza schermo ma con tasti e clic veri, usando [AvaloniaFact] al posto di [Fact].

Diciotto prove: macchina a stati, ViewModel e vista
Diciotto prove: macchina a stati, ViewModel e vista

Che cosa provare: le regole, con i casi limite (zero, uno, il massimo, il vuoto); ogni transizione di una macchina a stati; ogni difetto trovato, prima con una prova rossa che lo mostra e poi con la correzione che la fa diventare verde. E una prova nuova va guastata apposta una volta, per vederla rossa: una prova che passa sempre non serve.

Esercizi

  1. Gli errori al primo Iscrivi. Fai mostrare tutti gli errori dopo il primo tentativo di iscrizione, anche sui campi non toccati. Dove cambia il codice?
  2. Il registro come servizio. Aggiungi un IRegistro con Scrivi(string), che il Bilancio riceve nel costruttore: una versione sulla console e una in una lista per le prove.
  3. Il quarto pannello. Aggiungi al magazzino un pannello che conta i pezzi venduti di ogni articolo, iscritto a Venduto, senza toccare gli altri tre.
  4. Prova la rubrica. Scrivi una prova che controlla che un contatto senza nome lasci l'errore e i campi com'erano.

Per approfondire: «Solo in codice», volume 2, capitoli II.32–II.36.

23. Il thread dell'interfaccia: Dispatcher, await, timer, dati che arrivano

Il capitolo 12 ha presentato thread, Task, async e await in programmi console. In una finestra c'è un vincolo in più, e questo capitolo gira tutto intorno a lui: i controlli vivono su un thread solo. Si vedrà perché una finestra si blocca, come si sposta un lavoro lungo in un altro thread, come si riportano i risultati, e come si mostrano dati che arrivano di continuo.

23.1 Perché una finestra si blocca

Il thread dell'interfaccia fa tutto, uno dopo l'altro: legge il mouse e la tastiera, chiama i gestori degli eventi, fa il layout, disegna, fa scattare i timer. Ha una coda di cose da fare, il Dispatcher, e le prende una per volta. Finché un gestore non ha finito, la coda aspetta.

Per vederlo, i programmi di questo capitolo hanno in cima un polso: un timer che scatta ogni decimo di secondo sul thread della finestra e annota la pausa più lunga fra due battiti, cioè il tempo in cui la finestra non ha risposto a niente. Contando i numeri primi fino a cinque milioni direttamente nel gestore del clic, il polso dice 1,3 secondi: per tutto quel tempo la finestra non ha disegnato né risposto. Un lavoro lungo, più di qualche decina di millisecondi, non si fa nel gestore.

23.2 Un thread solo per i controlli

La prima idea è mandare il lavoro in un altro thread con Task.Run (capitolo 12), e mostrare il risultato da lì. Il polso resta puntuale, ma scrivere nella barra lancia un'eccezione.

Il controllo toccato dal thread sbagliato
Il controllo toccato dal thread sbagliato

È un'InvalidOperationException: «The calling thread cannot access this object because a different thread owns it». Ogni controllo appartiene al thread che lo ha creato, e anche solo leggere una sua proprietà da un altro thread la lancia. Il vincolo ha una ragione: un controllo ha decine di proprietà legate fra loro, e un thread che ne cambia una mentre un altro disegna lascerebbe tutto a metà. Invece di proteggere ogni proprietà con un lock, Avalonia, come quasi ogni libreria di finestre, fa fare tutto a un thread solo.

La soluzione: il lavoro lungo va nel pool e non tocca i controlli. Quando vuole mostrare qualcosa, chiede al Dispatcher di mettere un metodo in coda, e il thread della finestra lo eseguirà quando ci arriva:

// sul thread del pool, a ogni punto percentuale
private void Avanza() => Dispatcher.UIThread.Post(Mostra);

// sul thread della finestra: qui si toccano i controlli
private void Mostra() {
  _barra.Value = _conteggio.Percento;
  _esito.Text = $"Conto... {_conteggio.Fatti:N0}";
}

private async void ContaColDispatcher() {
  Inizia();
  int trovati = await Task.Run(ContaEAvvisa);
  Finisce(trovati);
}

Post mette Mostra in coda e torna subito. Dopo l'await il gestore riprende sul thread della finestra, e Finisce può toccare i controlli. Così la barra si muove e il polso batte puntuale per tutto il conteggio. Fra il lavoro e i controlli passano solo dati, mai un controllo. E un Post va fatto ogni tanto, non per ogni numero: un milione di Post riempirebbe la coda più in fretta di quanto la finestra la vuoti.

23.3 await nei gestori

Più spesso di un calcolo, un programma con finestre aspetta: un server, un file, un database. Per aspettare non serve un thread che lavora, serve await. Ecco il gestore giusto della ricerca nel catalogo di una biblioteca, che impiega 800 millisecondi:

private async void Cerca() {
  Accendi(false);
  try {
    var trovati = await _catalogo.CercaAsync(Testo);
    _trovati.ItemsSource = trovati;
  }
  finally {
    Accendi(true);
  }
}

Un gestore che aspetta è async void, l'unico caso in cui va bene; ovunque altro un metodo async restituisce un Task. Durante l'attesa la finestra ha battuto e disegnato; dopo, il gestore scrive nell'elenco senza eccezioni.

Come fa a tornare sul thread della finestra? Quando un await trova un lavoro non ancora finito, il metodo si ferma lì e il thread della finestra torna libero. Prima di fermarsi, await guarda il contesto di sincronizzazione del thread, che in una finestra Avalonia sa fare una cosa sola: mettere un lavoro nella coda del Dispatcher. Quando l'attesa finisce, il resto del gestore viene messo lì. In una console il contesto non c'è, ed è per questo che nei programmi del capitolo 12 il codice dopo un await riprendeva su un thread qualunque. ConfigureAwait(false) dice all'await di non tornare sul contesto: mai nel gestore, quasi sempre nelle librerie, il cui lavoro non ha bisogno della finestra.

Il blocco mortale. Aspettare fermi, con .Result o .Wait(), in una finestra è un errore grave.

Il blocco mortale: ognuno aspetta l'altro
Il blocco mortale: ognuno aspetta l'altro

Il gestore si ferma in .Result, e con lui il thread della finestra. Il lavoro, finita l'attesa, vuole riprendere sul thread della finestra e mette il suo seguito in coda al Dispatcher; ma il Dispatcher non può eseguirlo, perché il suo thread è fermo ad aspettare proprio quel lavoro. Ognuno aspetta l'altro, per sempre. La regola: in una finestra mai .Result né .Wait() su un lavoro che non è finito.

I pulsanti durante l'attesa. Una finestra libera risponde anche a un secondo clic sullo stesso pulsante: due ricerche insieme, o due salvataggi. Per questo il gestore spegne i pulsanti prima dell'await e li riaccende in un finally, così si riaccendono anche se il lavoro lancia un'eccezione.

23.4 Avanzamento, Annulla, timer

Un lavoro lungo deve dare all'utente una barra che si muove e un pulsante Annulla che lo ferma davvero. Il lavoro sta in una classe che non sa niente di finestre, e riceve come parametri un IProgress<T> a cui dire a che punto è e un CancellationToken da cui sapere se deve smettere (capitolo 12):

private async void Archivia() {
  _annulla = new CancellationTokenSource();
  // creato qui: gli avvisi arrivano sul thread della finestra
  var avanzamento = new Progress<Avanzamento>(Mostra);
  try {
    int fatti = await _archiviatore.ArchiviaAsync(Elenco(),
      avanzamento, _annulla.Token);
    _esito.Text = $"Fatto: {fatti} file archiviati.";
  }
  catch (OperationCanceledException) {
    _esito.Text = "Annullato.";
  }
  finally {
    _annulla.Dispose();
    _annulla = null;
  }
}

private void Annulla() => _annulla?.Cancel();

Progress<T> si ricorda il contesto del thread in cui nasce, e a ogni Report, da qualunque thread arrivi, mette Mostra in coda a quel contesto. Creato nel gestore, Mostra gira sempre sul thread della finestra, senza nessun Post.

A metà: la barra, e Annulla acceso
A metà: la barra, e Annulla acceso

Annulla chiama Cancel(): il lavoro se ne accorge e lancia OperationCanceledException, che risale fino all'await del gestore. L'annullamento non è un errore, e si dice con calma. Ogni lavoro vuole una sorgente nuova, perché una CancellationTokenSource annullata resta annullata; e una vista che esce dalla finestra (Unloaded) annulla il suo lavoro, che altrimenti continuerebbe per nessuno.

I timer. Per fare qualcosa a intervalli regolari ci sono tre timer, e la differenza sta nel thread su cui battono:

PerIl timer
aggiornare un controllo (un orologio, il polso)DispatcherTimer: sul thread della finestra
un lavoro regolare in un metodo asyncPeriodicTimer, aspettato con await
un lavoro che non tocca la finestraSystem.Threading.Timer: nel pool

Tutti e tre vanno fermati quando non servono più: un timer dimenticato acceso batte anche dopo la chiusura della finestra. RAVAL ha RAva.Every, che chiama un metodo a intervalli regolari sul thread della finestra.

23.5 Dati che arrivano di continuo

Molti programmi ricevono dati di continuo: sensori, una linea seriale, un server. L'esempio ha quattro sensori finti che leggono una temperatura nel pool, e una finestra che mostra le ultime letture.

I sensori scrivono nel canale, la finestra legge
I sensori scrivono nel canale, la finestra legge

Fra chi produce e chi mostra c'è un canale, Channel<T> (capitolo 12): una coda in cui un lato scrive e l'altro legge, da thread diversi, senza lock. I sensori non sanno niente della finestra, e la finestra conosce solo il canale. La finestra legge in un metodo async chiamato dal gestore di Avvia, quindi sul suo thread:

private async Task Leggi(ChannelReader<Lettura> canale) {
  while (await canale.WaitToReadAsync()) {
    while (canale.TryRead(out Lettura? l)) Mostra(l);
    _conti.Text = $"Mostrate: {_mostrate:N0} letture";
    // senza, timer e clic restano in coda per sempre
    await Task.Delay(Respiro);
  }
}

Tre idee in poche righe:

  • si legge a lotti: TryRead prende tutto quello che c'è, e i controlli si aggiornano venti volte al secondo invece di trecento. L'occhio non vede la differenza, il thread della finestra sì;
  • fra un lotto e l'altro si respira per cinquanta millisecondi. Senza questa pausa, in una prova il polso si è fermato del tutto: c'erano sempre letture nuove, il seguito del metodo tornava subito in coda, e timer, disegno e clic non arrivavano mai;
  • il canale ha un limite, trenta letture, e quando è pieno butta le più vecchie (BoundedChannelFullMode.DropOldest): una finestra lenta mostra sempre le letture recenti, e la memoria non cresce.

Le letture finiscono in una ObservableCollection, e la cambia solo il thread della finestra. Una raccolta cambiata dal pool mentre la finestra la scorre lancia «Collection was modified»: il canale esiste proprio perché i thread di lavoro scrivano in lui, e solo la finestra tocchi la raccolta.

Esercizi

  1. Troppi Post. Nel conteggio dei primi, fai chiamare Avanza a ogni numero invece che a ogni punto percentuale. Che cosa succede al polso?
  2. Un tempo massimo. Annulla da solo l'archiviazione dopo due secondi, con CancelAfter, e fai dire «tempo scaduto» invece di «annullato».
  3. L'orologio. Aggiungi a una finestra un orologio con ore, minuti e secondi. Quale timer usi?
  4. Respiro variabile. Nei sensori porta il respiro a 200 millisecondi. Che cosa cambia nei lotti e nel polso?

Per approfondire: «Solo in codice», volume 2, capitoli II.37–II.40.

24. Il cruscotto in tempo reale

L'ultimo capitolo della parte mette insieme in un programma solo quello che si è costruito un pezzo per volta: la macchina a stati, i ViewModel e i binding, le letture che arrivano da altri thread, il tema, le impostazioni, il montaggio e le prove.

Il programma è un cruscotto: il quadro da cui un capo reparto guarda le sue quattro macchine mentre lavorano. A sinistra l'elenco, con un pallino colorato per lo stato; al centro la macchina scelta, con la temperatura, lo stato, i pezzi fatti, il grafico delle ultime letture e il diario dei cambi di stato; in fondo il riepilogo e il polso.

Il cruscotto
Il cruscotto

Le macchine sono finte: quattro simulatori che mandano una lettura ogni decimo di secondo, al posto di una linea seriale o di un PLC. Tutto il resto è come sarebbe con le macchine vere.

24.1 Quattro progetti

ProgettoChe cosa contieneUsa
Impiantole macchine, la macchina a stati, i simulatori, le impostazionisolo .NET
Pannelloi ViewModel e il collegamento con le macchineImpianto
Quadrola finestra, il grafico, il montaggioPannello, Avalonia, RAVAL
ProveImpiantole prove, con xUnitPannello

I riferimenti vanno in una direzione sola, come negli strati del capitolo 21: l'impianto non sa che esiste un cruscotto, e il Pannello non sa che esiste Avalonia. Per questo si provano senza finestre, e si potrebbero riusare per un cruscotto web o per un servizio che manda gli allarmi per posta.

24.2 Una macchina a stati con due soglie

Ogni macchina ha quattro stati, Ferma, Avvio, Marcia e Allarme, e lo stato cambia in un punto solo, con una tabella scritta come switch:

// la tabella delle transizioni: lo stato dopo una lettura
private StatoMacchina Dopo(Lettura l) => Stato switch {
  Avvio when _inAvvio >= LettureDiAvvio => Marcia,
  Marcia when l.Temperatura >= Soglie.Allarme => Allarme,
  Allarme when l.Temperatura <= Soglie.Rientro => Marcia,
  _ => Stato,
};

Avviata, la macchina resta in Avvio per cinque letture e poi va in Marcia. In marcia, una temperatura di 85 gradi o più la porta in Allarme, e in allarme ci resta finché la temperatura non scende a 75. Due soglie, e non una: con una sola, una temperatura che oscilla attorno agli 85 gradi farebbe entrare e uscire dall'allarme a ogni lettura. Lo spazio fra le due soglie (in elettronica si chiama isteresi) è il margine che la macchina deve davvero recuperare. Nella figura il forno è ancora in allarme a 78,5 gradi: sotto la soglia di allarme, ma sopra quella di rientro.

24.3 I ViewModel e il collegamento

VmMacchina traduce una macchina per la finestra: lo stato scritto, la temperatura con l'unità, il livello per il colore del pallino, i valori per il grafico, e se si può avviare o fermare. VmCruscotto ha le quattro macchine, quella scelta, il riepilogo e il diario, e riceve le letture a lotti: ogni lettura va alla sua macchina, e se lo stato è cambiato il diario lo scrive, con l'ora chiesta a un IOrologio (capitolo 22).

Il Collegamento è il capitolo 23 in una classe: i simulatori scrivono nel pool in un canale limitato, e LeggiAsync prende le letture a lotti, le passa al VmCruscotto e respira. La vista lo aspetta nel suo evento Loaded, quindi sul thread della finestra, e in Unloaded lo ferma. Il polso dice 126 millisecondi di pausa più lunga, con quattro macchine che mandano dieci letture al secondo ciascuna.

24.4 La vista, il grafico, il tema

La vista costruisce i controlli e li lega al VmCruscotto. I controlli del dettaglio si legano a percorsi, come "Scelta.Temperatura": il binding segue la macchina scelta, e quando la scelta cambia segue la nuova. I pulsanti Avvia e Ferma sono legati a PuoAvviare e PuoFermare, che chiedono alla macchina a stati. Il pallino e il grafico sono controlli disegnati (la parte III spiega come); la scala del grafico è fissa, da 40 a 100 gradi, perché due macchine si possano confrontare a colpo d'occhio.

Le impostazioni (le due soglie, l'intervallo delle letture, il tema) stanno in impostazioni.json, lette con le regole del capitolo 22. Il menu Vista cambia il tema e lo salva. Il tema scuro non costa una riga di codice: i colori dei controlli disegnati sono coppie chiaro e scuro (capitolo 17), chieste per nome.

Il cruscotto nel tema scuro
Il cruscotto nel tema scuro

24.5 Il montaggio e le prove

Tutto si sceglie in un punto, all'avvio (capitolo 22): la cartella delle impostazioni, l'orologio del computer, le macchine con le loro soglie. Diciassette prove, in un decimo di secondo, provano l'impianto e i ViewModel senza finestre e senza thread: la tabella degli stati riga per riga, le due soglie, il diario con l'orologio fermo, il simulatore che con lo stesso seme dà le stesse letture.

Scrivere una delle prime prove ha fatto venire fuori una regola ambigua: i pezzi della lettura che porta la macchina da Avvio a Marcia vanno contati? Ora la regola è scritta nel codice e nella prova: contano i pezzi delle letture che arrivano quando la macchina è già in marcia. È uno dei regali delle prove: costringono a decidere quello che il codice lasciava nel vago.

Esercizi

  1. Le soglie dal menu. Aggiungi una finestra di dialogo (capitolo 19) per cambiare le soglie, che le salva nelle impostazioni e le passa alle macchine.
  2. Una macchina che non risponde. Se una macchina non manda letture per due secondi, il suo stato diventa «Muta»: aggiungi lo stato, la riga della tabella e una prova.
  3. Una prova della vista. Con [AvaloniaFact], prova che cliccare Ferma spegne il pulsante e cambia il riepilogo.
  4. La macchina vera. Sostituisci un simulatore con una classe che legge le letture da un file CSV scritto da un altro programma: che cosa deve cambiare nel resto del cruscotto?

Per approfondire: «Solo in codice», volume 2, capitolo II.41.

Parte III — Disegnare

Nelle prime due parti i controlli erano già fatti: un TextBlock scrive, un Button si preme, un pannello mette in fila. Ma un manometro, un grafico, la pianta di una casa o uno schema a blocchi non stanno in nessun elenco di controlli pronti: si disegnano. Questa parte insegna a farlo con quello che Avalonia ha già dentro, senza librerie in più: un controllo, il suo metodo Render, e un DrawingContext che riceve linee, forme, colori, immagini e testi.

Si parte da come Avalonia porta un disegno sullo schermo, perché da lì vengono quasi tutte le regole; poi gli strumenti, il controllo che disegna fatto per bene, le immagini e il testo, le coordinate di un grafico o di una pianta, l'esportazione in PNG, SVG e PDF, e infine le animazioni. Il grafico del capitolo 29 è il fratello minore di RChart, il grafico di RAVAL che la parte IV presenta nel capitolo 35.

25. Come disegna Avalonia

Cosa imparerai: che cosa succede fra il metodo Render di un controllo e i pixel sullo schermo, e quando Avalonia ridisegna; in che coordinate si disegna; le forme di base, i pennelli e le penne; perché una linea sottile a volte esce grigia; le geometrie, le curve di Bézier, gli archi e le regole di riempimento.

25.1 Dal Render ai pixel

Un controllo che disegna è una classe che deriva da Control e riscrive un metodo solo, Render:

// Un cerchio rosso nel mezzo del controllo, grande quanto lo spazio
internal sealed class Bollo : Control {

  /// <summary>Il cerchio, nella misura che il layout ha dato.</summary>
  public override void Render(DrawingContext dc) {
    double r = Math.Min(Bounds.Width, Bounds.Height) / 2;
    var centro = new Point(Bounds.Width / 2, Bounds.Height / 2);
    dc.DrawEllipse(Brushes.Firebrick, null, centro, r, r);
  }
}

La sorpresa è che Render non disegna sullo schermo. Il DrawingContext che riceve registra quello che gli si chiede, «un'ellisse qui, con questo pennello», in una lista dei comandi del controllo. Nessun pixel cambia ancora.

Dal Render ai pixel
Dal Render ai pixel

La lista passa al compositore, che tiene l'albero di tutto quello che c'è da disegnare e lavora su un altro thread, il thread del disegno. A ogni fotogramma percorre il suo albero ed esegue le liste con Skia, la libreria di grafica 2D di Google che sta sotto Avalonia, sulla scheda video se c'è, o con il processore. Ne vengono tre regole, che torneranno in tutta la parte:

  • Render costa poco chiamarlo e caro sbagliarlo: non disegna, ma quello che mette nella lista viene eseguito ogni volta che il controllo va ridisegnato;
  • se un controllo non cambia, il compositore riusa la lista di prima, e non serve chiamare di nuovo Render;
  • Render gira sul thread della finestra, e lì legge i campi del controllo senza lock; la lista invece la esegue più tardi l'altro thread. Per questo un DrawingContext non si conserva e non si usa fuori da Render. Avalonia non lo impedisce, e lo sbaglio passa senza errori: la regola la deve tenere il programma.

25.2 Quando si ridisegna

Avalonia chiama Render solo quando qualcuno le dice che il disegno è cambiato: quando il controllo chiama InvalidateVisual(), quando cambia una proprietà registrata con AffectsRender (capitolo 27), quando cambia la misura del controllo.

Quando Avalonia chiama Render
Quando Avalonia chiama Render

InvalidateVisual non chiama Render subito: segna il controllo, e Render arriva una volta sola al fotogramma successivo, anche se nel frattempo è stato chiamato cento volte. Chiamarlo in un ciclo sperando di vedere i passi non serve: si vede solo l'ultimo. E quando nessuno segna niente, niente Render e niente fotogrammi: una finestra ferma non costa niente al processore.

Per muovere un disegno si chiede ad Avalonia un fotogramma alla volta con RequestAnimationFrame. Una girandola di dodici raggi fa così:

// un fotogramma: l'angolo dall'ora, il disegno, il fotogramma dopo
private void Fotogramma(TimeSpan ora) {
  if (!_gira) return;
  // un quarto di giro al secondo
  _angolo = ora.TotalSeconds * Math.PI / 2;
  InvalidateVisual();
  TopLevel.GetTopLevel(this)?.RequestAnimationFrame(Fotogramma);
}

Avalonia chiama Fotogramma al fotogramma dopo, con l'ora di quel fotogramma, e non più spesso di quanti ne mostri lo schermo. L'angolo viene dall'ora, non da un contatore: se un fotogramma salta, la girandola non rallenta, fa un passo più lungo. Ferma la girandola, la catena si interrompe da sola.

Per guardare dentro il motore, ogni finestra ha RendererDiagnostics.DebugOverlays, che accende sopra la finestra i fotogrammi al secondo e le zone ridisegnate: un rettangolo che si colora a ogni fotogramma, dove non si muove niente, è un InvalidateVisual di troppo.

25.3 Le coordinate e le forme di base

Un controllo disegna nel suo sistema di coordinate: l'origine (0, 0) è il suo angolo in alto a sinistra, la x cresce verso destra e la y verso il basso, al contrario dei grafici della scuola. Il controllo occupa il rettangolo da (0, 0) a Bounds.Size. L'unità è il pixel indipendente dal dispositivo del capitolo 16, un novantaseiesimo di pollice: un pixel con lo schermo al 100%, due al 200%.

Il DrawingContext ha un metodo per ogni forma semplice:

MetodoChe cosa disegna
DrawLine(penna, da, a)un segmento fra due Point
DrawRectangle(pennello, penna, rect)un rettangolo
DrawRectangle(pennello, penna, rect, rx, ry)un rettangolo con gli angoli arrotondati
DrawEllipse(pennello, penna, centro, rx, ry)un'ellisse dal centro e dai due raggi
DrawGeometry(pennello, penna, geometria)una forma qualsiasi (§25.6)
FillRectangle(pennello, rect)un rettangolo pieno, senza contorno

Il pennello (brush) riempie, la penna (pen) disegna il contorno; un Rect si scrive (x, y, larghezza, altezza). Pennelli e penne che non cambiano si tengono in campi static readonly: si creano una volta, non a ogni Render.

La pagina delle forme di base
La pagina delle forme di base

Il pennello e la penna possono essere null, e allora quella parte non si disegna: con la penna nulla resta solo il riempimento, con il pennello nullo solo il contorno. E il contorno sta a cavallo della forma, metà dentro e metà fuori: un rettangolo largo 20 con una penna spessa 4 occupa 24 pixel. Se una forma deve stare esattamente dentro un'area, il rettangolo va ristretto di mezzo spessore per parte.

25.4 Le penne

Una penna ha lo spessore, il pennello del tratto, le estremità (LineCap), le giunzioni (LineJoin) e il tratteggio (DashStyle).

Estremità, giunzioni, limite delle punte, spessori
Estremità, giunzioni, limite delle punte, spessori

Le estremità dicono come finisce una linea: Flat esattamente sul punto, Round con un semicerchio, Square con un quadrato; le ultime due la allungano di mezzo spessore per parte. Le giunzioni dicono come si uniscono due lati: Miter a punta, Bevel con l'angolo tagliato, Round arrotondato. Qui c'è il primo inciampo della parte: la giunzione predefinita cambia con il costruttore. new Pen() fa gli angoli smussati, new Pen(pennello, 4) li fa a punta; quando gli angoli contano, la giunzione si scrive. E uno spessore 0 non disegna niente: non è la linea più sottile, è nessuna linea.

I tratteggi sono lunghezze alternate di pieno e vuoto, misurate in spessori della penna. Avalonia ne ha di pronti (DashStyle.Dash, Dot, DashDot), ma i punti hanno lunghezza zero, e con l'estremità Flat una penna DashStyle.Dot disegna una riga vuota. Con lineCap: PenLineCap.Round ogni punto diventa un pallino.

25.5 I mezzi pixel

Una linea verticale spessa 1, disegnata a x = 3, esce grigia e larga due pixel. Il pixel numero n copre lo spazio da n a n + 1: una coordinata intera non è il centro di un pixel, è il bordo fra due pixel. La linea a x = 3 copre da 2,5 a 3,5, metà del pixel 2 e metà del pixel 3, e con l'antialiasing, il modo predefinito, Skia li colora tutti e due a metà. Spostata a x = 3,5 copre esattamente il pixel 3, ed è nera e nitida.

Perché una linea su una coordinata intera è sfocata
Perché una linea su una coordinata intera è sfocata

La regola, con lo schermo al 100%: spessore dispari su una coordinata «e mezzo», spessore pari su una intera. Le forme piene senza contorno non hanno il problema. Al 200% una linea spessa 1 è larga due pixel veri ed è nitida dappertutto; al 150% è larga un pixel e mezzo, e non c'è coordinata che la renda nitida. L'antialiasing si può spegnere per un pezzo di disegno, con PushRenderOptions e EdgeMode.Aliased: niente grigi, ma le curve diventano a gradini. Va bene per griglie e righelli, non per il resto.

25.6 Le geometrie

Appena una forma non è un rettangolo o un'ellisse (una freccia, una goccia, la curva di un grafico) serve una geometria: la descrizione di una forma, separata dal modo di disegnarla.

Le famiglie delle geometrie
Le famiglie delle geometrie

Ci sono le forme pronte (RectangleGeometry, EllipseGeometry, LineGeometry, PolylineGeometry), le forme costruite a pezzi (StreamGeometry e PathGeometry) e le forme fatte di altre forme (CombinedGeometry e GeometryGroup). Una forma qualsiasi è fatta di figure, e ogni figura di segmenti che partono da dove è finito il precedente. Con StreamGeometry la si scrive come una serie di comandi: BeginFigure comincia una figura, LineTo aggiunge un lato, EndFigure(true) la chiude tornando al primo punto.

Ecco una goccia, che mette insieme le linee curve di cui parla il paragrafo dopo:

/// <summary>La goccia: la punta in alto, il fondo tondo di raggio r.</summary>
public static StreamGeometry Goccia(Point punta, double r) {
  double cx = punta.X, cy = punta.Y + 1.8 * r;
  var g = new StreamGeometry();
  using (StreamGeometryContext c = g.Open()) {
    c.BeginFigure(punta, true);
    c.CubicBezierTo(new Point(cx + 0.33 * r, punta.Y + 0.67 * r),
                    new Point(cx + r, cy - 0.67 * r),
                    new Point(cx + r, cy));
    c.ArcTo(new Point(cx - r, cy), new Size(r, r), 0, false,
            SweepDirection.Clockwise);
    c.CubicBezierTo(new Point(cx - r, cy - 0.67 * r),
                    new Point(cx - 0.33 * r, punta.Y + 0.67 * r),
                    punta);
    c.EndFigure(true);
  }
  return g;
}

La geometria si costruisce davvero alla chiusura del using, e le prove hanno mostrato tre cose che la documentazione non dice: prima del Dispose è vuota; un secondo Open aggiunge, non sostituisce, e per cambiare forma se ne crea una nuova; una figura aperta si riempie lo stesso, perché decide il true di BeginFigure, non EndFigure(false). PathGeometry fa le stesse forme con oggetti invece che con comandi, e li tiene: è la scelta quando la forma deve restare modificabile. In tutti e due i casi la geometria si costruisce una volta, in un campo, e Render la ridisegna con DrawGeometry quante volte serve.

25.7 Curve di Bézier, archi e riempimenti

Una curva di Bézier va da un punto a un altro tirata da uno o due punti di controllo, senza passarci: la quadratica ne ha uno, la cubica due.

Le due curve con i loro punti di controllo
Le due curve con i loro punti di controllo

La curva parte verso il primo controllo, arriva dalla direzione dell'ultimo, e più un controllo è lontano più la curva gli va incontro. Con due controlli la cubica può fare una S: è la curva delle forme morbide e dei caratteri tipografici. Un arco (ArcTo) è un pezzo di ellisse fino al punto dato; fra due punti ne passano quattro, e due parametri scelgono quale: isLargeArc quello lungo o quello corto, SweepDirection il verso, orario o antiorario, come si vede sullo schermo. Nella goccia, dal lato destro del fondo al sinistro, l'arco orario passa sotto.

Quando un bordo si incrocia, o una figura sta dentro un'altra, che cosa è «dentro»? Lo decide la regola di riempimento. EvenOdd conta i bordi attraversati da una semiretta: dispari dentro, pari fuori. NonZero conta ogni bordo +1 o −1 secondo il verso in cui è stato disegnato: se la somma non è zero, il punto è dentro.

EvenOdd sopra, NonZero sotto
EvenOdd sopra, NonZero sotto

La regola predefinita è EvenOdd dappertutto, e una figura disegnata dentro un'altra diventa un buco; con StreamGeometry la si cambia con SetFillRule, prima della prima figura. Una forma si può anche scrivere come testo, nella sintassi dei percorsi di SVG: Geometry.Parse("M 10,10 L 90,10 L 50,80 Z") è un triangolo, con M che sposta, L che traccia una linea, C una cubica, A un arco, Z che chiude, e F1 all'inizio per NonZero.

Infine una geometria sa dire di sé quanto è grande e che cosa contiene: Bounds è il suo rettangolo, FillContains(punto) e StrokeContains(penna, punto) dicono se un punto sta nel riempimento o nel tratto. Sono la base della domanda «che cosa c'è sotto il mouse?», che il capitolo 29 usa per prendere le forme.

Esercizi

  1. La griglia sfocata. Disegna una quadrettatura di 10 in 10 con linee spesse 1, su coordinate intere e poi «e mezzo». Di che colore sono le righe, e quanto sono larghe?
  2. La cornice esatta. Disegna una cornice spessa 6 che stia esattamente dentro i Bounds del controllo.
  3. Punti tondi. Fai una penna a pallini distanti fra loro tre volte il loro diametro: che Dashes servono?
  4. Il cuore. Scrivi un cuore con due cubiche nella sintassi dei percorsi e disegnalo con Geometry.Parse.
  5. La stella che si svuota. Disegna una stella unendo i vertici nell'ordine 0, 2, 4, 1, 3. Che cosa cambia con EvenOdd e con NonZero?

Per approfondire: «Solo in codice», volume 3, capitoli III.1–III.3.

26. Colori, pennelli, trasformazioni e ritagli

Cosa imparerai: i colori e le tavolozze; i pennelli pieni e a gradiente, e perché un pennello immutabile costa meno; che cosa diventa un colore trasparente. Poi come si sposta, gira e ingrandisce un disegno senza ricalcolarne i punti, i ritagli, l'opacità di un gruppo, e perché ogni stato si toglie con un using.

26.1 Il colore e le tavolozze

Un Color è fatto di quattro byte: A (alfa, l'opacità: 0 trasparente, 255 pieno), R, G e B. Lo stesso blu si scrive Color.FromRgb(30, 144, 255), Color.FromUInt32(0xFF1E90FF), Color.Parse("#1E90FF") o Colors.DodgerBlue. Parse legge anche i nomi e le forme corte come #F80; con una stringa sbagliata getta un'eccezione, e per un colore che viene da un file si usa Color.TryParse.

RGB dice quanta luce mettere in ogni canale, ma non è il modo in cui si pensa un colore. HSL lo descrive con la tinta H (un angolo: 0 il rosso, 120 il verde, 240 il blu), la saturazione S e la luce L, da 0 a 1; HslColor.FromHsl(h, s, l).ToRgb() lo porta in RGB.

I colori e gli spazi HSL e HSV
I colori e gli spazi HSL e HSV

HSL è il modo più semplice di fare una tavolozza: S e L fissi e H ogni 45 gradi danno colori diversi «dello stesso peso» per le serie di un grafico; H fissa e L che cambia danno le sfumature di un colore solo. Con un limite: a parità di L il giallo sembra molto più chiaro del blu, e si corregge a occhio. I colori del tema, invece, non si inventano: si leggono dalle risorse, come nel capitolo 17.

26.2 Pennelli mutabili e immutabili

Il pennello più semplice è SolidColorBrush, un colore solo dappertutto, e ce ne sono due specie. Il mutabile, new SolidColorBrush(colore), ha un colore che si può cambiare dopo, e il compositore segue il cambio da solo, senza un nuovo Render. L'immutabile, ImmutableSolidColorBrush, riceve il colore nel costruttore e non lo cambia più; lo sono quelli di Brushes e quelli che restituisce ToImmutable(). La comodità del primo ha un prezzo, misurato creandone centomila:

PennelloMemoriaTempo per 100.000
SolidColorBrush (mutabile)616 byte150–160 ms
ImmutableSolidColorBrush64 byte1,5–1,7 ms

Da qui la regola: nel Render non si crea un pennello mutabile. I colori fissi vengono da Brushes o da campi static readonly immutabili; un colore che viene da una proprietà diventa pennello quando la proprietà cambia, non a ogni disegno.

Un colore semitrasparente non ha un aspetto suo: quello che si vede è una media fra il colore e quello che c'è sotto, pesata con l'alfa. Lo stesso blu con alfa 64 è un azzurro pallido sul tema chiaro e un blu notte su quello scuro. Per un evidenziatore va bene; per un colore che deve dire qualcosa, come il rosso di un errore, meglio un colore pieno scelto per ogni tema.

26.3 I gradienti

Un LinearGradientBrush cambia colore lungo una retta, da StartPoint a EndPoint, passando per le fermate, ognuna con un colore e un Offset da 0 a 1:

// da sinistra a destra: cremisi, oro a metà, blu
private static readonly IBrush Sfumato = new LinearGradientBrush {
  StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
  EndPoint = new RelativePoint(1, 0, RelativeUnit.Relative),
  GradientStops = {
    new GradientStop(Colors.Crimson, 0),
    new GradientStop(Colors.Gold, 0.5),
    new GradientStop(Colors.DodgerBlue, 1),
  },
}.ToImmutable();

Con RelativeUnit.Relative i punti sono frazioni del rettangolo che si riempie, e il gradiente segue la forma; con Absolute sono coordinate del controllo, e restano ferme se la forma si sposta. Un inciampo: RelativePoint.Parse("1,0") dà un punto assoluto, a un pixel dall'origine, e il gradiente non si vede; le frazioni si scrivono "100%,0%". Avalonia mescola i numeri RGB, non la luce: a metà fra il cremisi e il blu esce un viola spento, e quando il passaggio diventa fangoso si aggiunge una fermata in mezzo, come l'oro dell'esempio.

I gradienti lineari
I gradienti lineari

Il RadialGradientBrush va da un punto verso un'ellisse: spostando solo GradientOrigin si ottiene il riflesso di luce su una sfera. Il ConicGradientBrush cambia colore girando attorno al centro, partendo dall'alto in senso orario. E ImageBrush e VisualBrush riempiono con un'immagine o con il disegno di un controllo, anche ripetuti come piastrelle.

26.4 Le trasformazioni

Nel capitolo 25 ogni figura aveva le sue coordinate scritte per intero. Il DrawingContext offre un'altra strada: la figura ha sempre le stesse coordinate, e quello che cambia è il sistema in cui la si disegna. Il contesto ha uno stato (una trasformazione, un ritaglio, un'opacità), e i metodi Push... lo cambiano fino alla fine del using:

Matrix gira = Matrix.CreateRotation(Matrix.ToRadians(30));
using (dc.PushTransform(gira * Matrix.CreateTranslation(150, 0))) {
  // la casetta di sempre, con le sue coordinate di sempre
  Disegni.Casetta(dc);
}

Le quattro trasformazioni di base sono metodi di Matrix: CreateTranslation sposta, CreateRotation gira attorno all'origine, CreateScale ingrandisce, CreateSkew inclina. Gli angoli sono in radianti, e con la y verso il basso un angolo positivo gira in senso orario.

Ruota-poi-trasla e trasla-poi-ruota
Ruota-poi-trasla e trasla-poi-ruota

L'ordine conta. A sinistra, Ruota * Trasla: la casetta gira sul posto e poi va a destra. A destra, Trasla * Ruota: va prima a destra e poi gira attorno all'origine, percorrendo un arco. In A * B agisce prima A; fra due PushTransform annidati agisce prima quello di dentro, così il codice di dentro disegna nelle coordinate che il codice di fuori gli ha preparato.

Per girare una figura attorno al suo centro c'è Matrix.CreateRotation(angolo, centro). La strada del ritorno la fa Invert(): serve ogni volta che un punto arriva già trasformato, come un clic del mouse, e bisogna sapere dove è caduto nella figura.

Anche un controllo intero si trasforma, con RenderTransform (qui gli angoli sono in gradi, e il perno è il centro del controllo). Ma RenderTransform agisce dopo il layout: un pulsante girato occupa il posto di prima e copre i vicini. Per trasformare prima del layout, e far fare posto ai vicini, c'è LayoutTransformControl.

26.5 Ritagli, opacità e i using

Un ritaglio limita i pixel che il disegno può toccare: PushClip con un rettangolo, anche arrotondato, PushGeometryClip con una forma qualsiasi. Disegnare largo e ritagliare è spesso più semplice che calcolare dove ogni linea incontra il bordo. I controlli hanno anche ClipToBounds, che parte falso per Canvas e StackPanel: per un Canvas che fa da tavolo da disegno conviene metterlo a vero, o le figure trascinate fuori disegnano sui vicini.

I ritagli
I ritagli

PushOpacity nasconde una sorpresa: due cerchi sovrapposti, con un PushOpacity(0.5) attorno a tutti e due, lasciano vedere il primo attraverso il secondo. L'opacità vale figura per figura, non per il gruppo. Per un gruppo trasparente come una cosa sola serve un livello, che si chiede mettendo prima un PushRenderOptions(new RenderOptions { RequiresFullOpacityHandling = true }).

Gli stati del DrawingContext sono una pila, e il using li toglie da solo nell'ordine giusto, anche se parte un'eccezione. Senza, togliere gli stati in ordine sbagliato getta un'InvalidOperationException; dimenticarne uno è peggio: sullo schermo non succede niente, ma lo stesso controllo salvato in una bitmap, come nel capitolo 30, getta l'eccezione. La regola: ogni Push ha il suo using.

Esercizi

  1. Una tavolozza per otto serie. Scrivi un metodo che restituisce otto pennelli immutabili con la tinta ogni 45 gradi. Quali si leggono male sullo sfondo chiaro e su quello scuro?
  2. La sfera. Con un RadialGradientBrush disegna una sfera illuminata dall'alto a sinistra. Poi falla illuminare da destra cambiando un numero solo.
  3. La girandola senza trigonometria. Disegna dodici volte lo stesso raggio, da (0, 0) a (r, 0), dentro un PushTransform che sposta nel centro e gira di i dodicesimi di giro.
  4. Il clic nella casetta. Disegna una casetta girata attorno al suo centro e porta il punto del clic nelle sue coordinate con Invert().

Per approfondire: «Solo in codice», volume 3, capitoli III.4–III.5.

27. Il controllo che disegna e i controlli personalizzati

Cosa imparerai: come si costruisce bene un controllo che disegna: le proprietà di Avalonia, il costruttore statico con AffectsRender e AffectsMeasure, la misura desiderata, il disegno che segue i Bounds, il puntatore, i colori del tema, e perché si prepara tutto una volta. Poi le tre strade per un controllo nuovo, e la più ricca: il TemplatedControl, con il template scritto in C#.

È il capitolo centrale della parte. Un controllo vero vive in mezzo agli altri: lo si mette in uno StackPanel o in una cella, se ne cambiano le proprietà da codice o con un legame, lo si usa con misure diverse, nel tema chiaro e in quello scuro. Questo capitolo prende un controllo che disegna e lo porta a quel punto.

27.1 Il quadro dei manometri

Il programma di esempio è un quadro di manometri: quattro quadranti della stessa classe Manometro, di misure diverse, e un cursore che li muove insieme.

Il quadro dei manometri
Il quadro dei manometri

Il manometro grande sta in una cella e cresce con la finestra; gli altri chiedono un diametro e sono grandi quanto chiedono. Il colore della lancetta è una proprietà, e chi non la imposta prende l'accento del tema. La rotella sopra un manometro muove solo quello. Accanto, per confronto, c'è una Spia: un controllo che disegna fatto in fretta.

27.2 Le proprietà e il costruttore statico

Le proprietà di un controllo che disegna si registrano come proprietà di Avalonia, come nel capitolo 15: così si possono legare, animare, cambiare con uno stile, e soprattutto Avalonia sa quando cambiano.

internal sealed class Manometro : Control {

  /// <summary>Il valore indicato dalla lancetta.</summary>
  public static readonly StyledProperty<double> ValoreProperty =
    AvaloniaProperty.Register<Manometro, double>(nameof(Valore));

  /// <summary>Il diametro desiderato, se lo spazio c'è.</summary>
  public static readonly StyledProperty<double> DiametroProperty =
    AvaloniaProperty.Register<Manometro, double>(nameof(Diametro), 120);

  /// <summary>Che cosa cambia il disegno e che cosa la misura.</summary>
  static Manometro() {
    AffectsRender<Manometro>(ValoreProperty, MinimoProperty,
      MassimoProperty, ColoreProperty, TitoloProperty);
    AffectsMeasure<Manometro>(DiametroProperty);
  }

  /// <summary>Il valore indicato dalla lancetta.</summary>
  public double Valore {
    get => GetValue(ValoreProperty);
    set => SetValue(ValoreProperty, value);
  }

  // ... Minimo, Massimo, Colore, Titolo e Diametro allo stesso modo
}

Registrare la proprietà non basta: Avalonia sa che è cambiata, non sa che cosa fare. Glielo dice il costruttore statico, una volta per tutta la classe. AffectsRender elenca le proprietà che cambiano solo il disegno: al loro cambio il controllo si segna, e al fotogramma dopo arriva Render. AffectsMeasure elenca quelle che cambiano lo spazio chiesto: il controllo viene rimisurato, ridisposto e ridisegnato.

Che cosa provoca un cambio
Che cosa provoca un cambio

Contare le chiamate insegna molto. Un cambio di Valore dà un Render e nessuna misura; cento cambi di fila ne danno ancora uno; lo stesso valore riscritto non dà niente. Un cambio di Diametro dà misura, disposizione e disegno. La Spia invece ha la proprietà Accesa registrata senza AffectsRender: accesa, conta zero Render, e resta disegnata spenta. Nessun errore: la proprietà ha il valore nuovo, il disegno no. Lo stesso succede con una proprietà C# normale, che per Avalonia è invisibile.

27.3 La misura desiderata

Il layout (capitolo 16) chiede a ogni controllo quanto spazio vuole, con MeasureOverride, poi gli dice quanto spazio ha, con ArrangeOverride. Se un controllo senza figli non risponde, risponde Control, e la sua risposta è 0 × 0. La spia lo mostra:

ContenitoreSpia (la misura di Control)Manometro
StackPanel verticale, largo 420420 × 0120 × 120
StackPanel orizzontale, alto 3200 × 320120 × 120
cella di una Grid 300 × 200300 × 200200 × 200

Nello StackPanel la spia sparisce: lo StackPanel le dà nella sua direzione esattamente quello che chiede, cioè niente. È l'inciampo classico del controllo che disegna: provato in una griglia funziona, messo in un elenco scompare, senza errori. Il manometro risponde così:

/// <summary>Un quadrato di lato Diametro, se lo spazio c'è.</summary>
protected override Size MeasureOverride(Size disponibile) {
  // in uno StackPanel lo spazio è Infinity: vince il diametro
  double lato = Math.Min(Diametro,
    Math.Min(disponibile.Width, disponibile.Height));
  return new Size(lato, lato);
}

/// <summary>Quadrato anche in uno spazio più largo che alto.</summary>
protected override Size ArrangeOverride(Size finale) {
  double lato = Math.Min(finale.Width, finale.Height);
  return new Size(lato, lato);
}

In uno StackPanel lo spazio nella direzione dell'elenco è infinito. Restituirlo così com'è ferma il programma con un'InvalidOperationException, «Invalid size returned for Measure»; Math.Min lo evita da sé. La misura restituita da ArrangeOverride, con l'allineamento predefinito, viene centrata nello spazio dato: per questo il manometro grande sta nel mezzo della sua cella.

27.4 Il disegno che segue i Bounds

Il Render del manometro non conosce nessuna misura fissa:

/// <summary>Il quadrante: fondo, scala, numeri, lancetta, valore.</summary>
public override void Render(DrawingContext dc) {
  Strumenti s = Prepara();
  // il fondo trasparente fa arrivare il puntatore anche negli angoli
  dc.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
  dc.DrawEllipse(s.Fondo, s.Bordo, s.Centro, s.Raggio, s.Raggio);
  dc.DrawGeometry(null, s.Tacche, s.Scala);
  foreach ((FormattedText testo, Point dove) in s.Numeri) {
    dc.DrawText(testo, dove);
  }
  double gradi = Strumenti.Inizio + Strumenti.Ampiezza * Parte();
  dc.DrawLine(s.Lancetta, s.Centro,
              Strumenti.Punto(s.Centro, s.Raggio * 0.80, gradi));
  // ... il perno e il testo del valore
}

Il centro è la metà di Bounds.Size, il raggio la metà del lato più corto, e tutto il resto è una frazione del raggio: le tacche, i numeri, la lancetta all'80%, gli spessori e i corpi dei testi. Così lo stesso codice disegna il quadrante grande e i piccoli con le stesse proporzioni. Bounds.X e Bounds.Y invece sono la posizione nel genitore: dentro Render non servono, perché l'origine è l'angolo del controllo.

La prima riga ha una ragione. Il puntatore arriva a un controllo che disegna solo dove ha disegnato qualcosa: la spia, che disegna solo il suo cerchio, non riceve un clic nell'angolo. Il FillRectangle con Brushes.Transparent su tutti i Bounds non si vede, ma il puntatore lo trova. Al contrario, IsHitTestVisible = false lascia passare il puntatore, per i disegni che stanno sopra altri controlli. E un controllo può disegnare fuori dai suoi Bounds: ClipToBounds parte falso, e un titolo troppo lungo finisce sui vicini.

27.5 Il tema, e prepararsi una volta

Il manometro legge i colori del quadrante dal tema in uso con ThemeBrush di RAVAL, e nel costruttore si iscrive con this.OnThemeChanged(Rinnova): al cambio di tema butta gli strumenti e chiama InvalidateVisual. Avalonia non ridisegna da sola un controllo quando cambia il tema: la spia, che non si è iscritta, riceve zero Render.

Il capitolo 25 ha messo le penne fisse in campi static readonly. Il manometro non può: lo spessore delle sue penne dipende dalla misura, il colore dalla proprietà e dal tema. Però niente di questo dipende dal valore, che è la cosa che cambia di continuo. Il manometro allora prepara penne, geometria della scala e numeri in un oggetto, Strumenti, e li butta solo quando cambia qualcosa da cui dipendono:

/// <summary>Via gli strumenti se cambia ciò da cui dipendono.</summary>
protected override void OnPropertyChanged(
    AvaloniaPropertyChangedEventArgs e) {
  base.OnPropertyChanged(e);
  if (e.Property == MinimoProperty || e.Property == MassimoProperty
      || e.Property == ColoreProperty || e.Property == TitoloProperty) {
    _strumenti = null;
  } else if (e.Property == BoundsProperty
      && e.GetOldValue<Rect>().Size != e.GetNewValue<Rect>().Size) {
    // solo se cambia la misura, non il posto
    _strumenti = null;
  }
}

Prepara, la prima riga di Render, li rifà solo se mancano. Rifare tutto a ogni Render costa circa il doppio del tempo e il 50% di memoria in più. Per quattro manometri non si vede, per cento sì; e la regola non costa niente: in Render si crea solo quello che dipende da ciò che cambia a ogni disegno.

27.6 Tre strade per un controllo nuovo

Le tre strade per un controllo nuovo
Le tre strade per un controllo nuovo
  • UserControl compone controlli che ci sono già. È la strada più corta, quella giusta per un pezzo di vista (capitolo 21); ma il suo aspetto è il suo contenuto, e chi lo usa non può cambiarlo.
  • Control con Render disegna da sé, come il manometro: grafici, strumenti, tele con migliaia di elementi.
  • TemplatedControl ha proprietà e comportamento, e l'aspetto glielo dà un template, un pezzo di albero di controlli che uno stile può sostituire senza toccare la classe. Ogni Button, CheckBox e Slider del tema Fluent è fatto così.

27.7 Il TemplatedControl: la lampada

La Lampada dell'esempio deriva da TemplatedControl e sa due cose, registrate come proprietà di Avalonia: se è Accesa e che Etichetta ha. Non sa che aspetto ha. Eredita Background, BorderBrush, Padding e le altre, che non usa: le tiene perché il template le usi. Senza template non mostra niente, misura 0 × 0.

In C# un template è un FuncControlTemplate<T>: una funzione che riceve il controllo e un ambito dei nomi, e restituisce la radice dei controlli che la lampada mostrerà. Avalonia la chiama per ogni lampada, quando il template si applica:

/// <summary>La lampada tonda, con il suo interruttore.</summary>
public static readonly FuncControlTemplate<Lampada> Tonda =
  new(CostruisciTonda);

// il bulbo e l'interruttore, uno sotto l'altro
private static Control CostruisciTonda(Lampada lampada, INameScope nomi) {
  var bulbo = new Ellipse { Name = "PART_Bulbo", Width = 64, Height = 64 };
  bulbo.Bind(Shape.FillProperty,
    new TemplateBinding(TemplatedControl.BackgroundProperty));
  // con la priorità Template, uno stile la può cambiare
  bulbo.SetValue(Shape.StrokeThicknessProperty, 3.0,
                 BindingPriority.Template);
  bulbo.RegisterInNameScope(nomi);
  var interruttore = new Button {
    Name = "PART_Interruttore",
    Content = "Accendi / spegni",
  };
  // senza, la lampada non lo trova
  interruttore.RegisterInNameScope(nomi);
  return new StackPanel { Children = { bulbo, interruttore } };
}

TemplateBinding lega una proprietà di un controllo del template a una della lampada: il bulbo ha il colore di Background, e lo segue quando cambia. Lo spessore è scritto con BindingPriority.Template: scritto con un semplice = sarebbe un valore locale, che vince sugli stili, e nessuno stile potrebbe più cambiarlo. I nomi con PART_ sono la convenzione di Avalonia per le parti che il codice del controllo cerca.

27.8 Le parti con nome, lo stato, gli stili

La lampada trova l'interruttore in OnApplyTemplate, che Avalonia chiama ogni volta che un template si applica:

/// <summary>Un template nuovo: le sue parti, cercate per nome.</summary>
protected override void OnApplyTemplate(TemplateAppliedEventArgs e) {
  base.OnApplyTemplate(e);
  // ci si stacca dall'interruttore del template di prima
  if (_interruttore != null) _interruttore.Click -= Premuto;
  _interruttore = e.NameScope.Find<Button>("PART_Interruttore");
  // un template senza interruttore non è un errore
  if (_interruttore != null) _interruttore.Click += Premuto;
}

Tre regole, tutte nel codice. Il nome non basta: senza RegisterInNameScope, Find restituisce null senza avvisi. La parte può mancare, e un aspetto senza interruttore non deve rompere la lampada. Il template può cambiare, e prima di prendere la parte nuova ci si stacca dalla vecchia.

Che aspetto abbia una lampada accesa non lo decide la lampada. Lei dice soltanto che è accesa, con una pseudoclasse, in OnPropertyChanged:

if (change.Property == AccesaProperty) {
  PseudoClasses.Set(":accesa", Accesa);
}

È il meccanismo con cui un Button dice :pressed. Gli stili fanno il resto: lo stile Lampada dà il template tondo e i colori da spenta, lo stile Lampada:accesa cambia Background, e il bulbo lo segue con il suo TemplateBinding. Né la lampada né il template contengono un colore da accesa.

Template stesso è una proprietà come le altre: uno stile Lampada.barra dà alle lampade con la classe barra un secondo template, una barra con l'etichetta dentro e senza interruttore, e Avalonia lo ricostruisce a ogni cambio.

Due aspetti della lampada, spenti e accesi
Due aspetti della lampada, spenti e accesi

Per questo il template si dà con uno stile e non nel costruttore: messo nel codice sarebbe un valore locale, e nessuno stile potrebbe più sostituirlo. Allo stesso modo si sostituisce il template di un controllo di Avalonia: un Button ridisegnato tiene il comportamento, il fuoco e le pseudoclassi, ma perde quello che il tema faceva dentro il suo template, come il colore al passaggio del mouse, da rimettere con stili nostri per :pointerover e :pressed.

Esercizi

  1. La spia ben fatta. Correggi la Spia: AffectsRender per Accesa e una misura desiderata di 24 × 24. Mettila in uno StackPanel e verifica che non sparisca più.
  2. La zona rossa. Aggiungi al manometro una proprietà Soglia, con un arco rosso sopra la soglia. Quale Affects le serve? Va negli strumenti preparati o nel Render?
  3. Il clic sulla scala. Con OnPointerPressed porta la lancetta al punto cliccato, con Math.Atan2.
  4. Il terzo aspetto. Scrivi un template piccolo per la lampada, senza etichetta, e uno stile Lampada.spia che lo sceglie.
  5. Il bordo locale. Togli BindingPriority.Template dallo spessore del bulbo: uno stile riesce ancora a cambiarlo?

Per approfondire: «Solo in codice», volume 3, capitoli III.6–III.7.

28. Immagini e testo grafico

Cosa imparerai: come si carica una bitmap da un file o da una risorsa e la si disegna, intera o a pezzi; che cosa fanno i modi di interpolazione; come si anima un foglio di sprite. Poi il testo dentro Render: FormattedText e le sue misure, come si allinea, si manda a capo e si taglia, e il testo come geometria.

28.1 Caricare e disegnare una bitmap

Un'immagine si può portare con il programma come file accanto all'eseguibile, o come risorsa dentro l'eseguibile stesso, dove nessuno la può perdere. Le risorse si dichiarano nel file del progetto con <AvaloniaResource Include="Immagini\*.png" />, e si aprono con un indirizzo avares://, il nome dell'assembly e il percorso nel progetto:

// il foglio degli sprite: caricato una volta, alla prima richiesta
private static readonly Bitmap Foglio = Carica("Immagini/sprite.png");

// una bitmap compilata nell'eseguibile, con l'indirizzo avares://
private static Bitmap Carica(string percorso) {
  var indirizzo = new Uri("avares://AlbumSprite/" + percorso);
  using Stream flusso = AssetLoader.Open(indirizzo);
  return new Bitmap(flusso);
}

Per un file basta new Bitmap(percorso). Le bitmap stanno in campi: caricarle in Render vorrebbe dire decodificare un PNG a ogni disegno. Negli indirizzi le maiuscole contano, anche se Windows con i file non fa differenza, e una risorsa che manca dà l'eccezione solo all'avvio, non alla compilazione.

Il DrawingContext ha due metodi: DrawImage(immagine, destinazione) disegna l'immagine intera, stirata nel rettangolo dato; DrawImage(immagine, sorgente, destinazione) ne disegna solo un pezzo. DrawImage non tiene le proporzioni: un'immagine quadrata in un rettangolo largo si deforma, e la destinazione giusta si calcola a mano.

28.2 L'interpolazione

Quando un'immagine si disegna più grande o più piccola dei suoi pixel, Skia deve decidere il colore di ogni pixel guardando quelli vicini: è l'interpolazione. Il modo si sceglie con RenderOptions.SetBitmapInterpolationMode su un controllo, o con PushRenderOptions in Render. None prende il pixel più vicino; LowQuality, il predefinito, mescola i quattro vicini; MediumQuality e HighQuality, rimpicciolendo, usano le mipmap, copie dell'immagine già dimezzate, e HighQuality ingrandendo guarda sedici pixel.

La pagina «Interpolazione»
La pagina «Interpolazione»

Ingrandita, l'icona con None mostra i pixel quadrati e netti, con HighQuality diventa morbida. Rimpicciolita, l'immagine dei cento anelli con None e LowQuality si riempie di cerchi falsi che nell'immagine non ci sono: l'aliasing. Le mipmap lo tolgono, ma in software costano da cinque a dieci volte di più. In breve: None per la grafica a pixel, il predefinito per le immagini quasi alla loro misura, MediumQuality o HighQuality per le foto rimpicciolite.

Per le icone a uno o due colori, che devono stare bene a misure diverse, una geometria batte la bitmap: il PathIcon di Avalonia la stira alla sua misura ed è nitido a 16 come a 96, mentre una bitmap di 16 pixel è già sfocata a 32.

28.3 I fogli di sprite

Uno sprite è una piccola immagine che si muove: un personaggio, una pallina che salta. Le sue pose stanno in una sola immagine, il foglio di sprite, in una griglia di fotogrammi della stessa misura.

Dal tempo al pezzo del foglio
Dal tempo al pezzo del foglio

Il fotogramma da mostrare si sceglie dall'ora, come l'angolo della girandola:

// dodici fotogrammi dello sprite al secondo, otto in tutto
int n = (int)(_ora.TotalSeconds * 12) % 8;
// quattro colonne e due righe di quadrati da 64
var sorgente = new Rect(n % 4 * 64, n / 4 * 64, 64, 64);
var pixel = new RenderOptions {
  BitmapInterpolationMode = BitmapInterpolationMode.None,
};
using (dc.PushRenderOptions(pixel)) {
  // ritagliato e ingrandito tre volte in un colpo solo
  dc.DrawImage(Foglio, sorgente, new Rect(20, 20, 192, 192));
}

Con uno schermo più veloce la pallina salterebbe alla stessa velocità. Un inciampo sul bordo: un pezzo ingrandito con il bilineare guarda anche i pixel subito fuori, cioè il fotogramma accanto, che entra come una frangia. La cura è lasciare uno o due pixel trasparenti fra i fotogrammi del foglio.

28.4 Il testo e le sue misure

Dentro Render non ci sono controlli, e il testo lo si scrive da sé con FormattedText e dc.DrawText:

var t = new FormattedText("Perché sì?", new CultureInfo("it-IT"),
  FlowDirection.LeftToRight, new Typeface("Segoe UI"), 64,
  Brushes.Black);
// centrato sulla guida x, poggiato sulla linea di base y
dc.DrawText(t, new Point(x - t.Width / 2, y - t.Baseline));

DrawText mette nel punto l'angolo in alto a sinistra di un rettangolo, non la linea su cui poggiano le lettere. Che cosa c'è nel rettangolo lo dicono le misure:

Le misure di un FormattedText e tre modi di allineare
Le misure di un FormattedText e tre modi di allineare

Width è la larghezza delle lettere senza gli spazi finali; Height è l'altezza della riga, che viene dal carattere e non dalle lettere; Baseline è la distanza dall'alto alla linea di base. Allineare è un conto: centrato in x - Width / 2, a destra in x - Width, e scritte di dimensioni diverse sulla stessa riga ognuna in y - Baseline. Con la stessa y sarebbero allineate le teste, e le linee di base salterebbero.

28.5 A capo, caratteri, testo come forma

Un FormattedText senza limiti è una riga sola. MaxTextWidth va a capo fra le parole, MaxTextHeight e MaxLineCount tengono le righe intere che ci stanno, e Trimming segna il taglio con «…». Due sorprese: la giustificazione è ignorata, e serve TextLayout; e una parola più lunga del limite, con il taglio predefinito, sparisce, sostituita da «…». Per un testo dell'utente, come il nome di un file, si scrive Trimming = TextTrimming.None.

Un Typeface chiede un carattere, anche con una lista di ripieghi, "Cascadia Mono, Consolas". Una famiglia che non c'è non dà errori: arriva quella predefinita. E monospace non è un nome che Avalonia riconosca: per un ripiego a spaziatura fissa si scrive un carattere vero, Consolas su Windows, Menlo su macOS, DejaVu Sans Mono su Linux.

TextLayout fa lo stesso lavoro e in più dice dove sta ogni carattere (HitTestTextPosition) e quale carattere c'è in un punto (HitTestPoint): è quello che serve per un cursore o una selezione. BuildGeometry trasforma il testo nella geometria dei suoi glifi, da riempire con un gradiente, contornare, usare come ritaglio. Con le due cose insieme si scrive perfino un testo lungo una curva, una lettera alla volta.

Il testo lungo una curva
Il testo lungo una curva

Su una bitmap trasparente, infine, il testo va disegnato in grigi, con PushTextOptions e TextRenderingMode.Antialias: il modo predefinito usa i colori dei sottopixel e, senza un fondo pieno, lascia frange colorate.

Esercizi

  1. Il foglio con i bordi. Prepara un foglio di sprite con un pixel trasparente attorno a ogni fotogramma e adatta il calcolo della sorgente.
  2. Lo sprite al contrario. Fai saltare la pallina all'indietro cambiando solo il calcolo del numero.
  3. L'etichetta con lo sfondo. Scrivi un metodo che disegna un testo in un rettangolo arrotondato, centrato su un punto.
  4. Le scale di un grafico. Disegna un asse verticale con le etichette da 0 a 100, allineate a destra contro l'asse e centrate sulla tacca.

Per approfondire: «Solo in codice», volume 3, capitoli III.8–III.9.

29. Coordinate, tele interattive e grafici

Cosa imparerai: come si passa un punto da un controllo all'altro e dal mouse al controllo; un sistema di coordinate proprio, in metri con la y verso l'alto, con lo zoom attorno al puntatore; una tela dove le forme si scelgono, si spostano e si annullano; e un grafico vero, con scale, tacche belle e cursore: la base di RChart.

29.1 Da un sistema all'altro

Ogni controllo ha il suo sistema locale, e Bounds dice dove sta nel genitore, non nella finestra. Sopra c'è la finestra, sopra ancora lo schermo, in pixel veri. E un programma ne aggiunge quasi sempre uno suo, il mondo: i metri di una pianta, i valori di un grafico.

I sistemi di coordinate e le funzioni che li collegano
I sistemi di coordinate e le funzioni che li collegano

TranslatePoint(punto, altro) porta un punto da un controllo a un altro, sommando margini, bordi e trasformazioni, e restituisce null se i due non stanno nello stesso albero; PointToScreen e PointToClient passano dallo schermo. Il puntatore non ha bisogno di conversioni: e.GetPosition(this) lo dà già nel sistema del controllo. Un inciampo misurato in Avalonia 12: fra due controlli fratelli, uno dei quali girato, TranslatePoint sbaglia di qualche pixel; la strada sicura passa dalla finestra, in due passi.

29.2 Il mondo e lo zoom

I dati veri hanno quasi sempre la y verso l'alto. Il programma di esempio è il piano quotato di un appartamento di 12 per 8 metri, con la rotella che ingrandisce attorno al puntatore e il trascinamento che scorre.

Il piano quotato
Il piano quotato

Il piano non tiene una matrice da moltiplicare a ogni zoom, ma tre numeri: il punto del mondo al centro della tavola, la scala (quante unità fa un metro) e la misura della tavola. La matrice si ricava ogni volta:

/// <summary>La matrice dal mondo alla tavola.</summary>
public Matrix MondoSchermo =>
  // il centro va nell'origine
  Matrix.CreateTranslation(-Centro.X, -Centro.Y)
  // i metri diventano unità, e la y si capovolge
  * Matrix.CreateScale(Scala, -Scala)
  // l'origine va al centro della tavola
  * Matrix.CreateTranslation(Misura.Width / 2, Misura.Height / 2);

/// <summary>Un punto del mondo, nella tavola.</summary>
public Point ASchermo(Point m) => MondoSchermo.Transform(m);

/// <summary>Un punto della tavola, nel mondo.</summary>
public Point AlMondo(Point s) => MondoSchermo.Invert().Transform(s);

/// <summary>La scala cambia, il punto sotto il puntatore no.</summary>
public void ZoomAttorno(Point schermo, double fattore) {
  Point fermo = AlMondo(schermo);
  Scala = Math.Clamp(Scala * fattore, ScalaMinima, ScalaMassima);
  Point dove = ASchermo(fermo);
  // si scorre finché il punto torna sotto il puntatore
  Centro = new Point(Centro.X - (schermo.X - dove.X) / Scala,
                     Centro.Y + (schermo.Y - dove.Y) / Scala);
}

Ricavare la matrice da tre numeri evita che gli errori di arrotondamento si accumulino, e quando la finestra cambia misura il centro resta al centro. La scala ha un minimo, mai zero, così la matrice ha sempre l'inversa. Lo zoom attorno al puntatore prende il punto del mondo sotto il mouse, cambia la scala, guarda dove è finito quel punto e scorre il disegno di quanto serve a riportarlo lì: senza quest'ultimo passo il disegno scapperebbe verso il centro.

Dentro la matrice, con PushTransform, le stanze si scrivono in metri; ma la matrice scala anche gli spessori, e una linea che deve restare sottile ha la penna spessa 1 / Scala. Il testo invece si scrive fuori, portando nella tavola solo il punto con ASchermo: dentro la matrice uscirebbe capovolto e crescerebbe con lo zoom. E un'avvertenza per le carte geografiche: Skia lavora in float, con sette cifre, e coordinate da milioni di metri disegnate dentro la matrice escono spostate e deformate. I conti fra mondo e tavola si fanno nel programma, in double.

29.3 La tela interattiva

Una tela dove le forme si prendono con il mouse è il cuore di ogni programma di disegno e di ogni schema. La regola prima è che le forme non sono controlli: sono oggetti di un modello che non usa Avalonia, in un elenco nell'ordine in cui si disegnano. Un modello così si salva, si prova senza finestra e si annulla; la tela resta un controllo solo. Per ogni forma la tela prepara una volta la geometria e le penne, e le rifà solo quando la forma cambia, come gli strumenti del manometro.

La domanda di ogni clic è «quale forma c'è sotto questo punto?», il hit test:

/// <summary>La forma sotto il punto, o null.</summary>
public static Forma? Sotto(IReadOnlyList<Forma> forme, Sagome sagome,
                           Point punto) {
  // dall'ultima alla prima: l'ultima disegnata è quella in cima
  for (int i = forme.Count - 1; i >= 0; i--) {
    Sagoma s = sagome.Di(forme[i]);
    // lontana dal suo rettangolo: niente conti
    if (!s.Riquadro.Contains(punto)) continue;
    if (s.Riempimento != null && s.Geometria.FillContains(punto)) {
      return forme[i];
    }
    if (s.Geometria.StrokeContains(s.PennaTocco, punto)) return forme[i];
  }
  return null;
}

Dall'ultima alla prima, perché quella disegnata per ultima è quella che si vede. Prima il rettangolo, che costa quasi niente. Il bordo si prova con una penna del tocco più larga di quella del disegno, quattro pixel per lato, perché una linea larga un pixel, con il mouse, non la prende nessuno. Ma attenzione all'estremità: con quella predefinita, Flat, la penna del tocco non arriva oltre la punta, e una linea si prende di lato ma non dalla fine; la penna del tocco vuole LineCap = PenLineCap.Round.

La mappa del hit test
La mappa del hit test

La selezione segue le regole di tutti i programmi di disegno: il clic sceglie, Ctrl+clic aggiunge o toglie, il clic nel vuoto tirato diventa un rettangolo che sceglie le forme che ci stanno dentro. Per spostare servono due accorgimenti: una soglia di qualche pixel prima che il gesto parta, perché una mano che clicca trema; e lo scarto, cioè la forma alla sua posizione di partenza più lo spostamento dal punto preso, così resta sotto il mouse dove la si è presa. Le otto maniglie del riquadro la ridimensionano, ognuna con il suo cursore. E per tutta la tela: si ridisegna quando il disegno cambia, non quando arriva un evento.

29.4 Annulla e ripeti

Il modo più diffuso di fare annulla e ripeti è il modello a comandi: ogni cambiamento è un oggetto che sa farsi e disfarsi.

internal interface IComando {
  string Nome { get; }
  void Esegui();
  void Annulla();
}

Un comando ricorda valori, non differenze (il riquadro di prima e quello di dopo), e annulla rimette il disegno esattamente com'era, anche l'ordine delle forme. I comandi vivono in una storia con due pile: fare un'azione la mette sulla pila annulla, Ctrl+Z la sposta su ripeti, Ctrl+Y la riporta indietro, e un'azione nuova svuota la pila ripeti. Un trascinamento è un passo solo: al rilascio si registra un comando già fatto. Più forme spostate insieme stanno in un composto, che annulla le sue parti dall'ultima alla prima.

I comandi e le due pile
I comandi e le due pile

29.5 Lo Schema dei diagrammi

Il modello e la tela hanno un fratello piccolo in questo libro. I diagrammi delle figure non sono fatti con un programma di disegno: li disegna un controllo, Schema, che sta negli esempi di RAVAL.

var s = new Schema();
int a = s.Riquadro("Program.Main", 20, 20, 160, 60, Schema.Blu);
int b = s.Riquadro("App", 260, 20, 160, 60, Schema.Verde);
s.Freccia(a, b, "avvia");
Un diagramma disegnato dallo Schema
Un diagramma disegnato dallo Schema

Dentro c'è tutto quello che questa parte ha spiegato. Il modello sono quattro elenchi (riquadri, frecce, scritte, fasce). Render disegna prima le fasce, poi le frecce, poi i riquadri, che coprono le code delle frecce, e infine le scritte. Ogni riquadro è un rettangolo arrotondato con il testo misurato e centrato; ogni freccia sceglie da sé se andare in verticale o in orizzontale, e la sua punta è un triangolo costruito con una StreamGeometry. Le figure si rifanno insieme al codice e non vanno mai fuori tempo.

29.6 Il grafico: scale e tacche

Il grafico del cruscotto del capitolo 24 aveva un margine fisso e una scala fissa: sapeva che cosa avrebbe disegnato. Un grafico riusabile non lo sa, e il programma di esempio lo rende generale una decisione alla volta.

Tre funzioni, con il cursore
Tre funzioni, con il cursore

Il primo pezzo è la scala, che lega i valori ai pixel e va nei due sensi:

// una scala lineare: dal valore al pixel, e ritorno
internal sealed class Scala {
  public double Minimo { get; set; }
  public double Massimo { get; set; }
  public double Inizio { get; set; }
  public double Fine { get; set; }

  /// <summary>Il pixel del valore.</summary>
  public double APixel(double v) =>
    Inizio + (v - Minimo) / (Massimo - Minimo) * (Fine - Inizio);

  /// <summary>Il valore del pixel: serve al cursore.</summary>
  public double DaPixel(double p) =>
    Minimo + (p - Inizio) / (Fine - Inizio) * (Massimo - Minimo);
}

Per l'asse y Inizio è il pixel in basso: con Fine minore di Inizio la stessa formula capovolge l'asse. La scala logaritmica è la stessa formula sui logaritmi, e ammette solo i valori positivi.

Il secondo pezzo sono le tacche. Dividere in parti uguali dà tacche come 1,46 e 2,92, esatte e illeggibili. Si leggono a colpo d'occhio i multipli di un passo bello, 1, 2 o 5 per una potenza di dieci:

private const double Tolleranza = 1e-9;

/// <summary>Il passo bello: 1, 2 o 5 per una potenza di dieci.</summary>
public static double PassoBello(double grezzo) {
  double potenza = Math.Pow(10, Math.Floor(Math.Log10(grezzo)));
  // il resto sta fra 1 e 10
  double resto = grezzo / potenza;
  double bello;
  if (resto <= 1 + Tolleranza) bello = 1;
  else if (resto <= 2 + Tolleranza) bello = 2;
  else if (resto <= 5 + Tolleranza) bello = 5;
  else bello = 10;
  return bello * potenza;
}

Il passo grezzo è l'intervallo diviso per le tacche volute: da 0 a 7,3 con cinque tacche è 1,46, e il passo bello è

  1. Le tacche poi si calcolano come k * passo, con k intero, mai sommando il passo alla tacca prima: 0,1 sommato

cento volte vale 9,99999999999998, 100 * 0.1 vale 10.

29.7 Margini, serie, cursore

Il grafico prepara una volta, in un telaio, tutto quello che non dipende dal mouse, e lo butta solo quando cambiano i dati, la misura o il tema. I margini non sono fissi: le scritte delle tacche si compongono prima, e la più larga fa il margine sinistro. Le scritte sono all'italiana, con i decimali decisi dal passo: «0,0 0,2 0,4». Ogni serie è una sola StreamGeometry, già in pixel; un valore mancante (double.NaN) chiude la figura e ne apre un'altra, perché unire i lati del buco disegnerebbe dati che nessuno ha misurato. Le serie si disegnano dentro un PushClip sull'area, così i dati fuori scala non passano sopra le scritte.

Il cursore usa la scala al contrario: DaPixel trasforma la x del mouse in un valore, Array.BinarySearch trova per ogni serie il punto più vicino, e un riquadro ne scrive i valori. Muovere il mouse non tocca il telaio.

Quanto conta la geometria unica lo dicono le misure con centomila punti: riusata, costa al thread della finestra 0,3 millisecondi e tiene il grafico fra 30 e 52 fotogrammi al secondo; rifatta a ogni Render, o disegnata con un DrawLine per segmento, porta il grafico a sette. La regola: una geometria per serie, costruita quando cambiano i dati e riusata per tutto il resto.

29.8 Linee con l'area sfumata e torte

Due grafici più semplici mostrano quanto danno pochi strumenti. Il grafico a linee con l'area sfumata disegna due geometrie: prima la linea chiusa verso il fondo, riempita con un gradiente verticale dall'accento al trasparente; poi la linea sola, sopra. I tondini dei punti, riempiti con il colore di sfondo, sembrano bucati in tutti e due i temi.

Il grafico a linee con l'area sfumata
Il grafico a linee con l'area sfumata

Una torta è fatta di settori, e ogni settore è una geometria di tre pezzi:

// un settore: dal centro al bordo, l'arco, di nuovo al centro
private static StreamGeometry Fetta(Point centro, double raggio,
                                   double da, double ampiezza) {
  var g = new StreamGeometry();
  using (StreamGeometryContext c = g.Open()) {
    c.BeginFigure(centro, true);
    c.LineTo(SulCerchio(centro, raggio, da));
    c.ArcTo(SulCerchio(centro, raggio, da + ampiezza),
            new Size(raggio, raggio), 0, ampiezza > 180,
            SweepDirection.Clockwise);
    c.EndFigure(true);
  }
  return g;
}

// il punto all'angolo dato: 0 in alto, gradi in senso orario
private static Point SulCerchio(Point c, double r, double gradi) {
  double a = gradi * Math.PI / 180;
  return new Point(c.X + r * Math.Sin(a), c.Y - r * Math.Cos(a));
}

Gli angoli sono quelli dell'orologio: seno e coseno scambiati rispetto alla scuola, e il meno per la y che scende. isLargeArc è vero quando il settore supera mezzo giro: senza, uno spicchio di 270 gradi verrebbe disegnato sull'altro cerchio che passa per gli stessi punti. Un settore del 100%, che va da un punto a sé stesso, Avalonia non lo disegna: il giro intero si fa con due mezzi archi. E un sottile bordo del colore dello sfondo separa i settori, ma copre del tutto quelli più sottili di lui: le voci sotto l'uno per cento vanno raccolte in un «altro».

29.9 Da qui a RChart

Il grafico di questo capitolo è il fratello minore di RChart, il grafico a curve di RAVAL che il capitolo 35 presenta. Dentro c'è lo stesso lavoro: scale lineari e logaritmiche, divisioni 1, 2, 5, margini misurati sulle scritte, serie come geometrie, ritaglio sull'area, tema chiaro e scuro. In più ha i marcatori da spostare, i limiti, lo zoom, l'asse del tempo e il salvataggio a 300 dpi. Chi ha seguito questo capitolo sa che cosa succede dentro quando gli si danno le curve, e da dove cominciare se un giorno gli serve un grafico che RChart non fa.

Esercizi

  1. Zoom con la tastiera. Nel piano quotato fai ingrandire + e − attorno all'ultimo punto del puntatore.
  2. Il righello. Con due clic sul piano, mostra la distanza fra i due punti in metri.
  3. In cima. Aggiungi alla tela Pag su e Pag giù per portare le forme scelte in cima o in fondo, come comandi che si annullano.
  4. La torta cliccabile. In OnPointerPressed trova lo spicchio sotto il mouse con FillContains sulle geometrie dei settori.

Per approfondire: «Solo in codice», volume 3, capitoli III.10–III.14.

30. Esportare e disegnare veloce

Cosa imparerai: come si salva in PNG, SVG e PDF quello che un controllo disegna, alla risoluzione giusta per la stampa; perché un controllo fuori dalla finestra non disegna niente; perché i numeri di un file vogliono la cultura invariante. Poi come si misura il costo del disegno, e che cosa conviene quando gli oggetti sono migliaia.

30.1 Un modello, tre strade

Per esportare, la prima idea sarebbe fotografare il controllo. Per il PNG funziona; per l'SVG e il PDF no, perché vogliono rettangoli, curve e testo, non pixel. Il programma di esempio parte allora da un modello, una tavola con un elenco di forme, e tre traduttori lo percorrono: uno in comandi del DrawingContext, per l'anteprima e il PNG; uno in XML, per l'SVG; uno in chiamate a Skia, per il PDF. Le unità sono quelle di Avalonia, 96 per pollice, e la tavola ha la stessa misura vera nei tre file.

Lo stesso modello, tre strade, tre verifiche
Lo stesso modello, tre strade, tre verifiche

30.2 PNG

Una RenderTargetBitmap è una bitmap in cui Avalonia disegna. Vuole la misura in pixel e i dpi, che dicono quanti pixel vale un'unità: a 300 dpi, la risoluzione della stampa, 3,125.

/// <summary>La tavola in un PNG ai dpi dati.</summary>
public static void Salva(Tavola tavola, string file, double dpi) {
  var controllo = new Anteprima(tavola);
  var misura = new Size(tavola.Larghezza, tavola.Altezza);
  // fuori dalla finestra il layout si fa a mano
  controllo.Measure(misura);
  controllo.Arrange(new Rect(misura));
  double scala = dpi / 96;
  var pixel = new PixelSize((int)Math.Ceiling(misura.Width * scala),
                            (int)Math.Ceiling(misura.Height * scala));
  using var bitmap = new RenderTargetBitmap(pixel, new Vector(dpi, dpi));
  bitmap.Render(controllo);
  bitmap.Save(file, PngBitmapEncoderOptions.Default);
  // Save non scrive i dpi nel file: li aggiunge il programma
  Png.ScriviDpi(file, dpi);
}

Il controllo esportato è nuovo, fuori da ogni finestra, così il PNG non dipende da quanto è grande la finestra. Ma fuori dalla finestra nessuno fa il layout: i Bounds restano 0 × 0, e Render disegna niente, in silenzio. Per questo Measure e Arrange si chiamano a mano. Il layout non basta per tutti: un Button resta vuoto, perché il suo aspetto è un template del tema, che fuori dalla finestra non arriva. Un controllo che si disegna da solo, come quelli di questa parte, si esporta bene; uno fatto di template no.

Altre due sorprese. I dpi non entrano nel file: Save non scrive il pezzo del PNG con i pixel per pollice, e un lettore lo stampa a 96 dpi, tre volte più grande; il programma di esempio lo aggiunge dopo. E la bitmap nasce trasparente: per la stampa si disegna prima un rettangolo bianco.

30.3 SVG e la virgola decimale

L'SVG è un file XML, scritto a mano con uno StreamWriter, un elemento per forma: <rect>, <ellipse>, <path>, <text>. Il riempimento si scrive sempre, anche fill="none", perché una forma senza fill è nera.

La trappola vera è la lingua. In un computer italiano ToString() e $"{x}" scrivono i numeri con la virgola, e in SVG la virgola separa due numeri: «12,5» diventa 12 e 5. Nessun errore, il file si apre, e le forme sono sparite o stravolte. Ogni numero di un file si scrive con la cultura invariante:

// i numeri di un file sempre con il punto, qualunque sia la lingua
private static string N(double x) =>
  x.ToString("0.###", CultureInfo.InvariantCulture);

Vale per l'SVG, un CSV, un JSON scritto a mano, ogni file che un altro programma deve rileggere. E un file prodotto si verifica rileggendolo, o facendolo disegnare a un browser, non guardandolo una volta.

30.4 PDF

Il PDF lo scrive SkiaSharp, che arriva con Avalonia. SKDocument.CreatePdf apre un documento, ogni BeginPage dà una tela su cui si disegna come sullo schermo:

using SKDocument documento = SKDocument.CreatePdf(file, info);
// l'A4 in punti tipografici, 72 per pollice
SKCanvas tela = documento.BeginPage(595, 842);
// 96 unità di Avalonia sono 72 punti
tela.Scale(0.75f);
Disegna(tela, tavola);
documento.EndPage();
documento.Close();

La pagina si misura in punti, 72 per pollice, e con la scala 0,75 la tavola resta della sua misura vera; senza, esce un terzo più grande. Il testo è vettoriale, e si può cercare. Un prezzo da sapere: questa Skia incorpora i caratteri interi, e un PDF di una pagina pesa un megabyte.

30.5 Misurare il disegno

Il disegno sta su due thread (capitolo 25), e ha tre cronometri: l'intervallo fra i fotogrammi, che è quello che vede chi guarda; il tempo di Render, sul thread della finestra; il tempo della lista, sul thread del disegno. Guardare solo Render inganna: sembra veloce, e il lavoro vero è sull'altro thread. Ogni misura va ripetuta dopo un riscaldamento, leggendo la mediana, perché la prima volta si misurano la compilazione e le cache vuote.

Dove misura il banco
Dove misura il banco

Un banco di prova fa girare fino a 50.000 cerchi, in quattro modi, e i risultati smentiscono qualche intuizione:

  • DrawEllipse con pennelli immutabili condivisi vince. La scheda video regge circa 5.000 cerchi a 60 fotogrammi, poi scende a scalini;
  • un pennello nuovo per cerchio porta il garbage collector a fermare il programma fino a due quinti del tempo. In Render niente oggetti nuovi per ogni elemento;
  • una geometria unica con tutti i cerchi sembra un affare e non lo è: il lavoro si sposta, e costa di più. La geometria conviene per una forma complicata disegnata molte volte, come le serie del grafico;
  • disegnare solo quello che si vede: saltare gli oggetti fuori dai Bounds toglie i comandi prima che arrivino a Skia, e un mondo di 50.000 cerchi di cui se ne vede un nono torna a 60 fotogrammi.

30.6 Lo sfondo che non cambia

Sotto il disegno che si muove c'è spesso qualcosa di fermo: la carta millimetrata di un oscilloscopio, una mappa. Metterlo in un controllo suo risparmia il Render; ma il compositore esegue ancora la sua lista a ogni fotogramma, perché la parte che si muove copre quella ferma. Per fermarlo davvero:

// il compositore disegna lo sfondo una volta in una bitmap sua
sfondo.CacheMode = new BitmapCache();

oppure lo si disegna una volta in una RenderTargetBitmap e si fa una DrawImage a ogni fotogramma. In tutti e due i casi i fotogrammi tornano a 60. La bitmap costa memoria e va rifatta quando cambia quello che contiene: conviene quando lo sfondo è caro, fermo e coperto spesso da qualcosa che si muove.

Esercizi

  1. Il PNG per la stampa. Esporta un manometro del capitolo 27 a 300 dpi, con lo sfondo bianco. Quanti pixel è largo il file?
  2. Il controllo dimenticato. Togli Measure e Arrange dall'esportazione e guarda il file che esce.
  3. La virgola. Scrivi un SVG con un cerchio di raggio 12,5 usando ToString() senza cultura, aprilo in un browser e confrontalo con quello scritto con la cultura invariante.
  4. La carta millimetrata. Metti sotto un grafico che si aggiorna una quadrettatura fitta, prima nello stesso Render e poi in un controllo con BitmapCache. Conta i fotogrammi nei due casi.

Per approfondire: «Solo in codice», volume 3, capitoli III.15–III.17.

31. Animazioni

Cosa imparerai: una panoramica delle animazioni in Avalonia: perché la posizione viene dal tempo vero; le animazioni con i fotogrammi chiave e le funzioni di andamento; le transizioni; il ciclo di gioco a passo fisso; percorsi, particelle ed effetti; le animazioni del compositore, che girano anche con il thread della finestra fermo; come si mettono d'accordo e si misurano.

Un'animazione è un disegno che cambia nel tempo, e Avalonia offre quattro modi di farla, dal più manuale al più automatico. Questo capitolo li passa in rassegna, e indica le regole che contano.

31.1 Il tempo vero

Per muovere un disegno dal codice ci vuole un orologio. Il DispatcherTimer del capitolo 23 su Windows non batte più spesso di ogni 15,6 millisecondi circa, e non con regolarità: va bene per le cose lente. RequestAnimationFrame, visto con la girandola del capitolo 25, chiama al ritmo dello schermo con l'ora del fotogramma. Lo Stopwatch misura con precisione, ma non chiama nessuno.

Le palline con il computer carico
Le palline con il computer carico

La regola è una sola: la posizione viene dal tempo vero, non dal numero dei battiti. Una pallina che avanza di un passo fisso a ogni fotogramma, su un computer carico che perde fotogrammi, rallenta; una che calcola la posizione dall'ora salta qualche fotogramma, ma arriva in tempo. Il delta, il tempo dal fotogramma prima, si limita a un decimo di secondo, perché un fotogramma in ritardo non diventi un salto. In pausa si smette di chiedere fotogrammi, e alla ripartenza si dimentica l'ora precedente. Un'animazione che finisce è un inizio, una durata e un andamento che piega il progresso da 0 a 1; e molte animazioni insieme vogliono un orologio solo.

31.2 Animation e KeyFrame

Avalonia ha una classe che fa tutto questo da sola, Animation: una durata, un andamento e i fotogrammi chiave, ognuno con un punto del tempo (Cue, da 0 a 1) e i valori da avere lì.

// la pallina va da 0 a 300 in 1,2 secondi, rimbalzando alla fine
var a = new Animation {
  Duration = TimeSpan.FromSeconds(1.2),
  Easing = new BounceEaseOut(),
  FillMode = FillMode.Forward,
};
a.Children.Add(new KeyFrame {
  Cue = new Cue(0),
  Setters = { new Setter(TranslateTransform.XProperty, 0.0) },
});
a.Children.Add(new KeyFrame {
  Cue = new Cue(1),
  Setters = { new Setter(TranslateTransform.XProperty, 300.0) },
});
await a.RunAsync(pallina, CancellationToken.None);

RunAsync avvia l'animazione, l'await dice quando è finita, e un CancellationToken la ferma a metà. FillMode decide che cosa resta alla fine: con None la proprietà torna al valore di prima, con Forward tiene l'ultimo. Si animano numeri, margini, colori, pennelli e trasformazioni; un bool cambia solo alla fine, una stringa non si anima.

Sedici funzioni di andamento
Sedici funzioni di andamento

Le funzioni di andamento piegano il progresso: CubicEaseOut frena, SineEaseInOut parte e arriva piano, BounceEaseOut rimbalza, e SplineEasing è una Bézier come quelle del capitolo 25. Una propria si scrive derivando da Easing. Un'Animation messa in uno stile parte quando il selettore comincia a valere e si ferma quando smette: è il posto delle animazioni senza fine, come una spia che lampeggia finché c'è un allarme.

31.3 Le transizioni

Una transizione fa arrivare piano un valore che il programma cambia come sempre. Si dichiara una volta quale proprietà, in quanto tempo e con che andamento; poi ogni cambio, invece di scattare, scivola:

tessera.Transitions = new Transitions {
  new DoubleTransition {
    Property = Visual.OpacityProperty,
    Duration = TimeSpan.FromMilliseconds(400),
    Easing = new CubicEaseOut(),
  },
};
// da qui in avanti ogni cambio dell'opacità sfuma in 0,4 secondi
tessera.Opacity = 0.2;

C'è una transizione per tipo di valore: numeri, margini (ThicknessTransition), colori (BrushTransition), trasformazioni (TransformOperationsTransition), ombre. Il posto giusto è uno stile, con un setter di Transitions, così vale per tutti i controlli di una classe. Se il valore cambia a metà strada, la transizione nuova riparte dal valore di adesso, ma con tutta la durata. E un cambio di tema è un cambio di valore come gli altri: con le transizioni sui colori, il passaggio dal chiaro allo scuro sfuma.

31.4 Il ciclo di gioco

Quando le cose si toccano, rimbalzano e rispondono ai tasti, serve il modo dei videogiochi: il ciclo di gioco, che a ogni fotogramma aggiorna lo stato e poi lo disegna, tenendo separate le due cose.

Il ciclo di gioco con l'accumulatore
Il ciclo di gioco con l'accumulatore

La fisica si aggiorna a passo fisso, per esempio centoventi volte al secondo, e un accumulatore spende il tempo dei fotogrammi a passi interi:

// il tempo del fotogramma, limitato, si spende a passi fissi
_accumulatore += Math.Min(dt, 0.1);
int passi = 0;
while (_accumulatore >= Passo && passi < 8) {
  _prima = _adesso;
  _adesso = Aggiorna(_adesso, Passo);
  _accumulatore -= Passo;
  passi++;
}
// si disegna fra lo stato di prima e quello di adesso
_alfa = _accumulatore / Passo;
InvalidateVisual();

Il disegno interpola fra lo stato di prima e quello di adesso, senza scatti. Il limite sui passi per fotogramma evita la spirale della morte, in cui un aggiornamento troppo caro accumula sempre più ritardo: il gioco rallenta, ma resta vivo. In cambio il passo fisso dà una fisica che non dipende dallo schermo, e con lo stesso seme e gli stessi comandi la stessa partita, bit per bit.

31.5 Percorsi, particelle, effetti

Il movimento lungo un percorso usa la geometria come binario: TryGetPointAndTangentAtDistance dà il punto e la direzione a una certa distanza, e una matrice gira l'oggetto secondo la tangente. Un trenino è una distanza sola, con ogni vagone un passo fisso dietro.

Il plastico del trenino
Il plastico del trenino

Un sistema di particelle (scintille, fumo, pioggia) regge migliaia di oggetti per fotogramma senza chiedere memoria: le particelle sono struct in un array preparato una volta e riciclato, e i pennelli di tutta la loro vita stanno in una tavolozza immutabile. Gli effetti sono l'ombra dei riquadri, BoxShadow, e quelli dei controlli, BlurEffect e DropShadowEffect, che si animano anche loro con le transizioni.

31.6 Il compositore

Le animazioni viste finora passano dal thread della finestra: a ogni fotogramma cambiano una proprietà, e il controllo si ridisegna. Il compositore del capitolo 25 sa animare da sé, sul thread del disegno, lo spostamento, la rotazione, la scala e l'opacità di un controllo. Ogni controllo in una finestra ha un visuale, e gli si dà l'animazione una volta sola:

CompositionVisual? v = ElementComposition.GetElementVisual(scheda);
if (v != null) {
  Vector3DKeyFrameAnimation a =
    v.Compositor.CreateVector3DKeyFrameAnimation();
  var lineare = new LinearEasing();
  a.InsertKeyFrame(0f, new Vector3D(0, 0, 0), lineare);
  a.InsertKeyFrame(1f, new Vector3D(200, 0, 0), lineare);
  // la durata predefinita è un millisecondo: va scritta
  a.Duration = TimeSpan.FromSeconds(1);
  a.Direction = PlaybackDirection.Alternate;
  a.IterationBehavior = AnimationIterationBehavior.Forever;
  v.StartAnimation("Translation", a);
}

Da quel momento il thread della finestra non fa più niente, zero Render, e l'animazione continua anche se il thread della finestra si blocca per un lavoro lungo. Le animazioni implicite fanno scivolare un controllo quando il layout lo sposta, come le righe di un elenco riordinato. I limiti: il compositore non cambia misure né contenuti, e non avvisa quando ha finito.

Due thread, tre animazioni
Due thread, tre animazioni

31.7 Mettere d'accordo e misurare

Più Animation si mettono in sequenza con un await dopo l'altro, o in gruppo aspettandole con Task.WhenAll. Per una scena vera serve una coreografia: una tabella dei tempi, con inizio, durata e andamento di ogni pezzo, e un orologio solo per tutta la tabella. La scena diventa una funzione del tempo, e la pausa, il riavvolgimento e un cursore che la porta a un istante qualunque toccano solo l'orologio.

Un'animazione si misura come ogni disegno, con i tre cronometri del capitolo 30. Per molte cose in movimento la strada più leggera resta un controllo solo che le disegna tutte, come la tela del capitolo 29. Due cortesie, infine. Avalonia 12 non espone la preferenza del sistema per meno movimento, che alcune persone attivano perché le animazioni le disturbano: la si legge dal sistema e la si rispetta. E con la finestra ridotta a icona Avalonia smette di chiamare Render, ma i fotogrammi e i calcoli continuano: un orologio fatto bene si ferma da sé guardando WindowState e IsVisible.

Esercizi

  1. Il contatore. Fai muovere una pallina di un passo fisso a ogni fotogramma e un'altra con la posizione dall'ora. Carica il computer: quale arriva prima?
  2. La spia che lampeggia. Metti in uno stile un'Animation senza fine dell'opacità, che parte con la pseudoclasse :allarme.
  3. Il tema che sfuma. Dai al Background di un pannello una BrushTransition di mezzo secondo e cambia tema.
  4. Il compositore contro il blocco. Anima un riquadro con il compositore e un altro con un'Animation, poi blocca il thread della finestra per due secondi. Quale dei due si ferma?

Per approfondire: «Solo in codice», volume 3, capitoli III.18–III.26.

Parte IV — I componenti di RAVAL

32. Le estensioni di RAva

Cosa imparerai: tutte le estensioni di RAVAL.RAva (disposizione, testo e cornici, pannelli pronti, eventi senza lambda, colori e temi), e perché si chiamano come le proprietà di Avalonia.

Le estensioni stanno in RAVAL\UI\: RAva.cs e, per gruppo, RAvaText.cs, RAvaPanels.cs, RAvaEvents.cs, RAvaColors.cs; i colori dei temi in RThemes.cs. Ognuna assegna una proprietà o aggiunge qualcosa al controllo e restituisce il controllo stesso, del suo tipo vero (sono generiche, T Margine<T>(this T c, ...)): dopo new TextBox().Margin(4) si ha ancora una TextBox.

EstensioneVale perFaEquivale a
Margin(tutti)ogni Layoutablestesso margine sui 4 latiMargin = new Thickness(t)
Margin(orizz, vert)ogni Layoutablemargine orizzontale e verticalenew Thickness(o, v)
Margin(s, a, d, b)ogni Layoutablesinistra, alto, destra, bassonew Thickness(s, a, d, b)
Align(o, v)ogni Layoutableallineamento orizzontale e verticaleHorizontalAlignment, VerticalAlignment
VAlign(v)ogni Layoutablesolo verticaleVerticalAlignment
Width(l), Height(a)ogni Layoutablemisura fissaWidth, Height
Column(c, span)ogni Controlcolonna della Grid (e quante)Grid.SetColumn, Grid.SetColumnSpan
Row(r, span)ogni Controlriga della Grid (e quante)Grid.SetRow, Grid.SetRowSpan
Dock(lato)ogni Controllato del DockPanelDockPanel.SetDock
Children(...)ogni Panelaggiunge i figliChildren.AddRange
Text(s)TextBlockil testoText
Content(o)ContentControl (Button, CheckBox...)il contenuto; con «_Prenota» anche il nome accessibile «Prenota»Content
Child(c)Border, ogni Decoratorl'unico figlioChild
ToolTip(s)ogni Controlil testo al passaggio del mouseToolTip.SetTip
AutomationName(s)ogni Controlil nome che annuncia il lettore di schermo (per i pulsanti con la sola icona)AutomationProperties.SetName
OnClick(azione)Buttonun metodo senza argomenti al clicClick += ...
Resource(prop, chiave)ogni Controllega la proprietà a una risorsa del temaBind(prop, GetResourceObservable(chiave))
ThemeBrush(chiave, ripiego)ogni Controlil pennello della risorsa nel tema attuale (per Render)TryFindResource(chiave, ActualThemeVariant, ...)

Un modulo che le usa tutte:

/// <summary>Un modulo di iscrizione che usa ogni estensione di RAva.</summary>
public static Control ModuloRAva() {
  var g = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*"), RowDefinitions = new RowDefinitions("Auto,Auto,Auto,Auto,*"), ColumnSpacing = 12, RowSpacing = 10 };
  g.Children(
    new TextBlock { FontSize = 18, FontWeight = FontWeight.SemiBold }.Text("Iscrizione al corso").Row(0).Column(0, span: 2),
    new TextBlock().Text("Nome").VAlign(VerticalAlignment.Center).Row(1),
    new TextBox { PlaceholderText = "Nome e cognome" }.Row(1).Column(1).ToolTip("Come compare sull'attestato"),
    new TextBlock().Text("Livello").VAlign(VerticalAlignment.Center).Row(2),
    new ComboBox { ItemsSource = new[] { "Principiante", "Intermedio", "Esperto" }, SelectedIndex = 0 }.Width(180).Row(2).Column(1),
    new CheckBox { IsChecked = true }.Content("Ricevi il materiale per e-mail").Row(3).Column(1),
    new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }
      .Align(HorizontalAlignment.Right, VerticalAlignment.Bottom).Row(4).Column(0, span: 2)
      .Children(
        new Button().Content("Annulla").OnClick(Annulla),
        new Button { Classes = { "accent" } }.Content("Iscriviti").Width(110).OnClick(Iscrivi)));
  return new Border { CornerRadius = new CornerRadius(10), BorderThickness = new Thickness(1), Padding = new Thickness(18) }
    .Resource(Border.BackgroundProperty, "Pannello").Resource(Border.BorderBrushProperty, "Bordo")
    .Margin(16).Height(290).Child(g);
}
Il modulo d'iscrizione
Il modulo d'iscrizione

Testo e cornici

In Avalonia le stesse proprietà stanno su classi diverse: FontSize c'è nel TextBlock e nei controlli con modello (Button, TextBox, CheckBox... cioè i TemplatedControl), Padding nel Border, nel TextBlock e nei controlli con modello ma non nei pannelli. Queste estensioni valgono per ogni Control e guardano il tipo vero quando le si chiama. Se il controllo non ha la proprietà, si fermano con un errore che dice per chi valgono: un'impostazione ignorata in silenzio si scoprirebbe solo guardando la finestra.

EstensioneVale perFa
FontSize(px)TextBlock, controlli con modello, pannellidimensione del carattere; su un pannello la ereditano i figli
FontWeight(p), Bold(), Italic()come soprapeso e stile del carattere
FontFamily("Consolas")come soprala famiglia del carattere
Foreground(pennello), Foreground("#1a7fb5")come soprail colore del testo
Wrap(), TextAlign(a)TextBlockil testo va a capo; allineamento delle righe
Border(spessore, raggio), CornerRadius(r)Border, controlli con modellospessore del bordo e angoli arrotondati
BorderBrush(...)Border, controlli con modelloil colore del bordo
Padding(...)Border, TextBlock, controlli con modellolo spazio fra il bordo e il contenuto (1, 2 o 4 valori, come Margin)
Background(...)pannelli, Border, TextBlock, controlli con modellolo sfondo
Visible(b), Enabled(b), Opacity(o)ogni ControlIsVisible, IsEnabled, Opacity
Classes("accent", ...)ogni Controlaggiunge classi di stile

I colori si possono scrivere come in HTML: "#1a7fb5", "#801a7fb5" (le prime due cifre sono l'opacità), "Red". Per un colore fisso c'è anche RAva.Brush("#1a7fb5") o RAva.Brush(0xFF1A7FB5); per un colore che segue il tema si usa sempre Resource (capitolo 17).

Pannelli pronti

MetodoCrea
RAva.HStack(spazio, figli...)uno StackPanel orizzontale
RAva.VStack(spazio, figli...)uno StackPanel verticale
RAva.Grid("Auto,*", "Auto,Auto,*")una Grid con colonne e righe (le righe si possono omettere)
.Spacing(s) / .Spacing(colonne, righe)lo spazio fra i figli di uno StackPanel / di una Grid

Non si chiamano Row e Column perché quei nomi sono già le estensioni che mettono un controllo in una riga o colonna della Grid.

Eventi senza lambda

Come OnClick, si passa un metodo con un nome, che riceve già il valore utile invece di mittente ed EventArgs da convertire. Dietro ogni estensione c'è una piccola classe che ricorda il controllo e il metodo: la stessa tecnica del capitolo 15, scritta una volta per tutte.

EstensioneVale perIl metodo riceve
OnTextChanged(metodo)TextBoxil testo nuovo, a ogni lettera (void Filtra(string testo))
OnEnter(metodo)TextBoxniente: è stato premuto Invio (ascoltato in fase Tunnel, prima della casella: vale anche con AcceptsReturn; capitolo 15)
OnChecked(metodo)CheckBox, ToggleSwitch, RadioButtonlo stato nuovo (void Attiva(bool spuntato))
OnSelectionChanged(metodo)ListBox, ComboBoxl'elemento scelto, o null (void Mostra(object? scelto))
OnThemeChanged(metodo)ogni Controlniente: il tema è cambiato (per chi disegna in Render, capitolo 27)
RAva.Every(intervallo, metodo)—niente: è passato l'intervallo; restituisce il DispatcherTimer già avviato
OnClick(metodo)MenuItemniente: la voce è stata scelta, col mouse o con il suo tasto

Per le voci dei menu c'è anche HotKey("Ctrl+S"), che scrive la scorciatoia a destra della voce (InputGesture) e la fa funzionare (HotKey), anche a menu chiuso. Le voci vanno messe con Items.Add: con ItemsSource quelle di un sottomenu chiuso non esistono ancora e il tasto non scatta.

Colori e temi

I due dizionari del capitolo 17 si possono scrivere a coppie, una riga per colore, con RThemes:

/// <summary>Tema Fluent e colori dei due temi.</summary>
public override void Initialize() {
  Styles.Add(new FluentTheme());
  new RThemes()
    .Color("Sfondo", light: "#ffffff", dark: "#1e1f22")
    .Color("Pannello", light: "#f3f5f8", dark: "#2a2c30")
    .Color("Testo", light: "#1f2328", dark: "#e6e6e6")
    .Apply(this);
}

Apply(this) in Application.Initialize vale per tutte le finestre; Apply(finestra) solo dentro una finestra. I controlli poi si legano alla chiave con Resource, come prima.

Una rubrica che si filtra

Una finestra che le usa: si scrive nella casella e l'elenco si filtra a ogni lettera, la spunta mostra solo i clienti attivi, Invio sceglie il primo nome, e la scelta riempie la scheda a destra. Ogni evento arriva a un metodo con un nome della classe Rubrica.

/// <summary>Una rubrica che si filtra mentre si scrive: pannelli pronti, testo, cornici ed eventi con un nome.</summary>
public static Control RubricaRAva() {
  var r = new Rubrica();
  var cerca = new TextBox { PlaceholderText = "Cerca un nome…" }.OnTextChanged(r.Filtra).OnEnter(r.ScegliPrimo);
  var soloAttivi = new CheckBox().Content("Solo i clienti attivi").OnChecked(r.SoloAttivi);
  r.Elenco.OnSelectionChanged(r.Mostra);
  // un pannello non ha imbottitura ne' bordo: la cornice e' un Border attorno
  var scheda = new Border().Padding(16).Border(1, radius: 8)
    .Resource(Border.BackgroundProperty, "Sfondo").Resource(Border.BorderBrushProperty, "Bordo")
    .Child(RAva.VStack(6,
      new TextBlock().Text("Scheda").FontSize(12).Bold().Resource(TextBlock.ForegroundProperty, "TestoTenue"),
      r.Nome.FontSize(20).FontWeight(FontWeight.SemiBold),
      r.Stato.FontSize(13).Wrap()));
  var g = RAva.Grid("220,*", "Auto,Auto,*").Spacing(14, 10).Children(
    cerca.Row(0),
    soloAttivi.Row(1),
    r.Elenco.Row(2),
    scheda.Row(0, span: 3).Column(1));
  r.Elenco.SelectedIndex = 1;                                                        // per la figura: una scelta gia' fatta
  return new Border().Padding(16).Resource(Border.BackgroundProperty, "Pannello").Child(g);
}

// la rubrica dell'esempio: i dati, i controlli da aggiornare e i metodi chiamati dagli eventi
private sealed class Rubrica {
  private static readonly string[] Tutti = { "Anna Bianchi", "Bruno Rossi", "Carla Verdi", "Dario Neri", "Elena Gallo" };
  private static readonly string[] Attivi = { "Anna Bianchi", "Bruno Rossi", "Elena Gallo" };
  private string _filtro = "";                                                       // il testo cercato
  private bool _soloAttivi;                                                          // solo i clienti attivi

  /// <summary>L'elenco dei nomi.</summary>
  public ListBox Elenco { get; } = new ListBox();
  /// <summary>Il nome scelto, grande, nella scheda.</summary>
  public TextBlock Nome { get; } = new TextBlock().Text("—");
  /// <summary>Lo stato del cliente scelto.</summary>
  public TextBlock Stato { get; } = new TextBlock();

  // all'inizio l'elenco completo
  public Rubrica() { Aggiorna(); }

  // AlCambioTesto: il filtro cambia a ogni lettera
  public void Filtra(string testo) { _filtro = testo.Trim(); Aggiorna(); }

  // AlSpunta: mostra tutti o solo gli attivi
  public void SoloAttivi(bool spuntato) { _soloAttivi = spuntato; Aggiorna(); }

  // OnEnter: sceglie il primo nome dell'elenco filtrato
  public void ScegliPrimo() { if (Elenco.ItemCount > 0) Elenco.SelectedIndex = 0; }

  // AlCambioSelezione: la scheda del nome scelto
  public void Mostra(object? scelto) {
    string? nome = scelto as string;
    Nome.Text = nome ?? "—";
    Stato.Text = nome == null ? "" : Array.IndexOf(Attivi, nome) >= 0 ? "Cliente attivo: ordini nell'ultimo anno." : "Nessun ordine nell'ultimo anno.";
  }

  // l'elenco secondo il filtro e la spunta (foreach, non LINQ con lambda)
  private void Aggiorna() {
    var nomi = new List<string>();
    foreach (string n in Tutti) {
      if (_soloAttivi && Array.IndexOf(Attivi, n) < 0) continue;
      if (_filtro.Length > 0 && !n.Contains(_filtro, StringComparison.OrdinalIgnoreCase)) continue;
      nomi.Add(n);
    }
    Elenco.ItemsSource = nomi;
  }
}
Una rubrica che si filtra mentre si scrive
Una rubrica che si filtra mentre si scrive

Notate la cornice della scheda: le righe stanno in un VStack, ma imbottitura e bordo li ha il Border attorno. Scrivere RAva.VStack(...).Padding(16) si ferma con l'errore «Imbottitura: StackPanel non ha questa proprietà»: un pannello non ha imbottitura.

Perché si chiamano come le proprietà

L'estensione che imposta Margin si chiama Margin, quella che imposta Background si chiama Background: chi conosce Avalonia non deve imparare nomi nuovi. controllo.Margin (senza parentesi) è la proprietà, controllo.Margin(8) è l'estensione. Funziona perché, quando un nome è chiamato con le parentesi, il compilatore scarta i membri che non si possono chiamare, come una proprietà, e va a cercare i metodi d'estensione. Misurato con Avalonia 12.1.4: new Button().Margin(8) imposta il margine a 8 su tutti i lati.

Le eccezioni sono poche e hanno un motivo:

  • AutomationName, non Name: in Avalonia Name è il nome del controllo nell'albero, un'altra cosa;
  • OnClick, OnTextChanged...: gli eventi non sono proprietà da impostare, e On dice che si collega un metodo;
  • RAva.HStack e RAva.VStack: Row e Column sono già le estensioni della Grid.

Per lo stesso motivo non serve Avalonia.Markup.Declarative o un'altra libreria «fluente»: queste poche estensioni bastano, e restano codice nostro.

33. Il progetto finale: un cruscotto

Cosa imparerai: a mettere insieme tutto: cornice con DockPanel, menu, elenco con un modello, schede in una Grid, i controlli disegnati del capitolo 27, i colori del tema e la selezione che aggiorna le schede.

Il cruscotto dell'officina
Il cruscotto dell'officina

33.1 I dati

/// <summary>Una macchina dell'officina: nome, stato e le sue misure.</summary>
public sealed record Macchina(string Nome, string Stato, double Carico, double[] Temperature, double[] Pezzi);

33.2 La riga dell'elenco

/// <summary>Un pallino colorato secondo lo stato, il nome e lo stato scritto.</summary>
public sealed class ModelloMacchina : IDataTemplate {

  /// <summary>Il modello vale per le Macchina.</summary>
  public bool Match(object? dati) => dati is Macchina;

  /// <summary>I controlli di una riga.</summary>
  public Control? Build(object? dati) {
    if (dati is not Macchina m) return null;
    IBrush colore = m.Stato == "In marcia" ? Brushes.SeaGreen : m.Stato == "Ferma" ? Brushes.Gray : Brushes.OrangeRed;
    return new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 }.Margin(2, 3).Children(
      new Border { Width = 10, Height = 10, CornerRadius = new CornerRadius(5), Background = colore }.VAlign(VerticalAlignment.Center),
      new StackPanel().Children(
        new TextBlock { FontWeight = FontWeight.SemiBold }.Text(m.Nome),
        new TextBlock { FontSize = 11, Opacity = 0.7 }.Text(m.Stato)));
  }
}

33.3 La finestra

I controlli che cambiano sono campi della classe: il gestore della selezione li ritrova lì.

public sealed class Cruscotto {

  private readonly ListBox _elenco = new() { ItemTemplate = new ModelloMacchina() };
  private readonly Indicatore _carico = new() { Unita = "carico %" };
  private readonly GraficoLinee _temperatura = new() { Titolo = "Temperatura (°C)" };
  private readonly GraficoBarre _pezzi = new() { Etichette = new[] { "Lun", "Mar", "Mer", "Gio", "Ven" } };
  private readonly TextBlock _titolo = new() { FontSize = 20, FontWeight = FontWeight.SemiBold };
  private readonly TextBlock _stato = new() { FontSize = 12 };

  /// <summary>La finestra completa.</summary>
  public Control Vista { get; }

  /// <summary>Costruisce la cornice e le schede, poi seleziona la prima macchina.</summary>
  public Cruscotto() {
    var macchine = new List<Macchina> { /* quattro macchine con le loro misure */ };
    _elenco.ItemsSource = macchine;
    _elenco.SelectionChanged += MacchinaScelta;

    var menu = new Menu().Dock(Dock.Top);
    menu.Items.Add(new MenuItem { Header = "_File" });
    menu.Items.Add(new MenuItem { Header = "_Vista" });
    menu.Items.Add(new MenuItem { Header = "_Aiuto" });

    var schede = new Grid { ColumnDefinitions = new ColumnDefinitions("*,2*"), RowDefinitions = new RowDefinitions("*,*"), ColumnSpacing = 12, RowSpacing = 12 }.Children(
      Scheda("Carico", _carico).Row(0).Column(0),
      Scheda("Andamento", _temperatura).Row(0).Column(1),
      Scheda("Ora", new Orologio { OraFissa = new TimeSpan(14, 37, 12) }).Row(1).Column(0),
      Scheda("Pezzi prodotti questa settimana", _pezzi).Row(1).Column(1));

    Vista = new DockPanel().Children(
      menu,
      new Border { BorderThickness = new Thickness(0, 1, 0, 0), Padding = new Thickness(12, 4) }
        .Resource(Border.BorderBrushProperty, "Bordo").Resource(Border.BackgroundProperty, "Pannello").Dock(Dock.Bottom)
        .Child(_stato.Resource(TextBlock.ForegroundProperty, "TestoTenue")),
      new Border { BorderThickness = new Thickness(0, 0, 1, 0), Width = 190 }
        .Resource(Border.BorderBrushProperty, "Bordo").Resource(Border.BackgroundProperty, "Pannello").Dock(Dock.Left)
        .Child(new DockPanel().Margin(8).Children(
          new TextBlock { FontWeight = FontWeight.SemiBold }.Text("Macchine").Margin(6, 4, 0, 8).Dock(Dock.Top), _elenco)),
      new DockPanel().Margin(16).Children(_titolo.Margin(0, 0, 0, 12).Dock(Dock.Top).Resource(TextBlock.ForegroundProperty, "Testo"), schede));
    _elenco.SelectedIndex = 2;
  }

  // ListBox.SelectionChanged: le schede mostrano la macchina scelta
  private void MacchinaScelta(object? mittente, SelectionChangedEventArgs e) {
    if (_elenco.SelectedItem is not Macchina m) return;
    _titolo.Text = m.Nome + " — " + m.Stato;
    _carico.Valore = m.Carico;
    _temperatura.Valori = m.Temperature;
    _pezzi.Valori = m.Pezzi;
    _stato.Text = $"Collegato a 4 macchine · ultima lettura 14:37:12 · {m.Nome}: {m.Temperature[^1]:0} °C";
  }

  // una scheda: bordo arrotondato col colore del pannello, un titolo e il contenuto
  private static Border Scheda(string titolo, Control contenuto) =>
    new Border { CornerRadius = new CornerRadius(10), BorderThickness = new Thickness(1), Padding = new Thickness(12) }
      .Resource(Border.BackgroundProperty, "Pannello").Resource(Border.BorderBrushProperty, "Bordo")
      .Child(new DockPanel().Children(
        new TextBlock { FontSize = 12, FontWeight = FontWeight.SemiBold }.Text(titolo.ToUpperInvariant())
          .Resource(TextBlock.ForegroundProperty, "TestoTenue").Margin(0, 0, 0, 6).Dock(Dock.Top),
        contenuto));
}

Lo stesso cruscotto nel tema scuro, senza una riga di codice in più:

Il cruscotto nel tema scuro
Il cruscotto nel tema scuro

Ripercorriamo cosa c'è dentro: la cornice è un DockPanel (capitolo 16) con menu (capitolo 19), barra di stato ed elenco; l'elenco usa un IDataTemplate (8); le schede sono Border in una Grid con colonne * e 2* (4); i colori sono tutti risorse del tema (7); i grafici sono i controlli disegnati (11), e i loro setter chiamano InvalidateVisual, così MacchinaScelta deve solo assegnare i dati nuovi (6). Per i dati veri, letti da una macchina in un altro thread, si aggiunge il Dispatcher (capitoli 12 e 23).

Esercizi

  1. Aggiungi il menu Vista → Tema scuro, che chiama Temi.CambiaTema.
  2. Con un DispatcherTimer aggiungi ogni secondo una temperatura finta alla macchina scelta, togliendo la più vecchia: il grafico deve scorrere.
  3. Aggiungi una quinta scheda con la Torta dei pezzi buoni e degli scarti.

34. Un server di API JSON

Cosa imparerai: a far rispondere il tuo programma alla rete con RServer: percorsi collegati a metodi con un nome, oggetti che diventano JSON, codici di errore, e i tre modi di avviarlo (senza await, con un thread, con await). Chi lo chiama può essere un browser, un telefono, un altro programma.

Un programma con finestre spesso deve anche farsi chiamare: il cruscotto del capitolo 33 letto da un telefono, una macchina che manda le sue misure, un altro programma che chiede un dato. Il modo più semplice oggi è un'API JSON su HTTP: l'altro manda una richiesta a un indirizzo (GET /api/macchine/3), il programma risponde con un testo JSON ({ "id": 3, "nome": "Pressa" }).

RServer di RAVAL lo fa sopra Kestrel, il server web di ASP.NET Core (gratuito, MIT, già nell'SDK di .NET), senza il resto del framework: niente controller, niente attributi, niente lambda. Ogni percorso è collegato a un metodo con un nome, che riceve la richiesta e restituisce l'oggetto da mandare.

34.1 La bacheca

Una finestra con un elenco di messaggi. Chiunque nella rete può leggerli (GET /api/messaggi) e aggiungerne uno (POST /api/messaggi con { "autore": "...", "testo": "..." }).

/// <summary>Un messaggio della bacheca: diventa e viene dal JSON { "autore": ..., "testo": ... }.</summary>
public sealed class MessaggioBacheca {
  /// <summary>Chi scrive.</summary>
  public string Autore { get; set; } = "";
  /// <summary>Il messaggio.</summary>
  public string Testo { get; set; } = "";
  /// <summary>Come compare nell'elenco.</summary>
  public override string ToString() => Autore + ": " + Testo;
}
/// <summary>La bacheca con il suo server sulla porta data (0 = una porta libera).</summary>
public Bacheca(int porta) {
  _server = new RServer(porta)
    .Get("/api/messaggi", Elenco)
    .Post("/api/messaggi", Aggiungi)
    .OnUiThread();                                                         // Aggiungi tocca l'elenco
  _server.Start();                                                                   // niente await: torna quando ascolta
  _stato.Text = "In ascolto su " + _server.Addresses[0] + "/api/messaggi";
  var titolo = new TextBlock().Text("Bacheca").FontSize(20).Bold();
  var elenco = new ListBox { ItemsSource = _messaggi };
  Vista = new DockPanel().Margin(16).Children(
    RAva.VStack(2, titolo, _stato).Margin(0, 0, 0, 10).Dock(Dock.Top),
    elenco);
  Vista.DetachedFromVisualTree += Chiusa;
}

// GET /api/messaggi: l'elenco attuale
private object? Elenco(RRequest r) => _messaggi;

// POST /api/messaggi: un messaggio nuovo, letto dal JSON del corpo
private object? Aggiungi(RRequest r) {
  MessaggioBacheca m = r.Body<MessaggioBacheca>();
  if (m.Testo.Trim().Length == 0) throw new RHttpError(400, "il testo e' vuoto");
  _messaggi.Add(m);                                                                  // siamo sul thread dell'interfaccia
  return RResponse.Created(m);
}

// la bacheca e' uscita dalla finestra: il server si ferma e libera la porta
private void Chiusa(object? mittente, VisualTreeAttachmentEventArgs e) => _server.Stop();
La bacheca dopo tre messaggi arrivati dalla rete
La bacheca dopo tre messaggi arrivati dalla rete

I tre messaggi della figura sono arrivati davvero via HTTP: il programma che disegna le figure li manda con un HttpClient da un altro thread, mentre la finestra è aperta. Per provarlo a mano, con il programma avviato su una porta fissa (per esempio new Bacheca(5080)), da un terminale:

curl http://127.0.0.1:5080/api/messaggi
curl -X POST http://127.0.0.1:5080/api/messaggi -H "Content-Type: application/json" -d "{\"autore\":\"Anna\",\"testo\":\"ciao\"}"

Oppure si apre http://127.0.0.1:5080/api/messaggi nel browser: i GET si leggono così.

34.2 Il viaggio di una richiesta

Il viaggio di una richiesta
Il viaggio di una richiesta
  1. Kestrel riceve la richiesta su un suo thread, legge il corpo e cerca il percorso;
  2. RServer costruisce la RRequest e chiama il tuo metodo. Con OnUiThread() lo chiama sul thread dell'interfaccia attraverso il Dispatcher (capitolo 12, §12.6), quindi il metodo può toccare i controlli; senza, lo chiama sul thread di Kestrel;
  3. l'oggetto restituito diventa JSON e torna indietro.

Le regole della risposta:

Il metodo...La risposta è
restituisce un oggetto200 con l'oggetto in JSON (nomi in camelCase: Autore diventa "autore")
restituisce null204, senza corpo
restituisce RResponse.Created(x), new RResponse(202, x)quel codice, con x in JSON
lancia new RHttpError(404, "non c'è")404 con { "error": "non c'è" }
lancia un'eccezione qualsiasi500 con { "error": messaggio }: il server non cade

E quello che il metodo trova nella RRequest:

MembroCosa dà
Param("id")un parametro del percorso (/api/macchine/{id}) o dopo il ? (?filtro=rossi); null se manca
Int("id")lo stesso, come intero; se non è un numero, la risposta è già un 400
Body<T>()il corpo JSON letto come oggetto; se manca o è sbagliato, la risposta è già un 400
Textil corpo come testo
Header("Authorization")un'intestazione della richiesta
Metodo, Percorso, ContestoGET/POST..., il percorso, l'HttpContext di ASP.NET per il resto

Un percorso con un parametro:

// GET /api/macchine/{id}: 404 se non c'e'
public object? Macchina(RRequest r) {
  int id = r.Int("id");
  if (id < 1 || id >= Macchine.Length) throw new RHttpError(404, "la macchina " + id + " non c'e'");
  return new Scheda { Id = id, Nome = Macchine[id] };
}

34.3 Tre modi di avviarlo

Come per tutto quello che aspetta (capitolo 12), ci sono tre modi, e vanno bene tutti:

// 1. come un metodo normale: Avvia torna quando il server ascolta
server.Start();
...
server.Stop();

// 2. con un thread: Esegui avvia il server e resta fermo finche' qualcuno chiama Ferma
var filo = new Thread(server.Run) { IsBackground = true, Name = "server" };
filo.Start();
server.WaitStarted(TimeSpan.FromSeconds(10));
...
server.Stop();                                                                    // libera il thread di Esegui
filo.Join();

// 3. con await
await server.StartAsync();
...
await server.StopAsync();

Il modo 2 è quello di un programma console che fa solo da server: server.Run(); alla fine di Main, e il programma vive finché qualcuno (un comando, un altro percorso dell'API) chiama Stop. Start e Stop si possono chiamare dal thread dell'interfaccia senza rischi: dentro fanno il lavoro su un thread del pool, per il motivo spiegato in §12.6.

Anche i metodi dei percorsi possono essere asincroni, se devono aspettare qualcosa (un archivio, un altro servizio): GetAsync e PostAsync accettano un async Task<object?> Metodo(RRequest r).

34.4 Con i thread invece di SulThreadInterfaccia

Senza OnUiThread() i metodi girano sui thread di Kestrel: sono più veloci (non aspettano il thread dell'interfaccia) ma non possono toccare i controlli. È la scelta giusta quando l'API legge dati che non stanno nei controlli, e si usano le regole del capitolo 12: i dati condivisi con la finestra si proteggono con lock, e per aggiornare lo schermo si chiede al Dispatcher.

private readonly object _chiave = new();                                           // protegge _ultimaMisura
private double _ultimaMisura;

// POST /api/misura, sul thread di Kestrel
private object? NuovaMisura(RRequest r) {
  Misura m = r.Body<Misura>();
  lock (_chiave) _ultimaMisura = m.Valore;
  Dispatcher.UIThread.Post(Mostra);                                                // lo schermo si aggiorna dal suo thread
  return null;                                                                     // 204
}

// sul thread dell'interfaccia
private void Mostra() {
  double v;
  lock (_chiave) v = _ultimaMisura;
  valore.Text = v.ToString("0.00");
}

34.5 Dalla rete

new RServer(5080) ascolta solo su questo computer (127.0.0.1): è il default, ed è il più sicuro. Per farsi raggiungere da un telefono o da un altro computer si aggiunge .OnAllInterfaces(), e la prima volta il sistema operativo chiede il permesso nel firewall. A quel punto chiunque nella rete può chiamare l'API: un'API che cambia qualcosa (POST, DELETE) va protetta, per esempio controllando un'intestazione con una chiave (r.Header("X-Chiave")) e rispondendo 401 se manca.

Una nota per chi distribuisce il programma: RServer usa il framework condiviso di ASP.NET Core, che c'è nell'SDK di .NET e nel «.NET Runtime» completo per i server. Un programma che usa RAVAL senza RServer non lo carica mai.

Esercizi

  1. Aggiungi alla bacheca DELETE /api/messaggi/{n}, che cancella il messaggio numero n e risponde 404 se non c'è.
  2. Aggiungi GET /api/messaggi?autore=Anna, che restituisce solo i messaggi di un autore (r.Param("autore"), un ciclo foreach, nessuna lambda).
  3. Rifai la bacheca senza OnUiThread(): i messaggi in una List protetta da lock, e l'elenco aggiornato con Dispatcher.UIThread.Post.
  4. Scrivi un programma console che fa solo da server con server.Run(), e un percorso POST /api/ferma che chiama Stop da un altro thread (attenzione: chi chiama Stop non deve essere il thread che sta rispondendo; usa un new Thread(server.Ferma).Start()).

35. Strumenti di misura: grafici, tabelle e stili pronti

Cosa imparerai: a costruire la finestra di uno strumento con i pezzi pronti di RAVAL: il grafico a curve RChart con i marcatori, la tabella RTable, l'elenco di valori RPropertyList, i numeri con i prefissi SI di RFormat, le curve in CSV con RCsv, il grafico salvato a 300 dpi con RImage e l'aspetto pulito di RStyles.

Nel capitolo 29 abbiamo disegnato i grafici a mano, punto per punto, per capire come si fa. Un programma di misura o di progetto però ha sempre bisogno delle stesse cose: un grafico con gli assi tondi e la legenda, i marcatori da spostare con il mouse, una tabella che si ordina, i valori dei componenti da cambiare, i numeri scritti come li legge un tecnico («1 kHz», non «1000»), le curve da salvare per un foglio di calcolo e le figure da stampare. RAVAL le ha già: in questo capitolo le mettiamo insieme nella finestra che misura un filtro passa-basso.

Lo strumento del filtro passa-basso
Lo strumento del filtro passa-basso

Il codice è in ESEMPI_RAVAL\Esempi\Strumento.cs. Le classi sono tutte nel namespace RAVAL, come le estensioni di RAva; i loro membri, uno per uno, sono nel riferimento (DOC\API.md).

35.1 I numeri come li legge un tecnico: RFormat

Un asse che dice «1000000000» non si legge, uno che dice «1 GHz» sì. RFormat.Si sceglie il prefisso che lascia da 1 a 999 prima del punto e tiene al più quattro cifre significative; RFormat.Parse fa il contrario, e accetta anche il modo in cui si scrivono i valori sugli schemi, con il prefisso al posto della virgola:

RFormat.Si(1.296e9, "Hz")        // «1.296 GHz»
RFormat.Si(2.2e-8, "F", 2)       // «22 nF»: due cifre significative
RFormat.Parse("4k7")             // 4700
RFormat.Parse("10 MHz", "Hz")    // 10 000 000: l'unità dopo il prefisso si salta
RFormat.Parse("5%")              // 0,05

I numeri scritti da RFormat hanno sempre il punto decimale, qualunque sia la lingua del computer: un valore copiato da una tabella si rilegge uguale. Il grafico, la tabella e l'elenco di proprietà usano RFormat da soli: la tabella, per esempio, ordina «470p» prima di «10n» perché ne legge il valore.

35.2 Il grafico: RChart, RSeries, RLimit

RChart è un controllo: si mette in un pannello come un TextBlock. Gli si danno le curve (RSeries, un nome e due array di ascisse e ordinate), i limiti (RLimit, un segmento orizzontale spesso, per esempio la banda che il filtro deve rispettare) e le ascisse dei marcatori:

private readonly RChart _grafico = new() { TitleY = "dB", UnitX = "Hz", LogX = true, MinY = -60, MaxY = 5 };

_grafico.Series.Add(new RSeries("calcolata", _f, calcolata));
_grafico.Series.Add(new RSeries("misura", _f, misura, 1) { Dashed = true });       // il secondo colore, tratteggiata
_grafico.Limits.Add(new RLimit(10, 300, -3, RAva.Brush("#2b8a3e")) { Label = "banda > −3 dB" });
_grafico.Markers.Add(1000);
_grafico.MarkersChanged += Marcatori;

Le divisioni degli assi sono «tonde» (1, 2 o 5 per una potenza di dieci) e, con LogX, l'ascissa è logaritmica con le divisioni 1-2-5 di ogni decade. Le ascisse si scrivono con RFormat.Si e l'unità UnitX; se servono scritte diverse (gradi, date), FormatX prende un metodo con un nome che riceve il numero e restituisce il testo. MinY e MaxY fissano l'asse sinistro; lasciati vuoti, lo decidono i dati. Una curva con RightAxis = true usa l'asse destro, che compare da solo con il titolo TitleY2; una curva con il nome vuoto si disegna ma resta fuori dalla legenda: serve quando le curve sono cento, come le prove di un'analisi statistica.

Il mouse lavora da solo: un clic mette il primo marcatore, Maiusc + clic ne aggiunge un altro, il tasto destro li toglie tutti. Ogni marcatore scrive in alto il valore di ogni curva, interpolato fra i due campioni più vicini (RSeries.At). Dopo ogni clic arriva l'evento MarkersChanged, un Action senza argomenti: il nostro metodo Marcatori rifà la tabella.

Quando le curve cambiano, si rifà la lista e si chiama Refresh:

_grafico.Series.Clear();
_grafico.Series.Add(new RSeries("calcolata", _f, calcolata));
_grafico.Series.Add(new RSeries("misura", _f, misura, 1) { Dashed = true });
_grafico.Refresh();

I colori delle curve vengono dalla tavolozza RColor.Palette, otto colori che si leggono sia sul bianco sia sul quasi nero; lo sfondo, la griglia e le scritte vengono dalle risorse del tema «ChartBack», «ChartGrid» e «ChartInk». Un'applicazione che non le definisce, come la nostra, vede comunque un grafico chiaro nel tema chiaro e scuro nel tema scuro: ogni risorsa ha il suo colore di riserva.

Lo stesso strumento nel tema scuro
Lo stesso strumento nel tema scuro

35.3 La tabella: RTable

RTable mostra righe di testo, si ordina e si sceglie. Le colonne si danno con le intestazioni; «:R» in fondo allinea a destra, «:C» al centro:

private readonly RTable _marcatori = new() { HeaderKey = "Bordo", StripeKey = "Pannello" };

_marcatori.Columns("Marcatore", "Frequenza:R", "Calcolata:R", "Misura:R");

// la tabella dei marcatori, una riga per marcatore
private void Marcatori() {
  _marcatori.Clear();
  for (int i = 0; i < _grafico.Markers.Count; i++) {
    double f = _grafico.Markers[i];
    _marcatori.AddRow("M" + (i + 1), RFormat.Si(f, "Hz"),
                      RFormat.Si(_grafico.Series[0].At(f), "dB", 3),
                      RFormat.Si(_grafico.Series[1].At(f), "dB", 3));
  }
}

Un clic sull'intestazione ordina per quella colonna (per valore, se ogni cella è un numero, anche con un prefisso SI; se no in ordine alfabetico), un secondo clic rovescia l'ordine. Un clic su una riga la sceglie e manda l'evento SelectionChanged con il numero della riga. HeaderKey e StripeKey sono le risorse del tema per lo sfondo dell'intestazione e delle righe alterne: qui usiamo i nomi italiani della nostra applicazione.

RTable è una Grid di TextBlock: è semplice e si disegna bene anche nelle figure, ma costruisce tutte le righe. Va bene per decine o centinaia di righe; per decine di migliaia serve un elenco virtualizzato (capitolo 18).

35.4 I valori da cambiare: RPropertyList

A sinistra, i valori dei componenti: nomi a sinistra, caselle a destra.

private readonly RPropertyList _valori = new();

_valori.Add("R", "1k").Add("C", "100n").Add("Celle", "2");
_valori.Changed += ValoreCambiato;

// un valore confermato nell'elenco: si ricalcola tutto
private void ValoreCambiato(string nome, string valore) => Ricalcola();

Changed non arriva a ogni tasto, che ricalcolerebbe il filtro con un valore scritto a metà: arriva quando si preme Invio o la casella perde il fuoco, e solo se il testo è cambiato davvero. Il metodo riceve il nome e il valore nuovo; Value("R") legge un valore in qualunque momento, e RFormat.Parse lo trasforma in numero.

35.5 L'aspetto: Card, Caption, Bar, ToolbarButton

La finestra è un DockPanel: la barra in alto, i valori a sinistra, i marcatori in basso e il grafico al centro, che prende lo spazio che resta (capitolo 16).

var barra = RAva.Bar(RAva.HStack(2,
  RAva.ToolbarButton("Apri", "Apri una misura", Apri),
  RAva.ToolbarButton("CSV", "Salva le curve in CSV", SalvaCsv),
  RAva.ToolbarButton("PNG", "Salva il grafico a 300 dpi", SalvaPng)), true, "Pannello", "Bordo");
var sinistra = RAva.Card(RAva.VStack(6, RAva.Caption("Componenti", "TestoTenue"), _valori),
                         "Pannello", "Bordo").Width(200).Margin(8, 8, 0, 8);
var sotto = RAva.Card(RAva.VStack(6, RAva.Caption("Marcatori", "TestoTenue"), _marcatori),
                      "Pannello", "Bordo").Margin(8, 0, 8, 8);
Vista = new DockPanel().Children(
  barra.Dock(Dock.Top), sinistra.Dock(Dock.Left), sotto.Dock(Dock.Bottom), _grafico.Margin(8));
  • RAva.Card è un riquadro con gli angoli tondi e il bordo sottile; RAva.Caption il titolo di una sezione, piccolo e in maiuscolo; RAva.Bar una barra degli strumenti (true, con la linea sotto) o di stato (false, con la linea sopra); RAva.ToolbarButton un bottone con il suggerimento e il metodo da chiamare. RAva.CodeBox(16) è una casella di testo a spaziatura fissa alta sedici righe, per un sorgente o dei numeri in colonna.
  • I colori vengono dalle risorse del tema: gli ultimi parametri sono i nomi delle chiavi. Senza, valgono i nomi inglesi «Panel», «Border», «Bar», «InkSoft»; noi passiamo quelli della nostra applicazione.

L'aspetto vero lo danno gli stili di RStyles.Clean(), che si applicano una volta sola all'applicazione:

public override void Initialize() {
  Styles.Add(new FluentTheme { DensityStyle = DensityStyle.Compact });
  RStyles.Clean().Apply(this);
  RStyles.Accent(this, Color.Parse("#1f5fa8"), Color.Parse("#5b9be0"));
}

Clean rende piatti i bottoni con la classe toolbar (quelli di ToolbarButton), dà alle schede una scritta di 13 punti invece di 24, mette la spaziatura fissa alle caselle con la classe code e compatta alberi ed elenchi. Accent cambia il colore d'accento di Fluent nei due temi, con le sei tinte più chiare e più scure che Fluent usa al passaggio del mouse e alla pressione. Le figure di questo libro sono disegnate tutte dalla stessa applicazione, che non applica Clean per non cambiare l'aspetto degli altri capitoli: per questo nella figura i bottoni della barra hanno ancora lo sfondo grigio di Fluent.

Gli stili di RStyles si scrivono anche da sé, senza la lambda che new Style(x => ...) chiederebbe:

new RStyles()
  .For<Button>("toolbar").Set(Button.BackgroundProperty, Brushes.Transparent)
  .For<TextBlock>("caption").Set(TextBlock.FontSizeProperty, 11.0)
  .Apply(this);

35.6 Salvare: le curve in CSV, il grafico a 300 dpi

Il bottone «CSV» salva le due curve in un testo che si apre con qualunque foglio di calcolo:

// «CSV»: le due curve, sulle frequenze della prima
private void SalvaCsv() {
  using var w = new StreamWriter("filtro.csv");
  w.Write(RCsv.Write("f (Hz)", _grafico.Series));
}

La prima colonna sono le ascisse della prima curva; le altre curve, se hanno ascisse diverse, si interpolano su quelle. I numeri sono scritti nel formato che si rilegge identico, bit per bit, e una cella che contiene una virgola o le virgolette va fra virgolette, come vuole lo standard del CSV (RFC 4180). RCsv.Write accetta anche colonne di numeri qualunque e una RTable; RCsv.Read restituisce le righe di un testo CSV.

Il bottone «PNG» salva il grafico per un documento stampato:

// «PNG»: un grafico nuovo con le stesse curve, largo 16 cm a 300 dpi
private void SalvaPng() {
  var copia = new RChart { TitleY = "dB", UnitX = "Hz", LogX = true, MinY = -60, MaxY = 5 };
  copia.Series.AddRange(_grafico.Series);
  copia.Limits.AddRange(_grafico.Limits);
  copia.Markers.AddRange(_grafico.Markers);
  RImage.SavePng(copia, "filtro.png", RImage.Dip(16), RImage.Dip(10), 300);
}

Una figura stampata deve avere almeno 300 punti per pollice alla misura in cui va sulla pagina, se no si vede sgranata. RImage.Dip(16) dice quanti dip (le unità di Avalonia, 96 per pollice) è largo un oggetto di 16 cm; SavePng disegna il controllo, anche senza metterlo in una finestra, a 300 dpi: 1890 pixel di larghezza. E scrive la risoluzione dentro il PNG, così il programma di impaginazione mette la figura alla misura giusta (RImage.Dpi la rilegge). Si disegna una copia del grafico, e non quello della finestra, perché un controllo ha un solo genitore e una sola misura alla volta (capitolo 42).

35.7 Perché sono fatti così

  • Disegnati a mano, come i grafici del capitolo 29: niente librerie di grafici da installare, ogni pixel si spiega, e senza schermo si provano con il mouse finto (TEST_RAVAL\ProveGrafica.cs).
  • Eventi con il valore utile: MarkersChanged, SelectionChanged(int), Changed(string, string) si collegano a metodi con un nome, come gli eventi di RAva del capitolo 15.
  • I colori dalle risorse del tema, sempre con una riserva: lo stesso codice va bene nel tema chiaro e nello scuro, e anche in un'applicazione che quelle risorse non le ha.

Esercizi

  1. Aggiungi una terza riga all'elenco, «Guadagno» in dB, e sommalo alle due curve.
  2. Metti la fase del filtro (-celle · atan(ωRC), in gradi) sull'asse destro, con RightAxis = true e TitleY2 = "°".
  3. Collega SelectionChanged della tabella a un metodo che sposta in cima la riga scelta (Select, Rows).
  4. Al bottone «Apri», leggi con RCsv.Read un file salvato da «CSV» e mostralo come terza curva.

36. Il log e le impostazioni

Cosa imparerai: a far raccontare al programma cosa fa, con RLog e la finestra RLogView, e a fargli ricordare le scelte dell'utente in un file JSON o INI con RSettings: la finestra che riapre dov'era e il menu dei file recenti.

Finché il programma gira sul tuo computer, il debugger ti dice tutto. Quando gira su quello di un cliente, l'unica cosa che vi resta è quello che il programma ha scritto: il log. E un programma che a ogni avvio ha dimenticato la cartella dell'ultimo progetto, la misura della finestra e la porta del server non piace a nessuno: servono le impostazioni. Sono le prime due cose che un programma vero si scrive, e in RAVAL ci sono già.

Il registro di un programma, dal vivo
Il registro di un programma, dal vivo

Il codice è in ESEMPI_RAVAL\Esempi\Registro.cs; la scheda completa delle classi in DOC\RApp.md.

36.1 Scrivere il log

RLog.Start("Registro", "1.0.0", "log/registro.log");
RLog.Info("impostazioni lette da " + _impostazioni.Path);
RLog.Debug("porta del server: " + porta);
RLog.Warning("la misura ha 3 punti fuori scala");

Start apre il file (e la sua cartella, se manca) e scrive la prima riga: nome e versione del programma, nome del computer, sistema operativo, versione di .NET e numero del processo. Quando un cliente vi manda un log, la prima cosa da sapere è quale versione girava e dove: è scritta lì, a ogni avvio.

Ogni riga ha un livello: Error (qualcosa non è riuscito), Warning (qualcosa di strano che il programma ha saputo gestire), Info (cosa fa: avvio, file aperti, collegamenti), Debug e Trace (i dettagli per chi cerca un difetto). RLog.Level dice il livello meno importante che si scrive: con Info, il valore predefinito, le righe Debug e Trace non costano niente. Nel file una riga è così:

2026-11-06 10:58:46.988 ERR [1] salvataggio non riuscito: System.IO.IOException: il disco è pieno

data e ora al millesimo, livello, numero del thread che l'ha scritta, testo. Un errore si scrive con la sua eccezione, che porta con sé il tipo, il messaggio e lo stack:

// «Errore»: l'eccezione va nel log con il suo tipo e il messaggio
private static void ScriviErrore() {
  try {
    throw new IOException("il disco è pieno");
  } catch (IOException e) {
    RLog.Error("salvataggio non riuscito", e);
  }
}

Sulla console le righe sono colorate: l'ora attenuata, la sigla del livello nel suo colore (errori rossi, avvisi arancio, Info blu, dettagli grigi) e il testo nel colore del livello. Una riga si può anche colorare a mano, per farla trovare a colpo d'occhio fra cento altre:

// «Colore»: una riga Info nel colore scelto, per farla trovare a colpo d'occhio
private static void ScriviColore() => RLog.Info("misura completata: 241 punti", "#2b8a3e");

In Windows Terminal, e nei terminali che lo dichiarano (COLORTERM=truecolor), i colori sono quelli esatti; nella vecchia console di Windows sono i più vicini fra i suoi sedici. Quando l'uscita del programma va in un file, i colori spariscono da soli: sarebbero solo caratteri strani in mezzo al testo. Anche il file del log è testo semplice, da leggere con qualunque programma.

RLog.CatchUnhandled(), chiamato una volta all'avvio, scrive anche le eccezioni che nessuno prende: quelle che chiudono il programma, che altrimenti sparirebbero con lui. Alla fine si chiama RLog.Stop(), che scrive le righe rimaste e chiude il file.

36.2 Perché il log ha un thread suo

Chi chiama RLog.Info non scrive sul disco: mette la riga in una coda e torna subito. Un thread del log prende le righe una alla volta e le scrive sulla console e nel file. È lo schema produttore e consumatore: molti thread producono righe, uno solo le consuma, e fra loro c'è una coda sicura fra thread (BlockingCollection, che fa aspettare il consumatore quando è vuota). Ha tre vantaggi:

  • una riga di log costa uguale sul thread dell'interfaccia e su un thread di lavoro: il disco lento non blocca la finestra;
  • le righe di thread diversi non si mescolano mai a metà, perché le scrive un thread solo;
  • il bottone «Lavoro» dell'esempio fa partire un thread che scrive dieci righe, e non serve nessun lock.

Il file ruota: quando supera MaxBytes (10 MB) diventa registro.1.log, il vecchio .1 diventa .2, e così via fino a MaxFiles (5). Un programma che resta acceso per mesi non riempie il disco.

36.3 Il log in una finestra: RLogView

Vista = new DockPanel().Children(barra.Dock(Dock.Top), new RLogView().Margin(8));

RLogView mostra il log dal vivo, con gli stessi colori della console e quelli scelti a mano. Il menu a sinistra sceglie il livello mostrato, «Pause» smette di seguire l'ultima riga, «Clear» svuota la vista (il file resta). Quando la vista entra in una finestra mostra anche le ultime righe scritte prima (RLog.Recent(), le ultime 2000): si può aprire la finestra del log quando c'è un problema e vedere cosa è successo.

Le righe arrivano sul thread del log, e i controlli si toccano solo dal thread dell'interfaccia (capitolo 23). La vista mette le righe in una coda sua e chiede al Dispatcher un solo lavoro, che sposta tutte quelle arrivate nel frattempo: mille righe in un colpo costano un aggiornamento, non mille. L'elenco è virtualizzato (capitolo 18): si costruiscono solo le righe visibili.

36.4 Le impostazioni

_impostazioni = RSettings.Open(Path.Join(cartella, "registro.json"));
int porta = _impostazioni.GetInt("server.port", 5080);
bool dettagli = _impostazioni.GetBool("log.debug", true);

RSettings.Open legge il file; con l'estensione «.ini» è un file INI, con le altre un file JSON. Le chiamate sono le stesse per i due formati. Ogni lettura ha il suo predefinito: se il file non c'è, se la chiave manca, se il valore non è un numero, il programma va avanti con il valore che ha scritto il programmatore.

Le chiavi sono percorsi con i punti. Dopo il primo avvio il file JSON è così:

{
  "server": {
    "port": 5080
  },
  "log": {
    "debug": true
  }
}

La porta non l'abbiamo scritta noi: una chiave letta e non trovata si aggiunge al file con il suo predefinito (AddMissing). Dopo il primo avvio il file elenca tutte le opzioni che il programma legge, e chi lo apre per cambiarle le trova tutte. Lo stesso programma con un file INI scriverebbe:

[server]
port = 5080

[log]
debug = true

e la sezione è tutto quello che sta prima dell'ultimo punto della chiave. Nel file INI i commenti (; e #), le righe vuote e l'ordine delle chiavi restano dove li ha messi l'utente: cambiare un valore riscrive solo la sua riga.

Per scrivere si usa Set:

_impostazioni.Set("server.port", 5081);

Set non scrive il file subito: lo salva mezzo secondo dopo l'ultima modifica (SaveDelayMs). Una finestra trascinata manda centinaia di posizioni al secondo, e il file si scrive una volta sola. Il salvataggio scrive un file temporaneo e poi lo rinomina: se il programma si chiude a metà, resta il file vecchio, non uno spezzato. E un file che non si riesce a leggere non si butta: diventa registro.json.bad, il log lo dice, e il programma parte con i predefiniti.

Dove si mettono le impostazioni di un programma installato? Non nella sua cartella, che spesso è di sola lettura: RSettings.Folder("Registro") dà la cartella giusta per l'utente (%APPDATA%\Registro su Windows, ~/.config/Registro su Linux e macOS).

36.5 La finestra che si ricorda, i file recenti

public FinestraPrincipale(RSettings s) {
  s.Remember(this, "window.main");                   // prima di Show
  _recenti = new RRecentFiles(s, "files.recent");
  _recenti.Opened += Apri;                           // un metodo con il percorso
  _recenti.Fill(_menuRecenti);                       // le voci di File → Recenti
}

Remember rimette la finestra dove l'utente l'aveva lasciata, con la sua misura, e se era ingrandita la riapre ingrandita. Ricorda la misura di quando la finestra era normale: ridotta, torna al suo posto e non alla misura dello schermo. Se lo schermo su cui stava non c'è più (il portatile staccato dal monitor), la finestra si apre al centro dello schermo principale invece che fuori dalla vista.

RRecentFiles tiene gli ultimi file aperti, il più recente in cima; un file riaperto torna in cima invece di comparire due volte. Fill riempie un menu con una voce per file, e lo tiene aggiornato; dopo ogni apertura o salvataggio si chiama _recenti.Add(percorso).

36.6 Il tema: chiaro, scuro o come il sistema

Il capitolo 17 spiega come funzionano i temi di Avalonia. In un programma vero servono anche tre cose in più: lasciare scegliere all'utente, ricordare la sua scelta, e seguire il sistema operativo se l'utente preferisce così.

public override void Initialize() {
  Styles.Add(new FluentTheme());
  RThemes.Standard().Color("Marchio", light: "#8f4a1c", dark: "#e39a5c").Apply(this);
  RTheme.Remember(impostazioni);                 // «ui.theme», applicato subito
}

RTheme.Fill(menuTema);                           // Chiaro / Scuro / Come il sistema
barra.Children.Add(RTheme.Button());             // il sole e la luna

RThemes.Standard() dà i colori che usano i controlli di RAVAL (Back, Panel, Ink, InkSoft, Border, Bar, ChartBack...), nei due temi. È la tavolozza del simulatore ASTRIP. Lo scuro è una grafite con una punta di blu, su tre livelli di luce: la barra più scura, poi i documenti, poi i pannelli più chiari. Così l'occhio vede dove finisce una zona senza bisogno di bordi pesanti. Il testo non è bianco puro, che sul fondo scuro abbaglia.

RTheme.Mode cambia il tema di tutta l'applicazione mentre il programma gira, finestre già aperte comprese. Con System decide il sistema operativo, e il programma lo segue quando cambia. Remember legge la scelta dalle impostazioni e la riscrive a ogni cambio. Changed avvisa chi deve rifare qualcosa a mano.

Un controllo disegnato a mano cambia con il tema solo se chiede i colori mentre disegna (ThemeBrush dentro Render) e si ridisegna al cambio (OnThemeChanged). Un pennello preso all'avvio e tenuto in un campo resta del colore del primo tema: è l'inciampo che ASTRIP ha trovato sulle sue carte.

36.7 Le lingue: RLang

RLang.Open(RSettings.Folder("Misure") + "/lingue", "it");    // «»: la lingua del sistema
var file = new MenuItem().Tr("menu.file", "_File");         // cambia con la lingua
string s = RLang.T("misura.salvata", "Measure saved");
string n = RLang.T("file.aperti", "{0} files open", 3);
RLang.Use("en");                                             // tutti i testi legati cambiano

Una lingua è un file nella cartella, «it.json» o «it.ini», letto con RSettings. Le chiavi sono percorsi con i punti, come nelle impostazioni. Il testo scritto nel codice è il predefinito, e per la prima lingua non serve nessun file. Una chiave chiesta e non trovata viene scritta nel file con il suo testo predefinito: dopo una prova del programma, il file di una lingua nuova elenca tutti i testi, pronti da tradurre. Il file nasce mezzo secondo dopo la prima chiave aggiunta, oppure con RLang.Save().

Tr lega un controllo a una chiave, scegliendo la proprietà giusta in base al controllo:

  • Text per un TextBlock;
  • Header per una voce di menu;
  • Title per una finestra;
  • PlaceholderText per una casella;
  • Content per un bottone.

Quando la lingua cambia, ogni controllo legato prende il testo nuovo. I legami sono deboli: un controllo buttato via non resta in memoria per colpa loro. I testi di RAVAL stessa hanno le chiavi «raval.*» e l'italiano già pronto: con Use("it") anche i bottoni e i titoli di RDialog e il menu del tema sono in italiano.

Esercizi

  1. Aggiungi all'esempio un CheckBox «Dettagli» che cambia RLog.Level fra Info e Debug e salva la scelta in log.debug.
  2. Cambia il file in registro.ini e guarda come lo scrive RSettings; aggiungi a mano un commento e controlla che resti al suo posto dopo un Set.
  3. Chiama RLog.CatchUnhandled() e fai un bottone che lancia un'eccezione da un thread: cercala nel file.
  4. Scrivi un programma con un menu File → Recenti fatto con RRecentFiles, e controlla che dopo un riavvio la lista ci sia ancora.

37. Gli alberi: JSON, XML e cartelle

Un file di progetto, una risposta JSON di un server, la configurazione di un apparato in XML, le cartelle di un disco: sono tutti alberi, e il modo naturale di guardarli è un TreeView. In Avalonia il TreeView si riempie di solito con un modello di dati e un TreeDataTemplate scritto in XAML. RTree fa lo stesso lavoro in codice: si danno i nodi, e il controllo pensa alle voci sullo schermo.

L'albero di un progetto in JSON: a destra il nodo scelto, con il percorso e il valore
L'albero di un progetto in JSON: a destra il nodo scelto, con il percorso e il valore

L'esempio completo è ESEMPI_RAVAL/Esempi/Albero.cs: a sinistra il progetto di un filtro, in JSON o in XML, con la casella di ricerca; a destra il percorso e il valore del nodo scelto.

37.1 I nodi: RTreeNode

Un nodo ha un testo, un valore che si vede più tenue accanto al testo, e i figli:

var server = new RTreeNode("server").Add(
  new RTreeNode("host", "localhost"),
  new RTreeNode("port", "5080"));

Tag tiene quello che serve al programma (l'oggetto da cui il nodo è nato), Color colora il testo, Key è il pezzo di percorso del nodo (il testo, se non lo si cambia). Path(".") mette insieme le chiavi dalla radice: «server.port», e per gli elementi di una lista «componenti[1].valore», senza punto davanti alla parentesi. Sono le stesse chiavi che legge RSettings (capitolo 36).

I nodi sono i dati, non le voci sullo schermo: si possono costruire, cercare e percorrere (Children, Child("port"), Descendants()) anche senza una finestra.

37.2 Da JSON e da XML

RTree albero = RTree.FromJson(testo);        // i membri dell'oggetto in cima
RTree albero = RTree.FromXml(testo);         // l'elemento radice in cima
albero.LoadJson(altroTesto);                 // lo stesso controllo, un altro file

Nel JSON gli oggetti e le liste diventano nodi con i membri come figli, e il valore dice quanti sono: «{3}» per un oggetto, «[5]» per una lista; numeri, testi e true/false sono foglie con il loro valore. I commenti e la virgola prima di una parentesi chiusa sono accettati, come nei file di impostazioni. Nell'XML ogni elemento è un nodo, con gli attributi come primi figli («@nome») e il testo come valore. Il testo lo legge RNode, del paragrafo che segue: ogni nodo dell'albero tiene nel suo Tag l'RNode da cui è nato.

37.3 I dati senza l'albero: RNode

Spesso un file JSON o XML non si deve guardare, si deve leggere: la porta di un server, il valore di un componente, la lista dei file recenti. Per questo c'è RNode, l'albero dei dati senza niente di grafico. Non usa nessun tipo di Avalonia, quindi va bene anche in un servizio, in un programma a riga di comando o in una prova.

RNode p = RNode.Load("progetto.json");           // «.xml»: XML; il resto: JSON
string taglio = p.GetString("specifiche.taglio", "100 MHz");
int punti = p.GetInt("simulazione.punti", 201);
foreach (RNode c in p["componenti"]!.Children)
  Console.WriteLine(c.GetString("nome", "?") + " " + c.GetString("valore", ""));

p.Find("simulazione.punti")!.Set(801);           // un numero
p["componenti"]!.Add(RNode.Object().Add(RNode.Text("nome", "C4"), RNode.Text("valore", "10p")));
p.Save("progetto.json");                         // un file temporaneo, poi rinominato

Le letture hanno un predefinito, come quelle di RSettings (capitolo 36): una chiave che manca non ferma il programma. Il percorso è lo stesso dappertutto: «specifiche.taglio», «componenti[1].valore», e nell'XML «server/@port» con il separatore «/» (p.GetInt("server/@port", 80, "/")). La radice di un documento JSON non ha nome e nel percorso non compare.

Ogni nodo ha un tipo (Kind: oggetto, lista, valore, elemento, attributo), un nome, un valore come testo e i figli. Source tiene il JsonNode o l'XElement da cui è stato letto, per chi ha bisogno del resto. Un nodo aggiunto a una lista prende il nome «[n]» dalla sua posizione. Un testo cambiato con Set si riscrive fra virgolette, un numero o un sì/no senza.

Si può anche passare da un formato all'altro. ToJson() scrive anche un albero letto dall'XML: gli attributi diventano membri «@nome», il testo diventa «#text» e gli elementi che si ripetono diventano una lista. ToXml() scrive un JSON come XML: la radice si chiama «root» e gli elementi di una lista «item».

Per vedere un RNode nella finestra: albero.LoadNode(p), oppure RTreeNode.FromNode(p) per metterlo sotto un altro nodo.

37.4 Il controllo: scegliere, aprire, cercare

albero.Searchable = true;                    // la casella di ricerca sopra
albero.SelectionChanged += Scelto;           // void Scelto(RTreeNode? n)
albero.Opened += Apri;                       // doppio clic o Invio
albero.Select(albero.Find("componenti[1].valore", "."));

Find legge un percorso scritto come lo scrive Path; Select apre i nodi sopra quello scelto e lo porta in vista. Expand, ExpandAll(livelli) e CollapseAll aprono e chiudono. Il filtro (Filter, o la casella di ricerca) tiene i nodi il cui testo o valore contiene le parole cercate, senza badare alle maiuscole, con i nodi sopra (aperti, così quello che si è trovato è in vista) e quelli sotto; Found dice quanti sono.

37.5 Le voci nascono quando servono

La voce di un nodo (TreeViewItem) si costruisce solo quando il nodo sopra viene aperto. Finché resta chiuso, al suo posto c'è una voce finta che serve solo a far vedere la freccia. Un JSON di centomila valori si apre in una frazione di secondo, perché sullo schermo c'è una voce sola: la prova (TEST_RAVAL/ProveAlbero.cs) lo misura.

Lo stesso vale per i dati, quando leggerli costa: un nodo può avere un caricatore al posto dei figli, un metodo che li legge la prima volta che servono.

var disco = RTreeNode.FromFolder("C:\\");    // le cartelle si leggono quando si aprono
albero.Add(disco);

var ordini = new RTreeNode("ordini", LeggiOrdini);
// IEnumerable<RTreeNode> LeggiOrdini(RTreeNode nodo) { ... la query ... }

FromFolder mostra prima le cartelle e poi i file con la misura (RFormat.Si, «2.05 kB»). Un caricatore che fallisce (una cartella senza permessi) non rompe l'albero: il nodo mostra un figlio rosso con l'errore, e il log ne tiene traccia. Reload() dimentica i figli letti e li fa rileggere. Il filtro cerca solo fra i figli già letti: non legge un disco intero per una ricerca.

Un nodo cambiato si vede subito: Add, Remove e i nuovi Text o Value aggiornano le voci che ci sono già.

37.6 Perché è fatto così

  • Niente modello di dati né modelli in XAML. Con ItemsSource e un TreeDataTemplate servono una classe del modello, un modello per le voci e i binding fra le due; qui le voci si fanno a mano, in un metodo che si legge dall'alto in basso.
  • Tre strati: i dati, i nodi, le voci. RNode sono i dati, senza Avalonia: si leggono in un altro thread (un file grande in un Task) e servono anche ai programmi senza finestre. RTreeNode è il nodo dell'albero visuale, con il colore e i caricatori. Le voci (TreeViewItem) esistono solo per la parte di albero che si vede.
  • Il valore tenue è trasparente, non grigio. Un grigio fisso sulla riga scelta, azzurra, quasi sparisce; con l'opacità al 60% il valore prende il colore del testo della riga, chiaro o scuro, e si legge sempre.

Esercizi

  1. Aggiungi all'esempio un bottone «Cartella» che mostra le cartelle dei documenti con un caricatore tuo, che scrive nel log una riga Debug ogni volta che viene chiamato: aprendo i nodi, guarda quando arriva.
  2. Con Opened apri un file scelto nell'albero delle cartelle e mostrane la misura in una RPropertyList.
  3. Carica nell'albero il file delle impostazioni del capitolo 36 e fai in modo che il percorso del nodo scelto sia proprio la chiave da passare a GetString.

38. Dialoghi e file in una riga

Nel capitolo 19 la finestra di conferma è una classe di trenta righe, e scegliere un file vuol dire costruire le opzioni, i tipi e leggere il percorso da un IStorageFile. Sono cose che un programma fa dieci volte, e ogni volta uguali. RDialog e RFiles le riducono a una riga ciascuna.

La conferma di «Svuota» dell'esempio: il segno, la domanda, «Annulla» e il bottone che conferma
La conferma di «Svuota» dell'esempio: il segno, la domanda, «Annulla» e il bottone che conferma

L'esempio completo è ESEMPI_RAVAL/Esempi/Appunti.cs, un blocco note in cui ogni bottone della barra è un metodo async di poche righe.

38.1 I dialoghi: RDialog

await RDialog.Info(Vista, "Salvato.");
if (!await RDialog.Confirm(Vista, "Cancellare tutto il testo?", "Svuota")) return;
string? titolo = await RDialog.Ask(Vista, "La prima riga degli appunti:", righe[0]);
int a = await RDialog.Choose(Vista, RDialogKind.Warning, "Non salvato", "Salvare le modifiche?",
                             "Annulla", "Scarta", "Salva");
await RDialog.Error(Vista, "Gli appunti non sono stati salvati.", e);

Il primo argomento è un controllo qualunque della finestra: il dialogo si apre sopra quella finestra, al centro, e finché non si chiude il resto della finestra non risponde (è modale). Il controllo deve già essere in una finestra, se no la chiamata dà un errore chiaro.

I bottoni stanno a destra, nell'ordine dato. L'ultimo è quello che conferma: ha il colore d'accento e risponde a Invio. Esc e la crocetta della finestra non scelgono nessun bottone: Confirm dà false, Ask dà null, Choose dà −1. Il segno a sinistra dice di che si tratta (RDialogKind): «i» blu per le informazioni, «!» arancio per gli avvisi, «×» rosso per gli errori, «?» blu per le domande.

Error con un'eccezione fa tre cose. Scrive il messaggio dell'eccezione sotto il testo, mette l'eccezione intera sotto «Dettagli», dove si può copiare per chi deve correggere, e la registra nel log con RLog.Error: quando l'utente chiude il riquadro, l'errore non è perso.

I testi dei bottoni e dei titoli sono in inglese, come il resto della libreria. Un programma in italiano li cambia una volta, all'avvio:

RDialog.Cancel = "Annulla";
RDialog.ConfirmTitle = "Conferma";
RDialog.ErrorTitle = "Errore";
RDialog.Details = "Dettagli";

Chi vuole qualcosa di diverso sotto il testo (una lista, una casella di spunta «non chiedere più») costruisce un RDialogBox con il suo controllo e lo apre con RDialog.Show. L'evento RDialog.Opening consegna ogni dialogo prima che si apra: le prove (TEST_RAVAL/ProveDialoghi.cs) lo usano per premere i bottoni con Pick, come farebbe l'utente.

38.2 I file: RFiles

string? f = await RFiles.OpenAsync(Vista, "Apri un file di testo", "Testo|*.txt;*.md", "Tutti|*.*");
if (f == null) return;                              // l'utente ha rinunciato
_testo.Text = RFiles.ReadText(f);

string? s = await RFiles.SaveAsync(Vista, "Salva gli appunti", "appunti.txt", "Testo|*.txt");
if (s != null) RFiles.WriteText(s, _testo.Text ?? "");

I tipi di file si scrivono come testo, «nome|.a;.b». La risposta è il percorso, che StreamReader e StreamWriter usano direttamente, oppure null se l'utente rinuncia. OpenManyAsync dà una lista di percorsi e FolderAsync una cartella. Il selettore successivo si apre nella cartella dell'ultimo file scelto (RFiles.LastFolder). SaveAsync chiede prima di sostituire un file che c'è già, e aggiunge l'estensione del primo tipo se l'utente non ne scrive una.

ReadText e WriteText leggono e scrivono un file di testo intero in UTF-8. WriteText crea la cartella se manca e scrive prima in un file temporaneo, che poi rinomina: se il programma si ferma a metà, resta il file vecchio, intero.

38.3 Perché sono fatti così

  • await e non una funzione da richiamare. Il codice si legge dall'alto in basso: la domanda, la risposta, quello che si fa dopo. Con async void nei gestori dei bottoni (capitolo 12) la finestra resta viva mentre il dialogo aspetta.
  • La risposta è nel dialogo, non nel valore di Close. ShowDialog<int> restituisce 0 quando la finestra si chiude con la crocetta, cioè lo stesso valore del primo bottone. RDialogBox tiene la risposta in Answer, che vale −1 finché non si preme un bottone: chiudere non è mai scambiato per una scelta.
  • I percorsi come testo. IStorageFile serve dove i file non sono sul disco (un telefono, una sandbox); un programma da scrivania vuole un percorso. Un file senza percorso sul disco dà null, come una rinuncia.

Esercizi

  1. Aggiungi al blocco note la domanda «Salvare le modifiche?» con Choose e i tre bottoni Annulla, Scarta, Salva, quando si apre un altro file dopo aver cambiato il testo.
  2. Con OpenManyAsync apri più file e metti i loro nomi in una RTable con la misura di ciascuno.
  3. Costruisci un RDialogBox con una CheckBox «Non chiedere più» sotto il testo, e salva la scelta con RSettings.

39. Appunti e trascinamento

Due modi di far entrare e uscire i dati da un programma senza passare dai menu. Gli appunti servono a copiare un valore, o una tabella di risultati da incollare in un foglio di calcolo. Il trascinamento serve a lasciare i file di misura sulla finestra invece di cercarli con «Apri». RClipboard e RDrop li fanno in una riga.

Lo scambio: il riquadro su cui lasciare i file, colorato mentre un file accettato ci passa sopra
Lo scambio: il riquadro su cui lasciare i file, colorato mentre un file accettato ci passa sopra

L'esempio completo è ESEMPI_RAVAL/Esempi/Scambio.cs: i file lasciati sul riquadro diventano righe della tabella, e la tabella si copia e si incolla da e verso un foglio di calcolo.

39.1 Gli appunti: RClipboard

await RClipboard.SetText(Vista, "1.296 GHz");
string? t = await RClipboard.GetText(Vista);              // null se negli appunti non c'è testo

await RClipboard.SetTable(Vista, righe);                  // si incolla in un foglio, una cella per valore
List<string[]> incollate = await RClipboard.GetTable(Vista);

await RClipboard.SetImage(Vista, RImage.Render(grafico, 600, 400, 192));
List<string> file = await RClipboard.GetFiles(Vista);     // i file copiati in Esplora risorse

In Avalonia 12 gli appunti appartengono alla finestra (TopLevel.Clipboard) e lavorano con formati e oggetti del disco; qui, come per i dialoghi, il primo argomento è un controllo qualunque della finestra e le risposte sono testi, righe e percorsi.

Una tabella va negli appunti come la copia un foglio di calcolo: una tabulazione fra le celle e un a capo dopo ogni riga. È il formato che tutti i fogli capiscono, nei due sensi: i risultati di una misura vanno in un foglio, e una colonna copiata da un foglio torna come righe (Cells fa il lavoro su un testo qualunque). Una tabulazione o un a capo dentro una cella diventano spazi, altrimenti la cella si spezzerebbe.

Un grafico si copia come immagine disegnandolo con RImage.Render: a 192 dpi, il doppio dello schermo, resta nitido quando lo si incolla in un documento.

39.2 Il trascinamento: RDrop

RDrop.Files(Area, Arrivati, "*.csv", "*.s2p", "*.s1p");   // void Arrivati(List<string> file)
RDrop.Text(note, Incolla);                                // void Incolla(string testo)

Il controllo accetta soltanto quello che sa usare. Mentre si trascinano dei file sopra di lui, il puntatore mostra «copia» se almeno uno corrisponde ai modelli e «vietato» altrimenti. Quando l'utente lascia andare, il metodo riceve soltanto i percorsi giusti. I modelli sono quelli dei nomi di file: * per qualunque sequenza di caratteri, ? per un carattere solo, senza badare alle maiuscole. Senza modelli va bene ogni file.

Mentre sopra il controllo c'è qualcosa che accetta, il controllo ha la classe drop. Con RStyles.Clean, un Border con la classe droparea è un riquadro con il bordo grigio; con drop il bordo diventa blu e lo sfondo azzurrino, come nella figura. Stop() toglie tutto e il controllo non accetta più niente.

39.3 Perché sono fatti così

  • Il foglio di calcolo come formato di scambio. Chi misura vuole i numeri in un foglio, e chi ha i dati in un foglio vuole incollarli nel programma; la tabulazione funziona senza aprire file, nei due sensi.
  • Il controllo dice subito se accetta. Un file sbagliato è rifiutato mentre è ancora sotto il puntatore, non con un messaggio d'errore dopo averlo lasciato.
  • Il bordo dallo stile, non dal controllo. Un valore scritto sul controllo (BorderBrush = ...) vince su ogni stile, anche su quello di drop: il riquadro dell'esempio, con il bordo messo a mano, si colorava dentro ma il bordo restava grigio. Per questo bordo e angoli vengono dalla classe droparea.

Esercizi

  1. Aggiungi allo scambio un bottone «Copia grafico» che mette negli appunti un RChart disegnato con RImage.Render, e incollalo in un documento.
  2. Fai accettare al blocco note del capitolo 38 un file .txt trascinato sopra, che lo apre come «Apri».
  3. Con RClipboard.GetTable incolla due colonne copiate da un foglio (frequenza e S21) e disegnale in un RChart.

40. Tabelle grandi e oggetti da modificare

RTable, del capitolo 35, crea un TextBlock per ogni cella: va benissimo per cento righe, ma con diecimila diventa lenta, e con centomila la finestra non si apre più. Un archivio di misure, il registro di un apparato o uno sweep di ventimila punti chiedono un'altra tabella. Allo stesso modo RPropertyList, con le coppie nome e valore aggiunte a mano, costringe a ricopiare i valori in un oggetto ogni volta che cambiano. Il capitolo risolve tutte e due le cose.

L'archivio: ventimila righe ordinate per S21, e a destra le impostazioni di un oggetto C#
L'archivio: ventimila righe ordinate per S21, e a destra le impostazioni di un oggetto C#

L'esempio completo è ESEMPI_RAVAL/Esempi/Archivio.cs.

40.1 La tabella virtualizzata: RVirtualTable

var t = new RVirtualTable();
t.LoadCsv(RFiles.ReadText("sweep.csv"));      // la prima riga: le intestazioni
t.SelectionChanged += Scelta;                 // void Scelta(int riga)
t.Filter = "C1";                              // solo le righe con C1 in una cella
t.Sort(2, false);                             // per la terza colonna

Le righe sono le voci di un ListBox virtualizzato: sullo schermo si costruiscono solo quelle che si vedono, una quindicina, e mentre si scorre si rifanno quelle che entrano. Così un CSV di centomila righe si apre in un terzo di secondo: la prova (TEST_RAVAL/ProveVirtuale.cs) lo misura, e conta le righe costruite.

Le colonne hanno una larghezza fissa, misurata sul testo più lungo dell'intestazione e delle prime mille righe (SetWidth per sceglierla). L'intestazione scorre di lato insieme alle righe. LoadCsv allinea a destra le colonne di soli numeri. Un clic su un'intestazione ordina: per valore se ogni cella è un numero, anche con il prefisso SI, se no in ordine alfabetico; un secondo clic rovescia. Centomila righe si ordinano in meno di due decimi di secondo, perché ogni cella si legge una volta sola e le celle che non sono numeri si scartano senza eccezioni (RFormat.TryParse).

Rows tiene tutte le righe; Shown quelle che passano il filtro, nell'ordine in cui si vedono. SelectedIndex e l'evento indicano sempre una riga di Rows, comunque la tabella sia ordinata o filtrata. Text() dà le righe mostrate con le tabulazioni, pronte per RClipboard.SetText.

40.2 Le proprietà di un oggetto: RPropertyList.Show

public sealed class Impostazioni {
  public string Nome { get; set; } = "Filtro 100 MHz";
  [DisplayName("Inizio"), Description("Hz; si può scrivere 1 MHz")]
  public double Inizio { get; set; } = 1e6;
  public int Punti { get; set; } = 20001;
  public Sweep Sweep { get; set; } = Sweep.Logaritmico;
  public bool Media { get; set; } = true;
  public double Durata => Math.Round(Punti * 25e-6, 3);     // solo get: in sola lettura
}

_impostazioni.Show(new Impostazioni());
_impostazioni.Changed += Cambiata;            // dopo che il valore è nell'oggetto
_impostazioni.Rejected += Rifiutata;          // un testo che non si converte, con il motivo

Show elenca le proprietà pubbliche che stanno in una casella: testi, numeri, sì/no ed enum, nell'ordine della classe. Quando si conferma un valore, con Invio o uscendo dalla casella, il testo torna nell'oggetto convertito nel tipo della proprietà:

  • i numeri si leggono come li legge RFormat, quindi «1 MHz», «4k7» e «1.5p» sono numeri;
  • i sì/no si scrivono come in RSettings;
  • gli enum per nome, senza badare alle maiuscole.

Un testo che non si converte, come «4.5» per un intero, è rifiutato: la casella torna al valore dell'oggetto e Rejected dice il motivo. Gli attributi di System.ComponentModel cambiano quello che si vede:

  • DisplayName per il nome mostrato;
  • Description per il suggerimento;
  • ReadOnly(true), o una proprietà senza set pubblico, per una casella che non si cambia;
  • Browsable(false) per non mostrarla.

Reload() rilegge l'oggetto quando è cambiato altrove: nell'esempio, la durata ricalcolata dopo che si sono cambiati i punti.

40.3 Perché sono fatti così

  • Righe come array di testo. Una tabella che mostra deve sapere scrivere, non conoscere il modello dei dati: con string[] la stessa tabella mostra un CSV, il risultato di una query o un registro.
  • I numeri dei tecnici, anche con l'unità. «10 MHz» scritto in una casella vale 1e7 anche quando nessuno dice che l'unità è «Hz»: RFormat.Parse, senza unità indicata, prova a tenere la prima lettera come prefisso e poi lascia cadere il resto («5 V» vale 5). I numeri molto grandi o molto piccoli si mostrano con il prefisso («1 M»), e si rileggono uguali.
  • L'oggetto è la verità. Dopo una conferma la casella mostra quello che c'è nell'oggetto, non quello che si è scritto: se un set arrotonda o rifiuta, lo si vede subito.

Esercizi

  1. Aggiungi all'archivio un bottone «Apri CSV» con RFiles.OpenAsync, e la possibilità di trascinare un CSV sulla tabella con RDrop.Files.
  2. Fai un bottone «Copia» che mette negli appunti le righe filtrate con t.Text(), e incollale in un foglio di calcolo.
  3. Mostra con Show un oggetto tuo con un set che rifiuta i valori negativi lanciando ArgumentException, e guarda il motivo che arriva a Rejected.

41. Altri grafici: barre, mappe, carta di Smith e polare

RChart, del capitolo 35, disegna curve su assi cartesiani. Un progetto a radiofrequenza ne chiede altri quattro:

  • la carta di Smith, per i coefficienti di riflessione;
  • il grafico polare, per le trasmissioni e i diagrammi d'antenna;
  • le barre, per confrontare valori di componenti;
  • la mappa di colori, per una grandezza su un piano, come la corrente su un circuito stampato.

Sono disegnati a mano come RChart, prendono i colori dalle stesse risorse del tema e si salvano a 300 dpi con SavePng.

Quattro grafici di un progetto RF: carta di Smith, diagramma d'antenna, barre, mappa della corrente
Quattro grafici di un progetto RF: carta di Smith, diagramma d'antenna, barre, mappa della corrente

L'esempio è ESEMPI_RAVAL/Esempi/GraficiRF.cs. La carta di Smith e il grafico polare vengono dal simulatore ASTRIP, portati in RAVAL e completati: ASTRIP stessa ora usa questi. Il capitolo del libro che spiega come si costruisce una carta di Smith ne descrive una versione didattica, più semplice.

41.1 La carta di Smith: RSmithChart

var s = new RSmithChart { Z0 = 50, ShowAdmittance = true };
s.Series.Add(new RComplexSeries("S11", gamma, frequenze));
s.Circles.Add(new RSmithCircle("VSWR 2", RSmithMath.Vswr(2), pennello) { Dashed = true });
s.Markers.Add(new RSmithMarker("M1", gamma[50], pennello, true) { Frequency = frequenze[50] });
s.MarkerMoved += Accorda;                        // void Accorda(RSmithMarker m)
La carta di Smith nel tema scuro: griglia di impedenza e di ammettenza, la regione instabile, il marcatore con le letture
La carta di Smith nel tema scuro: griglia di impedenza e di ammettenza, la regione instabile, il marcatore con le letture

Quello che la carta disegna:

  • le griglie: quella d'impedenza (r e x costanti) e, se si vuole, quella d'ammettenza, in rosa;
  • le curve: una per parametro, con le frequenze ai due capi;
  • i cerchi dell'utente: VSWR e Q costanti, e i cerchi di stabilità, guadagno e rumore. Fill colora l'interno di un cerchio, ad esempio la regione instabile;
  • i marcatori: ognuno scrive etichetta, frequenza, Z e VSWR. Quello trascinabile segue il mouse e avvisa con MarkerMoved: è il modo di accordare a mano;
  • la lettura sotto il puntatore: Γ, Z, Y, VSWR e perdita di ritorno.

Per muoversi: la rotella ingrandisce intorno al puntatore, un trascinamento sulla carta vuota sposta la vista, un doppio clic torna alla carta intera. Con MaxGamma = 2 la carta si allarga oltre |Γ| = 1, dove la resistenza è negativa: serve per i dispositivi instabili e gli oscillatori.

Le formule stanno in RSmithMath (cartella RF), che non usa Avalonia:

  • le conversioni: Gamma, Impedance, Admittance, Vswr, ReturnLoss;
  • i cerchi della griglia;
  • OutputStability e InputStability, con OutputStableInside per sapere da che parte si è stabili;
  • Rollett, che dà K e |Δ|;
  • UnilateralGain per i cerchi di guadagno e Noise per quelli di rumore.

Le prove le controllano con la loro definizione: sui punti di un cerchio di stabilità |Γin| vale 1, su quelli di un cerchio di rumore la cifra di rumore è quella chiesta.

41.2 Il grafico polare: RPolarChart

var p = new RPolarChart { Decibels = true, Range = 40, ZeroAngle = 90, Clockwise = true };
p.Series.Add(RComplexSeries.Pattern("Piano E", gradi, guadagnoDb, true));

Il modulo è la distanza dal centro e la fase è l'angolo. Di base lo zero sta a destra e gli angoli crescono in senso antiorario, come nel piano complesso: va bene per S21 in modulo e fase. Un diagramma d'antenna si disegna invece con lo zero in alto e in senso orario (ZeroAngle = 90, Clockwise), e in dB (Decibels). In dB il bordo è il massimo, arrotondato ai 5 dB, e il centro sta Range dB più giù, così si vedono anche i lobi a −30 dB. RComplexSeries.Pattern costruisce la curva da angoli e valori.

41.3 Le barre e la mappa di colori: RBarChart, RHeatMap

var b = new RBarChart { TitleY = "Q a 2.45 GHz" };
b.Categories.AddRange(new[] { "C1", "C2", "L1" });
b.Series.Add(new RBarSeries("misurato", new[] { 410, 380, 62.0 }));

var m = new RHeatMap { TitleX = "x, mm", TitleY = "y, mm", Unit = "A/m", Smooth = true };
m.SetValues(corrente, 0, 40, 0, 25);              // [riga, colonna], la riga 0 in basso

Le barre di una categoria stanno affiancate, una per serie, oppure impilate con Stacked. Partono da zero, quindi i valori negativi scendono, e scrivono il loro valore in cima.

La mappa trasforma la matrice in un'immagine con un pixel per valore, poi la stira sull'area: a quadretti, o sfumata con Smooth. Le scale di colore sono quattro:

  • Viridis, la predefinita: si legge bene anche stampata in grigio;
  • Heat;
  • BlueRed, per i valori intorno a un centro, come un errore o una fase;
  • Gray.

A destra c'è la barra della scala, con l'unità. Il puntatore legge il valore sotto di sé (ValueAt), e i valori NaN restano trasparenti.

41.4 Le curve di RChart: punti, tempi, dati dal vivo, zoom

RChart, del capitolo 35, fa anche il plotter XY, il registratore e l'oscilloscopio lento.

Il registratore: la temperatura di un forno degli ultimi dieci minuti, e la tensione di un diodo come nuvola di punti
Il registratore: la temperatura di un forno degli ultimi dieci minuti, e la tensione di un diodo come nuvola di punti
var nuvola = new RSeries("diodo", corrente, tensione, 1) { Style = RSeriesStyle.Points };

var c = new RChart { TimeAxis = true, TitleY = "°C" };
var forno = new RLiveSeries(c, "forno") { Window = 600 };     // gli ultimi dieci minuti
forno.Add(DateTime.Now, lettura);                            // da qualunque thread

L'esempio è ESEMPI_RAVAL/Esempi/Registratore.cs.

  • Punti e dispersione. Style vale Line, Points o LineAndPoints. Le ascisse possono venire in qualunque ordine: un ciclo d'isteresi, una traiettoria o una nuvola di misure si disegnano come sono. Solo una curva con le ascisse crescenti (Sorted) viene letta dai marcatori, perché lì a un'ascissa corrisponde un valore solo. At la legge per bisezione, quindi anche un milione di punti si legge subito.
  • L'asse dei tempi. Con TimeAxis le ascisse sono tempi: secondi dall'anno 1, quelli che scrive RTime.X(data). Le divisioni cadono su secondi, minuti, ore e giorni tondi, a partire dalla mezzanotte. Le etichette sono «10:15» o «10:15:30», e alla mezzanotte compare la data; un marcatore scrive data e ora per intero.
  • Dati dal vivo. RLiveSeries.Add si chiama da qualunque thread, ad esempio quello della porta seriale. I punti aspettano in coda, e un solo passaggio al thread dell'interfaccia li prende tutti insieme, come fa la finestra del log: mille punti al secondo costano pochi ridisegni. Window tiene gli ultimi Window secondi (o unità d'ascissa) e il grafico li segue; MaxPoints mette un tetto ai punti tenuti.
  • Zoom e spostamento. La rotella ingrandisce l'ascissa intorno al puntatore, con Maiusc l'ordinata. Un trascinamento sposta la vista e il doppio clic torna a tutti i dati, perché il clic fermo resta quello del marcatore. Mentre la vista è spostata, il registratore smette di seguire gli ultimi punti; da codice ci sono ZoomX, ZoomY, SetView, ResetView e i limiti fissi MinX e MaxX.
  • Etichette che non si toccano. Un'etichetta d'ascissa che toccherebbe la precedente si salta, mentre la linea della griglia resta. Su un asse logaritmico stretto restano le decadi.

41.5 Perché sono fatti così

  • Una volta sola, bene. La carta di Smith serve ad ASTRIP, agli strumenti di misura e ai libri: scritta una volta in RAVAL con le prove, non si corregge in tre posti.
  • Le formule separate dal disegno. RSmithMath non conosce Avalonia: un progetto calcola i cerchi senza aprire finestre, e le prove li controllano senza disegnarli.
  • Il punto, non la virgola. Come RChart (Culture): i numeri del grafico sono gli stessi della tabella e del CSV.

Esercizi

  1. Trascina il marcatore della carta e scrivi nella barra di stato l'impedenza letta in MarkerMoved.
  2. Aggiungi il cerchio di rumore a 2 dB di un transistor con RSmithMath.Noise e il cerchio di guadagno con UnilateralGain, e cerca a mano il punto che li tocca tutti e due.
  3. Disegna in una RHeatMap con la scala BlueRed la differenza fra due misure, con Min = −1 e Max = 1.

42. Gli inciampi di Avalonia 12

Cose che sono costate ore durante lo sviluppo di programmi veri e che non sono scritte nella documentazione di Avalonia. Sapendole prima, si risparmia il tempo.

Un metodo d'estensione può chiamarsi come una proprietà. Si legge spesso il contrario, e anche questo tutorial lo diceva: è falso. In una chiamata con le parentesi il compilatore scarta la proprietà, che non si può chiamare, e trova l'estensione: c.Margin(8) chiama Margin(this T, double) (misurato con Avalonia 12.1.4). Per questo le estensioni di RAva hanno i nomi delle proprietà che impostano (capitolo 32).

Dentro la classe delle estensioni, un metodo con il nome di un tipo nasconde il tipo. In RAva c'è il metodo Border, e allora Border scritto dentro RAva è il metodo, non la classe di Avalonia: c is Border dà l'errore CS0118. Lì dentro i tipi con lo stesso nome di un metodo si scrivono per intero (Avalonia.Controls.Border, Avalonia.Controls.Grid, Avalonia.Media.FontWeight). Fuori da RAva, nel codice di chi usa la libreria, il problema non c'è.

Non esiste GetVisualRoot. Per trovare la finestra di un controllo si usa TopLevel.GetTopLevel(controllo), che restituisce la Window (o il TopLevel del browser o del telefono), oppure null se il controllo non è ancora in una finestra. Da lì si arriva a StorageProvider, Clipboard, Screens.

Un controllo che ha già un genitore non si rimette altrove. Ogni controllo ha un solo genitore visuale. Riusare lo stesso controllo in una riga nuova di un elenco o di una griglia (in un programma vero: un CalendarDatePicker riusato nella riga di una nuova attività) manda in crash il programma con un'eccezione sul genitore. Si crea un controllo nuovo ogni volta, oppure si toglie prima il controllo dal vecchio genitore.

Invio in una TextBox su più righe non arriva al KeyDown. Con AcceptsReturn = true la TextBox usa Invio per andare a capo e lo segna come gestito: un KeyDown normale non lo riceve mai. Si ascolta nella fase Tunnel, prima della casella: AddHandler(InputElement.KeyDownEvent, Gestore, RoutingStrategies.Tunnel) oppure .OnEnter(Metodo) (capitolo 15). Una TextBox di una riga invece Invio lo lascia passare, anche nella fase Bubble.

Sullo stesso controllo, i gestori della stessa fase vanno in ordine inverso. L'ultimo iscritto con AddHandler è chiamato per primo; se segna l'evento come gestito, quelli iscritti prima non lo ricevono, a meno di handledEventsToo: true. Chi aggiunge un gestore Tunnel a una casella che ha già OnEnter lo riceve; chi lo aveva aggiunto prima, no.

Una Bitmap usata da due Image non va liberata a mano. Se la stessa Bitmap è la Source di due controlli Image (una miniatura e un'anteprima) e la si libera con Dispose quando uno dei due sparisce, l'altro va in crash dentro Image.Render al ridisegno successivo. Le bitmap condivise si lasciano al garbage collector; si liberano a mano solo quelle che hanno un solo proprietario sicuro.

Senza schermo i menu non si vedono. Quando si disegnano le finestre senza schermo (per le figure o per i test), i menu e le tendine aprono un Popup in una finestra a parte, che non finisce nella cattura. Uno stile su tutti i Popup con ShouldUseOverlayLayer = true li fa disegnare dentro la finestra (appendice C).

Il testo su sfondo trasparente si sgrana. Disegnando un controllo con RenderTargetBitmap senza sfondo, il testo perde l'antialiasing e diventa illeggibile: mettete il controllo in un Border con sfondo pieno prima di fotografarlo.

Il tema cambia ma il controllo disegnato a mano no. Render legge i colori al momento del disegno, e Avalonia non lo richiama da sé quando cambia il tema: ci si iscrive ad ActualThemeVariantChanged e si chiama InvalidateVisual (capitolo 27).

Aspettare un metodo asincrono dal thread dell'interfaccia può bloccare per sempre. MetodoAsync().Result o .GetAwaiter().GetResult() nel thread dell'interfaccia si bloccano se dentro il metodo un await vuole tornare su quel thread (§12.6). È successo davvero con l'arresto di ASP.NET Core in RServer.Stop(): la cura è Task.Run(FermaAsync).GetAwaiter().GetResult(), che fa il lavoro sul pool.

Senza schermo i DispatcherTimer non scattano con RunJobs(). Nelle prove con Avalonia.Headless, Dispatcher.UIThread.RunJobs() esegue i lavori in coda ma non i timer: per provare un timer bisogna far girare il ciclo vero con Dispatcher.UIThread.PushFrame(new DispatcherFrame()) e fermarlo da un altro thread (TEST_RAVAL\Prove.cs, classe Attesa).

A 300 dpi le scritte disegnate dopo un ritaglio spariscono. Una RenderTargetBitmap creata a 300 dpi perde ogni testo disegnato dopo la chiusura di un PushClip: il grafico salvato per la stampa non aveva né la legenda né i valori dei marcatori, che sullo schermo e a 96 dpi c'erano. La cura, in RImage, è creare la bitmap a 96 dpi ma già con i pixel della risoluzione voluta, e disegnare con la scala come trasformazione: stessi pixel, tutto disegnato. La risoluzione vera si scrive poi nel PNG (capitolo 35).

Una risorsa che manca toglie lo sfondo, e con lo sfondo il clic. Uno sfondo legato con Resource a una chiave che l'applicazione non ha resta nullo, e un Border senza sfondo non riceve il mouse: è trasparente anche al clic. Le righe di una tabella non si potevano scegliere in un'applicazione che chiamava i colori con nomi diversi. Un controllo che deve ricevere il clic ha sempre uno sfondo, anche solo Brushes.Transparent.

Appendici

A. Riferimento rapido

Voglio...Scrivo
una finestraclass MiaFinestra : Window con Title, Width, Height, Content
aprirlanew MiaFinestra().Show(); modale: await f.ShowDialog<T>(proprietaria)
chiuderla con un risultatoClose(valore)
dei figli in colonna (o in riga)RAva.VStack(8, a, b, c), RAva.HStack(8, a, b)
testo grande e in grassetto.Text("...").FontSize(18).Bold().Foreground("#1a7fb5")
una cornicenew Border().Border(1, radius: 8).Padding(16).Child(...)
un moduloGrid con ColumnDefinitions("Auto,*") e .Row(r).Column(c)
la cornice di un programmaDockPanel con .Dock(Dock.Top) ... e l'ultimo figlio al centro
reagire a un clicb.Click += Metodo; oppure .OnClick(Metodo)
Invio in una TextBox.OnEnter(Metodo) (dietro: AddHandler(InputElement.KeyDownEvent, ..., RoutingStrategies.Tunnel))
il testo che cambia.OnTextChanged(Metodo) con void Metodo(string testo)
una spunta, una scelta.OnChecked(Metodo), .OnSelectionChanged(Metodo)
un colore che segue il temanew RThemes().Color("Chiave", light: ..., dark: ...).Apply(this) + .Resource(Proprieta, "Chiave")
un elenco di oggettiListBox { ItemsSource = dati, ItemTemplate = new MioModello() }
la finestra di un controlloTopLevel.GetTopLevel(controllo)
scegliere un fileTopLevel.GetTopLevel(c)!.StorageProvider.OpenFilePickerAsync(...)
un lavoro lungoawait Task.Run(Metodo) e Dispatcher.UIThread.Post(Mostra) (capitolo 23)
un thread che lavora semprenew Thread(oggetto.Lavora) { IsBackground = true }.Start() (§12.1)
un dato condiviso fra threadlock (_chiave) { ... } o Interlocked.Increment(ref n) (§12.2)
aspettare che un thread sia prontoManualResetEventSlim: Set() da una parte, Wait() dall'altra (capitolo 12)
un risultato da un altro thread, senza awaitTask<T> t = Task.Run(Metodo); T x = t.Result; (§12.3)
due attese insiemeawait Task.WhenAll(a, b) (§12.5)
fermare un lavoroCancellationTokenSource + token passato alle attese (§12.7)
un server di API JSONnew RServer(5080).Get("/api/x", Metodo).Start() (capitolo 34)
scrivere nel logRLog.Start("App", "1.0", "log/app.log"), poi RLog.Info(...), RLog.Error("...", ex) (capitolo 36)
il log in una finestranew RLogView()
un JSON o un XML da guardareRTree.FromJson(testo), RTree.FromXml(testo); SelectionChanged += Metodo (capitolo 37)
leggere un JSON o un XML, senza finestraRNode.Load("f.json").GetInt("server.port", 5080); Set, Save (capitolo 37)
una domanda, una conferma, un erroreawait RDialog.Confirm(this, "Eliminare?", "Elimina"), Ask, Choose, Error(this, "...", e) (capitolo 38)
scegliere un file, salvarlostring? f = await RFiles.OpenAsync(this, "Apri", "CSV|*.csv"); SaveAsync, ReadText, WriteText (capitolo 38)
copiare una tabella in un foglio di calcoloawait RClipboard.SetTable(this, righe); GetTable, SetText, SetImage (capitolo 39)
accettare i file trascinati sulla finestraRDrop.Files(controllo, Metodo, "*.csv") (capitolo 39)
una tabella da centomila righevar t = new RVirtualTable(); t.LoadCsv(testo); t.Filter = "C1"; (capitolo 40)
le proprietà di un oggetto, modificabilinew RPropertyList().Show(oggetto); Changed, Rejected, Reload (capitolo 40)
un registratore dal vivovar s = new RLiveSeries(new RChart { TimeAxis = true }, "T") { Window = 600 }; s.Add(DateTime.Now, v); (capitolo 41)
una nuvola di punti XYnew RSeries("nome", x, y) { Style = RSeriesStyle.Points } (capitolo 41)
il tema chiaro/scuro/sistema, ricordatoRThemes.Standard().Apply(app); RTheme.Remember(impostazioni); RTheme.Fill(menu); (capitolo 36)
il programma in più lingueRLang.Open(cartella, "it"); new MenuItem().Tr("menu.file", "_File"); RLang.T("chiave", "testo") (capitolo 36)
una carta di Smithnew RSmithChart() + Series.Add(new RComplexSeries("S11", gamma, f)); RSmithMath per i cerchi (capitolo 41)
un diagramma d'antennanew RPolarChart { Decibels = true, ZeroAngle = 90, Clockwise = true } + RComplexSeries.Pattern (capitolo 41)
barre, mappa di coloriRBarChart con Categories e RBarSeries; RHeatMap.SetValues(matrice, x0, x1, y0, y1) (capitolo 41)
le cartelle di un discoalbero.Add(RTreeNode.FromFolder(percorso)): si leggono quando si aprono (capitolo 37)
un'impostazioneRSettings.Open("app.json").GetInt("server.port", 5080); scrivere: Set(chiave, valore)
la finestra che riapre dov'eraimpostazioni.Remember(this, "window.main") prima di Show
i file recentinew RRecentFiles(impostazioni, "files.recent") + Fill(menu) + Add(percorso)
qualcosa ogni secondoRAva.Every(TimeSpan.FromSeconds(1), Metodo), oppure DispatcherTimer con Tick += Metodo
un grafico a curve con i marcatorinew RChart { UnitX = "Hz" } + Series.Add(new RSeries("nome", x, y)) (capitolo 35)
una tabella che si ordinanew RTable().Columns("Nome", "Valore:R").AddRow("C1", "1.5p")
valori da cambiarenew RPropertyList().Add("R", "1k") + Changed += Metodo
un numero con il prefisso SIRFormat.Si(1.296e9, "Hz") → «1.296 GHz»; al contrario RFormat.Parse("4k7")
delle curve in CSVRCsv.Write("f (Hz)", grafico.Series); leggere: RCsv.Read(testo)
una figura per la stampaRImage.SavePng(controllo, "f.png", RImage.Dip(16), RImage.Dip(10), 300)
l'aspetto di uno strumentoRStyles.Clean().Apply(this) + RAva.Card, Caption, Bar, ToolbarButton
disegnareclass X : Control con override void Render(DrawingContext dc)
ridisegnareInvalidateVisual()

B. Glossario

  • Albero visuale: i controlli di una finestra, ciascuno dentro il suo genitore.
  • API JSON: un programma che risponde a richieste HTTP con dati in formato JSON (capitolo 34).
  • async / await: async segna un metodo che può aspettare; await aspetta un Task senza fermare il thread, e il resto del metodo riprende dopo (§12.4).
  • Blocco mortale (deadlock): due thread che si aspettano a vicenda e non vanno più avanti (§12.6).
  • CancellationToken: la richiesta di fermarsi, passata a chi lavora (§12.7).
  • Contesto di sincronizzazione: dice a await dove riprendere; quello del thread dell'interfaccia rimette il lavoro nella coda del Dispatcher (§12.6).
  • Corsa (race condition): due thread che scrivono lo stesso dato e si perdono a vicenda delle modifiche (§12.2).
  • Caricatore: il metodo che legge i figli di un nodo la prima volta che servono (capitolo 37).
  • Binding: un legame fra una proprietà e una sorgente che la tiene aggiornata; Resource di RAva ne crea uno verso una risorsa del tema.
  • Delegato: un metodo trattato come un valore (Action, Func, i gestori d'evento).
  • Dip: l'unità delle misure di Avalonia, 1/96 di pollice; sullo schermo normale è un pixel.
  • Dispatcher: la coda dei lavori del thread dell'interfaccia.
  • dpi: punti per pollice; una figura stampata ne vuole almeno 300 alla sua misura sulla pagina (§35.6).
  • Evento instradato: un evento che viaggia lungo l'albero (Tunnel verso l'interno, Bubble verso l'esterno).
  • Gruppo di metodi: il nome di un metodo usato come delegato (Click += Salva).
  • Lambda: una funzione senza nome (x => x * 2); in questo libro non si usa.
  • Log: il diario del programma, una riga per ogni cosa che fa, con l'ora e il livello (capitolo 36).
  • Layout: le due passate Measure e Arrange che decidono misura e posizione dei controlli.
  • lock: un blocco di codice in cui entra un thread per volta (§12.2).
  • Metodo d'estensione: un metodo statico con this sul primo parametro, che si chiama come se fosse del tipo esteso.
  • Modale: una finestra che blocca la proprietaria finché non si chiude.
  • Pennello (IBrush): come si riempie un'area. Penna (Pen): come si traccia una linea.
  • Proprietà di Avalonia (StyledProperty): una proprietà che supporta stili, binding e risorse; il suo descrittore è il campo statico NomeProperty.
  • Risorsa: un valore con un nome (colore, pennello) cercato risalendo l'albero fino all'applicazione.
  • Prefisso SI: la lettera che moltiplica l'unità per una potenza di mille: p, n, µ, m, k, M, G (§35.1).
  • Pool di thread: thread già pronti a cui .NET consegna i lavori brevi (Task.Run) (§12.3).
  • Task: un lavoro in corso, o lo «scontrino» del suo risultato (Task<T>) (§12.3).
  • Thread: una sequenza di istruzioni eseguita una dopo l'altra; più thread vanno avanti insieme (§12.1).
  • Thread dell'interfaccia: l'unico thread da cui si possono toccare i controlli.
  • Virtualizzazione: costruire solo le righe visibili di un elenco.

C. Come sono fatte le figure

Le figure delle parti I, II e III vengono dai primi tre volumi della collana «Solo in codice», dove le disegna il codice degli esempi: i programmi si compilano e si eseguono davvero, e le uscite e le finestre nelle figure sono quelle vere. Le figure della parte IV, e i diagrammi che hanno il nome che comincia per d, si generano con il progetto RAVAL\ESEMPI_RAVAL:

ComandoChe cosa fa
dotnet run --project ESEMPI_RAVALtutte le figure, in RAVAL\DOC\immagini
dotnet run --project ESEMPI_RAVAL -- <cartella> 10_tortauna figura sola
dotnet run --project ESEMPI_RAVAL -- --finestrail primo programma, con la finestra vera
dotnet run --project ESEMPI_RAVAL -- --figure-avale figure dei volumi citate dal tutorial
dotnet run --project ESEMPI_RAVAL -- --tutorialla copertina del tutorial in PDF

Il programma usa Avalonia.Headless con il disegno vero di Skia: le finestre non compaiono sullo schermo ma si impaginano come se ci fossero. Poi RImage.Render ridisegna la finestra intera, menu aperti compresi, alla risoluzione dei libri: almeno 300 dpi e almeno 2400 pixel di larghezza. La scala è la più grande delle due, quindi una finestra piccola esce a più di 300 dpi. I dpi veri vengono scritti nel PNG. CaptureRenderedFrame, la fotografia della finestra, senza schermo dà solo i pixel della finestra, cioè 96 dpi: sulla carta le figure erano sgranate.

AppBuilder.Configure<AppEsempi>()
  .UseSkia()
  .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
  .With(new FontManagerOptions { DefaultFamilyName = "Segoe UI" })
  .SetupWithoutStarting();
// menu e tendine disegnati dentro la finestra: senza schermo una finestra a comparsa a parte non finirebbe nella foto
var sovrapposte = new Style(Finestrelle);
sovrapposte.Setters.Add(new Setter(Popup.ShouldUseOverlayLayerProperty, true));
Application.Current!.Styles.Add(sovrapposte);

Ogni figura è una voce di Catalogo.Tutti(): il nome del PNG, la misura, il tema, il titolo della finestra finta disegnata attorno e il metodo che crea il contenuto. Per il menu aperto c'è anche un'azione da eseguire dopo che la finestra è apparsa (IsSubMenuOpen = true).