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