diff --git a/.gitignore b/.gitignore index 4782e40dc..33c7019af 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ /megameklab/docs/mml-revision.txt /megameklab/MegaMekLab.l4j.ini units.cache +.mml_tmp diff --git a/megameklab/docs/UserDirHelp.html b/megameklab/docs/UserDirHelp.html index 895448139..1ccb31946 100644 --- a/megameklab/docs/UserDirHelp.html +++ b/megameklab/docs/UserDirHelp.html @@ -1,8 +1,10 @@

How to Use the User Data Directory

-

Use this directory for resources you want to share between different installs or versions of MegaMek, MegaMekLab and MekHQ. Fonts, units, camos, portraits and unit fluff images will also be loaded from this directory (in addition to what is loaded from MegaMek's own data). The directory should be an absolute path such as D:/MyBTStuff (in other words, not relative to your MegaMek directory).

+

Use this directory for resources you want to share between different installs or versions of MegaMek, MegaMekLab and MekHQ. The files listed below will also be loaded from this directory (in addition to what is loaded from MegaMek's own data). The directory should be an absolute path such as D:/MyBTStuff (in other words, not relative to your MegaMek directory).

-

How to place files within the user data directory: +

Generally, all content from the user directory is added to the pre-defined content. In some cases, added content may replace pre-defined content when it has the same name or file path.

+ +

How to place files within the user data directory:

+

+ Additionally, the user directory is used to restore your most recent session in MegaMekLab. + If you set the startup mode of MML to "Restore Tabs", a hidden folder called ".mml_tmp" will be created in your user directory. + You should not touch this folder. Changing the contents of this folder may cause MML to fail to start. + If you have "Restore Tabs" set and MML is failing to start, try deleting the ".mml_tmp" folder, in case it was corrupted somehow. +

+

This is an example of a suitable directory structure with a few example files: -

-D:/myBTStuff
-    Oxanium.ttf
-    Exo.ttf
-    /campaign_units
-        Atlas AS8-XT.mtf
-    /data
-        Jura.ttf
-        /images
-            /camo
-                myForceCamo.png
-                /oldcamo
-                    camo1.png
-                    camo2.png
-            /portraits
-                myPortrait1.png
-            /fluff
-                /Mek
-                    Atlas.png
-                /DropShip
-                    Colossus.png
+

+ D:/myBTStuff
+     Oxanium.ttf
+     Exo.ttf
+     campaign_units/
+         Atlas AS8-XT.mtf
+     data/
+         Jura.ttf
+ MyMMSkin.xml
+         images/
+             camo/
+                 myForceCamo.png
+                 oldcamo/
+                     camo1.png
+                     camo2.png
+             portraits/
+                 minscandboo.png
+             fluff/
+                 Mek/
+                     Atlas.png
+                 DropShip/
+                     Colossus.png
+         universe/
+             ranks.xml
+             awards/
+                 MyAwards.xml
+                 AuriganAwards.xml
+     .mml_tmp/
+         Here be dragons!
+

+ diff --git a/megameklab/resources/megameklab/resources/Dialogs.properties b/megameklab/resources/megameklab/resources/Dialogs.properties index 62efaf624..2a696eef5 100644 --- a/megameklab/resources/megameklab/resources/Dialogs.properties +++ b/megameklab/resources/megameklab/resources/Dialogs.properties @@ -63,7 +63,7 @@ ConfigurationDialog.chkSummaryFormatTRO.tooltip=When checked, text exports are f ConfigurationDialog.chkSkipSavePrompts.text=Disable save prompts. Use at your own risk! ConfigurationDialog.chkSkipSavePrompts.tooltip=When checked, no safety dialogs warning about losing changes when switching unit or unit type will be shown. ConfigurationDialog.startup.text=MML Startup: -ConfigurationDialog.startup.tooltip=Depending on the startup type selected, MML will start in the main menu or, alternatively, directly load the most recent unit or start with a new unit instead of the main menu. +ConfigurationDialog.startup.tooltip=Depending on the startup type selected, MML will start in the main menu or, alternatively, directly load the most recent unit or start with a new unit instead of the main menu, or restore all of your opened tabs.
Restoring tabs restores the state of the entities as they were in the editor, not as you saved them! ConfigurationDialog.mekChassis.text=Mek Chassis Arrangement: ConfigurationDialog.mekChassis.tooltip=Meks with a Clan and an IS chassis name will print their chassis in the selected arrangement. Meks with no clan chassis name will always just print their chassis. ConfigurationDialog.cbMulOpenBehaviour.text=MUL file open behaviour: diff --git a/megameklab/resources/megameklab/resources/Menu.properties b/megameklab/resources/megameklab/resources/Menu.properties index 5b8a40a5e..fd834eea0 100644 --- a/megameklab/resources/megameklab/resources/Menu.properties +++ b/megameklab/resources/megameklab/resources/Menu.properties @@ -15,6 +15,9 @@ MenuBar.accessibleName=Main Menu Bar ### File Menu fileMenu.text=File miResetCurrentUnit.text=Reset Current Unit +miNewTab.text=New Tab +miCloseTab.text=Close Tab +miReopenTab.text=Reopen Closed Tab ## Switch Unit Type Menu switchUnitTypeMenu.text=Switch Unit Type miSwitchToMek.text=Mek @@ -107,6 +110,7 @@ message.exportingInvalidUnit.text=Warning: Exporting an invalid unit! # The following values are used programatically by MMLStartUp MMLStartUp.SPLASH_SCREEN=MML Main UI MMLStartUp.RECENT_UNIT=Last Unit +MMLStartUp.RESTORE_TABS=Restore Tabs MMLStartUp.NEW_MEK=New Mek MMLStartUp.NEW_TANK=New Combat Vehicle MMLStartUp.NEW_BATTLEARMOR=New BattleArmor diff --git a/megameklab/src/megameklab/MegaMekLab.java b/megameklab/src/megameklab/MegaMekLab.java index 7267ea4f6..50a65f8ee 100644 --- a/megameklab/src/megameklab/MegaMekLab.java +++ b/megameklab/src/megameklab/MegaMekLab.java @@ -154,6 +154,7 @@ private static void startup(String[] args) { new StartupGUI().setVisible(true); } } + case RESTORE_TABS -> UiLoader.restoreTabbedUi(); default -> { new StartupGUI().setVisible(true); } diff --git a/megameklab/src/megameklab/ui/MMLStartUp.java b/megameklab/src/megameklab/ui/MMLStartUp.java index fdb9d8853..d413c6885 100644 --- a/megameklab/src/megameklab/ui/MMLStartUp.java +++ b/megameklab/src/megameklab/ui/MMLStartUp.java @@ -31,6 +31,7 @@ public enum MMLStartUp { SPLASH_SCREEN, RECENT_UNIT, + RESTORE_TABS, NEW_MEK, NEW_TANK, NEW_BATTLEARMOR, @@ -62,4 +63,4 @@ public static MMLStartUp parse(String startUpName) { return SPLASH_SCREEN; } } -} \ No newline at end of file +} diff --git a/megameklab/src/megameklab/ui/MegaMekLabMainUI.java b/megameklab/src/megameklab/ui/MegaMekLabMainUI.java index 8bee05b13..a39cf3bb8 100644 --- a/megameklab/src/megameklab/ui/MegaMekLabMainUI.java +++ b/megameklab/src/megameklab/ui/MegaMekLabMainUI.java @@ -35,6 +35,7 @@ public abstract class MegaMekLabMainUI extends JFrame implements RefreshListener protected MenuBar mmlMenuBar; protected boolean refreshRequired = false; private String originalName = ""; + private MegaMekLabTabbedUI owner = null; public MegaMekLabMainUI() { setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); @@ -52,13 +53,13 @@ protected void finishSetup() { protected void setSizeAndLocation() { pack(); - restrictToScrenSize(); + restrictToScreenSize(); setLocationRelativeTo(null); CConfig.getMainUiWindowSize(this).ifPresent(this::setSize); CConfig.getMainUiWindowPosition(this).ifPresent(this::setLocation); } - private void restrictToScrenSize() { + private void restrictToScreenSize() { DisplayMode currentMonitor = getGraphicsConfiguration().getDevice().getDisplayMode(); int scaledMonitorW = UIUtil.getScaledScreenWidth(currentMonitor); int scaledMonitorH = UIUtil.getScaledScreenHeight(currentMonitor); @@ -128,6 +129,10 @@ public boolean exit() { public void refreshHeader() { String fileInfo = fileName.isBlank() ? "" : " (" + fileName + ")"; setTitle(getEntity().getFullChassis() + " " + getEntity().getModel() + fileInfo); + if (owner != null) { + getEntity().generateDisplayName(); + owner.setTabName(getEntity().getDisplayName()); + } } @Override @@ -203,4 +208,8 @@ public boolean hasEntityNameChanged() { public MenuBar getMMLMenuBar() { return mmlMenuBar; } + + public void setOwner(MegaMekLabTabbedUI owner) { + this.owner = owner; + } } diff --git a/megameklab/src/megameklab/ui/MegaMekLabTabbedUI.java b/megameklab/src/megameklab/ui/MegaMekLabTabbedUI.java new file mode 100644 index 000000000..0d8fae8da --- /dev/null +++ b/megameklab/src/megameklab/ui/MegaMekLabTabbedUI.java @@ -0,0 +1,447 @@ +/* + * Copyright (c) 2024 - The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMek is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MegaMek is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MegaMek. If not, see . + */ + +package megameklab.ui; + +import megamek.MegaMek; +import megamek.client.ui.swing.util.UIUtil; +import megamek.common.Entity; +import megamek.common.preference.PreferenceManager; +import megameklab.MMLConstants; +import megameklab.MegaMekLab; +import megameklab.ui.dialog.UiLoader; +import megameklab.ui.mek.BMMainUI; +import megameklab.ui.util.ExitOnWindowClosingListener; +import megameklab.ui.util.TabStateUtil; +import megameklab.util.CConfig; +import megameklab.util.MMLFileDropTransferHandler; +import megameklab.util.UnitUtil; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.*; +import java.io.IOException; +import java.util.*; +import java.util.List; + +/** + * Replaces {@link MegaMekLabMainUI} as the top-level window for MML. + * Holds several {@link MegaMekLabMainUI}s as tabs, allowing many units to be open at once. + */ +public class MegaMekLabTabbedUI extends JFrame implements MenuBarOwner { + private final List editors = new ArrayList<>(); + + private final ReopenTabStack closedEditors = new ReopenTabStack(); + + private final JTabbedPane tabs = new JTabbedPane(); + + private final MenuBar menuBar; + + /** + * Constructs a new MegaMekLabTabbedUI instance, which serves as the main tabbed UI + * for managing multiple MegaMekLabMainUI editors. Automatically initializes a default + * BMMainUI instance if no entities are provided. + * + * @param entities A variable number of MegaMekLabMainUI instances that will be added + * as tabs to the UI. If no entities are provided, a default BMMainUI + * instance will be created and added. + */ + public MegaMekLabTabbedUI(MegaMekLabMainUI... entities) { + super("MegaMekLab"); + + // If there are more tabs than can fit, show a scroll bar instead of stacking tabs in multiple rows + // This is a matter of preference, I could be convinced to switch this. + tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); + + + // Add the given editors as tabs, then add the New Tab Button. + for (MegaMekLabMainUI e : entities) { + addTab(e); + } + addNewTabButton(); + + setContentPane(tabs); + + menuBar = new MenuBar(this); + setJMenuBar(menuBar); + + // Enable opening unit and mul files by drag-and-drop + setTransferHandler(new MMLFileDropTransferHandler(this)); + + // Remember the size and position of the window from last time MML was launched + pack(); + restrictToScreenSize(); + setLocationRelativeTo(null); + CConfig.getMainUiWindowSize(this).ifPresent(this::setSize); + CConfig.getMainUiWindowPosition(this).ifPresent(this::setLocation); + + // ...and save that size and position on exit + setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + addWindowListener(new ExitOnWindowClosingListener(this)); + setExtendedState(CConfig.getIntParam(CConfig.GUI_FULLSCREEN)); + } + + /** + * Retrieves the currently selected editor from the tabbed user interface. + * + * @return The currently selected MegaMekLabMainUI instance, which represents the + * active editor in the tabbed UI. + */ + public MegaMekLabMainUI currentEditor() { + return editors.get(tabs.getSelectedIndex()); + } + + + /** + * Updates the name of the currently selected tab in the tabbed user interface. + * Should typically be called when the name of the unit being edited changes. + * + * @param tabName The new name to be set for the currently selected tab. + */ + public void setTabName(String tabName) { + // ClosableTab is a label with the unit name, and a close button. + // If we didn't need that close button, this could be tabs.setTitleAt + tabs.setTabComponentAt(tabs.getSelectedIndex(), new ClosableTab(tabName, currentEditor()) ); + } + + /** + * Adds a new editor tab to the tabbed UI. This includes adding the editor + * to the internal editor collection, refreshing it, setting the ownership, + * and adding the tab to the tabs UI. + * + * If there already exists a New Tab button, the new tab is placed before it. + * + * @param editor The MegaMekLabMainUI instance to be added as a new tab. + */ + private void addTab(MegaMekLabMainUI editor) { + MegaMekLabMainUI newTab = null; + NewTabButton newTabButton = null; + if (tabs.getTabCount() > 0 && tabs.getTabComponentAt(tabs.getTabCount() - 1) instanceof NewTabButton ntb) { + newTabButton = ntb; + newTab = editors.get(editors.size() - 1); + tabs.removeTabAt(tabs.getTabCount() - 1); + editors.remove(editors.size() - 1); + } + + editors.add(editor); + editor.refreshAll(); + editor.setOwner(this); + tabs.addTab(editor.getEntity().getDisplayName(), editor.getContentPane()); + // See ClosableTab later in this file for what's going on here. + tabs.setTabComponentAt(tabs.getTabCount() - 1, new ClosableTab(editor.getEntity().getDisplayName(), editor)); + + if (newTab != null) { + tabs.addTab("+", newTab.getContentPane()); + tabs.setTabComponentAt(tabs.getTabCount() - 1, newTabButton); + tabs.setEnabledAt(tabs.getTabCount() - 1, false); + editors.add(newTab); + } + } + + /** + * Similar to addTab above, this adds a blank Mek editor, but with the name "➕" + * so that it looks like a button for creating a new tab. + *

+ * The JTabbedPane doesn't come with any functionality for the user adding/removing tabs out of the box, + * so this is how we fake it. + */ + private void addNewTabButton() { + var editor = new BMMainUI(false, false); + editors.add(editor); + editor.refreshAll(); + editor.setOwner(this); + tabs.addTab("➕", editor.getContentPane()); + tabs.setTabComponentAt(tabs.getTabCount() - 1, new NewTabButton()); + tabs.setEnabledAt(tabs.getTabCount() - 1, false); + } + + /** + * The name is misleading, this is actually the Switch Unit Type operation! + * Replaces the current editor with a new blank one of the given unit type. + * Disposes of the old editor UI after the new one is initialized. + * + * @param type the type of unit to load for the new editor UI + * @param primitive whether the unit is primitive + * @param industrial whether the unit is an IndustrialMek + */ + private void newUnit(long type, boolean primitive, boolean industrial) { + var oldUi = editors.get(tabs.getSelectedIndex()); + var newUi = UiLoader.getUI(type, primitive, industrial); + editors.set(tabs.getSelectedIndex(), newUi); + tabs.setComponentAt(tabs.getSelectedIndex(), newUi.getContentPane()); + tabs.setTabComponentAt(tabs.getSelectedIndex(), new ClosableTab(newUi.getEntity().getDisplayName(), newUi)); + tabs.setEnabledAt(tabs.getSelectedIndex(), true); + oldUi.dispose(); + } + + /** + * The name is misleading, this is actually the Switch Unit Type operation! + * Replaces the current editor with a new blank one of the given unit type. + * Disposes of the old editor UI after the new one is initialized. + * + * @param type the type of unit to load for the new editor UI + * @param primitive whether the unit is primitive + */ + @Override + public void newUnit(long type, boolean primitive) { + newUnit(type, primitive, false); + } + + + /** + * Adds a new tab with the given unit to the tabbed user interface. + * + * @param entity The Entity object representing the unit to be added. + * @param filename The name of the file associated with the unit being added. + */ + public void addUnit(Entity entity, String filename) { + // Create a new "new tab" button, since we're about to replace the existing one + addNewTabButton(); + // Select the old "new tab" button... + tabs.setSelectedIndex(tabs.getTabCount() - 2); + // ...and replace it, since newUnit is actually the Switch Unit Type operation. + newUnit(UnitUtil.getEditorTypeForEntity(entity), entity.isPrimitive(), entity.isIndustrialMek()); + + currentEditor().setEntity(entity, filename); + currentEditor().reloadTabs(); + currentEditor().refreshAll(); + // Set the tab name + tabs.setTabComponentAt(tabs.getSelectedIndex(), new ClosableTab(entity.getDisplayName(), currentEditor())); + } + + @Override + public boolean exit() { + if (!currentEditor().safetyPrompt()) { + return false; + } + + CConfig.setParam(CConfig.GUI_FULLSCREEN, Integer.toString(getExtendedState())); + CConfig.setParam(CConfig.GUI_PLAF, UIManager.getLookAndFeel().getClass().getName()); + CConfig.writeMainUiWindowSettings(this); + CConfig.saveConfig(); + PreferenceManager.getInstance().save(); + MegaMek.getMMPreferences().saveToFile(MMLConstants.MM_PREFERENCES_FILE); + MegaMekLab.getMMLPreferences().saveToFile(MMLConstants.MML_PREFERENCES_FILE); + + if (CConfig.getStartUpType() == MMLStartUp.RESTORE_TABS) { + try { + TabStateUtil.saveTabState(editors.stream().limit(editors.size() - 1).toList()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + return true; + } + + private void restrictToScreenSize() { + DisplayMode currentMonitor = getGraphicsConfiguration().getDevice().getDisplayMode(); + int scaledMonitorW = UIUtil.getScaledScreenWidth(currentMonitor); + int scaledMonitorH = UIUtil.getScaledScreenHeight(currentMonitor); + int w = Math.min(getSize().width, scaledMonitorW); + int h = Math.min(getSize().height, scaledMonitorH); + setSize(new Dimension(w, h)); + } + + public void newTab() { + tabs.setEnabledAt(tabs.getTabCount() - 1, true); + tabs.setSelectedIndex(tabs.getTabCount() - 1); + tabs.setTabComponentAt( + tabs.getTabCount() - 1, + new ClosableTab(currentEditor().getEntity().getDisplayName(), currentEditor()) + ); + + addNewTabButton(); + } + + /** + * Deletes the current tab. + * This does not issue the safety prompt, it is up to the caller to do so! + */ + public void closeCurrentTab() { + closeTabAt(tabs.getSelectedIndex()); + } + + private void closeTabAt(int position) { + if (tabs.getTabCount() <= 2) { + MegaMekLabTabbedUI.this.dispatchEvent(new WindowEvent(MegaMekLabTabbedUI.this, WindowEvent.WINDOW_CLOSING)); + } + + var editor = editors.get(position); + + tabs.remove(editor.getContentPane()); + if (tabs.getSelectedIndex() == tabs.getTabCount() - 1) { + tabs.setSelectedIndex(tabs.getSelectedIndex() - 1); + } + editors.remove(editor); + closedEditors.push(editor); + // Tell the menu bar to enable the "reopen tab" shortcut + refreshMenuBar(); + } + + public void reopenTab() { + var editor = closedEditors.pop(); + if (editor != null) { + addTab(editor); + tabs.setSelectedIndex(tabs.getTabCount() - 2); + refreshMenuBar(); + } + } + + public boolean hasClosedTabs() { + return !closedEditors.isEmpty(); + } + + @Override + public JFrame getFrame() { + return this; + } + + @Override + public Entity getEntity() { + return currentEditor().getEntity(); + } + + @Override + public String getFileName() { + return currentEditor().getFileName(); + } + + @Override + public boolean hasEntityNameChanged() { + return currentEditor().hasEntityNameChanged(); + } + + @Override + public void refreshMenuBar() { + menuBar.refreshMenuBar(); + } + + @Override + public MenuBar getMMLMenuBar() { + return menuBar; + } + + + /** + * Represents a button used for creating new tabs in the MegaMekLabTabbedUI interface. + * Used to mimic functionality for adding new tabs in a tabbed user interface. + * Normally this tab should be disabled so it can't be navigated to, then when the + button is clicked + * the tab is replaced with a normal {@link ClosableTab}. + */ + private class NewTabButton extends JPanel { + public NewTabButton() { + setOpaque(false); + var button = new JButton("➕"); + button.setForeground(Color.GREEN); + button.setFont(Font.getFont("Symbola")); + button.setFocusable(false); + button.setBorder(BorderFactory.createEmptyBorder()); + + button.addActionListener(e -> { + newTab(); + }); + + add(button); + } + } + + /** + * Represents a custom tab component for use in a tabbed user interface, designed to display + * the name of a unit and provide a close button for removing the associated tab. + * The close button can be shift-clicked to skip the editor's safety prompt. + * This class extends JPanel and is initialized with a unit name and its associated editor instance. + */ + private class ClosableTab extends JPanel { + JLabel unitName; + JButton closeButton; + MegaMekLabMainUI editor; + + public ClosableTab(String name, MegaMekLabMainUI mainUI) { + unitName = new JLabel(name); + editor = mainUI; + + setOpaque(false); + + closeButton = new JButton("❌"); + closeButton.setFont(Font.getFont("Symbola")); + closeButton.setForeground(Color.RED); + closeButton.setFocusable(false); + closeButton.setBorder(BorderFactory.createEmptyBorder()); + closeButton.setToolTipText("Shift-click to skip the save confirmation dialog"); + add(unitName); + add(closeButton); + closeButton.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.isShiftDown() || editor.safetyPrompt()) { + closeTabAt(editors.indexOf(editor)); + } + } + }); + } + } + + /** + * ReopenTabStack is a utility class that manages a fixed-capacity stack of closed + * MegaMekLabMainUI editors. It allows for storing references to recently closed + * editors and retrieving them in reverse order of their closure, resembling + * a "reopen tab" functionality. + *

+ * This stack maintains a circular buffer of references with a maximum capacity + * defined by the constant STACK_CAPACITY. If the capacity is exceeded, the oldest + * editor will be disposed of and removed to make room for new entries. + */ + private static class ReopenTabStack { + public static final int STACK_CAPACITY = 20; + + private final MegaMekLabMainUI[] closedEditors = new MegaMekLabMainUI[STACK_CAPACITY]; + private int size = 0; + private int start = 0; + + public void push(MegaMekLabMainUI editor) { + int pos = start + size % closedEditors.length; + if (size == closedEditors.length) { + closedEditors[pos].dispose(); + start++; + start %= closedEditors.length; + } else { + size++; + } + closedEditors[pos] = editor; + } + + public MegaMekLabMainUI pop() { + if (size == 0) { + return null; + } + int pos = start + size - 1 % closedEditors.length; + var ret = closedEditors[pos]; + + closedEditors[pos] = null; + size--; + + return ret; + } + + public boolean isEmpty() { + return size == 0; + } + } +} diff --git a/megameklab/src/megameklab/ui/MenuBar.java b/megameklab/src/megameklab/ui/MenuBar.java index 12462e116..4ff5dd196 100644 --- a/megameklab/src/megameklab/ui/MenuBar.java +++ b/megameklab/src/megameklab/ui/MenuBar.java @@ -77,14 +77,16 @@ public MenuBar(MenuBarOwner owner) { /** * Returns the unit main UI, if this menubar is attached to one (instead of the - * StartupGUI - * aka splash screen), null otherwise. + * StartupGUI aka splash screen), null otherwise. + * Under the Tabbed UI, returns the main UI for the currently selected tab. * * @return The unit main UI of this menubar or null */ public @Nullable MegaMekLabMainUI getUnitMainUi() { if (owner instanceof MegaMekLabMainUI) { return (MegaMekLabMainUI) owner; + } else if (owner instanceof MegaMekLabTabbedUI tabbedUI) { + return tabbedUI.currentEditor(); } else { return null; } @@ -124,6 +126,33 @@ private JMenu createFileMenu() { fileMenu.setName("fileMenu"); fileMenu.setMnemonic(KeyEvent.VK_F); + if (owner instanceof MegaMekLabTabbedUI tabbedUI) { + final JMenuItem miNewTab = new JMenuItem(resources.getString("miNewTab.text")); + miNewTab.setName("miNewTab"); + miNewTab.setMnemonic(KeyEvent.VK_N); + miNewTab.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_N, InputEvent.CTRL_DOWN_MASK)); + miNewTab.addActionListener(e -> tabbedUI.newTab()); + fileMenu.add(miNewTab); + + final JMenuItem miCloseTab = new JMenuItem(resources.getString("miCloseTab.text")); + miCloseTab.setName("miCloseTab"); + miCloseTab.setMnemonic(KeyEvent.VK_W); + miCloseTab.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_W, InputEvent.CTRL_DOWN_MASK)); + miCloseTab.addActionListener(e -> { + if (tabbedUI.safetyPrompt()) { + tabbedUI.closeCurrentTab(); + } + }); + fileMenu.add(miCloseTab); + + final JMenuItem miReopenTab = new JMenuItem(resources.getString("miReopenTab.text")); + miReopenTab.setName("miReopenTab"); + miReopenTab.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_T, InputEvent.CTRL_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK)); + miReopenTab.addActionListener(e -> tabbedUI.reopenTab()); + miReopenTab.setEnabled(tabbedUI.hasClosedTabs()); + fileMenu.add(miReopenTab); + } + final JMenuItem miResetCurrentUnit = new JMenuItem(resources.getString("miResetCurrentUnit.text")); miResetCurrentUnit.setName("miResetCurrentUnit"); miResetCurrentUnit.setMnemonic(KeyEvent.VK_R); @@ -166,7 +195,7 @@ private JMenu createFileMenu() { private JMenu createSwitchUnitTypeMenu() { final JMenu switchUnitTypeMenu = new JMenu(resources.getString("switchUnitTypeMenu.text")); switchUnitTypeMenu.setName("switchUnitTypeMenu"); - switchUnitTypeMenu.setMnemonic(KeyEvent.VK_W); + switchUnitTypeMenu.setMnemonic(KeyEvent.VK_U); final Entity entity = owner.getEntity(); @@ -652,7 +681,7 @@ private JMenu createThemesMenu() { private JMenu createUnitValidationMenu() { final JMenu unitValidationMenu = new JMenu(resources.getString("unitValidationMenu.text")); unitValidationMenu.setName("unitValidationMenu"); - unitValidationMenu.setMnemonic(KeyEvent.VK_U); + unitValidationMenu.setMnemonic(KeyEvent.VK_V); final JMenuItem miValidateCurrentUnit = new JMenuItem(resources.getString("CurrentUnit.text")); miValidateCurrentUnit.setName("miValidateCurrentUnit"); @@ -1220,11 +1249,14 @@ public void loadFile(File unitFile) { warnOnInvalid(loadedUnit); newRecentUnit(unitFile.toString()); - if (isStartupGui() || (loadedUnit.getEntityType() != owner.getEntity().getEntityType())) { + if (owner instanceof MegaMekLabTabbedUI tabbedUi) { + tabbedUi.addUnit(loadedUnit, unitFile.toString()); + refresh(); + } else if (isStartupGui() || (loadedUnit.getEntityType() != owner.getEntity().getEntityType())) { owner.getFrame().setVisible(false); owner.getFrame().dispose(); UiLoader.loadUi(loadedUnit, unitFile.toString()); - } else { + } else if (owner instanceof MegaMekLabMainUI ){ getUnitMainUi().setEntity(loadedUnit, unitFile.toString()); UnitUtil.updateLoadedUnit(getUnitMainUi().getEntity()); reload(); diff --git a/megameklab/src/megameklab/ui/MenuBarOwner.java b/megameklab/src/megameklab/ui/MenuBarOwner.java index 7f6699984..e4902f3ee 100644 --- a/megameklab/src/megameklab/ui/MenuBarOwner.java +++ b/megameklab/src/megameklab/ui/MenuBarOwner.java @@ -117,6 +117,7 @@ default void changeTheme(UIManager.LookAndFeelInfo lookAndFeelInfo) { changeTheme(lookAndFeelInfo.getClassName()); } + /** * Sets the look and feel for the application and lets Swing update the current * components. diff --git a/megameklab/src/megameklab/ui/StartupGUI.java b/megameklab/src/megameklab/ui/StartupGUI.java index 2b14cc6da..88b0b5f13 100644 --- a/megameklab/src/megameklab/ui/StartupGUI.java +++ b/megameklab/src/megameklab/ui/StartupGUI.java @@ -277,7 +277,9 @@ public static void selectAndLoadUnitFromCache(MenuBarOwner previousFrame) { } CConfig.setMostRecentFile(fileName); - if (!(previousFrame instanceof MegaMekLabMainUI) + if (previousFrame instanceof MegaMekLabTabbedUI tabbedUi) { + tabbedUi.addUnit(newUnit, fileName); + } else if (!(previousFrame instanceof MegaMekLabMainUI) || (newUnit.getEntityType() != previousFrame.getEntity().getEntityType())) { previousFrame.getFrame().setVisible(false); previousFrame.getFrame().dispose(); diff --git a/megameklab/src/megameklab/ui/dialog/UiLoader.java b/megameklab/src/megameklab/ui/dialog/UiLoader.java index 7a774eff0..bacc395a3 100644 --- a/megameklab/src/megameklab/ui/dialog/UiLoader.java +++ b/megameklab/src/megameklab/ui/dialog/UiLoader.java @@ -19,6 +19,7 @@ package megameklab.ui.dialog; import java.awt.BorderLayout; +import java.io.IOException; import java.util.Map; import java.util.Objects; import java.util.ResourceBundle; @@ -31,7 +32,9 @@ import megamek.client.ui.swing.util.UIUtil; import megamek.common.*; import megameklab.ui.MegaMekLabMainUI; +import megameklab.ui.MegaMekLabTabbedUI; import megameklab.ui.PopupMessages; +import megameklab.ui.StartupGUI; import megameklab.ui.battleArmor.BAMainUI; import megameklab.ui.combatVehicle.CVMainUI; import megameklab.ui.fighterAero.ASMainUI; @@ -41,6 +44,7 @@ import megameklab.ui.mek.BMMainUI; import megameklab.ui.protoMek.PMMainUI; import megameklab.ui.supportVehicle.SVMainUI; +import megameklab.ui.util.TabStateUtil; import megameklab.util.UnitUtil; /** @@ -68,36 +72,20 @@ public class UiLoader { private final boolean industrial; private final Entity newUnit; private final String fileName; + private boolean restore = false; public static void loadUi(Entity newUnit, String fileName) { - if ((newUnit == null) || (newUnit instanceof Mek)) { - new UiLoader(Entity.ETYPE_MEK, false, false, newUnit, fileName).show(); - } else if (newUnit.isSupportVehicle()) { - new UiLoader(Entity.ETYPE_SUPPORT_TANK, false, false, newUnit, fileName).show(); - } else if (newUnit.hasETypeFlag(Entity.ETYPE_SMALL_CRAFT)) { - new UiLoader(Entity.ETYPE_DROPSHIP, newUnit.isPrimitive(), false, newUnit, fileName).show(); - } else if (newUnit.hasETypeFlag(Entity.ETYPE_JUMPSHIP)) { - new UiLoader(Entity.ETYPE_JUMPSHIP, newUnit.isPrimitive(), false, newUnit, fileName).show(); - } else if ((newUnit instanceof Aero) && !(newUnit instanceof FixedWingSupport)) { - new UiLoader(Entity.ETYPE_AERO, newUnit.isPrimitive(), false, newUnit, fileName).show(); - } else if (newUnit instanceof BattleArmor) { - new UiLoader(Entity.ETYPE_BATTLEARMOR, false, false, newUnit, fileName).show(); - } else if (newUnit instanceof Infantry) { - new UiLoader(Entity.ETYPE_INFANTRY, false, false, newUnit, fileName).show(); - } else if (newUnit instanceof ProtoMek) { - new UiLoader(Entity.ETYPE_PROTOMEK, false, false, newUnit, fileName).show(); - } else if ((newUnit instanceof Tank) && !(newUnit instanceof GunEmplacement)) { - new UiLoader(Entity.ETYPE_TANK, false, false, newUnit, fileName).show(); - } else { - PopupMessages.showUiLoadError(null); - new UiLoader(Entity.ETYPE_MEK, false, false, null, "").show(); - } + new UiLoader(UnitUtil.getEditorTypeForEntity(newUnit), newUnit.isPrimitive(), newUnit.isIndustrialMek(), newUnit, fileName).show(); } public static void loadUi(long type, boolean primitive, boolean industrial) { new UiLoader(type, primitive, industrial, null, "").show(); } + public static void restoreTabbedUi() { + new UiLoader(true).show(); + } + /** * @param type - the unit type to load the mainUI from, based on the types * in StartupGUI.java @@ -122,6 +110,16 @@ private UiLoader(long type, boolean primitive, boolean industrial, Entity newUni splashImage.setLocationRelativeTo(null); } + private UiLoader(boolean restore) { + this(0, false, false, null, null); + + if (!restore) { + throw new IllegalArgumentException("Impossible!"); + } + + this.restore = true; + } + /** * Shows the splash image, hides the calling frame and starts loading the new * unit's UI. @@ -132,16 +130,35 @@ public void show() { } private void loadNewUi() { - MegaMekLabMainUI newUI = getUI(type, primitive, industrial); - if (newUnit != null) { - UnitUtil.updateLoadedUnit(newUnit); - newUI.setEntity(newUnit, fileName); - newUI.reloadTabs(); - newUI.refreshAll(); + try { + MegaMekLabTabbedUI tabbedUi; + if (!restore) { + MegaMekLabMainUI newUI = getUI(type, primitive, industrial); + if (newUnit != null) { + UnitUtil.updateLoadedUnit(newUnit); + newUI.setEntity(newUnit, fileName); + newUI.reloadTabs(); + newUI.refreshAll(); + } + tabbedUi = new MegaMekLabTabbedUI(newUI); + tabbedUi.setVisible(true); + } else { + try { + var editors = TabStateUtil.loadTabState().toArray(new MegaMekLabMainUI[0]); + if (editors.length == 0) { + throw new IllegalStateException("Could not restore tabs"); + } + tabbedUi = new MegaMekLabTabbedUI(editors); + tabbedUi.setVisible(true); + } catch (IOException | IllegalStateException e) { + new StartupGUI().setVisible(true); + } + } + + } finally { + splashImage.setVisible(false); + splashImage.dispose(); } - newUI.setVisible(true); - splashImage.setVisible(false); - splashImage.dispose(); } /** diff --git a/megameklab/src/megameklab/ui/util/TabStateUtil.java b/megameklab/src/megameklab/ui/util/TabStateUtil.java new file mode 100644 index 000000000..ecce9602b --- /dev/null +++ b/megameklab/src/megameklab/ui/util/TabStateUtil.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2024 - The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMekLab. + * + * MegaMek is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MegaMek is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MegaMek. If not, see . + */ + +package megameklab.ui.util; + +import megamek.common.Entity; +import megamek.common.Mek; +import megamek.common.MekFileParser; +import megamek.common.loaders.BLKFile; +import megamek.common.loaders.EntityLoadingException; +import megamek.common.loaders.EntitySavingException; +import megamek.common.preference.PreferenceManager; +import megamek.logging.MMLogger; +import megameklab.ui.MegaMekLabMainUI; +import megameklab.ui.dialog.UiLoader; +import megameklab.util.UnitUtil; +import org.apache.commons.io.FileUtils; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.*; +import java.util.regex.Pattern; + +public class TabStateUtil { + private final static MMLogger logger = MMLogger.create(TabStateUtil.class); + + private final static String TAB_STATE_DIRECTORY = ".mml_tmp"; + private final static String TAB_STATE_CLEAN = "clean"; + private final static String FILENAME_ASSOCIATIONS = "filenames.db"; + + public static void saveTabState(List editors) throws IOException { + var dir = getTabStateDirectory(true); + + var clean = new File(dir, TAB_STATE_CLEAN); + if (clean.exists()) { + if (!clean.delete()) { + throw new IOException("Could not delete " + clean); + } + } + + FileUtils.cleanDirectory(dir); + + Map filenameAssociations = new LinkedHashMap<>(); + + for (var editor : editors) { + File unitFile; + if (editor.getEntity() instanceof Mek) { + unitFile = File.createTempFile("mml_unit_", ".mtf.tmp", dir); + try ( + var fos = new FileOutputStream(unitFile); + var ps = new PrintStream(fos) + ) { + ps.println(((Mek) editor.getEntity()).getMtf()); + } catch (Exception e) { + logger.fatal("Failed to write unit while saving tab state.", e); + return; + } + } else { + unitFile = File.createTempFile("mml_unit_", ".blk.tmp", dir); + try { + BLKFile.encode(unitFile.getPath(), editor.getEntity()); + } catch (EntitySavingException e) { + logger.fatal("Failed to write unit while saving tab state.", e); + return; + } + } + + var fileName = editor.getFileName(); + if (fileName == null || fileName.isBlank()) { + fileName = " "; + } + + filenameAssociations.put(unitFile, fileName); + } + + File filenameAssociationsFile = new File(dir, FILENAME_ASSOCIATIONS); + try ( + var fos = new FileOutputStream(filenameAssociationsFile); + var ps = new PrintStream(fos) + ) { + for (var entry : filenameAssociations.entrySet()) { + ps.print(entry.getKey().getPath() + '\0' + entry.getValue() + '\0'); + } + } + + if (!clean.createNewFile()) { + throw new IOException("Could not create " + clean); + } + } + + public static List loadTabState() throws IOException { + var dir = getTabStateDirectory(false); + + List editors = new ArrayList<>(); + + if (dir == null) { + return editors; + } + + var clean = new File(dir, TAB_STATE_CLEAN); + if (!clean.exists()) { + return editors; + } + + var db = new File(dir, FILENAME_ASSOCIATIONS); + if (!db.exists()) { + return editors; + } + + var parts = Files.readString(Paths.get(db.getAbsolutePath())).split(Pattern.quote("\0")); + for (int i = 0; i < parts.length; i += 2) { + var entityFile = new File(parts[i]); + + var newFile = new File(entityFile.getAbsolutePath().replaceFirst("\\.tmp$", "")); + FileUtils.copyFile(entityFile, newFile); + + var fileName = parts[i + 1]; + if (fileName.isBlank()) { + fileName = ""; + } + + try { + Entity loadedUnit = new MekFileParser(newFile).getEntity(); + var editor = UiLoader.getUI(UnitUtil.getEditorTypeForEntity(loadedUnit), loadedUnit.isPrimitive(), loadedUnit.isIndustrialMek()); + editor.setEntity(loadedUnit); + editor.setFileName(fileName); + editor.reloadTabs(); + editor.refreshAll(); + editors.add(editor); + } catch (EntityLoadingException e) { + logger.warn("Could not restore tab for entity file %s:%s".formatted(entityFile, fileName), e); + } finally { + if (!newFile.delete()) { + logger.warn("Could not delete temporary file %s".formatted(newFile)); + } + } + } + + if (!clean.delete()) { + logger.error("Could not mark tab state as dirty on load!"); + } + + return editors; + } + + private static File getTabStateDirectory(boolean create) throws IOException { + var userDirString = PreferenceManager.getClientPreferences().getUserDir(); + if (userDirString == null || userDirString.isBlank()) { + userDirString = "."; + } + + var userDir = new File(userDirString); + + if (!userDir.isDirectory()) { + throw new IOException("User dir is not a directory: " + userDirString); + } + + var tabStateDir = new File(userDir, TAB_STATE_DIRECTORY); + if (!tabStateDir.isDirectory()) { + if (!create) { + return null; + } + if (!tabStateDir.mkdir()) { + throw new IOException("Could not create tab state directory: " + tabStateDir); + } else { + Files.setAttribute(Paths.get(tabStateDir.getAbsolutePath()), "dos:hidden", true); + } + } + + return tabStateDir; + } + + private TabStateUtil() {} +} diff --git a/megameklab/src/megameklab/util/CConfig.java b/megameklab/src/megameklab/util/CConfig.java index 018d1df4e..c7c736ad5 100644 --- a/megameklab/src/megameklab/util/CConfig.java +++ b/megameklab/src/megameklab/util/CConfig.java @@ -36,15 +36,13 @@ import megamek.common.Configuration; import megamek.logging.MMLogger; import megameklab.printing.MekChassisArrangement; -import megameklab.ui.MMLStartUp; -import megameklab.ui.MegaMekLabMainUI; -import megameklab.ui.MenuBarOwner; -import megameklab.ui.PopupMessages; +import megameklab.ui.*; import megameklab.ui.battleArmor.BAMainUI; import megameklab.ui.combatVehicle.CVMainUI; import megameklab.ui.fighterAero.ASMainUI; import megameklab.ui.infantry.CIMainUI; import megameklab.ui.largeAero.DSMainUI; +import megameklab.ui.largeAero.WSMainUI; import megameklab.ui.mek.BMMainUI; import megameklab.ui.protoMek.PMMainUI; import megameklab.ui.supportVehicle.SVMainUI; @@ -84,6 +82,7 @@ public final class CConfig { public static final String GUI_CI_MAINUI_WINDOW = "CIWindow"; public static final String GUI_DS_MAINUI_WINDOW = "DSWindow"; public static final String GUI_WS_MAINUI_WINDOW = "WSWindow"; + public static final String GUI_TABBED_WINDOW = "TabbedWindow"; public static final int RECENT_FILE_COUNT = 10; public static final String FILE_RECENT_PREFIX = "Save_File_"; @@ -431,16 +430,16 @@ public static void writeFileChooserSettings(JDialog dialog) { writeWindowSettings(FILE_CHOOSER_WINDOW, dialog); } - public static Optional getMainUiWindowSize(MegaMekLabMainUI mainUi) { + public static Optional getMainUiWindowSize(MenuBarOwner mainUi) { return getWindowSize(settingForMainUi(mainUi)); } - public static Optional getMainUiWindowPosition(MegaMekLabMainUI mainUi) { + public static Optional getMainUiWindowPosition(MenuBarOwner mainUi) { return getWindowPosition(settingForMainUi(mainUi)); } - public static void writeMainUiWindowSettings(MegaMekLabMainUI mainUi) { - writeWindowSettings(settingForMainUi(mainUi), mainUi); + public static void writeMainUiWindowSettings(MenuBarOwner mainUi) { + writeWindowSettings(settingForMainUi(mainUi), (Component) mainUi); } public static String getRecentFile(int recentFileNumber) { @@ -501,7 +500,7 @@ private static void writeWindowSettings(String cconfigSetting, Component compone saveConfig(); } - private static String settingForMainUi(MegaMekLabMainUI ui) { + private static String settingForMainUi(MenuBarOwner ui) { if (ui instanceof BMMainUI) { return GUI_BM_MAINUI_WINDOW; } else if (ui instanceof CVMainUI) { @@ -518,9 +517,12 @@ private static String settingForMainUi(MegaMekLabMainUI ui) { return GUI_CI_MAINUI_WINDOW; } else if (ui instanceof SVMainUI) { return GUI_SV_MAINUI_WINDOW; - } else { + } else if (ui instanceof WSMainUI) { return GUI_WS_MAINUI_WINDOW; + } else if (ui instanceof MegaMekLabTabbedUI) { + return GUI_TABBED_WINDOW; } + throw new IllegalArgumentException("Unknown MenuBarOwner: " + ui.getClass().getName()); } private static void applyImportedSettings(MenuBarOwner menuBarOwner) { diff --git a/megameklab/src/megameklab/util/UnitUtil.java b/megameklab/src/megameklab/util/UnitUtil.java index 61e2502d7..f51502766 100644 --- a/megameklab/src/megameklab/util/UnitUtil.java +++ b/megameklab/src/megameklab/util/UnitUtil.java @@ -64,6 +64,7 @@ import megamek.common.weapons.srms.StreakSRMWeapon; import megamek.logging.MMLogger; import megameklab.ui.PopupMessages; +import megameklab.ui.dialog.UiLoader; public class UnitUtil { private static final MMLogger logger = MMLogger.create(UnitUtil.class); @@ -2140,4 +2141,28 @@ static boolean isNonMekOrTankWeapon(Entity unit, WeaponType weapon) { } return false; } + + public static long getEditorTypeForEntity(Entity newUnit) { + if ((newUnit == null) || (newUnit instanceof Mek)) { + return Entity.ETYPE_MEK; + } else if (newUnit.isSupportVehicle()) { + return Entity.ETYPE_SUPPORT_TANK; + } else if (newUnit.hasETypeFlag(Entity.ETYPE_SMALL_CRAFT)) { + return Entity.ETYPE_DROPSHIP; + } else if (newUnit.hasETypeFlag(Entity.ETYPE_JUMPSHIP)) { + return Entity.ETYPE_JUMPSHIP; + } else if ((newUnit instanceof Aero) && !(newUnit instanceof FixedWingSupport)) { + return Entity.ETYPE_AERO; + } else if (newUnit instanceof BattleArmor) { + return Entity.ETYPE_BATTLEARMOR; + } else if (newUnit instanceof Infantry) { + return Entity.ETYPE_INFANTRY; + } else if (newUnit instanceof ProtoMek) { + return Entity.ETYPE_PROTOMEK; + } else if ((newUnit instanceof Tank) && !(newUnit instanceof GunEmplacement)) { + return Entity.ETYPE_TANK; + } else { + throw new IllegalArgumentException("Cannot open this entity in an editor"); + } + } }