Tidy the map hover card

Sections with labelled values.
This commit is contained in:
piexel committed 2026-10-21 21:12:12 +02:00
1 parent 3123ab3753
commit 97612385e5
2 files changed
+205 -72

No files matched your search

@@ -839,11 +839,11 @@ final class MapCanvas extends JPanel {
return;
}
MapHoverProbe.Details details = hoverDetails;
List<String> lines = MapHoverCard.lines(quick.subject(), quick.region(), details, currentType, detailedHover,
MapHoverCard card = MapHoverCard.of(quick.subject(), quick.region(), details, currentType, detailedHover,
quick.worldX(), quick.worldZ());
int screenX = (int) Math.round(worldToScreenX(quick.worldX())) + StudioTheme.scale(16);
int screenY = (int) Math.round(worldToScreenZ(quick.worldZ()));
drawCard(canvas, screenX, screenY, 0D, 0D, lines);
card.paint(canvas, screenX, screenY, getWidth(), getHeight());
}
private void renderHelp(Graphics2D canvas) {
@@ -24,49 +24,131 @@ import com.volmit.iris.core.loader.IrisRegistrant;
import com.volmit.iris.engine.HybridBiomeSelector;
import com.volmit.iris.engine.object.IrisBiome;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* The text of the card that follows the cursor: what the drawn map shows there at once, and the rest once probed.
* The card that follows the cursor: what the drawn map shows there at once, and the rest once probed, in titled
* sections of labelled values.
*/
final class MapHoverCard {
private MapHoverCard() {
record MapHoverCard(String title, String subtitle, List<Section> sections, String footer) {
private static final int PADDING = 12;
private static final int SUBTITLE_GAP = 2;
private static final int SECTION_GAP = 10;
private static final int SECTION_TITLE_GAP = 4;
private static final int COLUMN_GAP = 14;
private static final int ROW_GAP = 2;
/**
* @param subject the biome, or region in the region view, the drawn tile shows; null while that tile is drawing
* @param details the probed facts about the spot, or null until they arrive
* @param detailed whether Shift is held, which adds the pack files
*/
static MapHoverCard of(MapLabels.Label subject, MapLabels.Label region, MapHoverProbe.Details details,
RenderType type, boolean detailed, double worldX, double worldZ) {
List<Section> sections = new ArrayList<>();
if (details != null) {
sections.add(terrainSection(details, type));
addPickSection(sections, details);
}
sections.add(locationSection(worldX, worldZ, detailed));
if (detailed) {
addFilesSection(sections, subject, region);
}
return new MapHoverCard(title(subject, details), subtitle(region, details, type), sections,
detailed ? null : "Shift for pack files · H highlights");
}
/**
* @param subject the biome, or region in the region view, the drawn tile shows; null while that tile is drawing
* @param details the probed facts about the spot, or null until they arrive
* @param detailed whether Shift is held, which adds keys and files
* Draws the card with its top left corner as close to the given point as fits inside the bounds.
*/
static List<String> lines(MapLabels.Label subject, MapLabels.Label region, MapHoverProbe.Details details,
RenderType type, boolean detailed, double worldX, double worldZ) {
List<String> lines = new ArrayList<>();
lines.add(title(subject, details));
if (type != RenderType.REGION) {
String regionName = region != null ? region.name() : details != null ? details.region().getName() : null;
if (regionName != null) {
lines.add("Region " + regionName);
void paint(Graphics2D canvas, int anchorX, int anchorY, int boundsWidth, int boundsHeight) {
int padding = StudioTheme.scale(PADDING);
int labelWidth = labelColumnWidth(canvas);
int width = Math.max(headerWidth(canvas), labelWidth + StudioTheme.scale(COLUMN_GAP) + valueColumnWidth(canvas)) + padding * 2;
int height = contentHeight(canvas) + padding * 2;
int x = Math.max(padding, Math.min(boundsWidth - width - padding, anchorX));
int y = Math.max(padding, Math.min(boundsHeight - height - padding, anchorY));
StudioOverlay.drawCard(canvas, x, y, width, height);
int left = x + padding;
int top = y + padding;
top = drawLine(canvas, title, StudioTheme.boldFont(), StudioTheme.TEXT, left, top);
if (subtitle != null) {
top = drawLine(canvas, subtitle, StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT, left,
top + StudioTheme.scale(SUBTITLE_GAP));
}
for (Section section : sections) {
top += StudioTheme.scale(SECTION_GAP);
canvas.setColor(StudioTheme.OVERLAY_BORDER);
canvas.fillRect(left, top, width - padding * 2, 1);
top += StudioTheme.scale(SECTION_GAP) / 2;
top = drawLine(canvas, section.title().toUpperCase(Locale.ROOT), StudioTheme.sectionFont(), StudioTheme.MUTED_TEXT,
left, top) + StudioTheme.scale(SECTION_TITLE_GAP);
for (Row row : section.rows()) {
drawLine(canvas, row.label(), StudioTheme.font(), StudioTheme.SECONDARY_TEXT, left, top);
top = drawLine(canvas, row.value(), StudioTheme.font(), StudioTheme.TEXT,
left + labelWidth + StudioTheme.scale(COLUMN_GAP), top) + StudioTheme.scale(ROW_GAP);
}
}
if (details != null) {
addTerrain(lines, details, type);
if (footer != null) {
drawLine(canvas, footer, StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, top + StudioTheme.scale(SECTION_GAP));
}
int blockX = MapCanvas.floorWorldCoordinate(worldX);
int blockZ = MapCanvas.floorWorldCoordinate(worldZ);
lines.add("Block " + blockX + ", " + blockZ + " · chunk " + (blockX >> 4) + ", " + (blockZ >> 4));
if (!detailed) {
lines.add("Shift for keys and files · H highlights");
return lines;
}
private int labelColumnWidth(Graphics2D canvas) {
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
return sections.stream().flatMap(section -> section.rows().stream())
.mapToInt(row -> metrics.stringWidth(row.label())).max().orElse(0);
}
private int valueColumnWidth(Graphics2D canvas) {
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
return sections.stream().flatMap(section -> section.rows().stream())
.mapToInt(row -> metrics.stringWidth(row.value())).max().orElse(0);
}
private int headerWidth(Graphics2D canvas) {
int width = canvas.getFontMetrics(StudioTheme.boldFont()).stringWidth(title);
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
if (subtitle != null) {
width = Math.max(width, small.stringWidth(subtitle));
}
addSource(lines, subject);
if (subject != null && subject.kind() != MapLabels.Kind.REGION) {
addSource(lines, region);
if (footer != null) {
width = Math.max(width, small.stringWidth(footer));
}
lines.add("Region file r." + (blockX >> 9) + "." + (blockZ >> 9) + ".mca");
return lines;
return width;
}
private int contentHeight(Graphics2D canvas) {
int small = canvas.getFontMetrics(StudioTheme.smallFont()).getHeight();
int row = canvas.getFontMetrics(StudioTheme.font()).getHeight() + StudioTheme.scale(ROW_GAP);
int sectionTitle = canvas.getFontMetrics(StudioTheme.sectionFont()).getHeight() + StudioTheme.scale(SECTION_TITLE_GAP);
int height = canvas.getFontMetrics(StudioTheme.boldFont()).getHeight();
if (subtitle != null) {
height += StudioTheme.scale(SUBTITLE_GAP) + small;
}
for (Section section : sections) {
height += StudioTheme.scale(SECTION_GAP) + StudioTheme.scale(SECTION_GAP) / 2 + sectionTitle + section.rows().size() * row;
}
if (footer != null) {
height += StudioTheme.scale(SECTION_GAP) + small;
}
return height;
}
private static int drawLine(Graphics2D canvas, String text, Font font, Color color, int left, int top) {
canvas.setFont(font);
canvas.setColor(color);
FontMetrics metrics = canvas.getFontMetrics();
canvas.drawString(text, left, top + metrics.getAscent());
return top + metrics.getHeight();
}
private static String title(MapLabels.Label subject, MapHoverProbe.Details details) {
@@ -79,61 +161,115 @@ final class MapHoverCard {
return "Drawing…";
}
private static void addTerrain(List<String> lines, MapHoverProbe.Details details, RenderType type) {
lines.add("Height " + Math.round(details.height()) + " · sea level " + Math.round(details.fluidHeight()));
String slope = "slope " + Math.round(details.slopeDegrees()) + "°";
lines.add(details.terrainKind() == null ? capitalize(slope) : details.terrainKind() + " · " + slope);
// The region view's title already is the region, so only the kind of ground is left to say.
private static String subtitle(MapLabels.Label region, MapHoverProbe.Details details, RenderType type) {
List<String> parts = new ArrayList<>();
if (type != RenderType.REGION) {
String regionName = region != null ? region.name() : details != null ? details.region().getName() : null;
if (regionName != null) {
parts.add(regionName + " region");
}
}
if (details != null && details.terrainKind() != null) {
parts.add(details.terrainKind());
}
return parts.isEmpty() ? null : String.join(" · ", parts);
}
private static Section terrainSection(MapHoverProbe.Details details, RenderType type) {
List<Row> rows = new ArrayList<>();
long height = Math.round(details.height());
rows.add(new Row("Height", height + " · " + heightAboveSea(details.height() - details.fluidHeight())));
rows.add(new Row("Slope", Math.round(details.slopeDegrees()) + "°"));
IrisBiome surface = details.surfaceBiome();
IrisBiome cave = details.caveBiome();
if (type == RenderType.CAVE_LAND && surface != null) {
lines.add("Surface biome " + surface.getName());
rows.add(new Row("Surface biome", surface.getName()));
} else if (type != RenderType.CAVE_LAND && cave != null && cave != surface) {
lines.add("Cave biome " + cave.getName());
rows.add(new Row("Cave biome", cave.getName()));
}
if (details.vanillaBiome() != null) {
lines.add("Minecraft biome " + details.vanillaBiome().replace('_', ' '));
rows.add(new Row("Minecraft", details.vanillaBiome().replace('_', ' ')));
}
return new Section("Terrain", rows);
}
private static void addPickSection(List<Section> sections, MapHoverProbe.Details details) {
List<Row> rows = new ArrayList<>();
MapHoverProbe.PickNoise noise = details.pickNoise();
if (noise != null && noise.regionNoise() != null) {
rows.add(new Row("Region noise", noiseText(noise.regionNoise())));
}
if (noise != null && noise.biomeType() != null) {
String label = capitalize(noise.biomeType().name().toLowerCase(Locale.ROOT)) + " noise";
rows.add(new Row(label, biomeNoiseText(noise, details.surfaceBiome())));
}
if (details.placement() != null) {
lines.add(placementText(details.placement()));
rows.add(new Row("Height", placementText(details.placement())));
}
if (details.pickNoise() != null) {
addPickNoise(lines, details.pickNoise(), surface);
if (!rows.isEmpty()) {
sections.add(new Section("Picked by", rows));
}
}
private static void addPickNoise(List<String> lines, MapHoverProbe.PickNoise noise, IrisBiome surface) {
if (noise.regionNoise() != null) {
lines.add("Region noise " + noiseText(noise.regionNoise()));
}
if (noise.biomeType() == null) {
return;
}
String biomeNoise = capitalize(noise.biomeType().name().toLowerCase(Locale.ROOT)) + " biome noise "
+ noiseText(noise.biomeNoise());
private static String biomeNoiseText(MapHoverProbe.PickNoise noise, IrisBiome surface) {
String value = noiseText(noise.biomeNoise());
IrisBiome picked = noise.noisePick();
if (picked == null) {
lines.add(biomeNoise);
} else if (surface == null || picked.getLoadKey().equals(surface.getLoadKey())) {
lines.add(biomeNoise + " picks " + picked.getName());
} else {
lines.add(biomeNoise + " picks " + picked.getName() + ", then " + surface.getName() + " takes over");
return value;
}
}
private static String noiseText(double noise) {
return String.format(Locale.ROOT, "%.3f", noise);
if (surface == null || picked.getLoadKey().equals(surface.getLoadKey())) {
return value + " → " + picked.getName();
}
return value + " → " + picked.getName() + ", child " + surface.getName() + " took over";
}
// How hybrid terrain chose the base biome; a child biome or a coast can still replace it afterwards.
private static String placementText(HybridBiomeSelector.Choice choice) {
String height = "Picked at " + heightAboveSea(choice.placementHeight()) + " · ";
String height = heightAboveSea(choice.placementHeight()) + " · ";
if (choice.fallback()) {
return height + "no range covers it, closest range wins";
return height + "no range covers it, closest wins";
}
if (choice.rivalCount() == 0) {
return height + "the only biome in range";
return height + "only biome in range";
}
return height + "beat " + choice.rivalCount() + (choice.rivalCount() == 1 ? " other biome" : " other biomes") + " in range";
return height + "beat " + choice.rivalCount() + (choice.rivalCount() == 1 ? " other" : " others") + " in range";
}
private static Section locationSection(double worldX, double worldZ, boolean detailed) {
int blockX = MapCanvas.floorWorldCoordinate(worldX);
int blockZ = MapCanvas.floorWorldCoordinate(worldZ);
List<Row> rows = new ArrayList<>();
rows.add(new Row("Block", blockX + ", " + blockZ));
rows.add(new Row("Chunk", (blockX >> 4) + ", " + (blockZ >> 4)));
if (detailed) {
rows.add(new Row("Region file", "r." + (blockX >> 9) + "." + (blockZ >> 9) + ".mca"));
}
return new Section("Location", rows);
}
private static void addFilesSection(List<Section> sections, MapLabels.Label subject, MapLabels.Label region) {
List<Row> rows = new ArrayList<>();
addPackFile(rows, subject);
if (subject != null && subject.kind() != MapLabels.Kind.REGION) {
addPackFile(rows, region);
}
if (!rows.isEmpty()) {
sections.add(new Section("Pack files", rows));
}
}
// The key is the path under the type's folder, so folder, key and extension give the file within the pack.
private static void addPackFile(List<Row> rows, MapLabels.Label label) {
if (label == null || !(label.subject() instanceof IrisRegistrant registrant)) {
return;
}
String kind = label.kind() == MapLabels.Kind.REGION ? "Region" : "Biome";
File file = registrant.getLoadFile();
String fileName = file == null ? "" : file.getName();
int extensionStart = fileName.lastIndexOf('.');
String extension = extensionStart < 0 ? "" : fileName.substring(extensionStart);
rows.add(new Row(kind, registrant.getFolderName() + "/" + label.key() + extension));
}
private static String heightAboveSea(double height) {
@@ -141,20 +277,17 @@ final class MapHoverCard {
return rounded < 0 ? -rounded + " below sea" : rounded + " above sea";
}
private static void addSource(List<String> lines, MapLabels.Label label) {
if (label == null || !(label.subject() instanceof IrisRegistrant registrant)) {
return;
}
String kind = label.kind() == MapLabels.Kind.REGION ? "Region" : "Biome";
lines.add(kind + " key " + label.key());
File file = registrant.getLoadFile();
if (file != null) {
File folder = file.getParentFile();
lines.add(kind + " file " + (folder == null ? "" : folder.getName() + "/") + file.getName());
}
private static String noiseText(double noise) {
return String.format(Locale.ROOT, "%.3f", noise);
}
private static String capitalize(String text) {
return Character.toUpperCase(text.charAt(0)) + text.substring(1);
}
record Section(String title, List<Row> rows) {
}
record Row(String label, String value) {
}
}