001/*
002 * Please feel free to use any fragment of the code in this file that you need in your own
003 * work. As far as I am concerned, it's in the public domain. No permission is necessary
004 * or required. Credit is always appreciated if you use a large chunk or base a
005 * significant product on one of my examples, but that's not required either.
006 * 
007 * This code is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
008 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
009 * PURPOSE.
010 * 
011 * --- Joseph A. Huwaldt
012 */
013package jahuwaldt.swing;
014
015import java.awt.*;
016import java.awt.desktop.*;
017import java.awt.event.ActionEvent;
018import java.awt.event.ActionListener;
019import java.awt.event.WindowAdapter;
020import java.awt.event.WindowEvent;
021import java.io.FilenameFilter;
022import java.io.IOException;
023import java.net.URI;
024import java.util.*;
025import java.util.List;
026import static java.util.Objects.*;
027import javax.swing.*;
028
029
030/**
031 * This class defines a MacOS friendly application instance. It is a replacement for
032 * Steve Roy's excellent MRJAdapter package of old that adds Window Menu support and other
033 * features used by Multi-Document-Interface (MDI) applications. Under MacOS, an
034 * application instance will automatically call "handleNew()" method if the user brings
035 * the application forward and there are no open window's registered. On non-Mac
036 * platforms, the application will quit once the last document window has been closed.
037 *
038 * <p> Modified by: Joseph A. Huwaldt </p>
039 *
040 * @author Joseph A. Huwaldt, Date: April 25, 2004
041 * @version December 24, 2023
042 */
043public class MDIApplication {
044
045    /**
046     * The application instance.
047     */
048    private static MDIApplication instance;
049    
050        /**
051         * The name of the application.
052         */
053        private String name;
054    
055    /**
056     * A reference to the AWT Desktop.
057     */
058    private static Desktop desktop;
059    
060    static {
061        if (Desktop.isDesktopSupported())
062            desktop = Desktop.getDesktop();
063    }
064    
065        /**
066         * The frameless menu bar that we have set.
067         */
068        private static JMenuBar framelessMenuBar;
069    
070    /**
071     * A list of all open document windows.
072     */
073    private static final Stack<Window> openWindows = new Stack();
074
075    /**
076     * A list of references to JMenu objects used to hold "Windows" menus.
077     */
078    private static final ArrayList<JMenu> windowsMenus = new ArrayList();
079
080    /**
081     * A list of objects that want to be notified if the application is going to quit.
082     */
083    private static final List<QuitListener> quitList = new ArrayList();
084
085    /**
086     * The resource bundle for this application.
087     */
088    private static ResourceBundle resBundle = null;
089
090    /**
091     * The user preferences for this application.
092     */
093    private static Preferences prefs = null;
094
095    /**
096     * The filename filter for this application.
097     */
098    private static FilenameFilter fnFilter = null;
099
100    /**
101     * Flag indicating if the application should quit when the last window is closed.
102     */
103    private static boolean quitOnClose = !AppUtilities.isMacOS();
104    
105    private QuitJMenuItem quitJMenuItem = null;
106    private AboutJMenuItem aboutJMenuItem = null;
107    private PreferencesJMenuItem prefsJMenuItem = null;
108    
109    private static AboutHandler aboutHdlr = null;
110    private static PreferencesHandler prefsHdlr = null;
111    
112
113    //-------------------------------------------------------------------------
114    /**
115     * Constructor a new MDIApplication instance with no name. Note that only one can ever
116     * be created. Attempting to instantiate more will result in an
117     * <code>IllegalStateException</code> being thrown.
118     */
119    protected MDIApplication(String name) {
120        if (nonNull(instance))
121            throw new IllegalStateException();
122        instance = this;
123        
124        // Set the application name.
125        if (nonNull(name))
126                setName(name);
127        
128        //  Handle "Application ReOpen Event" by bringing a window forward or by creating a
129        //  new blank document window if there are no currently open windows.
130        desktop.addAppEventListener(new AppReopenedListener() {
131            @Override
132            public void appReopened(AppReopenedEvent e) {
133                // If a window exists, bring it to the front, otherwise create a new document window.
134                if (!openWindows.empty()) {
135                    Window window = openWindows.peek();
136                    window.setVisible(true);
137
138                } else {
139                    //  Create a new blank document window for this application.
140                    handleNew(null);
141                }
142            }
143        });
144
145    }
146
147        /**
148         * Get the unique application instance. If it doesn't exist,
149         * this method creates it.
150         * @return the unique application instance
151         */
152        public static synchronized MDIApplication getInstance() {
153                if (isNull(instance))
154                        new MDIApplication(null);
155                return instance;
156        }
157        
158     //-------------------------------------------------------------------------
159        /**
160         * Set the name of the application.
161         * @param appName the name of the application
162         */
163        public final void setName(String appName) {
164                name = appName;
165        }
166        
167        /**
168         * Get the name of the application.
169         * @return the name of the application
170         */
171        public String getName() {
172                if (isNull(name))
173                        name = "";
174                return name;
175        }
176        
177        /**
178         * Set the Swing frameless menu bar. This menu bar is shown when
179         * no frame is visible. This state is normal for a Mac application
180         * Note that this method won't have any visual effect if the application
181     * is running on platforms other than MacOS. This method sets
182         * the menu bar internally so that it will be properly returned by
183         * a subsequent call to <code>getFramelessMenuBar()<code>.
184         * @param menuBar the Swing menu bar to use as frameless menu bar
185         */
186        public static void setFramelessJMenuBar(JMenuBar menuBar) {
187                if (Desktop.isDesktopSupported() && desktop.isSupported(Desktop.Action.APP_MENU_BAR)) {
188            desktop.setDefaultMenuBar(menuBar);
189        }
190        framelessMenuBar = menuBar;
191        }
192
193        /**
194         * Get the Swing frameless menu bar. This method is functional on
195         * all platforms.
196         * @return the Swing frameless menu bar or null if it hasn't been set.
197         */
198        public static JMenuBar getFramelessJMenuBar() {
199                return framelessMenuBar;
200        }
201
202    /**
203     * Used to set the resource bundle for this application. This is provided as a
204     * convenience for storing the resource bundle so that it is accessible throughout an
205     * application.
206     *
207     * @param bundle The resource bundle to be stored with this application.
208     */
209    public static void setResourceBundle(ResourceBundle bundle) {
210        resBundle = bundle;
211    }
212
213    /**
214     * Returns the resource bundle stored with this application. If no resource bundle has
215     * been stored, then null is returned.
216     *
217     * @return The resource bundle stored with this application.
218     */
219    public ResourceBundle getResourceBundle() {
220        return resBundle;
221    }
222
223    /**
224     * Used to set the user preferences for this application. This is provided as a
225     * convenience for storing the preferences so that it is accessible throughout an
226     * application.
227     *
228     * @param appPrefs The user preferences for this application.
229     */
230    public static final void setPreferences(Preferences appPrefs) {
231        prefs = appPrefs;
232    }
233
234    /**
235     * @return a reference to the user preferences for this application.
236     */
237    public final Preferences getPreferences() {
238        return prefs;
239    }
240
241    /**
242     * Installs a handler to show a custom Preference window for your application.
243     *
244     * @param prefsHandler the handler to respond to the
245     *                     PreferencesHandler.handlePreferences(PreferencesEvent) message
246     * @throws SecurityException if a security manager exists and it denies the
247     * RuntimePermission("canProcessApplicationEvents") permission
248     * @throws UnsupportedOperationException if the current platform does not support the
249     * Desktop.Action.APP_ABOUT action.
250     */
251    public static void setPreferencesHandler(PreferencesHandler preferencesHandler) {
252        prefsHdlr = preferencesHandler;
253        if (Desktop.isDesktopSupported() && desktop.isSupported(Desktop.Action.APP_ABOUT))
254            desktop.setPreferencesHandler(prefsHdlr);
255    }
256    
257        /**
258         * Get the About item as a Swing menu item. This item will automatically have the correct
259     * text and will have an ActionListener registered that will call the registered AboutHandler
260     * if the ActionEvent is triggered.
261     * 
262         * @return the About Swing menu item
263         */
264        public PreferencesJMenuItem getPreferencesJMenuItem() {
265                return getPreferencesJMenuItem("Preferences");
266        }
267        
268        /**
269         * Get the About item as a Swing menu item. This item will automatically have the correct
270     * text and will have an ActionListener registered that will call the registered AboutHandler
271     * if the ActionEvent is triggered.
272     * 
273     * @param prefsLabel the label for the Preferences menu item.
274         * @return the About Swing menu item
275         */
276        public PreferencesJMenuItem getPreferencesJMenuItem(String prefsLabel) {
277                if (isNull(prefsJMenuItem)) {
278                        prefsJMenuItem = new PreferencesJMenuItem(prefsLabel);
279            prefsJMenuItem.addActionListener(new ActionListener() {
280                @Override
281                public void actionPerformed(ActionEvent e) {
282                    if (nonNull(prefsHdlr)) {
283                        PreferencesEvent pe = new PreferencesEvent();
284                        prefsHdlr.handlePreferences(pe);
285                    }
286                }
287            });
288        }
289                return prefsJMenuItem;
290        }
291        
292    /**
293     * Used to set the filename filter for this application. This is provided as a
294     * convenience for storing the filename filter so that it is accessible throughout an
295     * application.
296     *
297     * @param filter The filename filter to be stored with this application.
298     */
299    public static void setFilenameFilter(FilenameFilter filter) {
300        fnFilter = filter;
301    }
302
303    /**
304     * Return a reference to this application's default file name filter or null if a
305     * filename filter has not been stored.
306     *
307     * @return The default filename filter for this application.
308     */
309    public static FilenameFilter getFilenameFilter() {
310        return fnFilter;
311    }
312
313    /**
314     * Sets a flag indicating if the application should quit when the last window is
315     * closed (true) or stay open (false; allowing the user to select "New" from the file
316     * menu for instance). The default value is true except on MacOS X where the default
317     * value if false.
318     *
319     * @param flag Set to true to have the application automatically quit when all the
320     *             windows close.
321     */
322    public static void setQuitOnClose(boolean flag) {
323        quitOnClose = flag;
324    }
325
326    /**
327     * Returns a flag indicating if the application should quit when the last window is
328     * closed (true) or stay open (false; allowing the user to select "New" from the file
329     * menu for instance). The default value is true except on MacOS X where the default
330     * value if false.
331     *
332     * @return A flag indicating if the application should quit when the last window is
333     *         closed.
334     */
335    public static boolean getQuitOnClose() {
336        return quitOnClose;
337    }
338
339        /**
340         * Get the Quit item as a Swing menu item. This item will automatically have the correct
341     * text and shortcut key for the platform and will have an ActionListener registered
342     * that will call System.exit(0) if the ActionEvent is triggered.
343     * 
344         * @return the Quit Swing menu item
345         */
346        public QuitJMenuItem getQuitJMenuItem() {
347                return getQuitJMenuItem("Exit");
348        }
349        
350        /**
351         * Get the Quit item as a Swing menu item. This item will automatically have the correct
352     * text and shortcut key for the platform and will have an ActionListener registered
353     * that will call System.exit(0) if the ActionEvent is triggered.
354     * 
355     * @param exitLabel The label for the quit menu on non-MacOS platforms.
356         * @return the Quit Swing menu item
357         */
358        public QuitJMenuItem getQuitJMenuItem(String exitLabel) {
359                if (isNull(quitJMenuItem)) {
360                        quitJMenuItem = new QuitJMenuItem(this, exitLabel);
361            quitJMenuItem.addActionListener(new ActionListener() {
362                @Override
363                public void actionPerformed(ActionEvent e) {
364                    //  Ask Java to quit if the user selects this menu item.
365                    //  The quit handler (if one is registered) will be called automatically.
366                    System.exit(0);
367                }
368            });
369        }
370                return quitJMenuItem;
371        }
372        
373    /**
374     * Register the supplied window with the list of windows managed by this
375     * MDIApplication. When the window is made visible, it will be added to the "Windows"
376     * menu. The window will be removed from the "Windows" menu when it is closed.
377     *
378     * @param window The window to be added to the list of open windows.
379     */
380    public static void addWindow(Window window) {
381        if (openWindows.contains(window))
382            return;   //  Don't add a window twice.
383        
384        //  Add a window event listener in order to keep the open windows list and menus up to date.
385        window.addWindowListener(new WindowAdapter() {
386            @Override
387            public void windowClosed(WindowEvent e) {
388                Window aWindow = e.getWindow();
389                int idx = openWindows.indexOf(aWindow);
390                if (idx >= 0) {
391                    removeMenuItem(openWindows.size() - idx - 1);
392                    openWindows.remove(idx);
393                }
394
395                //  If requested, quit if there are no open windows.
396                if (quitOnClose && openWindows.isEmpty())
397                    handleQuit(null,null);
398            }
399
400            @Override
401            public void windowActivated(WindowEvent e) {
402                Window aWindow = e.getWindow();
403                if (aWindow.isVisible()) {
404                    int idx = openWindows.indexOf(aWindow);
405                    if (idx >= 0) {
406                        //  Change the position of the window in the Windows menu.
407                        removeMenuItem(openWindows.size() - idx - 1);
408                        openWindows.remove(idx);
409                        openWindows.push(aWindow);
410                        addNewMenuItem(aWindow);
411                        
412                    } else {
413                        //  Add a new item to the Windows menu.
414                        openWindows.push(aWindow);
415                        addNewMenuItem(aWindow);
416                    }
417                }
418            }
419        });
420
421    }
422
423    /**
424     * Register an object to receive notification that the application is going to quit
425     * soon.
426     *
427     * @param listener The listener to register.
428     */
429    public static void addQuitListener(QuitListener listener) {
430        quitList.add(listener);
431    }
432
433    /**
434     * Method to remove a quit listener from the list of quit listeners for this
435     * application.
436     *
437     * @param listener The listener to remove.
438     */
439    public static void removeQuitListener(QuitListener listener) {
440        quitList.remove(listener);
441    }
442
443    /**
444     * Installs the handler which determines if the application should quit. The handler
445     * is passed a one-shot QuitResponse which can cancel or proceed with the quit.
446     * Setting the handler to null causes all quit requests to directly perform the
447     * default QuitStrategy.
448     *
449     * @param listener The listener to register.
450     */
451    public static void setQuitHandler​(QuitHandler quitHandler) {
452        if (Desktop.isDesktopSupported() && desktop.isSupported(Desktop.Action.APP_QUIT_HANDLER))
453            desktop.setQuitHandler(quitHandler);
454    }
455
456    /**
457     * Launches the default browser to display a URI. If the default browser is not able
458     * to handle the specified URI, the application registered for handling URIs of the
459     * specified type is invoked. The application is determined from the protocol and path
460     * of the URI, as defined by the URI class.
461     *
462     * @param uri the URI to be displayed in the user default browser.
463     */
464    public static void browse(URI uri) throws IOException {
465        if (Desktop.isDesktopSupported() && desktop.isSupported(Desktop.Action.BROWSE))
466            desktop.browse(uri);
467    }
468    
469    /**
470     * Installs a handler to show a custom About window for your application.
471     *
472     * @param aboutHandler the handler to respond to the
473     *                     AboutHandler.handleAbout(AboutEvent) message
474     * @throws SecurityException if a security manager exists and it denies the
475     * RuntimePermission("canProcessApplicationEvents") permission
476     * @throws UnsupportedOperationException if the current platform does not support the
477     * Desktop.Action.APP_ABOUT action.
478     */
479    public static void setAboutHandler(AboutHandler aboutHandler) {
480        aboutHdlr = aboutHandler;
481        if (Desktop.isDesktopSupported() && desktop.isSupported(Desktop.Action.APP_ABOUT))
482            desktop.setAboutHandler(aboutHandler);
483    }
484    
485        /**
486         * Get the About item as a Swing menu item. This item will automatically have the correct
487     * text and will have an ActionListener registered that will call the registered AboutHandler
488     * if the ActionEvent is triggered.
489     * 
490         * @return the About Swing menu item
491         */
492        public AboutJMenuItem getAboutJMenuItem() {
493                if (isNull(aboutJMenuItem)) {
494                        aboutJMenuItem = new AboutJMenuItem(this);
495            aboutJMenuItem.addActionListener(new ActionListener() {
496                @Override
497                public void actionPerformed(ActionEvent e) {
498                    if (nonNull(aboutHdlr)) {
499                        AboutEvent ae = new AboutEvent();
500                        aboutHdlr.handleAbout(ae);
501                    }
502                }
503            });
504        }
505                return aboutJMenuItem;
506        }
507        
508    /**
509     * Installs the handler which is notified when the application is asked to open a list
510     * of files.
511     *
512     * @param openFilesHandler handler
513     * @throws SecurityException if a security manager exists and its
514     * SecurityManager.checkRead(java.lang.String) method denies read access to the files,
515     * or it denies the RuntimePermission("canProcessApplicationEvents") permission, or
516     * the calling thread is not allowed to create a subprocess.
517     * @throws UnsupportedOperationException if the current platform does not support the
518     * Desktop.Action.APP_OPEN_FILE action
519     */
520    public static void setOpenFileHandler(OpenFilesHandler openFilesHandler) throws SecurityException, UnsupportedOperationException {
521        if (Desktop.isDesktopSupported() && desktop.isSupported(Desktop.Action.APP_OPEN_FILE))
522            desktop.setOpenFileHandler(openFilesHandler);
523    }
524    
525    /**
526     * Returns a reference to the top-most window in the list of all open windows
527     * registered with this application. If there are no open windows, null is returned.
528     *
529     * @return The top-most window in the list of all open windows.
530     */
531    public static Window getTopWindow() {
532        Window window = null;
533        if (!openWindows.empty())
534            window = openWindows.peek();
535        return window;
536    }
537
538    /**
539     * Get an unmodifiable list of all the currently open windows in the application.
540     *
541     * @return An unmodifiable list of all the currently open windows.
542     */
543    public static List<Window> allOpenWindows() {
544        return Collections.unmodifiableList(openWindows);
545    }
546
547    /**
548     * Get a new JMenu instance that can be used as a Windows menu using the specified
549     * title (typically "Windows"). The menu that is returned is maintained by this class
550     * and should not be modified by the user.
551     *
552     * @param title The title for the Windows menu.
553     * @return A JMenu that represents the application's Windows menu.
554     */
555    public static JMenu newWindowsMenu(String title) {
556        JMenu menu = new JMenu(title);
557
558        //  Add the new menu to our list of Windows menus.
559        windowsMenus.add(menu);
560
561        createNewMenuItems(menu);
562
563        return menu;
564    }
565
566    /**
567     * Call this method when a window being shown in a Windows menu has changed it's
568     * title. This method will update the titles shown in the Windows menu.
569     *
570     * @param window The window that has changed titles.
571     */
572    public static void windowTitleChanged(Window window) {
573        //  Find the window in the list of open windows.
574        int idx = openWindows.indexOf(window);
575        if (idx < 0)
576            return;
577
578        //  Adjust the index to be top down instead of bottom up.
579        idx = openWindows.size() - idx - 1;
580
581        if (idx >= 0 && !windowsMenus.isEmpty()) {
582            //  Get the new title.
583            String title = getWindowTitle(window);
584
585            //  Loop over all the Windows Menus in existence.
586            for (JMenu menu : windowsMenus) {
587                if (menu != null) {
588                    JMenuItem item = menu.getItem(idx);
589                    item.setText(title);
590                }
591            }
592        }
593    }
594
595    /**
596     * Return the title of the given window (assuming it is either a Frame or a Dialog).
597     * If it is actually a Window instance, this returns the title of the parent Frame.
598     *
599     * @param window The window to return the title for.
600     */
601    private static String getWindowTitle(Window window) {
602        String title;
603        if (window instanceof Frame)
604            title = ((Frame)window).getTitle();
605        else if (window instanceof Dialog)
606            title = ((Dialog)window).getTitle();
607        else {
608            Frame frame = AppUtilities.getFrameForComponent(window);
609            title = frame.getTitle();
610        }
611        return title;
612    }
613
614    /**
615     * Method that adds a menu item to the specified menu for each open window.
616     */
617    private static void createNewMenuItems(JMenu menu) {
618        for (int i = openWindows.size() - 1; i >= 0; --i) {
619            Window window = openWindows.get(i);
620            JMenuItem item = new JMenuItem(getWindowTitle(window));
621            item.addActionListener(new MenuListener(window));
622            menu.add(item);
623        }
624    }
625
626    /**
627     * Method that adds a single new menu item to the top of ALL the Windows menus.
628     */
629    private static void addNewMenuItem(Window window) {
630        if (!windowsMenus.isEmpty()) {
631
632            String title = getWindowTitle(window);
633            for (Iterator<JMenu> i = windowsMenus.iterator(); i.hasNext();) {
634                JMenu menu = i.next();
635
636                if (menu != null) {
637                    JMenuItem item = new JMenuItem(title);
638                    item.addActionListener(new MenuListener(window));
639                    menu.add(item, 0);
640                } else {
641                    i.remove();
642                }
643            }
644
645        }
646    }
647
648    /**
649     * Method that removes a single menu item from ALL of the Windows menus.
650     */
651    private static void removeMenuItem(int idx) {
652        if (!windowsMenus.isEmpty()) {
653
654            for (Iterator<JMenu> i = windowsMenus.iterator(); i.hasNext();) {
655                JMenu menu = i.next();
656                if (menu != null) {
657                    menu.remove(idx);
658                } else {
659                    i.remove();
660                }
661            }
662
663        }
664    }
665
666    /**
667     * Class that provides an action listener for Window menu items. When this listener is
668     * called, it brings it's window to the front.
669     */
670    private static class MenuListener implements ActionListener {
671
672        private final Window theWindow;
673
674        MenuListener(Window window) {
675            theWindow = window;
676        }
677
678        @Override
679        public void actionPerformed(ActionEvent event) {
680            theWindow.setVisible(true);
681        }
682    }
683
684    /**
685     * Handle the user requesting a new document window. If it is inappropriate for the
686     * application to create a new document window, then this method should do nothing.
687     * The default implementation does nothing and returns null.
688     *
689     * @param event The event that caused this method to be called. May be "null" if this
690     *              method is called by MDIApplication or one of it's subclasses.
691     * @return A reference to the newly created window frame or null if a window was not
692     *         created.
693     */
694    public Frame handleNew(ActionEvent event) {
695        return null;
696    }
697
698    /**
699     * Handle the user choosing "Close" from the File menu. This implementation dispatches
700     * a "Window Closing" event on the top most window.
701     *
702     * @param event The event that caused this method to be called.
703     */
704    public static void handleClose(ActionEvent event) {
705        if (!openWindows.isEmpty()) {
706            Window window = openWindows.peek();
707            window.dispatchEvent(new WindowEvent(window, WindowEvent.WINDOW_CLOSING));
708        }
709    }
710
711    /**
712     * Handle the user choosing "Quit" from the File menu. This implementation dispatches a
713     * separate "window closing" event to each window. This gives each window an
714     * opportunity to cancel the quit process.
715     *
716     * @param event The event that caused this method to be called or null if none.
717     * @param response the one-shot response object used to cancel or proceed with the quit action or null if none.
718     * @return True if the application can proceed with the quit or false if it shoudl be canceled.
719     */
720    public static synchronized boolean handleQuit(QuitEvent event, QuitResponse response) {
721
722        boolean quitFlag = true;
723        if (!quitList.isEmpty()) {
724    
725            // Loop over all the quit listeners, notifying them one at a time.
726            // When the last notification is made.  The application will quit.
727            QuitListener[] array = new QuitListener[quitList.size()];
728            quitList.toArray(array);
729            for (QuitListener next : array) {
730
731                //  Tell the listener that we are going to quit.
732                boolean cancel = next.quit();
733                if (cancel) {
734                    quitFlag = false;
735                    break;
736                }
737            }
738        }
739        
740        if (nonNull(response)) {
741            if (quitFlag)
742                response.performQuit();
743            else
744                response.cancelQuit();
745        }
746        
747        return quitFlag;
748    }
749
750}