Polish map view list

Outline icons, keycaps, row hover.
This commit is contained in:
piexel committed 2026-10-19 22:46:49 +02:00
1 parent a648ef6622
commit 2cd9167549
5 files changed
+331 -24

No files matched your search

@@ -112,6 +112,7 @@ final class MapCanvas extends JPanel {
private boolean entitiesVisible;
private boolean follow;
private boolean help;
private int coveredLeftWidth;
private boolean debug;
private boolean detailedHover;
private boolean active = true;
@@ -337,6 +338,17 @@ final class MapCanvas extends JPanel {
listener.onTogglesChanged();
}
/**
* Keeps the help, debug and notification cards clear of a panel floating over the map's left side.
*/
void setCoveredLeftWidth(int width) {
if (width == coveredLeftWidth) {
return;
}
coveredLeftWidth = width;
repaint();
}
void toggleHelp() {
help = !help;
repaint();
@@ -814,15 +826,16 @@ final class MapCanvas extends JPanel {
int lineHeight = canvas.getFontMetrics().getHeight() + StudioTheme.scale(4);
int width = keyWidth + columnGap + descriptionWidth + padding * 2;
int height = keys.size() * lineHeight + padding * 2;
StudioOverlay.drawCard(canvas, padding, padding, width, height);
int left = coveredLeftWidth + padding;
StudioOverlay.drawCard(canvas, left, padding, width, height);
for (int index = 0; index < keys.size(); index++) {
int y = padding * 2 + canvas.getFontMetrics().getAscent() + index * lineHeight;
canvas.setFont(StudioTheme.boldFont());
canvas.setColor(StudioTheme.LINK);
canvas.drawString(keys.get(index), padding * 2, y);
canvas.drawString(keys.get(index), left + padding, y);
canvas.setFont(StudioTheme.font());
canvas.setColor(StudioTheme.SECONDARY_TEXT);
canvas.drawString(descriptions.get(index), padding * 2 + keyWidth + columnGap, y);
canvas.drawString(descriptions.get(index), left + padding + keyWidth + columnGap, y);
}
}
@@ -835,7 +848,7 @@ final class MapCanvas extends JPanel {
lines.add("Tile scale " + (frame == null ? "-" : Form.f(frame.renderBlocksPerPixel(), 0)) + " blocks/px");
lines.add(paintCadenceFps + " paints per second");
int padding = StudioTheme.scale(CARD_PADDING);
drawCard(canvas, padding, getHeight() - padding, 0D, 1D, lines);
drawCard(canvas, coveredLeftWidth + padding, getHeight() - padding, 0D, 1D, lines);
}
private void renderNotifications(Graphics2D canvas) {
@@ -848,7 +861,7 @@ final class MapCanvas extends JPanel {
int padding = StudioTheme.scale(CARD_PADDING);
int width = textWidth + padding * 2;
int height = canvas.getFontMetrics().getHeight() + padding;
int x = (getWidth() - width) / 2;
int x = coveredLeftWidth + (getWidth() - coveredLeftWidth - width) / 2;
int y = getHeight() - height - StudioTheme.scale(16);
StudioOverlay.drawCard(canvas, x, y, width, height);
canvas.setColor(StudioTheme.TEXT);
@@ -0,0 +1,190 @@
/*
* 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 java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Line2D;
import java.awt.geom.Path2D;
import java.util.List;
/**
* A small outline picture of what a map view shows, in the style of IntelliJ's new UI icons: thin single-colour
* lines on a 16 by 16 grid, laid on half pixels so they stay crisp.
*/
final class MapViewIcon {
private static final double GRID = 16D;
private static final float LINE_WIDTH = 1F;
private static final Color LINE_COLOR = new Color(0xCED0D6);
private MapViewIcon() {
}
static void paint(Graphics2D graphics, MapView view, int x, int y, int size) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
canvas.translate(x, y);
canvas.scale(size / GRID, size / GRID);
canvas.setColor(LINE_COLOR);
canvas.setStroke(new BasicStroke(LINE_WIDTH, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
for (Shape line : glyphOf(view)) {
canvas.draw(line);
}
} finally {
canvas.dispose();
}
}
private static List<Shape> glyphOf(MapView view) {
return switch (view) {
case BIOMES -> leaf();
case REGIONS -> foldedMap();
case HEIGHT -> mountains();
case CONTINENTS -> globe();
case LAND_BIOMES -> tree();
case SEA_BIOMES -> waves();
case CAVE_BIOMES -> caveMouth();
case PALETTES -> layers();
case OBJECTS -> cube();
case NOISE -> noiseWave();
};
}
private static List<Shape> leaf() {
Path2D blade = new Path2D.Double();
blade.moveTo(3.5, 12.5);
blade.curveTo(2.5, 6, 6.5, 2.5, 13.5, 2.5);
blade.curveTo(13.5, 9.5, 10, 13.5, 3.5, 12.5);
return List.of(blade, new Line2D.Double(3.5, 12.5, 9.5, 6.5));
}
private static List<Shape> foldedMap() {
Path2D outline = new Path2D.Double();
outline.moveTo(1.5, 3.5);
outline.lineTo(5.5, 2.5);
outline.lineTo(10.5, 3.5);
outline.lineTo(14.5, 2.5);
outline.lineTo(14.5, 12.5);
outline.lineTo(10.5, 13.5);
outline.lineTo(5.5, 12.5);
outline.lineTo(1.5, 13.5);
outline.closePath();
return List.of(outline, new Line2D.Double(5.5, 2.5, 5.5, 12.5), new Line2D.Double(10.5, 3.5, 10.5, 13.5));
}
private static List<Shape> mountains() {
Path2D ridge = new Path2D.Double();
ridge.moveTo(1.5, 13.5);
ridge.lineTo(6.5, 4.5);
ridge.lineTo(9.5, 9.5);
ridge.lineTo(11, 7.5);
ridge.lineTo(14.5, 13.5);
ridge.closePath();
return List.of(ridge);
}
private static List<Shape> globe() {
return List.of(
new Ellipse2D.Double(1.5, 1.5, 13, 13),
new Ellipse2D.Double(5, 1.5, 6, 13),
new Line2D.Double(1.5, 8, 14.5, 8));
}
private static List<Shape> tree() {
Path2D crown = new Path2D.Double();
crown.moveTo(8, 1.5);
crown.lineTo(13.5, 11.5);
crown.lineTo(2.5, 11.5);
crown.closePath();
return List.of(crown, new Line2D.Double(8, 11.5, 8, 14.5));
}
private static List<Shape> waves() {
return List.of(wave(5.5), wave(10.5));
}
private static Shape wave(double middleY) {
Path2D wave = new Path2D.Double();
wave.moveTo(1.5, middleY);
wave.curveTo(3.7, middleY - 2.5, 5.8, middleY - 2.5, 8, middleY);
wave.curveTo(10.2, middleY + 2.5, 12.3, middleY + 2.5, 14.5, middleY);
return wave;
}
private static List<Shape> caveMouth() {
Path2D arch = new Path2D.Double();
arch.moveTo(3.5, 13.5);
arch.lineTo(3.5, 8);
arch.curveTo(3.5, 2, 12.5, 2, 12.5, 8);
arch.lineTo(12.5, 13.5);
return List.of(arch, new Line2D.Double(1.5, 13.5, 14.5, 13.5));
}
// Stacked sheets, like IntelliJ's layers icon.
private static List<Shape> layers() {
Path2D top = new Path2D.Double();
top.moveTo(8, 1.5);
top.lineTo(14.5, 5);
top.lineTo(8, 8.5);
top.lineTo(1.5, 5);
top.closePath();
return List.of(top, chevron(8.5), chevron(11.5));
}
private static Shape chevron(double sideY) {
Path2D chevron = new Path2D.Double();
chevron.moveTo(1.5, sideY);
chevron.lineTo(8, sideY + 3.5);
chevron.lineTo(14.5, sideY);
return chevron;
}
private static List<Shape> cube() {
Path2D outline = new Path2D.Double();
outline.moveTo(8, 1.5);
outline.lineTo(14, 4.5);
outline.lineTo(14, 11.5);
outline.lineTo(8, 14.5);
outline.lineTo(2, 11.5);
outline.lineTo(2, 4.5);
outline.closePath();
Path2D edges = new Path2D.Double();
edges.moveTo(2, 4.5);
edges.lineTo(8, 7.5);
edges.lineTo(14, 4.5);
edges.moveTo(8, 7.5);
edges.lineTo(8, 14.5);
return List.of(outline, edges);
}
private static List<Shape> noiseWave() {
Path2D wave = new Path2D.Double();
wave.moveTo(1.5, 9);
wave.curveTo(2.8, 3, 4.6, 3, 5.8, 8);
wave.curveTo(7, 13, 8.8, 13.5, 10, 7.5);
wave.curveTo(11, 3.5, 13, 4.5, 14.5, 8.5);
return List.of(wave);
}
}
@@ -18,27 +18,35 @@
package com.volmit.iris.core.gui;
import javax.swing.BorderFactory;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.Icon;
import javax.swing.JList;
import javax.swing.ListSelectionModel;
import javax.swing.UIManager;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.Locale;
import java.util.function.Consumer;
/**
* The map window's list of views, grouped under headers, each row showing the key that opens it.
* The map window's list of views, grouped under headers, each row with a picture of the view and the key that opens
* it.
*/
final class MapViewList extends JList<MapViewList.Row> {
private static final long serialVersionUID = -4130872409914437415L;
private static final int ROW_HEIGHT = 26;
private static final int ROW_HEIGHT = 28;
private static final int NO_ROW = -1;
private final DefaultListModel<Row> rows = new DefaultListModel<>();
private int lastViewRow;
private int hoveredRow = NO_ROW;
MapViewList(Consumer<MapView> onViewChosen) {
fillRows();
@@ -58,6 +66,43 @@ final class MapViewList extends JList<MapViewList.Row> {
lastViewRow = getSelectedIndex();
onViewChosen.accept(selected.view());
});
MouseAdapter hover = new MouseAdapter() {
@Override
public void mouseMoved(MouseEvent event) {
hover(viewRowAt(event));
}
@Override
public void mouseExited(MouseEvent event) {
hover(NO_ROW);
}
};
addMouseListener(hover);
addMouseMotionListener(hover);
}
private int viewRowAt(MouseEvent event) {
int row = locationToIndex(event.getPoint());
if (row < 0 || rows.get(row).isHeader() || !getCellBounds(row, row).contains(event.getPoint())) {
return NO_ROW;
}
return row;
}
private void hover(int row) {
if (row == hoveredRow) {
return;
}
repaintRow(hoveredRow);
hoveredRow = row;
repaintRow(hoveredRow);
}
private void repaintRow(int row) {
Rectangle bounds = row == NO_ROW ? null : getCellBounds(row, row);
if (bounds != null) {
repaint(bounds);
}
}
void select(MapView view) {
@@ -123,7 +168,12 @@ final class MapViewList extends JList<MapViewList.Row> {
private static final class RowRenderer extends DefaultListCellRenderer {
private static final long serialVersionUID = 6302156810211578474L;
private static final int ICON_SIZE = 16;
private static final int ICON_TEXT_GAP = 8;
private static final int KEYCAP_SIZE = 17;
private static final int KEYCAP_ARC = 5;
private String shortcut;
private boolean hovered;
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean selected, boolean focused) {
@@ -133,35 +183,83 @@ final class MapViewList extends JList<MapViewList.Row> {
setText(row.group().label().toUpperCase(Locale.ROOT));
setFont(StudioTheme.sectionFont());
setForeground(StudioTheme.MUTED_TEXT);
setBorder(StudioTheme.emptyBorder(index == 0 ? 0 : 6, 6, 0, 6));
setBorder(StudioTheme.emptyBorder(index == 0 ? 2 : 10, 8, 0, 6));
setIcon(null);
setToolTipText(null);
shortcut = null;
hovered = false;
setOpaque(true);
return this;
}
setFont(StudioTheme.font());
setBorder(StudioTheme.emptyBorder(0, 10, 0, 10));
setBorder(StudioTheme.emptyBorder(0, 8, 0, 8));
setIcon(new ViewIcon(row.view()));
setIconTextGap(StudioTheme.scale(ICON_TEXT_GAP));
setToolTipText(row.view().description());
shortcut = row.view().shortcutText();
hovered = !selected && list instanceof MapViewList viewList && viewList.hoveredRow == index;
setOpaque(!hovered);
return this;
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
if (shortcut == null) {
return;
}
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setFont(StudioTheme.smallFont());
canvas.setColor(StudioTheme.MUTED_TEXT);
int textWidth = canvas.getFontMetrics().stringWidth(shortcut);
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
canvas.drawString(shortcut, getWidth() - getInsets().right - textWidth, baseline);
if (hovered) {
paintHoverBackground(canvas);
}
super.paintComponent(graphics);
if (shortcut != null) {
paintKeycap(canvas);
}
} finally {
canvas.dispose();
}
}
// Shaped like the selection the look and feel paints, so moving between them doesn't jump.
private void paintHoverBackground(Graphics2D canvas) {
Insets inset = UIManager.getInsets("List.selectionInsets");
int left = inset == null ? 0 : StudioTheme.scale(inset.left);
int right = inset == null ? 0 : StudioTheme.scale(inset.right);
int arc = StudioTheme.scale(UIManager.getInt("List.selectionArc"));
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
canvas.fillRoundRect(left, 0, getWidth() - left - right, getHeight(), arc, arc);
}
private void paintKeycap(Graphics2D canvas) {
canvas.setFont(StudioTheme.smallFont());
int textWidth = canvas.getFontMetrics().stringWidth(shortcut);
int height = StudioTheme.scale(KEYCAP_SIZE);
int width = Math.max(height, textWidth + StudioTheme.scale(8));
int x = getWidth() - getInsets().right - width;
int y = (getHeight() - height) / 2;
int arc = StudioTheme.scale(KEYCAP_ARC);
canvas.setColor(StudioTheme.CONTROL_BORDER);
canvas.drawRoundRect(x, y, width - 1, height - 1, arc, arc);
canvas.setColor(StudioTheme.SECONDARY_TEXT);
int baseline = y + (height + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
canvas.drawString(shortcut, x + (width - textWidth) / 2, baseline);
}
}
private record ViewIcon(MapView view) implements Icon {
@Override
public void paintIcon(Component component, Graphics graphics, int x, int y) {
MapViewIcon.paint((Graphics2D) graphics, view, x, y, getIconWidth());
}
@Override
public int getIconWidth() {
return StudioTheme.scale(RowRenderer.ICON_SIZE);
}
@Override
public int getIconHeight() {
return getIconWidth();
}
}
}
@@ -47,6 +47,7 @@ import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.function.IntConsumer;
/**
* The studio map: one window showing the world as tiles in several views, with the palette, object and noise explorers
@@ -141,6 +142,7 @@ public final class MapWindow implements MapCanvas.Listener {
frame.add(inspectorSlot, BorderLayout.EAST);
frame.add(statusBars, BorderLayout.SOUTH);
installShortcuts(frame.getRootPane());
keepClearOfIsland(canvas::setCoveredLeftWidth);
frame.setSize(GuiHost.fitToScreen(StudioTheme.scaledSize(1440, 880)));
frame.setMinimumSize(StudioTheme.scaledSize(820, 520));
frame.setLocationRelativeTo(null);
@@ -260,16 +262,19 @@ public final class MapWindow implements MapCanvas.Listener {
}
}
private void createObjectExplorer() {
objectExplorer = new ObjectExplorer(start.engine(), start.worldId());
ObjectViewport viewport = objectExplorer.viewport();
viewport.setCoveredLeftWidth(sidebarIsland.getWidth());
private void keepClearOfIsland(IntConsumer setCoveredLeftWidth) {
setCoveredLeftWidth.accept(sidebarIsland.getWidth());
sidebarIsland.addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent event) {
viewport.setCoveredLeftWidth(sidebarIsland.getWidth());
setCoveredLeftWidth.accept(sidebarIsland.getWidth());
}
});
}
private void createObjectExplorer() {
objectExplorer = new ObjectExplorer(start.engine(), start.worldId());
keepClearOfIsland(objectExplorer.viewport()::setCoveredLeftWidth);
content.add(objectExplorer.view(), OBJECTS_CARD);
statusBars.add(objectExplorer.statusBar(), OBJECTS_CARD);
}
@@ -80,6 +80,7 @@ final class StudioTheme {
Map.entry("List.selectionInsets", "0,6,0,6"),
Map.entry("Tree.selectionArc", "6"),
Map.entry("Tree.selectionInsets", "0,6,0,6"),
Map.entry("Tree.border", "0,8,0,0"),
Map.entry("ScrollBar.width", "10"),
Map.entry("ScrollBar.thumbArc", "999"),
Map.entry("ScrollBar.thumbInsets", "2,2,2,2"),