launcher: wizard bar follows the copied megabytes, saves table shows 4 rows at the minimum size, mods tab keeps focus and caret when it is rebuilt, take-over notice expires; geom_export previews in game space; apptest checks every menu step with longer holds, loads slot 2, retries an early crash once and records load
This commit is contained in:
1 parent
d030c9c6c7
commit
d6f84be125
19 files changed
+650
-67
No files matched your search
@@ -53,7 +53,7 @@ public final class Installer {
|
||||
List<GameSource.SrcFile> cd = src.cdFiles();
|
||||
long total = totalSize(hd) + totalSize(cd);
|
||||
long[] done = {0};
|
||||
Progress copyP = p.sub(0, 0.85);
|
||||
Progress copyP = p.sub(0, COPY_SHARE);
|
||||
// files already copied are only trusted when an interrupted install of the same source
|
||||
// resumes; a new setup (other or repaired image) copies everything again
|
||||
Path marker = paths.root().resolve("original").resolve(".install-source");
|
||||
@@ -152,6 +152,9 @@ public final class Installer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Share of {@link #install}'s progress that is the file copy (byte-proportional); the rest is quick steps. */
|
||||
public static final double COPY_SHARE = 0.85;
|
||||
|
||||
/** "Copying 123 of 568 MB (wads/t1l1m001.wad)". */
|
||||
static String copyMessage(long done, long total, String file) {
|
||||
return String.format(Locale.ROOT, "Copying %d of %d MB (%s)", done >> 20, total >> 20, file);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.eliasmueller.llamamod.ui;
|
||||
|
||||
import java.awt.Component;
|
||||
import java.awt.Container;
|
||||
import java.awt.event.FocusAdapter;
|
||||
import java.awt.event.FocusEvent;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.text.JTextComponent;
|
||||
|
||||
/**
|
||||
* Keeps keyboard focus (and caret/selection) across a rebuild that replaces components: inputs are tagged with a
|
||||
* stable key, the focused one is remembered before the rebuild and its replacement focused afterwards.
|
||||
*/
|
||||
final class FocusKeeper {
|
||||
private static final String KEY = "llamamod.focusKey";
|
||||
private static final String SELECT_ALL_POLICY = "JTextField.selectAllOnFocusPolicy";
|
||||
|
||||
/** What had the focus: the tag of the input and, for text, caret and selection. */
|
||||
record Snapshot(String key, int caret, int selStart, int selEnd) {}
|
||||
|
||||
private FocusKeeper() {}
|
||||
|
||||
static <T extends JComponent> T tag(T c, String key) {
|
||||
c.putClientProperty(KEY, key);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** The tagged input that contains {@code focusOwner} (e.g. a spinner's text field), or null. */
|
||||
static Snapshot capture(Component focusOwner, Container root) {
|
||||
for (Component c = focusOwner; c != null && c != root; c = c.getParent()) {
|
||||
if (c instanceof JComponent j && j.getClientProperty(KEY) instanceof String key) {
|
||||
if (focusOwner instanceof JTextComponent t) {
|
||||
return new Snapshot(key, t.getCaretPosition(), t.getSelectionStart(), t.getSelectionEnd());
|
||||
}
|
||||
return new Snapshot(key, -1, -1, -1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Finds the replacement of the remembered input under {@code root}, restores caret/selection; null if gone. */
|
||||
static Component restore(Snapshot s, Container root) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
JComponent c = find(root, s.key());
|
||||
if (c == null) {
|
||||
return null;
|
||||
}
|
||||
Component target =
|
||||
c instanceof JSpinner sp && sp.getEditor() instanceof JSpinner.DefaultEditor ed ? ed.getTextField() : c;
|
||||
if (target instanceof JTextComponent t && s.caret() >= 0) {
|
||||
int len = t.getDocument().getLength();
|
||||
int a = Math.min(s.selStart(), len);
|
||||
int b = Math.min(s.selEnd(), len);
|
||||
if (a != b) {
|
||||
// keep the direction: the caret is where the user left it
|
||||
t.setCaretPosition(s.caret() == a ? b : a);
|
||||
t.moveCaretPosition(Math.min(s.caret(), len));
|
||||
} else {
|
||||
t.setCaretPosition(Math.min(s.caret(), len));
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Focuses the restored input without FlatLaf's select-all on focus gain (it would eat the next key). */
|
||||
static void focus(Component c) {
|
||||
if (c instanceof JTextComponent t) {
|
||||
t.putClientProperty(SELECT_ALL_POLICY, "never");
|
||||
t.addFocusListener(new FocusAdapter() {
|
||||
@Override
|
||||
public void focusGained(FocusEvent e) {
|
||||
t.removeFocusListener(this);
|
||||
SwingUtilities.invokeLater(() -> t.putClientProperty(SELECT_ALL_POLICY, null));
|
||||
}
|
||||
});
|
||||
}
|
||||
c.requestFocusInWindow();
|
||||
}
|
||||
|
||||
private static JComponent find(Container root, String key) {
|
||||
for (Component c : root.getComponents()) {
|
||||
if (c instanceof JComponent j && key.equals(j.getClientProperty(KEY))) {
|
||||
return j;
|
||||
}
|
||||
if (c instanceof Container sub) {
|
||||
JComponent f = find(sub, key);
|
||||
if (f != null) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ final class MainFrame extends JFrame {
|
||||
private final HeaderPanel header = new HeaderPanel();
|
||||
private final JButton playButton = new JButton(Strings.t("play"));
|
||||
private final JLabel statusLabel = new JLabel(" ");
|
||||
private final StatusLine statusLine = new StatusLine(statusLabel, StatusLine.TRANSIENT_MS);
|
||||
private final JProgressBar progress = new JProgressBar(0, 1000);
|
||||
private final JButton cancelButton = new JButton(Strings.t("cancel"));
|
||||
private final JTabbedPane tabs = new JTabbedPane();
|
||||
@@ -124,15 +125,19 @@ final class MainFrame extends JFrame {
|
||||
return l;
|
||||
}
|
||||
|
||||
/** Short windows (laptops, 720p) get a lower header so the mod list keeps its room. */
|
||||
/** Short windows (laptops, 720p) get a lower header so the mod list and the save table keep their room. */
|
||||
private void fitHeader() {
|
||||
int h = getHeight() < 860 ? 104 : 150;
|
||||
int h = headerHeight(getHeight());
|
||||
if (header.getPreferredSize().height != h) {
|
||||
header.setPreferredSize(new Dimension(900, h));
|
||||
header.revalidate();
|
||||
}
|
||||
}
|
||||
|
||||
static int headerHeight(int windowHeight) {
|
||||
return windowHeight < 700 ? 92 : windowHeight < 860 ? 104 : 150;
|
||||
}
|
||||
|
||||
private void buildUi() {
|
||||
JPanel root = new JPanel(new BorderLayout());
|
||||
header.setLayout(new BorderLayout());
|
||||
@@ -284,7 +289,7 @@ final class MainFrame extends JFrame {
|
||||
}
|
||||
modsPanel.reload();
|
||||
settingsPanel.reload();
|
||||
setStatus(Strings.t("status.configMerged"));
|
||||
setTransientStatus(Strings.t("status.configMerged"));
|
||||
}
|
||||
|
||||
void onBusy(Consumer<Boolean> l) {
|
||||
@@ -300,7 +305,11 @@ final class MainFrame extends JFrame {
|
||||
}
|
||||
|
||||
void setStatus(String s) {
|
||||
statusLabel.setText(s);
|
||||
statusLine.set(s);
|
||||
}
|
||||
|
||||
void setTransientStatus(String s) {
|
||||
statusLine.flash(s);
|
||||
}
|
||||
|
||||
private void updateGameRunning() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.eliasmueller.llamamod.mods.levels.LevelProjects;
|
||||
import com.eliasmueller.llamamod.util.SwingListeners;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FlowLayout;
|
||||
@@ -17,6 +18,7 @@ import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.Insets;
|
||||
import java.awt.KeyboardFocusManager;
|
||||
import java.awt.Point;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
@@ -116,6 +118,9 @@ final class ModsPanel extends JPanel {
|
||||
// rebuilding the cards must not move the list: new spinners and text fields scroll themselves into view
|
||||
Point keep = firstReload ? new Point() : scroll.getViewport().getViewPosition();
|
||||
firstReload = false;
|
||||
// e.g. a config merge while the user types in an option: the new field gets the focus and caret
|
||||
FocusKeeper.Snapshot focus = FocusKeeper.capture(
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(), list);
|
||||
list.removeAll();
|
||||
boxes.clear();
|
||||
removeButtons.clear();
|
||||
@@ -148,6 +153,10 @@ final class ModsPanel extends JPanel {
|
||||
updateState();
|
||||
list.revalidate();
|
||||
list.repaint();
|
||||
Component refocus = FocusKeeper.restore(focus, list);
|
||||
if (refocus != null) {
|
||||
FocusKeeper.focus(refocus);
|
||||
}
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
var viewport = scroll.getViewport();
|
||||
viewport.validate();
|
||||
@@ -248,7 +257,7 @@ final class ModsPanel extends JPanel {
|
||||
outer.add(c, BorderLayout.CENTER);
|
||||
JCheckBox cb = new JCheckBox();
|
||||
cb.setSelected(frame.state.cfg.enabledMods.contains(m.id()));
|
||||
boxes.put(m.id(), cb);
|
||||
boxes.put(m.id(), FocusKeeper.tag(cb, m.id()));
|
||||
GridBagConstraints g = cell(0, 0, 1, GridBagConstraints.NORTHWEST);
|
||||
g.gridheight = 2;
|
||||
g.insets = new Insets(0, 0, 0, 10);
|
||||
@@ -451,7 +460,7 @@ final class ModsPanel extends JPanel {
|
||||
int row = 0;
|
||||
for (ModOption o : m.options()) {
|
||||
String v = values.get(o.key());
|
||||
JComponent input = optionInput(m, o, v);
|
||||
JComponent input = optionInput(m, o, v, m.id() + "/" + o.key());
|
||||
String tip = tip(o);
|
||||
GridBagConstraints g = new GridBagConstraints();
|
||||
g.gridy = row;
|
||||
@@ -513,12 +522,12 @@ final class ModsPanel extends JPanel {
|
||||
return t;
|
||||
}
|
||||
|
||||
private JComponent optionInput(Mod m, ModOption o, String value) {
|
||||
private JComponent optionInput(Mod m, ModOption o, String value, String key) {
|
||||
switch (o.type()) {
|
||||
case BOOL -> {
|
||||
JCheckBox b = new JCheckBox(o.label(), Boolean.parseBoolean(value));
|
||||
b.addActionListener(e -> setOption(m, o, Boolean.toString(b.isSelected()), b));
|
||||
optionInputs.add(b);
|
||||
optionInputs.add(FocusKeeper.tag(b, key));
|
||||
return b;
|
||||
}
|
||||
case INT -> {
|
||||
@@ -527,7 +536,7 @@ final class ModsPanel extends JPanel {
|
||||
Long.valueOf(v), Long.valueOf(o.min()), Long.valueOf(o.max()), Long.valueOf(1)));
|
||||
s.addChangeListener(e -> setOption(m, o, Long.toString(((Number) s.getValue()).longValue()), s));
|
||||
s.setPreferredSize(new Dimension(110, s.getPreferredSize().height));
|
||||
optionInputs.add(s);
|
||||
optionInputs.add(FocusKeeper.tag(s, key));
|
||||
return s;
|
||||
}
|
||||
case CHOICE -> {
|
||||
@@ -540,7 +549,7 @@ final class ModsPanel extends JPanel {
|
||||
}
|
||||
c.addActionListener(e ->
|
||||
setOption(m, o, o.choices().get(c.getSelectedIndex()).value(), c));
|
||||
optionInputs.add(c);
|
||||
optionInputs.add(FocusKeeper.tag(c, key));
|
||||
return c;
|
||||
}
|
||||
case MULTI -> {
|
||||
@@ -563,16 +572,16 @@ final class ModsPanel extends JPanel {
|
||||
}
|
||||
setOption(m, o, o.multiValue(chosen), p);
|
||||
});
|
||||
optionInputs.add(b);
|
||||
optionInputs.add(FocusKeeper.tag(b, key + "#" + c.value()));
|
||||
p.add(b);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
case SEED -> {
|
||||
JTextField t = textInput(m, o, value, 12);
|
||||
JTextField t = textInput(m, o, value, 12, key);
|
||||
JButton random = new JButton(Strings.t("mods.option.random"));
|
||||
random.addActionListener(e -> t.setText(ModOptions.randomSeed()));
|
||||
optionInputs.add(random);
|
||||
optionInputs.add(FocusKeeper.tag(random, key + "#random"));
|
||||
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
|
||||
p.setOpaque(false);
|
||||
p.add(t);
|
||||
@@ -581,15 +590,15 @@ final class ModsPanel extends JPanel {
|
||||
return p;
|
||||
}
|
||||
default -> {
|
||||
return textInput(m, o, value, 20);
|
||||
return textInput(m, o, value, 20, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private JTextField textInput(Mod m, ModOption o, String value, int columns) {
|
||||
private JTextField textInput(Mod m, ModOption o, String value, int columns, String key) {
|
||||
JTextField t = new JTextField(value, columns);
|
||||
SwingListeners.onTextChange(t, () -> setOption(m, o, t.getText(), t));
|
||||
optionInputs.add(t);
|
||||
optionInputs.add(FocusKeeper.tag(t, key));
|
||||
return t;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,9 +78,14 @@ final class SavesPanel extends JPanel {
|
||||
JPanel left = new JPanel(new BorderLayout());
|
||||
JLabel hint = App.hintLabel(Strings.t("saves.hint"), 200, 11.5f);
|
||||
hint.setBorder(BorderFactory.createEmptyBorder(12, 2, 0, 0));
|
||||
// the table's legend lives here, where there is room: at the minimum window height every
|
||||
// line under the table would cost a table row
|
||||
JLabel coinsHint = App.hintLabel(Strings.t("saves.coinsHint"), 200, 11.5f);
|
||||
coinsHint.setBorder(BorderFactory.createEmptyBorder(10, 2, 0, 0));
|
||||
JPanel leftTop = new JPanel(new BorderLayout());
|
||||
leftTop.add(slots, BorderLayout.NORTH);
|
||||
leftTop.add(hint, BorderLayout.CENTER);
|
||||
leftTop.add(coinsHint, BorderLayout.SOUTH);
|
||||
left.add(leftTop, BorderLayout.NORTH);
|
||||
add(left, BorderLayout.WEST);
|
||||
|
||||
@@ -94,7 +99,7 @@ final class SavesPanel extends JPanel {
|
||||
slotTitle.setFont(slotTitle.getFont().deriveFont(Font.BOLD, 17f));
|
||||
ed.add(slotTitle, g);
|
||||
g.gridy++;
|
||||
g.insets = new Insets(2, 0, 10, 0);
|
||||
g.insets = new Insets(2, 0, 4, 0);
|
||||
ed.add(slotInfo, g);
|
||||
|
||||
for (int w = 1; w <= Worlds.COUNT; w++) {
|
||||
@@ -143,10 +148,6 @@ final class SavesPanel extends JPanel {
|
||||
JScrollPane sp = new JScrollPane(table);
|
||||
sp.setPreferredSize(new Dimension(420, 180));
|
||||
ed.add(sp, g);
|
||||
g.gridy++;
|
||||
g.weighty = 0;
|
||||
g.fill = GridBagConstraints.HORIZONTAL;
|
||||
ed.add(App.hintLabel(Strings.t("saves.coinsHint"), 2000, 11.5f), g);
|
||||
add(ed, BorderLayout.CENTER);
|
||||
|
||||
JPanel buttons = new JPanel(new BorderLayout());
|
||||
|
||||
@@ -313,6 +313,24 @@ final class SetupWizard extends JDialog {
|
||||
dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar follows the copied megabytes exactly (so it matches the "Copying x of y MB" text); the short steps
|
||||
* after the copy show a busy bar instead of a percentage.
|
||||
*/
|
||||
private void showProgress(double installFraction, String message) {
|
||||
double copy = barFraction(installFraction);
|
||||
boolean copying = copy < 1;
|
||||
bar.setIndeterminate(!copying);
|
||||
bar.setStringPainted(copying);
|
||||
bar.setValue((int) Math.round(copy * 1000));
|
||||
progressText.setText(message);
|
||||
}
|
||||
|
||||
/** Copy share of the installer progress mapped onto 0..1. */
|
||||
static double barFraction(double installFraction) {
|
||||
return Math.max(0, Math.min(1, installFraction / Installer.COPY_SHARE));
|
||||
}
|
||||
|
||||
private void startInstall() {
|
||||
if (!checkSource()) {
|
||||
frame.error(new IllegalArgumentException(Strings.t("wizard.noSource")));
|
||||
@@ -331,10 +349,7 @@ final class SetupWizard extends JDialog {
|
||||
Progress p = new Progress() {
|
||||
@Override
|
||||
public void update(double f, String m) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
bar.setValue((int) (f * 1000));
|
||||
progressText.setText(m);
|
||||
});
|
||||
SwingUtilities.invokeLater(() -> showProgress(f, m));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -346,10 +361,10 @@ final class SetupWizard extends JDialog {
|
||||
@Override
|
||||
protected Object doInBackground() throws Exception {
|
||||
try (GameSource gs = GameSource.open(src)) {
|
||||
new Installer(state.paths).install(gs, state.cfg, p.sub(0, 0.95));
|
||||
new Installer(state.paths).install(gs, state.cfg, p);
|
||||
}
|
||||
// re-setup keeps the active mods
|
||||
state.modManager.apply(state.activeMods(), p.sub(0.95, 1));
|
||||
state.modManager.apply(state.activeMods(), (f, m) -> p.update(1, m));
|
||||
return state.saves.migrateNullSaves();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.eliasmueller.llamamod.ui;
|
||||
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.Timer;
|
||||
|
||||
/** The status bar text: lasting messages, and transient ones that give way to the lasting one after a while. */
|
||||
final class StatusLine {
|
||||
static final int TRANSIENT_MS = 6000;
|
||||
|
||||
private final JLabel label;
|
||||
private final Timer expire;
|
||||
private String resting;
|
||||
|
||||
StatusLine(JLabel label, int transientMs) {
|
||||
this.label = label;
|
||||
this.resting = label.getText();
|
||||
expire = new Timer(transientMs, e -> label.setText(resting));
|
||||
expire.setRepeats(false);
|
||||
}
|
||||
|
||||
void set(String s) {
|
||||
expire.stop();
|
||||
resting = s;
|
||||
label.setText(s);
|
||||
}
|
||||
|
||||
/** Shown for a few seconds, then the last lasting message comes back (unless something newer replaced it). */
|
||||
void flash(String s) {
|
||||
label.setText(s);
|
||||
expire.restart();
|
||||
}
|
||||
|
||||
boolean showingTransient() {
|
||||
return expire.isRunning();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user