juin
2014
Dernièrement j’ai eut à coder un éditeur de code supportant CSS et FXML (XML en fait) dans un petit utilitaire rapidement codé en 4 jours permettant de générer des icônes à destination d’un site géographique. J’avais commencé par créer un prototype dans Inkscape mais la solution devenait rapidement peu flexible compte tenu du nombre important d’icônes à produire en cas de changement de label ou d’apparence. Je suis donc parti vers un outil qui permet de styler un FXML très simple via CSS et d’exporter le tout en images bitmaps. L’outil permet de modifier le code du CSS ou du FXML avec un aperçu immédiat des modifications à l’écran.
La première mouture se contentait bien évidement d’un simple TextArea dans la zone de code ; ce qui veut dire : pas de numérotation des lignes, pas de coloration syntaxique du code… JavaFX étant jeune, il ne faut guère espérer trouver un Beans gratuit, léger et facile à l’emploi de ce coté. Je ne me voyais également pas trop aller chercher du coté d’un éventuel interfaçage avec NetBeans RPC ou Éclipse pour obtenir un résultat similaire.
Après réflexion le plus simple restait encore de tenter une intégration d’un composant HTML5. J’avais déjà effectué par le passé la génération de rapports générés a partir de templates HTML stockés localement et aussi l’intégration d’une carte Google Map (donc un contrôle distant), mais là c’était l’occasion de tester l’utilisation d’un contrôle HTML5 complètement embarqué en local dans l’application.
Un petit tour d’horizon des divers contrôles d’édition de code disponibles sur le web m’a fait sélectionner CodeMirror qui semble assez simple tout en ayant les fonctionnalités minimales qui m’intéressent. Une fois la dernière version récupérée et désarchivée, je récupère divers sous-répertoires nécessaires au fonctionnement du contrôle :
- lib/ – contient les fichiers core CSS et JavaScript du contrôle CodeMirror.
- addon/eddit – contient un addon intéressant : le changement de couleur des accolades quand on met le curseur en début ou en fin de code.
- Divers modules nécessaires à la colorisation syntaxique:
- mode/clike – support des langages apparentés au C : C, C++, Java, C#.
- mode/css – support de CSS.
- mode/htmlmixed – support de HTML.
- mode/javascript – support de JavaScript.
- mode/xml – support de XML.
PS : j’ai effectué une seule modification dans lib/CodeMirror.css à la ligne 6 pour remplacer :
par :
Cela permettra d’éviter que notre contrôle JavaScript ait une taille maximale figée dans notre page web.
Je colle tout cela dans le package de mon projet, ce qui donne l’arborescence suivante :
Maintenant, il me faut une page HTML affichant ce contrôle :
<html>
<head>
<title>CodeEditor</title>
<STYLE type="text/css">
body {
margin-top: 0;
margin-left: 0;
margin-bottom: 0;
margin-right: 0;
}
.CodeMirror {
font-size: 0.9em;
}
</STYLE>
</head>
<body>
<link rel="stylesheet" href="lib/codemirror.css">
<script src="lib/codemirror.js"></script>
<script src="mode/clike/clike.js"></script>
<script src="mode/css/css.js"></script>
<script src="mode/htmlmixed/htmlmixed.js"></script>
<script src="mode/xml/xml.js"></script>
<script src="mode/javascript/javascript.js"></script>
<script src="addon/edit/matchbrackets.js"></script>
<script>
var editor = CodeMirror(document.body, {
lineWrapping: true,
lineNumbers: true,
matchBrackets: true,
});
editor.on('change', function() {
console.info("change");
java.updateText();
});
function clearText() {
editor.setValue('');
}
function setText(text) {
editor.setValue(text);
}
function getText() {
return editor.getValue();
}
function setMode(mode) {
editor.setOption("mode", mode);
}
</script>
</body>
</html>
Ici la feuille de style en début de fichier se contente de retirer la marge « naturelle » de la page web (pour permettre une meilleure intégration dans le contrôle FX) ainsi que de diminuer la taille de la police dans la zone d’édition. Nous chargeons ensuite les divers fichiers CSS et JavaScript nécessaires au contrôle en donnant une URL relative par rapport à l’endroit où se trouve notre fichier HTML.
Enfin, dans le script principal, nous instancions un nouvel éditeur sur lequel nous définissons un hook permettant de notifier un objet nommé « java » des modifications apportées (dialogue dans le sens JavaScript → JavaFX). Nous ajoutons également plusieurs fonctions que nous allons pouvoir invoquer depuis le code JavaFX pour modifier le contenu et le fonctionnement de l’éditeur (dialogue dans le sens JavaFX → JavaScript).
Il est possible de charger le fichier HTML dans un navigateur (pas trop ancien) pour vérifier que l’éditeur est fonctionnel. Ceux ayant accès a une console de débogage JavaScript observeront toutefois la présence d’une erreur relative au fait que la variable java n’est pas définie, ce qui est normal.
Nous allons maintenant créer notre contrôle JavaFX chargé d’afficher cette page web à l’écran :
import java.net.URL;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Worker;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.layout.Region;
import javafx.scene.web.WebView;
import netscape.javascript.JSObject;
/**
* CSS and XML code editor with syntax highlight.
* <br/>We use HTML5 for this editor.
* <br/>The underlying JavaScript editor is using <a href="http://codemirror.net/">CodeMirror</a>.
* @author Fabrice Bouyé (fabriceb@spc.int)
*/
public class CodeEditor extends Region {
private static final Logger LOGGER = Logger.getLogger(CodeEditor.class.getName());
/**
* Code modes in this editor.
* @author Fabrice Bouyé (fabriceb@spc.int)
*/
public enum Mode {
FXML("xml"), XML("xml"), CSS("css"), HTML("htmlmixed"), JAVASCRIPT("javascript"), JAVA("text/x-java"), C("text/x-csrc"), CPP("text/x-c++src"), CS("text/x-csharp"); // NOI18N.
private final String value;
private Mode(final String value) {
this.value = value;
}
String getValue() {
return value;
}
}
/**
* The delegated web view.
*/
private final WebView webView = new WebView();
/**
* Creates a new instance.
*/
public CodeEditor() {
super();
setId("codeEditor"); // NOI18N.
getStyleClass().add("code-editor"); // NOI18N.
getChildren().add(webView);
//
initialized.addListener(initializedChangeListener);
Platform.runLater(this::initializePeer);
}
/**
* Initialize the peer control.
*/
private void initializePeer() {
webView.getEngine().getLoadWorker().stateProperty().addListener(peerLoadStateChangeListener);
final URL htlmURL = getClass().getResource("CodeEditor.html"); // NOI18N.
webView.getEngine().load(htlmURL.toExternalForm());
}
/**
* Indicates whether this control has been initialized.
*/
private final ReadOnlyBooleanWrapper initialized = new ReadOnlyBooleanWrapper(this, "initialized", false);
public boolean isInitialized() {
return initialized.get();
}
public final ReadOnlyBooleanProperty initializedProperty() {
return initialized.getReadOnlyProperty();
}
@Override
protected void layoutChildren() {
super.layoutChildren();
final double width = getWidth();
final double height = getHeight();
final Insets insets = getInsets();
webView.resizeRelocate(insets.getLeft(), insets.getTop(), width - (insets.getLeft() + insets.getRight()), height - (insets.getTop() + insets.getBottom()));
}
////////////////////////////////////////////////////////////////////////////
private boolean isEditing = false;
/**
* Called whenever the initialized property changes value.
*/
private final ChangeListener<Boolean> initializedChangeListener = (ObservableValue<? extends Boolean> observableValue, Boolean oldValue, Boolean newValue) -> {
if (newValue) {
final Optional<EventHandler<ActionEvent>> onInitialized = Optional.ofNullable(getOnInitialized());
onInitialized.ifPresent((EventHandler<ActionEvent> eventHandler) -> {
try {
ActionEvent actionEvent = new ActionEvent(CodeEditor.this, null);
eventHandler.handle(actionEvent);
} catch (Throwable ex) {
LOGGER.log(Level.WARNING, ex.getMessage(), ex);
}
});
}
};
/**
* Called whenever the text is invalidated.
*/
private final InvalidationListener textInvalidationListener = (final Observable observable) -> {
if (!isEditing) {
Platform.runLater(() -> {
clearPeerContent();
pushTextToPeer();
});
}
};
/**
* Called whenever the mode is invalidated.
*/
private final InvalidationListener modeInvalidationListener = (final Observable observable) -> {
Platform.runLater(this::switchPeerMode);
};
/**
* Called when the state of the web engine loader changes.
*/
private final ChangeListener<Worker.State> peerLoadStateChangeListener = (final ObservableValue<? extends Worker.State> observableValue, final Worker.State oldValue, final Worker.State newValue) -> {
switch (newValue) {
case SUCCEEDED:
LOGGER.log(Level.INFO, "Code editor peer load succeeded.");
// Installing bridge object.
final JSObject jsObject = (JSObject) webView.getEngine().executeScript("window"); // NOI18N.
jsObject.setMember("java", new Bridge()); // NOI18N.
// Add property listeners.
modeProperty().addListener(modeInvalidationListener);
textProperty().addListener(textInvalidationListener);
// Finishing initialization.
initialized.set(true);
break;
case CANCELLED:
LOGGER.log(Level.INFO, "Code editor peer load canceled.");
break;
case FAILED:
final Throwable ex = webView.getEngine().getLoadWorker().getException();
LOGGER.log(Level.SEVERE, ex.getMessage(), ex);
break;
default:
}
};
////////////////////////////////////////////////////////////////////////////
/**
* The bridge class that is provided to JavaScript for bidirectionnal dialog.
* <br/>Had to be public to avoid some exceptions from being thrown.
* @author Fabrice Bouyé (fabriceb@spc.int)
*/
public final class Bridge {
public void updateText() {
LOGGER.entering(Bridge.class.getName(), "updateText");
pullTextFromPeer();
LOGGER.exiting(Bridge.class.getName(), "updateText");
}
}
////////////////////////////////////////////////////////////////////////////
/**
* Changes the editor mode of the HTML5 peer.
*/
private void switchPeerMode() {
final Optional<Mode> mode = Optional.ofNullable(getMode());
mode.ifPresent((final Mode m) -> {
final String command = String.format("setMode('%s');", m.getValue()); // NOI18N.
webView.getEngine().executeScript(command);
});
}
/**
* Clear the content of the HTML5 peer.
*/
private void clearPeerContent() {
try {
isEditing = true;
webView.getEngine().executeScript("clearText()"); // NOI18N.
} finally {
isEditing = false;
}
}
/**
* Push text from this control to our HTML5 peer.
*/
private void pushTextToPeer() {
final Optional<String> text = Optional.ofNullable(getText());
text.ifPresent((final String t) -> {
try {
isEditing = true;
final String content = t
.replaceAll("\\\\", "\\\\\\\\") // NOI18N.
.replaceAll("\n", "\\\\n") // NOI18N.
.replaceAll("'", "\\\'"); // NOI18N.
final String command = String.format("setText('%s');", content); // NOI18N.
webView.getEngine().executeScript(command);
} finally {
isEditing = false;
}
});
}
/**
* Pull text from our HTML5 peer to this control.
*/
private void pullTextFromPeer() {
if (isEditing) {
return;
}
try {
isEditing = true;
final String result = (String) webView.getEngine().executeScript("getText()"); // NOI18N.
setText(result);
} finally {
isEditing = false;
}
}
////////////////////////////////////////////////////////////////////////////
/**
* Current mode of the editor.
*/
private final ObjectProperty<Mode> mode = new SimpleObjectProperty<>(this, "mode"); // NOI18N.
public final Mode getMode() {
return mode.get();
}
public final void setMode(final Mode value) {
mode.set(value);
}
public final ObjectProperty<Mode> modeProperty() {
return mode;
}
/**
* Current content of the editor.
*/
private final StringProperty text = new SimpleStringProperty(this, "text"); // NOI18N.
public final String getText() {
return text.get();
}
public final void setText(final String value) {
text.set(value);
}
public final StringProperty textProperty() {
return text;
}
/**
* Action to execute once the editor has been initialized.
*/
private final ObjectProperty<EventHandler<ActionEvent>> onInitialized = new SimpleObjectProperty<>(this, "onInitialized"); // NOI18N.
public final EventHandler<ActionEvent> getOnInitialized() {
return onInitialized.get();
}
public final void setOnInitialized(final EventHandler<ActionEvent> value) {
onInitialized.set(value);
}
public final ObjectProperty<EventHandler<ActionEvent>> onInitializedProperty() {
return onInitialized;
}
}
Notre nouvelle classe étend Region et contient le contrôle WebView. Dans la méthode layoutChildren(), nous nous arrangeons pour que le contrôle WebView soit toujours redimensionné pour couvrir presque toute la surface de la région (nous laissons une marge pour la bordure optionnelle). Au lancement, nous allons charger notre fichier CodeEditor.html :
final URL htlmURL = getClass().getResource("CodeEditor.html"); // NOI18N.
webView.getEngine().load(htlmURL.toExternalForm());
Le chargement de la page par le web engine est asynchrone ; c’est à dire qu’il s’effectue en tâche de fond. Même dans le cas d’un code local et d’une page légère comme celle-ci, ce n’est pas forcement instantané (il faut entre autres charger et initialiser webkit, ainsi que l’interpréteur JavaScript puis parser le contenu de la page), c’est donc pourquoi j’ai rajouté divers écouteurs pour que :
- le contrôle lui-meme soit informé que le chargement soit terminé et procède aux dernières modifications avant qu’il soit utilisable.
- Prévenir les autres contrôles qui seraient en attente que notre éditeur de code est désormais prêt à être utilisé.
Lorsque l’initialisation est terminée, nous créons un objet Bridge que nous fournissons au web engine sous le nom « java » :
jsObject.setMember("java", new Bridge()); // NOI18N.
C’est cet objet qui sera appelé depuis le code JavaScript pour faire remonter l’événement de modification du contenu de l’éditeur.
Dans le cadre d’un dialogue JavaFX → JavaScript, il est ensuite facile d’invoquer les fonctions définies dans notre fichier HTML via la methode executeScript() du web engine.
Il faudra bien sur prendre quelques précautions supplémentaires lorsqu’on modifie le texte depuis JavaFX vers l’éditeur de code JavaScript pour éviter des soucis avec certains caractères tel que \n, ‘ ou encore \ :
text.ifPresent((final String t) -> {
try {
isEditing = true;
final String content = t
.replaceAll("\\\\", "\\\\\\\\") // NOI18N.
.replaceAll("\n", "\\\\n") // NOI18N.
.replaceAll("'", "\\\'"); // NOI18N.
final String command = String.format("setText('%s');", content); // NOI18N.
webView.getEngine().executeScript(command);
} finally {
isEditing = false;
}
});
Maintenant que nous avons un éditeur JavaFX fonctionnel, nous allons l’intégrer dans une UI qui permet de charger des fichiers depuis le disque :
import java.io.File;
import java.io.FileReader;
import java.io.LineNumberReader;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ToolBar;
import javafx.scene.layout.BorderPane;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
/**
*
* @author fabriceb
*/
public class Main extends Application {
@Override
public void start(Stage primaryStage) {
// Filters for the file chooser.
final FileChooser.ExtensionFilter cssFilter = new FileChooser.ExtensionFilter("Cascading Style Sheets", "*.css"); // NOI18N.
final FileChooser.ExtensionFilter fxmlFilter = new FileChooser.ExtensionFilter("FXML Files", "*.fxml"); // NOI18N.
final FileChooser.ExtensionFilter xmlFilter = new FileChooser.ExtensionFilter("XML Files", "*.xml"); // NOI18N.
final FileChooser.ExtensionFilter htmlFilter = new FileChooser.ExtensionFilter("HTML Files", "*.html"); // NOI18N.
final FileChooser.ExtensionFilter jsFilter = new FileChooser.ExtensionFilter("JavaScript source Files", "*.js"); // NOI18N.
final FileChooser.ExtensionFilter javaFilter = new FileChooser.ExtensionFilter("Java source Files", "*.java"); // NOI18N.
final FileChooser.ExtensionFilter cFilter = new FileChooser.ExtensionFilter("C source Files", "*.h", "*.c"); // NOI18N.
final FileChooser.ExtensionFilter cppFilter = new FileChooser.ExtensionFilter("C++ source Files", "*.h", "*.hpp", "*.hh", "*.c", "*.cpp", "*.cc"); // NOI18N.
final FileChooser.ExtensionFilter csFilter = new FileChooser.ExtensionFilter("C# source Files", "*.cs"); // NOI18N.
final List<FileChooser.ExtensionFilter> filters = Arrays.asList(cssFilter, fxmlFilter, xmlFilter, htmlFilter, jsFilter, javaFilter, cFilter, cppFilter, csFilter);
final Map<FileChooser.ExtensionFilter, CodeEditor.Mode> modeMap = new HashMap<>();
modeMap.put(cssFilter, CodeEditor.Mode.CSS);
modeMap.put(fxmlFilter, CodeEditor.Mode.FXML);
modeMap.put(xmlFilter, CodeEditor.Mode.XML);
modeMap.put(htmlFilter, CodeEditor.Mode.HTML);
modeMap.put(jsFilter, CodeEditor.Mode.JAVASCRIPT);
modeMap.put(javaFilter, CodeEditor.Mode.JAVA);
modeMap.put(cFilter, CodeEditor.Mode.C);
modeMap.put(cppFilter, CodeEditor.Mode.CPP);
modeMap.put(csFilter, CodeEditor.Mode.CS);
//
final CodeEditor codeEditor = new CodeEditor();
final Button loadButton = new Button();
loadButton.disableProperty().bind(codeEditor.initializedProperty().not());
loadButton.setText("Load..."); // NOI18N.
loadButton.setOnAction(event -> {
final File directory = new File(System.getProperty("user.dir")); // NOI18N.
final FileChooser dialog = new FileChooser();
dialog.setInitialDirectory(directory);
dialog.getExtensionFilters().setAll(filters);
final File file = dialog.showOpenDialog(loadButton.getScene().getWindow());
if (file != null) {
final StringBuilder builder = new StringBuilder();
try {
try (LineNumberReader input = new LineNumberReader(new FileReader(file))) {
for (String line = input.readLine(); line != null; line = input.readLine()) {
builder.append(line).append("\n"); // NOI18N.
}
}
final CodeEditor.Mode mode = modeMap.get(dialog.getSelectedExtensionFilter());
codeEditor.setMode(mode);
final String content = builder.toString();
codeEditor.setText(content);
} catch (Exception ex) {
Logger.getLogger(Main.class.getName()).log(Level.SEVERE, ex.getMessage(), ex);
}
}
});
final ToolBar toolBar = new ToolBar(loadButton);
final BorderPane root = new BorderPane();
root.setTop(toolBar);
root.setCenter(codeEditor);
final Scene scene = new Scene(root, 800, 800);
primaryStage.setTitle("Code Editor"); // NOI18N.
primaryStage.setScene(scene);
primaryStage.show();
}
/**
* @param args the command line arguments
*/
public static void main(String[] args) {
launch(args);
}
}
Il est désormais possible de charger des fichiers et de les afficher dans l’éditeur.
codeEditor.setMode(mode);
final String content = builder.toString();
codeEditor.setText(content);
Une fois le contenu du fichier chargé en mémoire, nous changeons le mode actif de l’éditeur pour prendre la coloration syntaxique appropriée et puis nous modifions le texte affiché à l’écran.
Ici par exemple, nous avons chargé le fichier CodeMirror.css :
Commentaires récents
- Back from the future… dans
- Back from the future… dans
- Static linking = does not Compute dans
- Paquetage x 2 dans
- Why you little… dans