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        log.debug("super.dispose() called in undecorate()");
390        super.dispose();
391
392        setUndecorated(true);
393        getRootPane().setWindowDecorationStyle(javax.swing.JRootPane.NONE);
394
395        pack();
396        setVisible(visible);
397    }
398
399    /**
400     * Tries to get window to fix entirely on screen. First choice is to move
401     * the origin up and left as needed, then to make the window smaller
402     */
403    void reSizeToFitOnScreen() {
404        int width = this.getPreferredSize().width;
405        int height = this.getPreferredSize().height;
406        Dimension maxSizeDimension = getMaximumSize();
407        log.trace("reSizeToFitOnScreen of \"{}\" starts with maximum size {}", getTitle(), maxSizeDimension);
408        log.trace("reSizeToFitOnScreen starts with preferred height {} width {}", height, width);
409        log.trace("reSizeToFitOnScreen starts with location {},{}", getX(), getY());
410        log.trace("reSizeToFitOnScreen starts with insets {},{}", getInsets().left, getInsets().top);
411        // Normalise the location
412        int screenNb = getContainingDisplay(this.getLocation());
413        ScreenDimensions sd = getScreenDimensions().get(screenNb);
414        Point locationOnDisplay = new Point(getLocation().x - sd.getBounds().x, getLocation().y - sd.getBounds().y);
415        log.trace("reSizeToFitOnScreen normalises origin to {}, {}", locationOnDisplay.x, locationOnDisplay.y);
416
417        if ((width + locationOnDisplay.x) >= maxSizeDimension.getWidth()) {
418            // not fit in width, try to move position left
419            int offsetX = (width + locationOnDisplay.x) - (int) maxSizeDimension.getWidth(); // pixels too large
420            log.trace("reSizeToFitOnScreen moves \"{}\" left {} pixels", getTitle(), offsetX);
421            int positionX = locationOnDisplay.x - offsetX;
422            if (positionX < this.getInsets().left) {
423                positionX = this.getInsets().left;
424                log.trace("reSizeToFitOnScreen sets \"{}\" X to minimum {}", getTitle(), positionX);
425            }
426            this.setLocation(positionX + sd.getBounds().x, this.getY());
427            log.trace("reSizeToFitOnScreen during X calculation sets location {}, {}", positionX + sd.getBounds().x, this.getY());
428            // try again to see if it doesn't fit
429            if ((width + locationOnDisplay.x) >= maxSizeDimension.getWidth()) {
430                width = width - (int) ((width + locationOnDisplay.x) - maxSizeDimension.getWidth());
431                log.trace("reSizeToFitOnScreen sets \"{}\" width to {}", getTitle(), width);
432            }
433        }
434        if ((height + locationOnDisplay.y) >= maxSizeDimension.getHeight()) {
435            // not fit in height, try to move position up
436            int offsetY = (height + locationOnDisplay.y) - (int) maxSizeDimension.getHeight(); // pixels too large
437            log.trace("reSizeToFitOnScreen moves \"{}\" up {} pixels", getTitle(), offsetY);
438            int positionY = locationOnDisplay.y - offsetY;
439            if (positionY < this.getInsets().top) {
440                positionY = this.getInsets().top;
441                log.trace("reSizeToFitScreen sets \"{}\" Y to minimum {}", getTitle(), positionY);
442            }
443            this.setLocation(this.getX(), positionY + sd.getBounds().y);
444            log.trace("reSizeToFitOnScreen during Y calculation sets location {}, {}", getX(), positionY + sd.getBounds().y);
445            // try again to see if it doesn't fit
446            if ((height + this.getY()) >= maxSizeDimension.getHeight()) {
447                height = height - (int) ((height + locationOnDisplay.y) - maxSizeDimension.getHeight());
448                log.trace("reSizeToFitOnScreen sets \"{}\" height to {}", getTitle(), height);
449            }
450        }
451        this.setSize(width, height);
452        log.debug("reSizeToFitOnScreen sets height {} width {} position {},{}", height, width, getX(), getY());
453
454    }
455
456    /**
457     * Move a frame down and to the left by it's top offset or a fixed amount, whichever is larger
458     * @param f JmirJFrame to move
459     */
460    void offSetFrameOnScreen(JmriJFrame f) {
461        /*
462         * We use the frame that we are moving away from for insets, as at this point our own insets have not been correctly
463         * built and always return a size of zero
464         */
465        int REQUIRED_OFFSET = 25; // units are pixels
466        int REQUIRED_OFFSET_X = Math.max(REQUIRED_OFFSET, f.getInsets().left);
467        int REQUIRED_OFFSET_Y = Math.max(REQUIRED_OFFSET, f.getInsets().top);
468
469        int frameOffSetx = this.getX() + REQUIRED_OFFSET_X;
470        int frameOffSety = this.getY() + REQUIRED_OFFSET_Y;
471
472        Dimension dim = getMaximumSize();
473
474        if (frameOffSetx >= (dim.getWidth() * 0.75)) {
475            frameOffSety = 0;
476            frameOffSetx = (f.getInsets().top) * 2;
477        }
478        if (frameOffSety >= (dim.getHeight() * 0.75)) {
479            frameOffSety = 0;
480            frameOffSetx = (f.getInsets().top) * 2;
481        }
482        /*
483         * If we end up with our off Set of X being greater than the width of the screen we start back at the beginning
484         * but with a half offset
485         */
486        if (frameOffSetx >= dim.getWidth()) {
487            frameOffSetx = f.getInsets().top / 2;
488        }
489        this.setLocation(frameOffSetx, frameOffSety);
490    }
491
492    String windowFrameRef;
493
494    public String getWindowFrameRef() {
495        return windowFrameRef;
496    }
497
498    /**
499     * By default, Swing components should be created an installed in this
500     * method, rather than in the ctor itself.
501     */
502    public void initComponents() {
503    }
504
505    /**
506     * Add a standard help menu, including window specific help item.
507     *
508     * Final because it defines the content of a standard help menu, not to be messed with individually
509     *
510     * @param ref    JHelp reference for the desired window-specific help page; null means no page
511     * @param direct true if the help main-menu item goes directly to the help system,
512     *               such as when there are no items in the help menu
513     */
514    final public void addHelpMenu(String ref, boolean direct) {
515        // only works if no menu present?
516        JMenuBar bar = getJMenuBar();
517        if (bar == null) {
518            bar = new JMenuBar();
519        }
520        // add Window menu
521        bar.add(new WindowMenu(this));
522        // add Help menu
523        jmri.util.HelpUtil.helpMenu(bar, ref, direct);
524        setJMenuBar(bar);
525    }
526
527    /**
528     * Adds a "Close Window" key shortcut to close window on op-W.
529     */
530    @SuppressWarnings("deprecation")  // getMenuShortcutKeyMask()
531    void addWindowCloseShortCut() {
532        // modelled after code in JavaDev mailing list item by Bill Tschumy <bill@otherwise.com> 08 Dec 2004
533        AbstractAction act = new AbstractAction() {
534
535            /** {@inheritDoc} */
536            @Override
537            public void actionPerformed(ActionEvent e) {
538                // log.debug("keystroke requested close window ", JmriJFrame.this.getTitle());
539                JmriJFrame.this.processWindowEvent(new java.awt.event.WindowEvent(JmriJFrame.this,
540                        java.awt.event.WindowEvent.WINDOW_CLOSING));
541            }
542        };
543        getRootPane().getActionMap().put("close", act);
544
545        int stdMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx();
546        InputMap im = getRootPane().getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT);
547
548        // We extract the modifiers as a string, then add the I18N string, and
549        // build a key code
550        String modifier = KeyStroke.getKeyStroke(KeyEvent.VK_W, stdMask).toString();
551        String keyCode = modifier.substring(0, modifier.length() - 1)
552                + Bundle.getMessage("VkKeyWindowClose").substring(0, 1);
553
554        im.put(KeyStroke.getKeyStroke(keyCode), "close"); // NOI18N
555        // im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "close");
556    }
557
558    private static String escapeKeyAction = "escapeKeyAction";
559    private boolean escapeKeyActionClosesWindow = false;
560
561    /**
562     * Bind an action to the Escape key.
563     * <p>
564     * Binds an AbstractAction to the Escape key. If an action is already bound
565     * to the Escape key, that action will be replaced. Passing
566     * <code>null</code> unbinds any existing actions from the Escape key.
567     * <p>
568     * Note that binding the Escape key to any action may break expected or
569     * standardized behaviors. See <a
570     * href="http://java.sun.com/products/jlf/ed2/book/Appendix.A.html">Keyboard
571     * Shortcuts, Mnemonics, and Other Keyboard Operations</a> in the Java Look
572     * and Feel Design Guidelines for standardized behaviors.
573     *
574     * @param action The AbstractAction to bind to.
575     * @see #getEscapeKeyAction()
576     * @see #setEscapeKeyClosesWindow(boolean)
577     */
578    public void setEscapeKeyAction(AbstractAction action) {
579        JRootPane root = this.getRootPane();
580        KeyStroke escape = KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0);
581        escapeKeyActionClosesWindow = false; // setEscapeKeyClosesWindow will set to true as needed
582        if (action != null) {
583            root.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(escape, escapeKeyAction);
584            root.getActionMap().put(escapeKeyAction, action);
585        } else {
586            root.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).remove(escape);
587            root.getActionMap().remove(escapeKeyAction);
588        }
589    }
590
591    /**
592     * The action associated with the Escape key.
593     *
594     * @return An AbstractAction or null if no action is bound to the Escape
595     *         key.
596     * @see #setEscapeKeyAction(javax.swing.AbstractAction)
597     * @see javax.swing.AbstractAction
598     */
599    public AbstractAction getEscapeKeyAction() {
600        return (AbstractAction) this.getRootPane().getActionMap().get(escapeKeyAction);
601    }
602
603    /**
604     * Bind the Escape key to an action that closes the window.
605     * <p>
606     * If closesWindow is true, this method creates an action that triggers the
607     * "window is closing" event; otherwise this method removes any actions from
608     * the Escape key.
609     *
610     * @param closesWindow Create or destroy an action to close the window.
611     * @see java.awt.event.WindowEvent#WINDOW_CLOSING
612     * @see #setEscapeKeyAction(javax.swing.AbstractAction)
613     */
614    public void setEscapeKeyClosesWindow(boolean closesWindow) {
615        if (closesWindow) {
616            setEscapeKeyAction(new AbstractAction() {
617
618                /** {@inheritDoc} */
619                @Override
620                public void actionPerformed(ActionEvent ae) {
621                    JmriJFrame.this.processWindowEvent(new java.awt.event.WindowEvent(JmriJFrame.this,
622                            java.awt.event.WindowEvent.WINDOW_CLOSING));
623                }
624            });
625        } else {
626            setEscapeKeyAction(null);
627        }
628        escapeKeyActionClosesWindow = closesWindow;
629    }
630
631    /**
632     * Does the Escape key close the window?
633     *
634     * @return <code>true</code> if Escape key is bound to action created by
635     *         setEscapeKeyClosesWindow, <code>false</code> in all other cases.
636     * @see #setEscapeKeyClosesWindow
637     * @see #setEscapeKeyAction
638     */
639    public boolean getEscapeKeyClosesWindow() {
640        return (escapeKeyActionClosesWindow && getEscapeKeyAction() != null);
641    }
642
643    private int getContainingDisplay(Point location) {
644        // Loop through attached screen to determine which
645        // contains the top-left origin point of this window
646        int si = 0;
647        for (ScreenDimensions sd: getScreenDimensions()) {
648            boolean isOnThisScreen = sd.getBounds().contains(location);
649            log.debug("Is \"{}\" window origin {} located on screen {}? {}", getTitle(), this.getLocation(), sd.getGraphicsDevice().getIDstring(), isOnThisScreen);
650            if (isOnThisScreen) {
651                // We've found the screen that contains this origin
652                return si;
653            }
654            si++;
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 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                int screenNb = getContainingDisplay(this.getLocation());
685                ScreenDimensions sd = getScreenDimensions().get(screenNb);
686                log.trace("getMaximumSize on screen {} with size {}", screenNb, sd.getBounds());
687                int widthInset = sd.getInsets().right + sd.getInsets().left;
688                int heightInset = sd.getInsets().top + sd.getInsets().bottom;
689
690                // If insets are zero, guess based on system type
691                if (widthInset == 0 && heightInset == 0) {
692                    String osName = SystemType.getOSName();
693                    if (SystemType.isLinux()) {
694                        // Linux generally has a bar across the top and/or bottom
695                        // of the screen, but lets you have the full width.
696                        // Linux generally has a bar across the top and/or bottom
697                        // of the main screen, but lets you have the full width.
698                        if ( screenNb == 0) {
699                            heightInset = 70;
700                        }
701                    } // Windows generally has values, but not always,
702                    // so we provide observed values just in case
703                    else if (osName.equals("Windows XP") || osName.equals("Windows 98")
704                            || osName.equals("Windows 2000")) {
705                        heightInset = 28; // bottom 28
706                    }
707                }
708
709                // Insets may also be provided as system parameters
710                String sw = System.getProperty("jmri.inset.width");
711                if (sw != null) {
712                    try {
713                        widthInset = Integer.parseInt(sw);
714                    } catch (NumberFormatException e1) {
715                        log.error("Error parsing jmri.inset.width: {}", e1.getMessage());
716                    }
717                }
718                String sh = System.getProperty("jmri.inset.height");
719                if (sh != null) {
720                    try {
721                        heightInset = Integer.parseInt(sh);
722                    } catch (NumberFormatException e1) {
723                        log.error("Error parsing jmri.inset.height: {}", e1.getMessage());
724                    }
725                }
726
727                // calculate size as screen size minus space needed for offsets
728                log.trace("getMaximumSize returns normally {},{}", (sd.getBounds().width - widthInset), (sd.getBounds().height - heightInset));
729                return new Dimension(sd.getBounds().width - widthInset, sd.getBounds().height - heightInset);
730
731        } catch (NoSuchMethodError e) {
732                Dimension screen = getToolkit().getScreenSize();
733                log.trace("getMaximumSize returns approx due to failure {},{}", screen.width, screen.height);
734                return new Dimension(screen.width, screen.height - 45); // approximate this...
735            }
736        } catch (RuntimeException e2) {
737            // failed completely, fall back to standard method
738            log.trace("getMaximumSize returns super due to failure {}", super.getMaximumSize());
739            return super.getMaximumSize();
740        }
741    }
742
743    /**
744     * {@inheritDoc}
745     * The preferred size must fit on the physical screen, so calculate the
746     * lesser of either the preferred size from the layout or the screen size.
747     *
748     * @return the preferred size or the maximum size, whichever is smaller
749     */
750    @Override
751    public Dimension getPreferredSize() {
752        // limit preferred size to size of screen (from getMaximumSize())
753        Dimension screen = getMaximumSize();
754        int width = Math.min(super.getPreferredSize().width, screen.width);
755        int height = Math.min(super.getPreferredSize().height, screen.height);
756        log.debug("getPreferredSize \"{}\" returns width {} height {}", getTitle(), width, height);
757        return new Dimension(width, height);
758    }
759
760    /**
761     * Get a List of the currently-existing JmriJFrame objects. The returned
762     * list is a copy made at the time of the call, so it can be manipulated as
763     * needed by the caller.
764     *
765     * @return a list of JmriJFrame instances. If there are no instances, an
766     *         empty list is returned.
767     */
768    @Nonnull
769    public static List<JmriJFrame> getFrameList() {
770        JmriJFrameManager m = getJmriJFrameManager();
771        synchronized (m) {
772            return new ArrayList<>(m);
773        }
774    }
775
776    /**
777     * Get a list of currently-existing JmriJFrame objects that are specific
778     * sub-classes of JmriJFrame.
779     * <p>
780     * The returned list is a copy made at the time of the call, so it can be
781     * manipulated as needed by the caller.
782     *
783     * @param <T> generic JmriJframe.
784     * @param type The Class the list should be limited to.
785     * @return An ArrayList of Frames.
786     */
787    @SuppressWarnings("unchecked") // cast in add() checked at run time
788    public static <T extends JmriJFrame> List<T> getFrameList(@Nonnull Class<T> type) {
789        List<T> result = new ArrayList<>();
790        JmriJFrameManager m = getJmriJFrameManager();
791        synchronized (m) {
792            m.stream().filter((f) -> (type.isInstance(f))).forEachOrdered((f) ->
793                {
794                    result.add((T)f);
795                });
796        }
797        return result;
798    }
799
800    /**
801     * Get a JmriJFrame of a particular name. If more than one exists, there's
802     * no guarantee as to which is returned.
803     *
804     * @param name the name of one or more JmriJFrame objects
805     * @return a JmriJFrame with the matching name or null if no matching frames
806     *         exist
807     */
808    public static JmriJFrame getFrame(String name) {
809        for (JmriJFrame j : getFrameList()) {
810            if (j.getTitle().equals(name)) {
811                return j;
812            }
813        }
814        return null;
815    }
816
817    /**
818     * Set whether the frame Position is saved or not after it has been created.
819     *
820     * @param save true if the frame position should be saved.
821     */
822    public void setSavePosition(boolean save) {
823        reuseFrameSavedPosition = save;
824        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent(prefsMgr -> {
825            prefsMgr.setSaveWindowLocation(windowFrameRef, save);
826        });
827    }
828
829    /**
830     * Set whether the frame Size is saved or not after it has been created.
831     *
832     * @param save true if the frame size should be saved.
833     */
834    public void setSaveSize(boolean save) {
835        reuseFrameSavedSized = save;
836        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent(prefsMgr -> {
837            prefsMgr.setSaveWindowSize(windowFrameRef, save);
838        });
839    }
840
841    /**
842     * Returns if the frame Position is saved or not.
843     *
844     * @return true if the frame position should be saved
845     */
846    public boolean getSavePosition() {
847        return reuseFrameSavedPosition;
848    }
849
850    /**
851     * Returns if the frame Size is saved or not.
852     *
853     * @return true if the frame size should be saved
854     */
855    public boolean getSaveSize() {
856        return reuseFrameSavedSized;
857    }
858
859    /**
860     * {@inheritDoc}
861     * A frame is considered "modified" if it has changes that have not been
862     * stored.
863     */
864    @Override
865    public void setModifiedFlag(boolean flag) {
866        this.modifiedFlag = flag;
867        // mark the window in the GUI
868        markWindowModified(this.modifiedFlag);
869    }
870
871    /** {@inheritDoc} */
872    @Override
873    public boolean getModifiedFlag() {
874        return modifiedFlag;
875    }
876
877    private boolean modifiedFlag = false;
878
879    /**
880     * Handle closing a window or quiting the program while the modified bit was
881     * set.
882     */
883    protected void handleModified() {
884        if (getModifiedFlag()) {
885            this.setVisible(true);
886            int result = JmriJOptionPane.showOptionDialog(this, Bundle.getMessage("WarnChangedMsg"),
887                    Bundle.getMessage("WarningTitle"), JmriJOptionPane.YES_NO_OPTION,
888                    JmriJOptionPane.WARNING_MESSAGE, null, // icon
889                    new String[]{Bundle.getMessage("WarnYesSave"), Bundle.getMessage("WarnNoClose")}, Bundle
890                    .getMessage("WarnYesSave"));
891            if (result == 0 ) { // array option 0 , WarnYesSave
892                // user wants to save
893                storeValues();
894            }
895        }
896    }
897
898    protected void storeValues() {
899        log.error("default storeValues does nothing for \"{}\"", getTitle());
900    }
901
902    // For marking the window as modified on Mac OS X
903    // See: https://web.archive.org/web/20090712161630/http://developer.apple.com/qa/qa2001/qa1146.html
904    final static String WINDOW_MODIFIED = "windowModified";
905
906    public void markWindowModified(boolean yes) {
907        getRootPane().putClientProperty(WINDOW_MODIFIED, yes ? Boolean.TRUE : Boolean.FALSE);
908    }
909
910    // Window methods
911    /** Does nothing in this class */
912    @Override
913    public void windowOpened(java.awt.event.WindowEvent e) {
914    }
915
916    /** Does nothing in this class */
917    @Override
918    public void windowClosed(java.awt.event.WindowEvent e) {
919    }
920
921    /** Does nothing in this class */
922    @Override
923    public void windowActivated(java.awt.event.WindowEvent e) {
924    }
925
926    /** Does nothing in this class */
927    @Override
928    public void windowDeactivated(java.awt.event.WindowEvent e) {
929    }
930
931    /** Does nothing in this class */
932    @Override
933    public void windowIconified(java.awt.event.WindowEvent e) {
934    }
935
936    /** Does nothing in this class */
937    @Override
938    public void windowDeiconified(java.awt.event.WindowEvent e) {
939    }
940
941    /**
942     * {@inheritDoc}
943     *
944     * The JmriJFrame implementation calls {@link #handleModified()}.
945     */
946    @Override
947    @OverridingMethodsMustInvokeSuper
948    public void windowClosing(java.awt.event.WindowEvent e) {
949        handleModified();
950    }
951
952    /** Does nothing in this class */
953    @Override
954    public void componentHidden(java.awt.event.ComponentEvent e) {
955    }
956
957    /** {@inheritDoc} */
958    @Override
959    public void componentMoved(java.awt.event.ComponentEvent e) {
960        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent(p -> {
961            if (reuseFrameSavedPosition && isVisible()) {
962                p.setWindowLocation(windowFrameRef, this.getLocation());
963            }
964        });
965    }
966
967    /** {@inheritDoc} */
968    @Override
969    public void componentResized(java.awt.event.ComponentEvent e) {
970        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent(p -> {
971            if (reuseFrameSavedSized && isVisible()) {
972                saveWindowSize(p);
973            }
974        });
975    }
976
977    /** Does nothing in this class */
978    @Override
979    public void componentShown(java.awt.event.ComponentEvent e) {
980    }
981
982    private transient AbstractShutDownTask task = null;
983
984    protected void setShutDownTask() {
985        task = new AbstractShutDownTask(getTitle()) {
986            @Override
987            public Boolean call() {
988                handleModified();
989                return Boolean.TRUE;
990            }
991
992            @Override
993            public void run() {
994            }
995        };
996        InstanceManager.getDefault(ShutDownManager.class).register(task);
997    }
998
999    protected boolean reuseFrameSavedPosition = true;
1000    protected boolean reuseFrameSavedSized = true;
1001
1002    /**
1003     * {@inheritDoc}
1004     *
1005     * When window is finally destroyed, remove it from the list of windows.
1006     * <p>
1007     * Subclasses that over-ride this method must invoke this implementation
1008     * with super.dispose() right before returning.
1009     */
1010    @OverridingMethodsMustInvokeSuper
1011    @Override
1012    public void dispose() {
1013        log.debug("JmriJFrame dispose invoked on {}", getTitle());
1014        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent(p -> {
1015            if (reuseFrameSavedPosition) {
1016                p.setWindowLocation(windowFrameRef, this.getLocation());
1017            }
1018            if (reuseFrameSavedSized) {
1019                saveWindowSize(p);
1020            }
1021        });
1022        log.debug("dispose \"{}\"", getTitle());
1023        if (windowInterface != null) {
1024            windowInterface.dispose();
1025        }
1026        if (task != null) {
1027            jmri.InstanceManager.getDefault(jmri.ShutDownManager.class).deregister(task);
1028            task = null;
1029        }
1030        JmriJFrameManager m = getJmriJFrameManager();
1031        synchronized (m) {
1032            m.remove(this);
1033        }
1034        
1035        // workaround for code that directly calls dispose()
1036        // instead of dispatching a WINDOW_CLOSED event.  This
1037        // causes the windowClosing method to not be called. This in turn is an
1038        // issue because people have put code in the windowClosed method that
1039        // should really be in windowClosing.
1040        ThreadingUtil.runOnGUIDelayed(() -> {
1041            removeWindowListener(this);
1042            removeComponentListener(this);
1043        }, 500);
1044        
1045        super.dispose();
1046    }
1047
1048    /*
1049     * Save current window size, do not put adjustments here. Search elsewhere for the problem.
1050     */
1051     private void saveWindowSize(jmri.UserPreferencesManager p) {
1052         p.setWindowSize(windowFrameRef, super.getSize());
1053     }
1054
1055    /*
1056     * This field contains a list of properties that do not correspond to the JavaBeans properties coding pattern, or
1057     * known properties that do correspond to that pattern. The default JmriJFrame implementation of
1058     * BeanInstance.hasProperty checks this hashmap before using introspection to find properties corresponding to the
1059     * JavaBean properties coding pattern.
1060     */
1061    protected HashMap<String, Object> properties = new HashMap<>();
1062
1063    /** {@inheritDoc} */
1064    @Override
1065    public void setIndexedProperty(String key, int index, Object value) {
1066        if (BeanUtil.hasIntrospectedProperty(this, key)) {
1067            BeanUtil.setIntrospectedIndexedProperty(this, key, index, value);
1068        } else {
1069            if (!properties.containsKey(key)) {
1070                properties.put(key, new Object[0]);
1071            }
1072            ((Object[]) properties.get(key))[index] = value;
1073        }
1074    }
1075
1076    /** {@inheritDoc} */
1077    @Override
1078    public Object getIndexedProperty(String key, int index) {
1079        if (properties.containsKey(key) && properties.get(key).getClass().isArray()) {
1080            return ((Object[]) properties.get(key))[index];
1081        }
1082        return BeanUtil.getIntrospectedIndexedProperty(this, key, index);
1083    }
1084
1085    /** {@inheritDoc}
1086     * Subclasses should override this method with something more direct and faster
1087     */
1088    @Override
1089    public void setProperty(String key, Object value) {
1090        if (BeanUtil.hasIntrospectedProperty(this, key)) {
1091            BeanUtil.setIntrospectedProperty(this, key, value);
1092        } else {
1093            properties.put(key, value);
1094        }
1095    }
1096
1097    /** {@inheritDoc}
1098     * Subclasses should override this method with something more direct and faster
1099     */
1100    @Override
1101    public Object getProperty(String key) {
1102        if (properties.containsKey(key)) {
1103            return properties.get(key);
1104        }
1105        return BeanUtil.getIntrospectedProperty(this, key);
1106    }
1107
1108    /** {@inheritDoc} */
1109    @Override
1110    public boolean hasProperty(String key) {
1111        return (properties.containsKey(key) || BeanUtil.hasIntrospectedProperty(this, key));
1112    }
1113
1114    /** {@inheritDoc} */
1115    @Override
1116    public boolean hasIndexedProperty(String key) {
1117        return ((this.properties.containsKey(key) && this.properties.get(key).getClass().isArray())
1118                || BeanUtil.hasIntrospectedIndexedProperty(this, key));
1119    }
1120
1121    protected transient WindowInterface windowInterface = null;
1122
1123    /** {@inheritDoc} */
1124    @Override
1125    public void show(JmriPanel child, JmriAbstractAction action) {
1126        if (null != windowInterface) {
1127            windowInterface.show(child, action);
1128        }
1129    }
1130
1131    /** {@inheritDoc} */
1132    @Override
1133    public void show(JmriPanel child, JmriAbstractAction action, Hint hint) {
1134        if (null != windowInterface) {
1135            windowInterface.show(child, action, hint);
1136        }
1137    }
1138
1139    /** {@inheritDoc} */
1140    @Override
1141    public boolean multipleInstances() {
1142        if (null != windowInterface) {
1143            return windowInterface.multipleInstances();
1144        }
1145        return false;
1146    }
1147
1148    public void setWindowInterface(WindowInterface wi) {
1149        windowInterface = wi;
1150    }
1151
1152    public WindowInterface getWindowInterface() {
1153        return windowInterface;
1154    }
1155
1156    /** {@inheritDoc} */
1157    @Override
1158    public Set<String> getPropertyNames() {
1159        Set<String> names = new HashSet<>();
1160        names.addAll(properties.keySet());
1161        names.addAll(BeanUtil.getIntrospectedPropertyNames(this));
1162        return names;
1163    }
1164
1165    public void setAllowInFrameServlet(boolean allow) {
1166        allowInFrameServlet = allow;
1167    }
1168
1169    public boolean getAllowInFrameServlet() {
1170        return allowInFrameServlet;
1171    }
1172
1173    /** {@inheritDoc} */
1174    @Override
1175    public Frame getFrame() {
1176        return this;
1177    }
1178
1179    private static JmriJFrameManager getJmriJFrameManager() {
1180        return InstanceManager.getOptionalDefault(JmriJFrameManager.class).orElseGet(() -> {
1181            return InstanceManager.setDefault(JmriJFrameManager.class, new JmriJFrameManager());
1182        });
1183    }
1184
1185    /**
1186     * A list container of JmriJFrame objects. Not a straight ArrayList, but a
1187     * specific class so that the {@link jmri.InstanceManager} can be used to
1188     * retain the reference to the list instead of relying on a static variable.
1189     */
1190    private static class JmriJFrameManager extends ArrayList<JmriJFrame> {
1191
1192    }
1193
1194    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(JmriJFrame.class);
1195
1196}