Metodologie di Programmazione: Lezione 20

Riccardo Silvestri

JavaFX: mini Web Browser II

Continua l'implementazione di un mini Web Browser. Aggiungiamo un componente che visualizza l'albero di parsing della pagina corrente.

Albero di parsing

Vogliamo dare all'utente la possibilità di poter visualizzare l'albero di parsing della pagina che è attualmente mostrata nella web view. JavaFX fornisce il controllo TreeView<T>, in javafx.scene.control, che gestisce la visualizzazione di alberi e l'interazione con essi. Il parametro di tipo T è il tipo dei valori dei nodi dell'albero, tipicamente è String. I nodi devono essere rappresentati da oggetti TreeItem<T>, sempre in javafx.scene.control, dove il tipo T deve essere lo stesso di quello di TreeView<T>. Quindi un TreeView conosce direttamente solamente il nodo radice dell'albero (di tipo TreeItem) perché la struttura dell'albero è interamente rappresentata tramite gli oggetti TreeItem. Ecco ad esempio come potrebbe essere creato e pronto per essere visualizzato un piccolo albero la cui radice ha tre figli:

 TreeItem<String> root = new TreeItem<String>("Root");
 root.getChildren().addAll(new TreeItem<String>("Child 1"), 
                           new TreeItem<String>("Child 2"),
                           new TreeItem<String>("Child 3"));
 TreeView<String> treeView = new TreeView<String>(root);

Per ragioni di efficienza TreeItem non è un sotto-tipo di Node. Questo implica che un oggetto TreeItem non può essere usato, almeno direttamente, per gestire eventi come quelli relativi al mouse. Tratteremo questo aspetto in dettaglio più avanti. Per ora ci accontenteremo di creare l'albero di parsing e di visualizzarlo. La sola interazione con l'utente sarà quella gestita automaticamente da TreeView che è l'espansione e il collasso dei figli di un nodo.

La parte di codice che crea la visualizzazione dell'albero tramite TreeView può essere abbastanza complesso per cui conviene che sia ben separato dal resto. Quando la pagina corrente cambia non è necessario creare un nuovo TreeView, perciò il riferimento al TreeView deve essere mantenuto. Però questo serve solamente alla gestione interna per la visualizzazione dell'albero. Sarebbe bene che non fosse un campo dell'oggetto applicazione. Una soluzione è di usare una classe annidata statica (in un futuro potrebbe essere promossa è diventare una classe di utilità di primo livello). La classe, che chiameremo Inspector, deve avere un metodo Node getNode() che ritorna il Node che visualizza l'albero di parsing. È meglio che questo metodo non ritorni un TreeView, anche se per adesso ritornerà proprio un oggetto di tale tipo, perché se in futuro vogliamo aggiungere dei controlli della visualizzazione o comunque vogliamo usare un layout che contiene il TreeView, potrebbe rendere più difficile effettuare tali modifiche. D'altronde il codice che userà Inspector non ha bisogno di sapere il tipo preciso dell'oggetto che visualizza l'albero. Quello che stiamo suggerendo è un esempio di un principio generalissimo: un'interfaccia non deve essere più specifica dello stretto necessario. L'interfaccia è intesa in senso lato, non solo una interface, ma anche quella definita da una classe tra l'implementazione e gli utilizzatori della classe o delle sue istanze.

La classe Inspector deve avere anche un metodo void set(Document dom) che crea ed imposta l'albero che visualizza l'albero di parsing definito dall'oggetto dom di tipo Document nel package org.w3c.dom. La web engine rende disponibile l'albero di parsing della pagina corrente tramite il metodo Document getDocument(). Il modo più semplice di tradurre un Document in un albero con nodi di tipo TreeItem pronto per essere visualizzato, è tramite una visita ricorsiva dell'albero di parsing del Document. Il tipo Document è un sotto-tipo di org.w3c.dom.Node e infatti un oggetto Document rappresenta anche la radice dell'albero di parsing (di tipo org.w3c.dom.Node). Quindi partendo da tale radice si può visitare ogni nodo grazie al metodo NodeList getChildNodes() di org.w3c.dom.Node, che ritorna la lista dei nodi figli di un nodo. Purtroppo la libreria org.w3c.dom è stata sviluppata prima dell'introduzione del framework delle Collections e così NodeList è una lista ad-hoc. Può essere scandita con un for sugli indici che vanno da 0 alla lunghezza (esclusa) della lista, ritornata dal metodo int getLength(), e il nodo di indice index si ottiene tramite il metodo Node item(int index). Per il momento il valore dei nodi dell'albero di visualizzazione è una stringa che contiene ciò che ritorna il metodo String getNodeName() del corrispondente org.w3c.dom.Node.

/** Gestisce la visualizzazione dell'albero di parsing di una pagina */
private static class Inspector {
    Inspector() { treeView = new TreeView<>(); }

    /** @return il nodo che visualizza l'albero */
    Node getNode() { return treeView; }

    /** Visualizza l'albero di parsing dell'oggetto {@link org.w3c.dom.Document}
     * specificato. Se è null, non visualizza nulla.
     * @param dom  l'oggetto che rappresenta l'albero di parsing o null */
    void set(Document dom) {
        TreeItem<String> treeRoot = null;
        if (dom != null) {
            treeRoot = new TreeItem<>(dom.getDocumentURI());
            createTree(treeRoot, dom);
        }
        treeView.setRoot(treeRoot);
    }

    /** Crea ricorsivamente l'albero di visualizzazione. Più precisamente
     * traduce il sotto-albero radicato in u in un corrispondente albero di
     * {@link javafx.scene.control.TreeItem} con la radice data root.
     * @param root  la radice dell'albero per la visualizzazione
     * @param u  la radice dell'albero di parsing */
    private void createTree(TreeItem<String> root, org.w3c.dom.Node u) {
        NodeList list = u.getChildNodes();
        for (int i = 0 ; i < list.getLength() ; i++) {
            org.w3c.dom.Node v = list.item(i);
            TreeItem<String> child = new TreeItem<>(v.getNodeName());
            root.getChildren().add(child);
            createTree(child, v);
        }
    }

    private TreeView<String> treeView;
}

Per impostare l'albero che il TreeView deve visualizzare abbiamo usato il metodo void setRoot(TreeItem<T> value).

Siccome la TreeView prende spazio nella finestra, vogliamo che l'utente possa dimensionarlo a piacimento e che lo possa nascondere/mostrare. Per permettere il ridimensionamento si può usare il controllo SplitPane che dà la possibilità di controllare la dimensione relativa dello spazio di visualizzazione di uno, due o più Node disposti in orizzontale (default) o in verticale. Quindi creeremo uno SplitPane in cui metteremo il VBox che contiene la web view e il componente per visualizzare l'albero. Poi aggiungiamo un bottone per mostrare e nascondere l'albero di parsing relativo alla pagina corrente. Questo bottone ha due stati, mostrato e nascosto, per cui conviene usare un ToggleButton. Per definire l'azione del ToggleButton potremmo usare il solito metodo setOnAction ma essendo la nostra azione legata allo stato e questo è mantenuto da una proprietà che si ottiene con il metodo BooleanProperty selectedProperty(), ci conviene definirla tramite un ChangeListener relativo a quest'ultima.

Inspector insp = new Inspector();
SplitPane inspSP = new SplitPane();
ToggleButton inspB = new ToggleButton("I");
inspB.selectedProperty().addListener((o,v,sel) -> {
    if (sel) {             // Se è selezionato, rende visibile il visualizzatore
        inspSP.getItems().add(insp.getNode());         // dell'albero di parsing
        insp.set(we.getDocument());      // Imposta l'albero di parsing
    } else                 // Se non è selezionato, nasconde il visualizzatore
        inspSP.getItems().remove(insp.getNode());
});

Abbiamo usato il metodo ObservableList<Node> getItems() per ottenere la lista dei Node gestiti dallo SplitPane. Per mostrare l'albero aggiungiamo il visualizzatore a tale lista e per nasconderlo lo rimuoviamo. Dobbiamo ancora inizializzare lo SplitPane inserendovi il VBox della web view e infine cambiare il Parent ritornato dal metodo createUI che deve essere lo SplitPane.

inspSP.getItems().add(vb);
return inspSP;

Rimane ancora una piccola cosa da fare. Se l'albero è mostrato, deve essere aggiornato ogni volta che la pagina corrente cambia. Dobbiamo quindi modificare il ChangeListener dello stato del Worker della web engine:

we.getLoadWorker().stateProperty().addListener((o, ov, nv) -> {
    if (nv == Worker.State.SUCCEEDED) {
        url.setText(we.getLocation());
        if (inspB.isSelected())              // Se il visualizzatore è visibile
            insp.set(we.getDocument());         // Aggiorna l'albero di parsing
    } else if (nv == Worker.State.FAILED || nv == Worker.State.CANCELLED) {
        if (inspB.isSelected())            // Anche se il downloading è fallito,
            insp.set(we.getDocument());        // Aggiorniamo il visualizzatore
        System.out.println("FAILED: " + we.getLocation());
    }
});

Adesso possiamo eseguire il nostro piccolo browser.

TreeView e Tooltip

La visualizzazione dell'albero non è molto informativa perché non riporta gli attributi degli elementi né il testo. D'altronde se visualizzassimo queste informazioni la struttura dell'albero diventerebbe piuttosto confusa dato che ci sarebbero nodi che prendono più linee o il loro testo sarebbe comunque molto lungo. Allora potremmo visualizzare tali informazioni solamente quando è richiesto. Ad esempio quando il mouse passa sopra un nodo dell'albero potremmo visualizzare una piccola finestra che riporta le informazioni relative al nodo. Per fare ciò JavaFX offre i Tooltip. Questi sono dei controlli che, una volta che sono stati attaccati ad un nodo, quando il mouse entra nell'area del nodo mostrano una finestra di popup con un certo contenuto. La finestra di un Tooltip è mostrata e nascosta in modo automatico. Noi dobbiamo solamente decidere a quali nodi attaccare un Tooltip e il loro contenuto. Il contenuto può essere qualsiasi, semplice testo o un intero scene graph. Creare e attaccare un Tooltip a un Node è molto facile, ad esempio

 Rectangle q = new Rectangle(0, 0, 80, 80);
 Tooltip t = new Tooltip("Quadrato");   // Crea un Tooltip con il dato testo
 Tooltip.install(q, t);                 // Attacca il Tooltip al Node q

Quando il mouse entra nell'area del quadrato q sarà automaticamente mostrata un piccola finestra di popup contenente il testo Quadrato.

Nel nostro caso le cose non sono così facili perché vogliamo attaccare un Tooltip ad ogni nodo dell'albero visualizzato nel TreeView. Non possiamo attaccare Tooltip agli oggetti che rappresentano i nodi perché TreeItem non è un sotto-tipo di Node. I controlli come TreeView, ad es. ListView<T> e TableView<S>, sono realizzati in modo che possano reggere la visualizzazione di milioni di elementi. Perciò i componenti grafici, sotto-tipi di Node, che sono usati per rendere gli elementi, non sono creati uno per ogni elemento, nel caso di TreeView uno per ogni nodo dell'albero. Ma sono creati solamente quelli strettamente sufficienti per rendere i nodi che sono visibili (e questi sono generalmente molti meno di tutti i nodi dell'albero). Per questa ragione questo tipo di controlli sono detti virtualizzati (virtualized controls). Il tipo base per la resa di un elemento è il controllo Cell<T> e il TreeView<T> usa una specializzazione che è TreeCell<T>. Internamente un TreeView usa TreeCell per rendere ogni nodo attualmente visibile. Quando i nodi visibili cambiano, il TreeView o riusa i TreeCell già esistenti per rendere altri nodi o se non ne ha abbastanza ne crea di nuovi. Quindi un oggetto TreeCell può essere usato per rendere tanti nodi diversi. Questo implica che non possiamo ottenere per ogni nodo un oggetto grafico che lo rende perché questo può cambiare nel tempo.

Per poter intervenire sull'oggetto grafico che rende un nodo il TreeView ha il metodo

void setCellFactory(Callback<TreeView<T>,TreeCell<T>> tcf)

che permette di impostare una Callback che funge da factory per i TreeCell che rendono i nodi. Più precisamente, quando il TreeView ha bisogno di una nuova TreeCell invoca il metodo call della tcf che prende in input il TreeView stesso e deve ritornare una nuova TreeCell. Una Cell e quindi anche il sotto-tipo TreeCell ha una proprietà itemProperty, che si ottiene tramite il metodo ObjectProperty<T> itemProperty(), che contiene il valore (di tipo T) del nodo che la TreeCell sta attualmente visualizzando. Allora, per poter attaccare i Tooltip ai nodi dobbiamo impostare una factory per i TreeCell che per ogni TreeCell creato definisce un ChangeListener per la proprietà itemProperty che imposta in modo appropriato il Tooltip per il nuovo nodo. In verità non deve fare solo questo ma deve anche impostare il testo del nodo. Questo è fatto automaticamente dalla factory di default, ma nel momento in cui si imposta una nuova factory diventa una responsabilità di quest'ultima. Inoltre, c'è da tener conto che il nuovo valore della TreeCell, passato come ultimo parametro del ChangeListener, può essere null. In tal caso il testo deve essere impostato alla stringa vuota.

Prima di poter aggiungere i Tooltip dobbiamo decidere cosa vogliamo visualizzare con essi. Questo dipende dal tipo HTML del nodo dell'albero di parsing. Il tipo HTML di un nodo si ottiene con il metodo short getNodeType() di org.w3c.dom.Node. Questo ritorna un intero interpretato tramite delle costanti statiche della classe org.w3c.dom.Node. Per i nodi che hanno un tag, che hanno tipo ELEMENT_NODE, vogliamo visualizzare gli attributi che si ottengono con il metodo NamedNodeMap getAttributes(). La mappa NamedNodeMap, per i motivi che abbiamo già ricordato, non è una Collection e per scorrerne gli elementi si devono usare i metodi int getLength() e Node item(int index). Il org.w3c.dom.Node ritornato dal metodo item rappresenta un'associazione della mappa, precisamente getNodeName() è la chiave, cioè il nome dell'attributo, e getNodeValue() è il valore. Per la radice dell'albero, il cui tipo è DOCUMENT_NODE, vogliamo visualizzare l'URI della pagina che si ottiene con il metodo String getBaseURI(). Per tutti gli altri tipi di nodi visualizziamo il loro valore dato dal metodo String getNodeValue(). Quindi definiamo il seguente metodo statico

/** Ritorna una stringa che contiene informazioni sul nodo dato.
 * @param u  un nodo di un albero di parsing HTML
 * @return una stringa che contiene informazioni sul nodo */
private static String info(org.w3c.dom.Node u) {
    switch (u.getNodeType()) {
        case org.w3c.dom.Node.DOCUMENT_NODE:
            return u.getBaseURI();
        case org.w3c.dom.Node.ELEMENT_NODE:
            NamedNodeMap attr = u.getAttributes();
            String s = "";
            for (int i = 0 ; i < attr.getLength() ; i++) {
                org.w3c.dom.Node a = attr.item(i);
                s += (i > 0 ? "\n" : "")+a.getNodeName()+": "+a.getNodeValue();
            }
            return s;
        default:
            return u.getNodeValue();
    }
}

Ora torniamo all'introduzione dei Tooltip. Come abbiamo già detto dobbiamo impostare una factory per le TreeCell che rendono i nodi dell'albero. Siccome ciò che deve essere visualizzato nel Tooltip dipende dal org.w3c.dom.Node che è reso, è necessario che il valore associato ad ogni nodo dell'albero sia proprio il nodo org.w3c.dom.Node dell'albero di parsing. Adesso possiamo scrivere la nuova versione della classe Inspector che visualizza i Tooltip e che chiamiamo InspectorTT.

private static class InspectorTT {
    InspectorTT() {
        treeView = new TreeView<>();
        treeView.setCellFactory(tv -> {
            TreeCell<org.w3c.dom.Node> cell = new TreeCell<>();
            cell.itemProperty().addListener((o, ov, u) -> {
                cell.setText(u != null ? u.getNodeName() : "");
                cell.setTooltip(u != null ? new Tooltip(info(u)) : null);
            });
            return cell;
        });
    }

    Node getNode() { return treeView; }

    void set(Document dom) {
        TreeItem<org.w3c.dom.Node> treeRoot = null;
        if (dom != null) {
            treeRoot = new TreeItem<>(dom);
            createTree(treeRoot, dom);
        }
        treeView.setRoot(treeRoot);
    }

    private void createTree(TreeItem<org.w3c.dom.Node> root, org.w3c.dom.Node u) {
        NodeList list = u.getChildNodes();
        for (int i = 0 ; i < list.getLength() ; i++) {
            org.w3c.dom.Node v = list.item(i);
            TreeItem<org.w3c.dom.Node> child = new TreeItem<>(v);
            root.getChildren().add(child);
            createTree(child, v);
        }
    }

    private TreeView<org.w3c.dom.Node> treeView;
}

Per poter usare la nuova classe dobbiamo sostituire la linea

Inspector insp = new Inspector();

con la linea

InspectorTT insp = new InspectorTT();

Adesso possiamo provarlo

Uno dei vantaggi d'impostare una factory per le TreeCell è che possiamo modificare lo stile di visualizzazione dei nodi dell'albero. Ad esempio possiamo modificare la fonte usata e possiamo fare lo stesso per i Tooltip. Basta modificare il costruttore di InspectorTT.

InspectorTT() {
    treeView = new TreeView<>();
    treeView.setCellFactory(tv -> {
        TreeCell<org.w3c.dom.Node> cell = new TreeCell<>();
        cell.setFont(new Font("Monaco", 10));
        cell.itemProperty().addListener((o, ov, u) -> {
            String txt = null;
            Tooltip tt = null;
            if (u != null) {
                txt = u.getNodeName();
                tt = new Tooltip(info(u));
                tt.setStyle("-fx-font-size: 12px");
            }
            cell.setText(txt);
            cell.setTooltip(tt);
        });
        return cell;
    });
}

Si noti che abbiamo usato il metodo void setStyle(String value) che permette di impostare alcune caratteristiche dello stile di visualizzazione. Ritorneremo su questo più avanti. Provando la nuova versione,

Icone

Ora vogliamo usare delle icone per i tre bottoni. Le immagini delle icone e qualsiasi altro tipo di risorse come file CSS, conviene che siano contenute in una directory separata da quelle che contengono i file sorgenti. Ad esempio si può usare una directory chiamata resources. Inoltre se una risorsa è usata da una classe con nome completo base.sub.MyClass, allora la risorsa dovrebbe essere messa nella directory resources/base/sub, cioè il percorso relativo della risorsa ricalca quello dei package della classe. Questo perché così la JVM, come vedremo, riesce più facilmente a individuare le risorse. Ma non solo, in questo modo le risorse sono naturalmente separate in funzione delle classi che le usano. Se stiamo usando un IDE come IntelliJ la directory base per le risorse la possiamo creare alla stesso livello della directory che contiene i sorgenti (chiamata di solito src). Inoltre dopo averla creata, usando il menu contestuale su di essa possiamo scegliere dal menu Mark Directory As di marcarla come Resources Root. Così l'IDE imposterà il class path in modo da contenere anche il percorso della directory resources. Questo garantirà che durante l'esecuzione la JVM potrà trovare le risorse.

Poniamo in resources/mp/gui tre immagini left16.png, right16.png e insp16.png, da usare come icone per i bottoni. Per impostare l'immagine per il bottone back sostituiamo la linea

Button back = new Button("<");

con

Image backIcon = new Image(getClass().getResource("left16.png").toString());
Button back = new Button(null, new ImageView(backIcon));

Le invocazioni getClass().getResource("left16.png") ritornano l'URL della risorsa con nome "left16.png". Il metodo URL getResource(String name) di Class<T>, cerca la risorsa di nome name tramite il class loader della classe sul quale è invocato (nel nostro caso è invocato relativamente all'oggetto Class<?> della classe Browser che è ritornato dall'invocazione getClass()). Parleremo in dettaglio dei class loader più avanti. Il metodo getResource non passa direttamente il name al class loader ma gli passa il nome assoluto della risorsa. Il nome assoluto è costruito nel seguente modo:

Quindi nel nostro caso il nome assoluto della risorsa "left16.png" sarà "mp/gui/left16.png". Se il percorso assoluto della directory resources è nel class path siamo sicuri che la risorsa sarà trovata. In caso contrario il metodo getResource ritornerà null.

Prima di poter visualizzare l'icona del bottone, l'immagine deve essere caricata in memoria e rappresentata tramite un oggetto Image nel package javafx.scene.image. E questo può farlo il costruttore di Image. Il tipo Image non essendo un sotto-tipo di Node non può visualizzare l'icona sul bottone. Dobbiamo quindi creare un oggetto capace di visualizzare immagini e questo è ImageView, sempre in javafx.scene.image, che è un sotto-tipo di Node. Il primo argomento passato al costruttore di Button è null per indicare che non c'è testo.

Per le icone degli altri due bottoni possiamo procedere in modo analogo sostituendo le linee

Button forth = new Button(">");
. . .
ToggleButton inspB = new ToggleButton("I");

con le linee

Image forthIcon = new Image(getClass().getResource("right16.png").toString());
Button forth = new Button(null, new ImageView(forthIcon));
. . .
Image inspIcon = new Image(getClass().getResource("insp16.png").toString());
ToggleButton inspB = new ToggleButton(null, new ImageView(inspIcon));

Ed ecco il risultato:

Esercizi

[NumeroNodi]    Modificare Inspector (o InspectorTT) in modo che mostri anche il numero totale di nodi dell'albero di parsing, che ovviamente deve essere aggiornato insieme all'albero. Per visualizzare il numero di nodi si può usare una Label.

[NumeroLinks]    Modificare Inspector (o InspectorTT) in modo che mostri anche il numero totale di links della pagina, che ovviamente deve essere aggiornato insieme all'albero. Per visualizzare il numero di links si può usare una Label.

[NumeroTags]    Modificare Inspector (o InspectorTT) in modo che mostri un ChoiceBox<T> coi nomi di tutti i tag HTML. Quando si sceglie uno dei tag, il numero di elementi nella pagina con quel tag viene mostrato, ad es. tramite una Label.

[NoText]    Modificare Inspector (o InspectorTT) in modo che mostri un ToggleButton che quando selezionato elimina dall'albero visualizzato nel TreeView tutti i nodi di tipo testo. Se deselezionato, riporta i nodi di tipo testo nell'albero.

[ColorTags]    Modificare Inspector (o InspectorTT) in modo che mostri un ChoiceBox<T> coi nomi di tutti i tag HTML. Quando si sceglie uno dei tag, tutti i nodi dell'albero con quel tag sono visualizzati con il colore rosso e in grassetto.

18 Mag 2015