From 7bc3807f63b162de7ee4d5516c44d80877c77348 Mon Sep 17 00:00:00 2001 From: extio-de Date: Sun, 18 Jan 2026 18:28:56 +0100 Subject: [PATCH] feat(renderer): implement PopupMenu control and integrate CustomPopupMenu for enhanced UI interactions --- .../renderer/g2d/control/G2DDrawControl.java | 14 + .../control/components/CustomPopupMenu.java | 294 ++++++++++++++++++ .../control/impl/G2DPopupMenuControlImpl.java | 151 +++++++++ .../renderer/model/bo/ControlRenderingBo.java | 10 + .../de/extio/game_engine/demo/DemoModule.java | 63 +++- 5 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 src/main/java/de/extio/game_engine/renderer/g2d/control/components/CustomPopupMenu.java create mode 100644 src/main/java/de/extio/game_engine/renderer/g2d/control/impl/G2DPopupMenuControlImpl.java diff --git a/src/main/java/de/extio/game_engine/renderer/g2d/control/G2DDrawControl.java b/src/main/java/de/extio/game_engine/renderer/g2d/control/G2DDrawControl.java index a0f4e75..4da24c5 100644 --- a/src/main/java/de/extio/game_engine/renderer/g2d/control/G2DDrawControl.java +++ b/src/main/java/de/extio/game_engine/renderer/g2d/control/G2DDrawControl.java @@ -23,6 +23,7 @@ import de.extio.game_engine.renderer.g2d.control.impl.G2DToggleButtonControlImpl import de.extio.game_engine.renderer.g2d.control.impl.G2DTooltipControl; import de.extio.game_engine.renderer.g2d.control.impl.G2DWindowCloseButtonControlImpl; import de.extio.game_engine.renderer.g2d.control.impl.G2DWindowPanelControlImpl; +import de.extio.game_engine.renderer.g2d.control.impl.G2DPopupMenuControlImpl; import de.extio.game_engine.renderer.model.RenderingBo; import de.extio.game_engine.renderer.model.RenderingBoLayer; import de.extio.game_engine.renderer.model.bo.ControlRenderingBo; @@ -67,6 +68,8 @@ public class G2DDrawControl extends G2DAbstractRenderingBo implements ControlRen private TableData tableData; + private PopupMenuData popupMenuData; + private SetFocusData setFocusData; private Object customData; @@ -143,6 +146,9 @@ public class G2DDrawControl extends G2DAbstractRenderingBo implements ControlRen else if (data instanceof final TableData tableData) { this.tableData = tableData; } + else if (data instanceof final PopupMenuData popupMenuData) { + this.popupMenuData = popupMenuData; + } else if (data instanceof final SetFocusData setFocusData) { this.setFocusData = setFocusData; } @@ -241,6 +247,9 @@ public class G2DDrawControl extends G2DAbstractRenderingBo implements ControlRen else if (this.clazz == TableControl.class) { control = new G2DTableControlImpl(); } + else if (this.clazz == PopupMenuControl.class) { + control = new G2DPopupMenuControlImpl(); + } else if (this.clazz == SetFocusControl.class) { control = new G2DSetFocusControl(); } @@ -334,6 +343,9 @@ public class G2DDrawControl extends G2DAbstractRenderingBo implements ControlRen else if (this.clazz == TableControl.class && this.tableData != null) { ((TableControl) control).setCustomData(this.tableData); } + else if (this.clazz == PopupMenuControl.class && this.popupMenuData != null) { + ((PopupMenuControl) control).setCustomData(this.popupMenuData); + } else if (this.clazz == SetFocusControl.class && this.setFocusData != null) { ((SetFocusControl) control).setCustomData(this.setFocusData); } @@ -373,6 +385,7 @@ public class G2DDrawControl extends G2DAbstractRenderingBo implements ControlRen this.textfieldData = o.textfieldData; this.sliderData = o.sliderData; this.tableData = o.tableData; + this.popupMenuData = o.popupMenuData; this.setFocusData = o.setFocusData; this.customData = o.customData; this.visible = o.visible; @@ -403,6 +416,7 @@ public class G2DDrawControl extends G2DAbstractRenderingBo implements ControlRen this.textfieldData = null; this.sliderData = null; this.tableData = null; + this.popupMenuData = null; this.setFocusData = null; this.customData = null; this.visible = false; diff --git a/src/main/java/de/extio/game_engine/renderer/g2d/control/components/CustomPopupMenu.java b/src/main/java/de/extio/game_engine/renderer/g2d/control/components/CustomPopupMenu.java new file mode 100644 index 0000000..f5713c9 --- /dev/null +++ b/src/main/java/de/extio/game_engine/renderer/g2d/control/components/CustomPopupMenu.java @@ -0,0 +1,294 @@ +package de.extio.game_engine.renderer.g2d.control.components; + +import java.awt.Color; +import java.awt.Component; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionListener; +import java.util.List; +import java.util.Objects; + +import de.extio.game_engine.renderer.ThemeManager; +import de.extio.game_engine.renderer.g2d.bo.rendering.G2DDrawFont; +import de.extio.game_engine.renderer.g2d.theme.G2DThemeManager; +import de.extio.game_engine.renderer.g2d.theme.Theme; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuItem; +import de.extio.game_engine.spatial2.model.CoordI2; +import de.extio.game_engine.spatial2.model.ImmutableCoordI2; + +@SuppressWarnings("serial") +public class CustomPopupMenu extends Component { + + private static final int DEFAULT_PADDING = 6; + + private List items = List.of(); + + private int fontSize; + + private double scaleFactor; + + private boolean enabled; + + private Color backgroundColor; + + private Color foregroundColor; + + private Color selectionColor; + + private int rowHeight; + + private int padding = DEFAULT_PADDING; + + private boolean dirty = true; + + private int hoveredIndex = -1; + + private CoordI2 lastMousePosition; + + private String lastSelectedItemId; + + private final ActionListener actionListener; + + private final ThemeManager themeManager; + + public CustomPopupMenu(final ActionListener listener, final ThemeManager themeManager) { + this.actionListener = listener; + this.themeManager = themeManager; + this.setIgnoreRepaint(true); + + this.addMouseListener(new MouseAdapter() { + + @Override + public void mousePressed(final MouseEvent e) { + CustomPopupMenu.this.dirty = true; + } + + @Override + public void mouseReleased(final MouseEvent e) { + if (!CustomPopupMenu.this.enabled) { + return; + } + final var index = CustomPopupMenu.this.getHoveredIndex(); + if (index < 0 || index >= CustomPopupMenu.this.items.size()) { + return; + } + final var item = CustomPopupMenu.this.items.get(index); + if (item == null || !item.enabled()) { + return; + } + CustomPopupMenu.this.lastSelectedItemId = item.id(); + if (CustomPopupMenu.this.actionListener != null) { + CustomPopupMenu.this.actionListener.actionPerformed(new ActionEvent(this, 0, item.id())); + } + CustomPopupMenu.this.dirty = true; + } + + @Override + public void mouseEntered(final MouseEvent e) { + CustomPopupMenu.this.lastMousePosition = ImmutableCoordI2.create(e.getX(), e.getY()); + CustomPopupMenu.this.updateHover(e.getY()); + } + + @Override + public void mouseExited(final MouseEvent e) { + CustomPopupMenu.this.lastMousePosition = null; + CustomPopupMenu.this.setHoveredIndex(-1); + } + }); + + this.addMouseMotionListener(new MouseMotionListener() { + + @Override + public void mouseMoved(final MouseEvent e) { + CustomPopupMenu.this.lastMousePosition = ImmutableCoordI2.create(e.getX(), e.getY()); + CustomPopupMenu.this.updateHover(e.getY()); + } + + @Override + public void mouseDragged(final MouseEvent e) { + CustomPopupMenu.this.lastMousePosition = ImmutableCoordI2.create(e.getX(), e.getY()); + CustomPopupMenu.this.updateHover(e.getY()); + } + }); + } + + public void setItems(final List items) { + if (Objects.equals(this.items, items)) { + return; + } + this.items = items == null ? List.of() : List.copyOf(items); + this.setHoveredIndex(-1); + this.dirty = true; + } + + public void setFontSize(final int fontSize) { + this.fontSize = fontSize; + } + + public void setScaleFactor(final double scaleFactor) { + this.scaleFactor = scaleFactor; + } + + @Override + public void setEnabled(final boolean enabled) { + if (!enabled) { + this.lastMousePosition = null; + this.setHoveredIndex(-1); + } + this.enabled = enabled; + } + + public void setBackgroundColor(final Color backgroundColor) { + this.backgroundColor = backgroundColor; + } + + public void setForegroundColor(final Color foregroundColor) { + this.foregroundColor = foregroundColor; + } + + public void setSelectionColor(final Color selectionColor) { + this.selectionColor = selectionColor; + } + + public void setRowHeight(final int rowHeight) { + this.rowHeight = rowHeight; + } + + public void setPadding(final int padding) { + this.padding = Math.max(0, padding); + } + + public String getLastSelectedItemId() { + return this.lastSelectedItemId; + } + + public CoordI2 getLastMousePosition() { + return this.lastMousePosition; + } + + public boolean isDirty() { + return this.dirty; + } + + public void setDirty(final boolean dirty) { + this.dirty = dirty; + } + + @Override + public void update(final Graphics g) { + } + + @Override + public void paint(final Graphics g) { + if (this.themeManager == null) { + return; + } + + final var g2d = (Graphics2D) g; + final Theme theme = this.themeManager.getCurrentTheme(); + final var patternRenderer = ((G2DThemeManager) this.themeManager).getPatternRenderer(theme.getPatternRendererName()); + + final var background = this.backgroundColor != null ? this.backgroundColor : theme.getWindowBackground().toColor(); + final var borderColor = theme.getBorderOuter().toColor(); + + if (patternRenderer != null) { + patternRenderer.drawDecorativeBorderFilled(g2d, 0, 0, this.getWidth(), this.getHeight(), 2, borderColor, background); + } + else { + g2d.setColor(background); + g2d.fillRect(0, 0, this.getWidth(), this.getHeight()); + } + + final var selection = this.selectionColor != null ? this.selectionColor : theme.getSelectionPrimary().toColor(); + final var normalText = this.foregroundColor != null ? this.foregroundColor : theme.getTextNormal().toColor(); + final var disabledText = theme.getTextDisabled().toColor(); + + final var rowHeight = this.getEffectiveRowHeight(g2d); + final int paddedWidth = Math.max(1, this.getWidth() - (this.padding * 2)); + + for (int i = 0; i < this.items.size(); i++) { + final var item = this.items.get(i); + if (item == null) { + continue; + } + final var rowY = this.padding + i * rowHeight; + if (rowY > this.getHeight()) { + break; + } + + if (i == this.hoveredIndex) { + g2d.setColor(selection); + g2d.fillRect(this.padding, rowY, paddedWidth, rowHeight); + } + + if (!item.enabled() || !this.enabled) { + g2d.setColor(disabledText); + } + else if (i == this.hoveredIndex) { + g2d.setColor(theme.getTextNormal().adjustBrightness(theme.getHoverBrightnessAdjustment()).toColor()); + } + else { + g2d.setColor(normalText); + } + + final var text = item.label(); + final var textDim = G2DDrawFont.getTextDimensions(text, g2d, this.fontSize, this.scaleFactor); + final int textX = this.padding; + final int textY = rowY + (rowHeight - textDim.getY()) / 2; + G2DDrawFont.renderText(g2d, + textDim, + this.scaleFactor, + (int) (textX / this.scaleFactor), + (int) (textY / this.scaleFactor), + this.fontSize, + text); + } + + this.dirty = false; + } + + private void updateHover(final int mouseY) { + if (!this.enabled) { + this.setHoveredIndex(-1); + return; + } + final var rowHeight = this.getEffectiveRowHeight(null); + if (rowHeight <= 0) { + this.setHoveredIndex(-1); + return; + } + final int index = (mouseY - this.padding) / rowHeight; + if (mouseY < this.padding || index < 0 || index >= this.items.size()) { + this.setHoveredIndex(-1); + } + else { + this.setHoveredIndex(index); + } + } + + private int getHoveredIndex() { + return this.hoveredIndex; + } + + private void setHoveredIndex(final int index) { + if (this.hoveredIndex != index) { + this.hoveredIndex = index; + this.dirty = true; + } + } + + private int getEffectiveRowHeight(final Graphics2D g2d) { + if (this.rowHeight > 0) { + return this.rowHeight; + } + if (g2d == null) { + return (int) (Math.max(G2DDrawFont.FONT_SIZE_MIN, this.fontSize) * G2DDrawFont.FONT_LEADING); + } + final var textDim = G2DDrawFont.getTextDimensions("M", g2d, this.fontSize, 1.0); + return (int) (textDim.getY() * G2DDrawFont.FONT_LEADING); + } +} diff --git a/src/main/java/de/extio/game_engine/renderer/g2d/control/impl/G2DPopupMenuControlImpl.java b/src/main/java/de/extio/game_engine/renderer/g2d/control/impl/G2DPopupMenuControlImpl.java new file mode 100644 index 0000000..74a8e45 --- /dev/null +++ b/src/main/java/de/extio/game_engine/renderer/g2d/control/impl/G2DPopupMenuControlImpl.java @@ -0,0 +1,151 @@ +package de.extio.game_engine.renderer.g2d.control.impl; + +import java.util.Objects; + +import de.extio.game_engine.renderer.g2d.G2DRenderer; +import de.extio.game_engine.renderer.g2d.control.components.CustomPopupMenu; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuData; +import de.extio.game_engine.renderer.model.event.UiControlEvent; +import de.extio.game_engine.renderer.model.color.RgbaColor; + +public class G2DPopupMenuControlImpl extends G2DBaseControlImpl implements PopupMenuControl { + + private CustomPopupMenu control; + + private PopupMenuData popupMenuData; + + private RgbaColor backgroundColor; + + private RgbaColor foregroundColor; + + private RgbaColor selectionColor; + + private int rowHeight; + + private int padding; + + private String lastSelectedItemId; + + @Override + public void setCustomData(final PopupMenuData data) { + this.modified |= !Objects.equals(this.popupMenuData, data); + this.popupMenuData = data; + if (data != null) { + this.setBackgroundColor(data.backgroundColor()); + this.setForegroundColor(data.foregroundColor()); + this.setSelectionColor(data.selectionColor()); + this.setRowHeight(data.rowHeight()); + this.setPadding(data.padding()); + } + } + + @Override + public void build() { + super.build(); + + this.createControl(); + this.initControl(); + final var mainFrame = ((G2DRenderer) this.rendererData.getRenderer()).getMainFrame(); + mainFrame.add(this.control); + this.updateAllComponentZOrder(); + } + + @Override + public void performAction() { + if (this.enabled) { + this.rendererData.getEventService().fire(new UiControlEvent(this.id, this.lastSelectedItemId)); + } + } + + @Override + public void render() { + if (this.modified || this.control.isDirty()) { + this.initControl(); + this.rebuildBufferedImage(); + this.control.paint(this.bufferedImageGraphics); + this.control.setDirty(false); + this.updateAllComponentZOrder(); + } + else if (this.positionModified) { + this.control.setLocation(this.x, this.y); + this.positionModified = false; + } + this.tooltipMousePosition = this.control.getLastMousePosition(); + + super.render(); + } + + @Override + public void close() { + if (this.control.isFocusOwner()) { + ((G2DRenderer) this.rendererData.getRenderer()).getMainFrame().requestFocus(); + } + this.control.setVisible(false); + this.control.invalidate(); + ((G2DRenderer) this.rendererData.getRenderer()).getMainFrame().remove(this.control); + + super.close(); + } + + protected void createControl() { + this.control = new CustomPopupMenu(event -> { + this.lastSelectedItemId = this.control.getLastSelectedItemId(); + if (this.enabled) { + this.performAction(); + } + }, this.rendererData.getThemeManager()); + this.control.setName(this.id); + } + + protected void initControl() { + this.control.setLocation(this.x, this.y); + this.positionModified = false; + this.control.setSize(this.width, this.height); + this.control.setFontSize(this.fontSize); + this.control.setScaleFactor(this.scaleFactor); + this.control.setEnabled(this.enabled); + this.control.setVisible(this.visible); + this.control.setItems(this.popupMenuData != null ? this.popupMenuData.items() : null); + this.control.setBackgroundColor(this.backgroundColor != null ? this.backgroundColor.toAwtColor() : null); + this.control.setForegroundColor(this.foregroundColor != null ? this.foregroundColor.toAwtColor() : null); + this.control.setSelectionColor(this.selectionColor != null ? this.selectionColor.toAwtColor() : null); + if (this.rowHeight > 0) { + this.control.setRowHeight((int) (this.rowHeight * this.scaleFactor)); + } + else { + this.control.setRowHeight(0); + } + if (this.padding > 0) { + this.control.setPadding((int) (this.padding * this.scaleFactor)); + } + else { + this.control.setPadding(0); + } + } + + private void setBackgroundColor(final RgbaColor backgroundColor) { + this.modified |= !Objects.equals(this.backgroundColor, backgroundColor); + this.backgroundColor = backgroundColor; + } + + private void setForegroundColor(final RgbaColor foregroundColor) { + this.modified |= !Objects.equals(this.foregroundColor, foregroundColor); + this.foregroundColor = foregroundColor; + } + + private void setSelectionColor(final RgbaColor selectionColor) { + this.modified |= !Objects.equals(this.selectionColor, selectionColor); + this.selectionColor = selectionColor; + } + + private void setRowHeight(final int rowHeight) { + this.modified |= this.rowHeight != rowHeight; + this.rowHeight = rowHeight; + } + + private void setPadding(final int padding) { + this.modified |= this.padding != padding; + this.padding = padding; + } +} diff --git a/src/main/java/de/extio/game_engine/renderer/model/bo/ControlRenderingBo.java b/src/main/java/de/extio/game_engine/renderer/model/bo/ControlRenderingBo.java index fed1783..aa0bea8 100644 --- a/src/main/java/de/extio/game_engine/renderer/model/bo/ControlRenderingBo.java +++ b/src/main/java/de/extio/game_engine/renderer/model/bo/ControlRenderingBo.java @@ -30,6 +30,10 @@ public interface ControlRenderingBo extends RenderingBoHasDimension { record TableData(List data, int rows, long version, boolean firstColDoubleSize) {} + record PopupMenuItem(String id, String label, boolean enabled) {} + + record PopupMenuData(List items, RgbaColor backgroundColor, RgbaColor foregroundColor, RgbaColor selectionColor, int rowHeight, int padding) {} + record SetFocusData(String focusId) {} /** @@ -177,6 +181,12 @@ public interface ControlRenderingBo extends RenderingBoHasDimension { } + public static interface PopupMenuControl extends BaseControl { + + void setCustomData(PopupMenuData data); + + } + /** * Sets focus on a certain control */ diff --git a/src/test/java/de/extio/game_engine/demo/DemoModule.java b/src/test/java/de/extio/game_engine/demo/DemoModule.java index a2fa41a..59ad9fa 100644 --- a/src/test/java/de/extio/game_engine/demo/DemoModule.java +++ b/src/test/java/de/extio/game_engine/demo/DemoModule.java @@ -22,6 +22,9 @@ import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ButtonControl; import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ButtonData; import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelControl; import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuItem; import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.SwitchControl; import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ToggleButtonControl; import de.extio.game_engine.renderer.model.bo.DrawEffectRenderingBo; @@ -64,6 +67,8 @@ public class DemoModule extends AbstractClientModule { private Window languageSelectionWindow; + private String welcomePopupMenuId; + private ScrollArea scrollArea; private final Map themeSelectionByControlId = new HashMap<>(); @@ -140,6 +145,7 @@ public class DemoModule extends AbstractClientModule { this.getModuleService().changeActiveState(this.themeSelectionWindow.getId(), false); this.getModuleService().changeActiveState(this.scrollAreaWindow.getId(), false); this.getModuleService().changeActiveState(this.languageSelectionWindow.getId(), false); + this.hideWelcomePopupMenu(); } @Override @@ -155,6 +161,7 @@ public class DemoModule extends AbstractClientModule { this.getModuleService().changeActiveState(this.themeSelectionWindow.getId(), false); this.getModuleService().changeActiveState(this.scrollAreaWindow.getId(), false); this.getModuleService().changeActiveState(this.languageSelectionWindow.getId(), false); + this.hideWelcomePopupMenu(); this.audioController.stopMusic(); } @@ -166,9 +173,7 @@ public class DemoModule extends AbstractClientModule { case "DemoModule_MainWindow_Label_Welcome" -> { this.audioController.play(new StaticResource(List.of("audio"), "contact0.ogg")); - this.setupSecondaryWindow(); - this.getModuleService().changeActiveState(this.secondaryWindow.getId(), true); - this.getModuleService().changeDisplayState(this.secondaryWindow.getId(), true); + this.showWelcomePopupMenu(); } case "DemoModule_MainWindow_Label_Start" -> { @@ -197,6 +202,13 @@ public class DemoModule extends AbstractClientModule { this.getModuleService().changeActiveState(this.secondaryWindow.getId(), false); } + case "DemoModule_MainWindow_PopupMenu_Welcome" -> { + this.hideWelcomePopupMenu(); + this.setupSecondaryWindow(); + this.getModuleService().changeActiveState(this.secondaryWindow.getId(), true); + this.getModuleService().changeDisplayState(this.secondaryWindow.getId(), true); + } + default -> { final var themeName = this.themeSelectionByControlId.get(event.getId()); if (themeName != null) { @@ -220,6 +232,51 @@ public class DemoModule extends AbstractClientModule { } } + private void showWelcomePopupMenu() { + this.welcomePopupMenuId = "DemoModule_MainWindow_PopupMenu_Welcome"; + this.mainWindow.removeRenderingBo(this.welcomePopupMenuId); + + final var items = List.of( + new PopupMenuItem("entry-1", "Open", true), + new PopupMenuItem("entry-2", "Details", true), + new PopupMenuItem("entry-3", "Cancel", true)); + final var rowHeight = 36; + final var padding = 8; + final var width = 260; + final var height = (rowHeight * items.size()) + (padding * 2); + + // Center the popup menu on the welcome label + final var labelX = Window.MARGIN_LEFT; + final var labelY = 100; + final var labelWidth = RendererControl.REFERENCE_RESOLUTION.divide(9).multiply(7).substract(Window.MARGIN_LEFT + Window.MARGIN_RIGHT).getX(); + final var labelHeight = 60; + final var labelCenterX = labelX + labelWidth / 2; + final var labelCenterY = labelY + labelHeight / 2; + final var x = labelCenterX - width / 2; + final var y = labelCenterY - height / 2; + + final var data = new PopupMenuData(items, null, null, null, rowHeight, padding); + final var bo = this.renderingBoPool.acquire(this.welcomePopupMenuId, ControlRenderingBo.class) + .setType(PopupMenuControl.class) + .setFontSize(24) + .setVisible(true) + .setEnabled(true) + .setControlData(data) + .withDimensionAbsolute(width, height) + .setLayer(RenderingBoLayer.UI1) + .withPositionRelative(x, y); + this.mainWindow.putRenderingBo(bo); + this.mainWindow.draw(); + } + + private void hideWelcomePopupMenu() { + if (this.welcomePopupMenuId == null) { + return; + } + this.mainWindow.removeRenderingBo(this.welcomePopupMenuId); + this.mainWindow.draw(); + } + private void setupMainWindow() { var bo = this.renderingBoPool.acquire("DemoModule_MainWindow_Label_Welcome", ControlRenderingBo.class) .setCaption(this.localizationService.translate("test-3"))