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