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