001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2015
005 * Space Science and Engineering Center (SSEC)
006 * University of Wisconsin - Madison
007 * 1225 W. Dayton Street, Madison, WI 53706, USA
008 * https://www.ssec.wisc.edu/mcidas
009 * 
010 * All Rights Reserved
011 * 
012 * McIDAS-V is built on Unidata's IDV and SSEC's VisAD libraries, and
013 * some McIDAS-V source code is based on IDV and VisAD source code.  
014 * 
015 * McIDAS-V is free software; you can redistribute it and/or modify
016 * it under the terms of the GNU Lesser Public License as published by
017 * the Free Software Foundation; either version 3 of the License, or
018 * (at your option) any later version.
019 * 
020 * McIDAS-V is distributed in the hope that it will be useful,
021 * but WITHOUT ANY WARRANTY; without even the implied warranty of
022 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
023 * GNU Lesser Public License for more details.
024 * 
025 * You should have received a copy of the GNU Lesser Public License
026 * along with this program.  If not, see http://www.gnu.org/licenses.
027 */
028
029package edu.wisc.ssec.mcidasv.jython;
030
031import static java.util.Objects.requireNonNull;
032
033import java.awt.BorderLayout;
034import java.awt.Color;
035import java.awt.Dimension;
036import java.awt.EventQueue;
037import java.awt.Font;
038import java.awt.event.KeyEvent;
039import java.awt.event.KeyListener;
040import java.awt.event.MouseAdapter;
041import java.awt.event.MouseEvent;
042import java.awt.Toolkit;
043import java.util.ArrayList;
044import java.util.Collections;
045import java.util.HashMap;
046import java.util.List;
047import java.util.Map;
048import java.util.Properties;
049import java.util.Set;
050import java.util.TreeSet;
051
052import javax.swing.JFrame;
053import javax.swing.JPanel;
054import javax.swing.JPopupMenu;
055import javax.swing.JScrollPane;
056import javax.swing.JTextPane;
057import javax.swing.KeyStroke;
058import javax.swing.text.BadLocationException;
059import javax.swing.text.Document;
060import javax.swing.text.JTextComponent;
061import javax.swing.text.SimpleAttributeSet;
062import javax.swing.text.StyleConstants;
063
064import org.python.core.PyJavaType;
065import org.python.core.PyList;
066import org.python.core.PyObject;
067import org.python.core.PyObjectDerived;
068import org.python.core.PyString;
069import org.python.core.PyStringMap;
070import org.python.core.PyTuple;
071import org.python.util.InteractiveConsole;
072import org.slf4j.Logger;
073import org.slf4j.LoggerFactory;
074
075import ucar.unidata.util.StringUtil;
076
077// TODO(jon): Console should become an interface. there is no reason people 
078//            should have to deal with the UI stuff when they only want to use
079//            an interpreter.
080public class Console implements Runnable, KeyListener {
081
082    public enum HistoryType { INPUT, SYSTEM };
083
084    /** Color of the Jython text as it is being entered. */
085    protected static final Color TXT_NORMAL = Color.BLACK;
086
087    /** Color of text coming from {@literal "stdout"}. */
088    protected static final Color TXT_GOOD = Color.BLUE;
089
090    /** Not used just yet... */
091    protected static final Color TXT_WARN = Color.ORANGE;
092
093    /** Color of text coming from {@literal "stderr"}. */
094    protected static final Color TXT_ERROR = Color.RED;
095
096    /** {@link Logger} object for Jython consoles. */
097    private static final Logger logger = LoggerFactory.getLogger(Console.class);
098
099    /** Offset array used when actual offsets cannot be determined. */
100    private static final int[] BAD_OFFSETS = { -1, -1 };
101
102    /** Normal jython prompt. */
103    private static final String PS1 = ">>> ";
104
105    /** Prompt that indicates more input is needed. */
106    private static final String PS2 = "... ";
107
108    /** Actual {@link String} of whitespace to insert for blocks and whatnot. */
109    private static final String WHITESPACE = "    ";
110
111    /** Not used yet. */
112    private static final String BANNER = InteractiveConsole.getDefaultBanner();
113
114    /** All text will appear in this font. */
115    private static final Font FONT = new Font("Monospaced", Font.PLAIN, 14);
116
117    /** Jython statements entered by the user. */
118    // TODO(jon): consider implementing a limit to the number of lines stored?
119    private final List<String> jythonHistory;
120
121    /** Thread that handles Jython command execution. */
122    private Runner jythonRunner;
123
124    /** A hook that allows external classes to respond to events. */
125    private ConsoleCallback callback;
126
127    /** Where the user interacts with the Jython interpreter. */
128    private JTextPane textPane;
129
130    /** {@link #textPane}'s internal representation. */
131    private Document document;
132
133    /** Panel that holds {@link #textPane}. */
134    private JPanel panel;
135
136    /** Title of the console window. */
137    private String windowTitle = "Super Happy Jython Fun Console "+hashCode();
138
139    private MenuWrangler menuWrangler;
140
141    /**
142     * Build a console with no initial commands.
143     */
144    public Console() {
145        this(Collections.<String>emptyList());
146    }
147
148    /**
149     * Builds a console and executes a list of Jython statements. It's been
150     * useful for dirty tricks needed during setup.
151     * 
152     * @param initialCommands Jython statements to execute.
153     */
154    public Console(final List<String> initialCommands) {
155        requireNonNull(initialCommands, "List of initial commands cannot be null");
156        jythonHistory = new ArrayList<>();
157        jythonRunner = new Runner(this, initialCommands);
158        jythonRunner.start();
159        // err, shouldn't the gui stuff be done explicitly in the EDT!?
160        menuWrangler = new DefaultMenuWrangler(this);
161        panel = new JPanel(new BorderLayout());
162        textPane = new JTextPane() {
163            @Override public void paste() {
164                
165                super.paste();
166            }
167        };
168        document = textPane.getDocument();
169        panel.add(BorderLayout.CENTER, new JScrollPane(textPane));
170        setCallbackHandler(new DummyCallbackHandler());
171        try {
172            showBanner(); 
173            document.createPosition(document.getLength() - 1);
174        } catch (BadLocationException e) {
175            logger.error("had difficulties setting up the console msg", e);
176        }
177
178        new EndAction(this, Actions.END);
179        new EnterAction(this, Actions.ENTER);
180        new DeleteAction(this, Actions.DELETE);
181        new HomeAction(this, Actions.HOME);
182        new TabAction(this, Actions.TAB);
183        new PasteAction(this, Actions.PASTE);
184//        new UpAction(this, Actions.UP);
185//        new DownAction(this, Actions.DOWN);
186
187        JTextComponent.addKeymap("jython", textPane.getKeymap());
188
189        textPane.setFont(FONT);
190        textPane.addKeyListener(this);
191        textPane.addMouseListener(new PopupListener());
192    }
193
194    /**
195     * Returns the panel containing the various UI components.
196     */
197    public JPanel getPanel() {
198        return panel;
199    }
200
201    /**
202     * Returns the {@link JTextPane} used by the console.
203     */
204    protected JTextPane getTextPane() {
205        return textPane;
206    }
207
208    /**
209     * Inserts the specified object into Jython's local namespace using the
210     * specified name.
211     * 
212     * <p><b>Example:</b><br/> 
213     * {@code console.injectObject("test", new PyJavaInstance("a test"))}<br/>
214     * Allows the interpreter to refer to the {@link String} {@code "a test"}
215     * as {@code test}.
216     * 
217     * @param name Object name as it will appear within the interpreter.
218     * @param object Object to place in the interpreter's local namespace.
219     */
220//    public void injectObject(final String name, final PyObject pyObject) {
221//        jythonRunner.queueObject(name, pyObject);
222//    }
223    public void injectObject(final String name, final Object object) {
224        jythonRunner.queueObject(name, object);
225    }
226
227    public void ejectObjectByName(final String name) {
228        jythonRunner.queueRemoval(name);
229    }
230
231    // TODO(jon): may not need this one.
232    public void ejectObject(final PyObject pyObject) {
233        Map<String, PyObject> locals = getLocalNamespace();
234        for (Map.Entry<String, PyObject> entry : locals.entrySet()) {
235            if (pyObject == entry.getValue()) {
236                jythonRunner.queueRemoval(entry.getKey());
237            }
238        }
239    }
240
241    /**
242     * Runs the file specified by {@code path} in the {@link Interpreter}.
243     * 
244     * @param name {@code __name__} attribute to use for loading {@code path}.
245     * @param path The path to the Jython file.
246     */
247    public void runFile(final String name, final String path) {
248        jythonRunner.queueFile(name, path);
249    }
250
251    /**
252     * Displays non-error output.
253     * 
254     * @param text The message to display.
255     */
256    public void result(final String text) {
257        insert(TXT_GOOD, '\n'+text);
258    }
259
260    /**
261     * Displays an error.
262     * 
263     * @param text The error message.
264     */
265    public void error(final String text) {
266        if (!getLineText(getLineCount() - 1).trim().isEmpty()) {
267            endln(TXT_ERROR);
268        }
269        insert(TXT_ERROR, '\n'+text);
270    }
271
272    /**
273     * Shows the normal Jython prompt.
274     */
275    public void prompt() {
276        if (!getLineText(getLineCount() - 1).trim().isEmpty()) {
277            endln(TXT_NORMAL);
278        }
279        insert(TXT_NORMAL, PS1);
280    }
281
282    /**
283     * Displays non-error output that was not the result of an 
284     * {@literal "associated"} {@link Command}.
285     * 
286     * @param text The text to display.
287     * @see #generatedError(String)
288     */
289    public void generatedOutput(final String text) {
290        if (getPromptLength(getLineText(getLineCount()-1)) > 0) {
291            endln(TXT_GOOD);
292        }
293        insert(TXT_GOOD, text);
294    }
295
296    /**
297     * Displays error output. Differs from {@link #error(String)} in that this
298     * is intended for output not {@literal "associated"} with a {@link Command}.
299     * 
300     * <p>Example: say you fire off a background thread. If it generates an
301     * error somehow, this is the method you want.
302     * 
303     * @param text The error message.
304     */
305    public void generatedError(final String text) {
306        if (getPromptLength(getLineText(getLineCount()-1)) > 0) {
307            insert(TXT_ERROR, '\n'+text);
308        } else {
309            insert(TXT_ERROR, text);
310        }
311    }
312
313    /**
314     * Shows the prompt that indicates more input is needed.
315     */
316    public void moreInput() {
317        insert(TXT_NORMAL, '\n'+PS2);
318    }
319
320    public void moreInput(final int blockLevel) {
321        
322    }
323
324    /**
325     * Will eventually display an initial greeting to the user.
326     * 
327     * @throws BadLocationException Upon attempting to clear out an invalid 
328     * portion of the document.
329     */
330    private void showBanner() throws BadLocationException {
331        document.remove(0, document.getLength());
332        prompt();
333        textPane.requestFocus();
334    }
335
336    /** 
337     * Inserts a newline character at the end of the input.
338     * 
339     * @param color Perhaps this should go!?
340     */
341    protected void endln(final Color color) {
342        insert(color, "\n");
343    }
344
345    /**
346     * Does the actual work of displaying color-coded messages in 
347     * {@link #textPane}.
348     * 
349     * @param color The color of the message.
350     * @param text The actual message.
351     */
352    protected void insert(final Color color, final String text) {
353        SimpleAttributeSet style = new SimpleAttributeSet();
354        style.addAttribute(StyleConstants.Foreground, color);
355        try {
356            document.insertString(document.getLength(), text, style);
357            textPane.setCaretPosition(document.getLength());
358        } catch (BadLocationException e) {
359            logger.error("bad location", e);
360        }
361    }
362
363    protected void insertAtCaret(final Color color, final String text) {
364        assert color != null : color;
365        assert text != null : text;
366
367        int position = textPane.getCaretPosition();
368        if (!canInsertAt(position)) {
369            return;
370        }
371
372        SimpleAttributeSet style = new SimpleAttributeSet();
373        style.addAttribute(StyleConstants.Foreground, color);
374
375        try {
376            document.insertString(position, text, style);
377        } catch (BadLocationException e) {
378            logger.trace("position={}", position);
379            logger.error("couldn't insert text", e);
380        }
381    }
382
383    /**
384     * Determines whether or not {@code position} is an acceptable place to
385     * insert text. Currently the criteria for {@literal "acceptable"} means
386     * that {@code position} is located within the last (or active) line, and
387     * not within either {@link #PS1} or {@link #PS2}.
388     * 
389     * @param position Position to test. Values less than zero are not allowed.
390     * 
391     * @return Whether or not text can be inserted at {@code position}.
392     */
393    private boolean canInsertAt(final int position) {
394        assert position >= 0;
395
396        if (!onLastLine()) {
397            return false;
398        }
399
400        int lineNumber = getCaretLine();
401        String currentLine = getLineText(lineNumber);
402        int[] offsets = getLineOffsets(lineNumber);
403        logger.debug("position={} offsets[0]={} promptLen={}", position, offsets[0], getPromptLength(currentLine));
404        return (position - offsets[0]) >= getPromptLength(currentLine);
405    }
406
407    /**
408     * @return Number of lines in the document.
409     */
410    public int getLineCount() {
411        return document.getRootElements()[0].getElementCount();
412    }
413
414    // TODO(jon): Rethink some of these methods names, especially getLineOffsets and getOffsetLine!!
415
416    public int getLineOffsetStart(final int lineNumber) {
417        return document.getRootElements()[0].getElement(lineNumber).getStartOffset();
418    }
419
420    public int getLineOffsetEnd(final int lineNumber) {
421        return document.getRootElements()[0].getElement(lineNumber).getEndOffset();
422    }
423
424    public int[] getLineOffsets(final int lineNumber) {
425        if (lineNumber >= getLineCount()) {
426            return BAD_OFFSETS;
427        }
428        // TODO(jon): possible inline these calls?
429        int start = getLineOffsetStart(lineNumber);
430        int end = getLineOffsetEnd(lineNumber);
431        return new int[] { start, end };
432    }
433
434    /**
435     * Returns the line number that contains the specified offset.
436     * 
437     * @param offset Offset whose line number you want.
438     * 
439     * @return Line number.
440     */
441    public int getOffsetLine(final int offset) {
442        return document.getRootElements()[0].getElementIndex(offset);
443    }
444
445    /**
446     * Returns the offsets of the beginning and end of the last line.
447     */
448    private int[] locateLastLine() {
449        return getLineOffsets(getLineCount() - 1);
450    }
451
452    /**
453     * Determines whether or not the caret is on the last line.
454     */
455    private boolean onLastLine() {
456        int[] offsets = locateLastLine();
457        int position = textPane.getCaretPosition();
458        return position >= offsets[0] && position <= offsets[1];
459    }
460
461    /**
462     * @return The line number of the caret's offset within the text.
463     */
464    public int getCaretLine() {
465        return getOffsetLine(textPane.getCaretPosition());
466    }
467
468    /**
469     * Returns the line of text that occupies the specified line number.
470     * 
471     * @param lineNumber Line number whose text is to be returned.
472     * 
473     * @return Either the line of text or null if there was an error.
474     */
475    public String getLineText(final int lineNumber) {
476        int start = getLineOffsetStart(lineNumber);
477        int stop = getLineOffsetEnd(lineNumber);
478        String line = null;
479        try {
480            line = document.getText(start, stop - start);
481        } catch (BadLocationException e) {
482            logger.error("Error getting text", e);
483        }
484        return line;
485    }
486
487    /**
488     * Returns the line of Jython that occupies a specified line number. 
489     * This is different than {@link #getLineText(int)} in that both 
490     * {@link #PS1} and {@link #PS2} are removed from the returned line.
491     * 
492     * @param lineNumber Line number whose text is to be returned.
493     * 
494     * @return Either the line of Jython or null if there was an error.
495     */
496    public String getLineJython(final int lineNumber) {
497        String text = getLineText(lineNumber);
498        if (text == null) {
499            return null;
500        }
501        int start = getPromptLength(text);
502        return text.substring(start, text.length() - 1);
503    }
504
505    /**
506     * Returns the length of {@link #PS1} or {@link #PS2} depending on the 
507     * contents of the specified line.
508     * 
509     * @param line The line in question. Cannot be {@code null}.
510     * 
511     * @return Either the prompt length or zero if there was none.
512     * 
513     * @throws NullPointerException if {@code line} is {@code null}.
514     */
515    public static int getPromptLength(final String line) {
516        requireNonNull(line, "Null lines do not have prompt lengths");
517        if (line.startsWith(PS1)) {
518            return PS1.length();
519        } else if (line.startsWith(PS2)) {
520            return PS2.length();
521        } else {
522            return 0;
523        }
524    }
525
526    /**
527     * Returns the {@literal "block depth"} of a given line of Jython.
528     * 
529     * <p>Examples:<pre>
530     * "print 'x'"         -> 0
531     * "    print 'x'"     -> 1
532     * "            die()" -> 3
533     * </pre>
534     * 
535     * @param line Line to test. Can't be {@code null}.
536     * @param whitespace The indent {@link String} used with {@code line}. Can't be {@code null}.
537     * 
538     * @return Either the block depth ({@code >= 0}) or {@code -1} if there was an error.
539     */
540    // TODO(jon): maybe need to explicitly use getLineJython?
541    public static int getBlockDepth(final String line, final String whitespace) {
542        int indent = whitespace.length();
543        int blockDepth = 0;
544        int tmpIndex = 0;
545        while ((tmpIndex+indent) < line.length()) {
546            int stop = tmpIndex + indent;
547            if (!line.substring(tmpIndex, stop).trim().isEmpty()) {
548                break;
549            }
550            tmpIndex += indent;
551            blockDepth++;
552        }
553        return blockDepth;
554    }
555
556    /**
557     * Registers a new callback handler with the console. Note that to maximize
558     * utility, this method also registers the same handler with 
559     * {@link #jythonRunner}.
560     * 
561     * @param newHandler The new callback handler.
562     * 
563     * @throws NullPointerException if the new handler is null.
564     */
565    public void setCallbackHandler(final ConsoleCallback newHandler) {
566        requireNonNull(newHandler, "Callback handler cannot be null");
567        jythonRunner.setCallbackHandler(newHandler);
568    }
569
570    public Set<String> getJythonReferencesTo(final Object obj) {
571        requireNonNull(obj, "Cannot find references to a null object");
572        Set<String> refs = new TreeSet<String>();
573        // TODO(jon): possibly inline getJavaInstances()?
574        for (Map.Entry<String, Object> entry : getJavaInstances().entrySet()) {
575            if (obj == entry.getValue()) {
576                refs.add(entry.getKey());
577            }
578        }
579        return refs;
580    }
581
582    /**
583     * Returns a subset of Jython's local namespace containing only variables
584     * that are {@literal "pure"} Java objects.
585     * 
586     * @return Jython variable names mapped to their Java instantiation.
587     */
588    public Map<String, Object> getJavaInstances() {
589        Map<String, Object> javaMap = new HashMap<>();
590        Map<String, PyObject> locals = getLocalNamespace();
591        for (Map.Entry<String, PyObject> entry : locals.entrySet()) {
592            PyObject val = entry.getValue();
593            if (val instanceof PyObjectDerived) {
594                PyObjectDerived derived = (PyObjectDerived)val;
595                if (derived.getType() instanceof PyJavaType) {
596                    javaMap.put(entry.getKey(), val.__tojava__(Object.class));
597                }
598            }
599        }
600        return javaMap;
601    }
602
603    /**
604     * Retrieves the specified Jython variable from the interpreters local 
605     * namespace.
606     * 
607     * @param var Variable name to retrieve.
608     * @return Either the variable or null. Note that null will also be 
609     * returned if {@link Runner#copyLocals()} returned null.
610     */
611    public PyObject getJythonObject(final String var) {
612        PyStringMap locals = jythonRunner.copyLocals();
613        if (locals == null) {
614            return null;
615        }
616        return locals.__finditem__(var);
617    }
618
619    /**
620     * Returns a copy of Jython's local namespace.
621     * 
622     * @return Jython variable names mapped to {@link PyObject}s.
623     */
624    public Map<String, PyObject> getLocalNamespace() {
625        Map<String, PyObject> localsMap = new HashMap<>();
626        PyStringMap jythonLocals = jythonRunner.copyLocals();
627        if (jythonLocals != null) {
628            PyList items = jythonLocals.items();
629            for (int i = 0; i < items.__len__(); i++) {
630                PyTuple tuple = (PyTuple)items.__finditem__(i);
631                String key = ((PyString)tuple.__finditem__(0)).toString();
632                PyObject val = tuple.__finditem__(1);
633                localsMap.put(key, val);
634            }
635        }
636        return localsMap;
637    }
638
639    public void handlePaste() {
640        logger.trace("not terribly sure...");
641        getTextPane().paste();
642        logger.trace("after forcing paste!");
643    }
644
645    /**
646     * Handles the user hitting the {@code Home} key. If the caret is on a line
647     * that begins with either {@link #PS1} or {@link #PS2}, the caret will be
648     * moved to just after the prompt. This is done mostly to emulate CPython's
649     * IDLE.
650     */
651    public void handleHome() {
652        int caretPosition = getCaretLine();
653        int[] offsets = getLineOffsets(caretPosition);
654        int linePosition = getPromptLength(getLineText(caretPosition));
655        textPane.setCaretPosition(offsets[0] + linePosition);
656    }
657
658    /**
659     * Moves the caret to the end of the line it is currently on, rather than
660     * the end of the document.
661     */
662    public void handleEnd() {
663        int[] offsets = getLineOffsets(getCaretLine());
664        textPane.setCaretPosition(offsets[1] - 1);
665    }
666
667    public void handleUp() {
668        logger.trace("handleUp");
669    }
670
671    public void handleDown() {
672        logger.trace("handleDown");
673    }
674
675    /**
676     * Inserts the contents of {@link #WHITESPACE} wherever the cursor is 
677     * located.
678     */
679    // TODO(jon): completion!
680    public void handleTab() {
681        logger.trace("handling tab!");
682        insertAtCaret(TXT_NORMAL, WHITESPACE);
683    }
684
685    // TODO(jon): what about selected regions?
686    // TODO(jon): what about multi lines?
687    public void handleDelete() {
688        if (!onLastLine()) {
689            return;
690        }
691
692        String line = getLineText(getCaretLine());
693        if (line == null) {
694            return;
695        }
696
697        int position = textPane.getCaretPosition();
698        int start = getPromptLength(line);
699
700        // don't let the user delete parts of PS1 or PS2
701        int lineStart = getLineOffsetStart(getCaretLine());
702        if (((position-1)-lineStart) < start) {
703            return;
704        }
705
706        try {
707            document.remove(position - 1, 1);
708        } catch (BadLocationException e) {
709            logger.error("failed to backspace at position={}", (position-1));
710        }
711    }
712
713    /**
714     * Handles the user pressing enter by basically grabbing the line of jython
715     * under the caret. If the caret is on the last line, the line is queued
716     * for execution. Otherwise the line is reinserted at the end of the 
717     * document--this lets the user preview a previous command before they 
718     * rerun it.
719     */
720    // TODO(jon): if you hit enter at the start of a block, maybe it should
721    // replicate the enter block at the end of the document?
722    public void handleEnter() {
723        String line = getLineJython(getCaretLine());
724        if (line == null) {
725            line = "";
726        }
727
728        if (onLastLine()) {
729            queueLine(line);
730        } else {
731            insert(TXT_NORMAL, line);
732        }
733    }
734
735    /**
736     * Returns the Jython statements as entered by the user, ordered from first
737     * to last.
738     * 
739     * @return User's history.
740     */
741    public List<String> getHistory() {
742        return new ArrayList<>(jythonHistory);
743    }
744
745    /**
746     * Sends a line of Jython to the interpreter via {@link #jythonRunner} and
747     * saves it to the history.
748     * 
749     * @param line Jython to queue for execution.
750     */
751    public void queueLine(final String line) {
752        jythonRunner.queueLine(line);
753        jythonHistory.add(line);
754    }
755
756    /**
757     * Sends a batch of Jython commands to the interpreter. <i>This is 
758     * different than simply calling {@link #queueLine(String)} for each 
759     * command;</i> the interpreter will attempt to execute each batched 
760     * command before returning {@literal "control"} to the console.
761     * 
762     * <p>This method is mostly useful for restoring Console sessions. Each
763     * command in {@code commands} will appear in the console as though the
764     * user typed it. The batch of commands will also be saved to the history.
765     * 
766     * @param name Identifier for the batch. Doesn't need to be unique, merely
767     * non-null.
768     * @param commands The commands to execute.
769     */
770    public void queueBatch(final String name, final List<String> commands) {
771//        jythonRunner.queueBatch(this, name, commands);
772        jythonRunner.queueBatch(name, commands);
773        jythonHistory.addAll(commands);
774    }
775
776    public void addPretendHistory(final String line) {
777        jythonHistory.add(line);
778    }
779
780    /**
781     * Puts together the GUI once EventQueue has processed all other pending 
782     * events.
783     */
784    public void run() {
785        JFrame frame = new JFrame(windowTitle);
786        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
787        frame.getContentPane().add(getPanel());
788        frame.getContentPane().setPreferredSize(new Dimension(600, 200));
789        frame.pack();
790        frame.setVisible(true);
791    }
792
793    /**
794     * Noop.
795     */
796    public void keyPressed(final KeyEvent e) { }
797
798    /**
799     * Noop.
800     */
801    public void keyReleased(final KeyEvent e) { }
802
803    // this is weird: hasAction is always false
804    // seems to work so long as the ConsoleActions fire first...
805    // might want to look at default actions again
806    public void keyTyped(final KeyEvent e) {
807        logger.trace("hasAction={} key={}", hasAction(textPane, e), e.getKeyChar());
808        int caretPosition = textPane.getCaretPosition();
809        if (!hasAction(textPane, e) && !canInsertAt(caretPosition)) {
810            logger.trace("hasAction={} lastLine={}", hasAction(textPane, e), onLastLine());
811            e.consume();
812        }
813    }
814
815    private static boolean hasAction(final JTextPane jtp, final KeyEvent e) {
816        assert jtp != null;
817        assert e != null;
818        KeyStroke stroke = 
819            KeyStroke.getKeyStroke(e.getKeyCode(), e.getModifiers());
820        return jtp.getKeymap().getAction(stroke) != null;
821    }
822
823    /**
824     * Maps a {@literal "jython action"} to a keystroke.
825     */
826    public enum Actions {
827        TAB("jython.tab", KeyEvent.VK_TAB, 0),
828        DELETE("jython.delete", KeyEvent.VK_BACK_SPACE, 0),
829        END("jython.end", KeyEvent.VK_END, 0),
830        ENTER("jython.enter", KeyEvent.VK_ENTER, 0),
831        HOME("jython.home", KeyEvent.VK_HOME, 0),
832        PASTE("jython.paste", KeyEvent.VK_V, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()),
833        //        PASTE("jython.paste", KeyEvent.VK_V, KeyEvent.CTRL_MASK);
834//        UP("jython.up", KeyEvent.VK_UP, 0),
835//        DOWN("jython.down", KeyEvent.VK_DOWN, 0);
836        ;
837
838        private final String id;
839        private final int keyCode;
840        private final int modifier;
841
842        Actions(final String id, final int keyCode, final int modifier) {
843            this.id = id;
844            this.keyCode = keyCode;
845            this.modifier = modifier;
846        }
847
848        public String getId() {
849            return id;
850        }
851
852        public KeyStroke getKeyStroke() {
853            return KeyStroke.getKeyStroke(keyCode, modifier);
854        }
855    }
856
857    public static class HistoryEntry {
858        private HistoryType type;
859        private String entry;
860
861        public HistoryEntry() {}
862
863        public HistoryEntry(final HistoryType type, final String entry) {
864            this.type = requireNonNull(type, "type cannot be null");
865            this.entry = requireNonNull(entry, "entry cannot be null");
866        }
867
868        public void setEntry(final String entry) {
869            this.entry = requireNonNull(entry, "entry cannot be null");
870        }
871
872        public void setType(final HistoryType type) {
873            this.type = requireNonNull(type, "type cannot be null");
874        }
875
876        public String getEntry() {
877            return entry;
878        }
879
880        public HistoryType getType() {
881            return type;
882        }
883
884        @Override public String toString() {
885            return String.format("[HistoryEntry@%x: type=%s, entry=\"%s\"]", 
886                hashCode(), type, entry);
887        }
888    }
889
890    private class PopupListener extends MouseAdapter {
891        public void mouseClicked(final MouseEvent e) {
892            checkPopup(e);
893        }
894
895        public void mousePressed(final MouseEvent e) {
896            checkPopup(e);
897        }
898
899        public void mouseReleased(final MouseEvent e) {
900            checkPopup(e);
901        }
902
903        private void checkPopup(final MouseEvent e) {
904            if (!e.isPopupTrigger()) {
905                return;
906            }
907            JPopupMenu popup = menuWrangler.buildMenu();
908            popup.show(textPane, e.getX(), e.getY());
909        }
910    }
911
912    public static String getUserPath(String[] args) {
913        for (int i = 0; i < args.length; i++) {
914            if ("-userpath".equals(args[i]) && (i+1) < args.length) {
915                return args[i+1];
916            }
917        }
918        return System.getProperty("user.home");
919    }
920
921    public static void main(String[] args) {
922        String os = System.getProperty("os.name");
923        String sep = "/";
924        if (os.startsWith("Windows")) {
925            sep = "\\";
926        }
927        String pythonHome = getUserPath(args);
928
929        Properties systemProperties = System.getProperties();
930        Properties jythonProperties = new Properties();
931        jythonProperties.setProperty("python.home", pythonHome+sep+"jython");
932        Interpreter.initialize(systemProperties, jythonProperties, new String[]{""});
933        EventQueue.invokeLater(new Console());
934        EventQueue.invokeLater(new Console());
935    }
936}