001package jmri.util;
002
003import java.awt.Dimension;
004import java.awt.Frame;
005import java.awt.GraphicsConfiguration;
006import java.awt.GraphicsDevice;
007import java.awt.GraphicsEnvironment;
008import java.awt.Insets;
009import java.awt.Point;
010import java.awt.Rectangle;
011import java.awt.Toolkit;
012import java.awt.event.ActionEvent;
013import java.awt.event.ComponentListener;
014import java.awt.event.KeyEvent;
015import java.awt.event.WindowListener;
016import java.util.ArrayList;
017import java.util.HashMap;
018import java.util.HashSet;
019import java.util.List;
020import java.util.Set;
021
022import javax.annotation.Nonnull;
023import javax.annotation.OverridingMethodsMustInvokeSuper;
024import javax.swing.AbstractAction;
025import javax.swing.InputMap;
026import javax.swing.JComponent;
027import javax.swing.JFrame;
028import javax.swing.JMenuBar;
029import javax.swing.JRootPane;
030import javax.swing.KeyStroke;
031
032import jmri.InstanceManager;
033import jmri.ShutDownManager;
034import jmri.UserPreferencesManager;
035import jmri.beans.BeanInterface;
036import jmri.beans.BeanUtil;
037import jmri.implementation.AbstractShutDownTask;
038import jmri.util.swing.JmriAbstractAction;
039import jmri.util.swing.JmriJOptionPane;
040import jmri.util.swing.JmriPanel;
041import jmri.util.swing.WindowInterface;
042import jmri.util.swing.sdi.JmriJFrameInterface;
043
044/**
045 * JFrame extended for common JMRI use.
046 * <p>
047 * We needed a place to refactor common JFrame additions in JMRI code, so this
048 * class was created.
049 * <p>
050 * Features:
051 * <ul>
052 * <li>Size limited to the maximum available on the screen, after removing any
053 * menu bars (macOS) and taskbars (Windows)
054 * <li>Cleanup upon closing the frame: When the frame is closed (WindowClosing
055 * event), the {@link #dispose()} method is invoked to do cleanup. This is inherited from
056 * JFrame itself, so super.dispose() needs to be invoked in the over-loading
057 * methods.
058 * <li>Maintains a list of existing JmriJFrames
059 * </ul>
060 * <h2>Window Closing</h2>
061 * Normally, a JMRI window wants to be disposed when it closes. This is what's
062 * needed when each invocation of the corresponding action can create a new copy
063 * of the window. To do this, you don't have to do anything in your subclass.
064 * <p>
065 * If you want this behavior, but need to do something when the window is
066 * closing, override the {@link #windowClosing(java.awt.event.WindowEvent)}
067 * method to do what you want. Also, if you override {@link #dispose()}, make
068 * sure to call super.dispose().
069 * <p>
070 * If you want the window to just do nothing or just hide, rather than be
071 * disposed, when closed, set the DefaultCloseOperation to DO_NOTHING_ON_CLOSE
072 * or HIDE_ON_CLOSE depending on what you're looking for.
073 *
074 * @author Bob Jacobsen Copyright 2003, 2008, 2023
075 */
076public class JmriJFrame extends JFrame implements WindowListener, jmri.ModifiedFlag,
077        ComponentListener, WindowInterface, BeanInterface {
078
079    protected boolean allowInFrameServlet = true;
080
081    /**
082     * Creates a JFrame with standard settings, optional save/restore of size
083     * and position.
084     *
085     * @param saveSize      Set true to save the last known size
086     * @param savePosition  Set true to save the last known location
087     */
088    public JmriJFrame(boolean saveSize, boolean savePosition) {
089        super();
090        reuseFrameSavedPosition = savePosition;
091        reuseFrameSavedSized = saveSize;
092        initFrame();
093    }
094
095    final void initFrame() {
096        addWindowListener(this);
097        addComponentListener(this);
098        windowInterface = new JmriJFrameInterface();
099
100        /*
101         * This ensures that different jframes do not get placed directly on top of each other,
102         * but are offset. However a saved preferences can override this.
103         */
104        JmriJFrameManager m = getJmriJFrameManager();
105        int X_MARGIN = 3; // observed uncertainty in window position, maybe due to roundoff
106        int Y_MARGIN = 3;
107        synchronized (m) {
108            for (JmriJFrame j : m) {
109                if ((j.getExtendedState() != ICONIFIED) && (j.isVisible())) {
110                    if ( Math.abs(j.getX() - this.getX()) < X_MARGIN+j.getInsets().left
111                        && Math.abs(j.getY() - this.getY()) < Y_MARGIN+j.getInsets().top) {
112                        offSetFrameOnScreen(j);
113                    }
114                }
115            }
116
117            m.add(this);
118        }
119        // Set the image for use when minimized
120        setIconImage(getToolkit().getImage("resources/jmri32x32.gif"));
121        // set the close short cut
122        setDefaultCloseOperation(javax.swing.WindowConstants.DISPOSE_ON_CLOSE);
123        addWindowCloseShortCut();
124
125        windowFrameRef = this.getClass().getName();
126        if (!this.getClass().getName().equals(JmriJFrame.class.getName())) {
127            generateWindowRef();
128            setFrameLocation();
129        }
130    }
131
132    /**
133     * Creates a JFrame with standard settings, including saving/restoring of
134     * size and position.
135     */
136    public JmriJFrame() {
137        this(true, true);
138    }
139
140    /**
141     * Creates a JFrame with with given name plus standard settings, including
142     * saving/restoring of size and position.
143     *
144     * @param name  Title of the JFrame
145     */
146    public JmriJFrame(String name) {
147        this(name, true, true);
148    }
149
150    /**
151     * Creates a JFrame with with given name plus standard settings, including
152     * optional save/restore of size and position.
153     *
154     * @param name          Title of the JFrame
155     * @param saveSize      Set true to save the last knowm size
156     * @param savePosition  Set true to save the last known location
157     */
158    public JmriJFrame(String name, boolean saveSize, boolean savePosition) {
159        this(saveSize, savePosition);
160        setFrameTitle(name);
161    }
162
163    final void setFrameTitle(String name) {
164        setTitle(name);
165        generateWindowRef();
166        if (this.getClass().getName().equals(JmriJFrame.class.getName())) {
167            if ((this.getTitle() == null) || (this.getTitle().isEmpty())) {
168                return;
169            }
170        }
171        setFrameLocation();
172    }
173
174    /**
175     * Remove this window from the Windows Menu by removing it from the list of
176     * active JmriJFrames.
177     */
178    public void makePrivateWindow() {
179        JmriJFrameManager m = getJmriJFrameManager();
180        synchronized (m) {
181            m.remove(this);
182        }
183    }
184
185    /**
186     * Add this window to the Windows Menu by adding it to the list of
187     * active JmriJFrames.
188     */
189    public void makePublicWindow() {
190        JmriJFrameManager m = getJmriJFrameManager();
191        synchronized (m) {
192            if (! m.contains(this)) {
193                m.add(this);
194            }
195        }
196    }
197
198    /**
199      * Reset frame location and size to stored preference value
200      */
201    public void setFrameLocation() {
202        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent(prefsMgr -> {
203            if (prefsMgr.hasProperties(windowFrameRef)) {
204                // Track the computed size and position of this window
205                Rectangle window = new Rectangle(this.getX(),this.getY(),this.getWidth(), this.getHeight());
206                boolean isVisible = false;
207                log.debug("Initial window location & size: {}", window);
208
209                log.debug("Detected {} screens.",GraphicsEnvironment.getLocalGraphicsEnvironment().getScreenDevices().length);
210                log.debug("windowFrameRef: {}", windowFrameRef);
211                if (reuseFrameSavedPosition) {
212                    log.debug("setFrameLocation 1st clause sets \"{}\" location to {}", getTitle(), prefsMgr.getWindowLocation(windowFrameRef));
213                    window.setLocation(prefsMgr.getWindowLocation(windowFrameRef));
214                }
215                //
216                // Simple case that if either height or width are zero, then we should not set them
217                //
218                if ((reuseFrameSavedSized)
219                        && (!((prefsMgr.getWindowSize(windowFrameRef).getWidth() == 0.0) || (prefsMgr.getWindowSize(
220                        windowFrameRef).getHeight() == 0.0)))) {
221                    log.debug("setFrameLocation 2nd clause sets \"{}\" preferredSize to {}", getTitle(), prefsMgr.getWindowSize(windowFrameRef));
222                    this.setPreferredSize(prefsMgr.getWindowSize(windowFrameRef));
223                    log.debug("setFrameLocation 2nd clause sets \"{}\" size to {}", getTitle(), prefsMgr.getWindowSize(windowFrameRef));
224                    window.setSize(prefsMgr.getWindowSize(windowFrameRef));
225                    log.debug("window now set to location: {}", window);
226                }
227
228                //
229                // We just check to make sure that having set the location that we do not have another frame with the same
230                // class name and title in the same location, if it is we offset
231                //
232                for (JmriJFrame j : getJmriJFrameManager()) {
233                    if (j.getClass().getName().equals(this.getClass().getName()) && (j.getExtendedState() != ICONIFIED)
234                            && (j.isVisible()) && j.getTitle().equals(getTitle())) {
235                        if ((j.getX() == this.getX()) && (j.getY() == this.getY())) {
236                            log.debug("setFrameLocation 3rd clause calls offSetFrameOnScreen({})", j);
237                            offSetFrameOnScreen(j);
238                        }
239                    }
240                }
241
242                //
243                // Now we loop through all possible displays to determine if this window rectangle would intersect
244                // with any of these screens - in other words, ensure that this frame would be (partially) visible
245                // on at least one of the connected screens
246                //
247                for (ScreenDimensions sd: getScreenDimensions()) {
248                    boolean canShow = window.intersects(sd.getBounds());
249                    if (canShow) isVisible = true;
250                    log.debug("Screen {} bounds {}, {}", sd.getGraphicsDevice().getIDstring(), sd.getBounds(), sd.getInsets());
251                    log.debug("Does \"{}\" window {} fit on screen {}? {}", getTitle(), window, sd.getGraphicsDevice().getIDstring(), canShow);
252                }
253
254                log.debug("Can \"{}\" window {} display on a screen? {}", getTitle(), window, isVisible);
255
256                //
257                // We've determined that at least one of the connected screens can display this window
258                // so set its location and size based upon previously stored values
259                //
260                if (isVisible) {
261                    this.setLocation(window.getLocation());
262                    this.setSize(window.getSize());
263                    log.debug("Set \"{}\" location to {} and size to {}", getTitle(), window.getLocation(), window.getSize());
264                }
265            }
266        });
267    }
268
269    private final static ArrayList<ScreenDimensions> screenDim = getInitialScreenDimensionsOnce();
270
271    /**
272     * returns the previously initialized array of screens. See getScreenDimensionsOnce()
273     * @return ArrayList of screen bounds and insets
274     */
275    public static ArrayList<ScreenDimensions> getScreenDimensions() {
276        return screenDim;
277    }
278
279    /**
280     * Iterates through the attached displays and retrieves bounds, insets
281     * and id for each screen.
282     * Size of returned ArrayList equals the number of detected displays.
283     * Used to initialize a static final array.
284     * @return ArrayList of screen bounds and insets
285     */
286    private static ArrayList<ScreenDimensions> getInitialScreenDimensionsOnce() {
287        ArrayList<ScreenDimensions> screenDimensions = new ArrayList<>();
288        if (GraphicsEnvironment.isHeadless()) {
289            // there are no screens
290            return screenDimensions;
291        }
292        for (GraphicsDevice gd: GraphicsEnvironment.getLocalGraphicsEnvironment().getScreenDevices()) {
293            Rectangle bounds = new Rectangle();
294            Insets insets = new Insets(0, 0, 0, 0);
295            for (GraphicsConfiguration gc: gd.getConfigurations()) {
296                if (bounds.isEmpty()) {
297                    bounds = gc.getBounds();
298                } else {
299                    bounds = bounds.union(gc.getBounds());
300                }
301                insets = Toolkit.getDefaultToolkit().getScreenInsets(gc);
302            }
303            screenDimensions.add(new ScreenDimensions(bounds, insets, gd));
304        }
305        return screenDimensions;
306    }
307
308    /**
309     * Represents the dimensions of an attached screen/display
310     */
311    public static class ScreenDimensions {
312        final Rectangle bounds;
313        final Insets insets;
314        final GraphicsDevice gd;
315
316        public ScreenDimensions(Rectangle bounds, Insets insets, GraphicsDevice gd) {
317            this.bounds = bounds;
318            this.insets = insets;
319            this.gd = gd;
320        }
321
322        public Rectangle getBounds() {
323            return bounds;
324        }
325
326        public Insets getInsets() {
327            return insets;
328        }
329
330        public GraphicsDevice getGraphicsDevice() {
331            return gd;
332        }
333    }
334
335    /**
336     * Regenerates the window frame ref that is used for saving and setting
337     * frame size and position against.
338     */
339    public void generateWindowRef() {
340        String initref = this.getClass().getName();
341        if ((this.getTitle() != null) && (!this.getTitle().equals(""))) {
342            if (initref.equals(JmriJFrame.class.getName())) {
343                initref = this.getTitle();
344            } else {
345                initref = initref + ":" + this.getTitle();
346            }
347        }
348
349        int refNo = 1;
350        String ref = initref;
351        JmriJFrameManager m = getJmriJFrameManager();
352        synchronized (m) {
353            for (JmriJFrame j : m) {
354                if (j != this && j.getWindowFrameRef() != null && j.getWindowFrameRef().equals(ref)) {
355                    ref = initref + ":" + refNo;
356                    refNo++;
357                }
358            }
359        }
360        log.debug("Created windowFrameRef: {}", ref);
361        windowFrameRef = ref;
362    }
363
364    /** {@inheritDoc} */
365    @Override
366    public void pack() {
367        // work around for Linux, sometimes the stored window size is too small
368        if (this.getPreferredSize().width < 100 || this.getPreferredSize().height < 100) {
369            this.setPreferredSize(null); // try without the preferred size
370        }
371        super.pack();
372        reSizeToFitOnScreen();
373    }
374
375    /**
376     * Remove any decoration, such as the title bar or close window control,
377     * from the JFrame.
378     * <p>
379     * JmriJFrames are often built internally and presented to the user before
380     * any scripting action can interact with them. At that point it's too late
381     * to directly invoke setUndecorated(true) because the JFrame is already
382     * displayable. This method uses dispose() to drop the windowing resources,
383     * sets undecorated, and then redisplays the window.
384     */
385    public void undecorate() {
386        boolean visible = isVisible();
387
388        setVisible(false);
389        super.dispose();
390
391        setUndecorated(true);
392        getRootPane().setWindowDecorationStyle(javax.swing.JRootPane.NONE);
393
394        pack();
395        setVisible(visible);
396    }
397
398    /**
399     * Initialize only once the MaximumSize for the screen
400     */
401    private final Dimension maxSizeDimension = getMaximumSize();
402
403    /**
404     * Tries to get window to fix entirely on screen. First choice is to move
405     * the origin up and left as needed, then to make the window smaller
406     */
407    void reSizeToFitOnScreen() {
408        int width = this.getPreferredSize().width;
409        int height = this.getPreferredSize().height;
410        log.trace("reSizeToFitOnScreen of \"{}\" starts with maximum size {}", getTitle(), maxSizeDimension);
411        log.trace("reSizeToFitOnScreen starts with preferred height {} width {}", height, width);
412        log.trace("reSizeToFitOnScreen starts with location {},{}", getX(), getY());
413        log.trace("reSizeToFitOnScreen starts with insets {},{}", getInsets().left, getInsets().top);
414        // Normalise the location
415        ScreenDimensions sd = getContainingDisplay(this.getLocation());
416        Point locationOnDisplay = new Point(getLocation().x - sd.getBounds().x, getLocation().y - sd.getBounds().y);
417        log.trace("reSizeToFitOnScreen normalises origin to {}, {}", locationOnDisplay.x, locationOnDisplay.y);
418
419        if ((width + locationOnDisplay.x) >= maxSizeDimension.getWidth()) {
420            // not fit in width, try to move position left
421            int offsetX = (width + locationOnDisplay.x) - (int) maxSizeDimension.getWidth(); // pixels too large
422            log.trace("reSizeToFitOnScreen moves \"{}\" left {} pixels", getTitle(), offsetX);
423            int positionX = locationOnDisplay.x - offsetX;
424            if (positionX < this.getInsets().left) {
425                positionX = this.getInsets().left;
426                log.trace("reSizeToFitOnScreen sets \"{}\" X to minimum {}", getTitle(), positionX);
427            }
428            this.setLocation(positionX + sd.getBounds().x, this.getY());
429            log.trace("reSizeToFitOnScreen during X calculation sets location {}, {}", positionX + sd.getBounds().x, this.getY());
430            // try again to see if it doesn't fit
431            if ((width + locationOnDisplay.x) >= maxSizeDimension.getWidth()) {
432                width = width - (int) ((width + locationOnDisplay.x) - maxSizeDimension.getWidth());
433                log.trace("reSizeToFitOnScreen sets \"{}\" width to {}", getTitle(), width);
434            }
435        }
436        if ((height + locationOnDisplay.y) >= maxSizeDimension.getHeight()) {
437            // not fit in height, try to move position up
438            int offsetY = (height + locationOnDisplay.y) - (int) maxSizeDimension.getHeight(); // pixels too large
439            log.trace("reSizeToFitOnScreen moves \"{}\" up {} pixels", getTitle(), offsetY);
440            int positionY = locationOnDisplay.y - offsetY;
441            if (positionY < this.getInsets().top) {
442                positionY = this.getInsets().top;
443                log.trace("reSizeToFitScreen sets \"{}\" Y to minimum {}", getTitle(), positionY);
444            }
445            this.setLocation(this.getX(), positionY + sd.getBounds().y);
446            log.trace("reSizeToFitOnScreen during Y calculation sets location {}, {}", getX(), positionY + sd.getBounds().y);
447            // try again to see if it doesn't fit
448            if ((height + this.getY()) >= maxSizeDimension.getHeight()) {
449                height = height - (int) ((height + locationOnDisplay.y) - maxSizeDimension.getHeight());
450                log.trace("reSizeToFitOnScreen sets \"{}\" height to {}", getTitle(), height);
451            }
452        }
453        this.setSize(width, height);
454        log.debug("reSizeToFitOnScreen sets height {} width {} position {},{}", height, width, getX(), getY());
455
456    }
457
458    /**
459     * Move a frame down and to the left by it's top offset or a fixed amount, whichever is larger
460     * @param f JmirJFrame to move
461     */
462    void offSetFrameOnScreen(JmriJFrame f) {
463        /*
464         * We use the frame that we are moving away from for insets, as at this point our own insets have not been correctly
465         * built and always return a size of zero
466         */
467        int REQUIRED_OFFSET = 25; // units are pixels
468        int REQUIRED_OFFSET_X = Math.max(REQUIRED_OFFSET, f.getInsets().left);
469        int REQUIRED_OFFSET_Y = Math.max(REQUIRED_OFFSET, f.getInsets().top);
470
471        int frameOffSetx = this.getX() + REQUIRED_OFFSET_X;
472        int frameOffSety = this.getY() + REQUIRED_OFFSET_Y;
473
474        Dimension dim = getMaximumSize();
475
476        if (frameOffSetx >= (dim.getWidth() * 0.75)) {
477            frameOffSety = 0;
478            frameOffSetx = (f.getInsets().top) * 2;
479        }
480        if (frameOffSety >= (dim.getHeight() * 0.75)) {
481            frameOffSety = 0;
482            frameOffSetx = (f.getInsets().top) * 2;
483        }
484        /*
485         * If we end up with our off Set of X being greater than the width of the screen we start back at the beginning
486         * but with a half offset
487         */
488        if (frameOffSetx >= dim.getWidth()) {
489            frameOffSetx = f.getInsets().top / 2;
490        }
491        this.setLocation(frameOffSetx, frameOffSety);
492    }
493
494    String windowFrameRef;
495
496    public String getWindowFrameRef() {
497        return windowFrameRef;
498    }
499
500    /**
501     * By default, Swing components should be created an installed in this
502     * method, rather than in the ctor itself.
503     */
504    public void initComponents() {
505    }
506
507    /**
508     * Add a standard help menu, including window specific help item.
509     *
510     * Final because it defines the content of a standard help menu, not to be messed with individually
511     *
512     * @param ref    JHelp reference for the desired window-specific help page; null means no page
513     * @param direct true if the help main-menu item goes directly to the help system,
514     *               such as when there are no items in the help menu
515     */
516    final public void addHelpMenu(String ref, boolean direct) {
517        // only works if no menu present?
518        JMenuBar bar = getJMenuBar();
519        if (bar == null) {
520            bar = new JMenuBar();
521        }
522        // add Window menu
523        bar.add(new WindowMenu(this));
524        // add Help menu
525        jmri.util.HelpUtil.helpMenu(bar, ref, direct);
526        setJMenuBar(bar);
527    }
528
529    /**
530     * Adds a "Close Window" key shortcut to close window on op-W.
531     */
532    @SuppressWarnings("deprecation")  // getMenuShortcutKeyMask()
533    void addWindowCloseShortCut() {
534        // modelled after code in JavaDev mailing list item by Bill Tschumy <bill@otherwise.com> 08 Dec 2004
535        AbstractAction act = new AbstractAction() {
536
537            /** {@inheritDoc} */
538            @Override
539            public void actionPerformed(ActionEvent e) {
540                // log.debug("keystroke requested close window ", JmriJFrame.this.getTitle());
541                JmriJFrame.this.processWindowEvent(new java.awt.event.WindowEvent(JmriJFrame.this,
542                        java.awt.event.WindowEvent.WINDOW_CLOSING));
543            }
544        };
545        getRootPane().getActionMap().put("close", act);
546
547        int stdMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx();
548        InputMap im = getRootPane().getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT);
549
550        // We extract the modifiers as a string, then add the I18N string, and
551        // build a key code
552        String modifier = KeyStroke.getKeyStroke(KeyEvent.VK_W, stdMask).toString();
553        String keyCode = modifier.substring(0, modifier.length() - 1)
554                + Bundle.getMessage("VkKeyWindowClose").substring(0, 1);
555
556        im.put(KeyStroke.getKeyStroke(keyCode), "close"); // NOI18N
557        // im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "close");
558    }
559
560    private static String escapeKeyAction = "escapeKeyAction";
561    private boolean escapeKeyActionClosesWindow = false;
562
563    /**
564     * Bind an action to the Escape key.
565     * <p>
566     * Binds an AbstractAction to the Escape key. If an action is already bound
567     * to the Escape key, that action will be replaced. Passing
568     * <code>null</code> unbinds any existing actions from the Escape key.
569     * <p>
570     * Note that binding the Escape key to any action may break expected or
571     * standardized behaviors. See <a
572     * href="http://java.sun.com/products/jlf/ed2/book/Appendix.A.html">Keyboard
573     * Shortcuts, Mnemonics, and Other Keyboard Operations</a> in the Java Look
574     * and Feel Design Guidelines for standardized behaviors.
575     *
576     * @param action The AbstractAction to bind to.
577     * @see #getEscapeKeyAction()
578     * @see #setEscapeKeyClosesWindow(boolean)
579     */
580    public void setEscapeKeyAction(AbstractAction action) {
581        JRootPane root = this.getRootPane();
582        KeyStroke escape = KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0);
583        escapeKeyActionClosesWindow = false; // setEscapeKeyClosesWindow will set to true as needed
584        if (action != null) {
585            root.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(escape, escapeKeyAction);
586            root.getActionMap().put(escapeKeyAction, action);
587        } else {
588            root.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).remove(escape);
589            root.getActionMap().remove(escapeKeyAction);
590        }
591    }
592
593    /**
594     * The action associated with the Escape key.
595     *
596     * @return An AbstractAction or null if no action is bound to the Escape
597     *         key.
598     * @see #setEscapeKeyAction(javax.swing.AbstractAction)
599     * @see javax.swing.AbstractAction
600     */
601    public AbstractAction getEscapeKeyAction() {
602        return (AbstractAction) this.getRootPane().getActionMap().get(escapeKeyAction);
603    }
604
605    /**
606     * Bind the Escape key to an action that closes the window.
607     * <p>
608     * If closesWindow is true, this method creates an action that triggers the
609     * "window is closing" event; otherwise this method removes any actions from
610     * the Escape key.
611     *
612     * @param closesWindow Create or destroy an action to close the window.
613     * @see java.awt.event.WindowEvent#WINDOW_CLOSING
614     * @see #setEscapeKeyAction(javax.swing.AbstractAction)
615     */
616    public void setEscapeKeyClosesWindow(boolean closesWindow) {
617        if (closesWindow) {
618            setEscapeKeyAction(new AbstractAction() {
619
620                /** {@inheritDoc} */
621                @Override
622                public void actionPerformed(ActionEvent ae) {
623                    JmriJFrame.this.processWindowEvent(new java.awt.event.WindowEvent(JmriJFrame.this,
624                            java.awt.event.WindowEvent.WINDOW_CLOSING));
625                }
626            });
627        } else {
628            setEscapeKeyAction(null);
629        }
630        escapeKeyActionClosesWindow = closesWindow;
631    }
632
633    /**
634     * Does the Escape key close the window?
635     *
636     * @return <code>true</code> if Escape key is bound to action created by
637     *         setEscapeKeyClosesWindow, <code>false</code> in all other cases.
638     * @see #setEscapeKeyClosesWindow
639     * @see #setEscapeKeyAction
640     */
641    public boolean getEscapeKeyClosesWindow() {
642        return (escapeKeyActionClosesWindow && getEscapeKeyAction() != null);
643    }
644
645    private ScreenDimensions getContainingDisplay(Point location) {
646        // Loop through attached screen to determine which
647        // contains the top-left origin point of this window
648        for (ScreenDimensions sd: getScreenDimensions()) {
649            boolean isOnThisScreen = sd.getBounds().contains(location);
650            log.debug("Is \"{}\" window origin {} located on screen {}? {}", getTitle(), this.getLocation(), sd.getGraphicsDevice().getIDstring(), isOnThisScreen);
651            if (isOnThisScreen) {
652                // We've found the screen that contains this origin
653                return sd;
654            }
655        }
656        // As a fall-back, return the first display which is the primary
657        log.debug("Falling back to using the primary display");
658        return getScreenDimensions().get(0);
659    }
660
661    /**
662     * {@inheritDoc}
663     * Provide a maximum frame size that is limited to what can fit on the
664     * screen after toolbars, etc are deducted.
665     * <p>
666     * Some of the methods used here return null pointers on some Java
667     * implementations, however, so this will return the superclasses's maximum
668     * size if the algorithm used here fails.
669     *
670     * @return the maximum window size
671     */
672    @Override
673    public Dimension getMaximumSize() {
674        // adjust maximum size to full screen minus any toolbars
675        if (GraphicsEnvironment.isHeadless()) {
676            // there are no screens
677            return new Dimension(0,0);
678        }
679        try {
680            // Try our own algorithm. This throws null-pointer exceptions on
681            // some Java installs, however, for unknown reasons, so be
682            // prepared to fall back.
683            try {
684                ScreenDimensions sd = getContainingDisplay(this.getLocation());
685                int widthInset = sd.getInsets().right + sd.getInsets().left;
686                int heightInset = sd.getInsets().top + sd.getInsets().bottom;
687
688                // If insets are zero, guess based on system type
689                if (widthInset == 0 && heightInset == 0) {
690                    String osName = SystemType.getOSName();
691                    if (SystemType.isLinux()) {
692                        // Linux generally has a bar across the top and/or bottom
693                        // of the screen, but lets you have the full width.
694                        heightInset = 70;
695                    } // Windows generally has values, but not always,
696                    // so we provide observed values just in case
697                    else if (osName.equals("Windows XP") || osName.equals("Windows 98")
698                            || osName.equals("Windows 2000")) {
699                        heightInset = 28; // bottom 28
700                    }
701                }
702
703                // Insets may also be provided as system parameters
704                String sw = System.getProperty("jmri.inset.width");
705                if (sw != null) {
706                    try {
707                        widthInset = Integer.parseInt(sw);
708                    } catch (NumberFormatException e1) {
709                        log.error("Error parsing jmri.inset.width: {}", e1.getMessage());
710                    }
711                }
712                String sh = System.getProperty("jmri.inset.height");
713                if (sh != null) {
714                    try {
715                        heightInset = Integer.parseInt(sh);
716                    } catch (NumberFormatException e1) {
717                        log.error("Error parsing jmri.inset.height: {}", e1.getMessage());
718                    }
719                }
720
721                // calculate size as screen size minus space needed for offsets
722                log.trace("getMaximumSize returns normally {},{}", (sd.getBounds().width - widthInset), (sd.getBounds().height - heightInset));
723                return new Dimension(sd.getBounds().width - widthInset, sd.getBounds().height - heightInset);
724
725        } catch (NoSuchMethodError e) {
726                Dimension screen = getToolkit().getScreenSize();
727                log.trace("getMaximumSize returns approx due to failure {},{}", screen.width, screen.height);
728                return new Dimension(screen.width, screen.height - 45); // approximate this...
729            }
730        } catch (RuntimeException e2) {
731            // failed completely, fall back to standard method
732            log.trace("getMaximumSize returns super due to failure {}", super.getMaximumSize());
733            return super.getMaximumSize();
734        }
735    }
736
737    /**
738     * {@inheritDoc}
739     * The preferred size must fit on the physical screen, so calculate the
740     * lesser of either the preferred size from the layout or the screen size.
741     *
742     * @return the preferred size or the maximum size, whichever is smaller
743     */
744    @Override
745    public Dimension getPreferredSize() {
746        // limit preferred size to size of screen (from getMaximumSize())
747        Dimension screen = getMaximumSize();
748        int width = Math.min(super.getPreferredSize().width, screen.width);
749        int height = Math.min(super.getPreferredSize().height, screen.height);
750        log.debug("getPreferredSize \"{}\" returns width {} height {}", getTitle(), width, height);
751        return new Dimension(width, height);
752    }
753
754    /**
755     * Get a List of the currently-existing JmriJFrame objects. The returned
756     * list is a copy made at the time of the call, so it can be manipulated as
757     * needed by the caller.
758     *
759     * @return a list of JmriJFrame instances. If there are no instances, an
760     *         empty list is returned.
761     */
762    @Nonnull
763    public static List<JmriJFrame> getFrameList() {
764        JmriJFrameManager m = getJmriJFrameManager();
765        synchronized (m) {
766            return new ArrayList<>(m);
767        }
768    }
769
770    /**
771     * Get a list of currently-existing JmriJFrame objects that are specific
772     * sub-classes of JmriJFrame.
773     * <p>
774     * The returned list is a copy made at the time of the call, so it can be
775     * manipulated as needed by the caller.
776     *
777     * @param <T> generic JmriJframe.
778     * @param type The Class the list should be limited to.
779     * @return An ArrayList of Frames.
780     */
781    @SuppressWarnings("unchecked") // cast in add() checked at run time
782    public static <T extends JmriJFrame> List<T> getFrameList(@Nonnull Class<T> type) {
783        List<T> result = new ArrayList<>();
784        JmriJFrameManager m = getJmriJFrameManager();
785        synchronized (m) {
786            m.stream().filter((f) -> (type.isInstance(f))).forEachOrdered((f) ->
787                {
788                    result.add((T)f);
789                });
790        }
791        return result;
792    }
793
794    /**
795     * Get a JmriJFrame of a particular name. If more than one exists, there's
796     * no guarantee as to which is returned.
797     *
798     * @param name the name of one or more JmriJFrame objects
799     * @return a JmriJFrame with the matching name or null if no matching frames
800     *         exist
801     */
802    public static JmriJFrame getFrame(String name) {
803        for (JmriJFrame j : getFrameList()) {
804            if (j.getTitle().equals(name)) {
805                return j;
806            }
807        }
808        return null;
809    }
810
811    /*
812     * addNotify removed - In linux the "setSize(dimension)" is honoured after the pack, increasing its size, overriding preferredSize
813     *                   - In windows the "setSize(dimension)" is ignored after the pack, so has no effect.
814     */
815    // handle resizing when first shown
816    // private boolean mShown = false;
817
818    // /** {@inheritDoc} */
819    /* @Override
820    public void addNotify() {
821        super.addNotify();
822        // log.debug("addNotify window ({})", getTitle());
823        if (mShown) {
824            return;
825        }
826        // resize frame to account for menubar
827        JMenuBar jMenuBar = getJMenuBar();
828        if (jMenuBar != null) {
829            int jMenuBarHeight = jMenuBar.getPreferredSize().height;
830            Dimension dimension = getSize();
831            dimension.height += jMenuBarHeight;
832            setSize(dimension);
833        }
834        mShown = true;
835    }
836*/
837
838    /**
839     * Set whether the frame Position is saved or not after it has been created.
840     *
841     * @param save true if the frame position should be saved.
842     */
843    public void setSavePosition(boolean save) {
844        reuseFrameSavedPosition = save;
845        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent(prefsMgr -> {
846            prefsMgr.setSaveWindowLocation(windowFrameRef, save);
847        });
848    }
849
850    /**
851     * Set whether the frame Size is saved or not after it has been created.
852     *
853     * @param save true if the frame size should be saved.
854     */
855    public void setSaveSize(boolean save) {
856        reuseFrameSavedSized = save;
857        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent(prefsMgr -> {
858            prefsMgr.setSaveWindowSize(windowFrameRef, save);
859        });
860    }
861
862    /**
863     * Returns if the frame Position is saved or not.
864     *
865     * @return true if the frame position should be saved
866     */
867    public boolean getSavePosition() {
868        return reuseFrameSavedPosition;
869    }
870
871    /**
872     * Returns if the frame Size is saved or not.
873     *
874     * @return true if the frame size should be saved
875     */
876    public boolean getSaveSize() {
877        return reuseFrameSavedSized;
878    }
879
880    /**
881     * {@inheritDoc}
882     * A frame is considered "modified" if it has changes that have not been
883     * stored.
884     */
885    @Override
886    public void setModifiedFlag(boolean flag) {
887        this.modifiedFlag = flag;
888        // mark the window in the GUI
889        markWindowModified(this.modifiedFlag);
890    }
891
892    /** {@inheritDoc} */
893    @Override
894    public boolean getModifiedFlag() {
895        return modifiedFlag;
896    }
897
898    private boolean modifiedFlag = false;
899
900    /**
901     * Handle closing a window or quiting the program while the modified bit was
902     * set.
903     */
904    protected void handleModified() {
905        if (getModifiedFlag()) {
906            this.setVisible(true);
907            int result = JmriJOptionPane.showOptionDialog(this, Bundle.getMessage("WarnChangedMsg"),
908                    Bundle.getMessage("WarningTitle"), JmriJOptionPane.YES_NO_OPTION,
909                    JmriJOptionPane.WARNING_MESSAGE, null, // icon
910                    new String[]{Bundle.getMessage("WarnYesSave"), Bundle.getMessage("WarnNoClose")}, Bundle
911                    .getMessage("WarnYesSave"));
912            if (result == 0 ) { // array option 0 , WarnYesSave
913                // user wants to save
914                storeValues();
915            }
916        }
917    }
918
919    protected void storeValues() {
920        log.error("default storeValues does nothing for \"{}\"", getTitle());
921    }
922
923    // For marking the window as modified on Mac OS X
924    // See: https://web.archive.org/web/20090712161630/http://developer.apple.com/qa/qa2001/qa1146.html
925    final static String WINDOW_MODIFIED = "windowModified";
926
927    public void markWindowModified(boolean yes) {
928        getRootPane().putClientProperty(WINDOW_MODIFIED, yes ? Boolean.TRUE : Boolean.FALSE);
929    }
930
931    // Window methods
932    /** Does nothing in this class */
933    @Override
934    public void windowOpened(java.awt.event.WindowEvent e) {
935    }
936
937    /** Does nothing in this class */
938    @Override
939    public void windowClosed(java.awt.event.WindowEvent e) {
940    }
941
942    /** Does nothing in this class */
943    @Override
944    public void windowActivated(java.awt.event.WindowEvent e) {
945    }
946
947    /** Does nothing in this class */
948    @Override
949    public void windowDeactivated(java.awt.event.WindowEvent e) {
950    }
951
952    /** Does nothing in this class */
953    @Override
954    public void windowIconified(java.awt.event.WindowEvent e) {
955    }
956
957    /** Does nothing in this class */
958    @Override
959    public void windowDeiconified(java.awt.event.WindowEvent e) {
960    }
961
962    /**
963     * {@inheritDoc}
964     *
965     * The JmriJFrame implementation calls {@link #handleModified()}.
966     */
967    @Override
968    public void windowClosing(java.awt.event.WindowEvent e) {
969        handleModified();
970    }
971
972    /** Does nothing in this class */
973    @Override
974    public void componentHidden(java.awt.event.ComponentEvent e) {
975    }
976
977    /** {@inheritDoc} */
978    @Override
979    public void componentMoved(java.awt.event.ComponentEvent e) {
980        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent(p -> {
981            if (reuseFrameSavedPosition && isVisible()) {
982                p.setWindowLocation(windowFrameRef, this.getLocation());
983            }
984        });
985    }
986
987    /** {@inheritDoc} */
988    @Override
989    public void componentResized(java.awt.event.ComponentEvent e) {
990        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent(p -> {
991            if (reuseFrameSavedSized && isVisible()) {
992                saveWindowSize(p);
993            }
994        });
995    }
996
997    /** Does nothing in this class */
998    @Override
999    public void componentShown(java.awt.event.ComponentEvent e) {
1000    }
1001
1002    private transient AbstractShutDownTask task = null;
1003
1004    protected void setShutDownTask() {
1005        task = new AbstractShutDownTask(getTitle()) {
1006            @Override
1007            public Boolean call() {
1008                handleModified();
1009                return Boolean.TRUE;
1010            }
1011
1012            @Override
1013            public void run() {
1014            }
1015        };
1016        InstanceManager.getDefault(ShutDownManager.class).register(task);
1017    }
1018
1019    protected boolean reuseFrameSavedPosition = true;
1020    protected boolean reuseFrameSavedSized = true;
1021
1022    /**
1023     * {@inheritDoc}
1024     *
1025     * When window is finally destroyed, remove it from the list of windows.
1026     * <p>
1027     * Subclasses that over-ride this method must invoke this implementation
1028     * with super.dispose() right before returning.
1029     */
1030    @OverridingMethodsMustInvokeSuper
1031    @Override
1032    public void dispose() {
1033        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent(p -> {
1034            if (reuseFrameSavedPosition) {
1035                p.setWindowLocation(windowFrameRef, this.getLocation());
1036            }
1037            if (reuseFrameSavedSized) {
1038                saveWindowSize(p);
1039            }
1040        });
1041        log.debug("dispose \"{}\"", getTitle());
1042        if (windowInterface != null) {
1043            windowInterface.dispose();
1044        }
1045        if (task != null) {
1046            jmri.InstanceManager.getDefault(jmri.ShutDownManager.class).deregister(task);
1047            task = null;
1048        }
1049        JmriJFrameManager m = getJmriJFrameManager();
1050        synchronized (m) {
1051            m.remove(this);
1052        }
1053        super.dispose();
1054    }
1055
1056    /*
1057     * Save current window size, do not put adjustments here. Search elsewhere for the problem.
1058     */
1059     private void saveWindowSize(jmri.UserPreferencesManager p) {
1060         p.setWindowSize(windowFrameRef, super.getSize());
1061     }
1062
1063    /*
1064     * This field contains a list of properties that do not correspond to the JavaBeans properties coding pattern, or
1065     * known properties that do correspond to that pattern. The default JmriJFrame implementation of
1066     * BeanInstance.hasProperty checks this hashmap before using introspection to find properties corresponding to the
1067     * JavaBean properties coding pattern.
1068     */
1069    protected HashMap<String, Object> properties = new HashMap<>();
1070
1071    /** {@inheritDoc} */
1072    @Override
1073    public void setIndexedProperty(String key, int index, Object value) {
1074        if (BeanUtil.hasIntrospectedProperty(this, key)) {
1075            BeanUtil.setIntrospectedIndexedProperty(this, key, index, value);
1076        } else {
1077            if (!properties.containsKey(key)) {
1078                properties.put(key, new Object[0]);
1079            }
1080            ((Object[]) properties.get(key))[index] = value;
1081        }
1082    }
1083
1084    /** {@inheritDoc} */
1085    @Override
1086    public Object getIndexedProperty(String key, int index) {
1087        if (properties.containsKey(key) && properties.get(key).getClass().isArray()) {
1088            return ((Object[]) properties.get(key))[index];
1089        }
1090        return BeanUtil.getIntrospectedIndexedProperty(this, key, index);
1091    }
1092
1093    /** {@inheritDoc}
1094     * Subclasses should override this method with something more direct and faster
1095     */
1096    @Override
1097    public void setProperty(String key, Object value) {
1098        if (BeanUtil.hasIntrospectedProperty(this, key)) {
1099            BeanUtil.setIntrospectedProperty(this, key, value);
1100        } else {
1101            properties.put(key, value);
1102        }
1103    }
1104
1105    /** {@inheritDoc}
1106     * Subclasses should override this method with something more direct and faster
1107     */
1108    @Override
1109    public Object getProperty(String key) {
1110        if (properties.containsKey(key)) {
1111            return properties.get(key);
1112        }
1113        return BeanUtil.getIntrospectedProperty(this, key);
1114    }
1115
1116    /** {@inheritDoc} */
1117    @Override
1118    public boolean hasProperty(String key) {
1119        return (properties.containsKey(key) || BeanUtil.hasIntrospectedProperty(this, key));
1120    }
1121
1122    /** {@inheritDoc} */
1123    @Override
1124    public boolean hasIndexedProperty(String key) {
1125        return ((this.properties.containsKey(key) && this.properties.get(key).getClass().isArray())
1126                || BeanUtil.hasIntrospectedIndexedProperty(this, key));
1127    }
1128
1129    protected transient WindowInterface windowInterface = null;
1130
1131    /** {@inheritDoc} */
1132    @Override
1133    public void show(JmriPanel child, JmriAbstractAction action) {
1134        if (null != windowInterface) {
1135            windowInterface.show(child, action);
1136        }
1137    }
1138
1139    /** {@inheritDoc} */
1140    @Override
1141    public void show(JmriPanel child, JmriAbstractAction action, Hint hint) {
1142        if (null != windowInterface) {
1143            windowInterface.show(child, action, hint);
1144        }
1145    }
1146
1147    /** {@inheritDoc} */
1148    @Override
1149    public boolean multipleInstances() {
1150        if (null != windowInterface) {
1151            return windowInterface.multipleInstances();
1152        }
1153        return false;
1154    }
1155
1156    public void setWindowInterface(WindowInterface wi) {
1157        windowInterface = wi;
1158    }
1159
1160    public WindowInterface getWindowInterface() {
1161        return windowInterface;
1162    }
1163
1164    /** {@inheritDoc} */
1165    @Override
1166    public Set<String> getPropertyNames() {
1167        Set<String> names = new HashSet<>();
1168        names.addAll(properties.keySet());
1169        names.addAll(BeanUtil.getIntrospectedPropertyNames(this));
1170        return names;
1171    }
1172
1173    public void setAllowInFrameServlet(boolean allow) {
1174        allowInFrameServlet = allow;
1175    }
1176
1177    public boolean getAllowInFrameServlet() {
1178        return allowInFrameServlet;
1179    }
1180
1181    /** {@inheritDoc} */
1182    @Override
1183    public Frame getFrame() {
1184        return this;
1185    }
1186
1187    private static JmriJFrameManager getJmriJFrameManager() {
1188        return InstanceManager.getOptionalDefault(JmriJFrameManager.class).orElseGet(() -> {
1189            return InstanceManager.setDefault(JmriJFrameManager.class, new JmriJFrameManager());
1190        });
1191    }
1192
1193    /**
1194     * A list container of JmriJFrame objects. Not a straight ArrayList, but a
1195     * specific class so that the {@link jmri.InstanceManager} can be used to
1196     * retain the reference to the list instead of relying on a static variable.
1197     */
1198    private static class JmriJFrameManager extends ArrayList<JmriJFrame> {
1199
1200    }
1201
1202    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(JmriJFrame.class);
1203
1204}