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: + *

+ */ + 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> 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 { + 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 ≤ mineach 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; + } + } +}