/*
* Copyright (C) 2012 Jason Gedge <http://www.gedge.ca>
*
* This file is part of the OpGraph project.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package ca.gedge.opgraph.nodes.general;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.util.Properties;
import java.util.Vector;
import java.util.logging.Logger;
import javax.script.Invocable;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineFactory;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import javax.swing.JComboBox;
import javax.swing.JEditorPane;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.ScrollPaneConstants;
import javax.swing.SwingUtilities;
import javax.swing.event.CaretEvent;
import javax.swing.event.CaretListener;
import javax.swing.text.BadLocationException;
import ca.gedge.opgraph.OpContext;
import ca.gedge.opgraph.OpNode;
import ca.gedge.opgraph.OpNodeInfo;
import ca.gedge.opgraph.OutputField;
import ca.gedge.opgraph.Processor;
import ca.gedge.opgraph.app.GraphDocument;
import ca.gedge.opgraph.app.GraphEditorModel;
import ca.gedge.opgraph.app.edits.node.NodeSettingsEdit;
import ca.gedge.opgraph.app.extensions.NodeSettings;
import ca.gedge.opgraph.exceptions.ProcessingException;
import ca.gedge.opgraph.nodes.general.script.InputFields;
import ca.gedge.opgraph.nodes.general.script.LoggingHelper;
import ca.gedge.opgraph.nodes.general.script.OutputFields;
/**
* A node that runs a script.
*/
@OpNodeInfo(
name="Script",
description="Executes a script.",
category="General"
)
public class ScriptNode
extends OpNode
implements NodeSettings
{
private static final Logger LOGGER = Logger.getLogger(ScriptNode.class.getName());
/** The script engine manager being used */
private ScriptEngineManager manager;
/** The script engine being used */
private ScriptEngine engine;
/** The scripting language of this node */
private String language;
/** The script source */
private String script;
/**
* Constructs a script node that uses Javascript as its language.
*/
public ScriptNode() {
this(null);
}
/**
* Constructs a script node that uses a given scripting language.
*
* @param language the name of the language
*/
public ScriptNode(String language) {
this.manager = new ScriptEngineManager();
this.script = "";
setScriptLanguage(language);
putExtension(NodeSettings.class, this);
}
/**
* Gets the scripting language of this node.
*
* @return the name of the scripting language currently used
*/
public String getScriptLanguage() {
return language;
}
/**
* Sets the scripting language of this node.
*
* @param language the name of a supported language
*/
public void setScriptLanguage(String language) {
language = (language == null ? "" : language);
if(!language.equals(this.language)) {
this.language = language;
this.engine = manager.getEngineByName(language);
// Only work with invocable script engines
if(this.engine == null || !(this.engine instanceof Invocable)) {
this.engine = null;
} else {
this.engine.put("Logging", new LoggingHelper());
}
reloadFields();
}
}
/**
* Gets the script source used in this node.
*
* @return the script source
*/
public String getScriptSource() {
return script;
}
/**
* Sets the script source used in this node.
*
* @param script the script source
*/
public void setScriptSource(String script) {
script = (script == null ? "" : script);
if(!script.equals(this.script)) {
this.script = script;
reloadFields();
}
}
/**
* Reload the input/output fields from the script.
*/
private void reloadFields() {
if(engine != null) {
try {
engine.eval(script);
// XXX Perhaps have InputFields and OutputFields store temporary
// sets of fields and only remove the input/output fields that
// don't exist in them (isntead of all fields)
//
removeAllInputFields();
removeAllOutputFields();
final InputFields inputFields = new InputFields(this);
final OutputFields outputFields = new OutputFields(this);
try {
((Invocable)engine).invokeFunction("init", inputFields, outputFields);
} catch(NoSuchMethodException exc) {
// XXX init() not necessary, but should we warn?
}
} catch(ScriptException exc) {
LOGGER.warning("Script error: " + exc.getLocalizedMessage());
}
}
}
//
// Overrides
//
@Override
public void operate(OpContext context) throws ProcessingException {
if(engine != null) {
try {
// Creating bindings from context
for(String key : context.keySet())
engine.put(key, context.get(key));
// provide logger for script as 'logger'
Logger logger = Logger.getLogger(Processor.class.getName());
engine.put("logger", logger);
// Execute run() method in script
((Invocable)engine).invokeFunction("run");
// Put output values in context
for(OutputField field : getOutputFields())
context.put(field, engine.get(field.getKey()));
// Erase values
for(String key : context.keySet())
engine.put(key, null);
} catch(ScriptException exc) {
throw new ProcessingException("Could not execute script script", exc);
} catch(NoSuchMethodException exc) {
throw new ProcessingException("No run() method in script", exc);
}
}
}
//
// NodeSettings
//
/**
* Constructs a math expression settings for the given node.
*/
public static class ScriptNodeSettings extends JPanel {
/**
* Constructs a component for editing a {@link ScriptNode}'s settings.
*
* @param node the {@link ScriptNode}
*/
public ScriptNodeSettings(final ScriptNode node) {
super(new GridBagLayout());
// Script source components
final JEditorPane sourceEditor = new JEditorPane() {
@Override
public boolean getScrollableTracksViewportWidth() {
// Only track width if the preferred with is less than the viewport width
if(getParent() != null)
return (getUI().getPreferredSize(this).width <= getParent().getSize().width);
return super.getScrollableTracksViewportWidth();
}
@Override
public Dimension getPreferredSize() {
// Add a little for the cursor
final Dimension dim = super.getPreferredSize();
//dim.width += 5;
return dim;
}
};
sourceEditor.setText(node.getScriptSource());
sourceEditor.setCaretPosition(0);
sourceEditor.addCaretListener(new CaretListener() {
@Override
public void caretUpdate(CaretEvent e) {
try {
final Rectangle rect = sourceEditor.modelToView(e.getMark());
if(rect != null) {
rect.width += 5;
rect.height += 5;
sourceEditor.scrollRectToVisible(rect);
}
} catch(BadLocationException exc) {}
}
});
sourceEditor.addFocusListener(new FocusListener() {
@Override
public void focusLost(FocusEvent e) {
// Post an undoable edit
final GraphDocument document = GraphEditorModel.getActiveDocument();
if(document != null) {
final Properties settings = new Properties();
settings.put(SCRIPT_KEY, sourceEditor.getText());
document.getUndoSupport().postEdit(new NodeSettingsEdit(node, settings));
} else {
node.setScriptSource(sourceEditor.getText());
}
}
@Override
public void focusGained(FocusEvent e) {}
});
final JScrollPane sourcePane = new JScrollPane(sourceEditor);
sourcePane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_ALWAYS);
sourcePane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS);
// Script language components
final Vector<ScriptEngineFactory> factories = new Vector<ScriptEngineFactory>();
final Vector<String> languageChoices = new Vector<String>();
factories.add(null);
languageChoices.add("<no language>");
for(ScriptEngineFactory factory : (new ScriptEngineManager()).getEngineFactories()) {
factories.add(factory);
languageChoices.add(factory.getLanguageName());
}
final JComboBox languageBox = new JComboBox(languageChoices);
languageBox.setEditable(false);
languageBox.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// Post an undoable edit
final ScriptEngineFactory factory = factories.get(languageBox.getSelectedIndex());
final GraphDocument document = GraphEditorModel.getActiveDocument();
if(document != null) {
final Properties settings = new Properties();
settings.put(LANGUAGE_KEY, factory == null ? "" : factory.getLanguageName());
document.getUndoSupport().postEdit(new NodeSettingsEdit(node, settings));
} else {
node.setScriptLanguage(factory == null ? null : factory.getLanguageName());
}
// Update editor kit
final int ss = sourceEditor.getSelectionStart();
final int se = sourceEditor.getSelectionEnd();
final String source = sourceEditor.getText();
// TODO editor kit with syntax highlighting
sourceEditor.setContentType("text/plain");
sourceEditor.setText(source);
sourceEditor.select(ss, se);
}
});
languageBox.setSelectedItem(node.getScriptLanguage());
// Add components
final GridBagConstraints gbc = new GridBagConstraints();
gbc.gridx = 0;
gbc.gridy = 0;
gbc.weightx = 0;
gbc.fill = GridBagConstraints.NONE;
gbc.anchor = GridBagConstraints.EAST;
add(new JLabel("Script Language: "), gbc);
gbc.gridx = 1;
gbc.gridy = 0;
gbc.weightx = 1;
gbc.fill = GridBagConstraints.HORIZONTAL;
gbc.anchor = GridBagConstraints.EAST;
add(languageBox, gbc);
gbc.gridx = 0;
gbc.gridy = 1;
gbc.gridwidth = 2;
gbc.weightx = 1;
gbc.weighty = 1;
gbc.anchor = GridBagConstraints.CENTER;
gbc.fill = GridBagConstraints.BOTH;
add(sourcePane, gbc);
// Put the cursor at the beginning of the document
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
sourceEditor.select(0, 0);
}
});
}
}
private static final String LANGUAGE_KEY = "scriptLanguage";
private static final String SCRIPT_KEY = "scriptSource";
@Override
public Component getComponent(GraphDocument document) {
return new ScriptNodeSettings(this);
}
@Override
public Properties getSettings() {
final Properties props = new Properties();
props.setProperty(LANGUAGE_KEY, getScriptLanguage());
props.setProperty(SCRIPT_KEY, getScriptSource());
return props;
}
@Override
public void loadSettings(Properties properties) {
if(properties.containsKey(LANGUAGE_KEY))
setScriptLanguage(properties.getProperty(LANGUAGE_KEY));
if(properties.containsKey(SCRIPT_KEY))
setScriptSource(properties.getProperty(SCRIPT_KEY));
}
}