MaCocoa 042

Capitolo 042 - Eppur si disegna

In questo capitolo si comincia a fare sul serio: comincio a tracciare qualche linea all'interno della coverView, usando direttamente il mouse.

Piglio come al solito spunto dall'esempio Sketch fornito da Apple.

Primo inserimento: 17 febbraio 2004

La palette degli strumenti

figura 01

figura 01

Come ogni buona applicazione per disegnare qualcosa, occorre predisporre una palette degli strumenti. Mi disegno quindi con Interface Builder una simpatica finestra che contenga qualche strumento di facile uso. Comincio con una matrice di pulsanti, per avere uno strumento di selezione, uno strumento per le linee, uno per i rettangoli, per le ellissi; per i testi lo prevedo ma lo lascio disabilitato, poi mi resta un buco vuoto, e lo lascio lì per future estensioni.

figura 02

figura 02

Aggiungo poi due NSColorWell con pulsante di spunta al seguito, per selezionare il colore del contorno e dell'eventuale riempimento. Infine, un menu pop-up dove è possibile selezionare alcuni spessori notevoli.

figura 03

figura 03

Ho già utilizzato una matrice di oggetti, quando ho preparato la finestra per la selezione delle preferenze di visualizzazione delle colonne. Da lì riciclerò concetti ed anche pezzi di codice. La gestione delle NSColorWell ho visto essere cosa facile, ed anche il menu pop-up non mi preoccupa. In effetti il punto più difficile di tutta la finestra è stato il disegno dei pulsanti. Sono notoriamente incapace di disegnare qualcosa di visibile senza vergogna. C'è da considerare il fatto che ogni pulsante si presenta secondo due aspetti: selezionato e deselezionato. Confesso che non ho letto attentamente la documentazione, ma ho sperimentato un po'. Ho scoperto che un comportamento decente per la palette si ottiene configurando la matrice nel modo radio, che consente la selezione di un solo elemento alla volta (ed automaticamente deselezionando tutti gli altri); e poi configurando i pulsanti come square button con comportamento (behaviour) Toggle. Ho così disegnato per ogni pulsante due bitmap, una con aspetto normale ed una con aspetto selezionato (che è l'aspetto normale con colori invertiti), e le ho attribuite come Icon e come Alt. Icon. Termino così le operazioni con Interface Builder e torno in XCode per scrivere un po' di codice.

Finestra CoverPalWinCtl

Il controllore della finestra palette non è nulla di speciale; come al solito ci sono un po' di outlet, ed una manciata di metodi per facilitare il lavoro delle altre classi

@interface CoverPalWinCtl : NSWindowController
{
    // elemento grafico corrente
    Class                        currCCE ;
    // elementi dell'interfaccia
    IBOutlet NSMatrix            * toolMatrix ;
    IBOutlet NSColorWell        * currLineWell ;
    IBOutlet NSColorWell        * currFillWell ;
    IBOutlet NSButton            * fillCheckbox;
    IBOutlet NSButton            * lineCheckbox;
    IBOutlet NSPopUpButton        * lineWidth;
}
+ ( id ) sharedCoverPalWinCtrl ;
- (IBAction)handleToolSel:(id)sender;
- (NSColor*) getCurrLineColor ;
- (NSColor*) getCurrFillColor ;
- (Class) getCurrClassTool ;
- (BOOL) getStrokeSts ;
- (BOOL) getFillSts ;
- (float) getLineWidth ;
@end

C'è qui un metodo interessante per tenere conto di quale sia l'elemento correntemente selezionato nella palette. Piuttosto che conversare un generico identificare della selezione corrente, conservo una variabile d'istanza di tipo Class. La variabile vale nil quando non sono selezionati elementi (all'interno della matrice), o meglio, quando è selezionato il pulsante con la freccia, che funziona da strumento selezione. Negli altri casi, attribuisco a tale variabile proprio la classe corrispondente all'elemento selezionato (CCE_Line, ad esempio). Questo costrutto mi renderà le cose interessanti al momento del disegno.

Il metodo corrispondente, invocato ad ogni clic sopra un elemento della matrice, si scrive molto facile:

- (IBAction)
handleToolSel:(id)sender
{
    switch ( [ [toolMatrix selectedCell] tag ] ) {
    case 0 : currCCE = nil ; break ;
    case 1 : currCCE = nil ; break ;
    case 2 : currCCE = [ CCE_Line class ] ; break ;
    case 3 : currCCE = [ CCE_Rect class ] ; break ;
    case 4 : currCCE = [ CCE_Circle class ]; break ;
    case 5 : currCCE = [ CCE_Text class ] ; break ;
    }
}

A seconda del valore del tag del pulsante, modifico il valore della variabile currCCE (lasciate per il momento perdere i casi 1 e 5, che tanto non sono mai attivati, avendo disabilitato i pulsanti corrispondenti).

Tutti gli altri metodi sono messi a disposizione per recuperare i valori degli altri controlli presenti nella finestra (colori, spessori, eccetera). Sono semplici e non ne faccio parola.

Va da sé che occorre aggiungere una voce di menu in MainManu.nib e metodi corrispondenti, nel solito modo, per aprire la finestra.

Trattare il mouse

Cosa succede quando l'utente fa clic da qualche parte? L'ambiente operativo si scatena, macina posizioni e numeri, e finisce col mandare un messaggio ad un oggetto NSResponder (o sottoclassi relative, di cui NSView è un esempio classico) che reputa essere in grado di rispondere. Nel mio caso, devo fare in modo che la coverView risponda a questo tipo di messaggi. Non devo fare altro che scrivere un metodo come il seguente:

- (void)
mouseDown:(NSEvent *)theEvent
{
    // vado a pigliare qual e' il tool correntemente selezionato nella finestra
    Class selCls = [[CoverPalWinCtl sharedCoverPalWinCtrl] getCurrClassTool];
    // se e' selezionato un elemento da disegnare
    if (selCls)
    {
        // costruisco l'elemento grafico
        CCE_BasicForm * curElem = [[ [selCls alloc ] init] autorelease ] ;
        // metto a posto un di attributi base dell'elemento
        [ curElem setCceLineWidth: [[CoverPalWinCtl sharedCoverPalWinCtrl] getLineWidth] ];
        [ curElem setCceFillColor: [[CoverPalWinCtl sharedCoverPalWinCtrl] getCurrFillColor] ];
        [ curElem setCceLineColor: [[CoverPalWinCtl sharedCoverPalWinCtrl] getCurrLineColor] ];
        [ curElem setCceIsFilled: [[CoverPalWinCtl sharedCoverPalWinCtrl] getFillSts] ];
        [ curElem setCceIsStroked: [[CoverPalWinCtl sharedCoverPalWinCtrl] getStrokeSts] ];
        // deseleziono tutto
        [ theElements setCceIsSelected: NO ] ;
        // dico che l'elemento che sto disegnando e' selezionato
        [ curElem setCceIsSelected: YES ];
        // e che si tratta dell'elemento in lavorazione
        [ self setCurCreatingElem: curElem ] ;
        // gestisco la costruzione col mouse
        if ([curElem handleCreation: theEvent inView: self ])
        {
            // se la creazione e' andata a buon fine,lo aggiungo alla lista
            [theElements addElem:curElem ];
        }
        // non ci sono piu' elementi in lavorazione
        [ self setCurCreatingElem: nil ] ;
    }
    else
    {
        // non si deve disegnare un nuovo elemento
        // tratto un generico clic all'interno della finestra
        [self handleMouseClick: theEvent];
    }
}

Il comportamento dipende in maniera fondamentale dall'elemento correntemente selezionato nella palette. La prima cosa che mi chiedo è allora qual è la selezione corrente, recuperando la classe selezionata. Se questa è nil (ramo else), c'è selezionato lo strumento selezione, e quindi bisogna trattare il clic del mouse in maniera standard (vedrò questo più avanti). Per il momento mi focalizzo sulla situazione in cui il clic avviene nella finestra con un elemento da disegnare.

La prima cosa da fare è ovviamente costruire un oggetto di tipo appropriato. E qui entra in gioco il discorso del paragrafo precedente. Uso direttamente la variabile selCls come destinataria dei messaggi alloc ed init, per costruire un elemento, nominalmente di tipo CCE_BasicForm, ma in realtà è proprio del tipo corretto.

Tramite i metodi di comodo, attribuisco i valori correnti per linee, spessori, colori, eccetera.

Tralascio le due istruzioni successive, che riprendo più tardi parlando della selezione degli elementi. Poi, cominciano i problemi. Non commento l'assegnazione dell'elemento corrente come elemento in lavorazione (ci sarà un paragrafo dedicato), e passo a gestire la creazione dell'elemento con il metodo handleCreation:inView:; essenzialmente, il cuore della faccenda.

Crearsi da soli

L'idea è di lasciare che ogni elemento tratti da sé la procedura di creazione. In questo modo è facile (polimorfismo!) gestire le diverse necessità di ogni oggetto in termini di operazioni richieste. Per il momento, ho diviso le operazioni richieste in due parti, la prima più o meno generica per ogni elemento, la seconda più specifica.

Quando si usa il mouse per disegnare una linea, un rettangolo, eccetera, in realtà si finisce spesso con l'individuare due punti: quello in cui è avvenuto il clic del mouse (mouseDown) e quello dove avviene il rilascio del mouse (mouseUp). Nel mezzo, tramite operazioni di drag (spostamento del mouse con pulsante premuto), si procede al disegno di una linea o di un rettangolo. Ecco il motivo per cui ho messo alla base della creazione di un elemento un ciclo che continua ad aggiornare l'elemento in corso di creazione basandosi appunto sul punto di mouseDown e sul punto di mouseUp.

- (BOOL)
handleCreation: (NSEvent *)theEvent
    inView:        (CoverView *)view
{
    // recupero la coordinata locale
    NSPoint point1 = [view convertPoint:[theEvent locationInWindow] fromView:nil];
    NSPoint point ;
    // inizializzo l'aspetto dell'oggetto (praticamente vuoto)
    [ self buildMeWithStartPt: point1 andPt: point1 ] ;
    // continuo a ciclare in attesa di un mouseup
    do {
        // recupero il prossimo evento, che sia un movimento del mouse
        // oppure un mouseup (che finisce il tutto)
        theEvent = [[view window] nextEventMatchingMask:(NSLeftMouseDraggedMask | NSLeftMouseUpMask)];
        // recupero la coordinata locale
        point = [view convertPoint:[theEvent locationInWindow] fromView:nil];
        // aggiusto l'aspetto dell'oggetto
        [ self buildMeWithStartPt: point1 andPt: point ] ;
        // forzo un ridisegno della finestra
        [ view setNeedsDisplay: YES ];
    }
    while ( [theEvent type] != NSLeftMouseUp) ;
    // arrivato qui, ho finito il tutto
    return YES;
}

Il metodo comincia mettendo da parte (una volta convertite le coordinate del punto di clic dal sistema di riferimento della finestra al sistema di riferimento della coverView) il punto di mouseDown. Poi comincia un ciclo di operazioni, che sono ripetute fino a che non è intercettato un evento di mouseUp.

Per prima cosa si recuperano le coordinate correnti del mouse. Con queste coordinate si procede alla costruzione dell'elemento attraverso il metodo buildMeWithStartPt:andPt:, per poi concludere il tutto forzando il ridisegno della vista.

Prima di capire qualche dettaglio, è bene vedere il metodo di costruzione, ad esempio nel caso semplice della classe CCE_Rect:

- (void)
buildMeWithStartPt: (NSPoint) stPt andPt: (NSPoint) endPt
{
        float maxPx = ( stPt.x > endPt.x ) ? stPt.x : endPt.x ;
        float maxPy = ( stPt.y > endPt.y ) ? stPt.y : endPt.y ;
        float minPx = ( stPt.x < endPt.x ) ? stPt.x : endPt.x ;
        float minPy = ( stPt.y < endPt.y ) ? stPt.y : endPt.y ;
        NSRect aRect = NSMakeRect( minPx, minPy, maxPx - minPx, maxPy - minPy );
        // per mia convenienza conservo il percorso
        NSBezierPath *path = [NSBezierPath bezierPathWithRect: aRect ];
        [ self setTheRect: path ];
        // metto a posto il rettangolo che lo contiene
        [self setElemLimit: aRect ];
}

Dati i due punti, determino le coordinate del punto in alto a sinistra (le coordinate verticali sono rovesciate) e le due grandezze larghezza ed altezza per determinare col metodo tipico bezierPathWithRect: il percorso che rappresenta il rettangolo.

Il metodo equivalente per una ellisse è quasi uguale, un po' diverso quello per la costruzione di una linea.

- (void)
buildMeWithStartPt: (NSPoint) stPt andPt: (NSPoint) endPt
{
        // per mia praticita', costruisco un percorso
        NSBezierPath *path = [NSBezierPath bezierPath];
        // assegno i punti di partenza ed arrivo
        startPoint = stPt ;
        endPoint = endPt ;
        // costruisco il percorso
        [path moveToPoint: startPoint ];
        [path lineToPoint: endPoint ];
        // lo assegno
        [ self setTheLine: path ];
        // metto a posto il rettangolo che lo contiene
        [self setElemLimit: [theLine bounds] ];
        // variabile di appoggio
        delta = 1.0F / sqrt( ( endPoint.x - startPoint.x)*( endPoint.x - startPoint.x) +
                ( endPoint.y - startPoint.y)*( endPoint.y - startPoint.y) );
}

In questo caso (a parte l'ultima istruzione, che servirà più avanti) ho praticamente estratto dal metodo di inizializzazione tutte le istruzioni e le ho inserite qui. In effetti, il metodo è diventato:

-(id) initStartPt: (NSPoint) stPt toPt: (NSPoint) endPt
{
    self = [ super init ];
    if ( self )
    {
        [ self buildMeWithStartPt: stPt andPt: endPt ];
        [ self setCceIsStroked: TRUE ];
    }
    return ( self );
}

L'elemento corrente

Mi sembrava di aver fatto tutto giusto e correttamente, eppure, non riuscivo a disegnare. Anzi no, la linea o il rettangolo disegnato comparivano, ma solo dopo il completamento dell'operazione, mentre a me interessava vedere il movimento elastico di un rettangolo mentre viene tracciato. Dopo lungo penare, ho capito il problema.

Chiamando il metodo setNeedsDisplay all'interno del ciclo di tracciatura del mouse, si forza il ridisegno della finestra. Ma il ridisegno dipinge tutti e solo gli elementi che si trovano all'interno del gruppo theElements della coverView. E l'elemento che sto disegnando non è ancora inserito all'interno di questo gruppo, ma lo sarà solamente al termine delle operazioni (e questo spiega il comportamento bizzarro).

Come gestire allora questo trascinamento dal vivo? Piuttosto che aggiungerlo al gruppo, ho deciso di tenerlo da parte, in una variabile d'istanza apposita della coverView.

    CCE_BasicForm        * curCreatingElem ;

Questa variabile ha normalmente il valore nil, e nulla accade. Quando si comincia a tracciare con il mouse, all'interno del metodo mouseDown:, assegno a questa variabile l'elemento appena creato

        [ self setCurCreatingElem: curElem ] ;

per poi distruggerlo al termine delle operazioni (ma non prima che questo sia appunto aggiunto al gruppo).

Tutto questo allo scopo di modificare il metodo che effettua il disegno della vista come segue:

- (void)drawRect:(NSRect)rect
{
    // sfondo bianco
    [[ NSColor whiteColor] set ];
    NSRectFill( rect );
    // poi dico di disegnarsi a tutti gli elementi
    [ [self theElements] drawElement: rect ];
    // e poi all'elemento in corso
    if ( curCreatingElem )
    {
        [ curCreatingElem drawElement: rect ] ;
    }
}

Semplicemente, quando si è in corso di creazione di elementi, si disegna anche l'elemento in corso di creazione. Così facendo, si ottiene il risultato sperato. Già che ci sono, ci sono due modifiche ai metodi propri dell'elemento gruppo.

La prima è banale ma pregna di conseguenze, e riguarda l'ordine con cui sono ridisegnati gli elementi del gruppo.

- (void) drawElement: (NSRect) aRect
{
    int        i , numElem ;
    CCE_BasicForm * elem ;
    
    numElem = [ elemArray count ];
    for ( i = numElem-1 ; i >= 0 ; i -- )
    {
        elem = [ elemArray objectAtIndex: i ];
        [ elem drawElement: aRect ];
    }
}

Invece che eseguire la classica spazzolata dall'indice zero all'indice massimo, il ridisegno avviene in senso inverso. Parto dall'idea che gli elementi a indice più basso si trovano davanti ad elementi di indice più alto, ovvero che i primi coprono, se sovrapposti, i secondi. Per avere un buon disegno, occorre quindi partire dall'elemento dietro tutti gli altri, e procedere verso quelli davanti.

Allo stesso modo, decido che quando aggiungo un elemento ad un gruppo, lo metto nella posizione davati a tutti gli altri (perché si suppone che sia stato creato per ultimo).

- (void) addElem: (CCE_BasicForm *) elem
{
    // facile
    //    [ elemArray addObject: elem ];
    // troppo facile; inserisco davanti a tutto
    [ elemArray insertObject: elem atIndex:0];
}

Il metodo standard addObject:, come si evince dalla documentazione, infila il nuovo alla fine del vettore NSMutableArray. Tocca quindi indicare esplicitamente la posizione dove inserire il nuovo elemento.

(Ora, i più accorti tra voi potrebbero osservare: ma se io lascio tutto com'era prima, funziona uguale: aggiungo elementi alla fine, ridisegno partendo dall'indice più basso, basta dire che gli elementi d'indice più basso si trovano dietro a quelli d'indice più alto, rovesciando la convenzione. Vero. Ma a me piace la convenzione opposta, e quindi va bene così).

Manici

figura 06

figura 06

Arrivato a questo punto, mi trovo con una applicazione in grado di disegnare linee, rettangoli ed ellissi a mano libera, con contorni ed interni di colore a piacere, ed altre piacevolezze. Molto bello.

Ma rimane da trattare la parte del mouse quando si fa clic senza l'intento di disegnare nuovi elementi. Avevo infatti lasciato in sospeso la realizzazione del metodo handleMouseClick:, che appunto interviene quando è attivo lo strumento selezione.

figura 04

figura 04

Ma prima, devo riscrivere i metodi di disegno degli elementi per tenere conto del fatto che un elemento può essere selezionato o meno. Normalmente, quando in un programma di disegno, si seleziona un elemento, questo è evidenziato in qualche modo. Spesso, cambia aspetto, e viene ridisegnato mettendo bene in evidenza alcuni punti notevoli dell'elemento grafico. Ad esempio, selezionando una linea, questa può presentarsi con gli estremi evidenziati tramite un quadratino. Generalmente, attraverso questi quadratini l'utente può poi manipolare l'elemento, cambiandone l'aspetto successivamente alla creazione (è stupefacente come operazioni ormai naturali a tutti richiedano così tante parole per essere descritte). Per questo motivi i quadratini sono spesso chiamati handle o manici.

Nel mio caso, ecco una possibile realizzazione nel caso di un CCE_Line:

- (void) drawElement: (NSRect) inRect
{
    // imposto il colore
    [ cceLineColor set ];
    // imposto lo spessore della linea
    [ theLine setLineWidth: [ self cceLineWidth ]];
    // disengo la linea
    [ theLine stroke ];
    // se l'elemento e' selezionato
    if ( cceIsSelected )
    {
        // disegno i manici per la manipolazione
        ccex_drawHandleAt( startPoint );
        ccex_drawHandleAt( endPoint );
    }
}

Se la variabile d'istanza cceIsSelected è vera, chiamo due volte una funzione per disegnare un handle, uno nel punto iniziale ed uno nel punto finale. Nel caso di un rettangolo o di una ellisse, chiamerò la funzione quattro volte, per i quattro vertici del rettangolo stesso (o quello che include l'ellisse). La funzione è piuttosto pedestre:

void    
ccex_drawHandleAt( NSPoint where )
{
    // per ora la dimensione del manico e' fissa
    int        handleDim = 3 ;
    NSRect    hRect, rRect ;
    // disegno un rettangolo pieno attorno al punto
    hRect = NSMakeRect( where.x, where.y,2*handleDim, 2*handleDim );
    rRect = NSOffsetRect( hRect , -handleDim, -handleDim);
    hRect = NSOffsetRect(rRect, 1.0, 1.0);
    [[NSColor lightGrayColor] set];
    NSRectFill(hRect);
    [[NSColor darkGrayColor] set];
    NSRectFill(rRect);
}

figura 05

figura 05

Uno handle è un quadrato centrato attorno al punto specificato come parametro. La variabile handleDim rappresenta metà della dimensione dello handle (tre pixel).

Determinato il rettangolo a partire dal punto selezionato, calcolo il nuovo rettangolo rRect centrandolo prorpio sul punto. Poi costruisco un altro rettangolo, spostato di un pixel, a mo' di ombra.

Strumento selezione

Finalmente, il metodo per gestire un clic di selezione.

- (void)
handleMouseClick: (NSEvent *)theEvent
{
    NSPoint whereClick;
    CCE_BasicForm * curElem = nil;
    // vado a vedere dove e' avvenuto il click
    whereClick = [self convertPoint:[theEvent locationInWindow] fromView:nil];
    // niente niente l'utente vuole selezionare un elemento
    curElem = [self getClickedElem:whereClick];
    // se c'e' un elemento sotto il mouse
    if ( curElem )
    {
        // abbiamo qualcosa sotto il mouse
        // se e' gia' selezionato, lo deseleziono
        if ( [ curElem cceIsSelected ] )
            [ curElem setCceIsSelected: NO ] ;
        else
        {
            // devo deselezionare tutto
            [ theElements setCceIsSelected: NO ] ;
            // e poi seleziono questo elemento
            [ curElem setCceIsSelected: YES ];
        }
    }
    else
    {
        // c'e' nulla sotto il mouse, deseleziono tutto
        [ theElements setCceIsSelected: NO ] ;
    }
    // ora che arrivo qui, faccio nulla, aspetto mouseup
    while (1)
    {
        theEvent = [[self window] nextEventMatchingMask: NSLeftMouseUpMask ];
        if ([theEvent type] == NSLeftMouseUp)
        {
            break;
        }
    }
    // ridisegno tutto, in ogni caso
    [ self setNeedsDisplay: YES ];
}

Il clic può avvenire in zona di nessuno (c'è nulla sotto il mouse), oppure il clic avviene proprio sopra un elemento. Nel primo caso, mi pare un buon comportamento deselezionare tutti gli elementi eventualmente selezionati (ramo else dello if principale). Se invece il clic avviene sopra un elemento (e questo lo scoprirò meglio quando esamino il metodo getClickedElem:), il comportamento dipende se l'elemento è selezionato oppure no. Nel primo caso, lo deseleziono, nel secondo, lo seleziono. Lo scenario cambierà quando dovrò considerare selezioni multiple, tasti modificatori e cose del genere. Per il momento mi accontento della situazione (che, va da sé, significa che c'è sempre e solo al massimo un elemento selezionato).

Il metodo si chiude con un ciclo di attesa del rilascio del mouse (che fa nulla; ma poi qui andranno inserite le istruzioni per gestire le selezioni di più elementi trascinando il mouse in giro per la coverView), e forzando il ridisegno della vista stessa.

Interessante il meccanismo utilizzato per la deselezione di tutti gli elementi selezionati. In effetti, basta spazzolare tutti gli elementi presenti in theElements e porre la variabile cceIsSelected a falso. Ma io, astutamente, ho riscritto il metodo accessor per la classe CCE_ElemGroup.

- (void)
setCceIsSelected:(BOOL)newIsSelected
{
    int        i , numElem ;
    CCE_BasicForm * elem ;
    // ciclo su tutti gli elementi del gruppo
    numElem = [ elemArray count ];
    for ( i = 0 ; i < numElem ; i ++ )
    {
        elem = [ elemArray objectAtIndex: i ];
        [ elem setCceIsSelected: newIsSelected ];
    }
}

In questo modo basta inviare il messaggio setCceIsSelected solo all'elemento theElements, che questo scatenerà per tutti gli elementi ed i gruppi previsti l'operazione richiesta (quando qualche capitolo fa parlavo della fecondità del concetto di elemento gruppo, pensavo a tutti questi giochini).

Bisogna adesso capire se il clic avviene sopra un elemento. È un lavoro (invero piuttosto banale) per:

- (CCE_BasicForm *)
getClickedElem:(NSPoint)point
{
    int                i , numElem ;
    CCE_BasicForm * elem ;
    NSMutableArray        * listaelem ;
    // esamino tutti gli elemento
    listaelem = [ theElements elemArray ];
    numElem = [ listaelem count ];
    for ( i = 0 ; i < numElem ; i ++ )
    {
        elem = [ listaelem objectAtIndex: i ];
        // e controllo se il click gli appartiene
        if ( [ elem checkClickOnMe: point ])
        {
            // ho finito, esco dal ciclo
            break;
        }
    }
    // potrei non avere trovato nulla
    if ( i == numElem )
        return ( nil );
    // se arrivo qui, ho trovato un elemento
    return ( elem );
}

Basta chiedere ad ogni elemento nel vettore theElements se il clic è avvenuto sopra di lui, e fermarsi non appena qualche oggetto risponde sì. Osservare bene l'ordine delle richieste: si parte dall'elemento davanti agli altri (indici bassi) per poi passare, in caso di risposte sempre negativi, ad elemento dietro agli altri, con indici più alti. Ancora una volta, il grosso del lavoro è svolto altrove dal metodo checkClickOnMe:, che sfrutta ancora una colta il meccanismo del polimorfismo.

C'è comunque una realizzazione di default di questo metodo per la classe CCE_BasicForm, che spesso va bene anche per elementi più specializzati.

- (BOOL)
checkClickOnMe:(NSPoint)point
{
    // in generale, controllo se il clik e' avvenuto dentro il rettangolo limite
    return (NSPointInRect(point, [self elemLimit])) ;
}

C'è una utile funzione che restituisce vero o falso a seconda se il punto (primo argomento) si trova nel rettangolo specificato come secondo argomento. Ad esempio, questo metodo è perfetto per un rettangolo, ma non va bene per un cerchio e per una linea. Per un cerchio, me la cavo ancora con poco:

- (BOOL)
checkClickOnMe:(NSPoint)point
{
    return ([theCircle containsPoint:point]) ;
}

Sfrutto uno dei metodi della classe NSBezierPath per capire se il punto sta o no dentro il percorso individuato dal path.

Con una linea, è un pasticcio. Un path lineare non contiene un punto, non c'è verso. Occorre inventare qualcos'altro. Ciò che mi è venuto in mente (ma ho visto che Sketch non se l'è cavata molto meglio) è di ricorrere ai miei studi liceali e di calcolare la distanza del punto di clic dalla retta. Si tratta di una formula di geometria analitica. Se non avete la più pallida di cosa sto parlando, prendete per buono il tutto ed andate avanti fiduciosi. Se sapete di cosa sto parlando, troverete tutto molto semplice (ma vi lascio fare un po' di calcoli se non siete convinti).

La formula che calcola la distanza del punto di coordinate (x0, y0) dalla retta ax+by+c=0 è:

d = abs(a x0 + b y0 + c ) / sqrt( a^2 + b^2)

Onde evitare di fare troppi calcoli quando non servono, nella classe aggiungo una variabile d'istanza delta che tenga da parte il valore

delta = 1 / sqrt( a^2 + b^2).

In questo modo, il metodo diventa:

- (BOOL)
checkClickOnMe:(NSPoint)point
{
    float ev ;
    NSRect locRect ;
    // rettangolo in cui si trova la linea
    locRect = NSInsetRect( [self elemLimit], -2, -2 );
    // se sono dentro, proseguo
    if (NSPointInRect(point, locRect ) )
    {
        // retta per un punto, e distanza di un punto da una retta
        ev = ( endPoint.x - startPoint.x)* (point.y - startPoint.y) -
            ( endPoint.y - startPoint.y)* (point.x - startPoint.x) ;
        if ( ev < 0 ) ev = -ev ;
        // se la distanza e' inferiore allo spessore della linea piu'
        // una certa tolleranza per essere sicuri di beccare il punto...
        if ( ev * delta < ([ self cceLineWidth] + 3) )
            return ( TRUE ) ;
    }
    // se arrivo qui, mancato!
    return ( FALSE );
}

Per evitare calcoli quando non sono palesemente utili (ed anche alcuni divertenti problemi geometrici), mi chiedo in primo luogo se il punto cade dentro nel rettangolo che racchiude la linea. Ho considerato un rettangolo leggermente più grande per tenere conto di linee esattamente verticali od orizzontali (che avrebbero una delle due dimensioni nulle!). Solo se il punto si trova dentro questo rettangolo, proseguo col controllo. Alla fine verifico se la distanza del punto della retta (data dal prodotto ev*delta) è inferiore allo spessore della linea, aumentato di un valore costante per tenere conto che lo spessore potrebbe anche essere nullo (e per dare una tolleranza nel clic, altrimenti selezionare una linea sottile diventa una operazione da certosini).

Piccolezze

Concludo con una piccolezza, ma è nei dettagli che si vede la grandezza del programmatore. Trovavo estremamente fastidioso il comportamento congiunto della palette e della finestra quando provavo a disegnare un elemento. Selezionavo ad esempio lo strumento rettangolo, facevo clic sulla coverView, e questo era sprecato per selezionare la finestra e portarla di fronte a tutte. Così, per disegnare, dovevo fare un primo clic per selezionare la finestra, e poi un secondo (tenendo il mouse premuto) per disegnare effettivamente l'elemento. Trovo molto più naturale che il primo clic non vada sprecato, ma utilizzato come punto di partenza di un nuovo elemento (la necessità di un doppio clic si ha anche negli altri casi, ad esempio, con lo strumento selezione occorrono due clic per selezionare un elemento).

Dopo averci bestemmiato un po' sopra, ho scoperto (navigando nella documentazione, come al solito), un metodo (di NSView) che fa proprio al caso mio:

- (BOOL)acceptsFirstMouse:(NSEvent *)theEvent
{
    return YES;
}

Neanche farlo apposta, la realizzazione di default restituisce NO, proprio perché il primo mouseDown in una vista è normalmente impiegato per selezionarla. Ma spesso, dice la documentazione, non è quello che si vuole...

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