MaCocoa 053

Capitolo 053 - Modificare il testo

In questo capitolo miglioro la classe CCE_Text introducendo la possibilità di modificare il testo, ed anche i singoli attributi dei caratteri.

Sorgente: Parto dall'esepio Sketch come idea, ma poi ballo da solo.

Prima stesura: 20 agosto 2004

Doppia idea

Fino a questo momento, l'unica possibilità di inserire degli elementi di testo all'interno di una CoverView era a disposizione del programmatore; il testo si può inserire solo da programma. Inoltre, non è possibile modificare il testo, o i suoi attributi, se non a livello globale (ad esempio, sostituendo l'intero contenuto di uno specifico CCE_Text con il catalogo di un volume). Per consentire all'utente l'inserimento di elementi CCE_Text, e la sua successiva modifica, bisogna permettere in primo luogo la modifica di un testo.

Allo scopo, osservo l'idea messa in pratica nell'esempio Sketch, fornito con l'ambiente XCode. Il testo è normalmente rappresentato in una vista disegnandolo in un metodo draw, come del resto già avviene anche per CCE_Text. Per permettere la modifica del testo, Sketch produce al volo un oggetto NSTextView, e lascia che sia questo oggetto a sbrigare tutte le pratiche. Al termine delle operazioni, lo elimina e passa oltre. Una idea che mi è parsa brillante (perché lavorare quando c'è qualcuno che può farlo per te?), che ho subito messo in pratica con due fondamentali modifiche.

La prima modifica è molto semplice; piuttosto che costruire al volo una NSTextView, ne uso una sola, sempre la stessa, adattandola di volta in volta ai miei scopi. In realtà è quanto fa anche Sketch, che usa una unica NSTextView globale; tuttavia, la mia pensata è di evitarne del tutto la costruzione da proramma, definendola direttamente all'interno del file nib.

In altre parole, uso Interface Builder per aggiungere, all'interno del file CoversWin.nib, una NSTextView, normalmente nascosta. Poi, quando necessario, la vista è resa visibile per consentire le operazioni, mentre, al termine delle stesse, è nuovamente resa invisibile.

La seconda modifica è un po' più interessante: invece di utilizzare una NSTextView semplice, uso una NSScrollView, in modo che sia possibile modificare tutto il testo, e non solamente quello rappresentato all'interno del rettangolino che circoscrive CCE_Text. Quindi, all'interno del file CoversWin.nib, costruisco in realtà un oggetto NSScrollView, all'interno del quale si trova, quale suo documento da visualizzare, una NSTextView.

La faccenda è ulteriormente complicata dal fatto che il testo è potenzialmente ruotato secondo un angolo arbitrario; piuttosto che modificare tale testo in loco, preferisco fare in modo che quando l'utente vuole modificare del testo, questo gli si presenti sempre bello dritto, in modo che si possa leggere comodamente, piuttosto che ruotato secondo l'angolo specificato.

Sequenza delle operazioni

Per poter modificare del testo, la sequenza è dunque la seguente: l'utente individua l'elemento CCE_Text da modificare facendo doppio cli su di esso. L'oggetto allora si pone in modalità di editing: piuttosto che disegnare il testo secondo il metodo normale, lascia che a occuparsene sia una NSTextView opportunatamente predisposta. Al termine delle operazioni, l'oggetto CCE_Text torna alla normale operatività cessando di impiegare la NSTextView e disegnando il testo con il metodo normale. Ci sono quindi le seguente modifiche sul codice: l'individuazione di un doppio clic, l'apertura di una sessione di editing, la chiusura di una sessione di editing, le modifiche al metodo di disegno per gestire il tutto.

Individuare un doppio clic è piuttosto semplice. All'interno dell'oggetto NSEvent, passato ad esempio come argomento al metodo mouseDown: dell'oggetto CoverView, è in effetti presenti l'informazione se si sono verificati clic ripetuti; attraverso il metodo clickCount si determina quante volte l'utente ha fatto clic. Se il numero è maggiore o uguale a due, abbiamo riconosciuto un doppio clic.

Il posto più adatto per effettuare il controllo sul doppio clic mi pare sia all'interno del metodo handleMouseClick:, non appena è stato riconosciuto un clic sopra un elemento. In questo modo evito di preoccuparmi se il doppio clic è avvenuto in terra di nessuno. Di conseguenza il metodo si presenta in questo modo:

- (void)
handleMouseClick: (NSEvent *)theEvent
{
    NSPoint            whereClick;
    CCE_BasicForm * curElem = nil;
    BOOL            shiftKeyPress = (([theEvent modifierFlags] & NSShiftKeyMask) ? YES : NO);
    short            clicInHandle, numofclic ;
    // vado a vedere dove e' avvenuto il click
    whereClick = [self convertPoint:[theEvent locationInWindow] fromView:nil];
    numofclic = [ theEvent clickCount ] ;
    // 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' un doppio clic, editing se il caso
        if ( numofclic >= 2 )
            [ curElem startEditing ] ;
        // vedo se e' gia' selezionato
        else if ( [ curElem cceIsSelected ] )
        {
                if ( shiftKeyPress )
                {
                ...

figura 04

figura 04

L'effetto di un doppio clic è quindi quello di inviare un messaggio startEditing all'oggetto sotto il clic. Normalmente, questo messaggio cade nel vuoto, perché un elemento generico non è in grado di rispondere in maniera sensata (al momento...) ad un doppio clic. Ciò non è vero per un elemento di classe CCE_Text, che invece proprio di questo doppio clic fa tesoro.

figura 05

figura 05

Ma prima di passare ad esaminare il metodo nel merito, occorre fare un passo indietro e parlare del file CoversWin.nib. Ho già detto che, per gestire la modifica del testo, utilizzo una NSScrollView con NSTextView incorporata, costruite direttamente all'interno della finestra. Ecco quindi che ho aggiunto questi oggetti attraverso Interface Builder. Questi oggetti sono dichiarati fin da subito Hidden attraverso l'apposito pulsante di spunta. Inoltre, la NSScrollView dovrebbe essere una sottovista della CoverView (dopotutto, è uno degli elementi che fa parte della copertina), ma non riesco ad ottenere ciò direttamente in Interface Builder. Infine, per permettere rapido accesso a questi due oggetti, aggiungo due outlet (shScroll e shText) alla CoverView e li collego direttamente alla NSScrollView ed alla NSTextView.

Di ritorno in XCode, completo l'inizializzazione: all'interno del metodo windowDidLoad della classe CoverWinCtl aggiungo qualche riga.

    ...
    [ [ cvrView shScroll ] removeFromSuperview];
    [ cvrView addSubview: [ cvrView shScroll ]];
    [ [ cvrView shScroll ] setHasHorizontalScroller: NO ];
    [ [ cvrView shScroll ] setHasVerticalScroller: YES ];
    [ [ [ cvrView shScroll ] verticalScroller ] setControlSize: NSSmallControlSize ] ;
    [ [ cvrView shScroll ] setAutohidesScrollers: NO ];
    [ [ cvrView shScroll ] setBorderType: NSGrooveBorder ] ;
    ...

Le prime due istruzioni sono quelle fondamentali: tolgono la NSScrollView dalla gerarchia stabilita da Interface Builder, e la fanno diventare una sottovista della CoverView. Le altre istruzioni non fanno altro che ribadire e precisare le caratteristiche che la NSScrollView deve avere.

figura 01

figura 01

Ho scelto dall'inizio di rappresentare il testo all'interno di un rettangolo di larghezza ed altezza determinata; è possibile che una parte del testo non riesca ad essere visualizzato. Tuttavia, il testo è sempre incolonnato all'interno della larghezza specificata, per cui l'eventuale testo non rappresentato è solo quello delle ultime righe. Questo spiega perché non è presente la barra di scorrimento orizzontale (il testo sarà giustificato sulla larghezza del rettangolo), ma, nella sola fase di modifica, è presente la barra verticale. Infine, dico che la barra è comunque visibile, anche se di dimensioni ridotte.

Cominciare le modifiche

Posso adesso commentare in modo adeguato il metodo startEditing proprio della classe CCE_Text:

- (void)
startEditing
{
    // recupero puntatori alla scrollView ed alla textView nascoste
    NSScrollView    * theScroll = [ ownerView shScroll ];
    NSTextView        * theEditText = [ ownerView shText ];
    // calcolo il rettangolo circoscritto; usero' l'origne come punto
    // base per il disegno della scrollview
    NSRect locrect = [ [ localTF transformBezierPath: theDrawPath ] bounds ] ;
    // la visualizzazione funziona meglio se arrotondo a pixel interi
    locrect.origin.x = round( locrect.origin.x );
    locrect.origin.y = round( locrect.origin.y );
    // le dimensioni della scrollview sono quelle del testo, corrette
    // perche' siano positive per non ingenerre confusione
    locrect.size.width = ( localSize.width < 0 ) ?
                    - localSize.width : localSize.width ;
    locrect.size.height = ( localSize.height < 0 ) ?
                    - localSize.height : localSize.height ;
    // dico che questo elemento e' in fase di editing
    editingText = YES ;
    // ed aggiusto la variabile della view per la sua gestione
    [ownerView setEditingInProgress:self ];
    // dimensioni della scrollview, faccio spazio per lo scroller
    [ theScroll setFrame: NSMakeRect(locrect.origin.x , locrect.origin.y,
                locrect.size.width + 11 , locrect.size.height ) ];
    // questa scrollview/textview e' gestita come unlayout manager aggiuntivo
    [ textStorage addLayoutManager: [ theEditText layoutManager ] ] ;
    // mi riporto in testa e seleziono tutto il testo
    [ theEditText scrollPoint: NSMakePoint( 0, 0 ) ];
    [ theEditText setSelectedRange: NSMakeRange(0, [ textStorage length ] ) ];
    // dico che la text e' il primo risponditore degli eventi
    // cosi' che tastiera e menu relativi al testo cadano qui
    [[ownerView window] makeFirstResponder:theEditText];
    // visualizzo la scrollviea
    [ theScroll setHidden: NO ] ;
    // forzo un ridisegno
    [ ownerView setNeedsDisplay: YES ];
}

In queste sedici istruzioni ci sono molti concetti che devono essere spiegati. Per prima cosa, il criterio di visualizzane della NSTextView (e della NSScollView che la contiene); dico che, indipendentemente dall'angolo di rotazione, la modifica del testo avviene nella modalità normale. Questo significa che devo calcolare una buona posizione e dimensione per queste due viste. Piglio quindi il rettangolo che circoscrive l'oggetto CCE_Text e stabilisco che il punto di origine di questo rettangolo è il punto di origine della NSTextView. In questo modo, se il CCE_Text ha un angolo nullo, la NSTextView ci va proprio sopra; altrimenti, in qualche modo si trova nei pressi.

figura 02

figura 02

Il flag editingText posto a Vero è utilizzato per individuare in questo elemento quello in corso di modifica, e sarà quindi il flag che determina il metodo di disegno dell'elemento stesso. Vedremo il suo uso più tardi. Analoga funzione la variabile editingInProgress, propria della CoverView, utilizzata per discriminare il destinatario di tutte le operazioni in corso.

Ci sono due istruzioni interessanti, di arrotondamento delle coordinate di questo punto origine. Per qualche motivo che non mi è molto chiaro, senza tale operazione il testo può essere rappresentato in malo modo; suppongo che il problema derivi dalle approssimazioni successive sulle coordinate di locrect, che portano il rettangolo a spostarsi di frazioni di pixel causando sovrascritture invedibili.

Una volta stabilito il punto origine della vista, dico che le sue dimensioni sono le stesse utilizzate per la visualizzazione corrente; per evitare di mettere in confusione Cocoa con lunghezze negative, predispongo il tutto perché si abbiamo sempre e solo valori positivi.

A questo punto, posso attribuire le nuove coordinate alla NSScrollView, quelle che appena calcolato in termini di punto origine e dimensione; alla dimensione orizzontale aggiungo 11 pixel, che è la dimensione della barra di scorrimento verticale.

Finalmente si arriva all'istruzione che permette a tutto il meccanismo di funzionare, ovvero l'aggiunga di un nuovo NSLayoutManager alla variabile textStorage, quella che contiene il testo da rappresentare all'interno dell'elemento CCE_Text. Riprendendo la discussione del capitolo 49, ricordo l'architettura per il disegno di testo: una classe NSTextStorage che conserva il testo, una classe NSTextContainer che specifica la zona di visualizzazione, ed una classe NSLayoutManager per gestisce l'interazione tra questi due oggetti. In genere una classe NSLayoutManager è solidale con l'unica classe NSTextStorage per fornire il testo da rappresentare all'interno di più zone specificate da NSTextContainer (pensate a NSLayoutManager come ad un demone che impagina il testo di NSTextStorage in più pagine visualizzate tramite NSTextContainer). Nel caso di CCE_Text, c'è la classe NSTextStorage e c'è una sola classe NSTextContainer che rappresenta la zona di spazio all'interno del rettangolo che caratterizza il CCE_Text; ad unire queste due classi, una classe NSLayoutManager. Questa è la catena che funziona in normali condizioni di disegno. Quando si passa in modalità di modifica del testo, alla variabile textStorage di CCE_Text si aggiunge temporaneamente un nuovo oggetto NSLayoutManager per una diversa rappresentazione del contenuto del testo; questa rappresentazione utilizza lo NSTextContainer specifico della NSTextView. Tutto questo paragrafo per spiegare l'unica istruzione:

    [ textStorage addLayoutManager: [ theEditText layoutManager ] ] ;

dove si piglia lo NSLayoutManager della NSTextView e lo si aggiunge a textStorage.

A questo punto, essendoci due gestori per la rappresentazione del testo, potrebbe venire il dubbio che il testo sia rappresentato due volte; ciò non accade, in quanto il primo NSLayoutManager (quello standard di CCE_Text) è tenuto disattivo nel (prossimamente descritto) metodo di disegno dell'elemento.

figura 03

figura 03

Il metodo startEditing sta per finire; riporta la barra di scorrimento all'inizio, e seleziona tutto il testo (avrei potuto operare una selezione nulla, ma l'istruzione di selezione va messa anche per attivare il cursore specifico per il testo). Stabilisce poi che la NSTextView diventi First Responder; in questo modo tutti i comandi di menu relativi al testo, copia, incolla, eccetera, avranno la NSTextView come referente, e da questa saranno gestiti in pieno automatismo (ad esempio, è possibile operare con effetti speciali sul testo, rappresentandolo con tutta la potenza messa a disposizione da Cocoa). Finalmente, si visualizza la NSScrollView e si forza un ridisegno dalal vista, in modo da mostrare tutto quanto fatto finora.

Disegno e fine delle modifiche

Il metodo di disegno del testo ha una piccola, fondamentale modifica, per permettere l'esecuzione corretta delle operazioni di modifica:

- (void)
specificDrawing: (NSRect) inRect
{
    // determino punto e dimensione di dove disegnare
    // per tenere conto di eventuali dimensioni negative
    // disegno del testo
    NSRect locrect = arrangeRect( localSize );
    [ super specificDrawing: inRect ] ;
    // se sto modificando il testo...
    if ( editingText )
    {
        // e' tutto fatto dalla NSTextView
    }
    else
    {
        NSRange glyphRange ;
        // recupero il primo layout
        NSLayoutManager *layoutManager = [ [ textStorage layoutManagers ] objectAtIndex: 0] ;
        [ theTextCont setContainerSize: locrect.size ];
        // recupero il range del testo
        glyphRange = [layoutManager glyphRangeForTextContainer: theTextCont ];
        [ layoutManager drawGlyphsForGlyphRange:glyphRange atPoint: locrect.origin ];
    }
}

Quando la variabile d'istanza editingText è Vera, significa che sono in corso le operazioni di modifica; in particolare, è attivo un secondo NSLayoutManager preposto alla rappresentazione del testo. È quindi necessario impedire che lo NSLayoutManager standard lavori per rappresentare una seconda volta il testo. L'iniziale funzione arrangeRect(.) è solo un metodo comodo per aggiustare i valori del punto di disegno e le dimensioni della zona interessata dal disegno. Questa è una correzione di un errore che in precedenza provocava qualche scompenso in presenza di dimensioni negative:

NSRect
arrangeRect ( NSSize tmpSize)
{
    NSPoint        wheretodraw = NSMakePoint( 0, 0) ;
    NSSize        newSize = tmpSize ;
    // se ho una dimensione negativa
    if ( tmpSize.width < 0 )
    {
        // sposto il punto di disegno
        wheretodraw.x = tmpSize.width ;
        // in modo da ottnere una dimensione positiva
        newSize.width = -tmpSize.width ;
    }
    if ( tmpSize.height < 0 )
    {
        wheretodraw.y = tmpSize.height ;
        newSize.height = -tmpSize.height ;
    }
    return ( NSMakeRect( wheretodraw.x, wheretodraw.y, newSize.width, newSize.height) ) ;
}

Finché l'utente continua ad operare sulla NSTextView, tutto funziona tranquillamente. Ad un certo punto, tuttavia, cambierà qualcosa (ad esempio, un clic altrove). Occorre allora uscire dalla modalità di modifica e tornare a lavorare in maniera normale. Per prima cosa, si deve riconoscere la situazione: all'interno del metodo mouseDown: della CoverView la prima istruzione è sempre questa:

- (void)
mouseDown:(NSEvent *)theEvent
{
    Class selCls = [[CoverPalWinCtl sharedCoverPalWinCtrl] getCurrClassTool];

    if ( editingInProgress )
    {
        [ editingInProgress endEditing ];
    }
    ...

È interessante notare che questo metodo è invocato solamente se il clic è avvenuto fuori della NSTextView; infatti questa, in qualità di First Responder, accetta direttamente ogni clic (ed ogni input da tastiera) che la riguarda; solo nel caso in cui il clic sia al di fuori della sua zona di interesse, passa l'evento alle view superiori, tra cui appunto la CoverView che lo gestisce. Insomma, sono certo che quando il metodo mousedown: è chiamto, è giunto il momento di chiudere le operazioni di modifica:

- (void)
endEditing
{
    // recupero puntatori alla scrollView ed alla textView
    NSScrollView * theScroll = [ ownerView shScroll ];
    NSTextView * theEditText = [ ownerView shText ];
    // se non e' per me, lascio perdere
    if ([ownerView editingInProgress] != self)
        return ;
    // se arrivo qui, fine editing per me
    editingText = NO ;
    [ ownerView setEditingInProgress:nil ];
    // tolgo il layout manager, che non serve piu'
    [ textStorage removeLayoutManager: [ theEditText layoutManager ] ] ;
    // nascondo la scrollview
    [ theScroll setHidden: YES ] ;
    // lascio che adesso tastiera e menu insistano sulla coverview
    [ [ownerView window] makeFirstResponder:ownerView ];
}

A parte l'ovvio controllo sul fatto che le operazioni van chiuse per il solo oggetto CCE_Text interessato, sono riportate allo stato originario le variabili d'istanza dell'elemento e della CoverView; la NSScrollView viene nuovamente resa invisibile, e il First Responder torna ad essere la CoverView. Notare la rimozione dello NSLayoutManager della NSTextView da textStorage, che a questo punto ritorna ad avere un solo gestore di visualizzazione del testo.

figura 06

figura 06

Come tocco finale a tutta la faccenda, ritorno in Interface Builder e modifico il file CoverPalette.nib, abilitando il controllo che consente la costruzione di elementi di classe CCE_Text. Tuttavia, così com'è, la cosa non funziona correttamente; il problema è la mancata inizializzazione delle variabili d'istanza specifiche della CCE_Text: guardando all'interno del metodo mouseDown:, nella parte dove è costruito un nuovo elemento, questo è costruito con la sequenza alloc e init, utilizzando quindi un costruttore povero per CCE_BasicForm al posto del più ricco costruttore standard. Perfar funzionare le cose, occorre sovrascrivere il metodo init all'interno di CCE_Text affinché chiami in cascata il costruttore designato.

- (id )
init
{
    self = [ self initWithId: 0 inView: nil withAttributedString: nil
                inRect: NSMakeRect(0,0,1,1) withAngle: 0 ] ;
    return self ;
}

Non sembrano esserci progressi; in realtà, invocare il metodo initWithId:... consente la corretta inizializzazione delle variabili d'istanza, tra cui la fondamentale textStorage, destinata a contenere tutto il testo che si desidera.

Se poi si aggiunge, sempre all'interno del metodo mouseDown: una semplice istruzione di inizio modifica del testo:

        ...
        if ([curElem handleCreation: theEvent ])
        {
            // se la creazione e' andata a buon fine,lo aggiungo alla lista
            [ curElem setObjID: [self getNextObjId]];
            [ theElements addElem:curElem atIndex: 0 ];
            [ curElem startEditing ] ;
        }
        ...

allora, subito dopo aver tracciato il rettangolo che delimita il testo, si entra subito in modalità di modifica, con la possibilità di inserire subito del testo all'interno del nuovo elemento CCE_Text.

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