diff --git a/enigma-swing/build.gradle b/enigma-swing/build.gradle
index dda6ada24..06fa86a50 100644
--- a/enigma-swing/build.gradle
+++ b/enigma-swing/build.gradle
@@ -20,6 +20,15 @@ plugins {
alias(libs.plugins.shadow)
}
+sourceSets {
+ guiVisualization
+}
+
+configurations {
+ guiVisualizationRuntimeClasspath.extendsFrom runtimeClasspath
+ guiVisualizationCompileClasspath.extendsFrom compileClasspath
+}
+
dependencies {
implementation project(':enigma')
implementation project(':enigma-server')
@@ -32,7 +41,9 @@ dependencies {
implementation libs.swing.dpi
implementation libs.fontchooser
implementation libs.javaparser
+
testImplementation(testFixtures(project(':enigma')))
+ guiVisualizationImplementation(project)
}
application {
@@ -97,6 +108,20 @@ project(":enigma").file("src/test/java/org/quiltmc/enigma/input").listFiles().ea
registerTestTask("complete")
+final guiVisualizationJar = tasks.register('guiVisualizationJar', Jar.class) {
+ from(sourceSets.guiVisualization.output)
+
+ archiveFileName = 'gui-visualization.jar'
+ destinationDirectory = layout.buildDirectory.map { it.dir('gui-visualization') }
+}
+
+tasks.register('visualizeGui', JavaExec) {
+ dependsOn(guiVisualizationJar)
+
+ classpath = sourceSets.guiVisualization.runtimeClasspath
+ mainClass = 'org.quilt.internal.gui.visualization.Main'
+}
+
void registerPrintColorKeyGroupsMapCode(String lafsName, LookAndFeel... lookAndFeels) {
tasks.register("print" + lafsName + "ColorKeyGroupsMapCode", PrintColorKeyGroupsMapCodeTask) { task ->
task.lookAndFeels = lookAndFeels as List
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/Main.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/Main.java
new file mode 100644
index 000000000..5bf33124e
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/Main.java
@@ -0,0 +1,120 @@
+package org.quilt.internal.gui.visualization;
+
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridAlignAndFillVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridAlignmentVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridCheckersVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridColumnVisualiser;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridDefaultRowVisualiser;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridDiagonalBricksVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridFillVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridExtentOverlapVisualiser;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridPriorityFillVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridPriorityScrollPanesVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridPriorityVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridQuiltVisualiser;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridRelativeExtentOverlapVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridRelativeRowsVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridSparseVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridGreaterVisualizer;
+import org.quilt.internal.gui.visualization.flex_grid.FlexGridTetrisVisualizer;
+
+import javax.swing.JButton;
+import javax.swing.JFrame;
+import javax.swing.WindowConstants;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Toolkit;
+import java.awt.Window;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * The main entrypoint for {@link Visualizer}s.
+ *
+ *
Opens a window full of buttons that open visualizer windows.
+ */
+public final class Main {
+ private Main() {
+ throw new UnsupportedOperationException();
+ }
+
+ private static final JFrame WINDOW = new JFrame("Gui Visualization");
+
+ // bootstrap
+ static {
+ registerVisualizer(new FlexGridDefaultRowVisualiser());
+ registerVisualizer(new FlexGridColumnVisualiser());
+ registerVisualizer(new FlexGridQuiltVisualiser());
+ registerVisualizer(new FlexGridExtentOverlapVisualiser());
+ registerVisualizer(new FlexGridFillVisualizer());
+ registerVisualizer(new FlexGridAlignmentVisualizer());
+ registerVisualizer(new FlexGridAlignAndFillVisualizer());
+ registerVisualizer(new FlexGridPriorityVisualizer());
+ registerVisualizer(new FlexGridPriorityFillVisualizer());
+ registerVisualizer(new FlexGridSparseVisualizer());
+ registerVisualizer(new FlexGridGreaterVisualizer());
+ registerVisualizer(new FlexGridCheckersVisualizer());
+ registerVisualizer(new FlexGridDiagonalBricksVisualizer());
+ registerVisualizer(new FlexGridTetrisVisualizer());
+ registerVisualizer(new FlexGridPriorityScrollPanesVisualizer());
+ registerVisualizer(new FlexGridRelativeRowsVisualizer());
+ registerVisualizer(new FlexGridRelativeExtentOverlapVisualizer());
+ }
+
+ private static void position(Window window) {
+ final Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
+ final int x = (screenSize.width - window.getWidth()) / 2;
+ final int y = (screenSize.height - window.getHeight()) / 2;
+
+ window.setLocation(x, y);
+ }
+
+ public static void main(String[] args) {
+ WINDOW.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
+
+ WINDOW.setLayout(new FlowLayout());
+
+ final Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
+ final int width = screenSize.width * 2 / 3;
+ final int height = screenSize.height * 2 / 3;
+ final int x = (screenSize.width - width) / 2;
+ final int y = (screenSize.height - height) / 2;
+
+ WINDOW.setBounds(x, y, width, height);
+ WINDOW.setVisible(true);
+ }
+
+ private static void registerVisualizer(Visualizer visualizer) {
+ final JButton button = new JButton(visualizer.getTitle());
+ final AtomicReference currentWindow = new AtomicReference<>();
+
+ button.addActionListener(e -> {
+ final JFrame window = currentWindow.updateAndGet(old -> {
+ if (old != null) {
+ old.dispose();
+ }
+
+ return new JFrame(visualizer.getTitle());
+ });
+
+ visualizer.visualize(window);
+
+ window.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
+ window.addWindowListener(new WindowAdapter() {
+ @Override
+ public void windowClosed(WindowEvent e) {
+ final JFrame window = currentWindow.get();
+ if (window == null || !window.isDisplayable()) {
+ WINDOW.requestFocus();
+ }
+ }
+ });
+
+ position(window);
+ window.setVisible(true);
+ });
+
+ WINDOW.add(button);
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/Visualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/Visualizer.java
new file mode 100644
index 000000000..601705e9f
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/Visualizer.java
@@ -0,0 +1,22 @@
+package org.quilt.internal.gui.visualization;
+
+import javax.swing.JFrame;
+
+/**
+ * A visualizer populates a window with content to help visualize what GUI code does.
+ *
+ * Visualizers are registered in {@link Main}.
+ */
+public interface Visualizer {
+ /**
+ * The title of the visualizer.
+ *
+ *
Used by {@link Main} for the visualizer's button text and window titles.
+ */
+ String getTitle();
+
+ /**
+ * Populates the passed {@code window} with content for visualization.
+ */
+ void visualize(JFrame window);
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridAlignAndFillVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridAlignAndFillVisualizer.java
new file mode 100644
index 000000000..faed18c42
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridAlignAndFillVisualizer.java
@@ -0,0 +1,33 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualUtils;
+
+import javax.swing.JFrame;
+
+public class FlexGridAlignAndFillVisualizer implements Visualizer {
+ @Override
+ public String getTitle() {
+ return "Flex Grid Align & Fill";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ VisualUtils.visualizeFlexGridQuilt(
+ window,
+ c -> c.fillNone().alignTopLeft(),
+ c -> c.fillOnlyY().alignTopCenter(),
+ c -> c.fillNone().alignTopRight(),
+
+ c -> c.fillOnlyX().alignCenterLeft(),
+ c -> c.fillBoth().alignCenter(),
+ c -> c.fillOnlyX().alignCenterRight(),
+
+ c -> c.fillNone().alignBottomLeft(),
+ c -> c.fillOnlyY().alignBottomCenter(),
+ c -> c.fillNone().alignBottomRight()
+ );
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridAlignmentVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridAlignmentVisualizer.java
new file mode 100644
index 000000000..828d7779c
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridAlignmentVisualizer.java
@@ -0,0 +1,47 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualBox;
+import org.quilt.internal.gui.visualization.util.VisualUtils;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints.Absolute;
+
+import javax.swing.JFrame;
+
+public class FlexGridAlignmentVisualizer implements Visualizer {
+ private static final int SPACER_SIZE = (int) (VisualBox.DEFAULT_SIZE * 1.2);
+
+ private static VisualBox createSpacer() {
+ return VisualBox.ofFixed(SPACER_SIZE);
+ }
+
+ @Override
+ public String getTitle() {
+ return "Flex Grid Alignment";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ VisualUtils.visualizeFlexGridQuilt(
+ window,
+ Absolute::alignTopLeft, Absolute::alignTopCenter, Absolute::alignTopRight,
+ Absolute::alignCenterLeft, Absolute::alignCenter, Absolute::alignCenterRight,
+ Absolute::alignBottomLeft, Absolute::alignBottomCenter, Absolute::alignBottomRight
+ );
+
+ final Absolute constraints = FlexGridConstraints.createAbsolute();
+ window.add(createSpacer(), constraints);
+ window.add(createSpacer(), constraints.nextColumn());
+ window.add(createSpacer(), constraints.nextColumn());
+
+ window.add(createSpacer(), constraints.nextRow());
+ window.add(createSpacer(), constraints.nextColumn());
+ window.add(createSpacer(), constraints.nextColumn());
+
+ window.add(createSpacer(), constraints.nextRow());
+ window.add(createSpacer(), constraints.nextColumn());
+ window.add(createSpacer(), constraints.nextColumn());
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridCheckersVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridCheckersVisualizer.java
new file mode 100644
index 000000000..3df2cc6b2
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridCheckersVisualizer.java
@@ -0,0 +1,38 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualBox;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+
+import javax.swing.JFrame;
+import java.awt.Color;
+
+public class FlexGridCheckersVisualizer implements Visualizer {
+ private static final int BOARD_SIZE = 8;
+
+ @Override
+ public String getTitle() {
+ return "Flex Grid Checkers";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ window.setLayout(new FlexGridLayout());
+
+ final FlexGridConstraints.Absolute constraints = FlexGridConstraints.createAbsolute();
+
+ boolean placeOnEvenY = false;
+ for (int x = 0; x < BOARD_SIZE; x++) {
+ for (int y = 0; y < BOARD_SIZE; y++) {
+ if (placeOnEvenY == (y % 2 == 0)) {
+ window.add(VisualBox.of(Color.RED), constraints.pos(x, y));
+ }
+ }
+
+ placeOnEvenY = !placeOnEvenY;
+ }
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridColumnVisualiser.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridColumnVisualiser.java
new file mode 100644
index 000000000..b7eb64aa5
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridColumnVisualiser.java
@@ -0,0 +1,27 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+
+public class FlexGridColumnVisualiser implements Visualizer {
+ @Override
+ public String getTitle() {
+ return "Flex Grid Column";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ window.setLayout(new FlexGridLayout());
+
+ final FlexGridConstraints.Absolute constraints = FlexGridConstraints.createAbsolute();
+ window.add(new JLabel("Label 1"), constraints);
+ window.add(new JLabel("Label 2"), constraints.nextRow());
+ window.add(new JLabel("Label 3"), constraints.nextRow());
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridDefaultRowVisualiser.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridDefaultRowVisualiser.java
new file mode 100644
index 000000000..9422c85ae
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridDefaultRowVisualiser.java
@@ -0,0 +1,25 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+
+public class FlexGridDefaultRowVisualiser implements Visualizer {
+ @Override
+ public String getTitle() {
+ return "Flex Grid Default Row";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ window.setLayout(new FlexGridLayout());
+
+ window.add(new JLabel("Label 1"));
+ window.add(new JLabel("Label 2"));
+ window.add(new JLabel("Label 3"));
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridDiagonalBricksVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridDiagonalBricksVisualizer.java
new file mode 100644
index 000000000..f8fd4e08c
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridDiagonalBricksVisualizer.java
@@ -0,0 +1,46 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualBox;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+
+import javax.swing.JFrame;
+import java.awt.Color;
+
+public class FlexGridDiagonalBricksVisualizer implements Visualizer {
+ private static final Color BRICK_COLOR = new Color(170, 74, 68);
+ private static final int BRICK_COUNT = 5;
+
+ private static final int BRICK_X_EXTENT = 4;
+ private static final int BRICK_Y_EXTENT = 2;
+
+ private static final int HALF_BRICK_X_EXTENT = BRICK_X_EXTENT / 2;
+
+ private static final int SIZE_UNIT = 30;
+ private static final int BRICK_WIDTH = BRICK_X_EXTENT * SIZE_UNIT;
+ private static final int BRICK_HEIGHT = BRICK_Y_EXTENT * SIZE_UNIT;
+
+ @Override
+ public String getTitle() {
+ return "Flex Grid Diagonal Bricks";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ window.setLayout(new FlexGridLayout());
+
+ final FlexGridConstraints.Absolute constraints = FlexGridConstraints.createAbsolute()
+ .extent(BRICK_X_EXTENT, BRICK_Y_EXTENT);
+
+ final int xEnd = HALF_BRICK_X_EXTENT * BRICK_COUNT;
+ for (int x = 0, y = 0; x < xEnd; x += HALF_BRICK_X_EXTENT, y += BRICK_Y_EXTENT) {
+ window.add(
+ VisualBox.of(BRICK_COLOR, BRICK_WIDTH, BRICK_HEIGHT),
+ constraints.pos(x, y)
+ );
+ }
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridExtentOverlapVisualiser.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridExtentOverlapVisualiser.java
new file mode 100644
index 000000000..5ad21e341
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridExtentOverlapVisualiser.java
@@ -0,0 +1,39 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualBox;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+
+import javax.swing.JFrame;
+import java.awt.Color;
+
+public class FlexGridExtentOverlapVisualiser implements Visualizer {
+ @Override
+ public String getTitle() {
+ return "Flex Grid Extent Overlap";
+ }
+
+ /**
+ *
+ * -------------------
+ * | RGB | RB | R |
+ * ------+-----+------
+ * | GB | B |
+ * ------+------
+ * | G |
+ * -------
+ *
+ */
+ @Override
+ public void visualize(JFrame window) {
+ window.setLayout(new FlexGridLayout());
+
+ final FlexGridConstraints.Absolute constraints = FlexGridConstraints.createAbsolute();
+ window.add(VisualBox.of(Color.RED, 300, 100), constraints.extent(3, 1));
+ window.add(VisualBox.of(Color.GREEN, 100, 300), constraints.extent(1, 3));
+ window.add(VisualBox.of(Color.BLUE, 200, 200), constraints.extent(2, 2));
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridFillVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridFillVisualizer.java
new file mode 100644
index 000000000..b41a9469f
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridFillVisualizer.java
@@ -0,0 +1,26 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualUtils;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+
+import javax.swing.JFrame;
+
+public class FlexGridFillVisualizer implements Visualizer {
+ @Override
+ public String getTitle() {
+ return "Flex Grid Fill";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ VisualUtils.visualizeFlexGridQuilt(
+ window,
+ FlexGridConstraints::fillNone, FlexGridConstraints::fillOnlyY, FlexGridConstraints::fillNone,
+ FlexGridConstraints::fillOnlyX, FlexGridConstraints::fillBoth, FlexGridConstraints::fillOnlyX,
+ FlexGridConstraints::fillNone, FlexGridConstraints::fillOnlyY, FlexGridConstraints::fillNone
+ );
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridGreaterVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridGreaterVisualizer.java
new file mode 100644
index 000000000..73ef279f7
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridGreaterVisualizer.java
@@ -0,0 +1,32 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualBox;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+
+import javax.swing.JFrame;
+
+public class FlexGridGreaterVisualizer implements Visualizer {
+ @Override
+ public String getTitle() {
+ return "Flex Grid >";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ window.setLayout(new FlexGridLayout());
+
+ final FlexGridConstraints.Absolute constraints = FlexGridConstraints.createAbsolute();
+
+ window.add(VisualBox.of(), constraints.pos(0, 2));
+ window.add(VisualBox.of(), constraints.pos(0, -2));
+
+ window.add(VisualBox.of(), constraints.pos(1, 1));
+ window.add(VisualBox.of(), constraints.pos(1, -1));
+
+ window.add(VisualBox.of(), constraints.pos(2, 0));
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridPriorityFillVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridPriorityFillVisualizer.java
new file mode 100644
index 000000000..582818563
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridPriorityFillVisualizer.java
@@ -0,0 +1,33 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualUtils;
+
+import javax.swing.JFrame;
+
+public class FlexGridPriorityFillVisualizer implements Visualizer {
+ @Override
+ public String getTitle() {
+ return "Flex Grid Priority Fill";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ VisualUtils.visualizeFlexGridQuilt(
+ window,
+ c -> c.fillOnlyX().incrementPriority(),
+ c -> c.fillOnlyY().incrementPriority(),
+ c -> c.fillOnlyX().incrementPriority(),
+
+ c -> c.fillOnlyY().incrementPriority(),
+ c -> c.fillOnlyX().incrementPriority(),
+ c -> c.fillOnlyY().incrementPriority(),
+
+ c -> c.fillOnlyX().incrementPriority(),
+ c -> c.fillOnlyY().incrementPriority(),
+ c -> c.fillOnlyX().incrementPriority()
+ );
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridPriorityScrollPanesVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridPriorityScrollPanesVisualizer.java
new file mode 100644
index 000000000..1afe93abe
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridPriorityScrollPanesVisualizer.java
@@ -0,0 +1,49 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quiltmc.enigma.gui.panel.SmartScrollPane;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+
+import javax.swing.JFrame;
+import javax.swing.JTextArea;
+
+public class FlexGridPriorityScrollPanesVisualizer implements Visualizer {
+ @Override
+ public String getTitle() {
+ return "Flex Grid Priority Scroll Panes";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ window.setLayout(new FlexGridLayout());
+
+ final FlexGridConstraints.Absolute constraints = FlexGridConstraints.createAbsolute();
+
+ final var firstText = new JTextArea(
+ """
+ 5 4 3 2 1
+ 4
+ 3
+ 2
+ 1\
+ """
+ );
+
+ window.add(new SmartScrollPane(firstText), constraints);
+
+ final var secondText = new JTextArea(
+ """
+ e d c b a
+ d
+ c
+ b
+ a\
+ """
+ );
+
+ window.add(new SmartScrollPane(secondText), constraints.nextRow().incrementPriority());
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridPriorityVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridPriorityVisualizer.java
new file mode 100644
index 000000000..c1a889ed4
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridPriorityVisualizer.java
@@ -0,0 +1,27 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualUtils;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints.Absolute;
+
+import javax.swing.JFrame;
+import java.util.function.UnaryOperator;
+
+public class FlexGridPriorityVisualizer implements Visualizer {
+ @Override
+ public String getTitle() {
+ return "Flex Grid Priority";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ VisualUtils.visualizeFlexGridQuilt(
+ window,
+ UnaryOperator.identity(), Absolute::incrementPriority, Absolute::incrementPriority,
+ Absolute::incrementPriority, Absolute::incrementPriority, Absolute::incrementPriority,
+ Absolute::incrementPriority, Absolute::incrementPriority, Absolute::incrementPriority
+ );
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridQuiltVisualiser.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridQuiltVisualiser.java
new file mode 100644
index 000000000..b6d3fcbe1
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridQuiltVisualiser.java
@@ -0,0 +1,26 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualUtils;
+
+import javax.swing.JFrame;
+import java.util.function.UnaryOperator;
+
+public class FlexGridQuiltVisualiser implements Visualizer {
+ @Override
+ public String getTitle() {
+ return "Flex Grid Quilt";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ VisualUtils.visualizeFlexGridQuilt(
+ window,
+ UnaryOperator.identity(), UnaryOperator.identity(), UnaryOperator.identity(),
+ UnaryOperator.identity(), UnaryOperator.identity(), UnaryOperator.identity(),
+ UnaryOperator.identity(), UnaryOperator.identity(), UnaryOperator.identity()
+ );
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridRelativeExtentOverlapVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridRelativeExtentOverlapVisualizer.java
new file mode 100644
index 000000000..caf4e2293
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridRelativeExtentOverlapVisualizer.java
@@ -0,0 +1,56 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualBox;
+import org.quilt.internal.gui.visualization.util.VisualUtils;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+
+import javax.swing.JFrame;
+import java.awt.Color;
+
+public class FlexGridRelativeExtentOverlapVisualizer implements Visualizer {
+ public static final int MAX_EXTENT = 3;
+ private static final int SQUARE_SIZE = 50;
+
+ @Override
+ public String getTitle() {
+ return "Flex Grid Relative Extent Overlap";
+ }
+
+ /**
+ *
+ * ---------------------
+ * | | R | P |
+ * | --------+--------
+ * | | | | O |
+ * | | ----- -----
+ * | | | |
+ * ----+---+----
+ * | G | B |
+ * ---------
+ *
+ */
+ @Override
+ public void visualize(JFrame window) {
+ window.setLayout(new FlexGridLayout());
+
+ for (int coord = 0; coord < MAX_EXTENT; coord++) {
+ final int extent = MAX_EXTENT - coord;
+ window.add(VisualBox.of(SQUARE_SIZE * extent), FlexGridConstraints.createAbsolute()
+ .pos(coord, coord)
+ .extent(extent, extent)
+ );
+ }
+
+ window.add(VisualBox.of(Color.RED, SQUARE_SIZE), FlexGridConstraints.createRelative().rowEnd());
+
+ window.add(VisualBox.of(Color.GREEN, SQUARE_SIZE), FlexGridConstraints.createRelative().newRow());
+ window.add(VisualBox.of(Color.BLUE, SQUARE_SIZE), FlexGridConstraints.createRelative().rowEnd());
+
+ window.add(VisualBox.of(VisualUtils.PURPLE, SQUARE_SIZE), FlexGridConstraints.createRelative().newColumn());
+ window.add(VisualBox.of(VisualUtils.ORANGE, SQUARE_SIZE), FlexGridConstraints.createRelative().columnEnd());
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridRelativeRowsVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridRelativeRowsVisualizer.java
new file mode 100644
index 000000000..56137ef0c
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridRelativeRowsVisualizer.java
@@ -0,0 +1,32 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualBox;
+import org.quilt.internal.gui.visualization.util.VisualUtils;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+
+import javax.swing.JFrame;
+import java.awt.Color;
+
+public class FlexGridRelativeRowsVisualizer implements Visualizer {
+ @Override
+ public String getTitle() {
+ return "Flex Grid Relative Rows";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ window.setLayout(new FlexGridLayout());
+
+ window.add(VisualBox.of(Color.RED));
+ window.add(VisualBox.of(VisualUtils.ORANGE));
+ window.add(VisualBox.of(Color.YELLOW));
+
+ window.add(VisualBox.of(Color.GREEN), FlexGridConstraints.createRelative().newRow());
+ window.add(VisualBox.of(Color.BLUE));
+ window.add(VisualBox.of(VisualUtils.PURPLE));
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridSparseVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridSparseVisualizer.java
new file mode 100644
index 000000000..dc2e0d61d
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridSparseVisualizer.java
@@ -0,0 +1,32 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualUtils;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+
+import javax.swing.JFrame;
+
+public class FlexGridSparseVisualizer implements Visualizer {
+ private static final int STEP = 1000;
+
+ private static FlexGridConstraints.Absolute stepColumns(FlexGridConstraints.Absolute constraints) {
+ return constraints.advanceColumns(STEP);
+ }
+
+ @Override
+ public String getTitle() {
+ return "Flex Grid Sparse";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ VisualUtils.visualizeFlexGridQuilt(
+ window,
+ c -> c.pos(-STEP, -STEP), FlexGridSparseVisualizer::stepColumns, FlexGridSparseVisualizer::stepColumns,
+ c -> c.pos(-STEP, 0), FlexGridSparseVisualizer::stepColumns, FlexGridSparseVisualizer::stepColumns,
+ c -> c.pos(-STEP, STEP), FlexGridSparseVisualizer::stepColumns, FlexGridSparseVisualizer::stepColumns
+ );
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridTetrisVisualizer.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridTetrisVisualizer.java
new file mode 100644
index 000000000..735b89bd8
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/flex_grid/FlexGridTetrisVisualizer.java
@@ -0,0 +1,69 @@
+package org.quilt.internal.gui.visualization.flex_grid;
+
+import org.quilt.internal.gui.visualization.Visualizer;
+import org.quilt.internal.gui.visualization.util.VisualBox;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+
+import javax.swing.JFrame;
+import java.awt.Color;
+
+public class FlexGridTetrisVisualizer implements Visualizer {
+ private static final int SQUARE_SIZE = 50;
+
+ private static final Color I_COLOR = Color.CYAN;
+ private static final Color O_COLOR = Color.YELLOW;
+ private static final Color T_COLOR = Color.MAGENTA;
+ private static final Color J_COLOR = Color.BLUE;
+ private static final Color L_COLOR = new Color(255, 128, 0);
+ private static final Color S_COLOR = Color.GREEN;
+ private static final Color Z_COLOR = Color.RED;
+
+ private static void addPart(JFrame window, Color color, int xExtent, int yExtent, int x, int y) {
+ window.add(
+ VisualBox.of(color, SQUARE_SIZE * xExtent, SQUARE_SIZE * yExtent),
+ FlexGridConstraints.createAbsolute().extent(xExtent, yExtent).pos(x, y)
+ );
+ }
+
+ @Override
+ public String getTitle() {
+ return "Flex Grid Tetris";
+ }
+
+ @Override
+ public void visualize(JFrame window) {
+ window.setLayout(new FlexGridLayout());
+
+ // default I
+ addPart(window, I_COLOR, 1, 4, 0, 0);
+
+ // inverted T
+ addPart(window, T_COLOR, 3, 1, 1, 3);
+ addPart(window, T_COLOR, 1, 2, 2, 2);
+
+ // vertical Z
+ addPart(window, Z_COLOR, 1, 2, 1, 1);
+ addPart(window, Z_COLOR, 1, 2, 2, 0);
+
+ // default J
+ addPart(window, J_COLOR, 2, 1, 4, 3);
+ addPart(window, J_COLOR, 1, 2, 5, 1);
+
+ // O
+ addPart(window, O_COLOR, 2, 2, 3, 1);
+
+ // horizontal I
+ addPart(window, I_COLOR, 4, 1, 3, 0);
+
+ // default L
+ addPart(window, L_COLOR, 2, 1, 6, 3);
+ addPart(window, L_COLOR, 1, 3, 6, 1);
+
+ // vertical S
+ addPart(window, S_COLOR, 1, 2, 7, 1);
+ addPart(window, S_COLOR, 1, 2, 8, 2);
+
+ window.pack();
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/util/VisualBox.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/util/VisualBox.java
new file mode 100644
index 000000000..bb8a69ee5
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/util/VisualBox.java
@@ -0,0 +1,219 @@
+package org.quilt.internal.gui.visualization.util;
+
+import org.jspecify.annotations.Nullable;
+import org.quiltmc.enigma.gui.docker.component.VerticalFlowLayout;
+
+import javax.swing.BorderFactory;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+
+import static org.quiltmc.enigma.util.Arguments.requireNonNegative;
+import static org.quiltmc.enigma.util.Arguments.requireNotLess;
+
+/**
+ * A simple box with customizable sizes, color, and name.
+ *
+ * Boxes have a dashed outline and a transparent background.
+ * There are numerous factories for creating boxes with as much specificity as needed.
+ */
+@SuppressWarnings("unused")
+public class VisualBox extends JPanel {
+ public static final int DEFAULT_SIZE = 100;
+
+ private static final String MIN_WIDTH = "minWidth";
+ private static final String MIN_HEIGHT = "minHeight";
+ private static final String PREFERRED_WIDTH = "preferredWidth";
+ private static final String PREFERRED_HEIGHT = "preferredHeight";
+ private static final String MAX_WIDTH = "maxWidth";
+ private static final String MAX_HEIGHT = "maxHeight";
+
+ public static VisualBox of() {
+ return of(null);
+ }
+
+ public static VisualBox of(@Nullable Color color) {
+ return of(null, color);
+ }
+
+ public static VisualBox of(@Nullable String name, @Nullable Color color) {
+ return of(name, color, DEFAULT_SIZE);
+ }
+
+ public static VisualBox of(int size) {
+ return of (null, size);
+ }
+
+ public static VisualBox of(@Nullable Color color, int size) {
+ return of(null, color, size);
+ }
+
+ public static VisualBox of(@Nullable String name, @Nullable Color color, int size) {
+ return of(name, color, size, size);
+ }
+
+ public static VisualBox of(int width, int height) {
+ return of(null, width, height);
+ }
+
+ public static VisualBox of(@Nullable Color color, int width, int height) {
+ return of(null, color, width, height);
+ }
+
+ public static VisualBox of(@Nullable String name, @Nullable Color color, int width, int height) {
+ return new VisualBox(name, color, width / 2, height / 2, width, height, width * 2, height * 2);
+ }
+
+ public static VisualBox ofFixed() {
+ return ofFixed(null);
+ }
+
+ public static VisualBox ofFixed(@Nullable Color color) {
+ return ofFixed(null, color);
+ }
+
+ public static VisualBox ofFixed(@Nullable String name, @Nullable Color color) {
+ return ofFixed(name, color, DEFAULT_SIZE);
+ }
+
+ public static VisualBox ofFixed(int size) {
+ return ofFixed(null, size);
+ }
+
+ public static VisualBox ofFixed(@Nullable Color color, int size) {
+ return ofFixed(null, color, size);
+ }
+
+ public static VisualBox ofFixed(@Nullable String name, @Nullable Color color, int size) {
+ return ofFixed(name, color, size, size);
+ }
+
+ public static VisualBox ofFixed(int width, int height) {
+ return ofFixed(null, width, height);
+ }
+
+ public static VisualBox ofFixed(@Nullable Color color, int width, int height) {
+ return ofFixed(null, color, width, height);
+ }
+
+ public static VisualBox ofFixed(@Nullable String name, @Nullable Color color, int width, int height) {
+ return new VisualBox(name, color, width, height, width, height, width, height);
+ }
+
+ public static VisualBox purplePatchOf() {
+ return purplePatchOf(null);
+ }
+
+ public static VisualBox purplePatchOf(@Nullable String name) {
+ return of(name, VisualUtils.PATCH_PURPLE);
+ }
+
+ public static VisualBox magentaPatchOf() {
+ return purplePatchOf(null);
+ }
+
+ public static VisualBox magentaPatchOf(@Nullable String name) {
+ return of(name, VisualUtils.PATCH_MAGENTA);
+ }
+
+ public static VisualBox cyanPatchOf() {
+ return cyanPatchOf(null);
+ }
+
+ public static VisualBox cyanPatchOf(@Nullable String name) {
+ return of(name, VisualUtils.PATCH_CYAN);
+ }
+
+ public static VisualBox bluePatchOf() {
+ return bluePatchOf(null);
+ }
+
+ public static VisualBox bluePatchOf(@Nullable String name) {
+ return of(name, VisualUtils.PATCH_BLUE);
+ }
+
+ private final int minWidth;
+ private final int minHeight;
+
+ private final int preferredWidth;
+ private final int preferredHeight;
+
+ private final int maxWidth;
+ private final int maxHeight;
+
+ private final JLabel sizeLabel = new JLabel();
+
+ protected VisualBox(
+ @Nullable String name, @Nullable Color color,
+ int minWidth, int minHeight,
+ int preferredWidth, int preferredHeight,
+ int maxWidth, int maxHeight
+ ) {
+ this.minWidth = requireNonNegative(minWidth, MIN_WIDTH);
+ this.minHeight = requireNonNegative(minHeight, MIN_HEIGHT);
+
+ this.preferredWidth = requireNotLess(preferredWidth, PREFERRED_WIDTH, minWidth, MIN_WIDTH);
+ this.preferredHeight = requireNotLess(preferredHeight, PREFERRED_HEIGHT, minHeight, MIN_HEIGHT);
+
+ this.maxWidth = requireNotLess(maxWidth, MAX_WIDTH, preferredWidth, PREFERRED_WIDTH);
+ this.maxHeight = requireNotLess(maxHeight, MAX_HEIGHT, preferredHeight, PREFERRED_HEIGHT);
+
+ this.setBackground(new Color(0, true));
+
+ if (color != null) {
+ this.setForeground(color);
+ }
+
+ final Color foreground = this.getForeground();
+
+ this.setLayout(new GridBagLayout());
+
+ final var center = new JPanel(new VerticalFlowLayout(5));
+ center.setBackground(this.getBackground());
+
+ if (name != null) {
+ final JLabel nameLabel = new JLabel(name);
+ nameLabel.setForeground(foreground);
+ center.add(nameLabel, BorderLayout.WEST);
+ }
+
+ this.sizeLabel.setForeground(foreground);
+ center.add(this.sizeLabel);
+ this.addComponentListener(new ComponentAdapter() {
+ @Override
+ public void componentResized(ComponentEvent e) {
+ final Dimension size = VisualBox.this.getSize();
+ VisualBox.this.sizeLabel.setText("%s x %s".formatted(size.width, size.height));
+ }
+ });
+
+ final JLabel dimensions = new JLabel("[%s x %s]".formatted(this.preferredWidth, this.preferredHeight));
+ dimensions.setForeground(foreground);
+ center.add(dimensions, BorderLayout.EAST);
+
+ this.add(center, new GridBagConstraints());
+
+ this.setBorder(BorderFactory.createDashedBorder(foreground, 2, 2, 4, false));
+ }
+
+ @Override
+ public Dimension getPreferredSize() {
+ return new Dimension(this.preferredWidth, this.preferredHeight);
+ }
+
+ @Override
+ public Dimension getMinimumSize() {
+ return new Dimension(this.minWidth, this.minHeight);
+ }
+
+ @Override
+ public Dimension getMaximumSize() {
+ return new Dimension(this.maxWidth, this.maxHeight);
+ }
+}
diff --git a/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/util/VisualUtils.java b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/util/VisualUtils.java
new file mode 100644
index 000000000..a3e7e6d33
--- /dev/null
+++ b/enigma-swing/src/guiVisualization/java/org/quilt/internal/gui/visualization/util/VisualUtils.java
@@ -0,0 +1,96 @@
+package org.quilt.internal.gui.visualization.util;
+
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+
+import javax.swing.JFrame;
+import java.awt.Color;
+import java.util.function.UnaryOperator;
+
+public final class VisualUtils {
+ private VisualUtils() {
+ throw new UnsupportedOperationException();
+ }
+
+ // Color.ORANGE is not very orange
+ public static final Color ORANGE = new Color(255, 128, 0);
+ public static final Color PURPLE = new Color(128, 0, 255);
+
+ // Quilt patch colors
+ public static final Color PATCH_PURPLE = new Color(151, 34, 255);
+ public static final Color PATCH_MAGENTA = new Color(220, 41, 221);
+ public static final Color PATCH_CYAN = new Color(39, 162, 253);
+ public static final Color PATCH_BLUE = new Color(51, 68, 255);
+
+ /**
+ * Visualizes Quilt's logo, giving each patch a name indicating its coordinates.
+ *
+ * @see #visualizeFlexGridQuilt(JFrame,
+ * String, UnaryOperator, String, UnaryOperator, String, UnaryOperator,
+ * String, UnaryOperator, String, UnaryOperator, String, UnaryOperator,
+ * String, UnaryOperator, String, UnaryOperator, String, UnaryOperator)
+ */
+ public static void visualizeFlexGridQuilt(
+ JFrame window,
+ UnaryOperator constrainer1,
+ UnaryOperator constrainer2,
+ UnaryOperator constrainer3,
+
+ UnaryOperator constrainer4,
+ UnaryOperator constrainer5,
+ UnaryOperator constrainer6,
+
+ UnaryOperator constrainer7,
+ UnaryOperator constrainer8,
+ UnaryOperator constrainer9
+ ) {
+ visualizeFlexGridQuilt(
+ window,
+ "(0, 0)", constrainer1, "(1, 0)", constrainer2, "(2, 0)", constrainer3,
+ "(0, 1)", constrainer4, "(1, 1)", constrainer5, "(2, 1)", constrainer6,
+ "(0, 2)", constrainer7, "(1, 2)", constrainer8, "(2, 2)", constrainer9
+ );
+ }
+
+ /**
+ * Gives the passed {@code window} a {@link FlexGridLayout} and forms Quilt's logo out of a 3 x 3 grid of
+ * {@link VisualBox} patches.
+ *
+ * The patches are given the passed names and their constraints are adjusted using the passed constrainers.
+ * The same {@link FlexGridConstraints} instance is passed to each constrainer, and its x and y coordinates are
+ * updated for each patch before passing it.
+ *
+ * @see #visualizeFlexGridQuilt(JFrame,
+ * UnaryOperator, UnaryOperator, UnaryOperator, UnaryOperator, UnaryOperator,
+ * UnaryOperator, UnaryOperator, UnaryOperator, UnaryOperator)
+ */
+ public static void visualizeFlexGridQuilt(
+ JFrame window,
+ String name1, UnaryOperator constrainer1,
+ String name2, UnaryOperator constrainer2,
+ String name3, UnaryOperator constrainer3,
+
+ String name4, UnaryOperator constrainer4,
+ String name5, UnaryOperator constrainer5,
+ String name6, UnaryOperator constrainer6,
+
+ String name7, UnaryOperator constrainer7,
+ String name8, UnaryOperator constrainer8,
+ String name9, UnaryOperator constrainer9
+ ) {
+ window.setLayout(new FlexGridLayout());
+
+ final FlexGridConstraints.Absolute constraints = FlexGridConstraints.createAbsolute();
+ window.add(VisualBox.purplePatchOf(name1), constrainer1.apply(constraints));
+ window.add(VisualBox.magentaPatchOf(name2), constrainer2.apply(constraints.nextColumn()));
+ window.add(VisualBox.cyanPatchOf(name3), constrainer3.apply(constraints.nextColumn()));
+
+ window.add(VisualBox.magentaPatchOf(name4), constrainer4.apply(constraints.nextRow()));
+ window.add(VisualBox.cyanPatchOf(name5), constrainer5.apply(constraints.nextColumn()));
+ window.add(VisualBox.bluePatchOf(name6), constrainer6.apply(constraints.nextColumn()));
+
+ window.add(VisualBox.purplePatchOf(name7), constrainer7.apply(constraints.nextRow()));
+ window.add(VisualBox.bluePatchOf(name8), constrainer8.apply(constraints.nextColumn()));
+ window.add(VisualBox.purplePatchOf(name9), constrainer9.apply(constraints.nextColumn()));
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java
index dc37fd791..ae9700c4d 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java
@@ -33,7 +33,7 @@
import org.quiltmc.enigma.gui.element.EditorTabbedPane;
import org.quiltmc.enigma.gui.element.MainWindow;
import org.quiltmc.enigma.gui.element.menu_bar.MenuBar;
-import org.quiltmc.enigma.gui.panel.BaseEditorPanel;
+import org.quiltmc.enigma.gui.panel.AbstractEditorPanel;
import org.quiltmc.enigma.gui.panel.EditorPanel;
import org.quiltmc.enigma.gui.panel.IdentifierPanel;
import org.quiltmc.enigma.gui.renderer.MessageListCellRenderer;
@@ -421,7 +421,7 @@ public void setMappingsFile(Path path) {
this.updateUiState();
}
- public void showTokens(BaseEditorPanel editor, List tokens) {
+ public void showTokens(AbstractEditorPanel> editor, List tokens) {
if (tokens.size() > 1) {
this.openDocker(CallsTreeDocker.class);
this.controller.setTokenHandle(editor.getClassHandle().copy());
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/EditorConfig.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/EditorConfig.java
index eb4acd888..a2f39c31f 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/EditorConfig.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/EditorConfig.java
@@ -16,4 +16,15 @@ public class EditorConfig extends ReflectiveConfig {
@Comment("Settings for editors' entry tooltips.")
public final EntryTooltipsSection entryTooltips = new EntryTooltipsSection();
+
+ @Comment("Settings for markers on the right side of the editor indicating where different entry types are.")
+ public final EntryMarkersSection entryMarkers = new EntryMarkersSection();
+
+ @Comment(
+ """
+ Settings for the editor's selection highlighting; used to highlight entries that have been navigated to.
+ The color of the highlight is defined per-theme (in themes/) by [syntax_pane_colors] > selection_highlight.\
+ """
+ )
+ public final SelectionHighlightSection selectionHighlight = new SelectionHighlightSection();
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/EntryMarkersSection.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/EntryMarkersSection.java
new file mode 100644
index 000000000..4894c457c
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/EntryMarkersSection.java
@@ -0,0 +1,36 @@
+package org.quiltmc.enigma.gui.config;
+
+import org.quiltmc.config.api.ReflectiveConfig;
+import org.quiltmc.config.api.annotations.Comment;
+import org.quiltmc.config.api.annotations.IntegerRange;
+import org.quiltmc.config.api.annotations.SerializedNameConvention;
+import org.quiltmc.config.api.metadata.NamingSchemes;
+import org.quiltmc.config.api.values.TrackedValue;
+
+@SerializedNameConvention(NamingSchemes.SNAKE_CASE)
+public class EntryMarkersSection extends ReflectiveConfig.Section {
+ public static final int MIN_MAX_MARKERS_PER_LINE = 0;
+ public static final int MAX_MAX_MARKERS_PER_LINE = 3;
+
+ @Comment("Whether markers should have tooltips showing their corresponding entries.")
+ public final TrackedValue tooltip = this.value(true);
+
+ @Comment("The maximum number of markers to show for a single line. Set to 0 to disable markers.")
+ @IntegerRange(min = MIN_MAX_MARKERS_PER_LINE, max = MAX_MAX_MARKERS_PER_LINE)
+ public final TrackedValue maxMarkersPerLine = this.value(2);
+
+ @Comment("Whether only declaration entries should be marked.")
+ public final TrackedValue onlyMarkDeclarations = this.value(true);
+
+ @Comment("Whether obfuscated entries should be marked.")
+ public final TrackedValue markObfuscated = this.value(true);
+
+ @Comment("Whether fallback entries should be marked.")
+ public final TrackedValue markFallback = this.value(true);
+
+ @Comment("Whether proposed entries should be marked.")
+ public final TrackedValue markProposed = this.value(false);
+
+ @Comment("Whether deobfuscated entries should be marked.")
+ public final TrackedValue markDeobfuscated = this.value(false);
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/SelectionHighlightSection.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/SelectionHighlightSection.java
new file mode 100644
index 000000000..ed3aee3d6
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/SelectionHighlightSection.java
@@ -0,0 +1,24 @@
+package org.quiltmc.enigma.gui.config;
+
+import org.quiltmc.config.api.ReflectiveConfig;
+import org.quiltmc.config.api.annotations.Comment;
+import org.quiltmc.config.api.annotations.IntegerRange;
+import org.quiltmc.config.api.annotations.SerializedNameConvention;
+import org.quiltmc.config.api.metadata.NamingSchemes;
+import org.quiltmc.config.api.values.TrackedValue;
+
+@SerializedNameConvention(NamingSchemes.SNAKE_CASE)
+public class SelectionHighlightSection extends ReflectiveConfig.Section {
+ public static final int MIN_BLINKS = 0;
+ public static final int MAX_BLINKS = 10;
+ public static final int MIN_BLINK_DELAY = 10;
+ public static final int MAX_BLINK_DELAY = 5000;
+
+ @Comment("The number of times the highlighting blinks. Set to 0 to disable highlighting.")
+ @IntegerRange(min = MIN_BLINKS, max = MAX_BLINKS)
+ public final TrackedValue blinks = this.value(3);
+
+ @Comment("The milliseconds the highlighting should be on and then off when blinking.")
+ @IntegerRange(min = MIN_BLINK_DELAY, max = MAX_BLINK_DELAY)
+ public final TrackedValue blinkDelay = this.value(200);
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/EditorTabbedPane.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/EditorTabbedPane.java
index 3441bc783..36a7fcbc9 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/EditorTabbedPane.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/EditorTabbedPane.java
@@ -16,6 +16,7 @@
import java.awt.Component;
import java.awt.event.MouseEvent;
import java.util.Iterator;
+import java.util.concurrent.CompletableFuture;
import javax.swing.JTabbedPane;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
@@ -41,52 +42,71 @@ public EditorTabbedPane(Gui gui) {
public EditorPanel openClass(ClassEntry entry) {
EditorPanel activeEditor = this.getActiveEditor();
- EditorPanel entryEditor = this.editors.computeIfAbsent(entry, editing -> {
- ClassHandle classHandle = this.gui.getController().getClassHandleProvider().openClass(editing);
- if (classHandle == null) {
- return null;
- }
-
- this.navigator = new NavigatorPanel(this.gui);
- EditorPanel newEditor = new EditorPanel(this.gui, this.navigator);
- newEditor.setClassHandle(classHandle);
- this.openFiles.addTab(newEditor.getSimpleClassName(), newEditor.getUi());
-
- ClosableTabTitlePane titlePane = new ClosableTabTitlePane(newEditor.getSimpleClassName(), newEditor.getFullClassName(), () -> this.closeEditor(newEditor));
- this.openFiles.setTabComponentAt(this.openFiles.indexOfComponent(newEditor.getUi()), titlePane.getUi());
- titlePane.setTabbedPane(this.openFiles);
-
- newEditor.addListener(new EditorActionListener() {
- @Override
- public void onCursorReferenceChanged(EditorPanel editor, EntryReference, Entry>> ref) {
- if (editor == EditorTabbedPane.this.getActiveEditor()) {
- EditorTabbedPane.this.gui.showCursorReference(ref);
- }
- }
- @Override
- public void onClassHandleChanged(EditorPanel editor, ClassEntry old, ClassHandle ch) {
- EditorTabbedPane.this.editors.remove(old);
- EditorTabbedPane.this.editors.put(ch.getRef(), editor);
+ final EditorPanel entryEditor;
+ final CompletableFuture> entryEditorReady;
+ {
+ final EditorPanel existingEditor = this.editors.get(entry);
+
+ if (existingEditor == null) {
+ ClassHandle classHandle = this.gui.getController().getClassHandleProvider().openClass(entry);
+ if (classHandle == null) {
+ entryEditor = null;
+ entryEditorReady = null;
+ } else {
+ this.navigator = new NavigatorPanel(this.gui);
+ final EditorPanel newEditor = new EditorPanel(this.gui, this.navigator);
+ entryEditorReady = newEditor.setClassHandle(classHandle);
+ this.openFiles.addTab(newEditor.getSimpleClassName(), newEditor.getUi());
+
+ ClosableTabTitlePane titlePane = new ClosableTabTitlePane(newEditor.getSimpleClassName(), newEditor.getFullClassName(), () -> this.closeEditor(newEditor));
+ this.openFiles.setTabComponentAt(this.openFiles.indexOfComponent(newEditor.getUi()), titlePane.getUi());
+ titlePane.setTabbedPane(this.openFiles);
+
+ newEditor.addListener(new EditorActionListener() {
+ @Override
+ public void onCursorReferenceChanged(EditorPanel editor, EntryReference, Entry>> ref) {
+ if (editor == EditorTabbedPane.this.getActiveEditor()) {
+ EditorTabbedPane.this.gui.showCursorReference(ref);
+ }
+ }
+
+ @Override
+ public void onClassHandleChanged(EditorPanel editor, ClassEntry old, ClassHandle ch) {
+ EditorTabbedPane.this.editors.remove(old);
+ EditorTabbedPane.this.editors.put(ch.getRef(), editor);
+ }
+
+ @Override
+ public void onTitleChanged(EditorPanel editor, String title) {
+ titlePane.setText(editor.getSimpleClassName(), editor.getFullClassName());
+ }
+ });
+
+ putKeyBindAction(KeyBinds.EDITOR_CLOSE_TAB, newEditor.getEditor(), e -> this.closeEditor(newEditor));
+ putKeyBindAction(KeyBinds.ENTRY_NAVIGATOR_NEXT, newEditor.getEditor(), e -> newEditor.getNavigatorPanel().navigateDown());
+ putKeyBindAction(KeyBinds.ENTRY_NAVIGATOR_LAST, newEditor.getEditor(), e -> newEditor.getNavigatorPanel().navigateUp());
+
+ this.editors.put(entry, newEditor);
+
+ entryEditor = newEditor;
}
-
- @Override
- public void onTitleChanged(EditorPanel editor, String title) {
- titlePane.setText(editor.getSimpleClassName(), editor.getFullClassName());
- }
- });
-
- putKeyBindAction(KeyBinds.EDITOR_CLOSE_TAB, newEditor.getEditor(), e -> this.closeEditor(newEditor));
- putKeyBindAction(KeyBinds.ENTRY_NAVIGATOR_NEXT, newEditor.getEditor(), e -> newEditor.getNavigatorPanel().navigateDown());
- putKeyBindAction(KeyBinds.ENTRY_NAVIGATOR_LAST, newEditor.getEditor(), e -> newEditor.getNavigatorPanel().navigateUp());
-
- return newEditor;
- });
+ } else {
+ entryEditor = existingEditor;
+ entryEditorReady = null;
+ }
+ }
if (entryEditor != null && activeEditor != entryEditor) {
- this.openFiles.setSelectedComponent(this.editors.get(entry).getUi());
+ this.openFiles.setSelectedComponent(entryEditor.getUi());
this.gui.updateStructure(entryEditor);
- this.gui.showCursorReference(entryEditor.getCursorReference());
+
+ final Runnable showReference = () -> this.gui.showCursorReference(entryEditor.getCursorReference());
+ if (entryEditorReady == null) {
+ showReference.run();
+ } else {
+ entryEditorReady.thenRunAsync(showReference, SwingUtilities::invokeLater);
+ }
}
return entryEditor;
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/IntRangeConfigMenuItem.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/IntRangeConfigMenuItem.java
new file mode 100644
index 000000000..acb908ae8
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/IntRangeConfigMenuItem.java
@@ -0,0 +1,97 @@
+package org.quiltmc.enigma.gui.element;
+
+import org.quiltmc.config.api.values.TrackedValue;
+import org.quiltmc.enigma.gui.Gui;
+import org.quiltmc.enigma.util.I18n;
+import org.quiltmc.enigma.util.Utils;
+
+import javax.swing.JMenuItem;
+import javax.swing.JOptionPane;
+import java.util.Optional;
+
+public class IntRangeConfigMenuItem extends JMenuItem {
+ public static final String DIALOG_TITLE_TRANSLATION_KEY_SUFFIX = ".dialog_title";
+ public static final String DIALOG_EXPLANATION_TRANSLATION_KEY_SUFFIX = ".dialog_explanation";
+ private final TrackedValue config;
+
+ private final String translationKey;
+
+ /**
+ * Constructs a menu item that, when clicked, prompts the user for an integer between the passed {@code min} and
+ * {@code max} using a dialog.
+ * The menu item will be kept in sync with the passed {@code config}.
+ *
+ * @param gui the gui
+ * @param config the config value to sync with
+ * @param min the minimum allowed value;
+ * this should coincide with any minimum imposed on the passed {@code config}
+ * @param max the maximum allowed value
+ * this should coincide with any maximum imposed on the passed {@code config}
+ * @param rootTranslationKey a translation key for deriving translations as follows:
+ *
+ * - this component's text: the unmodified key
+ *
- the title of the dialog: the key with
+ * {@value #DIALOG_TITLE_TRANSLATION_KEY_SUFFIX} appended
+ *
- the explanation of the dialog: the key with
+ * {@value #DIALOG_EXPLANATION_TRANSLATION_KEY_SUFFIX} appended
+ *
+ */
+ public IntRangeConfigMenuItem(Gui gui, TrackedValue config, int min, int max, String rootTranslationKey) {
+ this(
+ gui, config, min, max, rootTranslationKey,
+ rootTranslationKey + DIALOG_TITLE_TRANSLATION_KEY_SUFFIX,
+ rootTranslationKey + DIALOG_EXPLANATION_TRANSLATION_KEY_SUFFIX
+ );
+ }
+
+ private IntRangeConfigMenuItem(
+ Gui gui, TrackedValue config, int min, int max,
+ String translationKey, String dialogTitleTranslationKey, String dialogExplanationTranslationKey
+ ) {
+ this.config = config;
+ this.translationKey = translationKey;
+
+ this.addActionListener(e ->
+ getRangedIntInput(
+ gui, config.value(), min, max,
+ I18n.translate(dialogTitleTranslationKey),
+ I18n.translate(dialogExplanationTranslationKey)
+ )
+ .ifPresent(input -> {
+ if (!input.equals(config.value())) {
+ config.setValue(input);
+ }
+ })
+ );
+
+ config.registerCallback(updated -> {
+ this.retranslate();
+ });
+ }
+
+ public void retranslate() {
+ this.setText(I18n.translateFormatted(this.translationKey, this.config.value()));
+ }
+
+ private static Optional getRangedIntInput(
+ Gui gui, int initialValue, int min, int max, String title, String explanation
+ ) {
+ final String prompt = I18n.translateFormatted("prompt.input.int_range", min, max);
+ final String input = (String) JOptionPane.showInputDialog(
+ gui.getFrame(),
+ explanation + "\n" + prompt,
+ title,
+ JOptionPane.QUESTION_MESSAGE, null, null, initialValue
+ );
+
+ if (input != null) {
+ try {
+ return Optional.of(Utils.clamp(Integer.parseInt(input), min, max));
+ } catch (NumberFormatException e) {
+ return Optional.empty();
+ }
+ } else {
+ return Optional.empty();
+ }
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/AbstractEnigmaMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/AbstractEnigmaMenu.java
index 472ff03d8..f8b8bf645 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/AbstractEnigmaMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/AbstractEnigmaMenu.java
@@ -4,7 +4,7 @@
import javax.swing.JMenu;
-public class AbstractEnigmaMenu extends JMenu implements EnigmaMenu {
+public abstract class AbstractEnigmaMenu extends JMenu implements EnigmaMenu {
protected final Gui gui;
protected AbstractEnigmaMenu(Gui gui) {
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/EntryMarkersMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/EntryMarkersMenu.java
new file mode 100644
index 000000000..7038e9d1a
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/EntryMarkersMenu.java
@@ -0,0 +1,79 @@
+package org.quiltmc.enigma.gui.element.menu_bar.view;
+
+import org.quiltmc.enigma.gui.Gui;
+import org.quiltmc.enigma.gui.config.Config;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.util.GuiUtil;
+import org.quiltmc.enigma.util.I18n;
+
+import javax.swing.JCheckBoxMenuItem;
+import javax.swing.JMenu;
+import javax.swing.JToolBar;
+
+import static org.quiltmc.enigma.gui.config.EntryMarkersSection.MAX_MAX_MARKERS_PER_LINE;
+import static org.quiltmc.enigma.gui.config.EntryMarkersSection.MIN_MAX_MARKERS_PER_LINE;
+
+public class EntryMarkersMenu extends AbstractEnigmaMenu {
+ private final JCheckBoxMenuItem tooltip = GuiUtil
+ .createSyncedMenuCheckBox(Config.editor().entryMarkers.tooltip);
+
+ private final JMenu maxMarkersPerLineMenu = GuiUtil.createIntConfigRadioMenu(
+ Config.editor().entryMarkers.maxMarkersPerLine,
+ MIN_MAX_MARKERS_PER_LINE, MAX_MAX_MARKERS_PER_LINE,
+ this::translateMarkersPerLineMenu
+ );
+
+ private final JMenu markMenu = new JMenu();
+ private final JCheckBoxMenuItem onlyMarkDeclarations = GuiUtil
+ .createSyncedMenuCheckBox(Config.editor().entryMarkers.onlyMarkDeclarations);
+ private final JCheckBoxMenuItem markObfuscated = GuiUtil
+ .createSyncedMenuCheckBox(Config.editor().entryMarkers.markObfuscated);
+ private final JCheckBoxMenuItem markFallback = GuiUtil
+ .createSyncedMenuCheckBox(Config.editor().entryMarkers.markFallback);
+ private final JCheckBoxMenuItem markProposed = GuiUtil
+ .createSyncedMenuCheckBox(Config.editor().entryMarkers.markProposed);
+ private final JCheckBoxMenuItem markDeobfuscated = GuiUtil
+ .createSyncedMenuCheckBox(Config.editor().entryMarkers.markDeobfuscated);
+
+ public EntryMarkersMenu(Gui gui) {
+ super(gui);
+
+ this.add(this.tooltip);
+
+ this.add(this.maxMarkersPerLineMenu);
+
+ this.markMenu.add(this.onlyMarkDeclarations);
+ this.markMenu.add(new JToolBar.Separator());
+ this.markMenu.add(this.markObfuscated);
+ this.markMenu.add(this.markFallback);
+ this.markMenu.add(this.markProposed);
+ this.markMenu.add(this.markDeobfuscated);
+
+ this.add(this.markMenu);
+
+ this.retranslate();
+ }
+
+ @Override
+ public void retranslate() {
+ this.setText(I18n.translate("menu.view.entry_markers"));
+
+ this.tooltip.setText(I18n.translate("menu.view.entry_markers.tooltip"));
+
+ this.translateMarkersPerLineMenu();
+ this.markMenu.setText(I18n.translate("menu.view.entry_markers.mark"));
+
+ this.onlyMarkDeclarations.setText(I18n.translate("menu.view.entry_markers.mark.only_declarations"));
+ this.markObfuscated.setText(I18n.translate("menu.view.entry_markers.mark.obfuscated"));
+ this.markFallback.setText(I18n.translate("menu.view.entry_markers.mark.fallback"));
+ this.markProposed.setText(I18n.translate("menu.view.entry_markers.mark.proposed"));
+ this.markDeobfuscated.setText(I18n.translate("menu.view.entry_markers.mark.deobfuscated"));
+ }
+
+ private void translateMarkersPerLineMenu() {
+ this.maxMarkersPerLineMenu.setText(I18n.translateFormatted(
+ "menu.view.entry_markers.max_markers_per_line",
+ Config.editor().entryMarkers.maxMarkersPerLine.value())
+ );
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/SelectionHighlightMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/SelectionHighlightMenu.java
new file mode 100644
index 000000000..2544e4568
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/SelectionHighlightMenu.java
@@ -0,0 +1,54 @@
+package org.quiltmc.enigma.gui.element.menu_bar.view;
+
+import org.quiltmc.enigma.gui.Gui;
+import org.quiltmc.enigma.gui.config.Config;
+import org.quiltmc.enigma.gui.config.SelectionHighlightSection;
+import org.quiltmc.enigma.gui.element.IntRangeConfigMenuItem;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.util.GuiUtil;
+import org.quiltmc.enigma.util.I18n;
+
+import javax.swing.JMenu;
+
+public class SelectionHighlightMenu extends AbstractEnigmaMenu {
+ private final JMenu blinksMenu;
+ private final IntRangeConfigMenuItem blinkDelay;
+
+ protected SelectionHighlightMenu(Gui gui) {
+ super(gui);
+
+ final SelectionHighlightSection config = Config.editor().selectionHighlight;
+
+ this.blinksMenu = GuiUtil.createIntConfigRadioMenu(
+ config.blinks,
+ SelectionHighlightSection.MIN_BLINKS, SelectionHighlightSection.MAX_BLINKS,
+ this::retranslateBlinksMenu
+ );
+
+ this.blinkDelay = new IntRangeConfigMenuItem(
+ gui, config.blinkDelay,
+ SelectionHighlightSection.MIN_BLINK_DELAY, SelectionHighlightSection.MAX_BLINK_DELAY,
+ "menu.view.selection_highlight.blink_delay"
+ );
+
+ this.add(this.blinksMenu);
+ this.add(this.blinkDelay);
+
+ this.retranslate();
+ }
+
+ @Override
+ public void retranslate() {
+ this.setText(I18n.translate("menu.view.selection_highlight"));
+
+ this.retranslateBlinksMenu();
+ this.blinkDelay.retranslate();
+ }
+
+ private void retranslateBlinksMenu() {
+ this.blinksMenu.setText(I18n.translateFormatted(
+ "menu.view.selection_highlight.blinks",
+ Config.editor().selectionHighlight.blinks.value())
+ );
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ViewMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ViewMenu.java
index a9471b18c..0b0cc26a2 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ViewMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ViewMenu.java
@@ -15,6 +15,8 @@ public class ViewMenu extends AbstractEnigmaMenu {
private final ThemesMenu themes;
private final ScaleMenu scale;
private final EntryTooltipsMenu entryTooltips;
+ private final SelectionHighlightMenu selectionHighlight;
+ private final EntryMarkersMenu entryMarkers;
private final JMenuItem fontItem = new JMenuItem();
@@ -26,13 +28,17 @@ public ViewMenu(Gui gui) {
this.themes = new ThemesMenu(gui);
this.scale = new ScaleMenu(gui);
this.entryTooltips = new EntryTooltipsMenu(gui);
+ this.selectionHighlight = new SelectionHighlightMenu(gui);
+ this.entryMarkers = new EntryMarkersMenu(gui);
this.add(this.themes);
+ this.add(this.selectionHighlight);
this.add(this.languages);
this.add(this.notifications);
this.add(this.scale);
this.add(this.stats);
this.add(this.entryTooltips);
+ this.add(this.entryMarkers);
this.add(this.fontItem);
this.fontItem.addActionListener(e -> this.onFontClicked(this.gui));
@@ -48,6 +54,8 @@ public void retranslate() {
this.scale.retranslate();
this.stats.retranslate();
this.entryTooltips.retranslate();
+ this.selectionHighlight.retranslate();
+ this.entryMarkers.retranslate();
this.fontItem.setText(I18n.translate("menu.view.font"));
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/BaseEditorPanel.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/AbstractEditorPanel.java
similarity index 88%
rename from enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/BaseEditorPanel.java
rename to enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/AbstractEditorPanel.java
index 445f647e9..a16473b4a 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/BaseEditorPanel.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/AbstractEditorPanel.java
@@ -21,6 +21,7 @@
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.GuiController;
import org.quiltmc.enigma.gui.config.Config;
+import org.quiltmc.enigma.gui.config.SelectionHighlightSection;
import org.quiltmc.enigma.gui.config.theme.ThemeUtil;
import org.quiltmc.enigma.gui.config.theme.properties.composite.SyntaxPaneProperties;
import org.quiltmc.enigma.gui.highlight.BoxHighlightPainter;
@@ -54,14 +55,13 @@
import java.awt.GridBagLayout;
import java.awt.GridLayout;
import java.awt.Rectangle;
-import java.awt.event.ActionEvent;
-import java.awt.event.ActionListener;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.regex.Matcher;
@@ -70,10 +70,10 @@
import static org.quiltmc.enigma.gui.util.GuiUtil.consumeMousePositionIn;
import static org.quiltmc.enigma.gui.util.GuiUtil.getRecordIndexingService;
-public class BaseEditorPanel {
+public abstract class AbstractEditorPanel {
protected final JPanel ui = new JPanel();
protected final JEditorPane editor = new JEditorPane();
- protected final JScrollPane editorScrollPane = new JScrollPane(this.editor);
+ protected final S editorScrollPane = this.createEditorScrollPane(this.editor);
protected final GuiController controller;
protected final Gui gui;
@@ -102,12 +102,15 @@ public class BaseEditorPanel {
private final BoxHighlightPainter debugPainter;
private final BoxHighlightPainter fallbackPainter;
+ @Nullable
+ private SelectionHighlightHandler selectionHighlightHandler;
+
protected ClassHandler classHandler;
private DecompiledClassSource source;
private SourceBounds sourceBounds = new DefaultBounds();
protected boolean settingSource;
- public BaseEditorPanel(Gui gui) {
+ public AbstractEditorPanel(Gui gui) {
this.gui = gui;
this.controller = gui.getController();
@@ -143,6 +146,8 @@ public BaseEditorPanel(Gui gui) {
this.retryButton.addActionListener(e -> this.redecompileClass());
}
+ protected abstract S createEditorScrollPane(JEditorPane editor);
+
protected void installEditorRuler(int lineOffset) {
final SyntaxPaneProperties.Colors syntaxColors = Config.getCurrentSyntaxPaneColors();
@@ -154,26 +159,30 @@ protected void installEditorRuler(int lineOffset) {
ruler.setFont(this.editor.getFont());
}
- public void setClassHandle(ClassHandle handle) {
- this.setClassHandle(handle, true, null);
+ /**
+ * @return a future whose completion indicates that this editor's class handle and source have been set
+ */
+ public CompletableFuture> setClassHandle(ClassHandle handle) {
+ return this.setClassHandle(handle, true, null);
}
- protected void setClassHandle(
+ protected CompletableFuture> setClassHandle(
ClassHandle handle, boolean closeOldHandle,
@Nullable Function snippetFactory
) {
ClassEntry old = null;
if (this.classHandler != null) {
+ this.classHandler.removeListener();
old = this.classHandler.getHandle().getRef();
if (closeOldHandle) {
this.classHandler.getHandle().close();
}
}
- this.setClassHandleImpl(old, handle, snippetFactory);
+ return this.setClassHandleImpl(old, handle, snippetFactory);
}
- protected void setClassHandleImpl(
+ protected CompletableFuture> setClassHandleImpl(
ClassEntry old, ClassHandle handle,
@Nullable Function snippetFactory
) {
@@ -183,23 +192,25 @@ protected void setClassHandleImpl(
this.classHandler = ClassHandler.of(handle, new ClassHandleListener() {
@Override
public void onMappedSourceChanged(ClassHandle h, Result res) {
- BaseEditorPanel.this.handleDecompilerResult(res, snippetFactory);
+ AbstractEditorPanel.this.handleDecompilerResult(res, snippetFactory);
}
@Override
public void onInvalidate(ClassHandle h, InvalidationType t) {
SwingUtilities.invokeLater(() -> {
if (t == InvalidationType.FULL) {
- BaseEditorPanel.this.setDisplayMode(DisplayMode.IN_PROGRESS);
+ AbstractEditorPanel.this.setDisplayMode(DisplayMode.IN_PROGRESS);
}
});
}
});
- handle.getSource().thenAcceptAsync(
- res -> BaseEditorPanel.this.handleDecompilerResult(res, snippetFactory),
- SwingUtilities::invokeLater
- );
+ return handle.getSource()
+ .thenApplyAsync(
+ res -> this.handleDecompilerResult(res, snippetFactory),
+ SwingUtilities::invokeLater
+ )
+ .thenAcceptAsync(CompletableFuture::join);
}
public void destroy() {
@@ -212,19 +223,22 @@ private void redecompileClass() {
}
}
- private void handleDecompilerResult(
+ private CompletableFuture> handleDecompilerResult(
Result res,
@Nullable Function snippetFactory
) {
- SwingUtilities.invokeLater(() -> {
- if (res.isOk()) {
- this.setSource(res.unwrap(), snippetFactory);
- } else {
- this.displayError(res.unwrapErr());
- }
+ return CompletableFuture.runAsync(
+ () -> {
+ if (res.isOk()) {
+ this.setSource(res.unwrap(), snippetFactory);
+ } else {
+ this.displayError(res.unwrapErr());
+ }
- this.nextReference = null;
- });
+ this.nextReference = null;
+ },
+ SwingUtilities::invokeLater
+ );
}
private void displayError(ClassHandleError t) {
@@ -527,7 +541,18 @@ private void showReferenceImpl(EntryReference, Entry>> reference) {
return;
}
- final List tokens = Optional.of(this.controller.getTokensForReference(this.source, reference))
+ final List tokens = this.getReferences(reference);
+
+ if (tokens.isEmpty()) {
+ // DEBUG
+ Logger.debug("No tokens found for {} in {}", reference, this.classHandler.getHandle().getRef());
+ } else {
+ this.gui.showTokens(this, tokens);
+ }
+ }
+
+ protected List getReferences(EntryReference, Entry>> reference) {
+ return Optional.of(this.controller.getTokensForReference(this.source, reference))
.filter(directTokens -> !directTokens.isEmpty())
.or(() -> {
// record component getters often don't have a declaration token
@@ -540,13 +565,6 @@ private void showReferenceImpl(EntryReference, Entry>> reference) {
: Optional.empty();
})
.orElse(List.of());
-
- if (tokens.isEmpty()) {
- // DEBUG
- Logger.debug("No tokens found for {} in {}", reference, this.classHandler.getHandle().getRef());
- } else {
- this.gui.showTokens(this, tokens);
- }
}
/**
@@ -560,27 +578,24 @@ public void navigateToToken(@Nullable Token token) {
return;
}
- // highlight the token momentarily
- final Timer timer = new Timer(200, null);
- timer.addActionListener(new ActionListener() {
- private int counter = 0;
- private Object highlight = null;
+ this.startHighlightingSelection(boundedToken);
+ }
- @Override
- public void actionPerformed(ActionEvent event) {
- if (this.counter % 2 == 0) {
- this.highlight = BaseEditorPanel.this.addHighlight(boundedToken, SelectionHighlightPainter.INSTANCE);
- } else if (this.highlight != null) {
- BaseEditorPanel.this.editor.getHighlighter().removeHighlight(this.highlight);
- }
+ private void startHighlightingSelection(Token token) {
+ if (this.selectionHighlightHandler != null) {
+ this.selectionHighlightHandler.finish();
+ }
- if (this.counter++ > 6) {
- timer.stop();
- }
- }
- });
+ final SelectionHighlightSection config = Config.editor().selectionHighlight;
+ final int blinks = config.blinks.value();
+ if (blinks > 0) {
+ final SelectionHighlightHandler handler =
+ new SelectionHighlightHandler(token, config.blinkDelay.value(), blinks);
- timer.start();
+ handler.start();
+
+ this.selectionHighlightHandler = handler;
+ }
}
/**
@@ -628,7 +643,7 @@ protected Token navigateToTokenImpl(@Nullable Token unBoundedToken) {
protected Object addHighlight(Token token, HighlightPainter highlightPainter) {
try {
- return BaseEditorPanel.this.editor.getHighlighter()
+ return AbstractEditorPanel.this.editor.getHighlighter()
.addHighlight(token.start, token.end, highlightPainter);
} catch (BadLocationException ex) {
return null;
@@ -668,6 +683,51 @@ private ClassEntry getDeobfOrObfHandleRef() {
return deobfRef == null ? this.classHandler.handle.getRef() : deobfRef;
}
+ private class SelectionHighlightHandler extends Timer {
+ static final int BLINK_INTERVAL = 2;
+
+ final int counterMax;
+
+ int counter = 0;
+ Object highlight = null;
+
+ SelectionHighlightHandler(Token token, int delay, int blinks) {
+ super(delay, null);
+
+ this.counterMax = blinks * BLINK_INTERVAL;
+
+ this.setInitialDelay(0);
+
+ this.addActionListener(e -> {
+ if (this.counter < this.counterMax) {
+ if (this.counter % BLINK_INTERVAL == 0) {
+ this.highlight = AbstractEditorPanel.this.addHighlight(token, SelectionHighlightPainter.INSTANCE);
+ } else {
+ this.removeHighlight();
+ }
+
+ this.counter++;
+ } else {
+ this.finish();
+ }
+ });
+ }
+
+ void removeHighlight() {
+ if (this.highlight != null) {
+ AbstractEditorPanel.this.editor.getHighlighter().removeHighlight(this.highlight);
+ }
+ }
+
+ void finish() {
+ this.stop();
+ this.removeHighlight();
+ if (AbstractEditorPanel.this.selectionHighlightHandler == this) {
+ AbstractEditorPanel.this.selectionHighlightHandler = null;
+ }
+ }
+ }
+
public record Snippet(int start, int end) {
public Snippet {
if (start < 0) {
@@ -823,7 +883,7 @@ public int start() {
@Override
public int end() {
- return BaseEditorPanel.this.source.toString().length();
+ return AbstractEditorPanel.this.source.toString().length();
}
@Override
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/DeclarationSnippetPanel.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/DeclarationSnippetPanel.java
index e9488afa7..4f552be59 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/DeclarationSnippetPanel.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/DeclarationSnippetPanel.java
@@ -40,12 +40,14 @@
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.config.Config;
import org.quiltmc.enigma.gui.highlight.BoxHighlightPainter;
+import org.quiltmc.enigma.gui.util.GuiUtil;
import org.quiltmc.enigma.util.I18n;
import org.quiltmc.enigma.util.LineIndexer;
import org.quiltmc.enigma.util.Result;
import org.tinylog.Logger;
-import java.awt.Color;
+import javax.swing.JEditorPane;
+import javax.swing.JScrollPane;
import java.util.Comparator;
import java.util.Optional;
import java.util.function.Function;
@@ -54,7 +56,7 @@
import static org.quiltmc.enigma.gui.util.GuiUtil.getRecordIndexingService;
import static java.util.Comparator.comparingInt;
-public class DeclarationSnippetPanel extends BaseEditorPanel {
+public class DeclarationSnippetPanel extends AbstractEditorPanel {
private static final String NO_ENTRY_DEFINITION = "no entry definition!";
private static final String NO_TOKEN_RANGE = "no token range!";
// used to compose error messages
@@ -66,11 +68,9 @@ public DeclarationSnippetPanel(Gui gui, Entry> target, ClassHandle targetTopCl
this.getEditor().setEditable(false);
- this.editor.setCaretColor(new Color(0, 0, 0, 0));
+ this.editor.setCaretColor(GuiUtil.TRANSPARENT);
this.editor.getCaret().setSelectionVisible(true);
- this.setClassHandle(targetTopClassHandle, false, source -> this.createSnippet(source, target));
-
this.addSourceSetListener(source -> {
if (!this.isBounded()) {
// the source isn't very useful if it couldn't be trimmed
@@ -78,17 +78,24 @@ public DeclarationSnippetPanel(Gui gui, Entry> target, ClassHandle targetTopCl
this.editor.setText("// " + I18n.translate("editor.snippet.message.no_declaration_found"));
this.editor.getHighlighter().removeAllHighlights();
} else {
- this.installEditorRuler(new LineIndexer(source.toString()).getLine(this.getSourceBounds().start()));
+ this.installEditorRuler(source.getLineIndexer().getLine(this.getSourceBounds().start()));
this.resolveTarget(source, target)
.map(Target::token)
.map(this::navigateToTokenImpl)
.ifPresent(boundedToken -> this.addHighlight(boundedToken, BoxHighlightPainter.create(
- new Color(0, 0, 0, 0),
+ GuiUtil.TRANSPARENT,
Config.getCurrentSyntaxPaneColors().selectionHighlight.value()
)));
}
});
+
+ this.setClassHandle(targetTopClassHandle, false, source -> this.createSnippet(source, target));
+ }
+
+ @Override
+ protected JScrollPane createEditorScrollPane(JEditorPane editor) {
+ return new SmartScrollPane(editor);
}
private Snippet createSnippet(DecompiledClassSource source, Entry> targetEntry) {
@@ -144,7 +151,7 @@ private Result getVariableSnippet(
private Result findLambdaVariable(
DecompiledClassSource source, Token target, LocalVariableEntry targetEntry, MethodEntry parent
) {
- final LineIndexer lineIndexer = new LineIndexer(source.toString());
+ final LineIndexer lineIndexer = source.getLineIndexer();
final EntryIndex entryIndex = this.gui.getController().getProject().getJarIndex().getIndex(EntryIndex.class);
return Optional.ofNullable(entryIndex.getDefinition(parent))
@@ -197,9 +204,8 @@ private Result findClassSnippet(
DecompiledClassSource source, Token target, ClassEntry targetEntry
) {
return this.getNodeType(targetEntry).andThen(nodeType -> {
- final LineIndexer lineIndexer = new LineIndexer(source.toString());
- return findDeclaration(source, target, nodeType, lineIndexer)
- .andThen(declaration -> findTypeDeclarationSnippet(declaration, lineIndexer));
+ return findDeclaration(source, target, nodeType)
+ .andThen(declaration -> findTypeDeclarationSnippet(declaration, source.getLineIndexer()));
});
}
@@ -245,8 +251,6 @@ private Result>, String> getNodeType(ClassEnt
private Result findMethodSnippet(
DecompiledClassSource source, Token target, MethodEntry targetEntry
) {
- final LineIndexer lineIndexer = new LineIndexer(source.toString());
-
final Class extends CallableDeclaration>> nodeType;
final Function, Optional> bodyGetter;
if (targetEntry.isConstructor()) {
@@ -257,7 +261,7 @@ private Result findMethodSnippet(
bodyGetter = declaration -> ((MethodDeclaration) declaration).getBody();
}
- return findDeclaration(source, target, nodeType, lineIndexer).andThen(declaration -> {
+ return findDeclaration(source, target, nodeType).andThen(declaration -> {
final Range range = declaration.getRange().orElseThrow();
return bodyGetter.apply(declaration)
@@ -265,10 +269,10 @@ private Result findMethodSnippet(
.getRange()
.>map(Result::ok)
.orElseGet(() -> Result.err("no method body range!"))
- .map(bodyRange -> toSnippet(lineIndexer, range.begin, bodyRange.begin))
+ .map(bodyRange -> toSnippet(source.getLineIndexer(), range.begin, bodyRange.begin))
)
// no body: abstract
- .orElseGet(() -> Result.ok(toSnippet(lineIndexer, range)));
+ .orElseGet(() -> Result.ok(toSnippet(source.getLineIndexer(), range)));
});
}
@@ -296,8 +300,7 @@ private Result findFieldSnippet(
private Result findComponentParent(DecompiledClassSource source, ClassDefEntry parent) {
final Token parentToken = source.getIndex().getDeclarationToken(parent);
- final LineIndexer lineIndexer = new LineIndexer(source.toString());
- return findDeclaration(source, parentToken, RecordDeclaration.class, lineIndexer)
+ return findDeclaration(source, parentToken, RecordDeclaration.class)
.andThen(parentDeclaration -> parentDeclaration
.getImplementedTypes()
.getFirst()
@@ -309,7 +312,7 @@ private Result findComponentParent(DecompiledClassSource source
.getRange()
.map(implRange -> implRange.begin.right(-1))
.map(beforeImpl -> toSnippet(
- lineIndexer,
+ source.getLineIndexer(),
parentDeclaration.getBegin().orElseThrow(),
beforeImpl
))
@@ -321,26 +324,24 @@ private Result findComponentParent(DecompiledClassSource source
.orElseGet(() -> Result.err("no parent record token range!"))
)
// no implemented types
- .orElseGet(() -> findTypeDeclarationSnippet(parentDeclaration, lineIndexer))
+ .orElseGet(() -> findTypeDeclarationSnippet(parentDeclaration, source.getLineIndexer()))
);
}
private static Result findEnumConstantSnippet(DecompiledClassSource source, Token target) {
- final LineIndexer lineIndexer = new LineIndexer(source.toString());
- return findDeclaration(source, target, EnumConstantDeclaration.class, lineIndexer)
- .andThen(declaration -> Result.ok(toSnippet(lineIndexer, declaration.getRange().orElseThrow())));
+ return findDeclaration(source, target, EnumConstantDeclaration.class)
+ .andThen(declaration -> Result.ok(toSnippet(source.getLineIndexer(), declaration.getRange().orElseThrow())));
}
private static Result findRegularFieldSnippet(DecompiledClassSource source, Token target) {
- final LineIndexer lineIndexer = new LineIndexer(source.toString());
- return findDeclaration(source, target, FieldDeclaration.class, lineIndexer).andThen(declaration -> declaration
+ return findDeclaration(source, target, FieldDeclaration.class).andThen(declaration -> declaration
.getTokenRange()
.map(tokenRange -> {
final Range range = declaration.getRange().orElseThrow();
return declaration.getVariables().stream()
- .filter(variable -> rangeContains(lineIndexer, variable, target))
+ .filter(variable -> rangeContains(source.getLineIndexer(), variable, target))
.findFirst()
- .map(variable -> toDeclaratorSnippet(range, variable, lineIndexer))
+ .map(variable -> toDeclaratorSnippet(range, variable, source.getLineIndexer()))
.orElseGet(() -> Result.err("no matching field declarator!"));
})
.orElseGet(() -> Result.err(NO_TOKEN_RANGE))
@@ -350,14 +351,12 @@ private static Result findRegularFieldSnippet(DecompiledClassSo
private Result findLocalSnippet(
DecompiledClassSource source, Token parentToken, Token targetToken
) {
- final LineIndexer lineIndexer = new LineIndexer(source.toString());
-
- return findDeclaration(source, parentToken, MethodDeclaration.class, lineIndexer)
+ return findDeclaration(source, parentToken, MethodDeclaration.class)
.andThen(declaration -> declaration
.getBody()
.>map(Result::ok)
.orElseGet(() -> Result.err("no method body!"))
- .andThen(parentBody -> findLocalSnippet(targetToken, parentBody, lineIndexer, METHOD))
+ .andThen(parentBody -> findLocalSnippet(targetToken, parentBody, source.getLineIndexer(), METHOD))
);
}
@@ -398,12 +397,12 @@ private static Result findVariableExpressionSnippet(
* found declarations always {@linkplain TypeDeclaration#hasRange() have a range}
*/
private static > Result findDeclaration(
- DecompiledClassSource source, Token target, Class nodeType, LineIndexer lineIndexer
+ DecompiledClassSource source, Token target, Class nodeType
) {
return parse(source).andThen(unit -> unit
- .findAll(nodeType, declaration -> rangeContains(lineIndexer, declaration, target))
+ .findAll(nodeType, declaration -> rangeContains(source.getLineIndexer(), declaration, target))
.stream()
- .max(depthComparatorOf(lineIndexer))
+ .max(depthComparatorOf(source.getLineIndexer()))
.>map(Result::ok)
.orElseGet(() -> Result.err("not found in parsed source!"))
);
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EditorPanel.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EditorPanel.java
index 9e89d1382..3cf51563f 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EditorPanel.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EditorPanel.java
@@ -1,14 +1,22 @@
package org.quiltmc.enigma.gui.panel;
+import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
+import com.google.common.collect.ImmutableMap;
+import org.quiltmc.config.api.values.TrackedValue;
import org.quiltmc.enigma.api.EnigmaProject;
import org.quiltmc.enigma.api.analysis.EntryReference;
import org.quiltmc.enigma.api.class_handle.ClassHandle;
import org.quiltmc.enigma.api.event.ClassHandleListener;
import org.quiltmc.enigma.api.source.DecompiledClassSource;
+import org.quiltmc.enigma.api.source.TokenStore;
+import org.quiltmc.enigma.api.source.TokenType;
+import org.quiltmc.enigma.api.translation.representation.entry.MethodEntry;
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.config.Config;
+import org.quiltmc.enigma.gui.config.EntryMarkersSection;
import org.quiltmc.enigma.gui.config.keybind.KeyBinds;
+import org.quiltmc.enigma.gui.config.theme.properties.ThemeProperties;
import org.quiltmc.enigma.gui.dialog.EnigmaQuickFindToolBar;
import org.quiltmc.enigma.gui.element.EditorPopupMenu;
import org.quiltmc.enigma.gui.element.NavigatorPanel;
@@ -18,11 +26,18 @@
import org.quiltmc.enigma.api.translation.representation.entry.Entry;
import org.quiltmc.enigma.gui.util.GridBagConstraintsBuilder;
import org.quiltmc.syntaxpain.PairsMarker;
+import org.quiltmc.enigma.gui.util.ScaleUtil;
+import org.quiltmc.enigma.util.LineIndexer;
+import org.tinylog.Logger;
+import java.awt.BorderLayout;
+import java.awt.Color;
import java.awt.Component;
+import java.awt.Dimension;
import java.awt.GridBagConstraints;
import java.awt.KeyboardFocusManager;
import java.awt.Toolkit;
+import java.awt.Window;
import java.awt.event.AWTEventListener;
import java.awt.event.ActionEvent;
import java.awt.event.FocusAdapter;
@@ -36,12 +51,18 @@
import java.awt.event.WindowEvent;
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
+import java.util.function.Predicate;
import javax.swing.JComponent;
+import javax.swing.JEditorPane;
import javax.swing.JPanel;
+import javax.swing.JWindow;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.ToolTipManager;
+import javax.swing.text.BadLocationException;
import javax.swing.text.JTextComponent;
import javax.swing.text.TextAction;
@@ -50,15 +71,18 @@
import static javax.swing.SwingUtilities.isDescendingFrom;
import static java.awt.event.InputEvent.CTRL_DOWN_MASK;
-public class EditorPanel extends BaseEditorPanel {
+public class EditorPanel extends AbstractEditorPanel {
private final NavigatorPanel navigatorPanel;
private final EnigmaQuickFindToolBar quickFindToolBar = new EnigmaQuickFindToolBar();
private final EditorPopupMenu popupMenu;
- private final TooltipManager tooltipManager = new TooltipManager();
+ private final EntryTooltipManager entryTooltipManager = new EntryTooltipManager();
private final List listeners = new ArrayList<>();
+ @NonNull
+ private MarkerManager markerManager = this.createMarkerManager();
+
public EditorPanel(Gui gui, NavigatorPanel navigator) {
super(gui);
@@ -97,7 +121,7 @@ public void focusLost(FocusEvent e) {
this.editor.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e1) {
- if ((e1.getModifiersEx() & CTRL_DOWN_MASK) != 0 && e1.getButton() == MouseEvent.BUTTON1) {
+ if (!e1.isConsumed() && (e1.getModifiersEx() & CTRL_DOWN_MASK) != 0 && e1.getButton() == MouseEvent.BUTTON1) {
// ctrl + left click
EditorPanel.this.navigateToCursorReference();
}
@@ -150,9 +174,53 @@ public void keyTyped(KeyEvent event) {
if (this.navigatorPanel != null) {
this.navigatorPanel.resetEntries(source.getIndex().declarations());
}
+
+ this.refreshMarkers(source);
});
this.ui.putClientProperty(EditorPanel.class, this);
+
+ final EntryMarkersSection markersConfig = Config.editor().entryMarkers;
+ this.registerMarkerRefresher(markersConfig.onlyMarkDeclarations, MarkerManager::onlyMarksDeclarations);
+ this.registerMarkerRefresher(markersConfig.markObfuscated, MarkerManager::marksObfuscated);
+ this.registerMarkerRefresher(markersConfig.markFallback, MarkerManager::marksFallback);
+ this.registerMarkerRefresher(markersConfig.markProposed, MarkerManager::marksProposed);
+ this.registerMarkerRefresher(markersConfig.markDeobfuscated, MarkerManager::marksDeobfuscated);
+
+ markersConfig.maxMarkersPerLine.registerCallback(updated -> {
+ this.editorScrollPane.setMaxConcurrentMarkers(updated.value());
+ });
+ }
+
+ private void registerMarkerRefresher(TrackedValue config, Predicate handlerGetter) {
+ config.registerCallback(updated -> {
+ if (updated.value() != handlerGetter.test(this.markerManager)) {
+ this.markerManager = this.createMarkerManager();
+
+ final DecompiledClassSource source = this.getSource();
+ if (source != null) {
+ this.refreshMarkers(source);
+ } else {
+ this.editorScrollPane.clearMarkers();
+ }
+ }
+ });
+ }
+
+ private void refreshMarkers(DecompiledClassSource source) {
+ this.editorScrollPane.clearMarkers();
+
+ final TokenStore tokenStore = source.getTokenStore();
+ tokenStore.getByType().forEach((type, tokens) -> {
+ for (final Token token : tokens) {
+ this.markerManager.tryMarking(token, type, tokenStore);
+ }
+ });
+ }
+
+ @Override
+ protected MarkableScrollPane createEditorScrollPane(JEditorPane editor) {
+ return new MarkableScrollPane(editor, Config.editor().entryMarkers.maxMarkersPerLine.value());
}
public void onRename(boolean isNewMapping) {
@@ -199,7 +267,7 @@ public static EditorPanel byUi(Component ui) {
@Override
public void destroy() {
super.destroy();
- this.tooltipManager.removeExternalListeners();
+ this.entryTooltipManager.removeExternalListeners();
}
public NavigatorPanel getNavigatorPanel() {
@@ -207,11 +275,13 @@ public NavigatorPanel getNavigatorPanel() {
}
@Override
- protected void setClassHandleImpl(
+ protected CompletableFuture> setClassHandleImpl(
ClassEntry old, ClassHandle handle,
@Nullable Function snippetFactory
) {
- super.setClassHandleImpl(old, handle, snippetFactory);
+ final CompletableFuture> superFuture = super.setClassHandleImpl(old, handle, snippetFactory);
+
+ this.editorScrollPane.clearMarkers();
handle.addListener(new ClassHandleListener() {
@Override
@@ -228,6 +298,8 @@ public void onDeleted(ClassHandle h) {
});
this.listeners.forEach(l -> l.onClassHandleChanged(this, old, handle));
+
+ return superFuture;
}
private void onCaretMove(int pos) {
@@ -256,13 +328,13 @@ protected void setCursorReference(EntryReference, Entry>> ref) {
@Override
public void offsetEditorZoom(int zoomAmount) {
super.offsetEditorZoom(zoomAmount);
- this.tooltipManager.entryTooltip.setZoom(zoomAmount);
+ this.entryTooltipManager.tooltip.setZoom(zoomAmount);
}
@Override
public void resetEditorZoom() {
super.resetEditorZoom();
- this.tooltipManager.entryTooltip.resetZoom();
+ this.entryTooltipManager.tooltip.resetZoom();
}
public void addListener(EditorActionListener listener) {
@@ -300,17 +372,27 @@ public void actionPerformed(ActionEvent e) {
this.popupMenu.getButtonKeyBinds().forEach((key, button) -> putKeyBindAction(key, this.editor, e -> button.doClick()));
}
- private class TooltipManager {
+ private MarkerManager createMarkerManager() {
+ final EntryMarkersSection markersConfig = Config.editor().entryMarkers;
+ return new MarkerManager(
+ markersConfig.onlyMarkDeclarations.value(),
+ markersConfig.markObfuscated.value(),
+ markersConfig.markFallback.value(),
+ markersConfig.markProposed.value(),
+ markersConfig.markDeobfuscated.value()
+ );
+ }
+
+ private class EntryTooltipManager {
static final int MOUSE_STOPPED_MOVING_DELAY = 100;
- // DIY tooltip because JToolTip can't be moved or resized
- final EntryTooltip entryTooltip = new EntryTooltip(EditorPanel.this.gui);
+ final EntryTooltip tooltip = new EntryTooltip(EditorPanel.this.gui);
final WindowAdapter guiFocusListener = new WindowAdapter() {
@Override
public void windowLostFocus(WindowEvent e) {
- if (e.getOppositeWindow() != TooltipManager.this.entryTooltip) {
- TooltipManager.this.entryTooltip.close();
+ if (e.getOppositeWindow() != EntryTooltipManager.this.tooltip) {
+ EntryTooltipManager.this.tooltip.close();
}
}
};
@@ -325,11 +407,14 @@ public void windowLostFocus(WindowEvent e) {
// This also reduces the chances of accidentally updating the tooltip with
// a new entry's content as you move your mouse to the tooltip.
final Timer mouseStoppedMovingTimer = new Timer(MOUSE_STOPPED_MOVING_DELAY, e -> {
- if (Config.editor().entryTooltips.enable.value()) {
+ if (
+ Config.editor().entryTooltips.enable.value()
+ && !EditorPanel.this.markerManager.markerTooltip.isShowing()
+ ) {
EditorPanel.this.consumeEditorMouseTarget(
(token, entry, resolvedParent) -> {
this.hideTimer.stop();
- if (this.entryTooltip.isVisible()) {
+ if (this.tooltip.isVisible()) {
this.showTimer.stop();
if (!token.equals(this.lastMouseTargetToken)) {
@@ -342,7 +427,7 @@ public void windowLostFocus(WindowEvent e) {
}
},
() -> consumeMousePositionIn(
- this.entryTooltip.getContentPane(),
+ this.tooltip.getContentPane(),
(absolute, relative) -> this.hideTimer.stop(),
absolute -> {
this.lastMouseTargetToken = null;
@@ -358,7 +443,7 @@ public void windowLostFocus(WindowEvent e) {
ToolTipManager.sharedInstance().getInitialDelay() - MOUSE_STOPPED_MOVING_DELAY, e -> {
EditorPanel.this.consumeEditorMouseTarget((token, entry, resolvedParent) -> {
if (token.equals(this.lastMouseTargetToken)) {
- this.entryTooltip.setVisible(true);
+ this.tooltip.setVisible(true);
this.openTooltip(entry, resolvedParent);
}
});
@@ -367,55 +452,55 @@ public void windowLostFocus(WindowEvent e) {
final Timer hideTimer = new Timer(
ToolTipManager.sharedInstance().getDismissDelay() - MOUSE_STOPPED_MOVING_DELAY,
- e -> this.entryTooltip.close()
+ e -> this.tooltip.close()
);
@Nullable
Token lastMouseTargetToken;
- TooltipManager() {
+ EntryTooltipManager() {
this.mouseStoppedMovingTimer.setRepeats(false);
this.showTimer.setRepeats(false);
this.hideTimer.setRepeats(false);
- this.entryTooltip.setVisible(false);
+ this.tooltip.setVisible(false);
- this.entryTooltip.addMouseMotionListener(new MouseAdapter() {
+ this.tooltip.addMouseMotionListener(new MouseAdapter() {
@Override
public void mouseMoved(MouseEvent e) {
if (Config.editor().entryTooltips.interactable.value()) {
- TooltipManager.this.mouseStoppedMovingTimer.stop();
- TooltipManager.this.hideTimer.stop();
+ EntryTooltipManager.this.mouseStoppedMovingTimer.stop();
+ EntryTooltipManager.this.hideTimer.stop();
}
}
});
- this.entryTooltip.addCloseListener(TooltipManager.this::reset);
+ this.tooltip.addCloseListener(EntryTooltipManager.this::reset);
EditorPanel.this.editor.addKeyListener(new KeyAdapter() {
@Override
public void keyTyped(KeyEvent e) {
- TooltipManager.this.reset();
+ EntryTooltipManager.this.reset();
}
});
EditorPanel.this.editor.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent mouseEvent) {
- TooltipManager.this.entryTooltip.close();
+ EntryTooltipManager.this.tooltip.close();
}
});
EditorPanel.this.editor.addMouseMotionListener(new MouseAdapter() {
@Override
public void mouseMoved(MouseEvent e) {
- if (!TooltipManager.this.entryTooltip.hasRepopulated()) {
- TooltipManager.this.mouseStoppedMovingTimer.restart();
+ if (!EntryTooltipManager.this.tooltip.hasRepopulated()) {
+ EntryTooltipManager.this.mouseStoppedMovingTimer.restart();
}
}
});
- EditorPanel.this.editorScrollPane.getViewport().addChangeListener(e -> this.entryTooltip.close());
+ EditorPanel.this.editorScrollPane.getViewport().addChangeListener(e -> this.tooltip.close());
this.addExternalListeners();
}
@@ -432,7 +517,7 @@ void openTooltip(Entry> target, boolean inherited) {
final Component eventReceiver = focusOwner != null && isDescendingFrom(focusOwner, EditorPanel.this.gui.getFrame())
? focusOwner : null;
- this.entryTooltip.open(target, inherited, eventReceiver);
+ this.tooltip.open(target, inherited, eventReceiver);
}
void addExternalListeners() {
@@ -445,4 +530,251 @@ void removeExternalListeners() {
Toolkit.getDefaultToolkit().removeAWTEventListener(this.globalKeyListener);
}
}
+
+ private class MarkerManager {
+ static final ImmutableMap, Integer>
+ MARKER_PRIORITIES_BY_COLOR_CONFIG;
+
+ static {
+ int priority = 0;
+ MARKER_PRIORITIES_BY_COLOR_CONFIG = ImmutableMap.of(
+ Config.getCurrentSyntaxPaneColors().deobfuscatedOutline, priority++,
+ Config.getCurrentSyntaxPaneColors().proposedOutline, priority++,
+ Config.getCurrentSyntaxPaneColors().fallbackOutline, priority++,
+ Config.getCurrentSyntaxPaneColors().obfuscatedOutline, priority++,
+ Config.getCurrentSyntaxPaneColors().debugTokenOutline, priority
+ );
+ }
+
+ final MarkerTooltip markerTooltip = new MarkerTooltip();
+
+ final boolean onlyMarkDeclarations;
+
+ final boolean markObfuscated;
+ final boolean markFallback;
+ final boolean markProposed;
+ final boolean markDeobfuscated;
+
+ MarkerManager(
+ boolean onlyMarkDeclarations,
+ boolean markObfuscated, boolean markFallback, boolean markProposed, boolean markDeobfuscated
+ ) {
+ this.onlyMarkDeclarations = onlyMarkDeclarations;
+ this.markObfuscated = markObfuscated;
+ this.markFallback = markFallback;
+ this.markProposed = markProposed;
+ this.markDeobfuscated = markDeobfuscated;
+ }
+
+ void tryMarking(Token token, TokenType type, TokenStore tokenStore) {
+ if (this.onlyMarkDeclarations) {
+ final EntryReference, Entry>> reference =
+ EditorPanel.this.getReference(token);
+
+ if (reference != null) {
+ if (reference.entry instanceof MethodEntry method && method.isConstructor()) {
+ return;
+ }
+
+ final Entry> resolved = EditorPanel.this.resolveReference(reference);
+ final EntryReference, Entry>> declaration = EntryReference
+ .declaration(resolved, resolved.getName());
+
+ if (
+ EditorPanel.this.getReferences(declaration).stream()
+ .findFirst()
+ .filter(declarationToken -> !declarationToken.equals(token))
+ .isPresent()
+ ) {
+ return;
+ }
+ }
+ }
+
+ @Nullable
+ final TrackedValue colorConfig =
+ this.getColorConfig(token, type, tokenStore);
+
+ if (colorConfig != null) {
+ try {
+ final int tokenPos = (int) EditorPanel.this.editor.modelToView2D(token.start).getCenterY();
+
+ final int priority = Objects.requireNonNull(MARKER_PRIORITIES_BY_COLOR_CONFIG.get(colorConfig));
+ final Color color = colorConfig.value();
+ EditorPanel.this.editorScrollPane.addMarker(
+ tokenPos, color, priority,
+ new MarkerListener(token)
+ );
+ } catch (BadLocationException e) {
+ Logger.warn("Tried to add marker for token with bad location: " + token);
+ }
+ }
+ }
+
+ private TrackedValue getColorConfig(
+ Token token, TokenType type, TokenStore tokenStore
+ ) {
+ if (tokenStore.isFallback(token)) {
+ return this.markFallback ? Config.getCurrentSyntaxPaneColors().fallbackOutline : null;
+ } else {
+ return switch (type) {
+ case OBFUSCATED -> this.markObfuscated
+ ? Config.getCurrentSyntaxPaneColors().obfuscatedOutline
+ : null;
+ case DEOBFUSCATED -> this.markDeobfuscated
+ ? Config.getCurrentSyntaxPaneColors().deobfuscatedOutline
+ : null;
+ case JAR_PROPOSED, DYNAMIC_PROPOSED -> this.markProposed
+ ? Config.getCurrentSyntaxPaneColors().proposedOutline
+ : null;
+ // these only appear if debugTokenHighlights is true, so no need for a separate marker config
+ case DEBUG -> Config.getCurrentSyntaxPaneColors().debugTokenOutline;
+ };
+ }
+ }
+
+ boolean onlyMarksDeclarations() {
+ return this.onlyMarkDeclarations;
+ }
+
+ boolean marksObfuscated() {
+ return this.markObfuscated;
+ }
+
+ boolean marksFallback() {
+ return this.markFallback;
+ }
+
+ boolean marksProposed() {
+ return this.markProposed;
+ }
+
+ boolean marksDeobfuscated() {
+ return this.markDeobfuscated;
+ }
+
+ private class MarkerListener implements MarkableScrollPane.MarkerListener {
+ private final Token token;
+
+ MarkerListener(Token token) {
+ this.token = token;
+ }
+
+ @Override
+ public void mouseClicked(int x, int y) {
+ EditorPanel.this.navigateToToken(this.token);
+ }
+
+ @Override
+ public void mouseExited(int x, int y) {
+ if (
+ Config.editor().entryMarkers.tooltip.value()
+ && EditorPanel.this.entryTooltipManager.lastMouseTargetToken == null
+ ) {
+ MarkerManager.this.markerTooltip.close();
+ }
+ }
+
+ @Override
+ public void mouseEntered(int x, int y) {
+ if (Config.editor().entryMarkers.tooltip.value()) {
+ EditorPanel.this.entryTooltipManager.tooltip.close();
+ MarkerManager.this.markerTooltip.open(this.token, x, y);
+ }
+ }
+
+ @Override
+ public void mouseTransferred(int x, int y) {
+ this.mouseEntered(x, y);
+ }
+
+ @Override
+ public void mouseMoved(int x, int y) {
+ if (Config.editor().entryMarkers.tooltip.value()) {
+ EditorPanel.this.entryTooltipManager.tooltip.close();
+ }
+ }
+ }
+ }
+
+ private class MarkerTooltip extends JWindow {
+ public static final int DEFAULT_MARKER_PAD = 5;
+ final JPanel content = new JPanel();
+
+ // HACK to make getPreferredSize aware of its (future) position
+ // negative values indicate it's un-set and should be ignored
+ int right = -1;
+
+ MarkerTooltip() {
+ this.setContentPane(this.content);
+
+ this.setAlwaysOnTop(true);
+ this.setType(Window.Type.POPUP);
+ this.setLayout(new BorderLayout());
+ }
+
+ void open(Token target, int markerX, int markerY) {
+ this.content.removeAll();
+
+ if (EditorPanel.this.classHandler == null) {
+ return;
+ }
+
+ final SimpleSnippetPanel snippet = new SimpleSnippetPanel(EditorPanel.this.gui, target);
+
+ this.content.add(snippet.ui);
+
+ snippet.setSource(EditorPanel.this.getSource(), source -> {
+ final String sourceString = source.toString();
+ final LineIndexer lineIndexer = source.getLineIndexer();
+ final int line = lineIndexer.getLine(target.start);
+ int lineStart = lineIndexer.getStartIndex(line);
+ int lineEnd = lineIndexer.getStartIndex(line + 1);
+
+ if (lineEnd < 0) {
+ lineEnd = sourceString.length();
+ }
+
+ while (lineStart < lineEnd && Character.isWhitespace(sourceString.charAt(lineStart))) {
+ lineStart++;
+ }
+
+ while (lineEnd > lineStart && Character.isWhitespace(sourceString.charAt(lineEnd - 1))) {
+ lineEnd--;
+ }
+
+ return new Snippet(lineStart, lineEnd);
+ });
+
+ this.right = markerX - ScaleUtil.scale(DEFAULT_MARKER_PAD);
+
+ this.pack();
+
+ this.setLocation(this.right - this.getWidth(), markerY - this.getHeight() / 2);
+
+ this.right = -1;
+
+ this.setVisible(true);
+ }
+
+ void close() {
+ this.setVisible(false);
+ this.content.removeAll();
+ }
+
+ @Override
+ public Dimension getPreferredSize() {
+ final Dimension size = super.getPreferredSize();
+
+ if (this.right >= 0) {
+ final int left = this.right - size.width;
+ if (left < 0) {
+ // don't extend off the left side of the screen
+ size.width += left;
+ }
+ }
+
+ return size;
+ }
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EntryTooltip.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EntryTooltip.java
index dd19f4843..c2dcdf1a9 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EntryTooltip.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EntryTooltip.java
@@ -20,28 +20,28 @@
import org.quiltmc.enigma.gui.docker.DeobfuscatedClassesDocker;
import org.quiltmc.enigma.gui.docker.Docker;
import org.quiltmc.enigma.gui.docker.ObfuscatedClassesDocker;
-import org.quiltmc.enigma.gui.util.GridBagConstraintsBuilder;
+import org.quiltmc.enigma.gui.util.GuiUtil;
import org.quiltmc.enigma.gui.util.ScaleUtil;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
import org.quiltmc.enigma.util.I18n;
import org.quiltmc.enigma.util.Utils;
import javax.swing.Box;
import javax.swing.JLabel;
import javax.swing.JPanel;
-import javax.swing.JScrollBar;
import javax.swing.JScrollPane;
import javax.swing.JSeparator;
import javax.swing.JTextArea;
import javax.swing.JWindow;
+import javax.swing.SwingUtilities;
import javax.swing.tree.TreePath;
import java.awt.AWTEvent;
import java.awt.BorderLayout;
-import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Font;
-import java.awt.GridBagConstraints;
-import java.awt.GridBagLayout;
+import java.awt.Insets;
import java.awt.MouseInfo;
import java.awt.Point;
import java.awt.Toolkit;
@@ -128,7 +128,7 @@ public EntryTooltip(Gui gui) {
super(gui.getFrame());
this.gui = gui;
- this.content = new JPanel(new GridBagLayout());
+ this.content = new JPanel(new FlexGridLayout());
this.setAlwaysOnTop(true);
this.setType(Window.Type.POPUP);
@@ -211,7 +211,6 @@ private void populateWith(Entry> target, boolean inherited, boolean opening) {
this.repopulated = !opening;
this.content.removeAll();
- @Nullable
final MouseAdapter stopInteraction = Config.editor().entryTooltips.interactable.value()
? null : new MouseAdapter() {
@Override
@@ -226,8 +225,6 @@ public void mousePressed(MouseEvent e) {
final Font editorFont = ScaleUtil.scaleFont(Config.currentFonts().editor.value());
final Font italEditorFont = ScaleUtil.scaleFont(Config.currentFonts().editor.value().deriveFont(Font.ITALIC));
- int gridY = 0;
-
{
final Box parentLabelRow = Box.createHorizontalBox();
@@ -241,75 +238,44 @@ public void mousePressed(MouseEvent e) {
parentLabelRow.add(colonLabelOf("", editorFont));
parentLabelRow.add(this.parentLabelOf(target, editorFont, stopInteraction));
- parentLabelRow.add(Box.createHorizontalGlue());
- this.add(parentLabelRow, GridBagConstraintsBuilder.create()
- .pos(0, gridY++)
- .insets(ROW_OUTER_INSET, ROW_OUTER_INSET, ROW_INNER_INSET, ROW_OUTER_INSET)
- .anchor(GridBagConstraints.LINE_START)
- .build()
- );
+ parentLabelRow.setBorder(createEmptyBorder(ROW_OUTER_INSET, ROW_OUTER_INSET, ROW_INNER_INSET, ROW_OUTER_INSET));
+ this.add(parentLabelRow, FlexGridConstraints.createRelative().alignCenterLeft());
}
- final var mainContent = new JPanel(new GridBagLayout());
- // Put all main content in one big scroll pane.
- // Ideally there'd be separate javadoc and snippet scroll panes, but multiple scroll pane children
- // of a grid bag parent don't play nice when space is limited.
- // The snippet has its own scroll pane, but wrapping it in this one effectively disables its resizing.
- final var mainScroll = new JScrollPane(mainContent);
- mainScroll.setBorder(createEmptyBorder());
- int mainGridY = 0;
-
final String javadoc = this.getJavadoc(target).orElse(null);
final ImmutableList paramJavadocs =
this.paramJavadocsOf(target, editorFont, italEditorFont, stopInteraction);
if (javadoc != null || !paramJavadocs.isEmpty()) {
- mainContent.add(new JSeparator(), GridBagConstraintsBuilder.create()
- .pos(0, mainGridY++)
- .weightX(1)
- .fill(GridBagConstraints.HORIZONTAL)
- .build()
- );
+ this.add(new JSeparator(), FlexGridConstraints.createRelative().newRow().copy().fillX());
+
+ final var javadocs = new JPanel(new FlexGridLayout());
if (javadoc != null) {
- mainContent.add(javadocOf(javadoc, italEditorFont, stopInteraction), GridBagConstraintsBuilder.create()
- .pos(0, mainGridY++)
- .insets(ROW_INNER_INSET, ROW_OUTER_INSET)
- .weightX(1)
- .fill(GridBagConstraints.HORIZONTAL)
- .anchor(GridBagConstraints.LINE_START)
- .build()
- );
+ final JTextArea javadocText = javadocOf(javadoc, italEditorFont, stopInteraction);
+ javadocText.setBorder(createEmptyBorder(ROW_INNER_INSET, ROW_OUTER_INSET, ROW_INNER_INSET, ROW_OUTER_INSET));
+ javadocs.add(javadocText, FlexGridConstraints.createRelative().fillX());
}
if (!paramJavadocs.isEmpty()) {
- final JPanel params = new JPanel(new GridBagLayout());
- int paramsGridY = 0;
+ final JPanel params = new JPanel(new FlexGridLayout());
for (final ParamJavadoc paramJavadoc : paramJavadocs) {
- params.add(paramJavadoc.name, GridBagConstraintsBuilder.create()
- .pos(0, paramsGridY)
- .anchor(GridBagConstraints.FIRST_LINE_END)
- .build()
- );
+ params.add(paramJavadoc.name, FlexGridConstraints.createRelative().newRow().alignTopRight());
- params.add(paramJavadoc.javadoc, GridBagConstraintsBuilder.create()
- .pos(1, paramsGridY++)
- .weightX(1)
- .fill(GridBagConstraints.HORIZONTAL)
- .anchor(GridBagConstraints.LINE_START)
- .build()
+ params.add(paramJavadoc.javadoc, FlexGridConstraints.createRelative()
+ .fillX()
+ .alignTopLeft()
);
}
- mainContent.add(params, GridBagConstraintsBuilder.create()
- .insets(ROW_INNER_INSET, ROW_OUTER_INSET)
- .pos(0, mainGridY++)
- .weightX(1)
- .fill(GridBagConstraints.HORIZONTAL)
- .build()
- );
+ params.setBorder(createEmptyBorder(ROW_INNER_INSET, ROW_OUTER_INSET, ROW_INNER_INSET, ROW_OUTER_INSET));
+ javadocs.add(params, FlexGridConstraints.createRelative().newRow().fillX());
}
+
+ final JScrollPane javadocsScroll = new SmartScrollPane(javadocs);
+ javadocsScroll.setBorder(createEmptyBorder());
+ this.add(javadocsScroll, FlexGridConstraints.createRelative().newRow().fillX());
}
if (this.declarationSnippet != null) {
@@ -345,10 +311,7 @@ public void mouseClicked(MouseEvent e) {
final Dimension oldSize = opening ? null : this.getSize();
final Point oldMousePos = MouseInfo.getPointerInfo().getLocation();
this.declarationSnippet.addSourceSetListener(source -> {
- this.pack();
- // swing 3
- // a second call is required to eliminate extra space
- this.pack();
+ this.repaint();
if (this.declarationSnippet != null) {
// without this, the editor gets focus and has a blue border
@@ -356,21 +319,27 @@ public void mouseClicked(MouseEvent e) {
this.declarationSnippet.ui.requestFocus();
}
- final JScrollBar vertical = mainScroll.getVerticalScrollBar();
- // scroll to bottom so declaration snippet is in view
- vertical.setValue(vertical.getMaximum());
-
- if (oldSize == null) {
- // opening
- if (oldMousePos.distance(MouseInfo.getPointerInfo().getLocation()) < SMALL_MOVE_THRESHOLD) {
- this.moveNearCursor();
+ // JTextAreas (javadocs) adjust their preferred sizes after the first pack, so pack twice
+ this.pack();
+ // There seems to be a race condition when packing twice in a row where
+ // the tooltips window can be sized based on the first pack, but components are sized
+ // based on the second pack.
+ // Using invokeLater for *only* the second pack *seems* to solve it.
+ SwingUtilities.invokeLater(this::pack);
+
+ SwingUtilities.invokeLater(() -> {
+ if (oldSize == null) {
+ // opening
+ if (oldMousePos.distance(MouseInfo.getPointerInfo().getLocation()) < SMALL_MOVE_THRESHOLD) {
+ this.moveNearCursor();
+ } else {
+ this.moveOnScreen();
+ }
} else {
- this.moveOnScreen();
+ // not opening
+ this.moveMaintainingAnchor(oldMousePos, oldSize);
}
- } else {
- // not opening
- this.moveMaintainingAnchor(oldMousePos, oldSize);
- }
+ });
});
}
@@ -378,41 +347,21 @@ public void mouseClicked(MouseEvent e) {
this.declarationSnippet.editor.addMouseListener(stopInteraction);
}
- mainContent.add(this.declarationSnippet.ui, GridBagConstraintsBuilder.create()
- .pos(0, mainGridY++)
- .weightX(1)
- .fill(GridBagConstraints.HORIZONTAL)
- .anchor(GridBagConstraints.LINE_START)
- .build()
+ this.add(this.declarationSnippet.ui, FlexGridConstraints.createRelative().newRow()
+ .fillX()
+ .alignCenterLeft()
+ .incrementPriority()
);
} else {
- mainContent.add(new JSeparator(), GridBagConstraintsBuilder.create()
- .pos(0, mainGridY++)
- .weightX(1)
- .fill(GridBagConstraints.HORIZONTAL)
- .build()
- );
+ this.add(new JSeparator(), FlexGridConstraints.createRelative().newRow().fillX());
- mainContent.add(
- labelOf(I18n.translate("editor.tooltip.message.no_source"), italEditorFont),
- GridBagConstraintsBuilder.create()
- .pos(0, mainGridY++)
- .weightX(1)
- .fill(GridBagConstraints.HORIZONTAL)
- .anchor(GridBagConstraints.LINE_START)
- .insets(ROW_INNER_INSET, ROW_OUTER_INSET)
- .build()
- );
+ final JLabel noSource = labelOf(I18n.translate("editor.tooltip.message.no_source"), italEditorFont);
+ noSource.setBorder(createEmptyBorder(ROW_INNER_INSET, ROW_OUTER_INSET, ROW_INNER_INSET, ROW_OUTER_INSET));
+ this.add(noSource, FlexGridConstraints.createRelative().newRow().fillX());
}
}
- this.add(mainScroll, GridBagConstraintsBuilder.create()
- .pos(0, gridY++)
- .weight(1, 1)
- .fill(GridBagConstraints.BOTH)
- .build()
- );
-
+ this.repaint();
this.pack();
if (opening) {
@@ -467,16 +416,22 @@ private void moveNearCursor() {
}
final Dimension size = this.getSize();
- final Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
+ final Toolkit toolkit = Toolkit.getDefaultToolkit();
+ final Dimension screenSize = toolkit.getScreenSize();
+
final Point mousePos = MouseInfo.getPointerInfo().getLocation();
+ final Insets screenInsets = GuiUtil.findGraphicsConfig(mousePos.x, mousePos.y)
+ .map(toolkit::getScreenInsets)
+ .orElse(new Insets(0, 0, 0, 0));
+
final int x = findCoordinateSpace(
- size.width, screenSize.width,
+ size.width, screenInsets.left, screenSize.width - screenInsets.right,
mousePos.x - MOUSE_PAD, mousePos.x + MOUSE_PAD
);
final int y = findCoordinateSpace(
- size.height, screenSize.height,
+ size.height, screenInsets.top, screenSize.height - screenInsets.bottom,
mousePos.y - MOUSE_PAD, mousePos.y + MOUSE_PAD
);
@@ -529,9 +484,20 @@ private void moveMaintainingAnchor(Point oldMousePos, Dimension oldSize) {
anchoredY = pos.y + yDiff;
}
- final Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
- final int targetX = Utils.clamp(anchoredX, 0, screenSize.width - newSize.width);
- final int targetY = Utils.clamp(anchoredY, 0, screenSize.height - newSize.height);
+ final Toolkit toolkit = Toolkit.getDefaultToolkit();
+ final Dimension screenSize = toolkit.getScreenSize();
+ final Insets screenInsets = GuiUtil.findGraphicsConfig(pos.x, pos.y)
+ .map(toolkit::getScreenInsets)
+ .orElse(new Insets(0, 0, 0, 0));
+
+ final int targetX = Utils.clamp(
+ anchoredX, screenInsets.left,
+ screenSize.width - screenInsets.right - newSize.width
+ );
+ final int targetY = Utils.clamp(
+ anchoredY, screenInsets.top,
+ screenSize.height - screenInsets.bottom - newSize.height
+ );
if (targetX != pos.x || targetY != pos.y) {
this.setLocation(targetX, targetY);
@@ -546,20 +512,21 @@ private void moveOnScreen() {
return;
}
- final Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
- final Dimension size = this.getSize();
final Point pos = this.getLocationOnScreen();
+ final Toolkit toolkit = Toolkit.getDefaultToolkit();
+ final Dimension screenSize = toolkit.getScreenSize();
+ final Insets screenInsets = GuiUtil.findGraphicsConfig(pos.x, pos.y)
+ .map(toolkit::getScreenInsets)
+ .orElse(new Insets(0, 0, 0, 0));
+ final Dimension size = this.getSize();
- final int xOffScreen = pos.x + size.width - screenSize.width;
- final int yOffScreen = pos.y + size.height - screenSize.height;
-
- final boolean moveX = xOffScreen > 0;
- final boolean moveY = yOffScreen > 0;
+ final int offRight = pos.x + size.width - screenSize.width - screenInsets.right;
+ final int x = Math.max(screenInsets.left, offRight > 0 ? pos.x - offRight : pos.x);
- if (moveX || moveY) {
- final int x = pos.x - (moveX ? xOffScreen : 0);
- final int y = pos.y - (moveY ? yOffScreen : 0);
+ final int offBottom = pos.y + size.height - screenSize.height - screenInsets.bottom;
+ final int y = Math.max(screenInsets.top, offBottom > 0 ? pos.y - offBottom : pos.y);
+ if (x != pos.x || y != pos.y) {
this.setLocation(x, y);
}
}
@@ -573,17 +540,17 @@ private void onEntryClick(Entry> entry, int modifiers) {
}
}
- private static int findCoordinateSpace(int size, int screenSize, int mouseMin, int mouseMax) {
- final double spaceAfter = screenSize - mouseMax;
+ private static int findCoordinateSpace(int size, int screenMin, int screenMax, int mouseMin, int mouseMax) {
+ final double spaceAfter = screenMax - mouseMax;
if (spaceAfter >= size) {
return mouseMax;
} else {
final int spaceBefore = mouseMin - size;
- if (spaceBefore >= 0) {
+ if (spaceBefore >= screenMin) {
return spaceBefore;
} else {
// doesn't fit before or after; align with screen edge that gives more space
- return spaceAfter < spaceBefore ? 0 : screenSize - size;
+ return spaceAfter < spaceBefore ? 0 : screenMax - size;
}
}
}
@@ -608,8 +575,8 @@ private static JTextArea javadocOf(String javadoc, Font font, MouseAdapter stopI
text.setWrapStyleWord(true);
text.setForeground(Config.getCurrentSyntaxPaneColors().comment.value());
text.setFont(font);
- text.setBackground(invisibleColorOf());
- text.setCaretColor(invisibleColorOf());
+ text.setBackground(GuiUtil.TRANSPARENT);
+ text.setCaretColor(GuiUtil.TRANSPARENT);
text.getCaret().setSelectionVisible(true);
text.setBorder(createEmptyBorder());
@@ -620,8 +587,8 @@ private static JTextArea javadocOf(String javadoc, Font font, MouseAdapter stopI
return text;
}
- private static Color invisibleColorOf() {
- return new Color(0, 0, 0, 0);
+ private static String translatePlaceholder(String key) {
+ return "<%s>".formatted(I18n.translate(key));
}
private ImmutableList paramJavadocsOf(
@@ -716,7 +683,6 @@ private JLabel parentLabelOf(Entry> entry, Font font, @Nullable MouseAdapter s
nameBuilder.insert(0, packageName.replace('/', '.'));
}
- @Nullable
final MouseListener parentClicked;
if (stopInteraction == null) {
if (immediateParent != null) {
@@ -733,7 +699,10 @@ public void mouseClicked(MouseEvent e) {
parentClicked = null;
}
- final JLabel parentLabel = new JLabel(nameBuilder.isEmpty() ? "" : nameBuilder.toString());
+ final JLabel parentLabel = new JLabel(nameBuilder.isEmpty()
+ ? translatePlaceholder("editor.tooltip.label.no_package")
+ : nameBuilder.toString()
+ );
final Font parentFont;
if (parentClicked == null) {
@@ -831,12 +800,12 @@ private String getSimpleName(Entry> entry) {
if (access == null || !(access.isSynthetic())) {
return project.getRemapper().deobfuscate(entry).getSimpleName();
} else {
- return "";
+ return translatePlaceholder("editor.tooltip.label.synthetic");
}
}
}
- return "";
+ return translatePlaceholder("editor.tooltip.label.anonymous");
}
public void setZoom(int amount) {
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/MarkableScrollPane.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/MarkableScrollPane.java
new file mode 100644
index 000000000..8a4a37ef7
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/MarkableScrollPane.java
@@ -0,0 +1,676 @@
+package org.quiltmc.enigma.gui.panel;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
+import com.google.common.collect.Multiset;
+import com.google.common.collect.TreeMultiset;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+import org.quiltmc.enigma.gui.util.GuiUtil;
+import org.quiltmc.enigma.gui.util.ScaleUtil;
+
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.Insets;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.event.ComponentEvent;
+import java.awt.event.ComponentListener;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+
+import static org.quiltmc.enigma.util.Arguments.requireNonNegative;
+
+/**
+ * A scroll pane that renders markers in its view along the right edge, to the left of the vertical scroll bar.
+ * Markers support custom {@linkplain Color colors} and {@linkplain MarkerListener listeners}.
+ *
+ * Markers are associated with a vertical position within the vertical space of this scroll pane's view;
+ * markers with a position greater than the height of the current view are not rendered.
+ * Multiple markers may be rendered at the same position. Markers with the highest priority (specified when
+ * {@linkplain #addMarker(int, Color, int, MarkerListener) added}) will be rendered left-most.
+ * No more than {@link #maxConcurrentMarkers} will be rendered at the same position. If there are excess markers, those
+ * with lowest priority will be skipped. There's no guarantee which marker will be rendered when priorities are tied.
+ * When multiple markers are rendered at the same location, each will be narrower so the total width remains constant
+ * regardless of the number of markers at a position.
+ *
+ * @see #addMarker(int, Color, int, MarkerListener)
+ * @see #removeMarker(Object)
+ * @see MarkerListener
+ */
+public class MarkableScrollPane extends SmartScrollPane {
+ private static final int DEFAULT_MARKER_WIDTH = 10;
+ private static final int DEFAULT_MARKER_HEIGHT = 5;
+
+ private final Multimap markersByPos = Multimaps.newMultimap(new HashMap<>(), TreeMultiset::create);
+
+ private final int markerWidth;
+ private final int markerHeight;
+
+ private int maxConcurrentMarkers;
+
+ @Nullable
+ private PaintState paintState;
+ private MouseAdapter viewMouseAdapter;
+
+ /**
+ * Constructs a scroll pane displaying the passed {@code view} and {@code maxConcurrentMarkers}
+ * with {@link ScrollBarPolicy#AS_NEEDED AS_NEEDED} scroll bars.
+ *
+ * @see #MarkableScrollPane(Component, int, ScrollBarPolicy, ScrollBarPolicy)
+ */
+ public MarkableScrollPane(@Nullable Component view, int maxConcurrentMarkers) {
+ this(view, maxConcurrentMarkers, ScrollBarPolicy.AS_NEEDED, ScrollBarPolicy.AS_NEEDED);
+ }
+
+ /**
+ * @param view the component to display in this scroll pane's view port
+ * @param maxConcurrentMarkers see {@link #setMaxConcurrentMarkers(int)}
+ * @param vertical the vertical scroll bar policy
+ * @param horizontal the horizontal scroll bar policy
+ *
+ * @throws IllegalArgumentException if {@code maxConcurrentMarkers} is negative
+ *
+ * @see #addMarker(int, Color, int, MarkerListener)
+ */
+ public MarkableScrollPane(
+ @Nullable Component view, int maxConcurrentMarkers,
+ ScrollBarPolicy vertical, ScrollBarPolicy horizontal
+ ) {
+ super(view, vertical, horizontal);
+
+ this.setMaxConcurrentMarkers(maxConcurrentMarkers);
+
+ this.markerWidth = ScaleUtil.scale(DEFAULT_MARKER_WIDTH);
+ this.markerHeight = ScaleUtil.scale(DEFAULT_MARKER_HEIGHT);
+
+ this.addComponentListener(new ComponentListener() {
+ void refreshMarkers() {
+ MarkableScrollPane.this.paintState = MarkableScrollPane.this.createPaintState();
+
+ // I tried repainting only the old and new paintState areas here, but it sometimes left artifacts of
+ // previously painted markers when quickly resizing a right-side docker.
+ // Doing a full repaint avoids that artifacting.
+ MarkableScrollPane.this.repaint();
+ }
+
+ @Override
+ public void componentResized(ComponentEvent e) {
+ this.refreshMarkers();
+ }
+
+ @Override
+ public void componentMoved(ComponentEvent e) {
+ this.refreshMarkers();
+ }
+
+ @Override
+ public void componentShown(ComponentEvent e) { }
+
+ @Override
+ public void componentHidden(ComponentEvent e) {
+ MarkableScrollPane.this.paintState = null;
+ }
+ });
+ }
+
+ /**
+ * Adds a marker with passed {@code color} at the given {@code pos}.
+ *
+ * @param pos the vertical center of the marker within the space of this scroll pane's view;
+ * must not be negative; if greater than the height of the current view,
+ * the marker will not be rendered
+ * @param color the color of the marker
+ * @param priority the priority of the marker; if there are multiple markers at the same position, only up to
+ * {@link #maxConcurrentMarkers} of the highest priority markers will be rendered
+ * @param listener a listener for events within the marker; may be {@code null}
+ *
+ * @return an object which may be used to remove the marker by passing it to {@link #removeMarker(Object)}
+ *
+ * @throws IllegalArgumentException if {@code pos} is negative
+ *
+ * @see #removeMarker(Object)
+ * @see MarkerListener
+ */
+ public Object addMarker(int pos, Color color, int priority, @Nullable MarkerListener listener) {
+ requireNonNegative(pos, "pos");
+
+ final Marker marker = new Marker(color, priority, pos, Optional.ofNullable(listener));
+
+ this.markersByPos.put(pos, marker);
+
+ if (this.paintState == null) {
+ this.paintState = this.createPaintState();
+ }
+
+ this.paintState.pendingMarkerPositions.add(pos);
+ this.repaint(this.paintState.createArea());
+
+ return marker;
+ }
+
+ /**
+ * Removes the passed {@code marker} if it belongs to this scroll pane.
+ *
+ * @param marker an object previously returned by {@link #addMarker(int, Color, int, MarkerListener)}
+ *
+ * @see #addMarker(int, Color, int, MarkerListener)
+ * @see #clearMarkers()
+ */
+ public void removeMarker(Object marker) {
+ if (marker instanceof Marker removing) {
+ final boolean removed = this.markersByPos.remove(removing.pos, removing);
+ if (removed) {
+ if (this.paintState == null) {
+ this.paintState = this.createPaintState();
+ }
+
+ this.paintState.pendingMarkerPositions.add(removing.pos);
+ this.repaint(this.paintState.createArea());
+ }
+ }
+ }
+
+ /**
+ * Removes all markers from this scroll pane.
+ */
+ public void clearMarkers() {
+ this.markersByPos.clear();
+
+ if (this.paintState != null) {
+ this.paintState.clearMarkers();
+ }
+ }
+
+ /**
+ * @param maxConcurrentMarkers a (non-negative) number limiting how many markers will be rendered at the same position;
+ * more markers may be added, but only up to this number of markers with the highest priority will be
+ * rendered
+ *
+ * @throws IllegalArgumentException if {@code maxConcurrentMarkers} is negative
+ */
+ public void setMaxConcurrentMarkers(int maxConcurrentMarkers) {
+ requireNonNegative(maxConcurrentMarkers, "maxConcurrentMarkers");
+
+ if (maxConcurrentMarkers != this.maxConcurrentMarkers) {
+ this.maxConcurrentMarkers = maxConcurrentMarkers;
+
+ this.paintState = this.createPaintState();
+ this.repaint(this.paintState.createArea());
+ }
+ }
+
+ @Override
+ public void setViewportView(Component view) {
+ final Component oldView = this.getViewport().getView();
+ if (oldView != null) {
+ oldView.removeMouseListener(this.viewMouseAdapter);
+ oldView.removeMouseMotionListener(this.viewMouseAdapter);
+ }
+
+ super.setViewportView(view);
+
+ this.viewMouseAdapter = new MouseAdapter() {
+ @Nullable
+ ListenerPos lastListenerPos;
+
+ Optional findListenerPos(MouseEvent e) {
+ if (MarkableScrollPane.this.paintState == null) {
+ return Optional.empty();
+ } else {
+ final Point relativePos = GuiUtil
+ .getRelativePos(MarkableScrollPane.this, e.getXOnScreen(), e.getYOnScreen());
+ return MarkableScrollPane.this.paintState.findListenerPos(relativePos.x, relativePos.y);
+ }
+ }
+
+ void tryMarkerListeners(MouseEvent e, ListenerMethod method) {
+ this.findListenerPos(e).ifPresent(listenerPos -> listenerPos.invoke(method));
+ }
+
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ this.tryMarkerListeners(e, MarkerListener::mouseClicked);
+ }
+
+ @Override
+ public void mouseExited(MouseEvent e) {
+ this.mouseExitedImpl();
+ }
+
+ @Override
+ public void mouseMoved(MouseEvent e) {
+ this.tryMarkerListeners(e, MarkerListener::mouseMoved);
+
+ this.findListenerPos(e).ifPresentOrElse(
+ listenerPos -> {
+ if (!listenerPos.equals(this.lastListenerPos)) {
+ if (this.lastListenerPos == null) {
+ listenerPos.invoke(MarkerListener::mouseEntered);
+ } else {
+ listenerPos.invoke(MarkerListener::mouseTransferred);
+ }
+
+ this.lastListenerPos = listenerPos;
+ }
+ },
+ this::mouseExitedImpl
+ );
+ }
+
+ private void mouseExitedImpl() {
+ if (this.lastListenerPos != null) {
+ this.lastListenerPos.invoke(MarkerListener::mouseExited);
+ this.lastListenerPos = null;
+ }
+ }
+ };
+
+ // add the listener to the view because this doesn't receive clicks within the view
+ view.addMouseListener(this.viewMouseAdapter);
+ view.addMouseMotionListener(this.viewMouseAdapter);
+ }
+
+ @Override
+ public void paint(Graphics graphics) {
+ super.paint(graphics);
+
+ if (this.paintState == null) {
+ this.paintState = this.createPaintState();
+ }
+
+ final Graphics disposableGraphics = graphics.create();
+ this.paintState.paint(disposableGraphics);
+ disposableGraphics.dispose();
+ }
+
+ private PaintState createPaintState() {
+ final Rectangle bounds = this.getBounds();
+ final Insets insets = this.getInsets();
+
+ final int verticalScrollBarWidth = this.verticalScrollBar == null || !this.verticalScrollBar.isVisible()
+ ? 0 : this.verticalScrollBar.getWidth();
+
+ final Component view = this.getViewport().getView();
+ final int viewHeight = view.getPreferredSize().height;
+
+ final int areaHeight;
+ if (viewHeight < bounds.height) {
+ areaHeight = viewHeight;
+ } else {
+ final int horizontalScrollBarHeight =
+ this.horizontalScrollBar == null || !this.horizontalScrollBar.isVisible()
+ ? 0 : this.horizontalScrollBar.getHeight();
+
+ areaHeight = bounds.height - horizontalScrollBarHeight - insets.top - insets.bottom;
+ }
+
+ final int areaX = (int) (bounds.getMaxX() - this.markerWidth - verticalScrollBarWidth - insets.right);
+ final int areaY = bounds.y + insets.top;
+
+ return new PaintState(areaX, areaY, areaHeight, viewHeight, this.markersByPos.keySet());
+ }
+
+ private MarkersPainter markersPainterOf(List markers, int scaledPos, int x, int y, int height) {
+ final int markerCount = markers.size();
+ Preconditions.checkArgument(markerCount > 0, "no markers!");
+
+ if (markerCount == 1) {
+ final ImmutableList spans = ImmutableList
+ .of(markers.get(0).new Span(x, MarkableScrollPane.this.markerWidth));
+ return new MarkersPainter(spans, scaledPos, y, height);
+ } else {
+ final int spanWidth = MarkableScrollPane.this.markerWidth / markerCount;
+ // in case of non-evenly divisible width, give the most to the first marker: it has the highest priority
+ final int firstSpanWidth = MarkableScrollPane.this.markerWidth - spanWidth * (markerCount - 1);
+
+ final ImmutableList.Builder spansBuilder = ImmutableList.builder();
+ spansBuilder.add(markers.get(0).new Span(x, firstSpanWidth));
+
+ for (int i = 1; i < markerCount; i++) {
+ spansBuilder.add(markers.get(i).new Span(x + firstSpanWidth + spanWidth * (i - 1), spanWidth));
+ }
+
+ return new MarkersPainter(spansBuilder.build(), scaledPos, y, height);
+ }
+ }
+
+ private class PaintState {
+ final NavigableMap paintersByPos = new TreeMap<>();
+
+ final int areaX;
+ final int areaY;
+ final int areaHeight;
+
+ final int viewHeight;
+ final Set pendingMarkerPositions;
+
+ PaintState(int areaX, int areaY, int areaHeight, int viewHeight, Collection pendingMarkerPositions) {
+ this.areaX = areaX;
+ this.areaY = areaY;
+ this.areaHeight = areaHeight;
+ this.viewHeight = viewHeight;
+ this.pendingMarkerPositions = new HashSet<>(pendingMarkerPositions);
+ }
+
+ void paint(Graphics graphics) {
+ this.update();
+
+ for (final MarkersPainter painter : this.paintersByPos.values()) {
+ painter.paint(graphics);
+ }
+ }
+
+ void update() {
+ if (this.pendingMarkerPositions.isEmpty()) {
+ return;
+ }
+
+ final Map builderByScaledPos = new TreeMap<>();
+
+ for (final int pos : this.pendingMarkerPositions) {
+ final Collection markers = MarkableScrollPane.this.markersByPos.get(pos);
+ if (pos < this.viewHeight && !markers.isEmpty() && MarkableScrollPane.this.maxConcurrentMarkers > 0) {
+ final int scaledPos = this.scalePos(pos) + this.areaY;
+
+ this.paintersByPos.remove(pos);
+
+ final MarkersPainterBuilder builder = builderByScaledPos.computeIfAbsent(scaledPos, builderPos -> {
+ final int top = Math.max(builderPos - MarkableScrollPane.this.markerHeight / 2, this.areaY);
+ final int bottom = Math.min(
+ top + MarkableScrollPane.this.markerHeight,
+ this.areaY + this.areaHeight
+ );
+
+ return new MarkersPainterBuilder(pos, scaledPos, top, bottom);
+ });
+
+ builder.addMarkers(markers);
+
+ Integer abovePos = this.paintersByPos.lowerKey(pos);
+ while (abovePos != null) {
+ final int aboveScaled = this.scalePos(abovePos);
+ if (aboveScaled == scaledPos) {
+ this.paintersByPos.remove(abovePos);
+ builder.addMarkers(MarkableScrollPane.this.markersByPos.get(abovePos));
+
+ abovePos = this.paintersByPos.lowerKey(abovePos);
+ } else {
+ break;
+ }
+ }
+
+ Integer belowPos = this.paintersByPos.higherKey(pos);
+ while (belowPos != null) {
+ final int belowScaled = this.scalePos(belowPos);
+ if (belowScaled == scaledPos) {
+ this.paintersByPos.remove(belowPos);
+ builder.addMarkers(MarkableScrollPane.this.markersByPos.get(belowPos));
+
+ belowPos = this.paintersByPos.higherKey(belowPos);
+ } else {
+ break;
+ }
+ }
+ } else {
+ this.paintersByPos.remove(pos);
+ }
+ }
+
+ if (!builderByScaledPos.isEmpty()) {
+ final Iterator buildersItr = builderByScaledPos.values().iterator();
+ MarkersPainterBuilder currentBuilder = buildersItr.next();
+ while (true) {
+ final Map.Entry above = this.paintersByPos.lowerEntry(currentBuilder.pos);
+ if (above != null) {
+ final MarkersPainter aboveReplacement = currentBuilder.eliminateOverlap(above.getValue());
+ if (aboveReplacement != null) {
+ above.setValue(aboveReplacement);
+ }
+ }
+
+ final Map.Entry below = this.paintersByPos.higherEntry(currentBuilder.pos);
+ if (below != null) {
+ final MarkersPainter belowReplacement = currentBuilder.eliminateOverlap(below.getValue());
+ if (belowReplacement != null) {
+ below.setValue(belowReplacement);
+ }
+ }
+
+ if (buildersItr.hasNext()) {
+ final MarkersPainterBuilder nextBuilder = buildersItr.next();
+ currentBuilder.eliminateOverlap(nextBuilder);
+ currentBuilder = nextBuilder;
+ } else {
+ break;
+ }
+ }
+
+ for (final MarkersPainterBuilder builder : builderByScaledPos.values()) {
+ this.paintersByPos.put(builder.pos, builder.build(this.areaX));
+ }
+ }
+
+ this.pendingMarkerPositions.clear();
+ }
+
+ private int scalePos(int pos) {
+ return this.viewHeight > this.areaHeight
+ ? pos * this.areaHeight / this.viewHeight
+ : pos;
+ }
+
+ void clearMarkers() {
+ this.paintersByPos.clear();
+ this.pendingMarkerPositions.clear();
+ }
+
+ Optional findListenerPos(int x, int y) {
+ if (this.areaContains(x, y)) {
+ return this.paintersByPos.values().stream()
+ .filter(painter -> painter.y <= y && y <= painter.y + painter.height)
+ .flatMap(painter -> painter
+ .spans.stream()
+ .filter(span -> span.getMarker().listener.isPresent())
+ .filter(span -> span.x <= x && x <= span.x + span.width)
+ .findFirst()
+ .map(span -> {
+ final Point absolutePos = GuiUtil
+ .getAbsolutePos(MarkableScrollPane.this, this.areaX, painter.scaledPos);
+
+ return new ListenerPos(
+ span.getMarker().listener.orElseThrow(),
+ absolutePos.x, absolutePos.y
+ );
+ })
+ .stream()
+ )
+ .findFirst();
+ } else {
+ return Optional.empty();
+ }
+ }
+
+ boolean areaContains(int x, int y) {
+ return this.areaX <= x && x <= this.areaX + MarkableScrollPane.this.markerWidth
+ && this.areaY <= y && y <= this.areaY + this.areaHeight;
+ }
+
+ Rectangle createArea() {
+ return new Rectangle(this.areaX, this.areaY, MarkableScrollPane.this.markerWidth, this.areaHeight);
+ }
+ }
+
+ private record Marker(Color color, int priority, int pos, Optional listener)
+ implements Comparable {
+ @Override
+ public int compareTo(@NonNull Marker other) {
+ return other.priority - this.priority;
+ }
+
+ class Span {
+ final int x;
+ final int width;
+
+ Span(int x, int width) {
+ this.x = x;
+ this.width = width;
+ }
+
+ Marker getMarker() {
+ return Marker.this;
+ }
+ }
+ }
+
+ private record MarkersPainter(ImmutableList spans, int scaledPos, int y, int height) {
+ void paint(Graphics graphics) {
+ for (final Marker.Span span : this.spans) {
+ graphics.setColor(span.getMarker().color);
+ graphics.fillRect(span.x, this.y, span.width, this.height);
+ }
+ }
+
+ MarkersPainter withTopMoved(int amount) {
+ return new MarkersPainter(this.spans, this.scaledPos, this.y + amount, this.height - amount);
+ }
+
+ MarkersPainter withBottomMoved(int amount) {
+ return new MarkersPainter(this.spans, this.scaledPos, this.y, this.height + amount);
+ }
+ }
+
+ private class MarkersPainterBuilder {
+ final int pos;
+ final int scaledPos;
+
+ final Multiset markers = TreeMultiset.create();
+
+ int top;
+ int bottom;
+
+ MarkersPainterBuilder(int pos, int scaledPos, int top, int bottom) {
+ this.pos = pos;
+ this.scaledPos = scaledPos;
+
+ this.top = top;
+ this.bottom = bottom;
+ }
+
+ void addMarkers(Collection markers) {
+ this.markers.addAll(markers);
+ }
+
+ void eliminateOverlap(MarkersPainterBuilder lower) {
+ final int spaceBelow = lower.top - this.bottom;
+ if (spaceBelow < 0) {
+ final int thisAdjustment = spaceBelow / 2;
+ final int otherAdjustment = spaceBelow - thisAdjustment;
+
+ this.bottom += thisAdjustment;
+ lower.top -= otherAdjustment;
+ }
+ }
+
+ @Nullable
+ MarkersPainter eliminateOverlap(MarkersPainter painter) {
+ final int spaceAbove = painter.y + painter.height - this.top;
+ if (spaceAbove < 0) {
+ final int painterAdjustment = spaceAbove / 2;
+ final int thisAdjustment = spaceAbove - painterAdjustment;
+
+ this.top -= thisAdjustment;
+ return painter.withBottomMoved(painterAdjustment);
+ } else {
+ final int spaceBelow = painter.y - this.bottom;
+ if (spaceBelow < 0) {
+ final int thisAdjustment = spaceBelow / 2;
+ final int painterAdjustment = spaceBelow - thisAdjustment;
+
+ this.bottom += thisAdjustment;
+ return painter.withTopMoved(painterAdjustment);
+ }
+ }
+
+ return null;
+ }
+
+ MarkersPainter build(int x) {
+ final List markers = this.markers.stream()
+ .limit(MarkableScrollPane.this.maxConcurrentMarkers)
+ .toList();
+
+ return MarkableScrollPane.this.markersPainterOf(
+ markers, this.scaledPos,
+ x, this.top,
+ this.bottom - this.top
+ );
+ }
+ }
+
+ /**
+ * A listener for marker events.
+ *
+ * Listener methods receive the absolute position of the marker's left edge on the screen.
+ *
+ * @see #addMarker(int, Color, int, MarkerListener)
+ */
+ public interface MarkerListener {
+ /**
+ * Called when the mouse clicks the marker.
+ */
+ void mouseClicked(int x, int y);
+
+ /**
+ * Called when the mouse enters the marker.
+ */
+ void mouseEntered(int x, int y);
+
+ /**
+ * Called when the mouse exits the marker.
+ *
+ *
Not called when the mouse moves to an adjacent marker;
+ * see {@link #mouseTransferred}.
+ */
+ void mouseExited(int x, int y);
+
+ /**
+ * Called when the mouse moves from an adjacent marker to the marker.
+ */
+ void mouseTransferred(int x, int y);
+
+ /**
+ * Called when the mouse moves within the marker.
+ */
+ void mouseMoved(int x, int y);
+ }
+
+ private record ListenerPos(MarkerListener listener, int x, int y) {
+ void invoke(ListenerMethod method) {
+ method.listen(this.listener, this.x, this.y);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return o instanceof ListenerPos other && other.listener == this.listener;
+ }
+ }
+
+ @FunctionalInterface
+ private interface ListenerMethod {
+ void listen(MarkerListener listener, int x, int pos);
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/SimpleSnippetPanel.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/SimpleSnippetPanel.java
new file mode 100644
index 000000000..6f001528a
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/SimpleSnippetPanel.java
@@ -0,0 +1,44 @@
+package org.quiltmc.enigma.gui.panel;
+
+import org.jspecify.annotations.Nullable;
+import org.quiltmc.enigma.api.source.Token;
+import org.quiltmc.enigma.gui.Gui;
+import org.quiltmc.enigma.gui.config.Config;
+import org.quiltmc.enigma.gui.highlight.BoxHighlightPainter;
+import org.quiltmc.enigma.gui.util.GuiUtil;
+import org.quiltmc.enigma.util.LineIndexer;
+
+import javax.swing.JEditorPane;
+import javax.swing.JScrollPane;
+import java.awt.Color;
+
+public class SimpleSnippetPanel extends AbstractEditorPanel {
+ public SimpleSnippetPanel(Gui gui, @Nullable Token target) {
+ super(gui);
+
+ this.editor.setCaretColor(GuiUtil.TRANSPARENT);
+
+ this.addSourceSetListener(source -> {
+ this.installEditorRuler(new LineIndexer(source.toString()).getLine(this.getSourceBounds().start()));
+
+ if (target != null) {
+ final Token boundedTarget = this.navigateToTokenImpl(target);
+ if (boundedTarget != null) {
+ this.addHighlight(boundedTarget, BoxHighlightPainter.create(
+ new Color(0, 0, 0, 0),
+ Config.getCurrentSyntaxPaneColors().selectionHighlight.value()
+ ));
+ }
+ }
+ });
+ }
+
+ @Override
+ protected JScrollPane createEditorScrollPane(JEditorPane editor) {
+ return new JScrollPane(
+ editor,
+ JScrollPane.VERTICAL_SCROLLBAR_NEVER,
+ JScrollPane.HORIZONTAL_SCROLLBAR_NEVER
+ );
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/SmartScrollPane.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/SmartScrollPane.java
new file mode 100644
index 000000000..2bd49b5e5
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/SmartScrollPane.java
@@ -0,0 +1,74 @@
+package org.quiltmc.enigma.gui.panel;
+
+import org.jspecify.annotations.Nullable;
+
+import javax.swing.JScrollPane;
+import javax.swing.ScrollPaneConstants;
+import java.awt.Component;
+import java.awt.Dimension;
+
+/**
+ * A {@link JScrollPane} with QoL improvements.
+ *
+ * Currently it requests space for its scroll bars in {@link #getPreferredSize()} and uses
+ * {@link ScrollBarPolicy ScrollBarPolicy} instead of magic constants.
+ */
+public class SmartScrollPane extends JScrollPane {
+ /**
+ * Constructs a scroll pane displaying the passed {@code view}
+ * with {@link SmartScrollPane.ScrollBarPolicy#AS_NEEDED AS_NEEDED} scroll bars.
+ *
+ * @see #SmartScrollPane(Component, ScrollBarPolicy, ScrollBarPolicy)
+ */
+ public SmartScrollPane(@Nullable Component view) {
+ this(view, ScrollBarPolicy.AS_NEEDED, ScrollBarPolicy.AS_NEEDED);
+ }
+
+ /**
+ * @see #SmartScrollPane(Component)
+ */
+ public SmartScrollPane(@Nullable Component view, ScrollBarPolicy vertical, ScrollBarPolicy horizontal) {
+ super(view, vertical.vertical, horizontal.horizontal);
+ }
+
+ @Override
+ public Dimension getPreferredSize() {
+ final Dimension size = super.getPreferredSize();
+
+ if (this.verticalScrollBar.isShowing()) {
+ size.width += this.verticalScrollBar.getPreferredSize().width;
+ }
+
+ if (this.horizontalScrollBar.isShowing()) {
+ size.height += this.horizontalScrollBar.getPreferredSize().height;
+ }
+
+ return size;
+ }
+
+ public enum ScrollBarPolicy {
+ /**
+ * @see ScrollPaneConstants#HORIZONTAL_SCROLLBAR_AS_NEEDED
+ * @see ScrollPaneConstants#VERTICAL_SCROLLBAR_AS_NEEDED
+ */
+ AS_NEEDED(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED),
+ /**
+ * @see ScrollPaneConstants#HORIZONTAL_SCROLLBAR_ALWAYS
+ * @see ScrollPaneConstants#VERTICAL_SCROLLBAR_ALWAYS
+ */
+ ALWAYS(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_ALWAYS, ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS),
+ /**
+ * @see ScrollPaneConstants#HORIZONTAL_SCROLLBAR_NEVER
+ * @see ScrollPaneConstants#VERTICAL_SCROLLBAR_NEVER
+ */
+ NEVER(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER);
+
+ public final int horizontal;
+ public final int vertical;
+
+ ScrollBarPolicy(int horizontal, int vertical) {
+ this.horizontal = horizontal;
+ this.vertical = vertical;
+ }
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/CartesianOperations.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/CartesianOperations.java
new file mode 100644
index 000000000..bbc530173
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/CartesianOperations.java
@@ -0,0 +1,61 @@
+package org.quiltmc.enigma.gui.util;
+
+import java.awt.Component;
+import java.awt.Insets;
+
+/**
+ * Sets of operations for the {@link X} and {@link Y} axes of a cartesian plane.
+ */
+public interface CartesianOperations> {
+ int chooseCoord(int x, int y);
+
+ int getLeadingInset(Insets insets);
+ int getTrailingInset(Insets insets);
+ int getSpan(Component component);
+
+ O opposite();
+
+ interface X> extends CartesianOperations {
+ @Override
+ default int chooseCoord(int x, int y) {
+ return x;
+ }
+
+ @Override
+ default int getLeadingInset(Insets insets) {
+ return insets.left;
+ }
+
+ @Override
+ default int getTrailingInset(Insets insets) {
+ return insets.right;
+ }
+
+ @Override
+ default int getSpan(Component component) {
+ return component.getWidth();
+ }
+ }
+
+ interface Y> extends CartesianOperations {
+ @Override
+ default int chooseCoord(int x, int y) {
+ return y;
+ }
+
+ @Override
+ default int getLeadingInset(Insets insets) {
+ return insets.top;
+ }
+
+ @Override
+ default int getTrailingInset(Insets insets) {
+ return insets.bottom;
+ }
+
+ @Override
+ default int getSpan(Component component) {
+ return component.getHeight();
+ }
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/GuiUtil.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/GuiUtil.java
index f9e9a973a..9b07b8ee2 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/GuiUtil.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/GuiUtil.java
@@ -18,13 +18,16 @@
import javax.swing.AbstractButton;
import javax.swing.Action;
import javax.swing.ActionMap;
+import javax.swing.ButtonGroup;
import javax.swing.Icon;
import javax.swing.InputMap;
import javax.swing.JCheckBox;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComponent;
import javax.swing.JLabel;
+import javax.swing.JMenu;
import javax.swing.JPanel;
+import javax.swing.JRadioButtonMenuItem;
import javax.swing.JToolTip;
import javax.swing.JTree;
import javax.swing.KeyStroke;
@@ -41,6 +44,9 @@
import java.awt.Cursor;
import java.awt.Desktop;
import java.awt.Font;
+import java.awt.GraphicsConfiguration;
+import java.awt.GraphicsDevice;
+import java.awt.GraphicsEnvironment;
import java.awt.MouseInfo;
import java.awt.Point;
import java.awt.Toolkit;
@@ -70,9 +76,14 @@
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
+import java.util.function.Function;
import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
public final class GuiUtil {
+ public static final Color TRANSPARENT = new Color(0, true);
+
private GuiUtil() {
throw new UnsupportedOperationException();
}
@@ -396,6 +407,13 @@ public static Point getRelativePos(Component component, int absoluteX, int absol
return componentPos;
}
+ public static Point getAbsolutePos(Component component, int relativeX, int relativeY) {
+ final Point componentPos = component.getLocationOnScreen();
+ componentPos.translate(relativeX, relativeY);
+
+ return componentPos;
+ }
+
public static Optional getRecordIndexingService(Gui gui) {
return gui.getController()
.getProject()
@@ -472,6 +490,86 @@ private static void syncStateWithConfigImpl(
});
}
+ /**
+ * Creates a {@link JMenu} containing one {@linkplain JRadioButtonMenuItem radio item} for each value between the
+ * passed {@code min} and {@code max}, inclusive.
+ *
+ * Listeners are added to keep the selected radio item and the passed {@code config}'s
+ * {@link TrackedValue#value() value} in sync.
+ *
+ * @param config the config value to sync with
+ * @param min the minimum allowed value
+ * * this should coincide with any minimum imposed on the passed {@code config}
+ * @param max the maximum allowed value
+ * * this should coincide with any maximum imposed on the passed {@code config}
+ * @param onUpdate a function to run whenever the passed {@code config} changes, whether because a radio item was
+ * clicked or because another source updated it
+ *
+ * @return a newly created menu allowing configuration of the passed {@code config}
+ */
+ public static JMenu createIntConfigRadioMenu(
+ TrackedValue config, int min, int max, Runnable onUpdate
+ ) {
+ final Map radiosByChoice = IntStream.range(min, max + 1)
+ .boxed()
+ .collect(Collectors.toMap(
+ Function.identity(),
+ choice -> {
+ final JRadioButtonMenuItem choiceItem = new JRadioButtonMenuItem();
+ choiceItem.setText(Integer.toString(choice));
+ if (choice.equals(config.value())) {
+ choiceItem.setSelected(true);
+ }
+
+ choiceItem.addActionListener(e -> {
+ if (!config.value().equals(choice)) {
+ config.setValue(choice);
+ onUpdate.run();
+ }
+ });
+
+ return choiceItem;
+ }
+ ));
+
+ final ButtonGroup choicesGroup = new ButtonGroup();
+ final JMenu menu = new JMenu();
+ for (final JRadioButtonMenuItem radio : radiosByChoice.values()) {
+ choicesGroup.add(radio);
+ menu.add(radio);
+ }
+
+ config.registerCallback(updated -> {
+ final JRadioButtonMenuItem choiceItem = radiosByChoice.get(updated.value());
+
+ if (!choiceItem.isSelected()) {
+ choiceItem.setSelected(true);
+ onUpdate.run();
+ }
+ });
+
+ return menu;
+ }
+
+ // based on JPopupMenu::getCurrentGraphicsConfiguration
+ /**
+ * @return an {@link Optional} holding the {@link GraphicsConfiguration} of the
+ * {@linkplain GraphicsEnvironment#getScreenDevices() screen device} that contains the passed
+ * {@code x} and {@code y} coordinates if one could be found, or {@link Optional#empty()} otherwise
+ */
+ public static Optional findGraphicsConfig(int x, int y) {
+ for (final GraphicsDevice device : GraphicsEnvironment.getLocalGraphicsEnvironment().getScreenDevices()) {
+ if (device.getType() == GraphicsDevice.TYPE_RASTER_SCREEN) {
+ final GraphicsConfiguration config = device.getDefaultConfiguration();
+ if (config.getBounds().contains(x, y)) {
+ return Optional.of(config);
+ }
+ }
+ }
+
+ return Optional.empty();
+ }
+
public enum FocusCondition {
/**
* @see JComponent#WHEN_IN_FOCUSED_WINDOW
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/layout/flex_grid/ConstrainedGrid.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/layout/flex_grid/ConstrainedGrid.java
new file mode 100644
index 000000000..d4b88c4b4
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/layout/flex_grid/ConstrainedGrid.java
@@ -0,0 +1,291 @@
+package org.quiltmc.enigma.gui.util.layout.flex_grid;
+
+import org.quiltmc.enigma.gui.util.CartesianOperations;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout.Constrained;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+
+import java.awt.Component;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.stream.Stream;
+
+/**
+ * A map of cartesian coordinates to {@link Constrained} values.
+ * Only designed for use in {@link FlexGridLayout}.
+ *
+ * Multiple values can be associated with the same coordinates,
+ * but a value may only be associated with one coordinate pair at a time.
+ */
+class ConstrainedGrid {
+ // outer sorted map maps y coordinates to rows
+ // inner sorted map maps x coordinates to values
+ // component map holds values by component
+ private final SortedMap>> grid = new TreeMap<>();
+
+ private final Map componentCoordinates = new HashMap<>();
+
+ // outer sorted map maps constrained max y to rows
+ // mid sorted map maps constrained max x to values by min y
+ // used to find relative placements
+ private final SortedMap>>> maxYXGrid = new TreeMap<>();
+ // outer sorted map maps constrained max x to columns
+ // mid sorted map maps constrained max y to values by min x
+ // used to find relative placements
+ private final SortedMap>>> maxXYGrid = new TreeMap<>();
+
+ // used to find min x for relative placements
+ private final SortedMap> componentsByX = new TreeMap<>();
+
+ private final Set xFillers = new HashSet<>();
+ private final Set yFillers = new HashSet<>();
+
+ void put(int x, int y, Constrained value) {
+ final Component component = value.component();
+
+ this.remove(value.component());
+
+ this.componentCoordinates.put(component, new Coordinates(x, y));
+
+ this.grid
+ .computeIfAbsent(y, ignored -> new TreeMap<>())
+ .computeIfAbsent(x, ignored -> new HashMap<>(1))
+ .put(component, value);
+
+ final int maxY = y + value.getYExcess();
+ final int maxX = x + value.getXExcess();
+
+ this.maxYXGrid
+ .computeIfAbsent(maxY, ignored -> new TreeMap<>())
+ .computeIfAbsent(maxX, ignored -> new TreeMap<>())
+ .computeIfAbsent(y, ignored -> new HashSet<>(1))
+ .add(component);
+
+ this.maxXYGrid
+ .computeIfAbsent(maxX, ignore -> new TreeMap<>())
+ .computeIfAbsent(maxY, ignored -> new TreeMap<>())
+ .computeIfAbsent(x, ignored -> new HashSet<>(1))
+ .add(component);
+
+ this.componentsByX.computeIfAbsent(x, ignored -> new HashSet<>()).add(component);
+
+ if (value.fillX()) {
+ this.xFillers.add(component);
+ }
+
+ if (value.fillY()) {
+ this.yFillers.add(component);
+ }
+ }
+
+ void putRelative(Constrained value, FlexGridConstraints.Relative.Placement placement) {
+ final int x;
+ final int y;
+ if (this.isEmpty()) {
+ x = FlexGridConstraints.Absolute.DEFAULT_X;
+ y = FlexGridConstraints.Absolute.DEFAULT_Y;
+ } else {
+ switch (placement) {
+ case ROW_END -> {
+ final Coordinates coords = this.findEndPos(Operations.X.INSTANCE, this.maxYXGrid);
+ x = coords.x;
+ y = coords.y;
+ }
+ case NEW_ROW -> {
+ // min x
+ x = this.componentsByX.firstKey();
+ // max y + 1
+ y = this.maxYXGrid.lastKey() + 1;
+ }
+ case COLUMN_END -> {
+ final Coordinates coords = this.findEndPos(Operations.Y.INSTANCE, this.maxXYGrid);
+ x = coords.x;
+ y = coords.y;
+ }
+ case NEW_COLUMN -> {
+ // max x + 1
+ x = this.maxXYGrid.lastKey() + 1;
+ // min y
+ y = this.grid.firstKey();
+ }
+ default -> throw new AssertionError();
+ }
+ }
+
+ this.put(x, y, value);
+ }
+
+ private Coordinates findEndPos(
+ Operations ops,
+ SortedMap>>> maxGrid
+ ) {
+ final int max = maxGrid.lastKey();
+ final SortedMap>> maxXRow = maxGrid.get(max);
+ final SortedMap> maxXComponentsByY = maxXRow.get(maxXRow.lastKey());
+
+ final Set minYMaxXComponents = maxXComponentsByY.get(maxXComponentsByY.firstKey());
+ final Component component = minYMaxXComponents.iterator().next();
+ final Coordinates coords = this.componentCoordinates.get(component);
+
+ final int coord = ops.chooseCoord(coords)
+ + ops.getExcess(this.grid.get(coords.y).get(coords.x).get(component)) + 1;
+ final int oppositeCoord = ops.opposite().chooseCoord(coords);
+
+ return ops.createPos(coord, oppositeCoord);
+ }
+
+ void remove(Component component) {
+ final Coordinates coords = this.componentCoordinates.remove(component);
+ if (coords != null) {
+ final SortedMap> row = this.grid.get(coords.y);
+ final Map values = row.get(coords.x);
+ final Constrained removed = values.remove(component);
+
+ if (values.isEmpty()) {
+ row.remove(coords.x);
+
+ if (row.isEmpty()) {
+ this.grid.remove(coords.y);
+ }
+ }
+
+ final int maxY = coords.y + removed.getYExcess();
+ final int maxX = coords.x + removed.getXExcess();
+
+ this.removeFromMaxGrid(component, coords, maxX, maxY, Operations.Y.INSTANCE, this.maxYXGrid);
+ this.removeFromMaxGrid(component, coords, maxX, maxY, Operations.X.INSTANCE, this.maxXYGrid);
+
+ final Set xComponents = this.componentsByX.get(coords.x);
+ xComponents.remove(component);
+ if (xComponents.isEmpty()) {
+ this.componentsByX.remove(coords.x);
+ }
+
+ this.xFillers.remove(component);
+ this.yFillers.remove(component);
+ }
+ }
+
+ private void removeFromMaxGrid(
+ Component component, Coordinates coords, int maxX, int maxY, Operations ops,
+ SortedMap>>> maxGrid
+ ) {
+ final int coord = ops.chooseCoord(coords);
+ final int max = ops.chooseCoord(maxX, maxY);
+ final int oppositeMax = ops.opposite().chooseCoord(maxX, maxY);
+
+ final SortedMap>> maxRow = maxGrid.get(max);
+ final SortedMap> maximumsByCoord = maxRow.get(oppositeMax);
+ final Set maxComponents = maximumsByCoord.get(coord);
+ maxComponents.remove(component);
+
+ if (maxComponents.isEmpty()) {
+ maximumsByCoord.remove(coord);
+
+ if (maximumsByCoord.isEmpty()) {
+ maxRow.remove(oppositeMax);
+
+ if (maxRow.isEmpty()) {
+ maxGrid.remove(max);
+ }
+ }
+ }
+ }
+
+ boolean noneFillX() {
+ return this.xFillers.isEmpty();
+ }
+
+ boolean noneFillY() {
+ return this.yFillers.isEmpty();
+ }
+
+ boolean isEmpty() {
+ return this.componentCoordinates.isEmpty();
+ }
+
+ void forEach(EntriesConsumer action) {
+ this.grid.forEach((y, row) -> {
+ row.forEach((x, constrainedByComponent) -> {
+ action.accept(x, y, constrainedByComponent.values().stream());
+ });
+ });
+ }
+
+ Stream map(EntryFunction mapper) {
+ return this.grid.entrySet().stream().flatMap(rowEntry -> rowEntry
+ .getValue()
+ .entrySet()
+ .stream()
+ .flatMap(columnEntry -> columnEntry
+ .getValue()
+ .values()
+ .stream()
+ .map(constrained -> mapper.apply(columnEntry.getKey(), rowEntry.getKey(), constrained))
+ )
+ );
+ }
+
+ @FunctionalInterface
+ interface EntriesConsumer {
+ void accept(int x, int y, Stream values);
+ }
+
+ @FunctionalInterface
+ interface EntryFunction {
+ T apply(int x, int y, Constrained value);
+ }
+
+ private record Coordinates(int x, int y) { }
+
+ private interface Operations extends CartesianOperations {
+ class X implements Operations, CartesianOperations.X {
+ static final Operations.X INSTANCE = new Operations.X();
+
+ @Override
+ public int getExcess(Constrained constrained) {
+ return constrained.getXExcess();
+ }
+
+ @Override
+ public Coordinates createPos(int coord, int oppositeCoord) {
+ return new Coordinates(coord, oppositeCoord);
+ }
+
+ @Override
+ public Operations opposite() {
+ return Operations.Y.INSTANCE;
+ }
+ }
+
+ class Y implements Operations, CartesianOperations.Y {
+ static final Operations.Y INSTANCE = new Operations.Y();
+
+ @Override
+ public int getExcess(Constrained constrained) {
+ return constrained.getYExcess();
+ }
+
+ @Override
+ public Coordinates createPos(int coord, int oppositeCoord) {
+ return new Coordinates(oppositeCoord, coord);
+ }
+
+ @Override
+ public Operations opposite() {
+ return Operations.X.INSTANCE;
+ }
+ }
+
+ default int chooseCoord(Coordinates coords) {
+ return this.chooseCoord(coords.x, coords.y);
+ }
+
+ int getExcess(Constrained constrained);
+
+ Coordinates createPos(int coord, int oppositeCoord);
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/layout/flex_grid/FlexGridLayout.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/layout/flex_grid/FlexGridLayout.java
new file mode 100644
index 000000000..07ff45f92
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/layout/flex_grid/FlexGridLayout.java
@@ -0,0 +1,671 @@
+package org.quiltmc.enigma.gui.util.layout.flex_grid;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedMap;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+import org.quiltmc.enigma.gui.util.CartesianOperations;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints;
+import org.quiltmc.enigma.gui.util.layout.flex_grid.constraints.FlexGridConstraints.Alignment;
+
+import javax.swing.JComponent;
+import javax.swing.border.Border;
+import java.awt.Component;
+import java.awt.Container;
+import java.awt.Dimension;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.awt.LayoutManager2;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.function.Function;
+
+import static org.quiltmc.enigma.util.Utils.ceilDiv;
+
+/**
+ * A layout manager that lays out components in a grid and allocates space according to priority.
+ *
+ * Flex grids are similar to {@link GridBagLayout}s, with some key differences:
+ *
+ * - flex grids don't {@linkplain GridBagConstraints#weightx weight} their components; instead, space is
+ * allocated according to {@linkplain FlexGridConstraints#priority(int) priority} and position
+ *
- flex grids respect each component's {@linkplain Component#getMaximumSize() maximum size}
+ *
- flex grids support negative coordinates
+ *
- flex grids do not support insets; use {@linkplain JComponent#setBorder(Border) borders} instead
+ *
+ *
+ * Constraints
+ *
+ * Configure flex grids by passing {@link FlexGridConstraints} when
+ * {@linkplain Container#add(Component, Object) adding} {@link Component}s.
+ * When no constraints are specified, a default set of {@linkplain FlexGridConstraints#createRelative() relative}
+ * constraints are used.
+ * Passing non-{@linkplain FlexGridConstraints flex grid} constraints will result in an
+ * {@link IllegalArgumentException}.
+ *
+ * Space allocation
+ *
+ * Space is allocated to components per-axis, with high-{@linkplain FlexGridConstraints#priority(int) priority}
+ * components getting space first. In ties, components with the least position on the axis get priority.
+ * A component never gets less space in an axis than its {@linkplain Component#getMinimumSize() minimum size}
+ * allows, and it never gets more space than its {@linkplain Component#getMaximumSize() maximum size} allows.
+ * A component only ever gets more space in an axis than its
+ * {@linkplain Component#getPreferredSize() preferred size} requests if it's set to
+ * {@linkplain FlexGridConstraints#fill(boolean, boolean) fill} that axis.
+ *
+ *
Space allocation behavior depends on the parent container's available space and
+ * child components' total sizes (as before, per-axis):
+ *
+ *
+ * | space ≤ min |
+ * each component gets its minimum size (excess is clipped) |
+ *
+ *
+ * | min < space < preferred |
+ *
+ * each component gets at least its minimum size; components get additional space
+ * - up to their preferred sizes - according to priority
+ * |
+ *
+ *
+ * | space ≥ preferred |
+ *
+ * each component gets at least is preferred size; components that
+ * {@linkplain FlexGridConstraints#fill(boolean, boolean) fill} the axis get additional space
+ * - up to their max sizes - according to priority
+ * |
+ *
+ *
+ *
+ * Grid specifics
+ *
+ *
+ * - if a {@link FlexGridConstraints.Relative Relative} component is the first component to be added,
+ * it's placed at
+ * ({@value FlexGridConstraints.Absolute#DEFAULT_X}, {@value FlexGridConstraints.Absolute#DEFAULT_Y});
+ * otherwise its
+ * {@linkplain FlexGridConstraints.Relative#placement(FlexGridConstraints.Relative.Placement) placement}
+ * determines its position
+ *
- components with no constraints are treated are treated as though they have
+ * {@link FlexGridConstraints.Relative Relative} constraints with
+ * {@link FlexGridConstraints.Relative#DEFAULT_PLACEMENT DEFAULT_PLACEMENT}
+ *
- a component can occupy multiple grid cells when its constraint
+ * {@linkplain FlexGridConstraints#xExtent(int) xExtent} or
+ * {@linkplain FlexGridConstraints#yExtent(int) yExtent} exceeds {@code 1};
+ * it occupies cells starting from its coordinates and extending in the positive direction of each axis
+ *
- any number of components can share grid cells, resulting in overlap
+ *
- only the relative values of coordinates matter; components with the least x coordinate are left-most
+ * whether the coordinate is {@code -1000}, {@code 0}, or {@code 1000}
+ *
- vacant rows and columns are ignored; if two components are at x {@code -10} and {@code 10} and
+ * no other component has an x coordinate in that range, the two components are adjacent (in terms of x)
+ *
+ */
+public class FlexGridLayout implements LayoutManager2 {
+ // simplified integer overflow detection
+ // only correctly handles overflow if all values are positive
+ private static int sumOrMax(Collection values) {
+ int sum = 0;
+ for (final int value : values) {
+ sum += value;
+ if (sum < 0) {
+ return Integer.MAX_VALUE;
+ }
+ }
+
+ return sum;
+ }
+
+ private static int positiveOrMax(int value) {
+ return value < 0 ? Integer.MAX_VALUE : value;
+ }
+
+ private final ConstrainedGrid grid = new ConstrainedGrid();
+
+ /**
+ * Lazily populated cache.
+ *
+ * @see #getPreferredSizes()
+ */
+ private @Nullable Sizes preferredSizes;
+
+ /**
+ * Lazily populated cache.
+ *
+ * @see #getMinSizes()
+ */
+ private @Nullable Sizes minSizes;
+
+ /**
+ * Lazily populated cache.
+ *
+ * @see #getMaxSizes()
+ */
+ private @Nullable Sizes maxSizes;
+
+ @Override
+ public void addLayoutComponent(Component component, @Nullable Object constraints) throws IllegalArgumentException {
+ if (constraints == null) {
+ this.addDefaultConstrainedLayoutComponent(component);
+ } else if (constraints instanceof FlexGridConstraints> gridConstraints) {
+ this.addLayoutComponent(component, gridConstraints);
+ } else {
+ throw new IllegalArgumentException(
+ "constraints type %s does not extend %s!"
+ .formatted(constraints.getClass().getName(), FlexGridConstraints.class.getName())
+ );
+ }
+ }
+
+ public void addLayoutComponent(Component component, @Nullable FlexGridConstraints> constraints) {
+ if (constraints == null) {
+ this.addDefaultConstrainedLayoutComponent(component);
+ } else {
+ final Constrained constrained = Constrained.of(component, constraints);
+ if (constraints instanceof FlexGridConstraints.Absolute absolute) {
+ this.grid.put(absolute.getX(), absolute.getY(), constrained);
+ } else if (constraints instanceof FlexGridConstraints.Relative relative) {
+ this.grid.putRelative(constrained, relative.getPlacement());
+ } else {
+ throw new AssertionError();
+ }
+ }
+ }
+
+ @Override
+ public void addLayoutComponent(String ignored, Component component) {
+ this.addDefaultConstrainedLayoutComponent(component);
+ }
+
+ private void addDefaultConstrainedLayoutComponent(Component component) {
+ this.grid.putRelative(Constrained.defaultOf(component), FlexGridConstraints.Relative.DEFAULT_PLACEMENT);
+ }
+
+ @Override
+ public void removeLayoutComponent(Component component) {
+ this.grid.remove(component);
+ }
+
+ @Override
+ public float getLayoutAlignmentX(Container target) {
+ return 0.5f;
+ }
+
+ @Override
+ public float getLayoutAlignmentY(Container target) {
+ return 0.5f;
+ }
+
+ @Override
+ public void invalidateLayout(Container target) {
+ this.preferredSizes = null;
+ this.minSizes = null;
+ this.maxSizes = null;
+ }
+
+ @Override
+ public Dimension preferredLayoutSize(Container container) {
+ return this.getPreferredSizes().createTotalDimension(container.getInsets());
+ }
+
+ private Sizes getPreferredSizes() {
+ if (this.preferredSizes == null) {
+ this.preferredSizes = Sizes.calculate(this.grid, Component::getPreferredSize);
+ }
+
+ return this.preferredSizes;
+ }
+
+ @Override
+ public Dimension minimumLayoutSize(Container container) {
+ return this.getMinSizes().createTotalDimension(container.getInsets());
+ }
+
+ private Sizes getMinSizes() {
+ if (this.minSizes == null) {
+ this.minSizes = Sizes.calculate(this.grid, Component::getMinimumSize);
+ }
+
+ return this.minSizes;
+ }
+
+ @Override
+ public Dimension maximumLayoutSize(Container container) {
+ return this.getMaxSizes().createTotalDimension(container.getInsets());
+ }
+
+ private Sizes getMaxSizes() {
+ if (this.maxSizes == null) {
+ this.maxSizes = Sizes.calculate(this.grid, Component::getMaximumSize);
+ }
+
+ return this.maxSizes;
+ }
+
+ @Override
+ public void layoutContainer(Container parent) {
+ if (!this.grid.isEmpty()) {
+ this.layoutAxis(parent, Operations.X.INSTANCE);
+ this.layoutAxis(parent, Operations.Y.INSTANCE);
+ }
+ }
+
+ private void layoutAxis(Container parent, Operations ops) {
+ final Insets insets = parent.getInsets();
+ final int leadingInset = ops.getLeadingInset(insets);
+
+ final int availableSpace = ops.getSpan(parent) - leadingInset - ops.getTrailingInset(insets);
+
+ final Sizes preferred = this.getPreferredSizes();
+
+ final int extraSpace = availableSpace - ops.getTotalSpace(preferred);
+ if (extraSpace >= 0) {
+ if (extraSpace == 0 || ops.noneFill(this.grid)) {
+ this.layoutAxisImpl(leadingInset + extraSpace / 2, ops, ops.getCellSpans(preferred));
+ } else {
+ final ImmutableMap cellSpans = this.allocateCellSpace(ops, extraSpace, true);
+
+ final int allocatedSpace = sumOrMax(cellSpans.values());
+ final int startPos = leadingInset + (availableSpace - allocatedSpace) / 2;
+
+ this.layoutAxisImpl(startPos, ops, cellSpans);
+ }
+ } else {
+ final Sizes min = this.getMinSizes();
+
+ final int extraMinSpace = availableSpace - ops.getTotalSpace(min);
+ if (extraMinSpace <= 0) {
+ this.layoutAxisImpl(leadingInset, ops, ops.getCellSpans(min));
+ } else {
+ final ImmutableMap cellSpans = this.allocateCellSpace(ops, extraMinSpace, false);
+
+ this.layoutAxisImpl(leadingInset, ops, cellSpans);
+ }
+ }
+ }
+
+ private ImmutableMap allocateCellSpace(
+ Operations ops, int remainingSpace, boolean fill
+ ) {
+ final Sizes large;
+ final Sizes small;
+ if (fill) {
+ large = this.getMaxSizes();
+ small = this.getPreferredSizes();
+ } else {
+ large = this.getPreferredSizes();
+ small = this.getMinSizes();
+ }
+
+ final SortedMap cellSpans = new TreeMap<>(ops.getCellSpans(small));
+
+ final List prioritized = this.grid
+ .map((x, y, constrained) -> fill && !ops.fills(constrained)
+ ? Optional.empty()
+ : Optional.of(constrained.new At(ops.chooseCoord(x, y)))
+ )
+ .flatMap(Optional::stream)
+ .sorted()
+ .toList();
+
+ for (final Constrained.At at : prioritized) {
+ final int extent = ops.getExtent(at.constrained());
+ final Size targetSize = large.componentSizes.get(at.constrained().component);
+ assert targetSize != null;
+
+ final int targetSpan = ceilDiv(ops.getSpan(targetSize), extent);
+
+ for (int i = 0; i < extent; i++) {
+ final int extendedCoord = at.coord + i;
+ final int currentSpan = cellSpans.get(extendedCoord);
+
+ final int targetDiff = targetSpan - currentSpan;
+ if (targetDiff > 0) {
+ if (targetDiff <= remainingSpace) {
+ cellSpans.put(extendedCoord, targetSpan);
+
+ remainingSpace -= targetDiff;
+ if (remainingSpace == 0) {
+ break;
+ }
+ } else {
+ final int lastOfSpace = remainingSpace;
+ remainingSpace = 0;
+ cellSpans.compute(extendedCoord, (ignored, span) -> {
+ assert span != null;
+ return span + lastOfSpace;
+ });
+
+ break;
+ }
+ }
+ }
+
+ if (remainingSpace <= 0) {
+ break;
+ }
+ }
+
+ // ImmutableMaps maintain order and provide O(1) lookups
+ return ImmutableMap.copyOf(cellSpans);
+ }
+
+ /**
+ * @implNote the passed {@code cellSpans} must be ordered with increasing keys
+ */
+ private void layoutAxisImpl(int startPos, Operations ops, ImmutableMap cellSpans) {
+ final Map beginPositions = new HashMap<>();
+ int currentPos = startPos;
+ for (final Map.Entry entry : cellSpans.entrySet()) {
+ final int coord = entry.getKey();
+ final int span = entry.getValue();
+
+ beginPositions.put(coord, currentPos);
+
+ currentPos += span;
+ }
+
+ final Sizes preferred = this.getPreferredSizes();
+ final Sizes max = this.getMaxSizes();
+
+ this.grid.forEach((x, y, values) -> {
+ final int coord = ops.chooseCoord(x, y);
+
+ final int beginPos = beginPositions.get(coord);
+
+ values.forEach(constrained -> {
+ final int extent = ops.getExtent(constrained);
+
+ int extendedCellSpan = 0;
+ for (int i = 0; i < extent; i++) {
+ final Integer span = cellSpans.get(coord + i);
+ assert span != null;
+ extendedCellSpan += span;
+ }
+
+ final Sizes targets = ops.fills(constrained) ? max : preferred;
+ final Size targetSize = targets.componentSizes.get(constrained.component);
+ assert targetSize != null;
+
+ final int span = Math.min(ops.getSpan(targetSize), extendedCellSpan);
+
+ final int constrainedPos = switch (ops.getAlignment(constrained)) {
+ case BEGIN -> beginPos;
+ case CENTER -> beginPos + (extendedCellSpan - span) / 2;
+ case END -> beginPos + extendedCellSpan - span;
+ };
+
+ ops.setBounds(constrained.component, constrainedPos, span);
+ });
+ });
+ }
+
+ record Constrained(
+ Component component,
+ int xExtent, int yExtent,
+ boolean fillX, boolean fillY,
+ Alignment xAlignment, Alignment yAlignment,
+ int priority
+ ) {
+ static Constrained defaultOf(Component component) {
+ return new Constrained(
+ component,
+ FlexGridConstraints.DEFAULT_X_EXTENT, FlexGridConstraints.DEFAULT_Y_EXTENT,
+ FlexGridConstraints.DEFAULT_FILL_X, FlexGridConstraints.DEFAULT_FILL_Y,
+ FlexGridConstraints.DEFAULT_X_ALIGNMENT, FlexGridConstraints.DEFAULT_Y_ALIGNMENT,
+ FlexGridConstraints.DEFAULT_PRIORITY
+ );
+ }
+
+ static Constrained of(Component component, FlexGridConstraints> constraints) {
+ return new Constrained(
+ component,
+ constraints.getXExtent(), constraints.getYExtent(),
+ constraints.fillsX(), constraints.fillsY(),
+ constraints.getXAlignment(), constraints.getYAlignment(),
+ constraints.getPriority()
+ );
+ }
+
+ int getXExcess() {
+ return this.xExtent - 1;
+ }
+
+ int getYExcess() {
+ return this.yExtent - 1;
+ }
+
+ private class At implements Comparable {
+ static final Comparator PRIORITY_COMPARATOR = (left, right) -> {
+ return right.constrained().priority - left.constrained().priority;
+ };
+
+ static final Comparator COORD_COMPARATOR = Comparator.comparingInt(At::getCoord);
+
+ static final Comparator COMPARATOR = PRIORITY_COMPARATOR.thenComparing(COORD_COMPARATOR);
+
+ final int coord;
+
+ At(int coord) {
+ this.coord = coord;
+ }
+
+ int getCoord() {
+ return this.coord;
+ }
+
+ Constrained constrained() {
+ return Constrained.this;
+ }
+
+ @Override
+ public int compareTo(@NonNull At other) {
+ return COMPARATOR.compare(this, other);
+ }
+ }
+ }
+
+ private record Size(int width, int height) {
+ static Size of(Dimension dimension) {
+ return new Size(dimension.width, dimension.height);
+ }
+ }
+
+ /**
+ * A collection of sizes and size metrics used for calculating min/max/preferred container size and
+ * for laying out the container.
+ *
+ * @implNote {@link #rowHeights} and {@link #columnWidths} are ordered with increasing keys
+ */
+ private record Sizes(
+ int totalWidth, int totalHeight,
+ ImmutableMap rowHeights, ImmutableMap columnWidths,
+ ImmutableMap componentSizes
+ ) {
+ static Sizes EMPTY = new Sizes(
+ 0, 0,
+ ImmutableSortedMap.of(), ImmutableSortedMap.of(),
+ ImmutableMap.of()
+ );
+
+ static Sizes calculate(ConstrainedGrid grid, Function getSize) {
+ if (grid.isEmpty()) {
+ return EMPTY;
+ }
+
+ final Map componentSizes = new HashMap<>();
+
+ final Map> cellSizes = new HashMap<>();
+
+ grid.forEach((x, y, values) -> {
+ values.forEach(constrained -> {
+ final Size size = componentSizes
+ .computeIfAbsent(constrained.component, component -> Size.of(getSize.apply(component)));
+
+ final int componentCellWidth = ceilDiv(size.width, constrained.xExtent);
+ final int componentCellHeight = ceilDiv(size.height, constrained.yExtent);
+ for (int xOffset = 0; xOffset < constrained.xExtent; xOffset++) {
+ for (int yOffset = 0; yOffset < constrained.yExtent; yOffset++) {
+ final Dimension cellSize = cellSizes
+ .computeIfAbsent(x + xOffset, ignored -> new HashMap<>())
+ .computeIfAbsent(y + yOffset, ignored -> new Dimension());
+
+ cellSize.width = Math.max(cellSize.width, componentCellWidth);
+ cellSize.height = Math.max(cellSize.height, componentCellHeight);
+ }
+ }
+ });
+ });
+
+ final SortedMap rowHeights = new TreeMap<>();
+ final SortedMap columnWidths = new TreeMap<>();
+ cellSizes.forEach((x, column) -> {
+ column.forEach((y, size) -> {
+ rowHeights.compute(y, (ignored, height) -> height == null
+ ? size.height
+ : Math.max(height, size.height)
+ );
+ columnWidths.compute(x, (ignored, width) -> width == null
+ ? size.width
+ : Math.max(width, size.width)
+ );
+ });
+ });
+
+ return new Sizes(
+ sumOrMax(columnWidths.values()),
+ sumOrMax(rowHeights.values()),
+ // ImmutableMaps maintain order and provide O(1) lookups
+ ImmutableMap.copyOf(rowHeights), ImmutableMap.copyOf(columnWidths),
+ ImmutableMap.copyOf(componentSizes)
+ );
+ }
+
+ Dimension createTotalDimension(Insets insets) {
+ return new Dimension(
+ positiveOrMax(this.totalWidth + insets.left + insets.right),
+ positiveOrMax(this.totalHeight + insets.top + insets.bottom)
+ );
+ }
+ }
+
+ private interface Operations extends CartesianOperations {
+ class X implements Operations, CartesianOperations.X {
+ static final Operations.X INSTANCE = new Operations.X();
+
+ @Override
+ public int getTotalSpace(Sizes sizes) {
+ return sizes.totalWidth;
+ }
+
+ @Override
+ public ImmutableMap getCellSpans(Sizes sizes) {
+ return sizes.columnWidths;
+ }
+
+ @Override
+ public int getSpan(Size size) {
+ return size.width;
+ }
+
+ @Override
+ public boolean fills(Constrained constrained) {
+ return constrained.fillX;
+ }
+
+ @Override
+ public boolean noneFill(ConstrainedGrid grid) {
+ return grid.noneFillX();
+ }
+
+ @Override
+ public Alignment getAlignment(Constrained constrained) {
+ return constrained.xAlignment;
+ }
+
+ @Override
+ public int getExtent(Constrained constrained) {
+ return constrained.xExtent;
+ }
+
+ @Override
+ public void setBounds(Component component, int x, int width) {
+ component.setBounds(x, component.getY(), width, component.getHeight());
+ }
+
+ @Override
+ public Operations opposite() {
+ return Operations.Y.INSTANCE;
+ }
+ }
+
+ class Y implements Operations, CartesianOperations.Y {
+ static final Operations.Y INSTANCE = new Operations.Y();
+
+ @Override
+ public int getTotalSpace(Sizes sizes) {
+ return sizes.totalHeight;
+ }
+
+ @Override
+ public ImmutableMap getCellSpans(Sizes sizes) {
+ return sizes.rowHeights;
+ }
+
+ @Override
+ public int getSpan(Size size) {
+ return size.height;
+ }
+
+ @Override
+ public boolean fills(Constrained constrained) {
+ return constrained.fillY;
+ }
+
+ @Override
+ public boolean noneFill(ConstrainedGrid grid) {
+ return grid.noneFillY();
+ }
+
+ @Override
+ public Alignment getAlignment(Constrained constrained) {
+ return constrained.yAlignment;
+ }
+
+ @Override
+ public int getExtent(Constrained constrained) {
+ return constrained.yExtent;
+ }
+
+ @Override
+ public void setBounds(Component component, int y, int height) {
+ component.setBounds(component.getX(), y, component.getWidth(), height);
+ }
+
+ @Override
+ public Operations opposite() {
+ return Operations.X.INSTANCE;
+ }
+ }
+
+ int getTotalSpace(Sizes sizes);
+ ImmutableMap getCellSpans(Sizes sizes);
+ int getSpan(Size size);
+
+ boolean fills(Constrained constrained);
+ boolean noneFill(ConstrainedGrid grid);
+ Alignment getAlignment(Constrained constrained);
+ int getExtent(Constrained constrained);
+
+ void setBounds(Component component, int pos, int span);
+
+ @Override
+ Operations opposite();
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/layout/flex_grid/constraints/FlexGridConstraints.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/layout/flex_grid/constraints/FlexGridConstraints.java
new file mode 100644
index 000000000..deacb2046
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/layout/flex_grid/constraints/FlexGridConstraints.java
@@ -0,0 +1,1046 @@
+package org.quiltmc.enigma.gui.util.layout.flex_grid.constraints;
+
+import org.quiltmc.enigma.gui.util.layout.flex_grid.FlexGridLayout;
+
+import java.awt.Component;
+import java.awt.Container;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+
+import static org.quiltmc.enigma.util.Arguments.requirePositive;
+import static org.quiltmc.enigma.util.Utils.requireNonNull;
+
+/**
+ * Constraints for components added to a {@link Container} with a {@link FlexGridLayout} using
+ * {@link Container#add(Component, Object)}.
+ * {@link FlexGridConstraints} is to {@link GridBagConstraints} as
+ * {@link FlexGridLayout} is to {@link GridBagLayout}.
+ *
+ * Differences from {@link GridBagConstraints}
+ *
+ * - flex constraints have separate {@link Relative Relative} and {@link Absolute Absolute}
+ * types; {@link Absolute#toRelative() toRelative()} and {@link Relative#toAbsolute() toAbsolute()}
+ * convert between them
+ *
- {@link Relative Relative} constraints support different
+ * {@linkplain Relative#placement(Relative.Placement) placements}
+ *
- {@link Absolute Absolute} constraints support negative {@linkplain Absolute#pos(int, int) coordinates}
+ *
- flex constraints don't use magic constants
+ *
+ *
+ * Convenience
+ *
+ * - constraints use the builder pattern; they're designed for method chaining
+ *
- constraints are mutable but {@linkplain #copy() copyable}, and their values are copied when
+ * {@linkplain Container#add(Component, Object) adding} to a container
+ *
- they have numerous method variations for common use cases, including:
+ *
+ * - {@link Absolute#nextRow() nextRow()} and {@link Absolute#nextColumn() nextColumn()}
+ *
- a method for each {@link Relative.Placement Placement}
+ *
- {@link #incrementPriority()} and {@link #decrementPriority()}
+ *
- a method for each combination of vertical and horizontal {@link Alignment Alignment}s
+ *
+ *
+ *
+ * @param the type of these constraints; usually not relevant to users
+ */
+@SuppressWarnings("unused")
+public abstract sealed class FlexGridConstraints> {
+ public static final int DEFAULT_PRIORITY = 0;
+ public static final int DEFAULT_X_EXTENT = 1;
+ public static final int DEFAULT_Y_EXTENT = 1;
+ public static final boolean DEFAULT_FILL_X = false;
+ public static final boolean DEFAULT_FILL_Y = false;
+ public static final Alignment DEFAULT_X_ALIGNMENT = Alignment.BEGIN;
+ public static final Alignment DEFAULT_Y_ALIGNMENT = Alignment.CENTER;
+
+ private static final String EXTENT = "extent";
+ private static final String ALIGNMENT = "alignment";
+
+ public static Relative createRelative() {
+ return Relative.of();
+ }
+
+ public static Absolute createAbsolute() {
+ return Absolute.of();
+ }
+
+ /**
+ * Defaults to {@value #DEFAULT_X_EXTENT}.
+ * Always positive.
+ */
+ int xExtent;
+
+ /**
+ * Defaults to {@value #DEFAULT_Y_EXTENT}.
+ * Always positive.
+ */
+ int yExtent;
+
+ /**
+ * Defaults to {@value #DEFAULT_FILL_X}.
+ */
+ boolean fillX;
+
+ /**
+ * Defaults to {@value #DEFAULT_FILL_Y}.
+ */
+ boolean fillY;
+
+ /**
+ * Defaults to {@link #DEFAULT_X_ALIGNMENT}.
+ */
+ Alignment xAlignment;
+
+ /**
+ * Defaults to {@link #DEFAULT_Y_ALIGNMENT}.
+ */
+ Alignment yAlignment;
+
+ int priority;
+
+ private FlexGridConstraints(
+ int xExtent, int yExtent,
+ boolean fillX, boolean fillY,
+ Alignment xAlignment, Alignment yAlignment,
+ int priority
+ ) {
+ this.xExtent = xExtent;
+ this.yExtent = yExtent;
+
+ this.fillX = fillX;
+ this.fillY = fillY;
+
+ this.xAlignment = xAlignment;
+ this.yAlignment = yAlignment;
+
+ this.priority = priority;
+ }
+
+ public int getXExtent() {
+ return this.xExtent;
+ }
+
+ public int getYExtent() {
+ return this.yExtent;
+ }
+
+ public boolean fillsX() {
+ return this.fillX;
+ }
+
+ public boolean fillsY() {
+ return this.fillY;
+ }
+
+ public Alignment getXAlignment() {
+ return this.xAlignment;
+ }
+
+ public Alignment getYAlignment() {
+ return this.yAlignment;
+ }
+
+ public int getPriority() {
+ return this.priority;
+ }
+
+ /**
+ * Sets {@link #xExtent} to the passed value.
+ *
+ * The default values is {@value #DEFAULT_X_EXTENT}.
+ *
+ *
{@link #xExtent} controls the number of grid cells the constrained component occupies; it extends rightward.
+ *
+ * @throws IllegalArgumentException if the passed {@code extent} is not positive
+ *
+ * @see #yExtent(int)
+ * @see #extent(int, int)
+ */
+ public C xExtent(int extent) {
+ this.xExtent = requirePositive(extent, EXTENT);
+ return this.getSelf();
+ }
+
+ /**
+ * Sets {@link #yExtent} to the passed value.
+ *
+ *
The default values is {@value #DEFAULT_Y_EXTENT}.
+ *
+ *
{@link #yExtent} controls the number of grid cells the constrained component occupies; it extends downward.
+ *
+ * @throws IllegalArgumentException if the passed {@code extent} is not positive
+ *
+ * @see #xExtent(int)
+ * @see #extent(int, int)
+ */
+ public C yExtent(int extent) {
+ this.yExtent = requirePositive(extent, EXTENT);
+ return this.getSelf();
+ }
+
+ /**
+ * Sets {@link #xExtent} and {@link #yExtent} to the passed values.
+ *
+ *
The default values are {@value #DEFAULT_X_EXTENT} and {@value #DEFAULT_Y_EXTENT}, respectively.
+ *
+ *
{@link #xExtent} and {@link #yExtent} control the number of grid cells the constrained component occupies.
+ *
+ * @throws IllegalArgumentException if the passed {@code x} and {@code y} are not both positive
+ *
+ * @see #xExtent(int)
+ * @see #yExtent(int)
+ */
+ public C extent(int x, int y) {
+ return this.xExtent(x).yExtent(y);
+ }
+
+ /**
+ * Sets {@link #fillX} to the passed value.
+ *
+ *
The default value is {@value #DEFAULT_FILL_X}.
+ *
+ * @see #fillX()
+ * @see #fillOnlyX()
+ * @see #fillY(boolean)
+ * @see #fill(boolean, boolean)
+ */
+ public C fillX(boolean fill) {
+ this.fillX = fill;
+ return this.getSelf();
+ }
+
+ /**
+ * Sets {@link #fillX} to {@code true}.
+ *
+ *
The default value is {@value #DEFAULT_FILL_X}.
+ *
+ * @see #fillX(boolean)
+ * @see #fillOnlyX()
+ * @see #fillY()
+ * @see #fill(boolean, boolean)
+ */
+ public C fillX() {
+ return this.fillX(true);
+ }
+
+ /**
+ * Sets {@link #fillX} to {@code true} and {@link #fillY} to {@code false}.
+ *
+ *
The default values are {@value #DEFAULT_FILL_X} and {@value #DEFAULT_FILL_Y}, respectively.
+ *
+ * @see #fillX()
+ * @see #fillX(boolean)
+ * @see #fillOnlyY()
+ * @see #fillBoth()
+ * @see #fillNone()
+ * @see #fill(boolean, boolean)
+ */
+ public C fillOnlyX() {
+ return this.fillX().fillY(false);
+ }
+
+ /**
+ * Sets {@link #fillY} to the passed value.
+ *
+ *
The default value is {@value #DEFAULT_FILL_Y}.
+ *
+ * @see #fillY()
+ * @see #fillOnlyY()
+ * @see #fillX(boolean)
+ * @see #fill(boolean, boolean)
+ */
+ public C fillY(boolean fill) {
+ this.fillY = fill;
+ return this.getSelf();
+ }
+
+ /**
+ * Sets {@link #fillY} to {@code true}.
+ *
+ *
The default value is {@value #DEFAULT_FILL_Y}.
+ *
+ * @see #fillY(boolean)
+ * @see #fillOnlyY()
+ * @see #fillX()
+ * @see #fill(boolean, boolean)
+ */
+ public C fillY() {
+ return this.fillY(true);
+ }
+
+ /**
+ * Sets {@link #fillY} to {@code true} and {@link #fillX} to {@code false}.
+ *
+ *
The default values are {@value #DEFAULT_FILL_Y} and {@value #DEFAULT_FILL_X}, respectively.
+ *
+ * @see #fillY()
+ * @see #fillY(boolean)
+ * @see #fillOnlyX()
+ * @see #fillBoth()
+ * @see #fillNone()
+ * @see #fill(boolean, boolean)
+ */
+ public C fillOnlyY() {
+ return this.fillY().fillX(false);
+ }
+
+ /**
+ * Sets {@link #fillX} and {@link #fillY} to the passed values.
+ *
+ *
The default values are {@value #DEFAULT_FILL_X} and {@value #DEFAULT_FILL_Y}, respectively.
+ *
+ * @see #fillX()
+ * @see #fillX(boolean)
+ * @see #fillOnlyX()
+ * @see #fillY()
+ * @see #fillY(boolean)
+ * @see #fillOnlyY()
+ * @see #fillBoth()
+ * @see #fillNone()
+ */
+ public C fill(boolean x, boolean y) {
+ return this.fillX(x).fillY(y);
+ }
+
+ /**
+ * Sets {@link #fillX} and {@link #fillY} to {@code true}.
+ *
+ *
The default values are {@value #DEFAULT_FILL_X} and {@value #DEFAULT_FILL_Y}, respectively.
+ *
+ * @see #fillNone()
+ * @see #fillOnlyX()
+ * @see #fillOnlyY()
+ * @see #fill(boolean, boolean)
+ */
+ public C fillBoth() {
+ return this.fillX().fillY();
+ }
+
+ /**
+ * Sets {@link #fillX} and {@link #fillY} to {@code false}.
+ *
+ *
The default values are {@value #DEFAULT_FILL_X} and {@value #DEFAULT_FILL_Y}, respectively.
+ *
+ * @see #fillBoth()
+ * @see #fillOnlyX()
+ * @see #fillOnlyY()
+ * @see #fill(boolean, boolean)
+ */
+ public C fillNone() {
+ return this.fill(false, false);
+ }
+
+ /**
+ * Sets {@link #xAlignment} to the passed {@code alignment}.
+ *
+ *
The default value is {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ *
Also see the methods {@linkplain #alignTopLeft() below} for setting each combination of alignments.
+ *
+ * @see #alignLeft()
+ * @see #alignCenterX()
+ * @see #alignRight()
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignX(Alignment alignment) {
+ this.xAlignment = requireNonNull(alignment, ALIGNMENT);
+ return this.getSelf();
+ }
+
+ /**
+ * Sets {@link #xAlignment} to {@link Alignment#BEGIN BEGIN}.
+ *
+ *
The default value is {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ *
Also see the methods {@linkplain #alignTopLeft() below} for setting each combination of alignments.
+ *
+ * @see #alignCenterX()
+ * @see #alignRight()
+ * @see #alignX(Alignment)
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignLeft() {
+ return this.alignX(Alignment.BEGIN);
+ }
+
+ /**
+ * Sets {@link #xAlignment} to {@link Alignment#CENTER CENTER}.
+ *
+ *
The default value is {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ *
Also see the methods {@linkplain #alignTopLeft() below} for setting each combination of alignments.
+ *
+ * @see #alignLeft()
+ * @see #alignRight()
+ * @see #alignX(Alignment)
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignCenterX() {
+ return this.alignX(Alignment.CENTER);
+ }
+
+ /**
+ * Sets {@link #xAlignment} to {@link Alignment#END END}.
+ *
+ *
The default value is {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ *
Also see the methods {@linkplain #alignTopLeft() below} for setting each combination of alignments.
+ *
+ * @see #alignLeft()
+ * @see #alignCenterX()
+ * @see #alignX(Alignment)
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignRight() {
+ return this.alignX(Alignment.END);
+ }
+
+ /**
+ * Sets {@link #yAlignment} to the passed {@code alignment}.
+ *
+ *
The default value is {@link #DEFAULT_Y_ALIGNMENT}.
+ *
+ *
Also see the methods {@linkplain #alignTopLeft() below} for setting each combination of alignments.
+ *
+ * @see #alignTop()
+ * @see #alignCenterY()
+ * @see #alignBottom()
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignY(Alignment alignment) {
+ this.yAlignment = requireNonNull(alignment, ALIGNMENT);
+ return this.getSelf();
+ }
+
+ /**
+ * Sets {@link #yAlignment} to {@link Alignment#BEGIN BEGIN}.
+ *
+ *
The default value is {@link #DEFAULT_Y_ALIGNMENT}.
+ *
+ *
Also see the methods {@linkplain #alignTopLeft() below} for setting each combination of alignments.
+ *
+ * @see #alignCenterY()
+ * @see #alignBottom()
+ * @see #alignY(Alignment)
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignTop() {
+ return this.alignY(Alignment.BEGIN);
+ }
+
+ /**
+ * Sets {@link #yAlignment} to {@link Alignment#CENTER CENTER}.
+ *
+ *
The default value is {@link #DEFAULT_Y_ALIGNMENT}.
+ *
+ *
Also see the methods {@linkplain #alignTopLeft() below} for setting each combination of alignments.
+ *
+ * @see #alignTop()
+ * @see #alignBottom()
+ * @see #alignY(Alignment)
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignCenterY() {
+ return this.alignY(Alignment.CENTER);
+ }
+
+ /**
+ * Sets {@link #yAlignment} to {@link Alignment#END END}.
+ *
+ *
The default value is {@link #DEFAULT_Y_ALIGNMENT}.
+ *
+ *
Also see the methods {@linkplain #alignTopLeft() below} for setting each combination of alignments.
+ *
+ * @see #alignTop()
+ * @see #alignCenterY()
+ * @see #alignY(Alignment)
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignBottom() {
+ return this.alignY(Alignment.END);
+ }
+
+ /**
+ * Sets the {@link #xAlignment} and {@link #yAlignment} to the passed values.
+ *
+ *
The default values are {@link #DEFAULT_X_ALIGNMENT} and {@link #DEFAULT_Y_ALIGNMENT}.
+ *
+ *
Also see the methods {@linkplain #alignTopLeft() below} for setting each combination of alignments.
+ *
+ * @see #alignX(Alignment)
+ * @see #alignY(Alignment)
+ */
+ public C align(Alignment x, Alignment y) {
+ return this.alignX(x).alignY(y);
+ }
+
+ /**
+ * Sets {@link #yAlignment} and {@link #xAlignment} to {@link Alignment#BEGIN BEGIN}.
+ *
+ *
The default values are {@link #DEFAULT_Y_ALIGNMENT} and {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignTopLeft() {
+ return this.alignTop().alignLeft();
+ }
+
+ /**
+ * Sets {@link #yAlignment} to {@link Alignment#BEGIN BEGIN} and {@link #xAlignment} to
+ * {@link Alignment#CENTER CENTER}.
+ *
+ *
The default values are {@link #DEFAULT_Y_ALIGNMENT} and {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ * @see #alignTop()
+ * @see #alignCenterX()
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignTopCenter() {
+ return this.alignTop().alignCenterX();
+ }
+
+ /**
+ * Sets {@link #yAlignment} to {@link Alignment#BEGIN BEGIN} and {@link #xAlignment} to {@link Alignment#END END}.
+ *
+ *
The default values are {@link #DEFAULT_Y_ALIGNMENT} and {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ * @see #alignTop()
+ * @see #alignRight()
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignTopRight() {
+ return this.alignTop().alignRight();
+ }
+
+ /**
+ * Sets {@link #yAlignment} to {@link Alignment#CENTER CENTER} and {@link #xAlignment} to
+ * {@link Alignment#BEGIN BEGIN}.
+ *
+ *
The default values are {@link #DEFAULT_Y_ALIGNMENT} and {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ * @see #alignCenterY()
+ * @see #alignLeft()
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignCenterLeft() {
+ return this.alignCenterY().alignLeft();
+ }
+
+ /**
+ * Sets {@link #yAlignment} and {@link #xAlignment} to {@link Alignment#CENTER CENTER}.
+ *
+ *
The default values are {@link #DEFAULT_Y_ALIGNMENT} and {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ * @see #alignCenterY()
+ * @see #alignCenterX()
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignCenter() {
+ return this.alignCenterY().alignCenterX();
+ }
+
+ /**
+ * Sets {@link #yAlignment} to {@link Alignment#CENTER CENTER} and {@link #xAlignment} to {@link Alignment#END END}.
+ *
+ *
The default values are {@link #DEFAULT_Y_ALIGNMENT} and {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ * @see #alignCenterY()
+ * @see #alignRight()
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignCenterRight() {
+ return this.alignCenterY().alignRight();
+ }
+
+ /**
+ * Sets {@link #yAlignment} to {@link Alignment#END END} and {@link #xAlignment} to {@link Alignment#BEGIN BEGIN}.
+ *
+ *
The default values are {@link #DEFAULT_Y_ALIGNMENT} and {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ * @see #alignBottom()
+ * @see #alignLeft()
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignBottomLeft() {
+ return this.alignBottom().alignLeft();
+ }
+
+ /**
+ * Sets {@link #yAlignment} to {@link Alignment#END END} and {@link #xAlignment} to {@link Alignment#CENTER CENTER}.
+ *
+ *
The default values are {@link #DEFAULT_Y_ALIGNMENT} and {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ * @see #alignBottom()
+ * @see #alignCenterX()
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignBottomCenter() {
+ return this.alignBottom().alignCenterX();
+ }
+
+ /**
+ * Sets {@link #yAlignment} and {@link #xAlignment} to {@link Alignment#END END}.
+ *
+ *
The default values are {@link #DEFAULT_Y_ALIGNMENT} and {@link #DEFAULT_X_ALIGNMENT}.
+ *
+ * @see #alignBottom()
+ * @see #alignRight()
+ * @see #align(Alignment, Alignment)
+ */
+ public C alignBottomRight() {
+ return this.alignBottom().alignRight();
+ }
+
+ /**
+ * Sets {@link #priority}.
+ *
+ *
The default value is {@value #DEFAULT_PRIORITY}.
+ *
+ * @see #defaultPriority()
+ * @see #addPriority(int)
+ * @see #incrementPriority()
+ * @see #decrementPriority()
+ */
+ public C priority(int priority) {
+ this.priority = priority;
+ return this.getSelf();
+ }
+
+ /**
+ * Sets the {@link #priority} to its default value: {@value #DEFAULT_PRIORITY}.
+ *
+ * @see #priority(int)
+ */
+ public C defaultPriority() {
+ return this.priority(DEFAULT_PRIORITY);
+ }
+
+ /**
+ * Adds the passed {@code amount} to {@link #priority}.
+ *
+ *
The default value is {@value #DEFAULT_PRIORITY}.
+ *
+ * @see #incrementPriority()
+ * @see #decrementPriority()
+ * @see #priority(int)
+ */
+ public C addPriority(int amount) {
+ return this.priority(this.priority + amount);
+ }
+
+ /**
+ * Adds {@code 1} to {@link #priority}.
+ *
+ *
The default value is {@value #DEFAULT_PRIORITY}.
+ *
+ * @see #decrementPriority()
+ * @see #addPriority(int)
+ * @see #priority(int)
+ */
+ public C incrementPriority() {
+ return this.addPriority(1);
+ }
+
+ /**
+ * Subtracts {@code 1} from {@link #priority}.
+ *
+ *
The default value is {@value #DEFAULT_PRIORITY}.
+ *
+ * @see #incrementPriority()
+ * @see #addPriority(int)
+ * @see #priority(int)
+ */
+ public C decrementPriority() {
+ return this.addPriority(-1);
+ }
+
+ public abstract C copy();
+
+ abstract C getSelf();
+
+ public enum Alignment {
+ /**
+ * Left for horizontal alignment; top for vertical alignment.
+ */
+ BEGIN,
+ /**
+ * Middle for both horizontal and vertical alignments.
+ */
+ CENTER,
+ /**
+ * Right for horizontal alignment; bottom for vertical alignment.
+ */
+ END
+ }
+
+ /**
+ * {@link FlexGridConstraints} with relative coordinates.
+ * Components' {@linkplain Placement placements} determine their positions relative to components
+ * {@linkplain Container#add(Component, Object) added} before them.
+ *
+ *
Relative components never overlap components added before them, but {@link Absolute Absolute}
+ * components added after them may overlap.
+ *
+ * @see Absolute#toRelative() Absolute.toRelative()
+ * @see #toAbsolute()
+ * @see #toAbsolute(int, int)
+ */
+ public static final class Relative extends FlexGridConstraints {
+ public static final Placement DEFAULT_PLACEMENT = Placement.ROW_END;
+
+ public static Relative of() {
+ return new Relative(
+ DEFAULT_X_EXTENT, DEFAULT_Y_EXTENT,
+ DEFAULT_FILL_X, DEFAULT_FILL_Y,
+ DEFAULT_X_ALIGNMENT, DEFAULT_Y_ALIGNMENT,
+ DEFAULT_PRIORITY,
+ DEFAULT_PLACEMENT
+ );
+ }
+
+ /**
+ * Defaults to {@link #DEFAULT_PLACEMENT}.
+ */
+ private Placement placement;
+
+ private Relative(
+ int xExtent, int yExtent,
+ boolean fillX, boolean fillY,
+ Alignment xAlignment, Alignment yAlignment,
+ int priority,
+ Placement placement
+ ) {
+ super(xExtent, yExtent, fillX, fillY, xAlignment, yAlignment, priority);
+
+ this.placement = placement;
+ }
+
+ public Placement getPlacement() {
+ return this.placement;
+ }
+
+ /**
+ * Sets {@link #placement} to the passed value.
+ *
+ * The default value is {@link #DEFAULT_PLACEMENT}.
+ *
+ * @see #rowEnd()
+ * @see #newRow()
+ * @see #columnEnd()
+ * @see #newColumn()
+ */
+ public Relative placement(Placement placement) {
+ this.placement = placement;
+ return this;
+ }
+
+ /**
+ * Sets {@link #placement} to {@link Placement#ROW_END ROW_END}.
+ *
+ *
The default value is {@link #DEFAULT_PLACEMENT}.
+ *
+ * @see #placement(Placement)
+ */
+ public Relative rowEnd() {
+ return this.placement(Placement.ROW_END);
+ }
+
+ /**
+ * Sets {@link #placement} to {@link Placement#NEW_ROW NEW_ROW}.
+ *
+ *
The default value is {@link #DEFAULT_PLACEMENT}.
+ *
+ * @see #placement(Placement)
+ */
+ public Relative newRow() {
+ return this.placement(Placement.NEW_ROW);
+ }
+
+ /**
+ * Sets {@link #placement} to {@link Placement#COLUMN_END COLUMN_END}.
+ *
+ *
The default value is {@link #DEFAULT_PLACEMENT}.
+ *
+ * @see #placement(Placement)
+ */
+ public Relative columnEnd() {
+ return this.placement(Placement.COLUMN_END);
+ }
+
+ /**
+ * Sets {@link #placement} to {@link Placement#NEW_COLUMN NEW_COLUMN}.
+ *
+ *
The default value is {@link #DEFAULT_PLACEMENT}.
+ *
+ * @see #placement(Placement)
+ */
+ public Relative newColumn() {
+ return this.placement(Placement.NEW_COLUMN);
+ }
+
+ @Override
+ public Relative copy() {
+ return new Relative(
+ this.xExtent, this.yExtent,
+ this.fillX, this.fillY,
+ this.xAlignment, this.yAlignment,
+ this.priority,
+ this.placement
+ );
+ }
+
+ /**
+ * Creates {@link Absolute Absolute} constraints at ({@value Absolute#DEFAULT_X}, {@value Absolute#DEFAULT_Y})
+ * with these constraints' values ({@link #placement} is ignored).
+ *
+ * @see #toAbsolute(int, int)
+ * @see Absolute#toRelative() Absolute.toRelative()
+ */
+ public Absolute toAbsolute() {
+ return new Absolute(
+ Absolute.DEFAULT_X, Absolute.DEFAULT_Y,
+ this.xExtent, this.yExtent,
+ this.fillX, this.fillY,
+ this.xAlignment, this.yAlignment,
+ this.priority
+ );
+ }
+
+ /**
+ * Creates {@link Absolute Absolute} constraints with these constraints' values at the passed
+ * {@code x} and {@code y} coordinates ({@link #placement} is ignored).
+ *
+ * @see #toAbsolute()
+ * @see Absolute#toRelative() Absolute.toRelative()
+ */
+ public Absolute toAbsolute(int x, int y) {
+ return this.toAbsolute().pos(x, y);
+ }
+
+ @Override
+ Relative getSelf() {
+ return this;
+ }
+
+ /**
+ * Represents the placement of {@link Relative} constraints.
+ *
+ *
All placements will put a {@link Component} at ({@value Absolute#DEFAULT_X}, {@value Absolute#DEFAULT_Y})
+ * if the component is the first to be {@linkplain Container#add(Component, Object) added}
+ * to its {@link Container}.
+ */
+ public enum Placement {
+ /**
+ * At the end of the last row.
+ */
+ ROW_END,
+ /**
+ * In a new row after the current last row, with x equal to the current minimum x.
+ */
+ NEW_ROW,
+ /**
+ * At the bottom of the last column.
+ */
+ COLUMN_END,
+ /**
+ * In a new column after the current last column, with y equal to the current minimum y.
+ */
+ NEW_COLUMN
+ }
+ }
+
+ /**
+ * {@link FlexGridConstraints} with absolute {@link #x} and {@link #y} coordinates.
+ *
+ *
Absolute components will overlap with any components occupying the same grid cells, whether they're at the
+ * same coordinates or they {@linkplain #extent(int, int) extend} into each other's cells.
+ */
+ public static final class Absolute extends FlexGridConstraints {
+ public static final int DEFAULT_X = 0;
+ public static final int DEFAULT_Y = 0;
+
+ public static Absolute of() {
+ return new Absolute(
+ DEFAULT_X, DEFAULT_Y,
+ DEFAULT_X_EXTENT, DEFAULT_Y_EXTENT,
+ DEFAULT_FILL_X, DEFAULT_FILL_Y,
+ DEFAULT_X_ALIGNMENT, DEFAULT_Y_ALIGNMENT,
+ DEFAULT_PRIORITY
+ );
+ }
+
+ /**
+ * Defaults to {@value #DEFAULT_X}.
+ */
+ int x;
+
+ /**
+ * Defaults to {@value #DEFAULT_Y}.
+ */
+ int y;
+
+ private Absolute(
+ int x, int y,
+ int xExtent, int yExtent,
+ boolean fillX, boolean fillY,
+ Alignment xAlignment, Alignment yAlignment,
+ int priority
+ ) {
+ super(xExtent, yExtent, fillX, fillY, xAlignment, yAlignment, priority);
+
+ this.x = x;
+ this.y = y;
+ }
+
+ public int getX() {
+ return this.x;
+ }
+
+ public int getY() {
+ return this.y;
+ }
+
+ /**
+ * Sets {@link #x} to the passed value.
+ *
+ * The default value is {@value #DEFAULT_X}.
+ *
+ * @see #advanceRows(int)
+ * @see #nextRow()
+ * @see #y(int)
+ * @see #pos(int, int)
+ */
+ public Absolute x(int x) {
+ this.x = x;
+ return this;
+ }
+
+ /**
+ * Adds the passed {@code count} to {@link #x}.
+ *
+ *
The default value is {@value #DEFAULT_X}.
+ *
+ * @see #nextColumn()
+ * @see #y(int)
+ * @see #advanceRows(int)
+ */
+ public Absolute advanceColumns(int count) {
+ this.x += count;
+ return this.getSelf();
+ }
+
+ /**
+ * Adds {@code 1} to {@link #x}.
+ *
+ *
The default value is {@value #DEFAULT_X}.
+ *
+ * @see #advanceColumns(int)
+ * @see #y(int)
+ * @see #nextRow()
+ */
+ public Absolute nextColumn() {
+ return this.advanceColumns(1);
+ }
+
+ /**
+ * Sets {@link #y} to the passed value.
+ *
+ *
The default value is {@value #DEFAULT_Y}.
+ *
+ * @see #advanceColumns(int)
+ * @see #nextColumn()
+ * @see #x(int)
+ * @see #pos(int, int)
+ */
+ public Absolute y(int y) {
+ this.y = y;
+ return this;
+ }
+
+ /**
+ * Sets {@link #x} to {@code 0} and adds the passed {@code count} to {@link #y}.
+ *
+ *
The default values are {@value #DEFAULT_X} and {@value #DEFAULT_Y}, respectively.
+ *
+ * @see #nextRow()
+ * @see #x(int)
+ * @see #advanceColumns(int)
+ */
+ public Absolute advanceRows(int count) {
+ this.x = 0;
+ this.y += count;
+ return this.getSelf();
+ }
+
+ /**
+ * Sets {@link #x} to {@code 0} and adds {@code 1} to {@link #y}.
+ *
+ *
The default values are {@value #DEFAULT_X} and {@value #DEFAULT_Y}, respectively.
+ *
+ * @see #advanceRows(int)
+ * @see #x(int)
+ * @see #nextColumn()
+ */
+ public Absolute nextRow() {
+ return this.advanceRows(1);
+ }
+
+ /**
+ * Sets {@link #x} and {@link #y} to the passed values.
+ *
+ *
The default values are {@value #DEFAULT_X} and {@value #DEFAULT_Y}, respectively.
+ *
+ * @see #x(int)
+ * @see #y(int)
+ */
+ public Absolute pos(int x, int y) {
+ return this.x(x).y(y);
+ }
+
+ @Override
+ public Absolute copy() {
+ return new Absolute(
+ this.x, this.y,
+ this.xExtent, this.yExtent,
+ this.fillX, this.fillY,
+ this.xAlignment, this.yAlignment,
+ this.priority
+ );
+ }
+
+ /**
+ * Creates {@link Relative Relative} constraints with these constraints' values and
+ * {@link Relative#DEFAULT_PLACEMENT DEFAULT_PLACEMENT} ({@link #x} and {@link #y} ar ignored).
+ *
+ * @see #toRelative(Relative.Placement)
+ * @see Relative#toAbsolute() Relative.toAbsolute()
+ * @see Relative#toAbsolute(int, int) Relative.toAbsolute(int, int)
+ */
+ public Relative toRelative() {
+ return new Relative(
+ this.xExtent, this.yExtent,
+ this.fillX, this.fillY,
+ this.xAlignment, this.yAlignment,
+ this.priority,
+ Relative.DEFAULT_PLACEMENT
+ );
+ }
+
+ /**
+ * Creates {@link Relative Relative} constraints with these constraints' values and
+ * the passed {@code placement} ({@link #x} and {@link #y} ar ignored).
+ *
+ * @see #toRelative()
+ * @see Relative#toAbsolute() Relative.toAbsolute()
+ * @see Relative#toAbsolute(int, int) Relative.toAbsolute(int, int)
+ */
+ public Relative toRelative(Relative.Placement placement) {
+ return this.toRelative().placement(placement);
+ }
+
+ @Override
+ Absolute getSelf() {
+ return this;
+ }
+ }
+}
diff --git a/enigma/src/main/java/org/quiltmc/enigma/api/source/DecompiledClassSource.java b/enigma/src/main/java/org/quiltmc/enigma/api/source/DecompiledClassSource.java
index e6a49631f..fbbf1b574 100644
--- a/enigma/src/main/java/org/quiltmc/enigma/api/source/DecompiledClassSource.java
+++ b/enigma/src/main/java/org/quiltmc/enigma/api/source/DecompiledClassSource.java
@@ -11,6 +11,7 @@
import org.quiltmc.enigma.api.translation.representation.entry.Entry;
import org.quiltmc.enigma.api.translation.representation.entry.LocalVariableDefEntry;
import org.quiltmc.enigma.impl.translation.LocalNameGenerator;
+import org.quiltmc.enigma.util.LineIndexer;
import java.util.Collection;
import java.util.Iterator;
@@ -27,11 +28,14 @@ public class DecompiledClassSource {
private final TokenStore highlightedTokens;
+ private final LineIndexer lineIndexer;
+
private DecompiledClassSource(ClassEntry classEntry, SourceIndex obfuscatedIndex, SourceIndex remappedIndex, TokenStore highlightedTokens) {
this.classEntry = classEntry;
this.obfuscatedIndex = obfuscatedIndex;
this.remappedIndex = remappedIndex;
this.highlightedTokens = highlightedTokens;
+ this.lineIndexer = new LineIndexer(remappedIndex.getSource());
}
public DecompiledClassSource(ClassEntry classEntry, SourceIndex index) {
@@ -128,6 +132,10 @@ private static int getOffset(SourceIndex fromIndex, SourceIndex toIndex, int fro
return fromOffset + relativeOffset;
}
+ public LineIndexer getLineIndexer() {
+ return this.lineIndexer;
+ }
+
@Override
public String toString() {
return this.remappedIndex.getSource();
diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/Arguments.java b/enigma/src/main/java/org/quiltmc/enigma/util/Arguments.java
new file mode 100644
index 000000000..e05a96bf0
--- /dev/null
+++ b/enigma/src/main/java/org/quiltmc/enigma/util/Arguments.java
@@ -0,0 +1,56 @@
+package org.quiltmc.enigma.util;
+
+@SuppressWarnings("unused")
+public final class Arguments {
+ private Arguments() {
+ throw new UnsupportedOperationException();
+ }
+
+ public static int requireNonNegative(int argument, String name) {
+ if (argument < 0) {
+ throw new IllegalArgumentException("%s (%s) must not be negative!".formatted(name, argument));
+ } else {
+ return argument;
+ }
+ }
+
+ public static int requireNonPositive(int argument, String name) {
+ if (argument > 0) {
+ throw new IllegalArgumentException("%s (%s) must not be positive!".formatted(name, argument));
+ } else {
+ return argument;
+ }
+ }
+
+ public static int requirePositive(int argument, String name) {
+ if (argument <= 0) {
+ throw new IllegalArgumentException("%s (%s) must be positive!".formatted(name, argument));
+ } else {
+ return argument;
+ }
+ }
+
+ public static int requireNegative(int argument, String name) {
+ if (argument >= 0) {
+ throw new IllegalArgumentException("%s (%s) must be negative!".formatted(name, argument));
+ } else {
+ return argument;
+ }
+ }
+
+ /**
+ * @return the passed {@code greater} if it is not less than the passed {@code lesser}
+ *
+ * @throws IllegalArgumentException if the passed {@code greater} is less than the passed {@code lesser}
+ */
+ public static int requireNotLess(int greater, String greaterName, int lesser, String lesserName) {
+ if (greater < lesser) {
+ throw new IllegalArgumentException(
+ "%s (%s) must not be less than %s (%s)!"
+ .formatted(greaterName, greater, lesserName, lesser)
+ );
+ } else {
+ return greater;
+ }
+ }
+}
diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java b/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java
index d68d1b15b..68a0abaf3 100644
--- a/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java
+++ b/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java
@@ -16,6 +16,7 @@
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
+import java.util.Objects;
import java.util.Properties;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
@@ -196,4 +197,16 @@ public static float clamp(double value, float min, float max) {
public static double clamp(double value, double min, double max) {
return Math.min(max, Math.max(value, min));
}
+
+ public static int ceilDiv(int dividend, int divisor) {
+ return -Math.floorDiv(-dividend, divisor);
+ }
+
+ public static long ceilDiv(long dividend, long divisor) {
+ return -Math.floorDiv(-dividend, divisor);
+ }
+
+ public static T requireNonNull(T object, String name) {
+ return Objects.requireNonNull(object, () -> name + " must not be null!");
+ }
}
diff --git a/enigma/src/main/resources/lang/en_us.json b/enigma/src/main/resources/lang/en_us.json
index 7fce5ddaa..a4e8fc731 100644
--- a/enigma/src/main/resources/lang/en_us.json
+++ b/enigma/src/main/resources/lang/en_us.json
@@ -73,6 +73,12 @@
"menu.view.themes.metal": "Metal",
"menu.view.themes.system": "System",
"menu.view.themes.none": "None (JVM Default)",
+ "menu.view.selection_highlight": "Selection Highlight",
+ "menu.view.selection_highlight.blinks": "Blink count (%s)",
+ "menu.view.selection_highlight.blink_delay": "Blink delay (%sms)...",
+ "menu.view.selection_highlight.blink_delay.dialog_title": "Blink Delay",
+ "menu.view.selection_highlight.blink_delay.dialog_explanation":
+ "The milliseconds to blink on and off highlighting that indicates an entry that has been navigated to.",
"menu.view.languages": "Languages",
"menu.view.scale": "Scale",
"menu.view.scale.custom": "Custom...",
@@ -85,6 +91,15 @@
"menu.view.entry_tooltips": "Entry Tooltips",
"menu.view.entry_tooltips.enable": "Enable tooltips",
"menu.view.entry_tooltips.interactable": "Allow tooltip interaction",
+ "menu.view.entry_markers": "Entry Markers",
+ "menu.view.entry_markers.tooltip": "Marker tooltips",
+ "menu.view.entry_markers.max_markers_per_line": "Max markers per line (%s)",
+ "menu.view.entry_markers.mark": "Mark entries",
+ "menu.view.entry_markers.mark.only_declarations": "Only declarations",
+ "menu.view.entry_markers.mark.obfuscated": "Obfuscated",
+ "menu.view.entry_markers.mark.fallback": "Fallback",
+ "menu.view.entry_markers.mark.proposed": "Proposed",
+ "menu.view.entry_markers.mark.deobfuscated": "Deobfuscated",
"menu.view.font": "Fonts...",
"menu.view.change.title": "Changes",
"menu.view.change.summary": "Changes will be applied after the next restart.",
@@ -156,6 +171,9 @@
"editor.quick_find.persistent": "Persistent",
"editor.tooltip.label.from": "from",
"editor.tooltip.label.inherited_from": "inherited from",
+ "editor.tooltip.label.no_package": "no package",
+ "editor.tooltip.label.synthetic": "synthetic",
+ "editor.tooltip.label.anonymous": "anonymous",
"editor.tooltip.message.no_source": "No source available",
"editor.snippet.message.no_declaration_found": "Unable to locate declaration",
@@ -281,6 +299,8 @@
"prompt.create_server.confirm": "Start",
"prompt.password": "Password:",
+ "prompt.input.int_range": "Enter a whole number between %s and %s (inclusive):",
+
"disconnect.disconnected": "Disconnected",
"disconnect.server_closed": "Server closed",
"disconnect.wrong_jar": "Jar checksums don't match (you have the wrong jar)!",
diff --git a/enigma/src/test/java/org/quiltmc/enigma/input/tooltip/ConvergentInheritance.java b/enigma/src/test/java/org/quiltmc/enigma/input/tooltip/ConvergentInheritance.java
new file mode 100644
index 000000000..8ce9c6217
--- /dev/null
+++ b/enigma/src/test/java/org/quiltmc/enigma/input/tooltip/ConvergentInheritance.java
@@ -0,0 +1,20 @@
+package org.quiltmc.enigma.input.tooltip;
+
+public class ConvergentInheritance {
+ abstract static class Named {
+ public abstract void setName(String name);
+ }
+
+ interface Nameable {
+ void setName(String name);
+ }
+
+ static class Implementer extends Named implements Nameable {
+ private String name;
+
+ @Override
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+}