From 3edfe1250c806ef68145a016a74973ae3fbadf0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:43:43 +0000 Subject: [PATCH 01/26] Add layout and camera for a crafting plan graph view Also extracts the duplicated crafting plan header drawing into a shared helper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C5iUzweRYoCkKB5bvJdH8Y --- .../component/CraftingPlanGraphCamera.java | 165 +++++++++++ .../component/CraftingPlanGraphLayout.java | 279 ++++++++++++++++++ .../container/component/GuiCraftingPlan.java | 88 ++---- .../component/GuiCraftingPlanFlat.java | 63 +--- .../component/GuiCraftingPlanHeader.java | 104 +++++++ .../TestCraftingPlanGraphCamera.java | 156 ++++++++++ .../TestCraftingPlanGraphLayout.java | 245 +++++++++++++++ 7 files changed, 987 insertions(+), 113 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedterminals/client/gui/container/component/CraftingPlanGraphCamera.java create mode 100644 src/main/java/org/cyclops/integratedterminals/client/gui/container/component/CraftingPlanGraphLayout.java create mode 100644 src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanHeader.java create mode 100644 src/test/java/org/cyclops/integratedterminals/client/gui/container/component/TestCraftingPlanGraphCamera.java create mode 100644 src/test/java/org/cyclops/integratedterminals/client/gui/container/component/TestCraftingPlanGraphLayout.java diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/CraftingPlanGraphCamera.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/CraftingPlanGraphCamera.java new file mode 100644 index 000000000..8825a9975 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/CraftingPlanGraphCamera.java @@ -0,0 +1,165 @@ +package org.cyclops.integratedterminals.client.gui.container.component; + +/** + * The pan and zoom state of a graph inside a viewport. + * + * The camera stores the viewport-relative position of the graph origin, + * so the view stays steady when the graph around the origin changes. + * + * This class is independent of Minecraft, so it can be unit-tested and ported easily. + * + * @author rubensworks + */ +public class CraftingPlanGraphCamera { + + public static final float[] ZOOM_LEVELS = {0.5F, 0.75F, 1F, 1.5F, 2F}; + public static final int DEFAULT_ZOOM_INDEX = 2; + public static final int MARGIN = 8; + + private double originX; + private double originY; + private int zoomIndex = DEFAULT_ZOOM_INDEX; + + public double getOriginX() { + return originX; + } + + public double getOriginY() { + return originY; + } + + public int getZoomIndex() { + return zoomIndex; + } + + public float getZoom() { + return ZOOM_LEVELS[zoomIndex]; + } + + public boolean canZoomIn() { + return zoomIndex < ZOOM_LEVELS.length - 1; + } + + public boolean canZoomOut() { + return zoomIndex > 0; + } + + public double toGraphX(double viewX) { + return (viewX - originX) / getZoom(); + } + + public double toGraphY(double viewY) { + return (viewY - originY) / getZoom(); + } + + public double toViewX(double graphX) { + return originX + graphX * getZoom(); + } + + public double toViewY(double graphY) { + return originY + graphY * getZoom(); + } + + public void pan(double dx, double dy) { + this.originX += dx; + this.originY += dy; + } + + /** + * Change the zoom level while keeping the graph point under the given view position in place. + * @param viewX The anchor X position in the viewport. + * @param viewY The anchor Y position in the viewport. + * @param direction Positive to zoom in, negative to zoom out. + * @return If the zoom level changed. + */ + public boolean zoomAt(double viewX, double viewY, int direction) { + int newZoomIndex = Math.max(0, Math.min(ZOOM_LEVELS.length - 1, zoomIndex + Integer.signum(direction))); + if (newZoomIndex == zoomIndex) { + return false; + } + double graphX = toGraphX(viewX); + double graphY = toGraphY(viewY); + this.zoomIndex = newZoomIndex; + this.originX = viewX - graphX * getZoom(); + this.originY = viewY - graphY * getZoom(); + return true; + } + + /** + * Pick the largest zoom level at which the whole graph fits, and center it. + * If it does not even fit at the smallest level, the root is aligned to the left center. + * @param bounds The graph bounds. + * @param viewWidth The viewport width. + * @param viewHeight The viewport height. + */ + public void fit(CraftingPlanGraphLayout.Bounds bounds, int viewWidth, int viewHeight) { + if (fitZoom(bounds, viewWidth, viewHeight, ZOOM_LEVELS.length - 1)) { + this.originX = (viewWidth - bounds.getWidth() * getZoom()) / 2 - bounds.getMinX() * getZoom(); + } else { + this.originX = MARGIN - bounds.getMinX() * getZoom(); + } + centerVertically(bounds, viewHeight); + } + + /** + * Like {@link #fit(CraftingPlanGraphLayout.Bounds, int, int)}, but never zooms in beyond the default level, + * and always keeps the root at the left. + * @param bounds The graph bounds. + * @param viewWidth The viewport width. + * @param viewHeight The viewport height. + */ + public void reset(CraftingPlanGraphLayout.Bounds bounds, int viewWidth, int viewHeight) { + fitZoom(bounds, viewWidth, viewHeight, DEFAULT_ZOOM_INDEX); + this.originX = MARGIN - bounds.getMinX() * getZoom(); + centerVertically(bounds, viewHeight); + } + + protected boolean fitZoom(CraftingPlanGraphLayout.Bounds bounds, int viewWidth, int viewHeight, int maxZoomIndex) { + for (int i = maxZoomIndex; i >= 0; i--) { + float zoom = ZOOM_LEVELS[i]; + if (bounds.getWidth() * zoom + 2 * MARGIN <= viewWidth && bounds.getHeight() * zoom + 2 * MARGIN <= viewHeight) { + this.zoomIndex = i; + return true; + } + } + this.zoomIndex = 0; + return false; + } + + protected void centerVertically(CraftingPlanGraphLayout.Bounds bounds, int viewHeight) { + float zoom = getZoom(); + if (bounds.getHeight() * zoom + 2 * MARGIN <= viewHeight) { + this.originY = (viewHeight - bounds.getHeight() * zoom) / 2 - bounds.getMinY() * zoom; + } else { + // Center the root, which is at the origin + this.originY = viewHeight / 2D - CraftingPlanGraphLayout.NODE_SIZE / 2D * zoom; + } + } + + /** + * Restrict panning so that at least one node size of the graph stays visible. + * @param bounds The graph bounds. + * @param viewWidth The viewport width. + * @param viewHeight The viewport height. + */ + public void clamp(CraftingPlanGraphLayout.Bounds bounds, int viewWidth, int viewHeight) { + float zoom = getZoom(); + double visible = CraftingPlanGraphLayout.NODE_SIZE * zoom; + this.originX = clamp(originX, visible - bounds.getMaxX() * zoom, viewWidth - visible - bounds.getMinX() * zoom); + this.originY = clamp(originY, visible - bounds.getMaxY() * zoom, viewHeight - visible - bounds.getMinY() * zoom); + } + + protected static double clamp(double value, double min, double max) { + if (min > max) { + return (min + max) / 2; + } + return Math.max(min, Math.min(max, value)); + } + + public void copyFrom(CraftingPlanGraphCamera other) { + this.originX = other.originX; + this.originY = other.originY; + this.zoomIndex = other.zoomIndex; + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/CraftingPlanGraphLayout.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/CraftingPlanGraphLayout.java new file mode 100644 index 000000000..6c3e682e3 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/CraftingPlanGraphLayout.java @@ -0,0 +1,279 @@ +package org.cyclops.integratedterminals.client.gui.container.component; + +import com.google.common.collect.Lists; + +import java.util.Collections; +import java.util.List; + +/** + * Places the nodes of a tree in columns from left to right, with the root at the graph origin. + * + * This class is independent of Minecraft, so it can be unit-tested and ported easily. + * + * @param The node type. + * @author rubensworks + */ +public class CraftingPlanGraphLayout> { + + public static final int NODE_SIZE = 26; + public static final int COLUMN_GAP = 22; + public static final int ROW_GAP = 6; + public static final int COLUMN_PITCH = NODE_SIZE + COLUMN_GAP; + public static final int ROW_PITCH = NODE_SIZE + ROW_GAP; + + private final List> placements; + private final List> segments; + private final Bounds bounds; + + private CraftingPlanGraphLayout(List> placements, List> segments, Bounds bounds) { + this.placements = placements; + this.segments = segments; + this.bounds = bounds; + } + + /** + * @param root The root node. + * @param The node type. + * @return The layout of all nodes that are not collapsed into one of their ancestors. + */ + public static > CraftingPlanGraphLayout create(N root) { + List> placements = Lists.newArrayList(); + List> segments = Lists.newArrayList(); + Placement rootPlacement = place(root, 0, new int[]{0}, placements, segments); + + // Shift everything vertically so that the root ends up at the origin + int shiftY = -rootPlacement.getY(); + int minX = 0; + int minY = Integer.MAX_VALUE; + int maxX = 0; + int maxY = Integer.MIN_VALUE; + for (Placement placement : placements) { + placement.y += shiftY; + minY = Math.min(minY, placement.y); + maxX = Math.max(maxX, placement.x + NODE_SIZE); + maxY = Math.max(maxY, placement.y + NODE_SIZE); + } + for (Segment segment : segments) { + segment.y1 += shiftY; + segment.y2 += shiftY; + } + return new CraftingPlanGraphLayout<>(Collections.unmodifiableList(placements), + Collections.unmodifiableList(segments), new Bounds(minX, minY, maxX, maxY)); + } + + private static > Placement place(N node, int depth, int[] nextRow, + List> placements, List> segments) { + Placement placement = new Placement<>(node, depth * COLUMN_PITCH); + placements.add(placement); + + List> childPlacements = Lists.newArrayList(); + int hidden = 0; + for (N child : node.getChildren()) { + if (child.isVisible()) { + childPlacements.add(place(child, depth + 1, nextRow, placements, segments)); + } else { + hidden += countNodes(child); + } + } + placement.hiddenDescendants = hidden; + + if (childPlacements.isEmpty()) { + // Leaves (and collapsed nodes) take the next free row + placement.y = nextRow[0]++ * ROW_PITCH; + } else { + int firstY = childPlacements.get(0).y; + int lastY = childPlacements.get(childPlacements.size() - 1).y; + placement.y = (firstY + lastY) / 2; + + // Elbow connectors: a stub out of the parent, a vertical trunk, and a stub into each child + int center = NODE_SIZE / 2; + int parentRight = placement.x + NODE_SIZE; + int trunkX = parentRight + COLUMN_GAP / 2; + segments.add(new Segment<>(parentRight, placement.y + center, trunkX, placement.y + center, null)); + if (firstY != lastY) { + segments.add(new Segment<>(trunkX, firstY + center, trunkX, lastY + center, null)); + } + for (Placement childPlacement : childPlacements) { + segments.add(new Segment<>(trunkX, childPlacement.y + center, childPlacement.x - 1, childPlacement.y + center, + childPlacement.getNode())); + } + } + return placement; + } + + protected static > int countNodes(N node) { + int count = 1; + for (N child : node.getChildren()) { + count += countNodes(child); + } + return count; + } + + /** + * @return All visible nodes, in depth-first order, starting with the root. + */ + public List> getPlacements() { + return placements; + } + + /** + * @return The line segments that connect the nodes. + */ + public List> getSegments() { + return segments; + } + + public Bounds getBounds() { + return bounds; + } + + /** + * @param graphX An X position in graph space. + * @param graphY A Y position in graph space. + * @return The node at the given position, or null. + */ + public Placement getPlacementAt(double graphX, double graphY) { + for (Placement placement : placements) { + if (graphX >= placement.getX() && graphX < placement.getX() + NODE_SIZE + && graphY >= placement.getY() && graphY < placement.getY() + NODE_SIZE) { + return placement; + } + } + return null; + } + + /** + * A node in a tree that can be laid out. + * @param The node type. + */ + public static interface INode> { + /** + * @return All children, including invisible ones. + */ + public List getChildren(); + + /** + * @return If this node is shown, otherwise it is collapsed into its parent. + */ + public boolean isVisible(); + } + + /** + * The position of a node in graph space. + * @param The node type. + */ + public static class Placement { + private final N node; + private final int x; + private int y; + private int hiddenDescendants; + + public Placement(N node, int x) { + this.node = node; + this.x = x; + } + + public N getNode() { + return node; + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + /** + * @return The number of descendants that are hidden because they are collapsed into this node. + */ + public int getHiddenDescendants() { + return hiddenDescendants; + } + } + + /** + * An axis-aligned line in graph space, from (x1, y1) to (x2, y2), both inclusive. + */ + public static class Segment { + private final int x1; + private int y1; + private final int x2; + private int y2; + private final N target; + + public Segment(int x1, int y1, int x2, int y2, N target) { + this.x1 = x1; + this.y1 = y1; + this.x2 = x2; + this.y2 = y2; + this.target = target; + } + + public int getX1() { + return x1; + } + + public int getY1() { + return y1; + } + + public int getX2() { + return x2; + } + + public int getY2() { + return y2; + } + + /** + * @return The child node this segment leads into, or null if it is shared by all children. + */ + public N getTarget() { + return target; + } + } + + /** + * A rectangle in graph space, with exclusive maximum. + */ + public static class Bounds { + private final int minX; + private final int minY; + private final int maxX; + private final int maxY; + + public Bounds(int minX, int minY, int maxX, int maxY) { + this.minX = minX; + this.minY = minY; + this.maxX = maxX; + this.maxY = maxY; + } + + public int getMinX() { + return minX; + } + + public int getMinY() { + return minY; + } + + public int getMaxX() { + return maxX; + } + + public int getMaxY() { + return maxY; + } + + public int getWidth() { + return maxX - minX; + } + + public int getHeight() { + return maxY - minY; + } + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlan.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlan.java index aeaf3b565..2ec37f276 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlan.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlan.java @@ -59,6 +59,10 @@ public class GuiCraftingPlan extends AbstractWidget { public static final int ELEMENT_WIDTH = 221; + /** + * The width of the list views, including their scrollbar. + */ + public static final int VIEW_WIDTH = 238; private static final int ELEMENT_HEIGHT = 16; private static final int ELEMENT_HEIGHT_TOTAL = 18; @@ -75,14 +79,7 @@ public class GuiCraftingPlan extends AbstractWidget { private final List visibleElements; private final boolean valid; private final WidgetScrollBar scrollBar; - private final String label; - private final long tickDuration; - private final long estimatedTickDuration; - private final boolean estimatedTickDurationRemaining; - private final boolean showEstimatedTickDuration; - private final int channel; - @Nullable - private final String initiatorName; + private final GuiCraftingPlanHeader header; private int firstRow; @@ -96,16 +93,9 @@ public GuiCraftingPlan(AbstractContainerScreen parentGui, ITerminalCraftingPlan< this.valid = craftingPlan.getStatus().isValid(); this.scrollBar = new WidgetScrollBar(guiLeft + x + 227, guiTop + y + 0, 178, Component.translatable("gui.cyclopscore.scrollbar"), this::setFirstRow, visibleRows); this.scrollBar.setTotalRows(visibleElements.size()); - this.label = L10NHelpers.localize(craftingPlan.getUnlocalizedLabel()); - this.tickDuration = craftingPlan.getTickDuration(); - // Jobs that have not started yet can only show a total estimation, running jobs show what is left of it - this.estimatedTickDurationRemaining = craftingPlan.getStatus() != TerminalCraftingJobStatus.UNSTARTED; - this.estimatedTickDuration = this.estimatedTickDurationRemaining - ? craftingPlan.getEstimatedTickDurationRemaining() : craftingPlan.getEstimatedTickDurationTotal(); - // Jobs that can not be crafted have nothing to estimate, other jobs show a placeholder until they are measured - this.showEstimatedTickDuration = craftingPlan.getStatus().isValid(); - this.channel = craftingPlan.getChannel(); - this.initiatorName = craftingPlan.getInitiatorName(); + this.header = new GuiCraftingPlanHeader(craftingPlan.getUnlocalizedLabel(), craftingPlan.getStatus(), + craftingPlan.getTickDuration(), craftingPlan.getEstimatedTickDurationTotal(), + craftingPlan.getEstimatedTickDurationRemaining(), craftingPlan.getChannel(), craftingPlan.getInitiatorName()); } public void inheritVisualizationState(GuiCraftingPlan guiCraftingPlan) { @@ -320,33 +310,7 @@ public static int getProgress(ITerminalCraftingPlanFlat craftingPlan) { } public void drawGuiContainerBackgroundLayer(GuiGraphics guiGraphics, float partialTicks, int mouseX, int mouseY) { - Font fontRenderer = Minecraft.getInstance().font; - - // Draw plan label - guiGraphics.drawCenteredString(Minecraft.getInstance().font, this.label, guiLeft + getX() + ELEMENT_WIDTH / 2 + 8, guiTop + getY() - 13, 16777215); - - // Draw duration - if (tickDuration >= 0) { - String durationString = getDurationString(tickDuration); - RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, durationString, guiLeft + getX() + 200, guiTop + getY() - 14, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); - } - - // Draw channel - if (channel != -1) { - String channelString = L10NHelpers.localize("gui.integratedterminals.terminal_crafting_job.craftingplan.crafting_channel", channel); - RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, channelString, guiLeft + getX() + 200, guiTop + getY() - 8, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); - } - - // Draw initiator - if (initiatorName != null) { - String initiatorString = L10NHelpers.localize("gui.integratedterminals.terminal_crafting_job.craftingplan.owner", initiatorName); - RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, initiatorString, guiLeft + getX() - 4, guiTop + getY() - 8, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); - } - - // Draw estimated duration - if (showEstimatedTickDuration) { - RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, getEstimatedDurationString(), guiLeft + getX() - 4, guiTop + getY() - 14, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); - } + header.drawBackground(guiGraphics, guiLeft + getX(), guiTop + getY(), VIEW_WIDTH); drawGuiContainerLayer(guiGraphics, guiLeft, guiTop, ContainerScreenTerminalStorage.DrawLayer.BACKGROUND, partialTicks, mouseX, mouseY); scrollBar.render(guiGraphics, mouseX, mouseY, partialTicks); @@ -354,16 +318,7 @@ public void drawGuiContainerBackgroundLayer(GuiGraphics guiGraphics, float parti public void drawGuiContainerForegroundLayer(GuiGraphics guiGraphics, int mouseX, int mouseY) { drawGuiContainerLayer(guiGraphics, 0, 0, ContainerScreenTerminalStorage.DrawLayer.FOREGROUND, 0, mouseX, mouseY); - if (showEstimatedTickDuration) { - drawUnknownDurationTooltip(this.parentGui, guiGraphics, getEstimatedDurationString(), estimatedTickDuration, - getX() - 4, getY() - 14, mouseX, mouseY); - } - } - - protected String getEstimatedDurationString() { - return getDurationString(estimatedTickDurationRemaining - ? "gui.integratedterminals.terminal_crafting_job.craftingplan.duration.remaining" - : "gui.integratedterminals.terminal_crafting_job.craftingplan.duration.estimate", estimatedTickDuration); + header.drawForeground(this.parentGui, guiGraphics, getX(), getY(), mouseX, mouseY); } /** @@ -442,6 +397,7 @@ protected static void addElements(@Nullable Element parent, int indent, ITermina .collect(Collectors.toList()), 0, valid ? craftingPlan.getCraftingQuantity() : 0, + valid ? craftingPlan.getCraftingQuantityTotal() : 0, valid ? 0 : craftingPlan.getCraftingQuantity(), craftingPlan.getStatus().getColor(), craftingPlan.getStatus()); @@ -485,13 +441,14 @@ protected void updateWidgetNarration(NarrationElementOutput p_259858_) { } - public static class Element { + public static class Element implements CraftingPlanGraphLayout.INode { private final int id; private final int indent; private final List>> outputs; private final long storageQuantity; private final long craftQuantity; + private final long craftQuantityTotal; private final long missingQuantity; private final int color; private final List children; @@ -501,11 +458,17 @@ public static class Element { public Element(int id, int indent, List> outputs, long storageQuantity, long craftQuantity, long missingQuantity, int color, TerminalCraftingJobStatus status) { + this(id, indent, outputs, storageQuantity, craftQuantity, craftQuantity, missingQuantity, color, status); + } + + public Element(int id, int indent, List> outputs, long storageQuantity, long craftQuantity, + long craftQuantityTotal, long missingQuantity, int color, TerminalCraftingJobStatus status) { this.id = id; this.indent = indent; this.outputs = (List) outputs; this.storageQuantity = storageQuantity; this.craftQuantity = craftQuantity; + this.craftQuantityTotal = craftQuantityTotal; this.missingQuantity = missingQuantity; this.color = color; this.status = status; @@ -534,6 +497,13 @@ public long getCraftQuantity() { return craftQuantity; } + /** + * @return The number of instances this job crafts in total, including the ones that were crafted already. + */ + public long getCraftQuantityTotal() { + return craftQuantityTotal; + } + public long getMissingQuantity() { return missingQuantity; } @@ -547,10 +517,16 @@ public Element addChild(Element element) { return element; } + @Override public List getChildren() { return children; } + @Override + public boolean isVisible() { + return isEnabled(); + } + public boolean isEnabled() { return enabled; } diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanFlat.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanFlat.java index efbb8ef82..8da0399ba 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanFlat.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanFlat.java @@ -23,12 +23,10 @@ import org.cyclops.cyclopscore.helper.RenderHelpers; import org.cyclops.integratedterminals.Capabilities; import org.cyclops.integratedterminals.api.terminalstorage.crafting.ITerminalCraftingPlanFlat; -import org.cyclops.integratedterminals.api.terminalstorage.crafting.TerminalCraftingJobStatus; import org.cyclops.integratedterminals.client.gui.container.ContainerScreenTerminalStorage; import org.cyclops.integratedterminals.core.client.gui.CraftingOptionGuiData; import org.lwjgl.opengl.GL11; -import javax.annotation.Nullable; import java.util.List; /** @@ -60,14 +58,7 @@ public class GuiCraftingPlanFlat extends AbstractWidget { private final List visibleElements; private final boolean valid; private final WidgetScrollBar scrollBar; - private final String label; - private final long tickDuration; - private final long estimatedTickDuration; - private final boolean estimatedTickDurationRemaining; - private final boolean showEstimatedTickDuration; - private final int channel; - @Nullable - private final String initiatorName; + private final GuiCraftingPlanHeader header; private int firstRow; @@ -81,16 +72,9 @@ public GuiCraftingPlanFlat(AbstractContainerScreen parentGui, ITerminalCraftingP this.valid = craftingPlan.getStatus().isValid(); this.scrollBar = new WidgetScrollBar(guiLeft + x + 227, guiTop + y + 0, 178, Component.translatable("gui.cyclopscore.scrollbar"), this::setFirstRow, visibleRows); refreshList(); - this.label = L10NHelpers.localize(craftingPlan.getUnlocalizedLabel()); - this.tickDuration = craftingPlan.getTickDuration(); - // Jobs that have not started yet can only show a total estimation, running jobs show what is left of it - this.estimatedTickDurationRemaining = craftingPlan.getStatus() != TerminalCraftingJobStatus.UNSTARTED; - this.estimatedTickDuration = this.estimatedTickDurationRemaining - ? craftingPlan.getEstimatedTickDurationRemaining() : craftingPlan.getEstimatedTickDurationTotal(); - // Jobs that can not be crafted have nothing to estimate, other jobs show a placeholder until they are measured - this.showEstimatedTickDuration = craftingPlan.getStatus().isValid(); - this.channel = craftingPlan.getChannel(); - this.initiatorName = craftingPlan.getInitiatorName(); + this.header = new GuiCraftingPlanHeader(craftingPlan.getUnlocalizedLabel(), craftingPlan.getStatus(), + craftingPlan.getTickDuration(), craftingPlan.getEstimatedTickDurationTotal(), + craftingPlan.getEstimatedTickDurationRemaining(), craftingPlan.getChannel(), craftingPlan.getInitiatorName()); } public void inheritVisualizationState(GuiCraftingPlanFlat guiCraftingPlan) { @@ -222,33 +206,7 @@ public static String getDurationString(long tickDuration) { } public void drawGuiContainerBackgroundLayer(GuiGraphics guiGraphics, float partialTicks, int mouseX, int mouseY) { - Font fontRenderer = Minecraft.getInstance().font; - - // Draw plan label - guiGraphics.drawCenteredString(Minecraft.getInstance().font, this.label, guiLeft + getX() + GuiCraftingPlan.ELEMENT_WIDTH / 2 + 8, guiTop + getY() - 13, 16777215); - - // Draw duration - if (tickDuration >= 0) { - String durationString = getDurationString(tickDuration); - RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, durationString, guiLeft + getX() + 200, guiTop + getY() - 14, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); - } - - // Draw channel - if (channel != -1) { - String channelString = L10NHelpers.localize("gui.integratedterminals.terminal_crafting_job.craftingplan.crafting_channel", channel); - RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, channelString, guiLeft + getX() + 200, guiTop + getY() - 8, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); - } - - // Draw initiator - if (initiatorName != null) { - String initiatorString = L10NHelpers.localize("gui.integratedterminals.terminal_crafting_job.craftingplan.owner", initiatorName); - RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, initiatorString, guiLeft + getX() - 4, guiTop + getY() - 8, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); - } - - // Draw estimated duration - if (showEstimatedTickDuration) { - RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, getEstimatedDurationString(), guiLeft + getX() - 4, guiTop + getY() - 14, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); - } + header.drawBackground(guiGraphics, guiLeft + getX(), guiTop + getY(), GuiCraftingPlan.VIEW_WIDTH); drawGuiContainerLayer(guiGraphics, guiLeft, guiTop, ContainerScreenTerminalStorage.DrawLayer.BACKGROUND, partialTicks, mouseX, mouseY); scrollBar.render(guiGraphics, mouseX, mouseY, partialTicks); @@ -256,16 +214,7 @@ public void drawGuiContainerBackgroundLayer(GuiGraphics guiGraphics, float parti public void drawGuiContainerForegroundLayer(GuiGraphics guiGraphics, int mouseX, int mouseY) { drawGuiContainerLayer(guiGraphics, 0, 0, ContainerScreenTerminalStorage.DrawLayer.FOREGROUND, 0, mouseX, mouseY); - if (showEstimatedTickDuration) { - GuiCraftingPlan.drawUnknownDurationTooltip(this.parentGui, guiGraphics, getEstimatedDurationString(), - estimatedTickDuration, getX() - 4, getY() - 14, mouseX, mouseY); - } - } - - protected String getEstimatedDurationString() { - return GuiCraftingPlan.getDurationString(estimatedTickDurationRemaining - ? "gui.integratedterminals.terminal_crafting_job.craftingplan.duration.remaining" - : "gui.integratedterminals.terminal_crafting_job.craftingplan.duration.estimate", estimatedTickDuration); + header.drawForeground(this.parentGui, guiGraphics, getX(), getY(), mouseX, mouseY); } @Override diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanHeader.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanHeader.java new file mode 100644 index 000000000..4b7a1be82 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanHeader.java @@ -0,0 +1,104 @@ +package org.cyclops.integratedterminals.client.gui.container.component; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import org.cyclops.cyclopscore.helper.L10NHelpers; +import org.cyclops.cyclopscore.helper.RenderHelpers; +import org.cyclops.integratedterminals.api.terminalstorage.crafting.TerminalCraftingJobStatus; + +import javax.annotation.Nullable; + +/** + * The header lines above a crafting plan view: label, durations, channel and initiator. + * It is shared by all crafting plan views. + * @author rubensworks + */ +public class GuiCraftingPlanHeader { + + private final String label; + private final long tickDuration; + private final long estimatedTickDuration; + private final boolean estimatedTickDurationRemaining; + private final boolean showEstimatedTickDuration; + private final int channel; + @Nullable + private final String initiatorName; + + public GuiCraftingPlanHeader(String unlocalizedLabel, TerminalCraftingJobStatus status, long tickDuration, + long estimatedTickDurationTotal, long estimatedTickDurationRemaining, + int channel, @Nullable String initiatorName) { + this.label = L10NHelpers.localize(unlocalizedLabel); + this.tickDuration = tickDuration; + // Jobs that have not started yet can only show a total estimation, running jobs show what is left of it + this.estimatedTickDurationRemaining = status != TerminalCraftingJobStatus.UNSTARTED; + this.estimatedTickDuration = this.estimatedTickDurationRemaining + ? estimatedTickDurationRemaining : estimatedTickDurationTotal; + // Jobs that can not be crafted have nothing to estimate, other jobs show a placeholder until they are measured + this.showEstimatedTickDuration = status.isValid(); + this.channel = channel; + this.initiatorName = initiatorName; + } + + /** + * Draw the header lines above a view. + * @param guiGraphics The gui graphics. + * @param x The X position of the view. + * @param y The Y position of the view. + * @param width The width of the view, including its scrollbar. + */ + public void drawBackground(GuiGraphics guiGraphics, int x, int y, int width) { + Font fontRenderer = Minecraft.getInstance().font; + int rightX = x + width - 38; + + // Draw plan label + guiGraphics.drawCenteredString(fontRenderer, this.label, x + width / 2 - 1, y - 13, 16777215); + + // Draw duration + if (tickDuration >= 0) { + String durationString = GuiCraftingPlan.getDurationString(tickDuration); + RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, durationString, rightX, y - 14, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); + } + + // Draw channel + if (channel != -1) { + String channelString = L10NHelpers.localize("gui.integratedterminals.terminal_crafting_job.craftingplan.crafting_channel", channel); + RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, channelString, rightX, y - 8, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); + } + + // Draw initiator + if (initiatorName != null) { + String initiatorString = L10NHelpers.localize("gui.integratedterminals.terminal_crafting_job.craftingplan.owner", initiatorName); + RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, initiatorString, x - 4, y - 8, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); + } + + // Draw estimated duration + if (showEstimatedTickDuration) { + RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), fontRenderer, getEstimatedDurationString(), x - 4, y - 14, 0.5f, 16777215, true, Font.DisplayMode.NORMAL); + } + } + + /** + * Draw the header tooltips. + * @param parentGui The gui that the header is drawn in. + * @param guiGraphics The gui graphics. + * @param x The X position of the view, relative to the gui. + * @param y The Y position of the view, relative to the gui. + * @param mouseX The mouse X position. + * @param mouseY The mouse Y position. + */ + public void drawForeground(AbstractContainerScreen parentGui, GuiGraphics guiGraphics, int x, int y, int mouseX, int mouseY) { + if (showEstimatedTickDuration) { + GuiCraftingPlan.drawUnknownDurationTooltip(parentGui, guiGraphics, getEstimatedDurationString(), + estimatedTickDuration, x - 4, y - 14, mouseX, mouseY); + } + } + + protected String getEstimatedDurationString() { + return GuiCraftingPlan.getDurationString(estimatedTickDurationRemaining + ? "gui.integratedterminals.terminal_crafting_job.craftingplan.duration.remaining" + : "gui.integratedterminals.terminal_crafting_job.craftingplan.duration.estimate", estimatedTickDuration); + } + +} diff --git a/src/test/java/org/cyclops/integratedterminals/client/gui/container/component/TestCraftingPlanGraphCamera.java b/src/test/java/org/cyclops/integratedterminals/client/gui/container/component/TestCraftingPlanGraphCamera.java new file mode 100644 index 000000000..10d0ac80d --- /dev/null +++ b/src/test/java/org/cyclops/integratedterminals/client/gui/container/component/TestCraftingPlanGraphCamera.java @@ -0,0 +1,156 @@ +package org.cyclops.integratedterminals.client.gui.container.component; + +import org.junit.Test; + +import static org.cyclops.integratedterminals.client.gui.container.component.CraftingPlanGraphCamera.MARGIN; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author rubensworks + */ +public class TestCraftingPlanGraphCamera { + + private static final double EPSILON = 0.0001; + + @Test + public void testDefaults() { + CraftingPlanGraphCamera camera = new CraftingPlanGraphCamera(); + assertEquals(1F, camera.getZoom(), EPSILON); + assertEquals(10, camera.toViewX(10), EPSILON); + assertEquals(10, camera.toGraphY(10), EPSILON); + } + + @Test + public void testPan() { + CraftingPlanGraphCamera camera = new CraftingPlanGraphCamera(); + camera.pan(5, -3); + assertEquals(5, camera.toViewX(0), EPSILON); + assertEquals(-3, camera.toViewY(0), EPSILON); + assertEquals(-5, camera.toGraphX(0), EPSILON); + } + + @Test + public void testZoomAtKeepsAnchorFixed() { + CraftingPlanGraphCamera camera = new CraftingPlanGraphCamera(); + camera.pan(17, 23); + double graphX = camera.toGraphX(100); + double graphY = camera.toGraphY(60); + + assertTrue(camera.zoomAt(100, 60, 1)); + assertEquals(1.5F, camera.getZoom(), EPSILON); + assertEquals(100, camera.toViewX(graphX), EPSILON); + assertEquals(60, camera.toViewY(graphY), EPSILON); + + assertTrue(camera.zoomAt(100, 60, -1)); + assertTrue(camera.zoomAt(100, 60, -1)); + assertEquals(0.75F, camera.getZoom(), EPSILON); + assertEquals(100, camera.toViewX(graphX), EPSILON); + assertEquals(60, camera.toViewY(graphY), EPSILON); + } + + @Test + public void testZoomIsClamped() { + CraftingPlanGraphCamera camera = new CraftingPlanGraphCamera(); + for (int i = 0; i < 10; i++) { + camera.zoomAt(0, 0, 1); + } + assertEquals(2F, camera.getZoom(), EPSILON); + assertFalse(camera.canZoomIn()); + assertFalse(camera.zoomAt(0, 0, 1)); + for (int i = 0; i < 10; i++) { + camera.zoomAt(0, 0, -1); + } + assertEquals(0.5F, camera.getZoom(), EPSILON); + assertFalse(camera.canZoomOut()); + assertFalse(camera.zoomAt(0, 0, -1)); + } + + @Test + public void testFitSmallGraphZoomsIn() { + CraftingPlanGraphCamera camera = new CraftingPlanGraphCamera(); + CraftingPlanGraphLayout.Bounds bounds = new CraftingPlanGraphLayout.Bounds(0, -16, 74, 42); + camera.fit(bounds, 238, 178); + assertEquals(2F, camera.getZoom(), EPSILON); + // Centered + assertEquals(238 - camera.toViewX(74), camera.toViewX(0), EPSILON); + assertEquals(178 - camera.toViewY(42), camera.toViewY(-16), EPSILON); + } + + @Test + public void testFitPicksLargestFittingLevel() { + CraftingPlanGraphCamera camera = new CraftingPlanGraphCamera(); + // Fits at 0.75 (150 + 16 <= 200), not at 1 (200 + 16 > 200) + CraftingPlanGraphLayout.Bounds bounds = new CraftingPlanGraphLayout.Bounds(0, 0, 200, 50); + camera.fit(bounds, 200, 300); + assertEquals(0.75F, camera.getZoom(), EPSILON); + } + + @Test + public void testFitTooLargeAlignsRootLeft() { + CraftingPlanGraphCamera camera = new CraftingPlanGraphCamera(); + CraftingPlanGraphLayout.Bounds bounds = new CraftingPlanGraphLayout.Bounds(0, -1000, 1000, 1000); + camera.fit(bounds, 200, 100); + assertEquals(0.5F, camera.getZoom(), EPSILON); + assertEquals(MARGIN, camera.toViewX(0), EPSILON); + // Root center is vertically centered + assertEquals(50, camera.toViewY(CraftingPlanGraphLayout.NODE_SIZE / 2D), EPSILON); + } + + @Test + public void testResetNeverZoomsBeyondDefault() { + CraftingPlanGraphCamera camera = new CraftingPlanGraphCamera(); + CraftingPlanGraphLayout.Bounds bounds = new CraftingPlanGraphLayout.Bounds(0, -16, 74, 42); + camera.reset(bounds, 238, 178); + assertEquals(1F, camera.getZoom(), EPSILON); + assertEquals(MARGIN, camera.toViewX(0), EPSILON); + assertEquals(178 - camera.toViewY(42), camera.toViewY(-16), EPSILON); + } + + @Test + public void testResetLargeGraphZoomsOut() { + CraftingPlanGraphCamera camera = new CraftingPlanGraphCamera(); + CraftingPlanGraphLayout.Bounds bounds = new CraftingPlanGraphLayout.Bounds(0, -100, 170, 100); + camera.reset(bounds, 200, 200); + assertEquals(0.75F, camera.getZoom(), EPSILON); + assertEquals(MARGIN, camera.toViewX(0), EPSILON); + } + + @Test + public void testClampKeepsGraphReachable() { + CraftingPlanGraphLayout.Bounds bounds = new CraftingPlanGraphLayout.Bounds(0, -50, 100, 76); + int node = CraftingPlanGraphLayout.NODE_SIZE; + + CraftingPlanGraphCamera camera = new CraftingPlanGraphCamera(); + camera.pan(-10000, -10000); + camera.clamp(bounds, 200, 150); + assertEquals(node, camera.toViewX(100), EPSILON); + assertEquals(node, camera.toViewY(76), EPSILON); + + camera.pan(20000, 20000); + camera.clamp(bounds, 200, 150); + assertEquals(200 - node, camera.toViewX(0), EPSILON); + assertEquals(150 - node, camera.toViewY(-50), EPSILON); + + // Positions within the limits are untouched + CraftingPlanGraphCamera camera2 = new CraftingPlanGraphCamera(); + camera2.pan(10, 60); + camera2.clamp(bounds, 200, 150); + assertEquals(10, camera2.getOriginX(), EPSILON); + assertEquals(60, camera2.getOriginY(), EPSILON); + } + + @Test + public void testCopyFrom() { + CraftingPlanGraphCamera camera = new CraftingPlanGraphCamera(); + camera.pan(3, 4); + camera.zoomAt(0, 0, 1); + CraftingPlanGraphCamera copy = new CraftingPlanGraphCamera(); + copy.copyFrom(camera); + assertEquals(camera.getOriginX(), copy.getOriginX(), EPSILON); + assertEquals(camera.getOriginY(), copy.getOriginY(), EPSILON); + assertEquals(camera.getZoomIndex(), copy.getZoomIndex()); + } + +} diff --git a/src/test/java/org/cyclops/integratedterminals/client/gui/container/component/TestCraftingPlanGraphLayout.java b/src/test/java/org/cyclops/integratedterminals/client/gui/container/component/TestCraftingPlanGraphLayout.java new file mode 100644 index 000000000..38f51c0a2 --- /dev/null +++ b/src/test/java/org/cyclops/integratedterminals/client/gui/container/component/TestCraftingPlanGraphLayout.java @@ -0,0 +1,245 @@ +package org.cyclops.integratedterminals.client.gui.container.component; + +import com.google.common.collect.Lists; +import org.cyclops.integratedterminals.api.terminalstorage.crafting.ITerminalCraftingPlan; +import org.cyclops.integratedterminals.api.terminalstorage.crafting.TerminalCraftingJobStatus; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.cyclops.integratedterminals.client.gui.container.component.CraftingPlanGraphLayout.COLUMN_PITCH; +import static org.cyclops.integratedterminals.client.gui.container.component.CraftingPlanGraphLayout.NODE_SIZE; +import static org.cyclops.integratedterminals.client.gui.container.component.CraftingPlanGraphLayout.ROW_PITCH; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * @author rubensworks + */ +public class TestCraftingPlanGraphLayout { + + protected static Node node(String name, Node... children) { + return new Node(name, Arrays.asList(children)); + } + + protected static CraftingPlanGraphLayout.Placement placement(CraftingPlanGraphLayout layout, Node node) { + for (CraftingPlanGraphLayout.Placement placement : layout.getPlacements()) { + if (placement.getNode() == node) { + return placement; + } + } + return null; + } + + @Test + public void testSingleNode() { + Node root = node("root"); + CraftingPlanGraphLayout layout = CraftingPlanGraphLayout.create(root); + assertEquals(1, layout.getPlacements().size()); + assertEquals(0, placement(layout, root).getX()); + assertEquals(0, placement(layout, root).getY()); + assertTrue(layout.getSegments().isEmpty()); + assertBounds(layout.getBounds(), 0, 0, NODE_SIZE, NODE_SIZE); + } + + @Test + public void testChain() { + Node c = node("c"); + Node b = node("b", c); + Node a = node("a", b); + CraftingPlanGraphLayout layout = CraftingPlanGraphLayout.create(a); + assertEquals(0, placement(layout, a).getX()); + assertEquals(COLUMN_PITCH, placement(layout, b).getX()); + assertEquals(2 * COLUMN_PITCH, placement(layout, c).getX()); + for (CraftingPlanGraphLayout.Placement placement : layout.getPlacements()) { + assertEquals(0, placement.getY()); + } + assertBounds(layout.getBounds(), 0, 0, 2 * COLUMN_PITCH + NODE_SIZE, NODE_SIZE); + } + + @Test + public void testParentCenteredOnChildren() { + Node c1 = node("c1"); + Node c2 = node("c2"); + Node c3 = node("c3"); + Node root = node("root", c1, c2, c3); + CraftingPlanGraphLayout layout = CraftingPlanGraphLayout.create(root); + assertEquals(0, placement(layout, root).getY()); + assertEquals(-ROW_PITCH, placement(layout, c1).getY()); + assertEquals(0, placement(layout, c2).getY()); + assertEquals(ROW_PITCH, placement(layout, c3).getY()); + assertBounds(layout.getBounds(), 0, -ROW_PITCH, COLUMN_PITCH + NODE_SIZE, ROW_PITCH + NODE_SIZE); + } + + @Test + public void testRootAlwaysAtOrigin() { + Node root = node("root", node("a", node("a1"), node("a2"), node("a3")), node("b")); + CraftingPlanGraphLayout layout = CraftingPlanGraphLayout.create(root); + assertEquals(0, placement(layout, root).getX()); + assertEquals(0, placement(layout, root).getY()); + } + + @Test + public void testNestedSubtreesDoNotOverlap() { + Node a1 = node("a1"); + Node a2 = node("a2"); + Node a = node("a", a1, a2); + Node b1 = node("b1"); + Node b2 = node("b2"); + Node b = node("b", b1, b2); + Node root = node("root", a, b); + CraftingPlanGraphLayout layout = CraftingPlanGraphLayout.create(root); + + // Each leaf has its own row, in depth-first order + int y = placement(layout, a1).getY(); + assertEquals(y + ROW_PITCH, placement(layout, a2).getY()); + assertEquals(y + 2 * ROW_PITCH, placement(layout, b1).getY()); + assertEquals(y + 3 * ROW_PITCH, placement(layout, b2).getY()); + + // No two nodes in the same column overlap + for (CraftingPlanGraphLayout.Placement p1 : layout.getPlacements()) { + for (CraftingPlanGraphLayout.Placement p2 : layout.getPlacements()) { + if (p1 != p2 && p1.getX() == p2.getX()) { + assertTrue(Math.abs(p1.getY() - p2.getY()) >= NODE_SIZE); + } + } + } + } + + @Test + public void testCollapsedSubtree() { + Node a1 = node("a1"); + Node a2 = node("a2", node("a21")); + Node a = node("a", a1, a2); + Node b = node("b"); + Node root = node("root", a, b); + a1.visible = false; + a2.visible = false; + CraftingPlanGraphLayout layout = CraftingPlanGraphLayout.create(root); + + assertEquals(3, layout.getPlacements().size()); + assertNull(placement(layout, a1)); + assertEquals(3, placement(layout, a).getHiddenDescendants()); + assertEquals(0, placement(layout, b).getHiddenDescendants()); + // The collapsed node takes a single row + assertEquals(placement(layout, a).getY() + ROW_PITCH, placement(layout, b).getY()); + assertEquals(placement(layout, a).getY() + NODE_SIZE, layout.getBounds().getMaxY() - ROW_PITCH); + } + + @Test + public void testSegmentsConnectParentAndChildren() { + Node c1 = node("c1"); + Node c2 = node("c2"); + Node root = node("root", c1, c2); + CraftingPlanGraphLayout layout = CraftingPlanGraphLayout.create(root); + CraftingPlanGraphLayout.Placement pRoot = placement(layout, root); + + int center = NODE_SIZE / 2; + int trunkX = NODE_SIZE + CraftingPlanGraphLayout.COLUMN_GAP / 2; + List> segments = layout.getSegments(); + assertEquals(4, segments.size()); + + // Stub out of the parent's right edge + CraftingPlanGraphLayout.Segment stub = segments.get(0); + assertNull(stub.getTarget()); + assertEquals(pRoot.getX() + NODE_SIZE, stub.getX1()); + assertEquals(pRoot.getY() + center, stub.getY1()); + assertEquals(trunkX, stub.getX2()); + + // Trunk from the first to the last child + CraftingPlanGraphLayout.Segment trunk = segments.get(1); + assertEquals(trunkX, trunk.getX1()); + assertEquals(trunkX, trunk.getX2()); + assertEquals(placement(layout, c1).getY() + center, trunk.getY1()); + assertEquals(placement(layout, c2).getY() + center, trunk.getY2()); + + // Stubs into the left edge of each child + for (int i = 0; i < 2; i++) { + CraftingPlanGraphLayout.Segment childStub = segments.get(2 + i); + Node child = i == 0 ? c1 : c2; + assertSame(child, childStub.getTarget()); + assertEquals(trunkX, childStub.getX1()); + assertEquals(placement(layout, child).getX() - 1, childStub.getX2()); + assertEquals(placement(layout, child).getY() + center, childStub.getY1()); + } + } + + @Test + public void testSingleChildHasNoTrunk() { + Node root = node("root", node("c")); + CraftingPlanGraphLayout layout = CraftingPlanGraphLayout.create(root); + assertEquals(2, layout.getSegments().size()); + } + + @Test + public void testPlacementAt() { + Node c1 = node("c1"); + Node root = node("root", c1, node("c2")); + CraftingPlanGraphLayout layout = CraftingPlanGraphLayout.create(root); + assertSame(root, layout.getPlacementAt(1, 1).getNode()); + assertSame(root, layout.getPlacementAt(NODE_SIZE - 0.5, NODE_SIZE - 0.5).getNode()); + assertNull(layout.getPlacementAt(NODE_SIZE, 1)); + CraftingPlanGraphLayout.Placement p1 = placement(layout, c1); + assertSame(c1, layout.getPlacementAt(p1.getX() + 5, p1.getY() + 5).getNode()); + } + + @Test + public void testCraftingPlanElements() { + ITerminalCraftingPlan plan = TestGuiCraftingPlanProgress.plan(1, TerminalCraftingJobStatus.UNSTARTED, 1, 1, + Lists.newArrayList( + TestGuiCraftingPlanProgress.plan(2, TerminalCraftingJobStatus.UNSTARTED, 2, 2, Collections.emptyList()), + TestGuiCraftingPlanProgress.plan(3, TerminalCraftingJobStatus.UNSTARTED, 3, 3, Collections.emptyList()) + )); + List elements = GuiCraftingPlan.getElements(plan); + GuiCraftingPlan.Element root = elements.get(0); + assertEquals(3, CraftingPlanGraphLayout.create(root).getPlacements().size()); + + // Collapsing in the same way as the list view + for (GuiCraftingPlan.Element child : root.getChildren()) { + child.setEnabled(false); + } + CraftingPlanGraphLayout layout = CraftingPlanGraphLayout.create(root); + assertEquals(1, layout.getPlacements().size()); + assertEquals(2, layout.getPlacements().get(0).getHiddenDescendants()); + assertFalse(root.getChildren().get(0).isVisible()); + } + + protected static void assertBounds(CraftingPlanGraphLayout.Bounds bounds, int minX, int minY, int maxX, int maxY) { + assertEquals(minX, bounds.getMinX()); + assertEquals(minY, bounds.getMinY()); + assertEquals(maxX, bounds.getMaxX()); + assertEquals(maxY, bounds.getMaxY()); + } + + protected static class Node implements CraftingPlanGraphLayout.INode { + private final String name; + private final List children; + private boolean visible = true; + + public Node(String name, List children) { + this.name = name; + this.children = children; + } + + @Override + public List getChildren() { + return children; + } + + @Override + public boolean isVisible() { + return visible; + } + + @Override + public String toString() { + return name; + } + } + +} From 855510da2a611ad29127db7ecc65facfe900c157 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:17:09 +0000 Subject: [PATCH 02/26] Add a pannable and zoomable graph view for crafting plans The graph is shown in the crafting plan preview and in the plan of running jobs, next to the existing tree list and compact views, and replaces the tree list as the default tree-based view. It supports zooming around the cursor, fitting the plan to the view, collapsing nodes, and a fullscreen mode, and keeps its state across live updates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C5iUzweRYoCkKB5bvJdH8Y --- .../integratedterminals/GeneralConfig.java | 2 +- ...ntainerScreenTerminalCraftingJobsPlan.java | 119 ++-- .../ContainerScreenTerminalStorage.java | 46 +- ...inerScreenTerminalStorageCraftingPlan.java | 127 ++-- .../container/component/GuiCraftingPlan.java | 2 +- .../component/GuiCraftingPlanFlat.java | 2 +- .../component/GuiCraftingPlanGraph.java | 631 ++++++++++++++++++ .../component/GuiCraftingPlanToggler.java | 203 +++++- .../component/IGuiCraftingPlanView.java | 32 + .../client/gui/image/Images.java | 10 + .../integratedterminals/lang/en_us.json | 17 +- .../textures/gui/icons.png | Bin 6269 -> 6625 bytes .../component/TestGuiCraftingPlanGraph.java | 36 + 13 files changed, 1083 insertions(+), 144 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanGraph.java create mode 100644 src/main/java/org/cyclops/integratedterminals/client/gui/container/component/IGuiCraftingPlanView.java create mode 100644 src/test/java/org/cyclops/integratedterminals/client/gui/container/component/TestGuiCraftingPlanGraph.java diff --git a/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java b/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java index d670d5e96..01a02c64a 100644 --- a/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java +++ b/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java @@ -23,7 +23,7 @@ public class GeneralConfig extends DummyConfig { public static int terminalStoragePacketMaxInstances = 512; @ConfigurableProperty(category = "core", comment = "The maximum number of terminal storage crafting recipes that can be sent in a single packet. Reduce this when you have packet overflows.", isCommandable = true, configLocation = ModConfig.Type.SERVER) public static int terminalStoragePacketMaxRecipes = 128; - @ConfigurableProperty(category = "core", comment = "If crafting plans should default to the tree-based view. If false, it will default to the flattened view.", isCommandable = true, configLocation = ModConfig.Type.COMMON) + @ConfigurableProperty(category = "core", comment = "If crafting plans should default to the tree-based graph view. If false, it will default to the flattened view.", isCommandable = true, configLocation = ModConfig.Type.COMMON) public static boolean terminalStorageDefaultToCraftingPlanTree = false; @ConfigurableProperty(category = "core", comment = "The limit for the number of leaves in a tree-based crafting plan after which it won't be sent to the client anymore.", isCommandable = true, configLocation = ModConfig.Type.SERVER) public static int terminalStorageMaxTreePlanSize = 64; diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalCraftingJobsPlan.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalCraftingJobsPlan.java index b56f66cc6..389522ebc 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalCraftingJobsPlan.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalCraftingJobsPlan.java @@ -1,7 +1,7 @@ package org.cyclops.integratedterminals.client.gui.container; -import net.minecraft.ChatFormatting; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; import net.minecraft.nbt.CompoundTag; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; @@ -15,7 +15,9 @@ import org.cyclops.integratedterminals.Reference; import org.cyclops.integratedterminals.client.gui.container.component.GuiCraftingPlan; import org.cyclops.integratedterminals.client.gui.container.component.GuiCraftingPlanFlat; +import org.cyclops.integratedterminals.client.gui.container.component.GuiCraftingPlanGraph; import org.cyclops.integratedterminals.client.gui.container.component.GuiCraftingPlanToggler; +import org.cyclops.integratedterminals.client.gui.container.component.IGuiCraftingPlanView; import org.cyclops.integratedterminals.inventory.container.ContainerTerminalCraftingJobsPlan; import org.cyclops.integratedterminals.network.packet.CancelCraftingJobPacket; import org.cyclops.integratedterminals.network.packet.OpenCraftingJobsGuiPacket; @@ -29,12 +31,19 @@ */ public class ContainerScreenTerminalCraftingJobsPlan extends ContainerScreenExtended { + private static final ResourceLocation TEXTURE_PANEL = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "textures/gui/part_terminal_storage.png"); + private final Player player; private GuiCraftingPlanToggler guiCraftingPlanToggler; + @Nullable + private IGuiCraftingPlanView activeView; + // The last view of each type is kept, so that its visualization state can be inherited after an update @Nullable private GuiCraftingPlan guiCraftingPlan; @Nullable + private GuiCraftingPlanGraph guiCraftingPlanGraph; + @Nullable private GuiCraftingPlanFlat guiCraftingPlanFlat; private boolean craftingPlanInitialized = false; @@ -53,19 +62,20 @@ public ContainerScreenTerminalCraftingJobsPlan(ContainerTerminalCraftingJobsPlan if (previousGuiCraftingPlan != null) { this.guiCraftingPlan.inheritVisualizationState(previousGuiCraftingPlan); } - addRenderableWidget(this.guiCraftingPlan); - this.guiCraftingPlanFlat = null; - - if (this.getMenu().getCraftingPlanFlat().isPresent()) { - addRenderableWidget(new ButtonText(leftPos + 8, topPos + 198, 80, 20, - Component.translatable("gui.integratedterminals.craftingplan.view.flat"), - Component.translatable("gui.integratedterminals.craftingplan.view.flat").withStyle(ChatFormatting.ITALIC), - (b) -> { - this.guiCraftingPlanToggler.setCraftingPlanDisplayMode(GuiCraftingPlanToggler.CraftingPlanDisplayMode.FLAT); - this.init(); - }, - true)); + setActiveView(this.guiCraftingPlan); + }, + () -> { + GuiCraftingPlanGraph previousGuiCraftingPlanGraph = this.guiCraftingPlanGraph; + this.guiCraftingPlanGraph = new GuiCraftingPlanGraph(this, this.getMenu().getCraftingPlan().get(), leftPos, topPos, 9, 18, + imageWidth - 18, imageHeight - 44, true); + if (previousGuiCraftingPlanGraph != null) { + this.guiCraftingPlanGraph.inheritVisualizationState(previousGuiCraftingPlanGraph); } + // Overlay buttons must be added before the graph, so that they receive clicks first + for (Button button : GuiCraftingPlanToggler.createGraphButtons(this.guiCraftingPlanGraph, this::init)) { + addRenderableWidget(button); + } + setActiveView(this.guiCraftingPlanGraph); }, () -> { GuiCraftingPlanFlat previousGuiCraftingPlan = this.guiCraftingPlanFlat; @@ -73,27 +83,17 @@ public ContainerScreenTerminalCraftingJobsPlan(ContainerTerminalCraftingJobsPlan if (previousGuiCraftingPlan != null) { this.guiCraftingPlanFlat.inheritVisualizationState(previousGuiCraftingPlan); } - addRenderableWidget(this.guiCraftingPlanFlat); - this.guiCraftingPlan = null; - - if (this.getMenu().getCraftingPlan().isPresent()) { - addRenderableWidget(new ButtonText(leftPos + 8, topPos + 198, 80, 20, - Component.translatable("gui.integratedterminals.craftingplan.view.tree"), - Component.translatable("gui.integratedterminals.craftingplan.view.tree").withStyle(ChatFormatting.ITALIC), - (b) -> { - this.guiCraftingPlanToggler.setCraftingPlanDisplayMode(GuiCraftingPlanToggler.CraftingPlanDisplayMode.TREE); - this.init(); - }, - true)); - } + setActiveView(this.guiCraftingPlanFlat); }, - () -> { - this.guiCraftingPlan = null; - this.guiCraftingPlanFlat = null; - } + () -> this.activeView = null ); } + protected void setActiveView(IGuiCraftingPlanView view) { + this.activeView = view; + addRenderableWidget(view); + } + @Override protected ResourceLocation constructGuiTexture() { return ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "textures/gui/crafting_plan.png"); @@ -106,26 +106,32 @@ public ResourceLocation getGuiTexture() { @Override public int getBaseXSize() { - return 256; + return this.guiCraftingPlanToggler.getWidth(this.width); } @Override public int getBaseYSize() { - return 222; + return this.guiCraftingPlanToggler.getHeight(this.height); } @Override public void init() { + // The display mode determines the gui size + this.guiCraftingPlanToggler.resolveDisplayMode(); super.init(); // Reset states this.renderables.clear(); this.children().clear(); + for (Button button : this.guiCraftingPlanToggler.createViewButtons(leftPos + 8, topPos + imageHeight - 23, this::init)) { + addRenderableWidget(button); + } + this.guiCraftingPlanToggler.init(); - if (this.guiCraftingPlan != null || this.guiCraftingPlanFlat != null) { - addRenderableWidget(new ButtonText(leftPos + 221 + 10 - 100, topPos + 198, 100, 20, + if (this.activeView != null) { + addRenderableWidget(new ButtonText(leftPos + imageWidth - 25 - 100, topPos + imageHeight - 24, 100, 20, Component.translatable("gui.integratedterminals.terminal_crafting_job.craftingplan.cancel"), Component.translatable("gui.integratedterminals.terminal_crafting_job.craftingplan.cancel"), (b) -> cancelCraftingJob(), @@ -160,57 +166,58 @@ private void cancelCraftingJob() { @Override protected void renderBg(GuiGraphics guiGraphics, float partialTicks, int mouseX, int mouseY) { - super.renderBg(guiGraphics, partialTicks, mouseX, mouseY); - if (this.guiCraftingPlan != null) { - guiCraftingPlan.drawGuiContainerBackgroundLayer(guiGraphics, partialTicks, mouseX, mouseY); - } else if (this.guiCraftingPlanFlat != null) { - guiCraftingPlanFlat.drawGuiContainerBackgroundLayer(guiGraphics, partialTicks, mouseX, mouseY); + if (this.guiCraftingPlanToggler.getCraftingPlanDisplayMode() == GuiCraftingPlanToggler.CraftingPlanDisplayMode.GRAPH) { + ContainerScreenTerminalStorage.drawPanel(guiGraphics, TEXTURE_PANEL, leftPos, topPos, imageWidth, imageHeight); + } else { + super.renderBg(guiGraphics, partialTicks, mouseX, mouseY); + } + if (this.activeView != null) { + activeView.drawGuiContainerBackgroundLayer(guiGraphics, partialTicks, mouseX, mouseY); } else { guiGraphics.drawCenteredString(font, L10NHelpers.localize("gui.integratedterminals.terminal_crafting_job.craftingplan.empty"), - leftPos + getBaseXSize() / 2, topPos + 23, 16777215); + leftPos + imageWidth / 2, topPos + 23, 16777215); } } @Override protected void drawCurrentScreen(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { super.drawCurrentScreen(guiGraphics, mouseX, mouseY, partialTicks); - if (this.guiCraftingPlan != null) { - guiCraftingPlan.render(guiGraphics, mouseX, mouseY, partialTicks); - } else if (this.guiCraftingPlanFlat != null) { - guiCraftingPlanFlat.render(guiGraphics, mouseX, mouseY, partialTicks); + if (this.activeView != null) { + activeView.render(guiGraphics, mouseX, mouseY, partialTicks); } } @Override protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { // super.drawGuiContainerForegroundLayer(matrixStack, mouseX, mouseY); - if (this.guiCraftingPlan != null) { - guiCraftingPlan.drawGuiContainerForegroundLayer(guiGraphics, mouseX, mouseY); - } else if (this.guiCraftingPlanFlat != null) { - guiCraftingPlanFlat.drawGuiContainerForegroundLayer(guiGraphics, mouseX, mouseY); + if (this.activeView != null) { + activeView.drawGuiContainerForegroundLayer(guiGraphics, mouseX, mouseY); } } @Override public boolean mouseScrolled(double mouseX, double mouseY, double mouseZ, double delta) { - if (this.guiCraftingPlan != null) { - return guiCraftingPlan.mouseScrolled(mouseX, mouseY, mouseZ, delta); - } else if (this.guiCraftingPlanFlat != null) { - return guiCraftingPlanFlat.mouseScrolled(mouseX, mouseY, mouseZ, delta); + if (this.activeView != null) { + return activeView.mouseScrolled(mouseX, mouseY, mouseZ, delta); } return super.mouseScrolled(mouseX, mouseY, mouseZ, delta); } @Override public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, double mouseXPrev, double mouseYPrev) { - if (this.guiCraftingPlan != null) { - return guiCraftingPlan.mouseDragged(mouseX, mouseY, mouseButton, mouseXPrev, mouseYPrev); - } else if (this.guiCraftingPlanFlat != null) { - return guiCraftingPlanFlat.mouseDragged(mouseX, mouseY, mouseButton, mouseXPrev, mouseYPrev); + if (this.activeView != null) { + return activeView.mouseDragged(mouseX, mouseY, mouseButton, mouseXPrev, mouseYPrev); } return super.mouseDragged(mouseX, mouseY, mouseButton, mouseXPrev, mouseYPrev); } + @Override + public boolean mouseReleased(double mouseX, double mouseY, int mouseButton) { + // The views are not hit-tested by the default event routing, so they need to be notified explicitly + boolean handled = this.activeView != null && activeView.mouseReleased(mouseX, mouseY, mouseButton); + return super.mouseReleased(mouseX, mouseY, mouseButton) || handled; + } + @Override public void onUpdate(int valueId, CompoundTag value) { super.onUpdate(valueId, value); diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorage.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorage.java index 1f9dce871..06bed8fa9 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorage.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorage.java @@ -373,25 +373,10 @@ protected void renderBgTab(GuiGraphics guiGraphics, float f, int mouseX, int mou int offsetX = getTabBackgroundOffsetX(); int offsetY = TAB_BACKGROUND_OFFSET; int blitOffset = 0; - int cornerSize = 7; int columns = getSlotRowLength(); int rows = getSlotVisibleRows(); - // Corners - //blit(matrixStack, leftPos + offsetXX, topPos + offsetYY, 0, 0, imageWidth - 2 * offsetX, imageHeight - 2 * offsetY); // top-left - guiGraphics.blit(this.texture, leftPos + offsetX, topPos + offsetY, cornerSize, 0, cornerSize, cornerSize); // top-left - guiGraphics.blit(this.texture, leftPos + offsetX + tabWidth - cornerSize, topPos + offsetY, 0, 0, cornerSize, cornerSize); // top-right - guiGraphics.blit(this.texture, leftPos + offsetX + tabWidth - cornerSize, topPos + offsetY + tabHeight - cornerSize, cornerSize * 2, 0, cornerSize, cornerSize); // bottom-right - guiGraphics.blit(this.texture, leftPos + offsetX, topPos + offsetY + tabHeight - cornerSize, cornerSize * 3, 0, cornerSize, cornerSize); // bottom-left - - // Sides - blitRescalable(guiGraphics, this.texture, leftPos + offsetX + cornerSize, topPos + offsetY, blitOffset, cornerSize + 4, 0, 1, cornerSize, 256, 256, tabWidth - cornerSize * 2, cornerSize); // top - blitRescalable(guiGraphics, this.texture, leftPos + offsetX + tabWidth - cornerSize, topPos + offsetY + cornerSize, blitOffset, 0, 4, cornerSize, 1, 256, 256, cornerSize, tabHeight - cornerSize * 2); // right - blitRescalable(guiGraphics, this.texture, leftPos + offsetX + cornerSize, topPos + offsetY + tabHeight - cornerSize, blitOffset, 25, 0, 1, cornerSize, 256, 256, tabWidth - cornerSize * 2, cornerSize); // bottom - blitRescalable(guiGraphics, this.texture, leftPos + offsetX, topPos + offsetY + cornerSize, blitOffset, cornerSize, 4, cornerSize, 1, 256, 256, cornerSize, tabHeight - cornerSize * 2); // left - - // Background - blitRescalable(guiGraphics, this.texture, leftPos + offsetX + cornerSize, topPos + offsetY + cornerSize, blitOffset, 0, 3, 1, 1, 256, 256, tabWidth - cornerSize * 2, tabHeight - cornerSize * 2); + drawPanel(guiGraphics, this.texture, leftPos + offsetX, topPos + offsetY, tabWidth, tabHeight); // Slots for (int j = 0; j < rows; j++) { @@ -422,6 +407,35 @@ protected void renderBgTab(GuiGraphics guiGraphics, float f, int mouseX, int mou getSelectedClientTab().ifPresent(tab -> tab.onTabBackgroundRender(this, guiGraphics, f, mouseX, mouseY)); } + /** + * Draw a panel with a bevelled border of any size, by 9-slicing the given texture. + * @param guiGraphics The gui graphics. + * @param texture A texture with the same layout as part_terminal_storage.png. + * @param x The X position. + * @param y The Y position. + * @param width The width. + * @param height The height. + */ + public static void drawPanel(GuiGraphics guiGraphics, ResourceLocation texture, int x, int y, int width, int height) { + int blitOffset = 0; + int cornerSize = 7; + + // Corners + guiGraphics.blit(texture, x, y, cornerSize, 0, cornerSize, cornerSize); // top-left + guiGraphics.blit(texture, x + width - cornerSize, y, 0, 0, cornerSize, cornerSize); // top-right + guiGraphics.blit(texture, x + width - cornerSize, y + height - cornerSize, cornerSize * 2, 0, cornerSize, cornerSize); // bottom-right + guiGraphics.blit(texture, x, y + height - cornerSize, cornerSize * 3, 0, cornerSize, cornerSize); // bottom-left + + // Sides + blitRescalable(guiGraphics, texture, x + cornerSize, y, blitOffset, cornerSize + 4, 0, 1, cornerSize, 256, 256, width - cornerSize * 2, cornerSize); // top + blitRescalable(guiGraphics, texture, x + width - cornerSize, y + cornerSize, blitOffset, 0, 4, cornerSize, 1, 256, 256, cornerSize, height - cornerSize * 2); // right + blitRescalable(guiGraphics, texture, x + cornerSize, y + height - cornerSize, blitOffset, 25, 0, 1, cornerSize, 256, 256, width - cornerSize * 2, cornerSize); // bottom + blitRescalable(guiGraphics, texture, x, y + cornerSize, blitOffset, cornerSize, 4, cornerSize, 1, 256, 256, cornerSize, height - cornerSize * 2); // left + + // Background + blitRescalable(guiGraphics, texture, x + cornerSize, y + cornerSize, blitOffset, 0, 3, 1, 1, 256, 256, width - cornerSize * 2, height - cornerSize * 2); + } + public static void blitRescalable(GuiGraphics guiGraphics, ResourceLocation texture, int p_93145_, int p_93146_, int p_93147_, float p_93148_, float p_93149_, int p_93150_, int p_93151_, int p_93152_, int p_93153_, int realWidth, int realHeight) { guiGraphics.blit(texture, p_93145_, p_93145_ + realWidth, p_93146_, p_93146_ + realHeight, p_93147_, p_93150_, p_93151_, p_93148_, p_93149_, p_93152_, p_93153_); } diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java index f02941760..ce2b9d6e3 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorageCraftingPlan.java @@ -2,6 +2,7 @@ import net.minecraft.ChatFormatting; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Checkbox; import net.minecraft.client.gui.components.Tooltip; import net.minecraft.nbt.CompoundTag; @@ -17,7 +18,9 @@ import org.cyclops.integratedterminals.api.terminalstorage.crafting.ITerminalCraftingPlanFlat; import org.cyclops.integratedterminals.client.gui.container.component.GuiCraftingPlan; import org.cyclops.integratedterminals.client.gui.container.component.GuiCraftingPlanFlat; +import org.cyclops.integratedterminals.client.gui.container.component.GuiCraftingPlanGraph; import org.cyclops.integratedterminals.client.gui.container.component.GuiCraftingPlanToggler; +import org.cyclops.integratedterminals.client.gui.container.component.IGuiCraftingPlanView; import org.cyclops.integratedterminals.core.client.gui.CraftingOptionGuiData; import org.cyclops.integratedterminals.inventory.container.ContainerTerminalStorageCraftingPlanBase; import org.cyclops.integratedterminals.network.packet.TerminalStorageIngredientOpenCraftingJobAmountGuiPacket; @@ -31,12 +34,14 @@ */ public class ContainerScreenTerminalStorageCraftingPlan> extends ContainerScreenExtended { + private static final ResourceLocation TEXTURE_PANEL = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "textures/gui/part_terminal_storage.png"); + private GuiCraftingPlanToggler guiCraftingPlanToggler; @Nullable - private GuiCraftingPlan guiCraftingPlan; + private IGuiCraftingPlanView activeView; @Nullable - private GuiCraftingPlanFlat guiCraftingPlanFlat; + private GuiCraftingPlanGraph guiCraftingPlanGraph; private ITerminalCraftingPlan craftingPlan; private ITerminalCraftingPlanFlat craftingPlanFlat; @@ -48,43 +53,30 @@ public ContainerScreenTerminalStorageCraftingPlan(C container, Inventory invento this.guiCraftingPlanToggler = new GuiCraftingPlanToggler( () -> this.craftingPlan, () -> this.craftingPlanFlat, + () -> setActiveView(new GuiCraftingPlan(this, this.craftingPlan, leftPos, topPos, 9, 18, 10)), () -> { - this.guiCraftingPlan = new GuiCraftingPlan(this, this.craftingPlan, leftPos, topPos, 9, 18, 10); - addRenderableWidget(this.guiCraftingPlan); - - if (this.craftingPlanFlat != null) { - addRenderableWidget(new ButtonText(leftPos + 8, topPos + 198, 62, 20, - Component.translatable("gui.integratedterminals.craftingplan.view.flat"), - Component.translatable("gui.integratedterminals.craftingplan.view.flat").withStyle(ChatFormatting.ITALIC), - (b) -> { - this.guiCraftingPlanToggler.setCraftingPlanDisplayMode(GuiCraftingPlanToggler.CraftingPlanDisplayMode.FLAT); - this.init(); - }, - true)); + GuiCraftingPlanGraph previousGraph = this.guiCraftingPlanGraph; + this.guiCraftingPlanGraph = new GuiCraftingPlanGraph(this, this.craftingPlan, leftPos, topPos, 9, 18, + imageWidth - 18, imageHeight - 44, false); + if (previousGraph != null && previousGraph.getCraftingPlan() == this.craftingPlan) { + this.guiCraftingPlanGraph.inheritVisualizationState(previousGraph); } - }, - () -> { - this.guiCraftingPlanFlat = new GuiCraftingPlanFlat(this, this.craftingPlanFlat, leftPos, topPos, 9, 18, 10); - addRenderableWidget(this.guiCraftingPlanFlat); - - if (this.craftingPlan != null) { - addRenderableWidget(new ButtonText(leftPos + 8, topPos + 198, 62, 20, - Component.translatable("gui.integratedterminals.craftingplan.view.tree"), - Component.translatable("gui.integratedterminals.craftingplan.view.tree").withStyle(ChatFormatting.ITALIC), - (b) -> { - this.guiCraftingPlanToggler.setCraftingPlanDisplayMode(GuiCraftingPlanToggler.CraftingPlanDisplayMode.TREE); - this.init(); - }, - true)); + // Overlay buttons must be added before the graph, so that they receive clicks first + for (Button button : GuiCraftingPlanToggler.createGraphButtons(this.guiCraftingPlanGraph, this::init)) { + addRenderableWidget(button); } + setActiveView(this.guiCraftingPlanGraph); }, - () -> { - this.guiCraftingPlan = null; - this.guiCraftingPlanFlat = null; - } + () -> setActiveView(new GuiCraftingPlanFlat(this, this.craftingPlanFlat, leftPos, topPos, 9, 18, 10)), + () -> this.activeView = null ); } + protected void setActiveView(IGuiCraftingPlanView view) { + this.activeView = view; + addRenderableWidget(view); + } + @Override protected ResourceLocation constructGuiTexture() { return ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "textures/gui/crafting_plan.png"); @@ -97,45 +89,53 @@ public ResourceLocation getGuiTexture() { @Override public int getBaseXSize() { - return 256; + return this.guiCraftingPlanToggler.getWidth(this.width); } @Override public int getBaseYSize() { - return 222; + return this.guiCraftingPlanToggler.getHeight(this.height); } @Override public void init() { + // The display mode determines the gui size + this.guiCraftingPlanToggler.resolveDisplayMode(); super.init(); // Reset states this.renderables.clear(); this.children().clear(); - this.guiCraftingPlan = null; - this.guiCraftingPlanFlat = null; + this.activeView = null; + + for (Button button : this.guiCraftingPlanToggler.createViewButtons(leftPos + 8, topPos + imageHeight - 23, this::init)) { + addRenderableWidget(button); + } this.guiCraftingPlanToggler.init(); + if (this.guiCraftingPlanToggler.getCraftingPlanDisplayMode() != GuiCraftingPlanToggler.CraftingPlanDisplayMode.GRAPH) { + this.guiCraftingPlanGraph = null; + } - addRenderableWidget(new ButtonText(leftPos + 221 + 10 - 50 - 55, topPos + 198, 50, 20, + addRenderableWidget(new ButtonText(leftPos + imageWidth - 25 - 50 - 55, topPos + imageHeight - 24, 50, 20, Component.translatable("gui.integratedterminals.terminal_storage.step.back"), Component.translatable("gui.integratedterminals.terminal_storage.step.back"), (b) -> returnToCraftingOptionAmount(), true)); addRenderableWidget(Checkbox.builder(Component.translatable("gui.integratedterminals.terminal_storage.step.craft.notify"), font) - .pos(leftPos + 72, topPos + 200) + .pos(leftPos + 72, topPos + imageHeight - 22) .selected(getMenu().isNotifyOnCompletion()) .tooltip(Tooltip.create(Component.translatable("gui.integratedterminals.terminal_storage.step.craft.notify.info"))) .onValueChange((widget, selected) -> getMenu().setNotifyOnCompletion(selected)) .build()); - addRenderableWidget(buttonConfirm = new ButtonText(leftPos + 221 + 10 - 50, topPos + 198, 50, 20, + addRenderableWidget(buttonConfirm = new ButtonText(leftPos + imageWidth - 25 - 50, topPos + imageHeight - 24, 50, 20, Component.translatable("gui.integratedterminals.terminal_storage.step.craft"), Component.translatable("gui.integratedterminals.terminal_storage.step.craft").withStyle(ChatFormatting.YELLOW), createServerPressable(ContainerTerminalStorageCraftingPlanBase.BUTTON_START, (b) -> {}), true)); - buttonConfirm.active = (this.guiCraftingPlan != null && this.guiCraftingPlan.isValid()) || (this.guiCraftingPlanFlat != null && this.guiCraftingPlanFlat.isValid()); + buttonConfirm.active = this.activeView != null && this.activeView.isValid(); } @Override @@ -144,7 +144,7 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { returnToCraftingOptionAmount(); return true; } - if (this.guiCraftingPlan != null && this.guiCraftingPlan.isValid() + if (this.activeView != null && this.activeView.isValid() && (typedChar == GLFW.GLFW_KEY_ENTER || typedChar == GLFW.GLFW_KEY_KP_ENTER)) { buttonConfirm.onPress(); return true; @@ -173,57 +173,58 @@ private void returnToTerminalStorage() { @Override protected void renderBg(GuiGraphics guiGraphics, float partialTicks, int mouseX, int mouseY) { - super.renderBg(guiGraphics, partialTicks, mouseX, mouseY); - if (this.guiCraftingPlan != null) { - guiCraftingPlan.drawGuiContainerBackgroundLayer(guiGraphics, partialTicks, mouseX, mouseY); - } else if (this.guiCraftingPlanFlat != null) { - guiCraftingPlanFlat.drawGuiContainerBackgroundLayer(guiGraphics, partialTicks, mouseX, mouseY); + if (this.guiCraftingPlanToggler.getCraftingPlanDisplayMode() == GuiCraftingPlanToggler.CraftingPlanDisplayMode.GRAPH) { + ContainerScreenTerminalStorage.drawPanel(guiGraphics, TEXTURE_PANEL, leftPos, topPos, imageWidth, imageHeight); + } else { + super.renderBg(guiGraphics, partialTicks, mouseX, mouseY); + } + if (this.activeView != null) { + activeView.drawGuiContainerBackgroundLayer(guiGraphics, partialTicks, mouseX, mouseY); } else { guiGraphics.drawCenteredString(font, L10NHelpers.localize("gui.integratedterminals.terminal_storage.step.crafting_plan_calculating"), - leftPos + getBaseXSize() / 2, topPos + 23, 16777215); + leftPos + imageWidth / 2, topPos + 23, 16777215); } } @Override protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { // super.drawGuiContainerForegroundLayer(matrixStack, mouseX, mouseY); - if (this.guiCraftingPlan != null) { - guiCraftingPlan.drawGuiContainerForegroundLayer(guiGraphics, mouseX, mouseY); - } else if (this.guiCraftingPlanFlat != null) { - guiCraftingPlanFlat.drawGuiContainerForegroundLayer(guiGraphics, mouseX, mouseY); + if (this.activeView != null) { + activeView.drawGuiContainerForegroundLayer(guiGraphics, mouseX, mouseY); } } @Override protected void drawCurrentScreen(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { super.drawCurrentScreen(guiGraphics, mouseX, mouseY, partialTicks); - if (this.guiCraftingPlan != null) { - guiCraftingPlan.render(guiGraphics, mouseX, mouseY, partialTicks); - } else if (this.guiCraftingPlanFlat != null) { - guiCraftingPlanFlat.render(guiGraphics, mouseX, mouseY, partialTicks); + if (this.activeView != null) { + activeView.render(guiGraphics, mouseX, mouseY, partialTicks); } } @Override public boolean mouseScrolled(double mouseX, double mouseY, double mouseZ, double delta) { - if (this.guiCraftingPlan != null) { - return guiCraftingPlan.mouseScrolled(mouseX, mouseY, mouseZ, delta); - } else if (this.guiCraftingPlanFlat != null) { - return guiCraftingPlanFlat.mouseScrolled(mouseX, mouseY, mouseZ, delta); + if (this.activeView != null) { + return activeView.mouseScrolled(mouseX, mouseY, mouseZ, delta); } return super.mouseScrolled(mouseX, mouseY, mouseZ, delta); } @Override public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, double mouseXPrev, double mouseYPrev) { - if (this.guiCraftingPlan != null) { - return guiCraftingPlan.mouseDragged(mouseX, mouseY, mouseButton, mouseXPrev, mouseYPrev); - } else if (this.guiCraftingPlanFlat != null) { - return guiCraftingPlanFlat.mouseDragged(mouseX, mouseY, mouseButton, mouseXPrev, mouseYPrev); + if (this.activeView != null) { + return activeView.mouseDragged(mouseX, mouseY, mouseButton, mouseXPrev, mouseYPrev); } return super.mouseDragged(mouseX, mouseY, mouseButton, mouseXPrev, mouseYPrev); } + @Override + public boolean mouseReleased(double mouseX, double mouseY, int mouseButton) { + // The views are not hit-tested by the default event routing, so they need to be notified explicitly + boolean handled = this.activeView != null && activeView.mouseReleased(mouseX, mouseY, mouseButton); + return super.mouseReleased(mouseX, mouseY, mouseButton) || handled; + } + @Override public void onUpdate(int valueId, CompoundTag value) { super.onUpdate(valueId, value); diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlan.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlan.java index 2ec37f276..49ed22b55 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlan.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlan.java @@ -56,7 +56,7 @@ * * @author rubensworks */ -public class GuiCraftingPlan extends AbstractWidget { +public class GuiCraftingPlan extends AbstractWidget implements IGuiCraftingPlanView { public static final int ELEMENT_WIDTH = 221; /** diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanFlat.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanFlat.java index 8da0399ba..5643f73b3 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanFlat.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanFlat.java @@ -41,7 +41,7 @@ * * @author rubensworks */ -public class GuiCraftingPlanFlat extends AbstractWidget { +public class GuiCraftingPlanFlat extends AbstractWidget implements IGuiCraftingPlanView { private static final int COLUMNS = 2; private static final int COLUMN_PADDING = 2; diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanGraph.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanGraph.java new file mode 100644 index 000000000..214351ddb --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanGraph.java @@ -0,0 +1,631 @@ +package org.cyclops.integratedterminals.client.gui.container.component; + +import com.google.common.collect.Lists; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundEvents; +import org.cyclops.commoncapabilities.api.ingredient.IPrototypedIngredient; +import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; +import org.cyclops.cyclopscore.helper.GuiHelpers; +import org.cyclops.cyclopscore.helper.L10NHelpers; +import org.cyclops.cyclopscore.helper.RenderHelpers; +import org.cyclops.integratedterminals.Capabilities; +import org.cyclops.integratedterminals.api.terminalstorage.crafting.ITerminalCraftingPlan; +import org.cyclops.integratedterminals.api.terminalstorage.crafting.TerminalCraftingJobStatus; +import org.cyclops.integratedterminals.client.gui.container.ContainerScreenTerminalStorage; + +import javax.annotation.Nullable; +import java.util.List; +import java.util.Locale; + +import static org.cyclops.integratedterminals.client.gui.container.component.CraftingPlanGraphLayout.NODE_SIZE; + +/** + * A gui component for visualizing a crafting plan as a pannable and zoomable graph, + * with the requested output on the left and its ingredients to the right. + * + * The using gui must call the methods listed in {@link IGuiCraftingPlanView}. + * + * @author rubensworks + */ +public class GuiCraftingPlanGraph extends AbstractWidget implements IGuiCraftingPlanView { + + public static final int OVERLAY_BUTTON_SIZE = 15; + public static final int OVERLAY_BUTTON_COUNT = 4; + private static final int OVERLAY_MARGIN = 3; + private static final int LEGEND_MIN_WIDTH = 200; + private static final int GRID_SIZE = 16; + private static final int CLICK_DISTANCE = 2; + + private static final int COLOR_BACKGROUND = 0xFF2B2B2B; + private static final int COLOR_GRID = 0x0CFFFFFF; + private static final int COLOR_BEVEL_DARK = 0xFF373737; + private static final int COLOR_BEVEL_LIGHT = 0xFFFFFFFF; + private static final int COLOR_EDGE = 0xFFDADADA; + private static final int COLOR_EDGE_MISSING = 0xFFFF5555; + private static final int COLOR_EDGE_CRAFTING = 0xFF2BAEE7; + private static final int COLOR_NODE_OUTLINE = 0xFF000000; + private static final int COLOR_NODE_HIGHLIGHT = 0x80FFFFFF; + private static final int COLOR_NODE_SHADOW = 0x40000000; + private static final int COLOR_HOVER = 0xFFFFFFFF; + private static final int COLOR_BADGE = 0xFF1E1E1E; + private static final int COLOR_TEXT = 0xFFFFFFFF; + private static final int COLOR_TEXT_HINT = 0xFFBDBDBD; + private static final int COLOR_TEXT_BACKGROUND = 0xB02B2B2B; + + private static final TerminalCraftingJobStatus[] LEGEND_STATUSES = { + TerminalCraftingJobStatus.FINISHED, + TerminalCraftingJobStatus.CRAFTING, + TerminalCraftingJobStatus.PENDING_DEPENDENCIES, + TerminalCraftingJobStatus.INVALID + }; + private static final String[] LEGEND_LABELS = {"done", "crafting", "waiting", "missing"}; + + private final AbstractContainerScreen parentGui; + private final ITerminalCraftingPlan craftingPlan; + private final int guiLeft; + private final int guiTop; + private final int viewWidth; + private final int viewHeight; + private final boolean showLegend; + private final List elements; + private final boolean valid; + private final GuiCraftingPlanHeader header; + private final CraftingPlanGraphCamera camera; + + private CraftingPlanGraphLayout layout; + + private boolean pressing; + private double dragDistance; + @Nullable + private GuiCraftingPlan.Element pressedElement; + + /** + * @param parentGui The gui this is drawn in. + * @param craftingPlan The plan to visualize. + * @param guiLeft The gui X position. + * @param guiTop The gui Y position. + * @param x The viewport X position, relative to the gui. + * @param y The viewport Y position, relative to the gui. + * @param viewWidth The viewport width. + * @param viewHeight The viewport height. + * @param showLegend If a status legend should be shown instead of the controls hint. + */ + public GuiCraftingPlanGraph(AbstractContainerScreen parentGui, ITerminalCraftingPlan craftingPlan, int guiLeft, int guiTop, + int x, int y, int viewWidth, int viewHeight, boolean showLegend) { + super(x, y, 0, 0, Component.literal("")); + this.parentGui = parentGui; + this.craftingPlan = craftingPlan; + this.guiLeft = guiLeft; + this.guiTop = guiTop; + this.viewWidth = viewWidth; + this.viewHeight = viewHeight; + this.showLegend = showLegend; + this.elements = GuiCraftingPlan.getElements(craftingPlan); + pruneEmptyLeaves(this.elements.get(0)); + this.valid = craftingPlan.getStatus().isValid(); + this.header = new GuiCraftingPlanHeader(craftingPlan.getUnlocalizedLabel(), craftingPlan.getStatus(), + craftingPlan.getTickDuration(), craftingPlan.getEstimatedTickDurationTotal(), + craftingPlan.getEstimatedTickDurationRemaining(), craftingPlan.getChannel(), craftingPlan.getInitiatorName()); + this.camera = new CraftingPlanGraphCamera(); + refreshLayout(); + this.camera.reset(layout.getBounds(), getInnerWidth(), getInnerHeight()); + } + + /** + * Remove leaves that have nothing to show, such as running jobs whose outputs have become empty. + * @param element The element to prune the children of. + */ + protected static void pruneEmptyLeaves(GuiCraftingPlan.Element element) { + element.getChildren().removeIf(child -> { + pruneEmptyLeaves(child); + return child.getChildren().isEmpty() && child.getOutputs().stream() + .allMatch(alternatives -> alternatives.stream().allMatch(GuiCraftingPlanGraph::isEmpty)); + }); + } + + protected static boolean isEmpty(IPrototypedIngredient ingredient) { + return ((IngredientComponent) ingredient.getComponent()).getMatcher().isEmpty(ingredient.getPrototype()); + } + + public ITerminalCraftingPlan getCraftingPlan() { + return craftingPlan; + } + + public void inheritVisualizationState(GuiCraftingPlanGraph previous) { + // Inherit toggle state + IntOpenHashSet disabledElementIds = new IntOpenHashSet(); + for (GuiCraftingPlan.Element element : previous.elements) { + if (!element.isEnabled()) { + disabledElementIds.add(element.getId()); + } + } + for (GuiCraftingPlan.Element element : this.elements) { + if (disabledElementIds.contains(element.getId())) { + element.setEnabled(false); + } + } + refreshLayout(); + + // Inherit camera, keeping the center of the view in place if the viewport was resized + this.camera.copyFrom(previous.camera); + this.camera.pan((getInnerWidth() - previous.getInnerWidth()) / 2D, (getInnerHeight() - previous.getInnerHeight()) / 2D); + this.camera.clamp(layout.getBounds(), getInnerWidth(), getInnerHeight()); + } + + protected void refreshLayout() { + this.layout = CraftingPlanGraphLayout.create(elements.get(0)); + } + + protected int getInnerLeft() { + return guiLeft + getX() + 1; + } + + protected int getInnerTop() { + return guiTop + getY() + 1; + } + + protected int getInnerWidth() { + return viewWidth - 2; + } + + protected int getInnerHeight() { + return viewHeight - 2; + } + + /** + * @return The absolute X position of the overlay buttons. + */ + public int getOverlayButtonX() { + return getInnerLeft() + getInnerWidth() - OVERLAY_MARGIN - OVERLAY_BUTTON_SIZE; + } + + /** + * @param index The button index, from top to bottom. + * @return The absolute Y position of an overlay button. + */ + public int getOverlayButtonY(int index) { + return getInnerTop() + OVERLAY_MARGIN + index * (OVERLAY_BUTTON_SIZE + 1); + } + + protected boolean isInViewport(double mouseX, double mouseY) { + double viewX = mouseX - getInnerLeft(); + double viewY = mouseY - getInnerTop(); + return viewX >= 0 && viewY >= 0 && viewX < getInnerWidth() && viewY < getInnerHeight() + && !(mouseX >= getOverlayButtonX() && mouseX < getOverlayButtonX() + OVERLAY_BUTTON_SIZE + && mouseY >= getOverlayButtonY(0) && mouseY < getOverlayButtonY(OVERLAY_BUTTON_COUNT)); + } + + @Nullable + protected CraftingPlanGraphLayout.Placement getPlacementAt(double mouseX, double mouseY) { + if (!isInViewport(mouseX, mouseY)) { + return null; + } + // Node rectangles are drawn from rounded view positions, so hit testing uses the same rounding + for (CraftingPlanGraphLayout.Placement placement : layout.getPlacements()) { + if (mouseX >= toViewX(placement.getX()) && mouseX < toViewX(placement.getX() + NODE_SIZE) + && mouseY >= toViewY(placement.getY()) && mouseY < toViewY(placement.getY() + NODE_SIZE)) { + return placement; + } + } + return null; + } + + /** + * @param graphX An X position in graph space. + * @return The absolute X position on screen, in whole pixels. + */ + protected int toViewX(double graphX) { + return getInnerLeft() + (int) Math.round(camera.getOriginX()) + (int) Math.floor(graphX * camera.getZoom()); + } + + /** + * @param graphY A Y position in graph space. + * @return The absolute Y position on screen, in whole pixels. + */ + protected int toViewY(double graphY) { + return getInnerTop() + (int) Math.round(camera.getOriginY()) + (int) Math.floor(graphY * camera.getZoom()); + } + + protected int getTick() { + return (int) Minecraft.getInstance().level.getGameTime() / GuiCraftingPlan.TICK_DELAY; + } + + @Nullable + protected IPrototypedIngredient getShownOutput(GuiCraftingPlan.Element element) { + if (element.getOutputs().isEmpty()) { + return null; + } + List> alternatives = element.getOutputs().get(0); + return alternatives.isEmpty() ? null : alternatives.get(getTick() % alternatives.size()); + } + + protected static long getQuantity(IPrototypedIngredient ingredient) { + return ((IngredientComponent) ingredient.getComponent()).getMatcher().getQuantity(ingredient.getPrototype()); + } + + protected static boolean isFailed(TerminalCraftingJobStatus status) { + return status == TerminalCraftingJobStatus.INVALID || status == TerminalCraftingJobStatus.ERROR + || status == TerminalCraftingJobStatus.INVALID_INPUTS; + } + + protected static boolean isCollapsed(GuiCraftingPlan.Element element) { + for (GuiCraftingPlan.Element child : element.getChildren()) { + if (!child.isEnabled()) { + return true; + } + } + return false; + } + + protected String getLabel(GuiCraftingPlan.Element element, IPrototypedIngredient output) { + if (camera.getZoom() < 0.75F) { + return ""; + } + long total = element.getCraftQuantityTotal(); + long remaining = element.getCraftQuantity(); + if (total > remaining) { + return GuiHelpers.quantityToScaledString(total - remaining) + "/" + GuiHelpers.quantityToScaledString(total); + } + return GuiHelpers.quantityToScaledString(getQuantity(output)); + } + + @Override + public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { + + } + + @Override + public void drawGuiContainerBackgroundLayer(GuiGraphics guiGraphics, float partialTicks, int mouseX, int mouseY) { + int left = guiLeft + getX(); + int top = guiTop + getY(); + int innerLeft = getInnerLeft(); + int innerTop = getInnerTop(); + int innerRight = innerLeft + getInnerWidth(); + int innerBottom = innerTop + getInnerHeight(); + + header.drawBackground(guiGraphics, left, top, viewWidth); + + // Inset viewport + guiGraphics.fill(left, top, left + viewWidth, top + viewHeight, COLOR_BEVEL_LIGHT); + guiGraphics.fill(left, top, left + viewWidth - 1, top + viewHeight - 1, COLOR_BEVEL_DARK); + guiGraphics.fill(innerLeft, innerTop, innerRight, innerBottom, COLOR_BACKGROUND); + + guiGraphics.enableScissor(innerLeft, innerTop, innerRight, innerBottom); + + // Grid that moves along with the graph + int gridSize = Math.round(GRID_SIZE * camera.getZoom()); + int gridOffsetX = Math.floorMod((int) Math.round(camera.getOriginX()), gridSize); + int gridOffsetY = Math.floorMod((int) Math.round(camera.getOriginY()), gridSize); + for (int x = innerLeft + gridOffsetX; x < innerRight; x += gridSize) { + guiGraphics.fill(x, innerTop, x + 1, innerBottom, COLOR_GRID); + } + for (int y = innerTop + gridOffsetY; y < innerBottom; y += gridSize) { + guiGraphics.fill(innerLeft, y, innerRight, y + 1, COLOR_GRID); + } + + // Edges + int thickness = camera.getZoom() >= 1.5F ? 2 : 1; + for (CraftingPlanGraphLayout.Segment segment : layout.getSegments()) { + int color = COLOR_EDGE; + if (segment.getTarget() != null) { + if (isFailed(segment.getTarget().getStatus())) { + color = COLOR_EDGE_MISSING; + } else if (segment.getTarget().getStatus() == TerminalCraftingJobStatus.CRAFTING) { + color = COLOR_EDGE_CRAFTING; + } + } + if (segment.getY1() == segment.getY2()) { + int y = toViewY(segment.getY1()); + guiGraphics.fill(toViewX(segment.getX1()), y, toViewX(segment.getX2() + 1), y + thickness, color); + } else { + int x = toViewX(segment.getX1()); + guiGraphics.fill(x, toViewY(segment.getY1()), x + thickness, toViewY(segment.getY2()) + thickness, color); + } + } + + // Nodes + CraftingPlanGraphLayout.Placement hovered = getPlacementAt(mouseX, mouseY); + for (CraftingPlanGraphLayout.Placement placement : layout.getPlacements()) { + int x0 = toViewX(placement.getX()); + int y0 = toViewY(placement.getY()); + int x1 = toViewX(placement.getX() + NODE_SIZE); + int y1 = toViewY(placement.getY() + NODE_SIZE); + // Skip nodes outside the viewport, with some slack for the collapsed badge + if (x1 + NODE_SIZE < innerLeft || x0 > innerRight || y1 < innerTop || y0 > innerBottom) { + continue; + } + drawNode(guiGraphics, placement, x0, y0, x1, y1, placement == hovered, partialTicks, mouseX, mouseY); + } + + guiGraphics.flush(); + guiGraphics.disableScissor(); + + if (showLegend) { + drawLegend(guiGraphics, innerLeft, innerBottom); + } else { + drawHint(guiGraphics, innerLeft, innerBottom); + } + } + + protected void drawNode(GuiGraphics guiGraphics, CraftingPlanGraphLayout.Placement placement, + int x0, int y0, int x1, int y1, boolean hovered, float partialTicks, int mouseX, int mouseY) { + GuiCraftingPlan.Element element = placement.getNode(); + float zoom = camera.getZoom(); + + // Frame + if (hovered) { + guiGraphics.fill(x0 - 1, y0 - 1, x1 + 1, y1 + 1, COLOR_HOVER); + } + guiGraphics.fill(x0, y0, x1, y1, COLOR_NODE_OUTLINE); + guiGraphics.fill(x0 + 1, y0 + 1, x1 - 1, y1 - 1, element.getColor() | 0xFF000000); + guiGraphics.fill(x0 + 1, y0 + 1, x1 - 1, y0 + 2, COLOR_NODE_HIGHLIGHT); + guiGraphics.fill(x0 + 1, y0 + 2, x0 + 2, y1 - 1, COLOR_NODE_HIGHLIGHT); + guiGraphics.fill(x0 + 2, y1 - 2, x1 - 1, y1 - 1, COLOR_NODE_SHADOW); + guiGraphics.fill(x1 - 2, y0 + 2, x1 - 1, y1 - 2, COLOR_NODE_SHADOW); + + // Ingredient + IPrototypedIngredient output = getShownOutput(element); + if (output != null) { + IngredientComponent ingredientComponent = output.getComponent(); + long quantity = getQuantity(output); + String label = getLabel(element, output); + guiGraphics.pose().pushPose(); + guiGraphics.pose().translate((x0 + x1) / 2F, (y0 + y1) / 2F, 0); + guiGraphics.pose().scale(zoom, zoom, 1); + ingredientComponent.getCapability(Capabilities.IngredientComponentTerminalStorageHandler.INGREDIENT) + .ifPresent(h -> h.drawInstance(guiGraphics, output.getPrototype(), quantity, label, this.parentGui, + ContainerScreenTerminalStorage.DrawLayer.BACKGROUND, partialTicks, -8, -8, mouseX, mouseY, null)); + guiGraphics.pose().popPose(); + } + + // Markers are drawn in node space, in front of the ingredient + boolean failed = isFailed(element.getStatus()); + int hidden = placement.getHiddenDescendants(); + if (failed || hidden > 0) { + Font font = Minecraft.getInstance().font; + guiGraphics.pose().pushPose(); + guiGraphics.pose().translate(x0, y0, 300); + guiGraphics.pose().scale(zoom, zoom, 1); + if (failed) { + guiGraphics.fill(1, 1, 7, 11, COLOR_BADGE); + guiGraphics.drawString(font, "!", 3, 2, 0xFFFF5555, false); + } + if (hidden > 0) { + String badge = "+" + hidden; + int badgeWidth = font.width(badge) + 3; + int badgeX = NODE_SIZE - 2; + int badgeY = NODE_SIZE / 2 - 5; + guiGraphics.fill(badgeX, badgeY, badgeX + badgeWidth, badgeY + 10, COLOR_TEXT_HINT); + guiGraphics.fill(badgeX + 1, badgeY + 1, badgeX + badgeWidth - 1, badgeY + 9, COLOR_BADGE); + guiGraphics.drawString(font, badge, badgeX + 2, badgeY + 1, COLOR_TEXT, false); + } + guiGraphics.pose().popPose(); + } + } + + protected void drawHint(GuiGraphics guiGraphics, int innerLeft, int innerBottom) { + String hint = L10NHelpers.localize("gui.integratedterminals.craftingplan.graph.hint"); + int width = Minecraft.getInstance().font.width(hint) / 2; + guiGraphics.fill(innerLeft, innerBottom - 7, innerLeft + width + 4, innerBottom, COLOR_TEXT_BACKGROUND); + RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), Minecraft.getInstance().font, hint, + innerLeft + 2, innerBottom - 5, 0.5F, COLOR_TEXT_HINT, true, Font.DisplayMode.NORMAL); + } + + protected void drawLegend(GuiGraphics guiGraphics, int innerLeft, int innerBottom) { + if (viewWidth < LEGEND_MIN_WIDTH) { + return; + } + Font font = Minecraft.getInstance().font; + int width = 2; + for (String label : LEGEND_LABELS) { + width += 5 + font.width(L10NHelpers.localize("gui.integratedterminals.craftingplan.graph.legend." + label)) / 2 + 4; + } + guiGraphics.fill(innerLeft, innerBottom - 7, innerLeft + width, innerBottom, COLOR_TEXT_BACKGROUND); + int x = innerLeft + 2; + for (int i = 0; i < LEGEND_STATUSES.length; i++) { + String label = L10NHelpers.localize("gui.integratedterminals.craftingplan.graph.legend." + LEGEND_LABELS[i]); + guiGraphics.fill(x, innerBottom - 5, x + 3, innerBottom - 2, LEGEND_STATUSES[i].getColor() | 0xFF000000); + x += 5; + RenderHelpers.drawScaledString(guiGraphics.pose(), guiGraphics.bufferSource(), font, label, + x, innerBottom - 5, 0.5F, COLOR_TEXT_HINT, true, Font.DisplayMode.NORMAL); + x += font.width(label) / 2 + 4; + } + } + + @Override + public void drawGuiContainerForegroundLayer(GuiGraphics guiGraphics, int mouseX, int mouseY) { + header.drawForeground(this.parentGui, guiGraphics, getX(), getY(), mouseX, mouseY); + + CraftingPlanGraphLayout.Placement hovered = getPlacementAt(mouseX, mouseY); + if (hovered != null) { + GuiCraftingPlan.Element element = hovered.getNode(); + IPrototypedIngredient output = getShownOutput(element); + if (output != null) { + // The handler only shows a tooltip when the mouse is inside a slot-sized region, + // so center that region on the mouse, as nodes can be any size depending on the zoom level. + int x = mouseX - parentGui.getGuiLeft() - GuiHelpers.SLOT_SIZE_INNER / 2; + int y = mouseY - parentGui.getGuiTop() - GuiHelpers.SLOT_SIZE_INNER / 2; + List lines = getTooltipLines(hovered); + output.getComponent().getCapability(Capabilities.IngredientComponentTerminalStorageHandler.INGREDIENT) + .ifPresent(h -> h.drawInstance(guiGraphics, output.getPrototype(), getQuantity(output), "", this.parentGui, + ContainerScreenTerminalStorage.DrawLayer.FOREGROUND, 0, x, y, mouseX, mouseY, lines)); + } + } + } + + protected List getTooltipLines(CraftingPlanGraphLayout.Placement placement) { + GuiCraftingPlan.Element element = placement.getNode(); + List lines = Lists.newArrayList(); + + // Status + String statusKey = "gui.integratedterminals.craftingplan.status." + element.getStatus().name().toLowerCase(Locale.ENGLISH); + lines.add(Component.translatable("gui.integratedterminals.craftingplan.status", Component.translatable(statusKey)) + .withStyle(getStatusFormatting(element.getStatus()))); + lines.add(Component.translatable(statusKey + ".desc").withStyle(ChatFormatting.GRAY)); + + // Quantities + if (element.getStorageQuantity() > 0) { + lines.add(Component.translatable("gui.integratedterminals.terminal_storage.stored", element.getStorageQuantity()) + .withStyle(ChatFormatting.GRAY)); + } + if (element.getCraftQuantity() > 0) { + lines.add(Component.translatable("gui.integratedterminals.terminal_storage.crafting", element.getCraftQuantity()) + .withStyle(ChatFormatting.GRAY)); + } + if (element.getMissingQuantity() > 0) { + lines.add(Component.translatable("gui.integratedterminals.terminal_storage.missing", element.getMissingQuantity()) + .withStyle(ChatFormatting.RED)); + } + + // Other outputs + if (element.getOutputs().size() > 1) { + lines.add(Component.translatable("gui.integratedterminals.craftingplan.graph.outputs").withStyle(ChatFormatting.GRAY)); + int tick = getTick(); + for (List> alternatives : element.getOutputs()) { + IPrototypedIngredient output = alternatives.get(tick % alternatives.size()); + String name = output.getComponent().getMatcher().localize(output.getPrototype()); + lines.add(Component.literal(" " + GuiHelpers.quantityToScaledString(getQuantity(output)) + " " + name) + .withStyle(ChatFormatting.GRAY)); + } + } + + // Collapsing + if (!element.getChildren().isEmpty()) { + if (isCollapsed(element)) { + lines.add(Component.translatable("gui.integratedterminals.craftingplan.graph.collapsed", placement.getHiddenDescendants()) + .withStyle(ChatFormatting.DARK_GRAY, ChatFormatting.ITALIC)); + lines.add(Component.translatable("gui.integratedterminals.craftingplan.graph.expand") + .withStyle(ChatFormatting.DARK_GRAY, ChatFormatting.ITALIC)); + } else { + lines.add(Component.translatable("gui.integratedterminals.craftingplan.graph.collapse") + .withStyle(ChatFormatting.DARK_GRAY, ChatFormatting.ITALIC)); + } + } + return lines; + } + + protected static ChatFormatting getStatusFormatting(TerminalCraftingJobStatus status) { + if (isFailed(status)) { + return ChatFormatting.RED; + } + return switch (status) { + case FINISHED -> ChatFormatting.GREEN; + case CRAFTING -> ChatFormatting.AQUA; + case UNSTARTED -> ChatFormatting.WHITE; + default -> ChatFormatting.YELLOW; + }; + } + + public void zoomIn() { + zoom(1); + } + + public void zoomOut() { + zoom(-1); + } + + protected void zoom(int direction) { + camera.zoomAt(getInnerWidth() / 2D, getInnerHeight() / 2D, direction); + camera.clamp(layout.getBounds(), getInnerWidth(), getInnerHeight()); + } + + public void fit() { + camera.fit(layout.getBounds(), getInnerWidth(), getInnerHeight()); + } + + public boolean canZoomIn() { + return camera.canZoomIn(); + } + + public boolean canZoomOut() { + return camera.canZoomOut(); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { + if (mouseButton != 0 || !isInViewport(mouseX, mouseY)) { + return false; + } + CraftingPlanGraphLayout.Placement placement = getPlacementAt(mouseX, mouseY); + this.pressing = true; + this.dragDistance = 0; + this.pressedElement = placement != null ? placement.getNode() : null; + return true; + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, double offsetX, double offsetY) { + if (!pressing) { + return false; + } + camera.pan(offsetX, offsetY); + camera.clamp(layout.getBounds(), getInnerWidth(), getInnerHeight()); + dragDistance += Math.abs(offsetX) + Math.abs(offsetY); + return true; + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int mouseButton) { + if (!pressing) { + return false; + } + this.pressing = false; + GuiCraftingPlan.Element element = this.pressedElement; + this.pressedElement = null; + if (dragDistance < CLICK_DISTANCE && element != null && !element.getChildren().isEmpty()) { + CraftingPlanGraphLayout.Placement released = getPlacementAt(mouseX, mouseY); + if (released != null && released.getNode() == element) { + Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0F)); + toggleChildren(released); + } + } + return true; + } + + protected void toggleChildren(CraftingPlanGraphLayout.Placement placement) { + GuiCraftingPlan.Element element = placement.getNode(); + boolean expand = isCollapsed(element); + for (GuiCraftingPlan.Element child : element.getChildren()) { + child.setEnabled(expand); + } + int oldY = placement.getY(); + refreshLayout(); + + // Keep the toggled node under the mouse + for (CraftingPlanGraphLayout.Placement newPlacement : layout.getPlacements()) { + if (newPlacement.getNode() == element) { + camera.pan(0, (oldY - newPlacement.getY()) * camera.getZoom()); + } + } + camera.clamp(layout.getBounds(), getInnerWidth(), getInnerHeight()); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + if (!isInViewport(mouseX, mouseY) || scrollY == 0) { + return false; + } + camera.zoomAt(mouseX - getInnerLeft(), mouseY - getInnerTop(), (int) Math.signum(scrollY)); + camera.clamp(layout.getBounds(), getInnerWidth(), getInnerHeight()); + return true; + } + + @Override + public boolean isValid() { + return valid; + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { + + } +} diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanToggler.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanToggler.java index 3ee72e0fb..7303127c2 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanToggler.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/component/GuiCraftingPlanToggler.java @@ -1,47 +1,76 @@ package org.cyclops.integratedterminals.client.gui.container.component; +import com.google.common.collect.Lists; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Mth; +import org.cyclops.cyclopscore.client.gui.component.button.ButtonImage; +import org.cyclops.cyclopscore.client.gui.image.IImage; import org.cyclops.integratedterminals.GeneralConfig; import org.cyclops.integratedterminals.api.terminalstorage.crafting.ITerminalCraftingPlan; import org.cyclops.integratedterminals.api.terminalstorage.crafting.ITerminalCraftingPlanFlat; +import org.cyclops.integratedterminals.client.gui.image.Images; +import java.util.List; +import java.util.function.BooleanSupplier; import java.util.function.Supplier; /** - * A helper class that allows toggling between a tree-based and flattened crafting plan view. + * A helper class that allows toggling between a tree-based, graph-based and flattened crafting plan view. * @author rubensworks */ public class GuiCraftingPlanToggler { + public static final int DEFAULT_WIDTH = 256; + public static final int DEFAULT_HEIGHT = 222; + private static final int GRAPH_MAX_WIDTH = 400; + private static final int GRAPH_MAX_HEIGHT = 300; + private static final int SCREEN_MARGIN = 16; + + /** + * If the graph view is shown fullscreen, shared by all crafting plan guis for the session. + */ + private static boolean graphFullscreen = false; + private final Supplier craftingPlanSupplier; private final Supplier craftingPlanFlatSupplier; private final Runnable initPlanTree; + private final Runnable initPlanGraph; private final Runnable initPlanFlat; private final Runnable deinit; private CraftingPlanDisplayMode craftingPlanDisplayMode; - public GuiCraftingPlanToggler(Supplier craftingPlanSupplier, Supplier craftingPlanFlatSupplier, Runnable initPlanTree, Runnable initPlanFlat, Runnable deinit) { + public GuiCraftingPlanToggler(Supplier craftingPlanSupplier, Supplier craftingPlanFlatSupplier, + Runnable initPlanTree, Runnable initPlanGraph, Runnable initPlanFlat, Runnable deinit) { this.craftingPlanSupplier = craftingPlanSupplier; this.craftingPlanFlatSupplier = craftingPlanFlatSupplier; this.initPlanTree = initPlanTree; + this.initPlanGraph = initPlanGraph; this.initPlanFlat = initPlanFlat; this.deinit = deinit; } - public void init() { + /** + * Determine the active display mode, without initializing it. + * This can be called before the gui size is determined. + */ + public void resolveDisplayMode() { // Determine available display modes if (this.craftingPlanDisplayMode == null) { if (this.craftingPlanFlatSupplier.get() != null) { if (this.craftingPlanSupplier.get() == null) { this.craftingPlanDisplayMode = CraftingPlanDisplayMode.FLAT; } else { - this.craftingPlanDisplayMode = GeneralConfig.terminalStorageDefaultToCraftingPlanTree ? CraftingPlanDisplayMode.TREE : CraftingPlanDisplayMode.FLAT; + this.craftingPlanDisplayMode = GeneralConfig.terminalStorageDefaultToCraftingPlanTree ? CraftingPlanDisplayMode.GRAPH : CraftingPlanDisplayMode.FLAT; } } else { if (this.craftingPlanSupplier.get() == null) { this.craftingPlanDisplayMode = CraftingPlanDisplayMode.NONE; } else { - this.craftingPlanDisplayMode = CraftingPlanDisplayMode.TREE; + this.craftingPlanDisplayMode = CraftingPlanDisplayMode.GRAPH; } } } @@ -50,10 +79,15 @@ public void init() { if (this.craftingPlanFlatSupplier.get() == null && this.craftingPlanSupplier.get() == null) { this.craftingPlanDisplayMode = CraftingPlanDisplayMode.NONE; } + } + + public void init() { + resolveDisplayMode(); // Prepare gui element for active display mode switch (this.craftingPlanDisplayMode) { case TREE -> this.initPlanTree.run(); + case GRAPH -> this.initPlanGraph.run(); case FLAT -> this.initPlanFlat.run(); case NONE -> this.deinit.run(); } @@ -67,9 +101,168 @@ public CraftingPlanDisplayMode getCraftingPlanDisplayMode() { return craftingPlanDisplayMode; } + public static boolean isGraphFullscreen() { + return graphFullscreen; + } + + public static void setGraphFullscreen(boolean graphFullscreen) { + GuiCraftingPlanToggler.graphFullscreen = graphFullscreen; + } + + /** + * @param screenWidth The scaled screen width. + * @return The gui width for the active display mode. + */ + public int getWidth(int screenWidth) { + if (this.craftingPlanDisplayMode != CraftingPlanDisplayMode.GRAPH) { + return DEFAULT_WIDTH; + } + if (graphFullscreen) { + return Math.max(DEFAULT_WIDTH, screenWidth - SCREEN_MARGIN); + } + return clamp(Math.round(screenWidth * 0.6F), DEFAULT_WIDTH, Math.min(GRAPH_MAX_WIDTH, screenWidth - SCREEN_MARGIN)); + } + + /** + * @param screenHeight The scaled screen height. + * @return The gui height for the active display mode. + */ + public int getHeight(int screenHeight) { + if (this.craftingPlanDisplayMode != CraftingPlanDisplayMode.GRAPH) { + return DEFAULT_HEIGHT; + } + if (graphFullscreen) { + return Math.max(DEFAULT_HEIGHT, screenHeight - SCREEN_MARGIN); + } + return clamp(Math.round(screenHeight * 0.8F), DEFAULT_HEIGHT, Math.min(GRAPH_MAX_HEIGHT, screenHeight - SCREEN_MARGIN)); + } + + protected static int clamp(int value, int min, int max) { + // The minimum wins on screens that are too small + return Math.max(min, Mth.clamp(value, min, max)); + } + + /** + * Create buttons for switching to each of the available display modes. + * @param x The X position of the first button. + * @param y The Y position of the buttons. + * @param onChange Called after the display mode was changed. + * @return The buttons. + */ + public List