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:
MineTec committed 2026-11-07 14:00:22 +01:00
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();
}
}
@@ -0,0 +1,83 @@
package com.eliasmueller.llamamod.ui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import org.junit.jupiter.api.Test;
class FocusKeeperTest {
private static JPanel form(String text) {
JPanel root = new JPanel();
JPanel card = new JPanel();
card.add(FocusKeeper.tag(new JTextField(text, 10), "mod/name"));
card.add(FocusKeeper.tag(new JSpinner(new SpinnerNumberModel(5, 0, 9, 1)), "mod/count"));
root.add(card);
return root;
}
private static JTextField field(JPanel root) {
return (JTextField) ((JPanel) root.getComponent(0)).getComponent(0);
}
@Test
void caretMovesToTheRebuiltField() {
JPanel before = form("llama");
JTextField old = field(before);
old.setCaretPosition(3);
FocusKeeper.Snapshot s = FocusKeeper.capture(old, before);
assertNotNull(s);
assertEquals("mod/name", s.key());
JPanel after = form("llama");
assertSame(field(after), FocusKeeper.restore(s, after));
assertEquals(3, field(after).getCaretPosition());
}
@Test
void selectionAndShorterTextAreHandled() {
JPanel before = form("llamas");
JTextField old = field(before);
old.setCaretPosition(1);
old.moveCaretPosition(5);
FocusKeeper.Snapshot s = FocusKeeper.capture(old, before);
JPanel after = form("llamas");
FocusKeeper.restore(s, after);
assertEquals(1, field(after).getSelectionStart());
assertEquals(5, field(after).getSelectionEnd());
assertEquals(5, field(after).getCaretPosition());
JPanel shorter = form("ll");
FocusKeeper.restore(s, shorter);
assertEquals(2, field(shorter).getCaretPosition());
}
@Test
void spinnerEditorMapsToTheSpinner() {
JPanel before = form("x");
JSpinner sp = (JSpinner) ((JPanel) before.getComponent(0)).getComponent(1);
JTextField editor = ((JSpinner.DefaultEditor) sp.getEditor()).getTextField();
FocusKeeper.Snapshot s = FocusKeeper.capture(editor, before);
assertEquals("mod/count", s.key());
JPanel after = form("x");
JSpinner sp2 = (JSpinner) ((JPanel) after.getComponent(0)).getComponent(1);
assertSame(((JSpinner.DefaultEditor) sp2.getEditor()).getTextField(), FocusKeeper.restore(s, after));
}
@Test
void untaggedOrMissingGivesNull() {
JPanel root = new JPanel();
JTextField plain = new JTextField();
root.add(plain);
assertNull(FocusKeeper.capture(plain, root));
assertNull(FocusKeeper.capture(null, root));
assertNull(FocusKeeper.restore(new FocusKeeper.Snapshot("gone", 0, 0, 0), root));
}
}
@@ -0,0 +1,16 @@
package com.eliasmueller.llamamod.ui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class MainFrameLayoutTest {
@Test
void headerShrinksForShortWindows() {
assertEquals(150, MainFrame.headerHeight(900));
assertEquals(104, MainFrame.headerHeight(760));
assertEquals(104, MainFrame.headerHeight(720));
// the 640 px minimum: the Saves table keeps four rows
assertEquals(92, MainFrame.headerHeight(640));
}
}
@@ -0,0 +1,18 @@
package com.eliasmueller.llamamod.ui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.eliasmueller.llamamod.platform.Installer;
import org.junit.jupiter.api.Test;
class SetupWizardProgressTest {
@Test
void barFollowsTheCopiedMegabytes() {
// 553 of 568 MB copied: the bar must say 97 %, not the 78 % of the whole setup
double copied = 553.0 / 568.0;
assertEquals(copied, SetupWizard.barFraction(copied * Installer.COPY_SHARE), 1e-9);
assertEquals(0, SetupWizard.barFraction(0));
assertEquals(1, SetupWizard.barFraction(Installer.COPY_SHARE));
assertEquals(1, SetupWizard.barFraction(0.9)); // checking/creating: full (shown as busy)
}
}
@@ -0,0 +1,45 @@
package com.eliasmueller.llamamod.ui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import javax.swing.JLabel;
import javax.swing.SwingUtilities;
import org.junit.jupiter.api.Test;
class StatusLineTest {
private static void onEdt(Runnable r) throws Exception {
SwingUtilities.invokeAndWait(r);
}
@Test
void transientMessageGivesWayToTheLastingOne() throws Exception {
JLabel l = new JLabel(" ");
StatusLine s = new StatusLine(l, 150);
onEdt(() -> {
s.set("Ready.");
s.flash("Took over changes made in another LlamaMod window.");
});
assertEquals("Took over changes made in another LlamaMod window.", l.getText());
assertTrue(s.showingTransient());
Thread.sleep(600);
onEdt(() -> {});
assertEquals("Ready.", l.getText());
assertFalse(s.showingTransient());
}
@Test
void newerLastingMessageIsNotOverwritten() throws Exception {
JLabel l = new JLabel(" ");
StatusLine s = new StatusLine(l, 150);
onEdt(() -> {
s.set("Ready.");
s.flash("merged");
s.set("Applying mods");
});
Thread.sleep(600);
onEdt(() -> {});
assertEquals("Applying mods", l.getText());
}
}