MaCocoa 066

Capitolo 066 - Che fortuna!

Piglio spunto da un tutorial su Tevac riguardante il programma Unix chiamato Fortune per produrre una interfaccia grafica per il comando stesso, che presenti in una bella finestra la citazione del giorno.

Sorgenti: quelli di fortune, esempi e documentazione Apple

Prima stesura: 15 dicembre 2004.

Fortune

Fortune è un programma Unix di vecchia data che funziona da linea di comando. Ad ogni lancio produce una citazione pescata a caso da un insieme specificato. L'uso classico di questo comando è di lanciarlo al login di un utente, in modo che costui possa essere gratificato da una citazione o epigramma (generalmente divertente o pensosa, comunque interessante). Le citazioni sono conservate in una serie di file, in formato testo, con una struttura molto semplice. Se ne trovano a bizzeffe in internet, e se ne possono produrre anche da soli con le proprie citazioni preferite. Io stesso, per completezza, ne ho prodotto uno. Ho recuperato una serie di citazioni dell'unica trilogia composta da cinque libri (si tratta della Guida Galattica per Autostoppisti, del compianto Douglas Adams) e le ho inserite in un file di testo; ciascuna citazione è separata dalla successiva dal carattere %, presente da solo nella riga.

figura 01

figura 01

Ho poi recuperato i file sorgenti del comando fortune, ad esempio dal sito di Tevac, oppure da una distribuzione linux come Debian. Il primo compito che mi prefiggo è di compilare i file all'interno di XCode.

Faccio riferimento ai file sorgenti ottenuti tramite Tevac. L'intero pacchetto si compone di quattro file C con relativi header, più una serie di file ausiliari. La tecnica standard per la produzione del comando è di eseguire il comando make all'interno della directory dei sorgenti, ma io intendo operare in un altro modo.

Progetti e Target

Costruisco un progetto in XCode, una Cocoa-Based Application, chiamato YahFortune. Questo progetto conterrà sia l'applicazione grafica che il comando fortune.

figura 02

figura 02

Converrà spendere due parole sul concetto di Target, finora mai esplicitamente dichiarato in quando la situazione di default ha sempre funzionato. XCode raccoglie in un progetto una serie di file (sorgenti di vari linguaggi, risorse, testi, figure, suoni, eccetera), eventualmente separandoli in diversi gruppi e sottogruppi. Un Target è un insieme di regole che specificano un gruppo di file, come manipolarli e come raggrupparli assieme per produrre un obiettivo. L'esempio più semplice è una applicazione Cocoa. Il target identifica i file Objective-C da compilare, i file nib da aggiungere e come organizzare la cartella .app del prodotto finito.

C'è poi da ricordare la presenza degli Executables, ovvero l'ambiente in cui il risultato del Target è fatto funzionare. Quando ho prodotto un salvaschermo, non avevo un Executable esplicito, in quanto un salvaschermo non vive autonomamente, ma solo all'interno di un'altra applicazione. A quel tempo, avevo specificato le Preferenze di Sistema come eseguibile per poter verificare il funzionamento del salvaschermo.

Il punto interessante è che all'interno di un unico progetto possono coesistere diversi Target (per capirci, una applicazione che consiste sostanzialmente negli stessi file, ma con due diverse modalità di compilazione, ad esempio per Mac Os 9 e Mac Os X). Sfrutto questa situazione per produrre quattro diversi target. Il primo target sarà l'interfaccia grafica di fortune, la mia applicazione principale. Un secondo target è il comando fortune, compilato a partire dai suoi sorgenti; il terzo ed il quarto target sono due altri comandi che sono associati a fortune, utili per la gestione dei file con le citazioni.

figura 03

figura 03

Per aggiungere un target, si seleziona l'apposita voce nel menu Project. Si devono poi selezionare i file che partecipano ad ogni target: ci sono diversi metodi per fare ciò.

Il primo metodo è di selezionare ciascun target (ad esempio con il menu pop-up nella toolbar) e spuntare nella finestra che presenta l'elenco dei file l'appartenenza o meno al target (è l'ultima colonna della tabella sulla destra in figura). Oppure si possono draggare i file nell'apposita sezione source file della vista laterale (l'alberatura sulla sinistra). Dei quattro file .c che costituisco la distribuzione di fortune, due sono dedicati al comando fortune vero e proprio, ed uno ciascuno ai due comandi ausiliari strfile e unstr.

Definiti i target, passo a compilare. A parte una lunga sequenza di warning, produrre i comandi strfile e unstr è banale. Un po' più complicato il comando fortune vero e proprio, in quanto l'eseguibile deve incorporare la directory di default dove trovare i file con le citazioni.

figura 04

figura 04

Normalmente, su Unix, la directory è qualcosa del tipo /usr/share/games/fortune; tuttavia, io vorrò incorporare l'eseguibile fortune e alcuni file di citazioni di default all'interno dell'applicazione yahFortune.app risultante, in modo che l'applicazione non necessiti, in prima battuta, di altri file al di fuori dell'applicazione stessa. L'idea è che, visualizzando il contenuto del pacchetto yahFortune.app, il comando fortune si trovi all'interno della cartella Resources, e che i file delle citazioni siano conservati all'interno della sottocartella fortunes (nome predefinito dal comando).

Tutta questa introduzione giustifica il contenuto del file pathnames.h

#define FORTDIR "."

che indica a fortune che la cartella fortune si trova nella stessa in cui si trova il comando stesso.

Uso di fortune

A questo punto mi trovo con i tre comandi della distribuzione di fortune compilati e pronti all'uso.

figura 05

figura 05

Tuttavia, operando con il terminale, le cose non funzionano.

djzeropb:~/path/mc066 - yahFortune/build djzero00$ ./fortune
./fortunes: No such file or directory
djzeropb:~/path/mc066 - yahFortune/build djzero00$

In effetti, la configurazione non è quella definitiva: il comando fortune andrà a finire in altra posizione, assieme ai file delle citazioni. Per verificarne il funzionamento, bisogna ricreare delle condizioni opportune, ad esempio copiando il comando e la cartella fortune assieme in un'altra directory:

djzeropb:~/temp_fortune djzero00$ ls -l
total 24
-rwxr-xr-x 1 djzero00 djzero00 9300 15 Dec 16:23 fortune
drwxr-xr-x 5 djzero00 djzero00 170 15 Dec 16:34 fortunes
djzeropb:~/Temporaneo/temp_fortune djzero00$ ./fortune
fortune: ./fortunes: No fortune files in directory.
fortune:./fortunes not a fortune file or directory
djzeropb:~/Temporaneo/temp_fortune djzero00$

In realtà, nemmeno così va bene. Entrano in gioco il comando strfile e i file dat. Per poter funzionare correttamente, infatti, fortune richiede, oltre al file testo con le citazioni, una specie di indice di questo file; produrre questo indice è compito del comando strfile.

djzeropb:~/temp_fortune djzero00$ cd fortunes
djzeropb:~/temp_fortune/fortunes djzero00$ ../strfile adams.txt
"adams.txt.dat" created
There were 115 strings
Longest string: 569 bytes
Shortest string: 4 bytes
djzeropb:~/temp_fortune/fortunes djzero00$ cd ..
djzeropb:~/temp_fortune djzero00$ ./fortune
There is a theory which states that if anyone discovers just exactly
what the universe is for and why we are here, that it will instantly
disappear and be replaced by something even more bizarre and
inexplicable. Then there is a theory which states that this has already
happened.
djzeropb:~/temp_fortune djzero00$

Ecco che adesso le cose funzionano. tengo da conto il file dat e passo finalmente a progettare l'interfaccia grafica.

YahFortune

Ancora una volta, non sono particolarmente originale nell'idea di costruire una interfaccia grafica per il comando fortune. Ne trovate ad esempio una chiamata CocoaFortune, completa di molti file. Come al solito, il mio scopo non è produrre applicazioni senza le quali il mondo non può vivere, ma sperimentare con Cocoa (il nome stesso del progetto, YahFortune, per chi se lo fosse chiesto, sta per Yet Another Horrible Fortune, un altro orribile fortune). Tuttavia, cercherò di fare qualcosa di carico e multifunzionale, sviluppando nei prossimi capitoli alcune idee.

figura 08

figura 08

Il primo problema nasce quando voglio incorporare il comando fortune all'interno della applicazione. Due sono le operazioni necessarie: dire che il comando fortune è propedeutico alla compilazione dell'applicazione, e poi copiare il comando fortune all'interno dell'applicazione. La prima operazione si compie selezionando le informazioni del target, ed esplicitando la dipendenza del target YahFortune dal target fortune. In questo modo XCode, compilando l'applicazione, verifica in primo luogo l'esistenza del comando fortune. Se questo comando non fosse presente, esegue la compilazione del codice del comando prima di ogni altra operazione relativa all'applicazione.

figura 09

figura 09

Per inserire il comando fortune all'interno dell'applicazione, occorre invece definire una fase di build apposta. C'è una voce di menu nel menu Project che permette di aggiungere una fase di copiatura file. Questa voce aggiunge una cartella nelle proprietà del Target, le cui proprietà possono essere opportunamente configurate.

figura 06

figura 06

L'interfaccia è essenziale: un pannello con una scritta, un campo di testo per contenere la citazione, ed un pulsante per generare un'altra citazione.

E qui comincio subito a complicarmi al vita. Voglio che la finestra non abbia la barra del titolo, e che soprattutto possa essere chiusa (e l'intera applicazione con lei) con un clic del mouse o con la pressione di un tasto.

figura 07

figura 07

Per farlo, ho cercato a lungo nella documentazione la possibilità di nascondere la barra del titolo, ma non ho trovato nulla. O meglio, l'unica possibilità sembrava essere quella di costruire da programma la finestra, senza sfruttare le immense possibilità di Interface Builder. Dopo un po' di tribolazioni, la soluzione è molto semplice: si costruisce una sottoclasse di NSWindow, nel mio caso chiamata YahWinCtrl, e si riscrive il metodo di inizializzazione:

- (id)initWithContentRect:(NSRect)contentRect
    styleMask:(unsigned int)styleMask
    backing:(NSBackingStoreType)backingType
    defer:(BOOL)flag
{
    // riscrivo il metodo per togliere titoli bordi e quant'altro
    styleMask = NSBorderlessWindowMask ;
    // chiamo ovviamente il metodo della superclasse
    self = [ super initWithContentRect: contentRect styleMask: styleMask
                backing: backingType defer: flag ];
    return ( self );
}

Indicare infatti lo stile NSBorderlessWindowMask elimina tutto ciò che della finestra è accessorio (come appunto la barra del titolo).

A questo punto, già che ho costruito una sottoclasse, intercetto la pressione di un tasto ed il clic dei mouse:

- (void)
mouseDown:(NSEvent *)theEvent
{
    // passo la palla al delegato (frtWinCtrl)
    [[self delegate] handlesMouseDown: theEvent ] ;
}

- (void)
keyDown:(NSEvent *)theEvent
{
    // passo la palla al delegato (frtWinCtrl)
    [[self delegate] handlesKeyDown: theEvent ] ;
}

Il delegato della finestra sarà la classe che controlla l'intera applicazione, e che comincerà la procedura di terminazione dell'applicazione (perché non termino l'applicazione direttamente da qui sarà chiaro più avanti).

Se però la cosa funziona con il mouse, con la pressione di un tasto si ha il beep di sistema e la mancata trasmissione dell'incombenza al delegato. Dopo il solito lungo penare, vedo un esempio di apple (si chiama FancyAbout), dove si afferma che le finestre senza barra del titolo non possono diventare finestre principali e tanto meno finestre ricevere input da tastiera. Ma nello stesso tempo mostra anche come risolvere il problema: basta forzare la mano sovrascrivendo due metodi.

- (BOOL)
canBecomeMainWindow
{
return YES;
}

- (BOOL)
canBecomeKeyWindow
{
return YES;
}

Adesso, sia un clic del mouse sia la pressione di un tasto sono eventi passati al delegato.

Far fortuna

La classe fondamentale dell'applicazione, frtWinCtrl, è responsabile della visualizzazione della finestra e del riempimento del campo con il testo di una citazione.

Mi libero subito del problema di recuperare la citazione, visto che si tratta eseguire in un secondo task un comando Unix, e recuperare il prodotto di questo comando. Ho scritto un metodo che fa tutto, restituendo una stringa.

- (NSString *)
makeAFortune
{
    NSPipe            * pipe = [ NSPipe pipe ] ;    
    NSFileHandle    * handle = [ pipe fileHandleForReading ] ;
    NSString        * fortunePath ;
    NSTask            * fortuneTask ;
    NSData            * data;
    NSString        * theNewFortune = [ NSString string ];
    long            xxx ;

    // recupero il percorso dell'eseguibile all'interno dell'applicazione
    fortunePath = [ [ NSBundle mainBundle ] pathForResource: @"fortune" ofType: @"" ] ;
    // costruisco un task apposito
    fortuneTask = [[NSTask alloc] init];
    // predispongo i parametri di lancio del task
    [ fortuneTask setLaunchPath: fortunePath ];
    // la directory di lavoro e' quella di destinazione
    [ fortuneTask setCurrentDirectoryPath: [ fortunePath stringByDeletingLastPathComponent] ];
    // dico che l'uscita del comando va su una pipe
    // ridirigo sia lo standard output che lo standard error
    [ fortuneTask setStandardOutput: pipe ] ;
    [ fortuneTask setStandardError: pipe ];
    // lancio il programma
    [ fortuneTask launch];
    // gli do un attimo per partire
    Delay ( 1, & xxx );
    // aspetto, in maniera sincrona, che il programma risponda con tutta la stringa
    // prodotta secondo i suoi criteri
    while ((data = [handle availableData]) && [data length])
    {
        NSString    * tmpData ;
        // prelevo i caratteri arrivati
        tmpData = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ;
        // e li aggiungo a quelli gia' arrivati
        theNewFortune = [ theNewFortune stringByAppendingString: tmpData ];
    }
    // ecco il nuovo fortune
    return ( theNewFortune );
}

Rispetto al capitolo precedente, c'è una unica fondamentale differenza, ovvero che la lettura del risultato avviene in maniera sincrona piuttosto che asincrona; in altre parole, si lancia il comando fortune, e si aspetta che produca il risultato.

Ora, potrei finire qui; dopotutto, si tratta di inserire questa stringa all'interno del campo apposito; invece, mi complico ulteriormente la vita.

Voglio fare una piccola coreografia, facendo apparire la finestra con un effetto di fading, manipolandone la trasparenza. In altre parole, parto con la finestra completamente trasparente, e poi la opacizzo nel tempo fino a renderla del tutto solida.

Ugualmente, al termine delle operazione, eseguo l'operazione inversa, partendo da finestra solida e opaca, rendendola sempre più trasparente fino a farla scomparire.

Il metodo di inizializzazione si limita a predisporre una paio di variabili d'istanza:

- (id)
init
{
    self = [super init];
    if ( self )
    {
        // predispongo le variabili interne d'uso
        curStyle = defaultTextAttr( ) ;
        [ curStyle retain ] ;
        currentFade = 0 ;
        // predispongo un fortune
        [ self setLastFortune: [ self makeAFortune ] ];    
    }
    return self;
}

La variabile currentFade è il grado di trasparenza iniziale: con Zero la finestra è completamente trasparente, con Uno del tutto opaca. La variabile lastFortune contiene la citazione corrente, ed è riempita con il metodo visto in precedenza.

La variabile curStyle contiene un NSDictionary con gli attributi di visualizzazione della stringa: in pratica, specifica il carattere:

NSMutableDictionary *
defaultTextAttr( )
{
    NSFont                    * locFont ;
    NSMutableDictionary        * txtAtt ;
    // inizializzo un dizionario epr contenere gli attributi
    txtAtt = [[[NSMutableDictionary alloc] initWithCapacity:1] autorelease];
    // un font di default come primo attributo
    locFont = [ NSFont fontWithName: @"Georgia" size: 14 ];
    // aggiungo il tutto al dizionario
    [ txtAtt setObject: locFont forKey:NSFontAttributeName];
    return ( txtAtt );
}

Quando la finestra è caricata, benché non appaia ancora sulla scrivania, si esegue il metodo awakeFromNib:

- (void)
awakeFromNib
{
    // per fare in modo che la finestra sia comunque
    // davanti a tutte le altre, anche se l'applicazione
    // non ha menu e non e' presente in dock
    [ NSApp activateIgnoringOtherApps:YES];
    // la rendo invisibile
    [ theWindow setAlphaValue: currentFade ];
    // la porto davanti a tutti
    [ theWindow makeKeyAndOrderFront: self ];
    // imposto il font del campo
    [ theText setFont: [ curStyle objectForKey:NSFontAttributeName ] ];    
    // ridimensiono e centro la finestra
    [ self resizeWindowToNewFortune ];
    [ theWindow center ];
    // faccio partire il processo di fading
    [ self makeWindowFadeInOut: YES ];
}

La prima istruzione serve a portare la finestra davanti a tutte le altre, anche se l'applicazione risultante sarà background only, in modo che non abbia la barra dei menu e non compaia nel dock (ho scoperto come fare ciò nel capitolo 59 con la proprietà LSUIElement posta a 1). Imposto la trasparenza iniziale, porto davanti la finestra, assegno il font al campo di testo, lascio perdere le due istruzioni successive, e poi chiamo il metodo makeWindowFadeInOut:, per modificare nel tempo il valore di trasparenza:

- (void)
makeWindowFadeInOut: (BOOL) uot
{
    // devo fare il fading della finestra
    // in entrambi i casi, faccio partire un timer, che lanci ad intervalli
    // predefiniti, il metodo updateAlpha:
    fadeTimer = [NSTimer scheduledTimerWithTimeInterval: 0.1F target:self
        selector: @selector(updateAlpha:)
        userInfo: [ NSNumber numberWithBool: uot ]
        repeats: YES ];
}

Il metodo fa semplicemente partire un timer (una tecnica già vista nel capitolo 24), ovvero faccio in modo che ogni 0.1 secondi sia chiamato il metodo updateAlpha: di se stessa, cioè frtWinCtrl. Il parametro userInfo mi serve per distinguere il quale direzione si muove la trasparenza

- (void)
updateAlpha: (NSTimer *)timer
{
    // recupero la direzioen del fading
    BOOL    dir = [ [ timer userInfo] boolValue ];
    // se YES, fading in crescendo
    if ( dir )
    {
        // fading da Zero a Uno, apparizione
        currentFade += 0.1 ;
        // se raggiungo o supero 1, ho finito
        if ( currentFade >= 1 )
        {
            // uccido il timer
            [ fadeTimer invalidate ];
            // imposto 1 per essere sicuro
            currentFade = 1 ;
            // imposto un nuovo timer, dopo i secondi della morte programmata
            fadeTimer = [NSTimer scheduledTimerWithTimeInterval: 60.0 target:self
                selector: @selector(endOfDisplay:) userInfo: nil repeats: NO ];
        }
    }
    else
    {
        // fading da Uno a Zero, sparizione
        currentFade -= 0.1 ;
        // se sotto o uguale a zero, ho finito
        if ( currentFade <= 0 )
        {
            // uccido il timer
            [ fadeTimer invalidate ];
            // ma tanto vale: uccido anche l'applicazione
            [ NSApp terminate: self ] ;
        }
    }
    // aggiorno la finestra con il nuovo valore alpha
    [ theWindow setAlphaValue: currentFade ];
}

Con userInfo pari a YES, passo da finestra trasparente a finestra opaca (e viceversa con NO). In ogni caso, aggiorno il valore di currentFade, incrementando o decrementando secondo il caso. Se arrivo al limite superiore di 1, termino l'incremento della trasparenza uccidendo il timer; ma lo reimposto subito (un singolo colpo dopo un minuto); in questo modo l'applicazione, dopo un minuto, anche in assenza di attività da parte dell'utente, si chiude e termina di lavorare (è una scelta che modificherò più avanti). Se arrivo al limite inferiore di Zero, la finestra è scomparsa del tutto, e termino l'applicazione esplicitamente.

Dopo un minuto dalla partenza, il metodo endOfDisplay: fa ripartire il processo di modifica della trasparenza in senso contrario.

- (void)
endOfDisplay: (NSTimer *)timer
{
    // faccio ripartire il fading in dissolvenza
    [ self makeWindowFadeInOut: NO ];
}

Anche i metodi chiamati dalla finestra attraverso il meccanismo di delega fanno partire lo stesso processo:

- (BOOL)
handlesKeyDown: (NSEvent *) keyDown
{
    [ self makeWindowFadeInOut: NO ];
}

- (BOOL)
handlesMouseDown: (NSEvent *) mouseDown
{
    [ self makeWindowFadeInOut: NO ];
}

Completo questo paragrafo con il metodo attivato da un clic sul pulsante che mostra un'altra citazione:

- (IBAction)
anotherOne:(id)sender
{
    // costruisco il nuovo fortune
    [ self setLastFortune: [ self makeAFortune ] ];    
    // ridisegno la finestra
    [ self resizeWindowToNewFortune ];
    [ theWindow center ];
    // faccio ripartire il timer di morte programmata
    [ fadeTimer invalidate ];
    fadeTimer = [NSTimer scheduledTimerWithTimeInterval: 60.0 target:self
        selector: @selector(endOfDisplay:) userInfo: nil repeats: NO ];
}

Sostanzialmente, esegue le stesse operazioni di awakeFromNib, ma si ricorda di reimpostare il timer di morte programmata dell'applicazione di nuovo ad un minuto.

Ridimensionamento della finestra

Una cosa divertente che si può fare con la finestra è di dimensionarla opportunamente in modo che contenga comodamente la citazione (che può variare da una a tante linee).

È il compito del metodo resizeWindowToNewFortune:

- (void)
resizeWindowToNewFortune
{
    NSSize    limit ;
    NSRect    winFrame, prevFrame ;
    // pulisco il testo presentato
    [ theText setStringValue: @"" ];
    // dimensioni del testo rappresentato
    limit = [ lastFortune sizeWithAttributes: curStyle ] ;
    // piglio le dimensioni della finestra
    prevFrame = winFrame = [ theWindow frame ];
    // ricalcolo le nuove dimensioni a partire dalle dimensioni
    // del testo da rappresentare
    winFrame.size.width = limit.width + 50 ;
    if ( winFrame.size.width < 400 )
        winFrame.size.width = 400 ;
    winFrame.size.height = limit.height + 80 ;
    if ( winFrame.size.height < 160 )
        winFrame.size.height = 160 ;
    // il nuovo punto origine si sposta per via delle nuove dimensioni
    winFrame.origin.x -= 0.5 * (winFrame.size.width - prevFrame.size.width) ;
    winFrame.origin.y -= 0.5 * (winFrame.size.height - prevFrame.size.height) ;
    // imposto il nuovo frame eseguendo una animazione
    [ theWindow setFrame: winFrame display: YES animate: YES ];
    // inserisco il nuovo testo al suo posto
    [ theText setStringValue: lastFortune ];
}

Punto fondamentale è di conoscere le dimensioni occupate dalla stringa da visualizzare. Dopo lungo penare (come al solito), ho trovato il metodo sizeWithAttributes: che fa proprio al caso mio: calcola un NSSize con lo spazio occupato in orizzontale e verticale. Uso questi due valori per ridimensionare il frame della finestra (i 50 pixel in orizzontale e gli 80 in verticale servono a tenere conto dello spazio ai lati del campo di testo); evito comunque di avere una finestra troppo piccola limitando il valore inferiore delle dimensioni calcolate.

Avendo cambiato le dimensioni della finestra, ed intendendo mantenere la finestra stessa al centro (più o meno) dello schermo, devo riposizionare anche il punto di origine della finestra stessa; è sufficiente spostare l'origine di metà della differenza tra le vecchie e le nuove dimensioni.

Per cambiare le dimensioni della finestra, uso il metodo setFrame:display:animate: che esegue un vezzoso effetto di ridimensionamento dinamico. Ho scoperto che la velocità di ridimensionamento è normalmente fissa. Però, avendo già fatto una sottoclasse della finestra YahWinCtrl, è un giochino modificarla. È sufficiente sovrascrivere il seguente metodo:

- (NSTimeInterval)
animationResizeTime:(NSRect)newFrame
{
    return ( 0.5 );
}

Adesso l'applicazione YahFortune mi sembra abbastanza graziosa; ma ci sono ancora molte cose che si possono migliorare.

Licenza Creative Commons
Eccetto dove diversamente specificato, i contenuti di questo sito sono rilasciati sotto Licenza Creative Commons.
Pagina a cura di Livio Sandel (macocoa2012@gmail.com).