001package apps;
002
003import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
004
005import java.awt.BorderLayout;
006import java.awt.Color;
007import java.awt.Font;
008import java.awt.datatransfer.Clipboard;
009import java.awt.datatransfer.StringSelection;
010import java.awt.event.ActionEvent;
011import java.awt.event.MouseAdapter;
012import java.awt.event.MouseEvent;
013import java.awt.event.MouseListener;
014import java.io.IOException;
015import java.io.OutputStream;
016import java.io.PrintStream;
017import java.lang.reflect.InvocationTargetException;
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.Map;
021import java.util.ResourceBundle;
022
023import javax.swing.ButtonGroup;
024import javax.swing.JButton;
025import javax.swing.JCheckBox;
026import javax.swing.JFrame;
027import javax.swing.JMenu;
028import javax.swing.JMenuItem;
029import javax.swing.JPanel;
030import javax.swing.JPopupMenu;
031import javax.swing.JRadioButtonMenuItem;
032import javax.swing.JScrollPane;
033import javax.swing.JSeparator;
034import javax.swing.SwingUtilities;
035
036import jmri.UserPreferencesManager;
037import jmri.util.JmriJFrame;
038import jmri.util.swing.TextAreaFIFO;
039
040/**
041 * Class to direct standard output and standard error to a ( JTextArea ) TextAreaFIFO .
042 * This allows for easier clipboard operations etc.
043 * <hr>
044 * This file is part of JMRI.
045 * <p>
046 * JMRI is free software; you can redistribute it and/or modify it under the
047 * terms of version 2 of the GNU General Public License as published by the Free
048 * Software Foundation. See the "COPYING" file for a copy of this license.
049 * <p>
050 * JMRI is distributed in the hope that it will be useful, but WITHOUT ANY
051 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
052 * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
053 *
054 * @author Matthew Harris copyright (c) 2010, 2011, 2012
055 */
056public final class SystemConsole {
057
058    /**
059     * Get current SystemConsole instance.
060     * If one doesn't yet exist, create it.
061     * @return current SystemConsole instance
062     */
063    public static SystemConsole getInstance() {
064        return InstanceHolder.INSTANCE;
065    }
066
067    private static class InstanceHolder {
068        private static final SystemConsole INSTANCE;
069
070        static {
071            SystemConsole instance = null;
072            try {
073                instance = new SystemConsole();
074            } catch (RuntimeException ex) {
075                log.error("failed to complete Console redirection", ex);
076            }
077            INSTANCE = instance;
078        }
079    }
080
081    static final ResourceBundle rbc = ResourceBundle.getBundle("apps.AppsConfigBundle"); // NOI18N
082
083    private static final int STD_ERR = 1;
084    private static final int STD_OUT = 2;
085
086    private final TextAreaFIFO console;
087
088    private final PrintStream originalOut;
089    private final PrintStream originalErr;
090
091    private final PrintStream outputStream;
092    private final PrintStream errorStream;
093
094    private JmriJFrame frame = null;
095
096    private final JPopupMenu popup = new JPopupMenu();
097
098    private JMenuItem copySelection = null;
099
100    private JMenu wrapMenu = null;
101    private ButtonGroup wrapGroup = null;
102
103    private JMenu schemeMenu = null;
104    private ButtonGroup schemeGroup = null;
105
106    private ArrayList<Scheme> schemes;
107
108    private int scheme = 0; // Green on Black
109
110    private int fontSize = 12;
111
112    private int fontStyle = Font.PLAIN;
113
114    private static final String FONT_FAMILY = "Monospaced";
115
116    public static final int WRAP_STYLE_NONE = 0x00;
117    public static final int WRAP_STYLE_LINE = 0x01;
118    public static final int WRAP_STYLE_WORD = 0x02;
119
120    private int wrapStyle = WRAP_STYLE_WORD;
121
122    private final String alwaysScrollCheck = this.getClass().getName() + ".alwaysScroll"; // NOI18N
123    private final String alwaysOnTopCheck = this.getClass().getName() + ".alwaysOnTop";   // NOI18N
124
125    public int MAX_CONSOLE_LINES = 5000;  // public, not static so can be modified via a script
126
127    /**
128     * Initialise the system console ensuring both System.out and System.err
129     * streams are re-directed to the consoles JTextArea
130     */
131
132    @SuppressFBWarnings(value = "DM_DEFAULT_ENCODING",
133            justification = "Can only be called from the same instance so default encoding OK")
134    private SystemConsole() {
135        // Record current System.out and System.err
136        // so that we can still send to them
137        originalOut = System.out;
138        originalErr = System.err;
139
140        // Create the console text area
141        console = new TextAreaFIFO(MAX_CONSOLE_LINES);
142
143        // Setup the console text area
144        console.setRows(20);
145        console.setColumns(120);
146        console.setFont(new Font(FONT_FAMILY, fontStyle, fontSize));
147        console.setEditable(false);
148        setScheme(scheme);
149        setWrapStyle(wrapStyle);
150
151        this.outputStream = new PrintStream(outStream(STD_OUT), true);
152        this.errorStream = new PrintStream(outStream(STD_ERR), true);
153
154        // Then redirect to it
155        redirectSystemStreams(outputStream, errorStream);
156    }
157
158    /**
159     * Return the JFrame containing the console
160     *
161     * @return console JFrame
162     */
163    public static JFrame getConsole() {
164        return SystemConsole.getInstance().getFrame();
165    }
166
167    public JFrame getFrame() {
168
169        // Check if we've created the frame and do so if not
170        if (frame == null) {
171            log.debug("Creating frame for console");
172            // To avoid possible locks, frame layout should be
173            // performed on the Swing thread
174            if (SwingUtilities.isEventDispatchThread()) {
175                createFrame();
176            } else {
177                try {
178                    // Use invokeAndWait method as we don't want to
179                    // return until the frame layout is completed
180                    SwingUtilities.invokeAndWait(this::createFrame);
181                } catch (InvocationTargetException ex) {
182                    log.error("Invocation Exception creating system console frame", ex);
183                } catch (InterruptedException ex) {
184                    log.error("Interrupt Exception creating system console frame", ex);
185                    Thread.currentThread().interrupt();
186                }
187            }
188            log.debug("Frame created");
189        }
190
191        return frame;
192    }
193
194    /**
195     * Layout the console frame
196     */
197    private void createFrame() {
198        // Use a JmriJFrame to ensure that we fit on the screen
199        frame = new JmriJFrame(Bundle.getMessage("TitleConsole"));
200
201        UserPreferencesManager pref = jmri.InstanceManager.getDefault(UserPreferencesManager.class);
202
203        // Add Help menu (Windows menu automaitically added)
204        frame.addHelpMenu("package.apps.SystemConsole", true); // NOI18N
205
206        // Grab a reference to the system clipboard
207        final Clipboard clipboard = frame.getToolkit().getSystemClipboard();
208
209        // Setup the scroll pane
210        JScrollPane scroll = new JScrollPane(console);
211        frame.add(scroll, BorderLayout.CENTER);
212
213
214        JPanel p = new JPanel();
215
216        // Add button to clear display
217        JButton clear = new JButton(Bundle.getMessage("ButtonClear"));
218        clear.addActionListener( e -> console.setText(""));
219        clear.setToolTipText(Bundle.getMessage("ButtonClearTip"));
220        p.add(clear);
221
222        // Add button to allow copy to clipboard
223        JButton copy = new JButton(Bundle.getMessage("ButtonCopyClip"));
224        copy.addActionListener( e -> {
225            StringSelection text = new StringSelection(console.getText());
226            clipboard.setContents(text, text);
227        });
228        p.add(copy);
229
230        // Add button to allow console window to be closed
231        JButton close = new JButton(Bundle.getMessage("ButtonClose"));
232        close.addActionListener( e -> {
233            frame.setVisible(false);
234            console.dispose();
235            frame.dispose();
236        });
237        p.add(close);
238
239        JButton stackTrace = new JButton(Bundle.getMessage("ButtonStackTrace"));
240        stackTrace.addActionListener( e -> performStackTrace());
241        p.add(stackTrace);
242
243        // Add checkbox to enable/disable auto-scrolling
244        // Use the inverted SimplePreferenceState to default as enabled
245        JCheckBox autoScroll = new JCheckBox(Bundle.getMessage("CheckBoxAutoScroll"));
246        p.add( autoScroll, !pref.getSimplePreferenceState(alwaysScrollCheck));
247        console.setAutoScroll(autoScroll.isSelected());
248        autoScroll.addActionListener((ActionEvent event) -> {
249            console.setAutoScroll(autoScroll.isSelected());
250            pref.setSimplePreferenceState(alwaysScrollCheck, !autoScroll.isSelected());
251        });
252
253        // Add checkbox to enable/disable always on top
254        JCheckBox alwaysOnTop = new JCheckBox(Bundle.getMessage("CheckBoxOnTop"));
255        p.add( alwaysOnTop, pref.getSimplePreferenceState(alwaysOnTopCheck));
256        alwaysOnTop.setVisible(true);
257        alwaysOnTop.setToolTipText(Bundle.getMessage("ToolTipOnTop"));
258        alwaysOnTop.addActionListener((ActionEvent event) -> {
259            frame.setAlwaysOnTop(alwaysOnTop.isSelected());
260            pref.setSimplePreferenceState(alwaysOnTopCheck, alwaysOnTop.isSelected());
261        });
262
263        frame.setAlwaysOnTop(alwaysOnTop.isSelected());
264
265        // Define the pop-up menu
266        copySelection = new JMenuItem(Bundle.getMessage("MenuItemCopy"));
267        copySelection.addActionListener((ActionEvent event) -> {
268            StringSelection text = new StringSelection(console.getSelectedText());
269            clipboard.setContents(text, text);
270        });
271        popup.add(copySelection);
272
273        JMenuItem menuItem = new JMenuItem(Bundle.getMessage("ButtonCopyClip"));
274        menuItem.addActionListener((ActionEvent event) -> {
275            StringSelection text = new StringSelection(console.getText());
276            clipboard.setContents(text, text);
277        });
278        popup.add(menuItem);
279
280        popup.add(new JSeparator());
281
282        JRadioButtonMenuItem rbMenuItem;
283
284        // Define the colour scheme sub-menu
285        schemeMenu = new JMenu(rbc.getString("ConsoleSchemeMenu"));
286        schemeGroup = new ButtonGroup();
287        for (final Scheme s : schemes) {
288            rbMenuItem = new JRadioButtonMenuItem(s.description);
289            rbMenuItem.addActionListener( e -> setScheme(schemes.indexOf(s)));
290            rbMenuItem.setSelected(getScheme() == schemes.indexOf(s));
291            schemeMenu.add(rbMenuItem);
292            schemeGroup.add(rbMenuItem);
293        }
294        popup.add(schemeMenu);
295
296        // Define the wrap style sub-menu
297        wrapMenu = new JMenu(rbc.getString("ConsoleWrapStyleMenu"));
298        wrapGroup = new ButtonGroup();
299        rbMenuItem = new JRadioButtonMenuItem(rbc.getString("ConsoleWrapStyleNone"));
300        rbMenuItem.addActionListener( e -> setWrapStyle(WRAP_STYLE_NONE));
301        rbMenuItem.setSelected(getWrapStyle() == WRAP_STYLE_NONE);
302        wrapMenu.add(rbMenuItem);
303        wrapGroup.add(rbMenuItem);
304
305        rbMenuItem = new JRadioButtonMenuItem(rbc.getString("ConsoleWrapStyleLine"));
306        rbMenuItem.addActionListener( e -> setWrapStyle(WRAP_STYLE_LINE));
307        rbMenuItem.setSelected(getWrapStyle() == WRAP_STYLE_LINE);
308        wrapMenu.add(rbMenuItem);
309        wrapGroup.add(rbMenuItem);
310
311        rbMenuItem = new JRadioButtonMenuItem(rbc.getString("ConsoleWrapStyleWord"));
312        rbMenuItem.addActionListener( e -> setWrapStyle(WRAP_STYLE_WORD));
313        rbMenuItem.setSelected(getWrapStyle() == WRAP_STYLE_WORD);
314        wrapMenu.add(rbMenuItem);
315        wrapGroup.add(rbMenuItem);
316
317        popup.add(wrapMenu);
318
319        // Bind pop-up to objects
320        MouseListener popupListener = new PopupListener();
321        console.addMouseListener(popupListener);
322        frame.addMouseListener(popupListener);
323
324        // Add the button panel to the frame & then arrange everything
325        frame.add(p, BorderLayout.SOUTH);
326        frame.pack();
327    }
328
329    /**
330     * Add text to the console
331     *
332     * @param text  the text to add
333     * @param which the stream that this text is for
334     */
335    private void updateTextArea(final String text, final int which) {
336        // Append message to the original System.out / System.err streams
337        if (which == STD_OUT) {
338            originalOut.append(text);
339        } else if (which == STD_ERR) {
340            originalErr.append(text);
341        }
342
343        // Now append to the JTextArea
344        SwingUtilities.invokeLater(() -> {
345            synchronized (SystemConsole.this) {
346                console.append(text);            }
347        });
348
349    }
350
351    /**
352     * Creates a new OutputStream for the specified stream
353     *
354     * @param which the stream, either STD_OUT or STD_ERR
355     * @return the new OutputStream
356     */
357    private OutputStream outStream(final int which) {
358        return new OutputStream() {
359            @Override
360            public void write(int b) throws IOException {
361                updateTextArea(String.valueOf((char) b), which);
362            }
363
364            @Override
365            @SuppressFBWarnings(value = "DM_DEFAULT_ENCODING",
366                    justification = "Can only be called from the same instance so default encoding OK")
367            public void write(byte[] b, int off, int len) throws IOException {
368                updateTextArea(new String(b, off, len), which);
369            }
370
371            @Override
372            public void write(byte[] b) throws IOException {
373                write(b, 0, b.length);
374            }
375        };
376    }
377
378    /**
379     * Method to redirect the system streams to the console
380     */
381    @SuppressFBWarnings(value = "DM_DEFAULT_ENCODING",
382            justification = "Can only be called from the same instance so default encoding OK")
383    private void redirectSystemStreams(PrintStream out, PrintStream err) {
384        System.setOut(out);
385        System.setErr(err);
386    }
387
388    /**
389     * Set the console wrapping style to one of the following:
390     *
391     * @param style one of the defined style attributes - one of
392     * <ul>
393     * <li>{@link #WRAP_STYLE_NONE} No wrapping
394     * <li>{@link #WRAP_STYLE_LINE} Wrap at end of line
395     * <li>{@link #WRAP_STYLE_WORD} Wrap by word boundaries
396     * </ul>
397     */
398    public void setWrapStyle(int style) {
399        wrapStyle = style;
400        console.setLineWrap(style != WRAP_STYLE_NONE);
401        console.setWrapStyleWord(style == WRAP_STYLE_WORD);
402
403        if (wrapGroup != null) {
404            wrapGroup.setSelected(wrapMenu.getItem(style).getModel(), true);
405        }
406    }
407
408    /**
409     * Retrieve the current console wrapping style
410     *
411     * @return current wrapping style - one of
412     * <ul>
413     * <li>{@link #WRAP_STYLE_NONE} No wrapping
414     * <li>{@link #WRAP_STYLE_LINE} Wrap at end of line
415     * <li>{@link #WRAP_STYLE_WORD} Wrap by word boundaries (default)
416     * </ul>
417     */
418    public int getWrapStyle() {
419        return wrapStyle;
420    }
421
422    /**
423     * Set the console font size
424     *
425     * @param size point size of font between 6 and 24 point
426     */
427    public void setFontSize(int size) {
428        updateFont(FONT_FAMILY, fontStyle, (fontSize = size < 6 ? 6 : size > 24 ? 24 : size));
429    }
430
431    /**
432     * Retrieve the current console font size (default 12 point)
433     *
434     * @return selected font size in points
435     */
436    public int getFontSize() {
437        return fontSize;
438    }
439
440    /**
441     * Set the console font style
442     *
443     * @param style one of
444     *              {@link Font#BOLD}, {@link Font#ITALIC}, {@link Font#PLAIN}
445     *              (default)
446     */
447    public void setFontStyle(int style) {
448
449        if (style == Font.BOLD || style == Font.ITALIC || style == Font.PLAIN || style == (Font.BOLD | Font.ITALIC)) {
450            fontStyle = style;
451        } else {
452            fontStyle = Font.PLAIN;
453        }
454        updateFont(FONT_FAMILY, fontStyle, fontSize);
455    }
456
457    /**
458     * Retrieve the current console font style
459     *
460     * @return selected font style - one of
461     *         {@link Font#BOLD}, {@link Font#ITALIC}, {@link Font#PLAIN}
462     *         (default)
463     */
464    public int getFontStyle() {
465        return fontStyle;
466    }
467
468    /**
469     * Update the system console font with the specified parameters
470     *
471     * @param style font style
472     * @param size  font size
473     */
474    private void updateFont(String family, int style, int size) {
475        console.setFont(new Font(family, style, size));
476    }
477
478    /**
479     * Method to define console colour schemes
480     */
481    private void defineSchemes() {
482        schemes = new ArrayList<>();
483        schemes.add(new Scheme(rbc.getString("ConsoleSchemeGreenOnBlack"), Color.GREEN, Color.BLACK));
484        schemes.add(new Scheme(rbc.getString("ConsoleSchemeOrangeOnBlack"), Color.ORANGE, Color.BLACK));
485        schemes.add(new Scheme(rbc.getString("ConsoleSchemeWhiteOnBlack"), Color.WHITE, Color.BLACK));
486        schemes.add(new Scheme(rbc.getString("ConsoleSchemeBlackOnWhite"), Color.BLACK, Color.WHITE));
487        schemes.add(new Scheme(rbc.getString("ConsoleSchemeWhiteOnBlue"), Color.WHITE, Color.BLUE));
488        schemes.add(new Scheme(rbc.getString("ConsoleSchemeBlackOnLightGray"), Color.BLACK, Color.LIGHT_GRAY));
489        schemes.add(new Scheme(rbc.getString("ConsoleSchemeBlackOnGray"), Color.BLACK, Color.GRAY));
490        schemes.add(new Scheme(rbc.getString("ConsoleSchemeWhiteOnGray"), Color.WHITE, Color.GRAY));
491        schemes.add(new Scheme(rbc.getString("ConsoleSchemeWhiteOnDarkGray"), Color.WHITE, Color.DARK_GRAY));
492        schemes.add(new Scheme(rbc.getString("ConsoleSchemeGreenOnDarkGray"), Color.GREEN, Color.DARK_GRAY));
493        schemes.add(new Scheme(rbc.getString("ConsoleSchemeOrangeOnDarkGray"), Color.ORANGE, Color.DARK_GRAY));
494    }
495
496    @SuppressWarnings("deprecation")    // The method getId() from the type Thread is deprecated since version 19
497                                        // The replacement Thread.threadId() isn't available before version 19
498    private void performStackTrace() {
499        System.out.println("----------- Begin Stack Trace -----------"); //NO18N
500        System.out.println("-----------------------------------------"); //NO18N
501        Map<Thread, StackTraceElement[]> traces = new HashMap<>(Thread.getAllStackTraces());
502        for (Thread thread : traces.keySet()) {
503            System.out.println("[" + thread.getId() + "] " + thread.getName());
504            for (StackTraceElement el : thread.getStackTrace()) {
505                System.out.println("  " + el);
506            }
507            System.out.println("-----------------------------------------"); //NO18N
508        }
509        System.out.println("-----------  End Stack Trace  -----------"); //NO18N
510    }
511
512    /**
513     * Set the console colour scheme
514     *
515     * @param which the scheme to use
516     */
517    public void setScheme(int which) {
518        scheme = which;
519
520        if (schemes == null) {
521            defineSchemes();
522        }
523
524        Scheme s;
525
526        try {
527            s = schemes.get(which);
528        } catch (IndexOutOfBoundsException ex) {
529            s = schemes.get(0);
530            scheme = 0;
531        }
532
533        console.setForeground(s.foreground);
534        console.setBackground(s.background);
535
536        if (schemeGroup != null) {
537            schemeGroup.setSelected(schemeMenu.getItem(scheme).getModel(), true);
538        }
539    }
540
541    public PrintStream getOutputStream() {
542        return this.outputStream;
543    }
544
545    public PrintStream getErrorStream() {
546        return this.errorStream;
547    }
548
549    /**
550     * Stop logging System output and error streams to the console.
551     */
552    public void close() {
553        redirectSystemStreams(originalOut, originalErr);
554    }
555
556    /**
557     * Start logging System output and error streams to the console.
558     */
559    public void open() {
560        redirectSystemStreams(getOutputStream(), getErrorStream());
561    }
562
563    /**
564     * Retrieve the current console colour scheme
565     *
566     * @return selected colour scheme
567     */
568    public int getScheme() {
569        return scheme;
570    }
571
572    public Scheme[] getSchemes() {
573        return this.schemes.toArray(new Scheme[this.schemes.size()]);
574        // return this.schemes.toArray(Scheme[]::new);
575        // It should be possible to use the line above, however causes eclipse compilation error
576        // Annotation type 'org.eclipse.jdt.annotation.NonNull' cannot be found on the build path,
577        // which is implicitly needed for null analysis.
578    }
579
580    /**
581     * Class holding details of each scheme
582     */
583    public static final class Scheme {
584
585        public Color foreground;
586        public Color background;
587        public String description;
588
589        Scheme(String description, Color foreground, Color background) {
590            this.foreground = foreground;
591            this.background = background;
592            this.description = description;
593        }
594    }
595
596    /**
597     * Class to deal with handling popup menu
598     */
599    public final class PopupListener extends MouseAdapter {
600
601        @Override
602        public void mousePressed(MouseEvent e) {
603            maybeShowPopup(e);
604        }
605
606        @Override
607        public void mouseReleased(MouseEvent e) {
608            maybeShowPopup(e);
609        }
610
611        private void maybeShowPopup(MouseEvent e) {
612            if (e.isPopupTrigger()) {
613                copySelection.setEnabled(console.getSelectionStart() != console.getSelectionEnd());
614                popup.show(e.getComponent(), e.getX(), e.getY());
615            }
616        }
617    }
618
619    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(SystemConsole.class);
620
621}