From 2cd9167549ce4ddc20277092c05e96573d38fea0 Mon Sep 17 00:00:00 2001 From: piexel Date: Mon, 19 Oct 2026 22:46:23 +0200 Subject: [PATCH] Polish map view list Outline icons, keycaps, row hover. --- .../com/volmit/iris/core/gui/MapCanvas.java | 23 ++- .../com/volmit/iris/core/gui/MapViewIcon.java | 190 ++++++++++++++++++ .../com/volmit/iris/core/gui/MapViewList.java | 126 ++++++++++-- .../com/volmit/iris/core/gui/MapWindow.java | 15 +- .../com/volmit/iris/core/gui/StudioTheme.java | 1 + 5 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 core/src/main/java/com/volmit/iris/core/gui/MapViewIcon.java diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java index d78dd54ab..b48d08968 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapCanvas.java @@ -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); diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapViewIcon.java b/core/src/main/java/com/volmit/iris/core/gui/MapViewIcon.java new file mode 100644 index 000000000..59f812793 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/MapViewIcon.java @@ -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 . + */ + +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 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 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 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 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 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 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 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 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 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 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 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); + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapViewList.java b/core/src/main/java/com/volmit/iris/core/gui/MapViewList.java index e70eea3e4..76d5cc33b 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapViewList.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapViewList.java @@ -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 { 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 rows = new DefaultListModel<>(); private int lastViewRow; + private int hoveredRow = NO_ROW; MapViewList(Consumer onViewChosen) { fillRows(); @@ -58,6 +66,43 @@ final class MapViewList extends JList { 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 { 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 { 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(); + } } } diff --git a/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java index d7df45f07..bd3bb9ef6 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java +++ b/core/src/main/java/com/volmit/iris/core/gui/MapWindow.java @@ -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); } diff --git a/core/src/main/java/com/volmit/iris/core/gui/StudioTheme.java b/core/src/main/java/com/volmit/iris/core/gui/StudioTheme.java index 7a71842e9..9d877bfd5 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/StudioTheme.java +++ b/core/src/main/java/com/volmit/iris/core/gui/StudioTheme.java @@ -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"),