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    private void redirectSystemStreams(PrintStream out, PrintStream err) {
382        System.setOut(out);
383        System.setErr(err);
384    }
385
386    /**
387     * Set the console wrapping style to one of the following:
388     *
389     * @param style one of the defined style attributes - one of
390     * <ul>
391     * <li>{@link #WRAP_STYLE_NONE} No wrapping
392     * <li>{@link #WRAP_STYLE_LINE} Wrap at end of line
393     * <li>{@link #WRAP_STYLE_WORD} Wrap by word boundaries
394     * </ul>
395     */
396    public void setWrapStyle(int style) {
397        wrapStyle = style;
398        console.setLineWrap(style != WRAP_STYLE_NONE);
399        console.setWrapStyleWord(style == WRAP_STYLE_WORD);
400
401        if (wrapGroup != null) {
402            wrapGroup.setSelected(wrapMenu.getItem(style).getModel(), true);
403        }
404    }
405
406    /**
407     * Retrieve the current console wrapping style
408     *
409     * @return current wrapping style - one of
410     * <ul>
411     * <li>{@link #WRAP_STYLE_NONE} No wrapping
412     * <li>{@link #WRAP_STYLE_LINE} Wrap at end of line
413     * <li>{@link #WRAP_STYLE_WORD} Wrap by word boundaries (default)
414     * </ul>
415     */
416    public int getWrapStyle() {
417        return wrapStyle;
418    }
419
420    /**
421     * Set the console font size
422     *
423     * @param size point size of font between 6 and 24 point
424     */
425    public void setFontSize(int size) {
426        updateFont(FONT_FAMILY, fontStyle, (fontSize = size < 6 ? 6 : size > 24 ? 24 : size));
427    }
428
429    /**
430     * Retrieve the current console font size (default 12 point)
431     *
432     * @return selected font size in points
433     */
434    public int getFontSize() {
435        return fontSize;
436    }
437
438    /**
439     * Set the console font style
440     *
441     * @param style one of
442     *              {@link Font#BOLD}, {@link Font#ITALIC}, {@link Font#PLAIN}
443     *              (default)
444     */
445    public void setFontStyle(int style) {
446
447        if (style == Font.BOLD || style == Font.ITALIC || style == Font.PLAIN || style == (Font.BOLD | Font.ITALIC)) {
448            fontStyle = style;
449        } else {
450            fontStyle = Font.PLAIN;
451        }
452        updateFont(FONT_FAMILY, fontStyle, fontSize);
453    }
454
455    /**
456     * Retrieve the current console font style
457     *
458     * @return selected font style - one of
459     *         {@link Font#BOLD}, {@link Font#ITALIC}, {@link Font#PLAIN}
460     *         (default)
461     */
462    public int getFontStyle() {
463        return fontStyle;
464    }
465
466    /**
467     * Update the system console font with the specified parameters
468     *
469     * @param style font style
470     * @param size  font size
471     */
472    private void updateFont(String family, int style, int size) {
473        console.setFont(new Font(family, style, size));
474    }
475
476    /**
477     * Method to define console colour schemes
478     */
479    private void defineSchemes() {
480        schemes = new ArrayList<>();
481        schemes.add(new Scheme(rbc.getString("ConsoleSchemeGreenOnBlack"), Color.GREEN, Color.BLACK));
482        schemes.add(new Scheme(rbc.getString("ConsoleSchemeOrangeOnBlack"), Color.ORANGE, Color.BLACK));
483        schemes.add(new Scheme(rbc.getString("ConsoleSchemeWhiteOnBlack"), Color.WHITE, Color.BLACK));
484        schemes.add(new Scheme(rbc.getString("ConsoleSchemeBlackOnWhite"), Color.BLACK, Color.WHITE));
485        schemes.add(new Scheme(rbc.getString("ConsoleSchemeWhiteOnBlue"), Color.WHITE, Color.BLUE));
486        schemes.add(new Scheme(rbc.getString("ConsoleSchemeBlackOnLightGray"), Color.BLACK, Color.LIGHT_GRAY));
487        schemes.add(new Scheme(rbc.getString("ConsoleSchemeBlackOnGray"), Color.BLACK, Color.GRAY));
488        schemes.add(new Scheme(rbc.getString("ConsoleSchemeWhiteOnGray"), Color.WHITE, Color.GRAY));
489        schemes.add(new Scheme(rbc.getString("ConsoleSchemeWhiteOnDarkGray"), Color.WHITE, Color.DARK_GRAY));
490        schemes.add(new Scheme(rbc.getString("ConsoleSchemeGreenOnDarkGray"), Color.GREEN, Color.DARK_GRAY));
491        schemes.add(new Scheme(rbc.getString("ConsoleSchemeOrangeOnDarkGray"), Color.ORANGE, Color.DARK_GRAY));
492    }
493
494    @SuppressWarnings("deprecation")    // The method getId() from the type Thread is deprecated since version 19
495                                        // The replacement Thread.threadId() isn't available before version 19
496    private void performStackTrace() {
497        System.out.println("----------- Begin Stack Trace -----------"); //NO18N
498        System.out.println("-----------------------------------------"); //NO18N
499        Map<Thread, StackTraceElement[]> traces = new HashMap<>(Thread.getAllStackTraces());
500        for (Thread thread : traces.keySet()) {
501            System.out.println("[" + thread.getId() + "] " + thread.getName());
502            for (StackTraceElement el : thread.getStackTrace()) {
503                System.out.println("  " + el);
504            }
505            System.out.println("-----------------------------------------"); //NO18N
506        }
507        System.out.println("-----------  End Stack Trace  -----------"); //NO18N
508    }
509
510    /**
511     * Set the console colour scheme
512     *
513     * @param which the scheme to use
514     */
515    public void setScheme(int which) {
516        scheme = which;
517
518        if (schemes == null) {
519            defineSchemes();
520        }
521
522        Scheme s;
523
524        try {
525            s = schemes.get(which);
526        } catch (IndexOutOfBoundsException ex) {
527            s = schemes.get(0);
528            scheme = 0;
529        }
530
531        console.setForeground(s.foreground);
532        console.setBackground(s.background);
533
534        if (schemeGroup != null) {
535            schemeGroup.setSelected(schemeMenu.getItem(scheme).getModel(), true);
536        }
537    }
538
539    public PrintStream getOutputStream() {
540        return this.outputStream;
541    }
542
543    public PrintStream getErrorStream() {
544        return this.errorStream;
545    }
546
547    /**
548     * Stop logging System output and error streams to the console.
549     */
550    public void close() {
551        redirectSystemStreams(originalOut, originalErr);
552    }
553
554    /**
555     * Start logging System output and error streams to the console.
556     */
557    public void open() {
558        redirectSystemStreams(getOutputStream(), getErrorStream());
559    }
560
561    /**
562     * Retrieve the current console colour scheme
563     *
564     * @return selected colour scheme
565     */
566    public int getScheme() {
567        return scheme;
568    }
569
570    public Scheme[] getSchemes() {
571        return this.schemes.toArray(new Scheme[this.schemes.size()]);
572        // return this.schemes.toArray(Scheme[]::new);
573        // It should be possible to use the line above, however causes eclipse compilation error
574        // Annotation type 'org.eclipse.jdt.annotation.NonNull' cannot be found on the build path,
575        // which is implicitly needed for null analysis.
576    }
577
578    /**
579     * Class holding details of each scheme
580     */
581    public static final class Scheme {
582
583        public Color foreground;
584        public Color background;
585        public String description;
586
587        Scheme(String description, Color foreground, Color background) {
588            this.foreground = foreground;
589            this.background = background;
590            this.description = description;
591        }
592    }
593
594    /**
595     * Class to deal with handling popup menu
596     */
597    public final class PopupListener extends MouseAdapter {
598
599        @Override
600        public void mousePressed(MouseEvent e) {
601            maybeShowPopup(e);
602        }
603
604        @Override
605        public void mouseReleased(MouseEvent e) {
606            maybeShowPopup(e);
607        }
608
609        private void maybeShowPopup(MouseEvent e) {
610            if (e.isPopupTrigger()) {
611                copySelection.setEnabled(console.getSelectionStart() != console.getSelectionEnd());
612                popup.show(e.getComponent(), e.getX(), e.getY());
613            }
614        }
615    }
616
617    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(SystemConsole.class);
618
619}