Skip to main content

Embed Scripts in a desktop application

Posted by forax on July 31, 2006 at 1:22 AM PDT

After my previous post about
Scripting for end users, i've made the promise to explain how to create
a script aware desktop application.
I think it's a good counterpart of the basics described in Scripting for the Java Platform.

So let me introduce a little
OpenOffice Calc clone.

The spreadsheet is composed of cells, each cell
contains two objects :

  • the source of the script and
  • a compiled version of the script.

The two objects are kept in memory in order to improve speed by using the source version during edition and the compiled version during evaluation.

private class Cell {
  final String text;
  final CompiledScript script; 
  public Cell(String text,CompiledScript script) {
    this.text=text;
    this.script=script;
  }
  @Override
  public String toString() {
    return text;
  }
}

In Java, it's fairly easy to create a the spread sheet because
javax.swing.JTable is already a spreadsheet, you just have to hand code a TableModel.
The following table model has one thousand rows and one hundred
columns, all are editable and it delegates how to store a cell
to method setCell() and how to retreive a cell to getCell().
private class ExCellTableModel extends AbstractTableModel {
  public int getRowCount() {
    return 1000;
  }
  public int getColumnCount() {
    return 100;
  }
  public Object getValueAt(int row, int column) {
    return getCell(row,column);
  }
  @Override
  public boolean isCellEditable(int row, int column) {
    return true;
  }
  @Override
  public void setValueAt(Object aValue, int row, int column) {
    String text=aValue.toString();
    CompiledScript script=null;
    try {
      script = compilable.compile(text);
    } catch (ScriptException e) {
      e.printStackTrace();
      return;
    }
    setCell(row,column,text,script);
   
    fireTableDataChanged();
  }
}

The method setValueAt is interresting, it is called when the user
edit a cell. This method try to compile the script
using a compilable engine (a script engine that can compile a script not just interpret it).
After compiling the script, the method creates a new cell and informs the table component (JTable) to redraw the whole table
using fireTableDataChanged().
We can't just redraw the edited cell because perhaps some other cells
have scripts bound to the current cell.


Since we can have a lot of cells (1000*100), and most of the cells will be empty, i use a hashmap associating a cell to its position
in order to not store empty cells.
private final HashMap<Long,Cell> cellMap=new HashMap<Long,Cell>();

Cell getCell(int row,int column) {
  return cellMap.get(mangle(row,column));
}
 
void setCell(int row,int column,String text,CompiledScript script) {
  cellMap.put(mangle(row,column),new Cell(text,script));
}
 
private static long mangle(int row,int column) {
  return (((long)row)<<32)+column;
}

The method main creates a script engine (here a javascript engine)
using javax.script, the application and a JTable linked to the table model with a specific renderer.
The rendrer evaluates (using eval) the cell's script each time
a cell is rendered.
This is not efficient but because the program doesn't
maintain dependency between cells there is no way to do better.
We need to re-evaluate all cells when one cell changed.
public static void main(String[] args) {
  ScriptEngineManager manager=new ScriptEngineManager();
  ScriptEngine engine=manager.getEngineByExtension("js");
   
  final SpreadTheWorld application=new SpreadTheWorld(engine);
  JTable table=new JTable(exCell.new ExCellTableModel());
  table.setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
    table.setDefaultRenderer(Object.class,new DefaultTableCellRenderer() {
    @Override
    public Component getTableCellRendererComponent(JTable table, Object value,
      boolean isSelected, boolean hasFocus, int row, int column) {

      Cell cell=application.getCell(row,column);
      return super.getTableCellRendererComponent(table,
          (cell==null)?null:application.eval(cell),
          isSelected, hasFocus, row, column);
    }
  });
   
  JFrame frame=new JFrame("Spread the World");
  frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
  frame.setContentPane(new JScrollPane(table));
  frame.pack();
  frame.setVisible(true);
}

The method eval() evaluates a cell and detects circularity
using a set of visited cells.
private transient HashSet<Cell> circularity=
  new HashSet<Cell>();
 
private final Bindings bindings=new CellBindings();
final Compilable compilable;
 
public SpreadTheWorld(ScriptEngine engine) {
  this.compilable=(Compilable)engine;
}
 
Object eval(Cell cell) {
  if (cell.script==null)
    return cell.text;
   
  if (!circularity.add(cell)) {
   throw new IllegalStateException("circular refs");
  }
  try {
    return cell.script.eval(bindings);
  } catch (ScriptException e) {
    return e;
  } finally {
    circularity.remove(cell);
  }
}

Because cell script can refer to other cells,
script.eval() take a hand coded binding as parameter.


A bindings (javax.script.Bindings) is a symbol table of all objects that are accessible from a script.
Here, the bindings understands variables that match the regex
"letters followed by digits"
and returns an evaluation of the corresponding cell
(the top left cell is referenced by a0).
private class CellBindings extends SimpleBindings {
  @Override
  public boolean containsKey(Object key) {
    return CELL_REF_PATTERN.matcher(key.toString()).matches() ||
      super.containsKey(key);
  }
   
  @Override
  public Object get(Object key) {
    Matcher matcher=CELL_REF_PATTERN.matcher(key.toString());
    if (!matcher.matches())
      return super.get(key);
    Cell cell=getCell(Integer.parseInt(matcher.group(2)),
      getColumnIndexFromName(matcher.group(1)));
    if (cell==null)
      return 0;
    return eval(cell);
  }
}
 
static final Pattern CELL_REF_PATTERN=Pattern.compile("(\\p{Alpha}+)(\\p{Digit}+)");

static int getColumnIndexFromName(String columnName) {
  int value=0;
  for(int i=0;i<columnName.length();i++) {
    value=value*26+Character.toLowerCase(columnName.charAt(i))-'a';
  }
  return value;
}

The above code of the bindings is not legal because a bindings inherits from java.util.Map so it must provide a way to iterate over all variables contained in the bindings.
But it seems to work with the javascript engine but
perhaps it will not work with another engine or with
a newer version of Rhino.


I'm too lazy to update that code now but perhaps
i will do it in a future blog entry.

The full code is available here !

Related Topics >>