package org.jruby.demo;
import java.awt.Color;
import java.awt.Point;
import java.awt.EventQueue;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JComboBox;
import javax.swing.plaf.basic.BasicComboPopup;
import javax.swing.text.AbstractDocument;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.Document;
import javax.swing.text.DocumentFilter;
import javax.swing.text.JTextComponent;
import javax.swing.text.MutableAttributeSet;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import org.jruby.Ruby;
import org.jruby.RubyEncoding;
import org.jruby.RubyIO;
import org.jruby.RubyModule;
import org.jruby.RubyString;
import org.jruby.ext.readline.Readline;
import org.jruby.runtime.Arity;
import org.jruby.runtime.Block;
import org.jruby.runtime.builtin.IRubyObject;
import org.jruby.runtime.callback.Callback;
import org.jruby.util.Join;
public class TextAreaReadline implements KeyListener {
private static final String EMPTY_LINE = "";
private JTextComponent area;
private volatile int startPos;
private String currentLine;
public volatile MutableAttributeSet promptStyle;
public volatile MutableAttributeSet inputStyle;
public volatile MutableAttributeSet outputStyle;
public volatile MutableAttributeSet resultStyle;
private JComboBox completeCombo;
private BasicComboPopup completePopup;
private int start;
private int end;
private final InputStream inputStream = new Input();
private final OutputStream outputStream = new Output();
private static class InputBuffer {
public final byte[] bytes;
public int offset = 0;
public InputBuffer(byte[] bytes) {
this.bytes = bytes;
}
}
public enum Channel {
AVAILABLE,
READ,
BUFFER,
EMPTY,
LINE,
GET_LINE,
SHUTDOWN,
FINISHED
}
private static class ReadRequest {
public final byte[] b;
public final int off;
public final int len;
public ReadRequest(byte[] b, int off, int len) {
this.b = b;
this.off = off;
this.len = len;
}
public int perform(Join join, InputBuffer buffer) {
final int available = buffer.bytes.length - buffer.offset;
int len = this.len;
if ( len > available ) {
len = available;
}
if ( len == available ) {
join.send(Channel.EMPTY, null);
} else {
buffer.offset += len;
join.send(Channel.BUFFER, buffer);
}
System.arraycopy(buffer.bytes, buffer.offset, this.b, this.off, len);
return len;
}
}
private static final Join.Spec INPUT_SPEC = new Join.Spec() {{
addReaction(new Join.FastReaction(Channel.SHUTDOWN, Channel.BUFFER) {
public void react(Join join, Object[] args) {
join.send(Channel.FINISHED, null);
}
});
addReaction(new Join.FastReaction(Channel.SHUTDOWN, Channel.EMPTY) {
public void react(Join join, Object[] args) {
join.send(Channel.FINISHED, null);
}
});
addReaction(new Join.FastReaction(Channel.SHUTDOWN, Channel.FINISHED) {
public void react(Join join, Object[] args) {
join.send(Channel.FINISHED, null);
}
});
addReaction(new Join.FastReaction(Channel.FINISHED, Channel.LINE) {
public void react(Join join, Object[] args) {
join.send(Channel.FINISHED, null);
}
});
addReaction(new Join.SyncReaction(Channel.AVAILABLE, Channel.BUFFER) {
public Object react(Join join, Object[] args) {
InputBuffer buffer = (InputBuffer)args[1];
join.send(Channel.BUFFER, buffer);
return buffer.bytes.length - buffer.offset;
}
});
addReaction(new Join.SyncReaction(Channel.AVAILABLE, Channel.EMPTY) {
public Object react(Join join, Object[] args) {
join.send(Channel.EMPTY, null);
return 0;
}
});
addReaction(new Join.SyncReaction(Channel.AVAILABLE, Channel.FINISHED) {
public Object react(Join join, Object[] args) {
join.send(Channel.FINISHED, null);
return 0;
}
});
addReaction(new Join.SyncReaction(Channel.READ, Channel.BUFFER) {
public Object react(Join join, Object[] args) {
return ((ReadRequest)args[0]).perform(join, (InputBuffer)args[1]);
}
});
addReaction(new Join.SyncReaction(Channel.READ, Channel.EMPTY, Channel.LINE) {
public Object react(Join join, Object[] args) {
final ReadRequest request = (ReadRequest)args[0];
final String line = (String)args[2];
if (line.length() != 0) {
byte[] bytes = RubyEncoding.encodeUTF8(line);
return request.perform(join, new InputBuffer(bytes));
} else {
return -1;
}
}
});
addReaction(new Join.SyncReaction(Channel.READ, Channel.FINISHED) {
public Object react(Join join, Object[] args) {
join.send(Channel.FINISHED, null);
return -1;
}
});
addReaction(new Join.SyncReaction(Channel.GET_LINE, Channel.LINE) {
public Object react(Join join, Object[] args) {
return args[1];
}
});
addReaction(new Join.SyncReaction(Channel.GET_LINE, Channel.FINISHED) {
public Object react(Join join, Object[] args) {
join.send(Channel.FINISHED, null);
return EMPTY_LINE;
}
});
}};
private static final int MAX_DOC_SIZE = 100000;
private final Join inputJoin = INPUT_SPEC.createJoin();
public TextAreaReadline(JTextComponent area) {
this(area, null);
}
public TextAreaReadline(JTextComponent area, final String message) {
this.area = area;
inputJoin.send(Channel.EMPTY, null);
area.addKeyListener(this);
// No editing before startPos
if (area.getDocument() instanceof AbstractDocument) {
((AbstractDocument) area.getDocument()).setDocumentFilter(
new DocumentFilter() {
public void insertString(DocumentFilter.FilterBypass fb, int offset, String string, AttributeSet attr) throws BadLocationException {
if (offset >= startPos) super.insertString(fb, offset, string, attr);
}
public void remove(DocumentFilter.FilterBypass fb, int offset, int length) throws BadLocationException {
if (offset >= startPos || offset == 0) super.remove(fb, offset, length);
}
public void replace(DocumentFilter.FilterBypass fb, int offset, int length, String text, AttributeSet attrs) throws BadLocationException {
if (offset >= startPos) super.replace(fb, offset, length, text, attrs);
}
}
);
}
promptStyle = new SimpleAttributeSet();
StyleConstants.setForeground(promptStyle, new Color(0xa4, 0x00, 0x00));
inputStyle = new SimpleAttributeSet();
StyleConstants.setForeground(inputStyle, new Color(0x20, 0x4a, 0x87));
outputStyle = new SimpleAttributeSet();
StyleConstants.setForeground(outputStyle, Color.darkGray);
resultStyle = new SimpleAttributeSet();
StyleConstants.setItalic(resultStyle, true);
StyleConstants.setForeground(resultStyle, new Color(0x20, 0x4a, 0x87));
completeCombo = new JComboBox();
completeCombo.setRenderer(new DefaultListCellRenderer()); // no silly ticks!
completePopup = new BasicComboPopup(completeCombo);
if (message != null) {
final MutableAttributeSet messageStyle = new SimpleAttributeSet();
StyleConstants.setBackground(messageStyle, area.getForeground());
StyleConstants.setForeground(messageStyle, area.getBackground());
append(message, messageStyle);
}
startPos = area.getDocument().getLength();
}
public InputStream getInputStream() {
return inputStream;
}
public OutputStream getOutputStream() {
return outputStream;
}
private Ruby runtime;
/**
* Hooks this <code>TextAreaReadline</code> instance into the
* runtime, redefining the <code>Readline</code> module so that
* it uses this object. This method does not redefine the standard
* input-output streams. If you need that, use
* {@link #hookIntoRuntimeWithStreams(Ruby)}.
*
* @param runtime
* The runtime.
* @see #hookIntoRuntimeWithStreams(Ruby)
*/
public void hookIntoRuntime(final Ruby runtime) {
this.runtime = runtime;
/* Hack in to replace usual readline with this */
runtime.getLoadService().require("readline");
RubyModule readlineM = runtime.getModule("Readline");
readlineM.defineModuleFunction("readline", new Callback() {
public IRubyObject execute(IRubyObject recv, IRubyObject[] args, Block block) {
String line = readLine(args[0].toString());
if (line != null) {
return RubyString.newUnicodeString(runtime, line);
} else {
return runtime.getNil();
}
}
public Arity getArity() { return Arity.twoArguments(); }
});
}
/**
* Hooks this <code>TextAreaReadline</code> instance into the
* runtime, redefining the <code>Readline</code> module so that
* it uses this object. This method also redefines the standard
* input-output streams accordingly.
*
* @param runtime
* The runtime.
* @see #hookIntoRuntime(Ruby)
*/
public void hookIntoRuntimeWithStreams(final Ruby runtime) {
hookIntoRuntime(runtime);
RubyIO in = new RubyIO(runtime, getInputStream());
runtime.getGlobalVariables().set("$stdin", in);
RubyIO out = new RubyIO(runtime, getOutputStream());
runtime.getGlobalVariables().set("$stdout", out);
runtime.getGlobalVariables().set("$stderr", out);
}
protected void completeAction(KeyEvent event) {
if (Readline.getCompletor(Readline.getHolder(runtime)) == null) return;
event.consume();
if (completePopup.isVisible()) return;
List candidates = new LinkedList();
String bufstr = null;
try {
bufstr = area.getText(startPos, area.getCaretPosition() - startPos);
} catch (BadLocationException e) {
return;
}
int cursor = area.getCaretPosition() - startPos;
int position = Readline.getCompletor(Readline.getHolder(runtime)).complete(bufstr, cursor, candidates);
// no candidates? Fail.
if (candidates.isEmpty()) {
return;
}
if (candidates.size() == 1) {
replaceText(startPos + position, area.getCaretPosition(), (String) candidates.get(0));
return;
}
start = startPos + position;
end = area.getCaretPosition();
Point pos = area.getCaret().getMagicCaretPosition();
// bit risky if someone changes completor, but useful for method calls
int cutoff = bufstr.substring(position).lastIndexOf('.') + 1;
start += cutoff;
if (candidates.size() < 10) {
completePopup.getList().setVisibleRowCount(candidates.size());
} else {
completePopup.getList().setVisibleRowCount(10);
}
completeCombo.removeAllItems();
for (Iterator i = candidates.iterator(); i.hasNext();) {
String item = (String) i.next();
if (cutoff != 0) item = item.substring(cutoff);
completeCombo.addItem(item);
}
completePopup.show(area, pos.x, pos.y + area.getFontMetrics(area.getFont()).getHeight());
}
protected void backAction(KeyEvent event) {
if (area.getCaretPosition() <= startPos) {
event.consume();
}
}
protected void upAction(KeyEvent event) {
event.consume();
if (completePopup.isVisible()) {
int selected = completeCombo.getSelectedIndex() - 1;
if (selected < 0) return;
completeCombo.setSelectedIndex(selected);
return;
}
if (!Readline.getHistory(Readline.getHolder(runtime)).next()) {
currentLine = getLine(); // at end
} else {
Readline.getHistory(Readline.getHolder(runtime)).previous(); //undo check
}
if (!Readline.getHistory(Readline.getHolder(runtime)).previous()) return;
String oldLine = Readline.getHistory(Readline.getHolder(runtime)).current().toString().trim();
replaceText(startPos, area.getDocument().getLength(), oldLine);
}
protected void downAction(KeyEvent event) {
event.consume();
if (completePopup.isVisible()) {
int selected = completeCombo.getSelectedIndex() + 1;
if (selected == completeCombo.getItemCount()) return;
completeCombo.setSelectedIndex(selected);
return;
}
if (!Readline.getHistory(Readline.getHolder(runtime)).next()) return;
String oldLine;
if (!Readline.getHistory(Readline.getHolder(runtime)).next()) {
oldLine = currentLine; // at end
} else {
Readline.getHistory(Readline.getHolder(runtime)).previous(); // undo check
oldLine = Readline.getHistory(Readline.getHolder(runtime)).current().toString().trim();
}
replaceText(startPos, area.getDocument().getLength(), oldLine);
}
protected void replaceText(int start, int end, String replacement) {
try {
area.getDocument().remove(start, end - start);
area.getDocument().insertString(start, replacement, inputStyle);
} catch (BadLocationException e) {
e.printStackTrace();
}
}
protected String getLine() {
try {
return area.getText(startPos, area.getDocument().getLength() - startPos);
} catch (BadLocationException e) {
e.printStackTrace();
}
return null;
}
protected void enterAction(KeyEvent event) {
event.consume();
if (completePopup.isVisible()) {
if (completeCombo.getSelectedItem() != null) {
replaceText(start, end, (String) completeCombo.getSelectedItem());
}
completePopup.setVisible(false);
return;
}
append("\n", null);
String line = getLine();
startPos = area.getDocument().getLength();
inputJoin.send(Channel.LINE, line);
}
public String readLine(final String prompt) {
if (EventQueue.isDispatchThread()) {
throw runtime.newThreadError("Cannot call readline from event dispatch thread");
}
EventQueue.invokeLater(new Runnable() {
public void run() {
append(prompt.trim(), promptStyle);
append(" ", inputStyle); // hack to get right style for input
area.setCaretPosition(area.getDocument().getLength());
startPos = area.getDocument().getLength();
Readline.getHistory(Readline.getHolder(runtime)).moveToEnd();
}
});
final String line = (String)inputJoin.call(Channel.GET_LINE, null);
if (line.length() > 0) {
return line.trim();
} else {
return null;
}
}
public void keyPressed(KeyEvent event) {
int code = event.getKeyCode();
switch (code) {
case KeyEvent.VK_TAB: completeAction(event); break;
case KeyEvent.VK_LEFT:
case KeyEvent.VK_BACK_SPACE:
backAction(event); break;
case KeyEvent.VK_UP: upAction(event); break;
case KeyEvent.VK_DOWN: downAction(event); break;
case KeyEvent.VK_ENTER: enterAction(event); break;
case KeyEvent.VK_HOME: event.consume(); area.setCaretPosition(startPos); break;
case KeyEvent.VK_D:
if ( ( event.getModifiersEx() & KeyEvent.CTRL_DOWN_MASK ) != 0 ) {
event.consume();
inputJoin.send(Channel.LINE, EMPTY_LINE);
}
break;
}
if (completePopup.isVisible() &&
code != KeyEvent.VK_TAB &&
code != KeyEvent.VK_UP &&
code != KeyEvent.VK_DOWN )
completePopup.setVisible(false);
}
public void keyReleased(KeyEvent arg0) { }
public void keyTyped(KeyEvent arg0) { }
public void shutdown() {
inputJoin.send(Channel.SHUTDOWN, null);
}
/** Output methods **/
protected void append(String toAppend, AttributeSet style) {
try {
Document doc = area.getDocument();
doc.insertString(doc.getLength(), toAppend, style);
// Cut the document to fit into the MAX_DOC_SIZE.
// See JRUBY-4237.
int extra = doc.getLength() - MAX_DOC_SIZE;
if (extra > 0) {
int removeBytes = extra + MAX_DOC_SIZE/10;
doc.remove(0, removeBytes);
startPos -= removeBytes;
}
} catch (BadLocationException e) {}
}
private void writeLineUnsafe(final String line) {
if (line.startsWith("=>")) {
append(line, resultStyle);
} else {
append(line, outputStyle);
}
startPos = area.getDocument().getLength();
}
private void writeLine(final String line) {
if (EventQueue.isDispatchThread()) {
writeLineUnsafe(line);
} else {
EventQueue.invokeLater(new Runnable() {
public void run() {
writeLineUnsafe(line);
}
});
}
}
private class Input extends InputStream {
private volatile boolean closed = false;
@Override
public int available() throws IOException {
if (closed) {
throw new IOException("Stream is closed");
}
return (Integer)inputJoin.call(Channel.AVAILABLE, null);
}
@Override
public int read() throws IOException {
byte[] b = new byte[1];
if ( read(b, 0, 1) == 1 ) {
return b[0];
} else {
return -1;
}
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
if (closed) {
throw new IOException("Stream is closed");
}
if (EventQueue.isDispatchThread()) {
throw new IOException("Cannot call read from event dispatch thread");
}
if (b == null) {
throw new NullPointerException();
}
if (off < 0 || len < 0 || off+len > b.length) {
throw new IndexOutOfBoundsException();
}
if (len == 0) {
return 0;
}
final ReadRequest request = new ReadRequest(b, off, len);
return (Integer)inputJoin.call(Channel.READ, request);
}
@Override
public void close() {
closed = true;
inputJoin.send(Channel.SHUTDOWN, null);
}
}
private class Output extends OutputStream {
@Override
public void write(int b) throws IOException {
writeLine("" + b);
}
@Override
public void write(byte[] b, int off, int len) {
writeLine(RubyEncoding.decodeUTF8(b, off, len));
}
@Override
public void write(byte[] b) {
writeLine(RubyEncoding.decodeUTF8(b));
}
}
}