MaCocoa 064

Capitolo 064 - Progettare un salvaschermo

Ancora una deviazione dal consueto flusso di Macocoa (ho l'impressione che ce ne saranno sempre più; la catalogazione ha un po' smesso di stimolarmi...). Questa volta produco un salvaschermo, e non è per nulla difficile.

Sorgenti: documenti ed esempi apple

Stesura: 28 novembre 2004

Salvaschermo

Un salvaschermo è un programma che prende possesso dello schermo dopo un certo periodo di inattività del computer. A parte le funzioni di preservare il funzionamento dei componenti fisici di uno schermo (cosa sempre meno utile, dal momento che spesso i monitor ormai si spengono da soli anche per conservare energia), forniscono talvolta intrattenimento e condivisione di risorse. In Mac OS X ci sono due tipi di salvaschermo: il primo è molto semplice, e consiste in uno slide show. Chiunque può produrne uno indicando una cartella colma di immagini. Qui mi interessa produrre un salvaschermo più sofisticato (si fa per dire), utilizzando Cocoa.

Dal punto di vista pratico, un salvaschermo è un bundle, ovvero un conglomerato di file, racchiusi all'interno di una cartella di estensione .saver. Questo bundle deve essere installato all'interno di una apposita cartella perché possa funzionare correttamente (/Library/Screen Savers oppure ~/Library/Screen Savers, dove il carattere tilde indica la propria home directory o cartella d'inizio). Tuttavia, facendo doppio clic su di un bundle salvaschermo, il sistema operativo è così gentile da installarlo al posto giusto.

figura 01

figura 01

Cocoa mette a disposizione del programmatore un framework (collezione di oggetti, funzioni e quant'altro) per la progettazione di salvaschermi. si tratta di ScreenSaver.framework, che si trova ovviamente all'interno di /Library/Frameworks. In realtà, non occorre preoccuparsi più di tanto di questi dettagli, perché XCode provvede da solo a fornirci tutto il necessario.

figura 02

figura 02

È infatti sufficiente generare un nuovo progetto, e scegliere il template opportuno nella lista sottoposta. Così facendo, XCode produce quasi tutto ciò che è necessario alla programmazione di un salvaschermo.

La classe ScreenSaverView

Il framework ScreenSaver consiste in una classe (ScreenSaverView) che fornisce i servizi di base; per scrivere un salvaschermo, se ne deve definire una sottoclasse e realizzare alcuni metodi necessari all'animazione ed alla configurazione del salvaschermo. Di fianco a questa classe, c'è una specifica sottoclasse di NSUserDefaults adatta alla gestione dei default dei salvaschermi (ScreenSaverDefaults) ed altre funzioni utili alla realizzazioni di salvaschermi (che infatti userò nel mio codice).

Per scrivere un salvaschermo, occorre dunque realizzare una sottoclasse di ScreenSaverView, ed in particolare sovrascrivere alcuni metodi. Fondamentalmente, i metodi da scrivere sono due:

- (id)            initWithFrame:(NSRect)frame isPreview:(BOOL)isPreview ;
- (void)        animateOneFrame ;

Il primo metodo è ovviamente chiamato quando il sistema operativo decide di far partire il salvaschermo. L'argomento booleano indica se il salvaschermo parte nella finestra di anteprima piuttosto che a tutto schermo; può essere utilizzato per differenziare il comportamento nei due casi.

Il secondo metodo è chiamato periodicamente ed è utilizzato per disegnare il contenuto dello schermo. Normalmente, il salvaschermo è una animazione, e quindi il metodo è utilizzato per disegnare un fotogramma di questa animazione.

A corredo di questi due metodi, ce ne sono molti altri per gestire le operazioni di contorno. Ad esempio, c'è un metodo per impostare la frequenza di aggiornamento, per eseguire operazioni poco prima o subito al termine delle operazioni, e due metodi per poter gestire una finestra di configurazione del salvaschermo.

L'inizializzazione

Il salvaschermo che mi accingo a realizzare è piuttosto semplice, e ricalca un tipico salvaschermo che inspiegabilmente è assente da Mac OS X. L'idea è che il salvaschermo presenti a video una scritta mobile, definita dall'utente oppure che riporta l'ora corrente. Il movimento della scritta può essere scelto lineare, con rimbalzo sulle pareti laterali dello schermo, oppure può essere una passeggiata casuale (o dell'ubriaco). Per poter scegliere tra le varie opzioni, avrò bisogno di una finestra di configurazione, e di poter conservare le opzioni utente in un file di defaults.

Comincio con il lungo metodo di inizializzazione.

- (id)
initWithFrame:(NSRect)frame isPreview:(BOOL)isPreview
{
    // chiamo il metodo ereditato
    self = [super initWithFrame:frame isPreview:isPreview];
    // aggiusto le particolarita' di questo salvaschermo
    if (self)
    {
        NSSize                        requiredSize ;
        NSRange                        glyphRange ;
        int                            version ;
        ScreenSaverDefaults            * defaults ;
        // prelevo i default, se presenti
        defaults = [ScreenSaverDefaults defaultsForModuleWithName:DEFAULTFILENAME];

Per conservare i valori di default tra successivi lanci del salvaschermo, si utilizza una sottoclasse specifica di NSUserDefaults. La specializzazione si rende necessaria per poter indicare il nome del file (che normalmente piglia il nome direttamente dall'applicazione, recuperandolo all'interno del file Info.plist). Per il resto, si utilizza in maniera identica, recuperando e salvando valori di default coi classici metodi.

Per essere sicuro dell'esistenza del file (e quindi evitare di caricare i valori da un file vuoto) verifico che uno dei valori estratti dal file abbia un valore sensato. Utilizzo allo scopo un campo di versione, utile anche in vista di successiva aggiornamenti del salvaschermo.

Gli altri valori conservati nelle preferenze sono il tipo di movimento (lineare o casuale), natura e colore del font utilizzato per la visualizzazione del testo, il tipo di testo da visualizzare (l'ora corrente piuttosto che un testo definito dall'utente) ed il testo stesso; infine, la massima velocità di movimento, in pixel per unità di tempo (il valore vero e proprio non sarà visualizzato all'utente, ma potrà qualitativamente sceglierlo attraverso uno slider).


        // uso un default 'versione' per verificare l'esistenza del file
        // oppure, un eventuale aggiornamento
        version = [defaults integerForKey: DEF_FILEPRESENT ];
        // se versione non e' zero, il file esiste gia'
        if ( version > 0 )
        {
            // leggo i parametri di default
            // tipo di movimento, font, tipo di testo, stringa e velocita'
            userSSType = [defaults integerForKey: DEF_USERSSTYPE ];
            [ self setUserFont: [ NSFont fontWithName: [defaults stringForKey: DEF_USERFONT ]
                                size: [defaults floatForKey: DEF_USERFONTSIZE ] ] ];
            [ self setUserColor: [ NSUnarchiver unarchiveObjectWithData:
                                [defaults objectForKey: DEF_USERCOLOR ] ] ];
            userTextType = [defaults integerForKey: DEF_USERTEXTTYPE ];
            userString = [defaults stringForKey: DEF_USERSTRING ];
            userMaxSpeed = [defaults integerForKey: DEF_USERMAXSPEED ];
        }
        else
        {
            // predispongo valori di default cablati
            userSSType = SSTYPE_RANDOMWALK ;
            [ self setUserFont: [ NSFont fontWithName: @"Georgia" size: 48 ] ];
            [ self setUserColor: [ NSColor redColor] ];
            userTextType = SSTXT_CURRTIME;
            userString = @"Macocoa Screen Saver";
            userMaxSpeed = 10 ;
            // predispongo i valori iniziali nelle preferenze
            [defaults setInteger: 1 forKey: DEF_FILEPRESENT];
            [defaults setInteger: userSSType forKey: DEF_USERSSTYPE];
            [defaults setObject: [userFont fontName] forKey: DEF_USERFONT ];
            [defaults setFloat: [userFont pointSize] forKey: DEF_USERFONTSIZE ];
            [defaults setObject: [ NSArchiver archivedDataWithRootObject: userColor]
                forKey: DEF_USERCOLOR ];
            [defaults setInteger: userTextType forKey: DEF_USERTEXTTYPE ];
            [defaults setObject: userString forKey: DEF_USERSTRING ];
            [defaults setInteger: userMaxSpeed forKey: DEF_USERMAXSPEED];
            [defaults synchronize];
        }

Nel caso in cui il file dei default non sia presente, seleziono alcuni valori adatti alla prima partenza del salvaschermo, scrivendoli anche all'interno del file stesso. Ne segue che questa parte else del codice è eseguita una volta sola, al primo lancio in assoluto del salvaschermo per un dato utente (oppure, ogni volta che qualcuno cancella le preferenze...).

Adesso è tempo di costruire la struttura del salvaschermo.

La prima istruzione imposta l'intervallo di tempo tra due animazioni successive. Decido che tale tempo sarà di un decimo di secondo, per non caricare troppo il processore con attività poco redditizie. Per quanto riguarda il testo, decido di utilizzare per intero l'architettura di Cocoa, piuttosto che limitarmi alla singola NSAttributedString, in modo da sfruttare tutte le capacità di layout. Infatti, dopo tutta la sequenza di costruzione delle classi necessari, calcolo espressamente il rettangolo che racchiude il testo (o meglio, le sue dimensioni); queste dimensioni sono poi usate successivamente per cancellare solo la zona dello schermo interessata dal testo. Altrimenti, avrei dovuto cancellare tutta l'area dello schermo prima di disegnare nuovamente il testo in un'altra posizione. Poiché le operazioni non grafiche sono molto più veloci delle operazioni grafiche, cancellare solo il minimo indispensabile è più elegante.

        // predispongo i valori interni del salvaschermo
        // frequenza di aggiornamento: ogni freqValue secondi sara' chiamato
        // il successivo metodo animateOneFrame
        [self setAnimationTimeInterval: freqValue ];
        // costruisco un textStorage (e conseguenti) per la gestione del testo
        textStorage = [ [ NSTextStorage alloc ] initWithAttributedString:
            [ self prepareDspString ] ] ;
        layoutManager = [ [ [ NSLayoutManager alloc ] init ] autorelease ];
        [ textStorage addLayoutManager: layoutManager ];
        textContainer = [ [ [ NSTextContainer alloc ] init ] autorelease ];
        [ layoutManager addTextContainer:textContainer ];
        // calcolo una buona dimensione per il textContainer; prima dico
        // che la sua dimensione e' piuttosto grande
        [ textContainer setContainerSize: NSMakeSize( 10000, 10000 ) ];
        // poi effettuo il primo rendering della stringa
        glyphRange = [layoutManager glyphRangeForTextContainer: textContainer ];
        // estraggo l'effettivo rettangolo necessario per il disegno del testo
        requiredSize = [layoutManager usedRectForTextContainer:textContainer].size;
        // e lo attribuisco al textcontainer
        [ textContainer setContainerSize: requiredSize ];
        // aggiusto i parametri iniziali del movimento
        [ self newStartPoint ] ;
        // predispongo due filtri per velocita' ed angolo
        // utilizzati solo nel caso di random walk
        // per ridurre il nervosismo del movimento
        // filtro sulla velocita', taglia a 10 Hz
        InitIIR2Filter( & spdFlt, freqValue );
        spdFlt.filtType = FLTTYPE_DBLPOLE ;
        SetIIR2FilterParam( & spdFlt, 2, 1, 20 );
        SetIIR2FilterParam( & spdFlt, 2, 2, 1 );
        ConfigIIR2Filter( & spdFlt, ZERO );
        ResetIIR2Filter( & spdFlt, ZERO );
        // filtro sull'incremento dell'angolo, taglia a 20Hz
        InitIIR2Filter( & angFlt, freqValue );
        angFlt.filtType = FLTTYPE_DBLPOLE ;
        SetIIR2FilterParam( & angFlt, 2, 1, 20 );
        SetIIR2FilterParam( & angFlt, 2, 2, 1 );
        ConfigIIR2Filter( & angFlt, ZERO );
        ResetIIR2Filter( & angFlt, ZERO );
    }
    return self;
}

L'ultima parte del metodo inizializza i parametri del movimento. Il metodo newStartPoint calcola appunto i parametri iniziali del movimento (ci arrivo tra un attimo); seguono poi una serie di istruzioni piuttosto criptiche e che eviterò di spiegare. Mi limito a dire che qui costruisco due filtri numerici per smussare i movimenti casuali (quando il salvaschermo usa la passeggiata casuale); trovate il codice in due file filter.c e filter.h, scritti in puro C e che derivano da altri miei lavori in tutt'altro ambito. Poiché li avevo già pronti, puliti e funzionanti, li ho utilizzati senza troppe modifiche. La loro comprensione richiede troppo cultura che possa essere descritta in queste povere pagine, ma sono disposto a spiegazioni ulteriori a richiesta.

Il metodo newStartPoint utilizza altre funzioni specifiche per i salvaschermi:

- (void)
newStartPoint
{
    // zona occupata dal salvaschermo
    NSRect        vRect = [ self bounds ];
    // zona occupata dal testo
    NSSize        tSize = [ textContainer containerSize ] ;
    // una posizione casuale in x e y
    lastPosition.x = SSRandomFloatBetween( 0,
            vRect.origin.x + vRect.size.width - tSize.width ) ;
    lastPosition.y = SSRandomFloatBetween( 0,
            vRect.origin.y + vRect.size.height - tSize.height ) ;
    // un angolo casuale, evitando angoli troppo vicini a zero o a novanta
    // o multipli, che darebbero movimenti troppo banali
    lastAngle = M_PI * (SSRandomFloatBetween( 0.05, 0.45 ) + 0.5F * SSRandomIntBetween(-2,1));
}

Spesso infatti si ha bisogno di avere dei numeri (pseudo) casuali per poter variare l'aspetto di un salvaschermo. Le funzioni SSRandomFloatBetween e SSRandomIntBetween producono appunto numeri casuali compresi tra i due argomenti della funzione. Utilizzo queste due funzioni per calcolare un punto iniziale di visualizzazione all'interno della vista, e per avere una direzione iniziale di movimento.

L'espressione che calcola lastAngle appare piuttosto complicata; in realtà cerca di evitare angoli troppo vicini a zero e 90 in modo da non avere movimenti (lineari) troppo banali.

Il movimento

Prima di dettagliare il metodo di animazione, occorre spiegare come avviene il movimento.

Il primo tipo di movimento è lineare. Si parte con una direzione (un angolo compreso tra 0 e 360 gradi, o meglio, in radianti, tra meno pi greco e più greco) ed una velocità (cioé, un incremento di posizione nell'unità di tempo, dove l'unità di tempo è l'intervallo di aggiornamento del salvaschermo). Dalla posizione corrente ci si sposta nella direzione prescelta dell'ammontare della velocità, fino a raggiungere o superare uno dei quattro bordi dello schermo. In questo caso, si rimbalza elasticamente indietro, come se la scritta fosse una palla di biliardo.

Il secondo tipo di movimento è la passeggiata casuale o, come preferisco dire, da ubriaco. Si parte da una posizione e con una certa direzione; da qui si generano due numeri casuali (sempre con le funzioni viste sopra). Il primo numero è l'incremento di posizione (un numero compreso tra 1 e la massima velocità specificata dall'utente); il secondo numero è la variazione della direzione, un numero compreso tra -90 e 90 (l'ubriaco cerca di mantenere un percorso più o meno in avanti), che si somma alla direzione corrente.

Ecco il metodo che esegue un passo dell'animazione:

- (void)
animateOneFrame
{
    float    newAngle, themargin ;
    float    newSpeed, tmpInc ;
    NSRect     rect;
    NSSize    limit ;
    NSRange glyphRange ;
    // in primo luogo, ripulisco la zona precedente
    [ self cleanLastText ];
    // ricalcolo le dimensioni della stringa da visualizzare
    [ textStorage setAttributedString: [ self prepareDspString ] ] ;    
    [ textContainer setContainerSize: NSMakeSize( 10000, 10000 ) ];
    glyphRange = [layoutManager glyphRangeForTextContainer: textContainer ];
    limit = [layoutManager usedRectForTextContainer:textContainer].size;
    [ textContainer setContainerSize: limit ];
    // questo e' il rettangolo occupato dal salvaschermo
    rect = [self bounds] ;

Una buona animazione procede in questo modo: cancello il frame precedente, e poi disegno il nuovo frame. Meglio ancora, cancello solo la porzione del frame precedente che è cambiata, e poi scrivo il nuovo frame. La prima istruzione del metodo serve quindi a cancellare la zona di schermo occupata dal testo nel frame precedente. Di seguito, si produce la nuova stringa (il codice è inefficiente, in quanto la maggior parte delle volte la stringa non è cambiata: cambia solo nel caso di visualizzazione dell'ora corrente, e solo una volta su dieci. Ma non avevo voglia di complicarmi la vita), e si ricalcola lo spazio occupato da questa stringa (utile al passo di animazione successivo).

Adesso il codice si biforca per tenere conto delle due possibilità di movimento:

    // calcolo una nuova posizione, in dipendenza dal tipo di movimento
    switch ( userSSType ) {
    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    case SSTYPE_RANDOMWALK :    // passeggiata da ubriaco
        // variazione dell'angolo, piu' o meno in avanti
        tmpInc = M_PI * SSRandomFloatBetween( -0.5, 0.5 );
        // filtro le asperita' del movimento
        newAngle = CalcIIR2Filter( & angFlt, tmpInc ) ;
        // calcolo la nuova direzione di movimento
        lastAngle += newAngle ;
        // spostamento, casuale tra 1 e massimo
        tmpInc = SSRandomFloatBetween( 1, (float) userMaxSpeed );
        // evito accelerazioni troppo brusche
        newSpeed = CalcIIR2Filter( & spdFlt, tmpInc ) ;
        // calcolo la nuova posizione aggiungendo lo spostamento
        lastPosition.x += newSpeed * cos ( lastAngle ) ;
        lastPosition.y += newSpeed * sin ( lastAngle ) ;
        // costringo la posizione all'interno del rettangolo massimo
        // se la posizione supera i limiti, rimango a contatto col margine
        // (l'ubriaco incontra il muro, e ci rimane attaccato...)
        if ( lastPosition.x + limit.width > rect.origin.x + rect.size.width )
            lastPosition.x = rect.origin.x + rect.size.width - limit.width ;
        if ( lastPosition.x < rect.origin.x )
            lastPosition.x = rect.origin.x ;
        // credo che si debba tenere conto anche delle caratteristiche del font
        tmpInc = [ userFont ascender ] + [ userFont descender ] ;
        themargin = lastPosition.y + tmpInc + limit.height ;
        if ( themargin > (rect.origin.y + rect.size.height) )
            lastPosition.y = rect.origin.y + rect.size.height
                - limit.height - tmpInc ;
        themargin = lastPosition.y + tmpInc ;
        if ( themargin < rect.origin.y )
            lastPosition.y = rect.origin.y - tmpInc ;
        break ;

Siamo nel caso della passeggiata casuale; genero un incremento di direzione (un angolo compreso tra -90 e 90 gradi), lo filtro per evitare cambi di direzione troppo bruschi, e poi ottengo la nuova direzione di movimento. Produco poi un nuovo spostamento, compreso tra un pixel ed il massimo numero previsto, filtro nuovamente per evitare accelerazioni eccessive; finalmente, arrivo a calcolare una nuova posizione del testo. Questa posizione però potrebbe essere tale da portare la rappresentazione del testo al di fuori dello spazio ammissibile. Vado quindi a correggere la posizione, facendo in modo che il testo sbatta contro il bordo, e lì rimanga finché la casualità non l'allontana nuovamente in direzione opposta. Nel correggere la posizione, devo tenere conto dello spazio occupato dalla scritta: uso quindi le dimensioni calcolate dalla classe NSLayoutManager per i margini destro e superiore. Devo tenere conto anche delle caratteristiche del font (fatto riconosciuto dopo lungo penare, come al solito). Non sono per nulla convinto della correttezza del mio codice (anzi, secondo me ho sbagliato in pieno); tuttavia, produce risultati accettabili (e, come si dice, quando va che tanto basta, non toccare, ché si guasta). Ad ogni modo, alla posizione corrente, a partire dalla quale verrà disegnato il testo, aggiungo un paio di contributi (ascent e descent) tipiche del font.

    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    case SSTYPE_LINEAR :    // movimento lineare con rimbalzo
        // mantengo direzione e spostamento
        lastPosition.x += userMaxSpeed * cos ( lastAngle ) ;
        lastPosition.y += userMaxSpeed * sin ( lastAngle ) ;
        // gestione del rimbalzo
        // se vado troppo a destra, rimbalzo indietro verso sinistra
        themargin = lastPosition.x + limit.width ;
        if ( themargin > rect.origin.x + rect.size.width )
        {
            // spazio che manca da percorrere
            float    delta = themargin - (rect.origin.x + rect.size.width) ;
            // aggiorno la posizione
            lastPosition.x = (rect.origin.x + rect.size.width - limit.width) - delta ;
            // cambia l'angolo di movimento
            lastAngle = M_PI - lastAngle ;
        }
        // se vado troppo a sinistra, rimbalzo verso destra
        // (c'e' una metafora politica in tutto cio'?)
        if ( lastPosition.x < rect.origin.x )
        {
            // spazio che manca da percorrere
            float    delta = rect.origin.x - lastPosition.x ;
            // aggiorno la posizione
            lastPosition.x = rect.origin.x + delta ;
            // cambia l'angolo di movimento
            lastAngle = M_PI - lastAngle ;
        }
        // se vado troppo in alto, rimbalzo in basso
        // credo che si debba tenere conto anche delle caratteristiche del font
        tmpInc = [ userFont ascender ] + [ userFont descender ] ;
        themargin = lastPosition.y + tmpInc + limit.height ;
        if ( themargin > (rect.origin.y + rect.size.height) )
        {
            float    delta = themargin - (rect.origin.y + rect.size.height) ;
            lastPosition.y = rect.origin.y + rect.size.height - limit.height
                    - delta -tmpInc ;
            lastAngle = - lastAngle ;
        }
        // se vado troppo i nbasso, rimbalzo in alto
        themargin = lastPosition.y + tmpInc ;
        if ( themargin < rect.origin.y )
        {
            float    delta = rect.origin.y - themargin ;
            lastPosition.y = rect.origin.y + delta - tmpInc ;
            lastAngle = - lastAngle ;
        }
        break ;
    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    default :    // non dovrei mai capitare qui...
        NSLog( @"Non dovrei mai scrivere questo, ma non si sa mai - 1");
        break ;
    }
    // riporto la direzione all'interno dell'angolo fondamentale
    while ( lastAngle < - M_PI )    lastAngle += 2 * M_PI ;
    while ( lastAngle > M_PI )    lastAngle -= 2 * M_PI ;
    // scrivo di nuovo la stringa, nella nuova posizione
    [ layoutManager drawGlyphsForGlyphRange:glyphRange atPoint: lastPosition ];
}

A questo punto l'altro tipo di movimento, quello lineare, è solo leggermente più complicato. Aggiorno la posizione aggiungendo lo spostamento previsto, e poi verifico se ho raggiunto e superato uno dei bordi. Nel caso, devo cambiare la direzione in modo da simulare un rimbalzo elastico. Allo scopo, ci sono due elementi da tenere in considerazione: lo spazio e l'angolo. Comincio con l'angolo che è più facile. In un rimbalzo elastico l'angolo di incidenza deve essere uguale all'angolo di riflessione; a seconda del bordo il nuovo angolo si ottiene pigliando l'angolo piatto e sottraendo l'angolo di incidenza, oppure semplicemente rovesciando il segno. Per lo spazio rimasto da percorrere, piglio la differenza tra la nuova posizione ed il bordo; questa differenza va sottratta alla coordinata del bordo per ottenere la posizione riflessa. La cosa è complicata vieppiù dal fatto che bisogna considerare lo spazio occupato dal testo e le onnipresenti caratteristiche del font.

Finalmente, l'ultima istruzione del metodo disegna il testo nella nuova posizione.

Mancano un paio di metodi per completare il disegno del testo.

Il metodo per cancellare lo spazio occupato in precedenza:

- (void)
cleanLastText
{
    NSRect     rect;
    NSSize    limit = [layoutManager usedRectForTextContainer: textContainer ].size;
    // calcolo il rettangolo da cancellare; parto dalla posizione precedente
    // ma devo tenere conto delle caratteristiche del font
    rect = NSMakeRect( lastPosition.x,
        lastPosition.y + [ userFont ascender ] + [ userFont descender ],
        limit.width, limit.height );
    [[NSColor blackColor] set];
    NSRectFill( NSInsetRect( rect, -2, -2) );
}

Qui utilizzo la posizione corrente e le dimensioni del testo per costruire un rettangolo, che riempio di nero. Ancora una volta, le caratteristiche del font aiutano a calcolare meglio il rettangolo.

Infine, il metodo che prepara il testo:

- (NSMutableAttributedString *)
prepareDspString
{
    NSMutableAttributedString    * thestring ;
    NSMutableDictionary        * txtAtt ;

    switch ( userTextType ) {
    case SSTXT_CURRTIME :    // ora corrente
        thestring = [ [NSMutableAttributedString alloc] initWithString:
            [ [NSDate date] descriptionWithCalendarFormat:@"%H:%M:%S" timeZone:nil locale:nil] ];
        break ;
    case SSTXT_USERTEXT :    // testo stabilito dall'utente
        thestring = [ [NSMutableAttributedString alloc] initWithString: userString ];
        break ;
    default :    // non dovrei mai capitare qui...
        NSLog( @"Non dovrei mai scrivere questo, ma non si sa mai - 2");
        break ;
    }    
    // inizializzo un dizionario per contenere gli attributi
    txtAtt = [[[NSMutableDictionary alloc] initWithCapacity:2] autorelease];
    [ txtAtt setObject: userFont forKey: NSFontAttributeName ];
    [ txtAtt setObject: userColor forKey: NSForegroundColorAttributeName ];
    [ thestring setAttributes: txtAtt range: NSMakeRange(0, [thestring length])];
    return ( thestring );
}

Qui non c'è molto di nuovo rispetto a quanto visto nei precedenti capitoli di Macocoa. A seconda del tipo di testo da visualizzare, costruisco una NSMutableAttributeString, attribuisco font e colore, e la restituisco indietro.

Configurazione del Salvaschermo

La parte disegno del salvaschermo è adesso completa. C'è da scrivere tutta la sezione di configurazione. A parte un paio di dettagli, non è che ci siano grandi concetti. Si devono sovrascrivere due metodi della classe SceenSaverView.

figura 03

figura 03

Il primo metodo serve ad indicare al gestore dei salvaschermo se è presente o meno un pannello di configurazione. La versione di default di questo metodo dice di no, e quindi tutto si ferma lì. Qui invece il pannello è presente. Occorre allora scrivere un secondo metodo che restituisca la finestra che appunto presenta l'interfaccia di configurazione.

Ecco i due metodi necessari

- (BOOL)hasConfigureSheet
{
    // c'e' la finestra, ritorno YES
    return YES;
}

- (NSWindow*)configureSheet
{
    NSString    * thestr ;
    // se la finestra non e' presente, la carico dal file nib
    if (! prefSheet)
        [NSBundle loadNibNamed:@"prefSheet" owner:self];
    // questo e' un buon posto per predisporre la finestra in modo
    // che mostri i valori correnti
    [ ssType selectItemAtIndex: (userSSType - SSTYPE_BASEVAL4MENU) ] ;
    [ textType selectItemAtIndex: (userTextType - SSTXT_BASEVAL4MENU) ] ;
    if ( userTextType == SSTXT_USERTEXT )
        [ displayText setEnabled: YES ] ;
    else
        [ displayText setEnabled: NO ] ;
    [ displayText setStringValue: userString ] ;
    [ textColor setColor: userColor ];
    [ travelSpeed setIntValue: userMaxSpeed ] ;
    // aggiusto la visualizzazione del font di esempio
    thestr = [ NSString stringWithFormat: @"%@ %4.1f",
                    [ userFont displayName ], [ userFont pointSize ] ] ;
    // il nome del font lo imposto con font e col colore prescelti
    [ choosenfont setTextColor: userColor ] ;
    [ choosenfont setStringValue: thestr ];
    return ( prefSheet );
}

Il secondo metodo, come si può facilmente capire leggendo il codice, carica la finestra dal nib che mi sono premunito di costruire. Utilizzando poi i valori di default presenti, predispone i vari controlli della finestra in maniera da rispecchiare lo stato corrente.

Un punto interessante è che il proprietario di questo nib (e della finestra contenuta) non è come al solito una classe NSWindowController, ma una classe NSView; in effetti non ci sono particolari differenze di trattamento, basta aggiungere gli opportuni outlet verso i controlli, realizzare le action che i controlli attivano, proprio come una normale classe controllore di finestre.

Per il resto, è la solita ordinaria amministrazione. Quando si chiude la finestra facendo clic sul pulsante di Ok, si devono effettuare le modifiche ai valori di default, ed aggiornarli nelle preferenze:

- (IBAction)
prefSheetSave:(id) sender
{
    ScreenSaverDefaults            * defaults ;
    // recupero la stringa utente
    [ self setUserString: [ displayText stringValue ]] ;
    // chiudo la finestra
    [NSApp endSheet:prefSheet];
    // aggiorno i defaults
    defaults = [ScreenSaverDefaults defaultsForModuleWithName:DEFAULTFILENAME];
    if ( [defaults integerForKey: DEF_FILEPRESENT ] <= 0 )
        [defaults setInteger: 1 forKey: DEF_FILEPRESENT];
    [defaults setInteger: userSSType forKey: DEF_USERSSTYPE];
    [defaults setObject: [userFont fontName] forKey: DEF_USERFONT ];
    [defaults setFloat: [userFont pointSize ] forKey: DEF_USERFONTSIZE ];
    [defaults setObject: [ NSArchiver archivedDataWithRootObject: userColor]
        forKey: DEF_USERCOLOR ];
    [defaults setInteger: userTextType forKey: DEF_USERTEXTTYPE ];
    [defaults setObject: userString forKey: DEF_USERSTRING ];
    [defaults setInteger: userMaxSpeed forKey: DEF_USERMAXSPEED ];
    [defaults synchronize];
}

Se invece si fa clic sul pulsante Cancel, ci si limita a chiudere la finestra

- (IBAction)
prefSheetCancel:(id) sender
{
    [NSApp endSheet:prefSheet];
}

In entrambi i casi la finestra va chiusa esplicitamente, proprio perché il suo proprietario non è un NSWindowController, e quindi non eredita alcune funzionalità.

Tutti i controlli che hanno un effetto diretto sul salvaschermo chiamano il seguente metodo come action, metodo che aggiusta i valori interni dei parametri:

- (IBAction)
updateWindow:(id) sender
{
    int        thetag = [ sender tag ] ;
    switch ( thetag ) {
    case 1 :    // tipo di movimento
        userSSType = [[ ssType selectedItem ] tag] - SSTYPE_BASETAG4MENU ;    
        break ;
    case 2 :    // velocita' di movimento
        userMaxSpeed = [ travelSpeed floatValue ] ;    
        break ;
    case 3 :    // cambio tipo di testo
        userTextType = [[ textType selectedItem ] tag] - SSTXT_BASETAG4MENU ;    
        if ( userTextType == SSTXT_USERTEXT )
        {
            [ displayText setEnabled: YES ] ;
            [ displayText setStringValue: userString ] ;
        }
        else
        {
            [ displayText setEnabled: NO ] ;
        }
        break ;
    case 5 :    // colore del testo
        [ self setUserColor: [ textColor color] ];
        [ choosenfont setTextColor: userColor ] ;
        break ;
    default :
        break ;
    }
    [ self cleanLastText ];
}

Un controllo che non passa per questo metodo è il campo di testo dove l'utente specifica il testo a visualizzare, che è gestito direttamente dal metodo prefSheetSave.

Un altro controllo è il pulsante che permette di selezionare il font, che apre direttamente il pannello di scelta font:

- (IBAction)
selectFont:(id) sender
{
    [ [ NSFontManager sharedFontManager] orderFrontFontPanel: nil ];
}

A sua volta, il pannello attiva come action il metodo changeFont, che ho così realizzato:

- (void)
changeFont:(id)sender
{
    NSString    * thestr ;
    // ottengo il nuovo font
    NSFont        * newFont = [sender convertFont:userFont] ;
    // lo imposto internamente
    [ self setUserFont: newFont ];
    // aggiorno la stringa visualizzata
    thestr = [ NSString stringWithFormat: @"%@ %4.1f", [ newFont displayName ], [ newFont pointSize ] ] ;
    // e cambia la visualizzazione della stessa in accordo al nuovo font e colore
    newFont = [ NSFont fontWithName: [userFont fontName] size: 12 ] ;
    [ choosenfont setFont: newFont ] ;
    [ choosenfont setStringValue: thestr ];
    // pulisco il testo attuale
    [ self cleanLastText ];
    return;
}

Ho infatti pensato che fosse cosa carina (ed educativa) fare in modo che una stringa con il nome del font prescelto fosse rappresentata autoreferenzialmente (cioé, con lo stesso font che indica). Così facendo, ho scoperto la differenza tra il metodo displayName (produce una stringa adatta alla visualizzazione verso un utente umano) e fontName (produce una stringa più adatta ad un utente elettronico).

Infine, un trucco per permettere all'utente di interagire con il salvaschermo, e cambiarne alcune proprietà mentre è in esecuzione:

-(void)
keyDown:(NSEvent *)theEvent
{
    // recupero i caratteri associati all'evento
    NSString *characters = [theEvent characters];
    // se ci sono effettivamente caratteri
    if ([characters length])
    {
        // ricavo il primo carattere
        unichar character = [characters characterAtIndex:0];
        // ed eseguo quanto richiesto
        switch (character) {
        case 'r':    // riparto con nuovi valori di movimento
            [ self cleanLastText ];
            [ self newStartPoint ];
            break ;
        case 't':    // cambio tipo di salvaschermo
            if ( userSSType == SSTYPE_RANDOMWALK )
                userSSType = SSTYPE_LINEAR ;
            else if ( userSSType == SSTYPE_LINEAR )
                userSSType = SSTYPE_RANDOMWALK ;
            // ...
            [ self cleanLastText ];
            [ self newStartPoint ];
            break ;
        default :
            break ;
        }
    }
}

Il metodo intercetta ogni pressione di tasto, e tratta in maniera particolare un paio di caratteri; se l'utente preme il tasto 'r', il salvaschermo riparte daccapo, ma con lo stesso tipo di movimento. Con il tasto 't', invece, il movimento cambia natura (da casuale diventa lineare e viceversa), ed ovviamente riparte daccapo.

Debug di un salvaschermo

Un problema che si è presentato abbastanza presto nello sviluppo di questo salvaschermo è stato come eseguire il debug. Infatti, il processo di compilazione produce un file .saver, che non è una applicazione. Per provare il funzionamento, non è possibile utilizzare i comandi Run e Debug presenti in XCode. Il mio primo tentativo era allora di compilare, passare da XCode al Finder, fare doppio clic sul risultato della compilazione, installare il salvaschermo, e finalmente, all'interno dell'applicazione Preferenze di Sistema, eseguire il salvaschermo.

Un primo passo è stata l'eliminazione del passo di installazione. Si può fare in modo che XCode costruisce il salvaschermo in una directory che non è quella di default (all'interno della cartella del progetto), ma a scelta. Ho quindi scelto come cartella destinazione quella dei salvaschermi utente.

figura 04

figura 04

Si tratta d modificare il percorso di default nel tab General della finestra delle informazioni del progetto. Poi, sfruttando quella immensa mole di informazioni che è la mailing list degli sviluppatori Cocoa, ho trovato una brillante soluzione, anzi due.

In effetti è possibile eseguire le operazioni di Run e Debug di un salvaschermo direttamente all'interno di XCode (in altre parole: si può utilizzare il debugger!); si tratta di costruire un nuovo Custom Executable (c'è una voce apposita nel menu Project di XCode) e di stabilire l'applicazione Preferenze di Sistema come applicazione da utilizzare. Così facendo, il comando Run di Xcode fa partire appunto questa applicazione (e così salto un ulteriore passo). Addirittura, inserendo dei breakpoint nel codice del salvaschermo, con il comando Debug è possibile eseguire passo passo il codice del salvaschermo (ovviamente, solo la parte specifica del salvaschermo in via di costruzione, non essendo disponibile il sorgente di Preferenze di Sistema).

figura 05

figura 05

C'è addirittura un passaggio ulteriore; esiste una applicazione dal nome di SaverLab, open source, che fornisce un ambiente dove eseguire sperimentazioni con i salvaschermi. Tra le cose interessanti, la possibilità di rallentare o accelerare l'animazione, utilizzare finestre di varie dimensioni, avere più istanze contemporanee del salvaschermo, produrre un filmato quicktime con l'animazione eseguita dal salvaschermo. A parte qualche idiosincrasia minore (cose che funzionano con Preferenze di Sistema e non funzionano con SaverLab), è decisamente un programma da avere quando si sviluppano salvaschermi.

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).