Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect unsaved work #1685

Merged
merged 4 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion megameklab/src/megameklab/ui/MegaMekLabMainUI.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import megamek.common.Entity;
import megamek.common.Mounted;
import megamek.common.preference.PreferenceManager;
import megameklab.util.EntityChangedUtil;
import megameklab.MMLConstants;
import megameklab.MegaMekLab;
import megameklab.ui.util.ExitOnWindowClosingListener;
Expand Down Expand Up @@ -83,7 +84,7 @@ public void setVisible(boolean b) {

@Override
public boolean safetyPrompt() {
if (CConfig.getBooleanParam(CConfig.MISC_SKIP_SAFETY_PROMPTS)) {
if (CConfig.getBooleanParam(CConfig.MISC_SKIP_SAFETY_PROMPTS) || !EntityChangedUtil.hasEntityChanged(this)) {
return true;
} else {
int savePrompt = JOptionPane.showConfirmDialog(this,
Expand Down
102 changes: 87 additions & 15 deletions megameklab/src/megameklab/ui/MegaMekLabTabbedUI.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,20 @@
import megamek.client.ui.swing.util.UIUtil;
import megamek.common.Entity;
import megamek.common.preference.PreferenceManager;
import megameklab.util.EntityChangedUtil;
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.MegaMekLabFileSaver;
import megameklab.ui.util.TabStateUtil;
import megameklab.util.CConfig;
import megameklab.util.MMLFileDropTransferHandler;
import megameklab.util.UnitUtil;

import javax.swing.*;
import javax.swing.Timer;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import java.awt.*;
Expand Down Expand Up @@ -88,6 +91,11 @@ public MegaMekLabTabbedUI(MegaMekLabMainUI... entities) {
// Enable opening unit and mul files by drag-and-drop
setTransferHandler(new MMLFileDropTransferHandler(this));

// If you can think of a way to detect when the user makes a change to the entity, let me know.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might be able to get by with a 'dirty' flag in the entity or something, but for now let's see how this works out.

// I can't, so we just poll the entity for changed to set the "unsaved work" indicator periodically
// --Pavel Braginskiy (cat /dev/random)
new Timer(500, e -> checkChanged(tabs.getSelectedIndex())).start();

// Remember the size and position of the window from last time MML was launched
pack();
restrictToScreenSize();
Expand Down Expand Up @@ -121,7 +129,8 @@ public MegaMekLabMainUI currentEditor() {
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()) );
tabs.setTabComponentAt(tabs.getSelectedIndex(), new EditorTab(tabName, currentEditor()) );
checkChanged(tabs.getSelectedIndex());
}

/**
Expand All @@ -147,8 +156,9 @@ private void addTab(MegaMekLabMainUI 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));
// See EditorTab later in this file for what's going on here.
tabs.setTabComponentAt(tabs.getTabCount() - 1, new EditorTab(editor.getEntity().getDisplayName(), editor));
checkChanged(tabs.getTabCount() - 1);

if (newTab != null) {
tabs.addTab("+", newTab.getContentPane());
Expand Down Expand Up @@ -189,7 +199,7 @@ private void newUnit(long type, boolean primitive, boolean industrial) {
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.setTabComponentAt(tabs.getSelectedIndex(), new EditorTab(newUi.getEntity().getDisplayName(), newUi));
tabs.setEnabledAt(tabs.getSelectedIndex(), true);
oldUi.dispose();
}
Expand Down Expand Up @@ -226,12 +236,43 @@ public void addUnit(Entity entity, String filename) {
currentEditor().reloadTabs();
currentEditor().refreshAll();
// Set the tab name
tabs.setTabComponentAt(tabs.getSelectedIndex(), new ClosableTab(entity.getDisplayName(), currentEditor()));
tabs.setTabComponentAt(tabs.getSelectedIndex(), new EditorTab(entity.getDisplayName(), currentEditor()));
}

private boolean exitPrompt() {
if (CConfig.getBooleanParam(CConfig.MISC_SKIP_SAFETY_PROMPTS)) {
return true;
}
// No editors have changes, no need to prompt for saving
if (editors.stream().limit(editors.size() - 1).noneMatch(EntityChangedUtil::hasEntityChanged)) {
return true;
}
int savePrompt = JOptionPane.showConfirmDialog(this,
"All unsaved changes to open units will be discarded. Save the units first?",
"Save Units Before Proceeding?",
JOptionPane.YES_NO_CANCEL_OPTION,
JOptionPane.WARNING_MESSAGE);
if (savePrompt == JOptionPane.NO_OPTION) {
return true;
}
// For each editor with unsaved changes, switch to that editor and save it.
if (savePrompt == JOptionPane.YES_OPTION) {
return editors.stream().limit(editors.size() - 1)
.filter(EntityChangedUtil::hasEntityChanged)
.noneMatch(editor -> {
tabs.setSelectedComponent(editor.getContentPane());
// paintImmediately means that the user can see which unit they're about to pick a file for
// because it updates the UI without waiting for this method to return
tabs.paintImmediately(tabs.getBounds());
return !menuBar.saveUnit();
});
}
return false;
}

@Override
public boolean exit() {
if (!currentEditor().safetyPrompt()) {
if (!exitPrompt()) {
return false;
}

Expand Down Expand Up @@ -268,7 +309,7 @@ public void newTab() {
tabs.setSelectedIndex(tabs.getTabCount() - 1);
tabs.setTabComponentAt(
tabs.getTabCount() - 1,
new ClosableTab(currentEditor().getEntity().getDisplayName(), currentEditor())
new EditorTab(currentEditor().getEntity().getDisplayName(), currentEditor())
);

addNewTabButton();
Expand Down Expand Up @@ -342,10 +383,24 @@ public MenuBar getMMLMenuBar() {
return menuBar;
}

@Override
public boolean safetyPrompt() {
return currentEditor().safetyPrompt();
}

@Override
public void stateChanged(ChangeEvent e) {
if (e.getSource() == tabs) {
refreshMenuBar();
checkChanged(checkChangedTab);
checkChangedTab = tabs.getSelectedIndex();
}
}

private int checkChangedTab = 0;
private void checkChanged(int tabIndex) {
if (tabs.getTabCount() >= tabIndex + 1 && tabs.getTabComponentAt(tabIndex) instanceof EditorTab et) {
et.markChanged(EntityChangedUtil.hasEntityChanged(editors.get(tabIndex)));
}
}

Expand All @@ -354,7 +409,7 @@ public void stateChanged(ChangeEvent e) {
* 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}.
* the tab is replaced with a normal {@link EditorTab}.
*/
private class NewTabButton extends JPanel {
public NewTabButton() {
Expand Down Expand Up @@ -466,24 +521,29 @@ private JPopupMenu loadUnitMenu() {
* 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;
private class EditorTab extends JPanel {
private final JLabel changesIndicator;
private final MegaMekLabMainUI editor;

public ClosableTab(String name, MegaMekLabMainUI mainUI) {
unitName = new JLabel(name);
public void markChanged(boolean changed) {
changesIndicator.setText(changed ? "*" : "");
}

public EditorTab(String name, MegaMekLabMainUI mainUI) {
JLabel unitName = new JLabel(name);
changesIndicator = new JLabel();
editor = mainUI;

setOpaque(false);

closeButton = new JButton("❌");
JButton 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(changesIndicator);
add(closeButton);
closeButton.addMouseListener(new MouseAdapter() {
@Override
Expand All @@ -493,6 +553,18 @@ public void mouseClicked(MouseEvent e) {
}
}
});

addMouseListener(
new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
// middle click to close tab
if (e.getButton() == MouseEvent.BUTTON2 && editor.safetyPrompt()) {
closeTabAt(editors.indexOf(editor));
}
}
}
);
}
}

Expand Down
3 changes: 2 additions & 1 deletion megameklab/src/megameklab/ui/MenuBar.java
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,8 @@ public void loadFile(File unitFile) {
throw new Exception();
}

if (!owner.safetyPrompt()) {
// TabbedUi loads a unit into a new tab, no safety prompt needed.
if (!(owner instanceof MegaMekLabTabbedUI) || !owner.safetyPrompt()) {
return;
}

Expand Down
8 changes: 8 additions & 0 deletions megameklab/src/megameklab/ui/util/MegaMekLabFileSaver.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
import megamek.common.loaders.BLKFile;
import megamek.logging.MMLogger;
import megameklab.ui.FileNameManager;
import megameklab.ui.MegaMekLabMainUI;
import megameklab.ui.PopupMessages;
import megameklab.ui.dialog.MMLFileChooser;
import megameklab.util.CConfig;
import megameklab.util.EntityChangedUtil;

import javax.swing.*;
import javax.swing.filechooser.FileNameExtensionFilter;
Expand Down Expand Up @@ -119,6 +121,12 @@ private String saveUnitTo(JFrame ownerFrame, File file, Entity entity) {
} else {
BLKFile.encode(file.getPath(), entity);
}

if (ownerFrame instanceof MegaMekLabMainUI mui) {
// Since we've saved the entity, update the entity being compared against to determine if the user has unsaved work.
EntityChangedUtil.editorSaved(mui);
}

PopupMessages.showUnitSavedMessage(ownerFrame, entity, file);
return file.toString();
} catch (Exception ex) {
Expand Down
117 changes: 117 additions & 0 deletions megameklab/src/megameklab/util/EntityChangedUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/

package megameklab.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.logging.MMLogger;
import megameklab.ui.MegaMekLabMainUI;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Helps to determine if a tab should have an "Unsaved Work" indicator and if the "would you like to save" prompt should appear.
*/
public class EntityChangedUtil {
private static final MMLogger logger = MMLogger.create(EntityChangedUtil.class);

private static final Map<String, Entity> cache = new ConcurrentHashMap<>();

/**
* Determines if the editor has unsaved changes.
* @param editor The MainUI to check for changes
* @return true if saving the unit would produce a different unit than the one saved already, or if there is no existing file for the unit.
*/
public static boolean hasEntityChanged(MegaMekLabMainUI editor) {
var filename = editor.getFileName();
if (filename == null || filename.isBlank()) {
return true;
}

// The idea behind how this works is to store a copy of the unit based on that unit's current file.
// By encoding both the original and current version of the entity to mtf/blk at the same time,
// We ensure that identical units will encode to identical mtf/blk strings, which indicates no unsaved work.

if (!cache.containsKey(filename)) {
var f = new File(filename);
if (!f.exists()) {
return true;
}

try {
var e = new MekFileParser(f).getEntity();
cache.put(filename, e);
} catch (Exception ex) {
logger.error("Entity loading failure:", ex);
cache.put(filename, null);
}
}

try {
var o = encode(cache.get(filename));
var n = encode(editor.getEntity());
return !o.equals(n);
} catch (EntitySavingException e) {
logger.error("Entity encoding failure:", e);
return true;
}
}

/**
*
* @param editor The editor containing the unit that was just saved.
*/
public static void editorSaved(MegaMekLabMainUI editor) {
var filename = editor.getFileName();
if (filename == null || filename.isBlank()) {
return;
}

try {
var bis = new ByteArrayInputStream(encode(editor.getEntity()).getBytes());
cache.put(editor.getFileName(), new MekFileParser(bis, editor.getFileName()).getEntity());
} catch (EntitySavingException | EntityLoadingException e) {
cache.remove(filename);
logger.error("Entity encoding failure:", e);
}
}

private static String encode(Entity e) throws EntitySavingException {
if (e == null) {
return "";
}

if (e instanceof Mek m) {
return m.getMtf();
} else {
var blk = BLKFile.getBlock(e);
return String.join("\n", blk.getAllDataAsString());
}
}

private EntityChangedUtil() {}
}
Loading