Pin folder headers, cut layers and see through blocks in the Objects view

Folder headers stay pinned at the top of the object tree while scrolling,
one per nesting level; clicking one jumps to that folder. A faint slider
over the 3D view (or Page Up/Down) cuts away the top layers so floors of
large structures like the woodland mansion can be looked into. Clicking a
block in the overview list makes that block type see-through, so whatever
it hides shows behind it.

Model used: Claude Opus 5.5
This commit is contained in:
piexel committed 2026-10-19 21:39:42 +02:00
1 parent db3d42df26
commit 99258bfd98
9 files changed
+674 -29

No files matched your search

@@ -270,7 +270,7 @@ public final class MapWindow implements MapCanvas.Listener {
viewport.setCoveredLeftWidth(sidebarIsland.getWidth());
}
});
content.add(viewport, OBJECTS_CARD);
content.add(objectExplorer.view(), OBJECTS_CARD);
statusBars.add(objectExplorer.statusBar(), OBJECTS_CARD);
}
@@ -91,7 +91,7 @@ final class ObjectBrowser extends JPanel {
countLabel.setFont(StudioTheme.smallFont());
countLabel.setForeground(StudioTheme.SECONDARY_TEXT);
add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
add(StudioToolbar.scroll(tree), BorderLayout.CENTER);
add(StickyFolderHeaders.around(tree, StudioToolbar.scroll(tree)), BorderLayout.CENTER);
add(StudioToolbar.padded(countLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
}
@@ -27,12 +27,14 @@ import org.bukkit.util.BlockVector;
import javax.swing.JComponent;
import java.awt.EventQueue;
import java.awt.event.KeyEvent;
import java.awt.image.BufferedImage;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
@@ -46,13 +48,15 @@ import java.util.concurrent.atomic.AtomicLong;
final class ObjectExplorer {
private static final NamespacedKey TINT_BIOME = NamespacedKey.minecraft("plains");
private static final int UNKNOWN_BLOCK_COLOR = 0x8A8A8A;
private static final String CAMERA_HINT = "Drag to orbit · Shift-drag to pan · Scroll to zoom · R resets";
private static final String CAMERA_HINT = "Drag to orbit · Shift-drag to pan · Scroll to zoom · Page Up/Down cuts layers · R resets";
private final Engine startEngine;
private final UUID worldId;
private final ObjectBrowser browser = new ObjectBrowser(this::showObject);
private final ObjectViewport viewport = new ObjectViewport();
private final ObjectOverviewPanel overview = new ObjectOverviewPanel();
private final ObjectLayerCut layerCut = new ObjectLayerCut(shownLayers -> rebuildShownMesh());
private final JComponent viewWithLayerCut = layerCut.floatingOver(viewport);
private final ObjectOverviewPanel overview = new ObjectOverviewPanel(this::showSeeThrough);
private final StudioStatusBar statusBar = new StudioStatusBar();
private final ExecutorService loader = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris Object Loader");
@@ -61,11 +65,14 @@ final class ObjectExplorer {
return thread;
});
private final AtomicLong objectRevision = new AtomicLong();
private final AtomicLong meshRevision = new AtomicLong();
private final Map<String, BlockModel> models = new ConcurrentHashMap<>();
private final Runnable hotloadHook = this::reloadPack;
private volatile BlockTextures textures;
private volatile BiomeTintColors tintColors;
private String shownKey;
private LoadedObject shown;
private Set<String> seeThroughTypes = Set.of();
ObjectExplorer(Engine engine, UUID worldId) {
this.startEngine = engine;
@@ -73,6 +80,8 @@ final class ObjectExplorer {
GuiHost.interruptOnPluginShutdown(loader);
GuiHost.registerHotloadHook(hotloadHook);
statusBar.showPosition(CAMERA_HINT);
viewport.bindKey(KeyEvent.VK_PAGE_UP, "show-layer-above", () -> layerCut.stepBy(1));
viewport.bindKey(KeyEvent.VK_PAGE_DOWN, "hide-top-layer", () -> layerCut.stepBy(-1));
}
JComponent browser() {
@@ -83,6 +92,13 @@ final class ObjectExplorer {
return viewport;
}
/**
* The 3D view with the slider that cuts away its top layers.
*/
JComponent view() {
return viewWithLayerCut;
}
JComponent overview() {
return overview;
}
@@ -133,6 +149,7 @@ final class ObjectExplorer {
if (key == null) {
viewport.showMessage("This pack has no objects");
overview.clear();
layerCut.showHeight(1);
return;
}
browser.select(key);
@@ -141,6 +158,8 @@ final class ObjectExplorer {
private void showObject(String key) {
shownKey = key;
shown = null;
meshRevision.incrementAndGet();
long revision = objectRevision.incrementAndGet();
viewport.showMessage("Loading " + key + "...");
statusBar.showActivity("Loading " + key + "...");
@@ -170,18 +189,73 @@ final class ObjectExplorer {
});
ObjectMesh mesh = ObjectMesh.build(blocks, this::modelOf);
ObjectSummary summary = ObjectSummary.of(key, mesh.bounds(), blocks.values(), this::iconOf);
return new LoadedObject(mesh, summary);
return new LoadedObject(blocks, mesh, summary);
}
private void showLoaded(long revision, LoadedObject loaded) {
if (revision != objectRevision.get()) {
return;
}
shown = loaded;
seeThroughTypes = Set.of();
statusBar.showActivity("");
viewport.show(loaded.mesh());
overview.show(loaded.summary());
statusBar.showSummary(String.format(Locale.ROOT, "%s · %,d blocks · %,d faces drawn",
loaded.summary().key(), loaded.summary().blockCount(), loaded.mesh().faceCount()));
layerCut.showHeight(loaded.mesh().bounds().sizeY());
showMeshSummary(loaded.mesh());
}
private void showMeshSummary(ObjectMesh mesh) {
ObjectSummary summary = shown.summary();
String cut = layerCut.isCutting()
? String.format(Locale.ROOT, " · showing layers 1-%d of %d", layerCut.shownLayerCount(), layerCut.layerCount())
: "";
statusBar.showSummary(String.format(Locale.ROOT, "%s · %,d blocks · %,d faces drawn%s",
summary.key(), summary.blockCount(), mesh.faceCount(), cut));
}
private void showSeeThrough(Set<String> blockTypes) {
seeThroughTypes = blockTypes;
rebuildShownMesh();
}
// Blocks under the cut that were covered by the ones above get their hidden faces back, so each floor shows, and
// blocks behind see-through ones get theirs back too.
private void rebuildShownMesh() {
LoadedObject object = shown;
if (object == null) {
return;
}
long revision = meshRevision.incrementAndGet();
ObjectMesh.Bounds bounds = object.mesh().bounds();
boolean cutting = layerCut.isCutting();
int highestShownY = bounds.minY() + layerCut.shownLayerCount() - 1;
Set<String> seeThrough = seeThroughTypes;
loader.execute(() -> {
if (revision != meshRevision.get()) {
return;
}
ObjectMesh mesh = !cutting && seeThrough.isEmpty()
? object.mesh()
: ObjectMesh.build(cutting ? blocksUpTo(object.blocks(), highestShownY) : object.blocks(), this::modelOf, bounds,
block -> seeThrough.contains(block.getMaterial().getKey().getKey()));
EventQueue.invokeLater(() -> {
if (revision == meshRevision.get() && shown == object) {
viewport.replaceMesh(mesh);
showMeshSummary(mesh);
}
});
});
}
private static Map<BlockVector, BlockData> blocksUpTo(Map<BlockVector, BlockData> blocks, int highestY) {
Map<BlockVector, BlockData> below = new HashMap<>();
blocks.forEach((position, block) -> {
if (position.getBlockY() <= highestY) {
below.put(position, block);
}
});
return below;
}
private void showFailure(long revision, String key, Throwable error) {
@@ -191,6 +265,7 @@ final class ObjectExplorer {
statusBar.showActivity("");
viewport.showMessage("Could not load " + key);
overview.clear();
layerCut.showHeight(1);
statusBar.showError(error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage());
}
@@ -238,6 +313,6 @@ final class ObjectExplorer {
return texture.tinted(colors.colorAt(texture.biomeTint(), 0, 0));
}
private record LoadedObject(ObjectMesh mesh, ObjectSummary summary) {
private record LoadedObject(Map<BlockVector, BlockData> blocks, ObjectMesh mesh, ObjectSummary summary) {
}
}
@@ -0,0 +1,179 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSlider;
import javax.swing.SwingConstants;
import java.awt.AlphaComposite;
import java.awt.BorderLayout;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.function.IntConsumer;
/**
* A slim slider floating on the right of the 3D view that hides an object's top layers, to look inside buildings
* floor by floor. Most objects never need it, so it stays faint until the mouse comes near or a cut is active.
*/
final class ObjectLayerCut extends JPanel {
private static final long serialVersionUID = 4473820211915526120L;
private static final int PANEL_WIDTH = 30;
private static final int MAX_HEIGHT = 320;
private static final double HEIGHT_SHARE_OF_VIEW = 0.5D;
private static final int EDGE_MARGIN = 8;
private static final float FAINT_OPACITY = 0.28F;
private final JSlider slider = new JSlider(SwingConstants.VERTICAL, 1, 1, 1);
private final JLabel shownLayers = new JLabel(" ", SwingConstants.CENTER);
private final IntConsumer onLayersChanged;
private boolean resetting;
private boolean hovered;
/**
* @param onLayersChanged told how many layers from the bottom stay visible whenever the user moves the slider
*/
ObjectLayerCut(IntConsumer onLayersChanged) {
super(new BorderLayout(0, StudioTheme.scale(4)));
this.onLayersChanged = onLayersChanged;
setOpaque(false);
setToolTipText("Hide the top layers to look inside · Page Up / Page Down");
shownLayers.setFont(StudioTheme.smallFont());
shownLayers.setForeground(StudioTheme.SECONDARY_TEXT);
slider.setOpaque(false);
slider.setFocusable(false);
slider.setToolTipText(getToolTipText());
slider.addChangeListener(event -> {
showLayerLabel();
if (!resetting) {
onLayersChanged.accept(slider.getValue());
}
});
MouseAdapter hover = new MouseAdapter() {
@Override
public void mouseEntered(MouseEvent event) {
setHovered(true);
}
@Override
public void mouseExited(MouseEvent event) {
setHovered(slider.getValueIsAdjusting());
}
@Override
public void mouseReleased(MouseEvent event) {
setHovered(slider.contains(event.getPoint()));
}
};
slider.addMouseListener(hover);
addMouseListener(hover);
add(shownLayers, BorderLayout.NORTH);
add(slider, BorderLayout.CENTER);
showHeight(1);
}
/**
* The view with this slider floating over its right edge, halfway down.
*/
JComponent floatingOver(JComponent view) {
ObjectLayerCut cut = this;
JPanel stack = new JPanel(null) {
private static final long serialVersionUID = -1950381405658102347L;
// The slider overlaps the view, so the view may not repaint without the slider on top of it.
@Override
public boolean isOptimizedDrawingEnabled() {
return false;
}
@Override
public void doLayout() {
view.setBounds(0, 0, getWidth(), getHeight());
int width = StudioTheme.scale(PANEL_WIDTH);
int height = Math.min(StudioTheme.scale(MAX_HEIGHT), (int) (getHeight() * HEIGHT_SHARE_OF_VIEW));
cut.setBounds(getWidth() - width - StudioTheme.scale(EDGE_MARGIN), (getHeight() - height) / 2, width, height);
}
};
stack.add(cut);
stack.add(view);
return stack;
}
/**
* Shows every layer of a newly chosen object without telling anyone, since nothing is hidden yet.
*/
void showHeight(int layers) {
resetting = true;
try {
slider.setMaximum(Math.max(1, layers));
slider.setValue(slider.getMaximum());
} finally {
resetting = false;
}
showLayerLabel();
setVisible(layers > 1);
}
void stepBy(int layers) {
if (isVisible()) {
slider.setValue(slider.getValue() + layers);
}
}
boolean isCutting() {
return slider.getValue() < slider.getMaximum();
}
int shownLayerCount() {
return slider.getValue();
}
int layerCount() {
return slider.getMaximum();
}
@Override
protected void paintChildren(Graphics graphics) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
if (!hovered && !isCutting()) {
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, FAINT_OPACITY));
}
super.paintChildren(canvas);
} finally {
canvas.dispose();
}
}
private void setHovered(boolean nowHovered) {
if (hovered != nowHovered) {
hovered = nowHovered;
repaint();
}
}
private void showLayerLabel() {
shownLayers.setText(isCutting() ? Integer.toString(slider.getValue()) : " ");
repaint();
}
}
@@ -27,6 +27,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Predicate;
/**
* The faces of an object's blocks that can be seen from outside. A face is left out when the neighbour on its side
@@ -34,22 +35,36 @@ import java.util.function.Function;
* kept apart because they are drawn last, blended over everything behind them.
*/
record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, Bounds bounds) {
private static final double SEE_THROUGH_OPACITY = 0.22D;
private static final double FULL_OPACITY = 1D;
static ObjectMesh build(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf) {
return build(blocks, modelOf, Bounds.of(blocks.keySet()), block -> false);
}
/**
* @param bounds the whole object's extent, kept when only part of it is drawn so the view does not jump
* @param isSeeThrough blocks drawn faintly, which hide nothing behind them, to look past them into the object
*/
static ObjectMesh build(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf, Bounds bounds,
Predicate<BlockData> isSeeThrough) {
List<Quad> solid = new ArrayList<>();
List<Quad> translucent = new ArrayList<>();
for (Map.Entry<BlockVector, BlockData> entry : blocks.entrySet()) {
BlockVector position = entry.getKey();
BlockData block = entry.getValue();
BlockModel model = modelOf.apply(block);
boolean seeThrough = isSeeThrough.test(block);
for (Face face : model.faces()) {
if (isHiddenByNeighbour(blocks, modelOf, position, block, model, face)) {
if (isHiddenByNeighbour(blocks, modelOf, isSeeThrough, position, block, model, face)) {
continue;
}
Quad quad = new Quad(face, position.getBlockX(), position.getBlockY(), position.getBlockZ());
(face.texture().isTranslucent() ? translucent : solid).add(quad);
Quad quad = new Quad(face, position.getBlockX(), position.getBlockY(), position.getBlockZ(),
seeThrough ? SEE_THROUGH_OPACITY : FULL_OPACITY);
(seeThrough || face.texture().isTranslucent() ? translucent : solid).add(quad);
}
}
return new ObjectMesh(List.copyOf(solid), List.copyOf(translucent), Bounds.of(blocks.keySet()));
return new ObjectMesh(List.copyOf(solid), List.copyOf(translucent), bounds);
}
boolean isEmpty() {
@@ -60,9 +75,11 @@ record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, Bounds bou
return solidQuads.size() + translucentQuads.size();
}
// Glass next to glass and water next to water hide the side between them too, as they do in the game.
// Glass next to glass and water next to water hide the side between them too, as they do in the game. A see-through
// block only hides sides of other see-through blocks, so whatever it covers shows through it.
private static boolean isHiddenByNeighbour(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf,
BlockVector position, BlockData block, BlockModel model, Face face) {
Predicate<BlockData> isSeeThrough, BlockVector position, BlockData block,
BlockModel model, Face face) {
CubeFace side = face.cullFace();
if (side == null) {
return false;
@@ -71,7 +88,7 @@ record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, Bounds bou
position.getBlockX() + side.stepX(),
position.getBlockY() + side.stepY(),
position.getBlockZ() + side.stepZ()));
if (neighbour == null) {
if (neighbour == null || (isSeeThrough.test(neighbour) && !isSeeThrough.test(block))) {
return false;
}
BlockModel neighbourModel = modelOf.apply(neighbour);
@@ -82,9 +99,9 @@ record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, Bounds bou
}
/**
* A model face placed at a block in the object.
* A model face placed at a block in the object, drawn at the given opacity on top of its texture's own.
*/
record Quad(Face face, int x, int y, int z) {
record Quad(Face face, int x, int y, int z, double opacity) {
double originX() {
return x + face.origin().x();
}
@@ -20,6 +20,8 @@ package com.volmit.iris.core.gui;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.SwingUtilities;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.JLabel;
@@ -28,18 +30,28 @@ import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.AlphaComposite;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Composite;
import java.awt.Cursor;
import java.awt.Insets;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Consumer;
/**
* The chosen object's size and the blocks it is made of, most used first, like the overview in Procedural Objects.
* Clicking a block makes it see-through in the 3D view, to look past walls into the object.
*/
final class ObjectOverviewPanel extends JPanel {
private static final long serialVersionUID = -8811407613645718290L;
@@ -50,6 +62,8 @@ final class ObjectOverviewPanel extends JPanel {
private static final int ROW_PADDING = 10;
private static final int SHARE_WIDTH = 92;
private static final int SHARE_BAR_HEIGHT = 4;
private static final float SEE_THROUGH_ICON_OPACITY = 0.3F;
private static final String SEE_THROUGH_HINT = "Click a block to see through it";
private final JLabel title = new JLabel(" ");
private final JLabel path = new JLabel(" ");
@@ -65,10 +79,18 @@ final class ObjectOverviewPanel extends JPanel {
return true;
}
};
private final JLabel seeThroughHint = new JLabel(SEE_THROUGH_HINT);
private final JButton showAllSolid = new JButton("Show all solid");
private final Set<String> seeThroughTypes = new LinkedHashSet<>();
private final Consumer<Set<String>> onSeeThroughChanged;
private ObjectSummary summary;
ObjectOverviewPanel() {
/**
* @param onSeeThroughChanged told which block types to draw see-through whenever the user changes them
*/
ObjectOverviewPanel(Consumer<Set<String>> onSeeThroughChanged) {
super(new BorderLayout());
this.onSeeThroughChanged = onSeeThroughChanged;
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
setBackground(StudioTheme.PANEL_BACKGROUND);
@@ -85,6 +107,8 @@ final class ObjectOverviewPanel extends JPanel {
blockCount.setText(formatCount(nextSummary.blockCount()));
blockTypes.setText(formatCount(nextSummary.blockUses().size()));
filled.setText(String.format(Locale.ROOT, "%.1f%%", nextSummary.filledFraction() * 100D));
seeThroughTypes.clear();
showSeeThroughState();
refilter();
}
@@ -96,6 +120,8 @@ final class ObjectOverviewPanel extends JPanel {
value.setText("—");
}
rows.clear();
seeThroughTypes.clear();
showSeeThroughState();
}
private JPanel buildHeader() {
@@ -147,11 +173,68 @@ final class ObjectOverviewPanel extends JPanel {
list.setCellRenderer(new BlockUseRenderer());
list.setBackground(StudioTheme.PANEL_BACKGROUND);
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
blocks.add(StudioToolbar.padded(filterField, 4, 12, 6, 12), BorderLayout.NORTH);
list.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
list.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent event) {
int row = list.locationToIndex(event.getPoint());
if (SwingUtilities.isLeftMouseButton(event) && row >= 0 && list.getCellBounds(row, row).contains(event.getPoint())) {
toggleSeeThrough(rows.get(row).blockType());
}
}
});
JPanel top = new JPanel();
top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS));
top.setOpaque(false);
top.add(leftAligned(StudioToolbar.padded(filterField, 4, 12, 4, 12)));
top.add(leftAligned(StudioToolbar.padded(seeThroughRow(), 0, 14, 6, 12)));
blocks.add(top, BorderLayout.NORTH);
blocks.add(StudioToolbar.scroll(list), BorderLayout.CENTER);
return blocks;
}
private JPanel seeThroughRow() {
seeThroughHint.setFont(StudioTheme.smallFont());
seeThroughHint.setForeground(StudioTheme.MUTED_TEXT);
showAllSolid.setFont(StudioTheme.smallFont());
showAllSolid.setForeground(StudioTheme.LINK);
showAllSolid.setBorderPainted(false);
showAllSolid.setContentAreaFilled(false);
showAllSolid.setFocusable(false);
showAllSolid.setMargin(new Insets(0, 0, 0, 0));
showAllSolid.setBorder(BorderFactory.createEmptyBorder());
showAllSolid.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
showAllSolid.addActionListener(event -> {
seeThroughTypes.clear();
seeThroughChanged();
});
JPanel row = new JPanel(new BorderLayout());
row.setOpaque(false);
row.add(seeThroughHint, BorderLayout.CENTER);
row.add(showAllSolid, BorderLayout.EAST);
showSeeThroughState();
return row;
}
private void toggleSeeThrough(String blockType) {
if (!seeThroughTypes.remove(blockType)) {
seeThroughTypes.add(blockType);
}
seeThroughChanged();
}
private void seeThroughChanged() {
showSeeThroughState();
list.repaint();
onSeeThroughChanged.accept(Set.copyOf(seeThroughTypes));
}
private void showSeeThroughState() {
int count = seeThroughTypes.size();
seeThroughHint.setText(count == 0 ? SEE_THROUGH_HINT : count == 1 ? "1 block see-through" : count + " blocks see-through");
showAllSolid.setVisible(count > 0);
}
private void refilter() {
rows.clear();
if (summary == null) {
@@ -199,6 +282,7 @@ final class ObjectOverviewPanel extends JPanel {
private final class BlockUseRenderer extends DefaultListCellRenderer {
private static final long serialVersionUID = 2785337045812613941L;
private transient BufferedImage icon;
private boolean seeThrough;
private double share;
private String percent = "";
@@ -206,9 +290,12 @@ final class ObjectOverviewPanel extends JPanel {
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
ObjectSummary.BlockUse use = (ObjectSummary.BlockUse) value;
super.getListCellRendererComponent(owner, use.blockType(), index, false, false);
seeThrough = seeThroughTypes.contains(use.blockType());
setFont(StudioTheme.font());
setForeground(seeThrough ? StudioTheme.MUTED_TEXT : StudioTheme.TEXT);
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + ICON_SIZE + ICON_GAP, 0, ROW_PADDING + SHARE_WIDTH));
setToolTipText(use.blockType() + " · " + formatCount(use.count()) + " blocks");
setToolTipText(use.blockType() + " · " + formatCount(use.count()) + " blocks · click to "
+ (seeThrough ? "make solid again" : "see through it"));
icon = use.icon();
share = summary == null || summary.blockCount() == 0 ? 0D : use.count() / (double) summary.blockCount();
percent = share > 0D && share < 0.001D ? "<0.1%" : String.format(Locale.ROOT, "%.1f%%", share * 100D);
@@ -225,7 +312,12 @@ final class ObjectOverviewPanel extends JPanel {
int iconY = (getHeight() - iconSize) / 2;
if (icon != null) {
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
Composite solid = canvas.getComposite();
if (seeThrough) {
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, SEE_THROUGH_ICON_OPACITY));
}
canvas.drawImage(icon, iconX, iconY, iconSize, iconSize, null);
canvas.setComposite(solid);
} else {
canvas.setColor(StudioTheme.CONTROL_BORDER);
canvas.drawRect(iconX, iconY, iconSize - 1, iconSize - 1);
@@ -248,7 +340,7 @@ final class ObjectOverviewPanel extends JPanel {
int barY = (getHeight() - barHeight) / 2;
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
canvas.fillRoundRect(barX, barY, barWidth, barHeight, barHeight, barHeight);
canvas.setColor(StudioTheme.ACCENT);
canvas.setColor(seeThrough ? StudioTheme.CONTROL_BORDER : StudioTheme.ACCENT);
canvas.fillRoundRect(barX, barY, Math.max(barHeight, (int) Math.round(barWidth * share)), barHeight, barHeight, barHeight);
canvas.setColor(StudioTheme.SECONDARY_TEXT);
int textWidth = canvas.getFontMetrics().stringWidth(percent);
@@ -193,7 +193,7 @@ final class ObjectRenderer {
}
int color = BlockPreviewColors.shade(texels[texel], shade);
if (blended) {
pixels[index] = blend(color, pixels[index], alphas == null ? 1D : alphas[texel] / FULL_ALPHA);
pixels[index] = blend(color, pixels[index], (alphas == null ? 1D : alphas[texel] / FULL_ALPHA) * quad.opacity());
continue;
}
depths[index] = depth;
@@ -86,6 +86,26 @@ final class ObjectViewport extends JComponent {
repaint();
}
/**
* Swaps in another mesh of the same object, like one with its top layers cut away, keeping the camera.
*/
void replaceMesh(ObjectMesh nextMesh) {
mesh = nextMesh;
message = nextMesh.isEmpty() ? "No visible blocks" : null;
requestFrame();
repaint();
}
void bindKey(int keyCode, String name, Runnable action) {
getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke(keyCode, 0), name);
getActionMap().put(name, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
action.run();
}
});
}
void showMessage(String text) {
mesh = null;
message = text;
@@ -218,12 +238,6 @@ final class ObjectViewport extends JComponent {
}
private void bindKey(int keyCode, String name, UnaryOperator<ObjectCamera> move) {
getInputMap(WHEN_FOCUSED).put(KeyStroke.getKeyStroke(keyCode, 0), name);
getActionMap().put(name, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
moveCamera(move);
}
});
bindKey(keyCode, name, () -> moveCamera(move));
}
}
@@ -0,0 +1,268 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import javax.swing.CellRendererPane;
import javax.swing.Icon;
import javax.swing.JComponent;
import javax.swing.JLayer;
import javax.swing.JScrollPane;
import javax.swing.JTree;
import javax.swing.JViewport;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.plaf.LayerUI;
import javax.swing.plaf.basic.BasicTreeUI;
import javax.swing.tree.TreePath;
import java.awt.AWTEvent;
import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.GradientPaint;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.List;
/**
* Keeps the folders around the top row of a scrolled tree pinned at the top, so a long folder never hides where it
* is. The next folder pushes the last header up as it arrives, and clicking a header jumps to its folder.
*/
final class StickyFolderHeaders extends LayerUI<JScrollPane> {
private static final long serialVersionUID = -2205474187531254372L;
private static final int SHADOW_HEIGHT = 4;
private static final Color SHADOW = new Color(0, 0, 0, 90);
private static final Color NO_SHADOW = new Color(0, 0, 0, 0);
// The folder handle sits one indent to the left of the folder's name, as the tree draws it.
private static final int HANDLE_NUDGE = 1;
private final JTree tree;
private final CellRendererPane rendererPane = new CellRendererPane();
private transient JLayer<JScrollPane> layer;
private int hoveredHeader = -1;
private StickyFolderHeaders(JTree tree) {
this.tree = tree;
}
static JLayer<JScrollPane> around(JTree tree, JScrollPane scrollPane) {
// Copying the scrolled pixels would drag the pinned headers along, so every scroll repaints instead.
scrollPane.getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE);
StickyFolderHeaders headers = new StickyFolderHeaders(tree);
JLayer<JScrollPane> layer = new JLayer<>(scrollPane, headers);
scrollPane.getViewport().addChangeListener(event -> layer.repaint());
return layer;
}
@Override
@SuppressWarnings("unchecked")
public void installUI(JComponent component) {
super.installUI(component);
layer = (JLayer<JScrollPane>) component;
layer.setLayerEventMask(AWTEvent.MOUSE_EVENT_MASK | AWTEvent.MOUSE_MOTION_EVENT_MASK);
tree.add(rendererPane);
}
@Override
public void uninstallUI(JComponent component) {
layer.setLayerEventMask(0);
tree.remove(rendererPane);
layer = null;
super.uninstallUI(component);
}
@Override
public void paint(Graphics graphics, JComponent component) {
super.paint(graphics, component);
List<Header> headers = pinnedHeaders();
if (headers.isEmpty()) {
return;
}
Graphics2D canvas = (Graphics2D) graphics.create();
try {
Rectangle viewport = viewportBounds();
canvas.clipRect(viewport.x, viewport.y, viewport.width, viewport.height);
for (int index = 0; index < headers.size(); index++) {
paintHeader(canvas, headers.get(index), index == hoveredHeader, viewport);
}
Header last = headers.get(headers.size() - 1);
int shadowTop = viewport.y + last.top() + tree.getRowHeight();
canvas.setPaint(new GradientPaint(0, shadowTop, SHADOW, 0, shadowTop + StudioTheme.scale(SHADOW_HEIGHT), NO_SHADOW));
canvas.fillRect(viewport.x, shadowTop, viewport.width, StudioTheme.scale(SHADOW_HEIGHT));
} finally {
canvas.dispose();
}
}
private void paintHeader(Graphics2D canvas, Header header, boolean hovered, Rectangle viewport) {
int rowHeight = tree.getRowHeight();
int top = viewport.y + header.top();
Rectangle row = tree.getRowBounds(header.row());
int left = viewport.x + row.x - scrolledView().x;
canvas.setColor(hovered ? StudioTheme.HOVER_BACKGROUND : tree.getBackground());
canvas.fillRect(viewport.x, top, viewport.width, rowHeight);
paintFolderHandle(canvas, left, top + rowHeight / 2);
Component renderer = tree.getCellRenderer().getTreeCellRendererComponent(
tree, header.path().getLastPathComponent(), false, true, false, header.row(), false);
rendererPane.paintComponent(canvas, renderer, tree, left, top, viewport.x + viewport.width - left, rowHeight, true);
}
private void paintFolderHandle(Graphics2D canvas, int folderLeft, int middleY) {
Icon handle = UIManager.getIcon("Tree.expandedIcon");
if (handle == null || !(tree.getUI() instanceof BasicTreeUI treeUI)) {
return;
}
int middleX = folderLeft - treeUI.getRightChildIndent() + HANDLE_NUDGE;
handle.paintIcon(tree, canvas, middleX - handle.getIconWidth() / 2, middleY - handle.getIconHeight() / 2);
}
@Override
protected void processMouseEvent(MouseEvent event, JLayer<? extends JScrollPane> source) {
if (event.getID() == MouseEvent.MOUSE_EXITED) {
hover(-1);
return;
}
int index = headerAt(event);
if (index < 0) {
return;
}
event.consume();
if (event.getID() == MouseEvent.MOUSE_CLICKED && SwingUtilities.isLeftMouseButton(event)) {
jumpTo(pinnedHeaders().get(index), index);
}
}
@Override
protected void processMouseMotionEvent(MouseEvent event, JLayer<? extends JScrollPane> source) {
hover(headerAt(event));
}
private void hover(int index) {
if (index == hoveredHeader) {
return;
}
hoveredHeader = index;
tree.setCursor(index >= 0 ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : null);
layer.repaint();
}
// The folder lands just below the headers of the folders around it.
private void jumpTo(Header header, int level) {
Rectangle row = tree.getRowBounds(header.row());
JViewport viewport = layer.getView().getViewport();
int top = Math.max(0, row.y - level * tree.getRowHeight());
int bottomLimit = Math.max(0, tree.getHeight() - viewport.getExtentSize().height);
viewport.setViewPosition(new Point(viewport.getViewPosition().x, Math.min(top, bottomLimit)));
}
private int headerAt(MouseEvent event) {
if (layer == null || event.getComponent() == null) {
return -1;
}
Point point = SwingUtilities.convertPoint(event.getComponent(), event.getPoint(), layer);
Rectangle viewport = viewportBounds();
if (!viewport.contains(point)) {
return -1;
}
List<Header> headers = pinnedHeaders();
for (int index = headers.size() - 1; index >= 0; index--) {
int top = viewport.y + headers.get(index).top();
if (point.y >= top && point.y < top + tree.getRowHeight()) {
return index;
}
}
return -1;
}
/**
* For each depth, the folder holding the row just under the headers above it, as long as that folder's own row
* has scrolled out of sight.
*/
private List<Header> pinnedHeaders() {
List<Header> headers = new ArrayList<>();
int rowHeight = tree.getRowHeight();
if (layer == null || rowHeight <= 0 || tree.getRowCount() == 0) {
return headers;
}
Rectangle view = scrolledView();
for (int level = 0; ; level++) {
int slotTop = level * rowHeight;
int row = tree.getClosestRowForLocation(view.x, view.y + slotTop);
TreePath path = row < 0 ? null : tree.getPathForRow(row);
if (path == null || !isInsideFolderAt(path, level)) {
return headers;
}
TreePath folder = ancestorAt(path, level);
Rectangle lastRow = tree.getRowBounds(lastRowInside(folder, row));
int top = Math.min(slotTop, lastRow.y + lastRow.height - view.y - rowHeight);
headers.add(new Header(folder, tree.getRowForPath(folder), top));
if (top < slotTop) {
return headers;
}
}
}
// The hidden root is the path's first element, so the folder at depth 0 is its second.
private static boolean isInsideFolderAt(TreePath path, int level) {
return path.getPathCount() > level + 2;
}
private static TreePath ancestorAt(TreePath path, int level) {
TreePath ancestor = path;
while (ancestor.getPathCount() > level + 2) {
ancestor = ancestor.getParentPath();
}
return ancestor;
}
// A folder's rows sit together, so the last one is found by halving the rows after a row known to be inside.
private int lastRowInside(TreePath folder, int rowInside) {
int low = rowInside;
int high = tree.getRowCount() - 1;
while (low < high) {
int middle = (low + high + 1) >>> 1;
if (folder.isDescendant(tree.getPathForRow(middle))) {
low = middle;
} else {
high = middle - 1;
}
}
return low;
}
private Rectangle scrolledView() {
return layer.getView().getViewport().getViewRect();
}
private Rectangle viewportBounds() {
JViewport viewport = layer.getView().getViewport();
Point origin = SwingUtilities.convertPoint(viewport, 0, 0, layer);
return new Rectangle(origin.x, origin.y, viewport.getWidth(), viewport.getHeight());
}
/**
* A pinned folder, its row in the tree and how far below the top of the visible rows it is drawn.
*/
private record Header(TreePath path, int row, int top) {
}
}