From 989ed075271b7464876f67eb71aa69d71b5eca0a Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Tue, 10 Nov 2026 14:39:10 +0200 Subject: [PATCH 1/8] Make machine UI elements extensible and self-contained --- docs/block-entities.md | 2 +- docs/capabilities.md | 25 +- docs/custom-fluid-element.md | 378 ++++ docs/gui-screens.md | 1516 ++++++++++++++++- docs/inventory-automation.md | 96 +- docs/machine-companion.md | 6 +- docs/mod-author-guide.md | 4 +- docs/xml-machines.md | 11 + scripts/companion.gradle | 22 + scripts/gui-smoke.init.gradle | 21 +- .../moddev/mc/companion/CompanionMod.java | 39 +- .../moddev/mc/companion/CompanionWidgets.java | 12 +- .../moddev/mc/companion/ProcessorEntity.java | 59 +- .../assets/mmdlib_companion/lang/en_us.json | 4 +- .../resources/mmdlib/screen/crusher.xml | 9 +- .../resources/mmdlib/screen/dual_crusher.xml | 4 +- .../resources/mmdlib/screen/logistics.xml | 12 +- .../resources/mmdlib/screen/transmuter.xml | 2 +- .../companion/test/AutomationClientSmoke.java | 40 +- .../companion/test/AutomationGameTests.java | 18 +- .../test/AutomationRecoveryTests.java | 19 +- .../companion/test/AutomationRoleTests.java | 13 +- .../companion/test/BackpackClientSmoke.java | 27 +- .../test/BackpackInteractionClientSmoke.java | 11 +- .../test/BasePoweredMachineTests.java | 6 +- .../companion/test/CompanionClientSmoke.java | 231 ++- .../mc/companion/test/CompanionGameTests.java | 468 ++++- .../companion/test/CompanionWidgetSmoke.java | 24 +- .../test/ContainerCellClientSmoke.java | 421 +++++ .../test/ContributedHintClientSmoke.java | 367 ++++ .../test/CreativePowerClientSmoke.java | 27 +- .../mc/companion/test/CuriosClientSmoke.java | 29 +- .../mc/companion/test/CuriosFixture.java | 8 +- .../test/DrawerExtensionClientSmoke.java | 153 ++ .../test/DrawerMemoryClientSmoke.java | 123 +- .../test/ElementBehaviorClientSmoke.java | 139 ++ .../test/ElementInheritanceClientSmoke.java | 244 +++ .../test/ElementRendererClientSmoke.java | 243 +++ .../test/FilterElementClientSmoke.java | 346 ++++ .../test/FragmentNavigationClientSmoke.java | 234 +++ .../test/GroupPresentationClientSmoke.java | 357 ++++ .../companion/test/NavigationClientSmoke.java | 238 +++ .../mc/companion/test/PopoutClientSmoke.java | 169 ++ .../test/ProcessorButtonClientSmoke.java | 40 +- .../companion/test/RedstoneClientSmoke.java | 46 +- .../mc/companion/test/RedstoneSmoke.java | 7 +- .../test/ReservationClientSmoke.java | 9 +- .../test/SimpleCrusherClientSmoke.java | 96 ++ .../test/SimpleLavaFurnaceTests.java | 99 +- .../companion/test/SlotPolicyClientSmoke.java | 243 +++ .../test/SlotTooltipClientSmoke.java | 135 ++ .../mc/companion/test/StatsClientSmoke.java | 59 +- .../test/StatsExtensionClientSmoke.java | 176 ++ .../mc/companion/test/TooltipClientSmoke.java | 197 +++ .../companion/test/ValueTypeClientSmoke.java | 148 ++ .../mc/companion/test/ValueTypeFixture.java | 75 + .../api/automation/AutomationInventory.java | 117 +- .../mc/lib/api/automation/FilterControl.java | 576 ++++++- .../lib/api/automation/InventoryFilter.java | 39 +- .../mc/lib/api/automation/ResourceGroup.java | 15 + .../lib/api/capabilities/ItemInventories.java | 319 ++++ .../mc/lib/api/capabilities/ItemStorage.java | 17 +- .../mc/lib/api/capabilities/ItemStore.java | 46 + .../api/capabilities/ReservationReceiver.java | 8 + .../runtime => api/stats}/CounterRate.java | 4 +- .../moddev/mc/lib/api/stats/Statistics.java | 59 +- .../mc/lib/api/stats/StatsFormatContext.java | 30 + .../runtime => api/stats}/StatsNumbers.java | 6 +- .../lib/api/stats/StatsSamplingContext.java | 24 + .../mc/lib/api/stats/StatsTimescale.java | 48 + .../mc/lib/client/jei/JeiIngredientTypes.java | 42 + .../moddev/mc/lib/client/jei/MachineJei.java | 74 +- .../mc/lib/compat/curios/CuriosEquipment.java | 50 +- .../zone/moddev/mc/lib/gui/ElementAction.java | 93 + .../moddev/mc/lib/gui/ElementActions.java | 59 + .../mc/lib/gui/ElementInteractions.java | 50 + .../zone/moddev/mc/lib/gui/ElementType.java | 99 ++ .../zone/moddev/mc/lib/gui/WidgetAction.java | 81 +- .../zone/moddev/mc/lib/gui/WidgetType.java | 10 +- .../lib/gui/client/BehaviorCapabilities.java | 15 + .../lib/gui/client/BuiltinElementClients.java | 128 ++ .../mc/lib/gui/client/ButtonBehavior.java | 20 + .../mc/lib/gui/client/ButtonRenderer.java | 46 + ...lorButtonBar.java => ColourButtonBar.java} | 63 +- .../lib/gui/client/ColouredGroupBehavior.java | 24 + .../lib/gui/client/ColouredGroupRenderer.java | 17 + .../lib/gui/client/ContainerCellRenderer.java | 63 + .../mc/lib/gui/client/ContainerDrawer.java | 611 ++++--- .../gui/client/ContainerElementRenderer.java | 53 + .../mc/lib/gui/client/ContextBehavior.java | 38 + .../lib/gui/client/CustomWidgetBehavior.java | 139 ++ .../gui/client/DefaultElementBehavior.java | 63 + .../zone/moddev/mc/lib/gui/client/Drawer.java | 209 +++ .../mc/lib/gui/client/DrawerFactories.java | 67 + .../mc/lib/gui/client/DrawerMemory.java | 202 +-- .../moddev/mc/lib/gui/client/Drawers.java | 202 +++ .../mc/lib/gui/client/ElementAccess.java | 61 + .../mc/lib/gui/client/ElementBehavior.java | 97 ++ .../mc/lib/gui/client/ElementBehaviors.java | 63 + .../mc/lib/gui/client/ElementClient.java | 84 + .../mc/lib/gui/client/ElementDrawing.java | 47 + .../mc/lib/gui/client/ElementFragment.java | 446 +++++ .../mc/lib/gui/client/ElementHighlight.java | 50 + .../mc/lib/gui/client/ElementHover.java | 31 + .../mc/lib/gui/client/ElementInput.java | 315 ++++ .../lib/gui/client/ElementRenderAccess.java | 95 ++ .../mc/lib/gui/client/ElementRenderers.java | 287 ++++ .../mc/lib/gui/client/ElementTooltips.java | 117 ++ .../lib/gui/client/EnergyGaugeRenderer.java | 31 +- .../gui/client/FilterControlPresentation.java | 6 + .../lib/gui/client/FilterCustomBehavior.java | 82 + .../mc/lib/gui/client/FilterDrawing.java | 25 + .../moddev/mc/lib/gui/client/FilterEdits.java | 19 + .../lib/gui/client/FilterNumberBehavior.java | 65 + .../lib/gui/client/FilterSamplesBehavior.java | 47 + .../mc/lib/gui/client/FilterTextBehavior.java | 94 + .../lib/gui/client/FilterToggleBehavior.java | 50 + .../mc/lib/gui/client/GaugeRenderer.java | 30 +- .../moddev/mc/lib/gui/client/GhostItems.java | 11 +- .../mc/lib/gui/client/GroupBehavior.java | 20 + .../mc/lib/gui/client/GroupLabelProvider.java | 10 + .../lib/gui/client/HighlightPresentation.java | 13 + .../moddev/mc/lib/gui/client/IconSprite.java | 32 + .../lib/gui/client/IngredientDropHandler.java | 23 + .../mc/lib/gui/client/IngredientDrops.java | 57 + .../mc/lib/gui/client/IngredientType.java | 20 + .../mc/lib/gui/client/IngredientTypes.java | 17 + .../mc/lib/gui/client/MachineScreen.java | 1225 ++++--------- .../mc/lib/gui/client/MeterRenderer.java | 31 + .../mc/lib/gui/client/NativeSlotRenderer.java | 151 ++ .../mc/lib/gui/client/NavigableBehavior.java | 25 + .../mc/lib/gui/client/NavigationMemory.java | 57 + .../mc/lib/gui/client/OptionSelection.java | 22 + .../mc/lib/gui/client/PopoutDrawer.java | 172 ++ .../mc/lib/gui/client/PopoutRenderer.java | 29 + .../mc/lib/gui/client/RadioBehavior.java | 20 + .../mc/lib/gui/client/RadioGroupBehavior.java | 34 + .../mc/lib/gui/client/RedstoneDrawer.java | 104 +- .../mc/lib/gui/client/ReservationOverlay.java | 55 +- .../moddev/mc/lib/gui/client/ScreenText.java | 74 +- .../mc/lib/gui/client/SideConfigBehavior.java | 53 + ...ationDrawer.java => SideConfigDrawer.java} | 654 ++++--- .../gui/client/SideConfigPresentation.java | 8 + .../mc/lib/gui/client/SideConfigRenderer.java | 72 + .../mc/lib/gui/client/SideDrawerRenderer.java | 19 +- .../mc/lib/gui/client/SlotBehavior.java | 64 + .../mc/lib/gui/client/SlotRenderer.java | 53 + .../mc/lib/gui/client/SpriteRenderer.java | 35 + .../moddev/mc/lib/gui/client/StatsDrawer.java | 185 +- .../lib/gui/client/TextControlRenderer.java | 39 + .../mc/lib/gui/client/ValueLabelProvider.java | 10 + .../mc/lib/gui/client/WidgetAccess.java | 4 +- .../moddev/mc/lib/gui/client/WidgetInput.java | 208 +-- .../mc/lib/gui/client/WidgetInstance.java | 62 +- .../mc/lib/gui/client/WidgetRenderer.java | 6 +- .../mc/lib/gui/client/WidgetRenderers.java | 44 +- .../mc/lib/gui/drawer/BuiltinDrawers.java | 108 ++ .../mc/lib/gui/drawer/DrawerDefinition.java | 30 + .../mc/lib/gui/drawer/DrawerPlacement.java | 38 + .../mc/lib/gui/drawer/DrawerRegistry.java | 94 + .../mc/lib/gui/layout/AutomationLayout.java | 28 +- .../lib/gui/layout/ContainerDrawerLayout.java | 97 +- .../moddev/mc/lib/gui/layout/DrawerTabs.java | 74 + .../lib/gui/layout/ElementFragmentLayout.java | 82 + .../mc/lib/gui/layout/ElementLayouts.java | 67 + .../mc/lib/gui/layout/FilterPanelLayout.java | 50 +- .../moddev/mc/lib/gui/layout/FlowLayout.java | 44 + .../mc/lib/gui/layout/GroupHighlights.java | 66 + .../moddev/mc/lib/gui/layout/GroupOrder.java | 24 + .../lib/gui/layout/InventoryColorOrder.java | 34 - .../mc/lib/gui/layout/LayoutContext.java | 56 + .../mc/lib/gui/layout/LayoutContribution.java | 70 + .../mc/lib/gui/layout/ScreenLayout.java | 571 ++----- .../mc/lib/gui/layout/SideDrawerLayout.java | 12 + .../moddev/mc/lib/gui/load/BoundValue.java | 92 +- .../mc/lib/gui/load/BuiltinElements.java | 518 ++++++ .../gui/load/ResourceValidationContext.java | 29 + .../mc/lib/gui/load/ScreenAttribute.java | 39 + .../moddev/mc/lib/gui/load/ScreenBinder.java | 84 +- .../moddev/mc/lib/gui/load/ScreenLoader.java | 31 +- .../moddev/mc/lib/gui/load/ScreenReader.java | 616 +------ .../moddev/mc/lib/gui/load/ScreenSchemas.java | 201 +++ .../moddev/mc/lib/gui/load/ScreenTag.java | 202 +++ .../moddev/mc/lib/gui/load/ScreenTags.java | 90 + .../mc/lib/gui/load/ScreenTemplates.java | 59 +- .../mc/lib/gui/load/ScreenValidation.java | 93 + .../lib/gui/load/ScreenValidationContext.java | 31 + .../moddev/mc/lib/gui/load/ScreenXml.java | 277 +++ .../zone/moddev/mc/lib/gui/model/Align.java | 11 + .../zone/moddev/mc/lib/gui/model/Common.java | 51 +- .../mc/lib/gui/model/DisplayValues.java | 52 + .../zone/moddev/mc/lib/gui/model/Edge.java | 9 + .../zone/moddev/mc/lib/gui/model/Element.java | 687 +++----- .../moddev/mc/lib/gui/model/ElementGroup.java | 35 + .../mc/lib/gui/model/ElementSnapshot.java | 4 + .../moddev/mc/lib/gui/model/GaugeFill.java | 36 + .../moddev/mc/lib/gui/model/Interaction.java | 13 + .../mc/lib/gui/model/InventoryScope.java | 39 + .../moddev/mc/lib/gui/model/Navigation.java | 74 + .../moddev/mc/lib/gui/model/Orientation.java | 9 + .../moddev/mc/lib/gui/model/PopoutKind.java | 11 + .../mc/lib/gui/model/ProgressDirection.java | 13 + .../mc/lib/gui/model/ResourceBinding.java | 22 + .../zone/moddev/mc/lib/gui/model/Screen.java | 58 +- .../moddev/mc/lib/gui/model/SlotKind.java | 11 + .../moddev/mc/lib/gui/model/SlotSize.java | 9 + .../moddev/mc/lib/gui/model/TextMarkup.java | 42 + .../mc/lib/gui/model/TooltipContext.java | 46 + .../mc/lib/gui/model/ValueContract.java | 21 + .../zone/moddev/mc/lib/gui/model/ViewRef.java | 22 + .../AutomationInventoryElement.java | 147 ++ .../lib/gui/model/containers/BoxElement.java | 172 ++ .../containers/ColouredGroupElement.java | 133 ++ .../containers/ContainerInventoryElement.java | 115 ++ .../gui/model/containers/ContextElement.java | 195 +++ .../gui/model/containers/GroupElement.java | 119 ++ .../containers/InventoryControlsElement.java | 72 + .../model/containers/InventoryElement.java | 382 +++++ .../model/containers/NavigableElement.java | 17 + .../model/containers/PlayerArmourElement.java | 37 + .../containers/PlayerInventoryElement.java | 219 +++ .../containers/PlayerOffhandElement.java | 37 + .../model/containers/PlayerSlotsElement.java | 105 ++ .../model/containers/RadioGroupElement.java | 104 ++ .../model/containers/SelectiveContainer.java | 9 + .../gui/model/containers/SideTabElement.java | 84 + .../gui/model/containers/SpacerElement.java | 46 + .../lib/gui/model/containers/ViewElement.java | 84 + .../gui/model/containers/package-info.java | 7 + .../model/elements/AnimatedSpriteElement.java | 70 + .../model/elements/AutomationSlotElement.java | 24 + .../lib/gui/model/elements/ButtonControl.java | 32 + .../lib/gui/model/elements/ButtonElement.java | 204 +++ .../gui/model/elements/CheckboxElement.java | 70 + .../model/elements/ContainerSlotElement.java | 95 ++ .../lib/gui/model/elements/CustomElement.java | 133 ++ .../model/elements/EnergyGaugeElement.java | 134 ++ .../gui/model/elements/FluidTankElement.java | 330 ++++ .../lib/gui/model/elements/GaugeElement.java | 135 ++ .../gui/model/elements/GhostSlotElement.java | 122 ++ .../gui/model/elements/ItemSlotElement.java | 237 +++ .../lib/gui/model/elements/LabelElement.java | 55 + .../lib/gui/model/elements/MeterElement.java | 164 ++ .../gui/model/elements/PlayerSlotElement.java | 162 ++ .../lib/gui/model/elements/PopoutElement.java | 178 ++ .../elements/ProcessorButtonElement.java | 203 +++ .../gui/model/elements/ProgressElement.java | 99 ++ .../model/elements/ProgressSpriteElement.java | 127 ++ .../lib/gui/model/elements/RadioElement.java | 55 + .../gui/model/elements/SideConfigElement.java | 156 ++ .../lib/gui/model/elements/SlotElement.java | 429 +++++ .../lib/gui/model/elements/SpriteElement.java | 87 + .../lib/gui/model/elements/package-info.java | 7 + .../moddev/mc/lib/gui/model/package-info.java | 8 +- .../mc/lib/gui/runtime/AutomationMenu.java | 307 +++- .../mc/lib/gui/runtime/AutomationNetwork.java | 18 +- .../mc/lib/gui/runtime/ClientMenuItems.java | 3 +- .../lib/gui/runtime/ContainerDrawerMenu.java | 248 +-- .../gui/runtime/ContainerDrawerNetwork.java | 186 +- .../mc/lib/gui/runtime/ContainerPolicy.java | 152 ++ .../mc/lib/gui/runtime/ContainerTargets.java | 92 + .../lib/gui/runtime/ElementActionSession.java | 191 +++ .../lib/gui/runtime/EquippedContainers.java | 181 +- .../mc/lib/gui/runtime/InventoryBinding.java | 25 + .../gui/runtime/InventoryMenuComponent.java | 132 ++ .../mc/lib/gui/runtime/MachineMenu.java | 783 +++------ .../mc/lib/gui/runtime/MenuRequests.java | 94 + .../lib/gui/runtime/PlayerEquipmentSlot.java | 71 - .../mc/lib/gui/runtime/RedstoneMenu.java | 62 +- .../mc/lib/gui/runtime/RedstoneNetwork.java | 18 +- .../mc/lib/gui/runtime/ReservationSlot.java | 87 - .../moddev/mc/lib/gui/runtime/ScreenHost.java | 149 +- .../runtime/ScreenInventoryValidation.java | 33 +- .../mc/lib/gui/runtime/ScreenNetwork.java | 124 +- .../mc/lib/gui/runtime/ScreenRuntime.java | 29 +- .../mc/lib/gui/runtime/ScreenSnapshots.java | 36 +- .../mc/lib/gui/runtime/ScreenStats.java | 99 +- .../mc/lib/gui/runtime/ScreenValues.java | 101 +- .../lib/gui/runtime/StatsMenuComponent.java | 81 + .../moddev/mc/lib/gui/runtime/StatsPage.java | 27 + .../lib/gui/runtime/TooltipMenuComponent.java | 119 ++ .../component/BuiltinMenuComponents.java | 78 + .../runtime/component/ComponentSnapshot.java | 8 + .../runtime/component/ComponentStateView.java | 6 + .../runtime/component/ComponentStates.java | 132 ++ .../gui/runtime/component/ComponentSync.java | 80 + .../gui/runtime/component/DrawerSession.java | 10 + .../gui/runtime/component/DrawerSessions.java | 36 + .../runtime/component/InventoryAccess.java | 18 + .../gui/runtime/component/MenuComponent.java | 27 + .../runtime/component/MenuComponentKey.java | 13 + .../component/MenuComponentRegistry.java | 157 ++ .../gui/runtime/component/MenuComponents.java | 120 ++ .../component/MenuSlotContributor.java | 26 + .../gui/runtime/component/MenuSnapshot.java | 13 + .../gui/runtime/component/ServerTooltips.java | 12 + .../gui/runtime/network/BuiltinNetworks.java | 27 + .../gui/runtime/network/NetworkRegistry.java | 139 ++ .../mc/lib/gui/runtime/slot/MenuSlots.java | 179 ++ .../slot/ReservationMenuComponent.java | 84 + .../mc/lib/gui/runtime/slot/SlotAdapter.java | 27 + .../gui/runtime/slot/SlotTransferContext.java | 46 + .../mc/lib/gui/sync/ElementStateValue.java | 30 + .../mc/lib/gui/sync/ElementStateView.java | 15 + .../moddev/mc/lib/gui/sync/ElementStates.java | 166 ++ .../moddev/mc/lib/gui/sync/ElementSync.java | 145 ++ .../mc/lib/gui/sync/SynchronizedElement.java | 10 + .../moddev/mc/lib/gui/value/ValueType.java | 236 +++ .../mc/lib/gui/value/ValueTypeRegistry.java | 139 ++ .../moddev/mc/lib/gui/value/ValueTypes.java | 187 ++ .../moddev/mc/lib/gui/value/package-info.java | 8 + .../machine/definition/MachineDefinition.java | 15 + .../definition/MachineDefinitions.java | 12 +- .../definition/MachineScreenGenerators.java | 170 +- .../mc/lib/machine/neoforge/BaseMachine.java | 9 +- .../neoforge/DefinedMachineEntity.java | 67 +- .../machine/neoforge/FluidTankTooltip.java | 73 - .../lib/machine/neoforge/MachineContent.java | 10 +- .../neoforge/MachineInventoryLifecycle.java | 10 +- .../machine/neoforge/MachineResources.java | 57 +- .../lib/machine/neoforge/StorageGroups.java | 25 +- .../resources/assets/mmdlib/lang/en_us.json | 8 +- src/main/resources/mmdlib/schema/screen.xsd | 121 +- .../mc/lib/smoke/AutomationSlotSmoke.java | 225 +++ .../mc/lib/smoke/ClientMenuItemsSmoke.java | 33 +- .../mc/lib/smoke/ContainerDrawerSmoke.java | 99 +- .../mc/lib/smoke/ContainerPolicySmoke.java | 226 +++ .../moddev/mc/lib/smoke/CrusherSmoke.java | 52 +- .../moddev/mc/lib/smoke/GuiClientSmoke.java | 57 +- .../moddev/mc/lib/smoke/GuiGaugeSmoke.java | 6 +- .../moddev/mc/lib/smoke/GuiGhostSmoke.java | 96 ++ .../mc/lib/smoke/GuiIngredientDropSmoke.java | 323 ++++ .../zone/moddev/mc/lib/smoke/GuiJeiSmoke.java | 50 + .../moddev/mc/lib/smoke/GuiRuntimeChecks.java | 88 +- .../lib/smoke/InventoryArchitectureSmoke.java | 226 +++ .../mc/lib/smoke/InventoryBindingSmoke.java | 178 ++ .../moddev/mc/lib/smoke/MMDLibGameTests.java | 15 + .../moddev/mc/lib/smoke/MachineSmoke.java | 7 + .../mc/lib/smoke/MenuComponentSmoke.java | 142 ++ .../mc/lib/smoke/PlayerEquipmentSmoke.java | 160 +- .../mc/lib/smoke/ReservationSyncSmoke.java | 218 +++ .../moddev/mc/lib/smoke/SlotPolicySmoke.java | 447 +++++ .../moddev/mc/lib/smoke/StatsPageSmoke.java | 95 ++ .../mc/lib/gui/ColourButtonBarTest.java | 126 ++ .../mc/lib/gui/ComponentStatesTest.java | 174 ++ .../mc/lib/gui/ContainerDrawerLayoutTest.java | 86 +- .../moddev/mc/lib/gui/CustomWidgetsTest.java | 37 +- .../moddev/mc/lib/gui/DrawerRegistryTest.java | 207 +++ .../mc/lib/gui/ElementBehaviorsTest.java | 258 +++ .../moddev/mc/lib/gui/ElementClickTest.java | 114 ++ .../moddev/mc/lib/gui/ElementHoverTest.java | 128 ++ .../mc/lib/gui/ElementInheritanceTest.java | 224 +++ .../mc/lib/gui/ElementInteractionTest.java | 143 ++ .../moddev/mc/lib/gui/ElementLimitsTest.java | 106 ++ .../mc/lib/gui/ElementRegistrationTest.java | 190 +++ .../mc/lib/gui/ElementRenderersTest.java | 284 +++ .../moddev/mc/lib/gui/ElementSyncTest.java | 215 +++ .../mc/lib/gui/ElementValidationTest.java | 195 +++ .../mc/lib/gui/EnergyGaugeElementTest.java | 82 + .../moddev/mc/lib/gui/EnergyRateTest.java | 2 +- .../moddev/mc/lib/gui/FilterElementsTest.java | 178 ++ .../moddev/mc/lib/gui/FragmentLayoutTest.java | 95 ++ .../zone/moddev/mc/lib/gui/GaugeFillTest.java | 35 + .../mc/lib/gui/GroupHighlightsTest.java | 154 ++ .../moddev/mc/lib/gui/GroupOrderTest.java | 137 ++ .../mc/lib/gui/GroupPresentationTest.java | 118 ++ .../mc/lib/gui/IngredientDropsTest.java | 141 ++ .../lib/gui/InventoryColorButtonBarTest.java | 107 -- .../mc/lib/gui/InventoryElementTest.java | 204 +++ .../mc/lib/gui/LayoutContributionTest.java | 177 ++ .../moddev/mc/lib/gui/MenuComponentsTest.java | 90 + .../moddev/mc/lib/gui/ModElementTest.java | 110 ++ .../moddev/mc/lib/gui/NavigationTest.java | 250 +++ .../mc/lib/gui/NetworkRegistryTest.java | 145 ++ .../moddev/mc/lib/gui/PlayerElementsTest.java | 185 ++ .../mc/lib/gui/PlayerInventoryLayoutTest.java | 12 +- .../moddev/mc/lib/gui/PopoutElementTest.java | 99 ++ .../mc/lib/gui/ProcessorButtonTest.java | 18 +- .../moddev/mc/lib/gui/RadioSelectionTest.java | 220 +++ .../mc/lib/gui/ResourceValidationTest.java | 99 ++ .../mc/lib/gui/RuntimeDrawerLayoutTest.java | 58 +- .../mc/lib/gui/ScreenFillGeneratorTest.java | 97 +- .../mc/lib/gui/ScreenInheritanceTest.java | 59 +- .../moddev/mc/lib/gui/ScreenLoaderTest.java | 157 +- .../moddev/mc/lib/gui/ScreenTagsTest.java | 290 ++++ .../moddev/mc/lib/gui/SpriteElementsTest.java | 166 ++ .../mc/lib/gui/TooltipCompositionTest.java | 195 +++ .../moddev/mc/lib/gui/ValueTypesTest.java | 258 +++ .../moddev/mc/lib/gui/WidgetInputTest.java | 9 +- .../mc/lib/gui/runtime/CycleRateTest.java | 2 + .../mc/lib/gui/runtime/MenuRequestsTest.java | 109 ++ .../mc/lib/gui/runtime/ScreenStatsTest.java | 8 +- .../lib/gui/runtime/StatsExtensionTest.java | 244 +++ .../lib/machine/MachineDefinitionsTest.java | 2 +- 394 files changed, 37113 insertions(+), 6403 deletions(-) create mode 100644 docs/custom-fluid-element.md create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/ContainerCellClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/ContributedHintClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/DrawerExtensionClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/ElementBehaviorClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/ElementInheritanceClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/ElementRendererClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/FilterElementClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/FragmentNavigationClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/GroupPresentationClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/NavigationClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/PopoutClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/SlotPolicyClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/SlotTooltipClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/StatsExtensionClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/TooltipClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/ValueTypeClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/ValueTypeFixture.java create mode 100644 src/main/java/zone/moddev/mc/lib/api/capabilities/ItemInventories.java create mode 100644 src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStore.java create mode 100644 src/main/java/zone/moddev/mc/lib/api/capabilities/ReservationReceiver.java rename src/main/java/zone/moddev/mc/lib/{gui/runtime => api/stats}/CounterRate.java (89%) create mode 100644 src/main/java/zone/moddev/mc/lib/api/stats/StatsFormatContext.java rename src/main/java/zone/moddev/mc/lib/{gui/runtime => api/stats}/StatsNumbers.java (79%) create mode 100644 src/main/java/zone/moddev/mc/lib/api/stats/StatsSamplingContext.java create mode 100644 src/main/java/zone/moddev/mc/lib/api/stats/StatsTimescale.java create mode 100644 src/main/java/zone/moddev/mc/lib/client/jei/JeiIngredientTypes.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/ElementAction.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/ElementActions.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/ElementInteractions.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/ElementType.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/BehaviorCapabilities.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/BuiltinElementClients.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ButtonBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ButtonRenderer.java rename src/main/java/zone/moddev/mc/lib/gui/client/{InventoryColorButtonBar.java => ColourButtonBar.java} (61%) create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ColouredGroupBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ColouredGroupRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ContainerCellRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ContainerElementRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ContextBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/CustomWidgetBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/DefaultElementBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/Drawer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/DrawerFactories.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/Drawers.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementAccess.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementBehaviors.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementClient.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementDrawing.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementFragment.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementHighlight.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementHover.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementInput.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementRenderAccess.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementRenderers.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ElementTooltips.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/FilterControlPresentation.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/FilterCustomBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/FilterDrawing.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/FilterEdits.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/FilterNumberBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/FilterSamplesBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/FilterTextBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/FilterToggleBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/GroupBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/GroupLabelProvider.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/HighlightPresentation.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/IconSprite.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/IngredientDropHandler.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/IngredientDrops.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/IngredientType.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/IngredientTypes.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/MeterRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/NativeSlotRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/NavigableBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/NavigationMemory.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/OptionSelection.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/PopoutDrawer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/PopoutRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/RadioBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/RadioGroupBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/SideConfigBehavior.java rename src/main/java/zone/moddev/mc/lib/gui/client/{AutomationDrawer.java => SideConfigDrawer.java} (50%) create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/SideConfigPresentation.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/SideConfigRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/SlotBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/SlotRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/SpriteRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/TextControlRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/ValueLabelProvider.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/drawer/BuiltinDrawers.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerDefinition.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerPlacement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerRegistry.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/layout/DrawerTabs.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/layout/ElementFragmentLayout.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/layout/ElementLayouts.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/layout/FlowLayout.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/layout/GroupHighlights.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/layout/GroupOrder.java delete mode 100644 src/main/java/zone/moddev/mc/lib/gui/layout/InventoryColorOrder.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/layout/LayoutContext.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/layout/LayoutContribution.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/load/BuiltinElements.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/load/ResourceValidationContext.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/load/ScreenAttribute.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/load/ScreenSchemas.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/load/ScreenTag.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/load/ScreenTags.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/load/ScreenValidation.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/load/ScreenValidationContext.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/load/ScreenXml.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/Align.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/DisplayValues.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/Edge.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/ElementGroup.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/ElementSnapshot.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/GaugeFill.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/Interaction.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/InventoryScope.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/Navigation.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/Orientation.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/PopoutKind.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/ProgressDirection.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/ResourceBinding.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/SlotKind.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/SlotSize.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/TextMarkup.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/TooltipContext.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/ValueContract.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/ViewRef.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/AutomationInventoryElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/BoxElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/ColouredGroupElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/ContainerInventoryElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/ContextElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/GroupElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/InventoryControlsElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/InventoryElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/NavigableElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/PlayerArmourElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/PlayerInventoryElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/PlayerOffhandElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/PlayerSlotsElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/RadioGroupElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/SelectiveContainer.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/SideTabElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/SpacerElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/ViewElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/containers/package-info.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/AnimatedSpriteElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/AutomationSlotElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/ButtonControl.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/ButtonElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/CheckboxElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/ContainerSlotElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/CustomElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/EnergyGaugeElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/FluidTankElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/GaugeElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/GhostSlotElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/ItemSlotElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/LabelElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/MeterElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/PlayerSlotElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/PopoutElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/ProcessorButtonElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/ProgressElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/ProgressSpriteElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/RadioElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/SideConfigElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/SlotElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/SpriteElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/package-info.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerPolicy.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerTargets.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/ElementActionSession.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/InventoryBinding.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/InventoryMenuComponent.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/MenuRequests.java delete mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/PlayerEquipmentSlot.java delete mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/ReservationSlot.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/StatsMenuComponent.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/StatsPage.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/TooltipMenuComponent.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/BuiltinMenuComponents.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/ComponentSnapshot.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/ComponentStateView.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/ComponentStates.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/ComponentSync.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/DrawerSession.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/DrawerSessions.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/InventoryAccess.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/MenuComponent.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/MenuComponentKey.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/MenuComponentRegistry.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/MenuComponents.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/MenuSlotContributor.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/MenuSnapshot.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/component/ServerTooltips.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/network/BuiltinNetworks.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/network/NetworkRegistry.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/slot/MenuSlots.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/slot/ReservationMenuComponent.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/slot/SlotAdapter.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/runtime/slot/SlotTransferContext.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/sync/ElementStateValue.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/sync/ElementStateView.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/sync/ElementStates.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/sync/ElementSync.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/sync/SynchronizedElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/value/ValueType.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/value/ValueTypeRegistry.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/value/ValueTypes.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/value/package-info.java delete mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/FluidTankTooltip.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/AutomationSlotSmoke.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerPolicySmoke.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiGhostSmoke.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiIngredientDropSmoke.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/InventoryBindingSmoke.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/MenuComponentSmoke.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReservationSyncSmoke.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/SlotPolicySmoke.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/StatsPageSmoke.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ColourButtonBarTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ComponentStatesTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/DrawerRegistryTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ElementBehaviorsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ElementClickTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ElementHoverTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ElementInheritanceTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ElementInteractionTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ElementLimitsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ElementRegistrationTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ElementRenderersTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ElementSyncTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ElementValidationTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/EnergyGaugeElementTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/FilterElementsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/FragmentLayoutTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/GaugeFillTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/GroupHighlightsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/GroupOrderTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/GroupPresentationTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/IngredientDropsTest.java delete mode 100644 src/test/java/zone/moddev/mc/lib/gui/InventoryColorButtonBarTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/InventoryElementTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/LayoutContributionTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/MenuComponentsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ModElementTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/NavigationTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/NetworkRegistryTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/PlayerElementsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/PopoutElementTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/RadioSelectionTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ResourceValidationTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ScreenTagsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/SpriteElementsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/TooltipCompositionTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ValueTypesTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/runtime/MenuRequestsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/runtime/StatsExtensionTest.java diff --git a/docs/block-entities.md b/docs/block-entities.md index 5ab08b03..84a72540 100644 --- a/docs/block-entities.md +++ b/docs/block-entities.md @@ -182,7 +182,7 @@ tanks must be registered with the lifecycle to participate in persistence, clear | Base behavior | Subclass extension | |---|---| -| `getEnergy()`, `getEnergyCapacity()` | Backed by the owned buffer | +| `screenEnergy()` | Exposes the owned buffer to `EnergyGaugeElement`, which supplies its own snapshots and tooltip | | `getProgress()`, `getTime()`, `getPaused()` | First registered recipe processor; zero/false if none | | `screenFeatures()`, `screenStats()` | Owned host; statistics include energy and all feature statistics providers | | `screenAutomation()` | Lifecycle's automation inventory, when configured | diff --git a/docs/capabilities.md b/docs/capabilities.md index 0e759766..47aecd1c 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -37,6 +37,11 @@ directly, and automation gets per-face views of it. machine facing up or down, `TOP` is the north side. `RelativeFace.facing(state)` reads the `facing` or `horizontal_facing` property, and defaults to north when the state has neither. +`ScreenHost.screenFluids()` exposes storage to resource-bound screen elements. `FluidTankElement` +owns the player view for its selected tank: manual extraction is allowed from input/manual tanks, +while insertion filters, output-role restrictions and reservations remain enforced by storage. +Side modes apply to automation. The storage has no tooltip or cursor-container interaction code. + ## Reservations `ItemStorage` and the client menu handler share `PlannedItemHandler` for insertion, extraction and @@ -70,6 +75,21 @@ loads, so load the storages first. ## Stable storage identities and recovery +`ItemInventories` groups named, **independent `ItemStorage` handlers**. Each handler owns its +contents, limits, filters, revisions and reservation plans. `inventory("input")` returns that +physical handler; `address(global)` / `index(name, local)` translate the optional combined view +used by block capabilities and legacy callers. Both `ItemStorage` and `ItemInventories` implement +`ItemStore`, so recipe groups and `MachineInventoryLifecycle` support either. Transactions track +the physical handler and local index even when several recipe aliases address the same slot. + +Named saves store each handler under `Inventories/`, with slot identities scoped to that +name. Inventory order can change without moving contents between groups. Loading also accepts +old flat `ItemStorage` saves: globally unique old slot IDs and legacy numeric mappings migrate +into the configured handlers. Removed inventories and reduced limits retain displaced entries +in `Recovery`; restoring the inventory makes those entries available again. Keep inventory names +stable as well as slot IDs. `ItemInventories.partition(names)` allocates handlers while retaining +an existing combined slot order, including interleaved groups. + Assign durable identities with `items.slotId(index, "input")` and `fluids.tankId(index, "coolant")`. IDs must be nonblank and unique within that storage. They are save identities, separate from numeric recipe/menu indexes and from resource-group or XML element IDs. @@ -175,8 +195,9 @@ is checked every tick, keep a NeoForge `BlockCapabilityCache` instead. - `ItemStorage` keeps its configured slot count and `FluidStorage` its configured tanks. Entries that cannot be restored remain in recovery; see stable identities above. -- `EnergyBuffer` stores up to `Long.MAX_VALUE`. Screens show the full `long` through a getter - such as `#getEnergy` (`stored()`), while other mods see at most `Integer.MAX_VALUE`. +- `EnergyBuffer` stores up to `Long.MAX_VALUE`. `EnergyGaugeElement` reads the full `long` + from `ScreenHost.screenEnergy()` and owns its UI snapshots and tooltip. Other mods see at + most `Integer.MAX_VALUE` through the automation capability. - The runtime smoke test covers roles, filters, limits, faces, out-of-range indices, transfer, NBT round trips, int clamping, and the face mapping. Real cables and pipes still need testing in game. diff --git a/docs/custom-fluid-element.md b/docs/custom-fluid-element.md new file mode 100644 index 00000000..8833f964 --- /dev/null +++ b/docs/custom-fluid-element.md @@ -0,0 +1,378 @@ +# A complete custom fluid element + +This example targets **NeoForge 1.21.1 / Java 21** and the current MMDLib API. It adds +a water-only, 4,000 mB tank with persistent storage, a custom gauge, cursor-container +transfers, and a namespaced XML tag. All Java files below include their imports. + +The example plugs into a mod with ID `demo` and an existing registered machine block +and block-entity type `demo:machine`. Keep your normal block/item/type registration, +assets and MMDLib dependency declaration; those are outside this element example. +Use the host class below as that type's block entity, passing its registered type to +the constructor. Replace `demo` and `com.example.demo` consistently for your mod. + +The responsibilities are: + +| Piece | Owns | +|---|---| +| `MyFluidStorage` | Capacity, accepted fluids and change notifications | +| `MyMachineBlockEntity` | Storage lifetime and persistence | +| `MyFluidTankElement` | Server snapshots, tooltip rows and transfer clicks | +| `MyElements` | Common element declaration and XML registration | +| `MyClientElements` | Client-only renderer registration and drawing | + +## 1. Supply your own storage + +`MyFluidStorage.java` uses NeoForge's `FluidTank`, independently of MMDLib's +`FluidStorage`. You can replace it with your own `IFluidHandler` implementation. +The handler returned to each element must expose exactly one tank at local index 0. +Its fill/drain operations must honor simulation without modifying contents. + +```java +package com.example.demo; + +import net.minecraft.world.level.material.Fluids; +import net.neoforged.neoforge.fluids.capability.templates.FluidTank; + +public final class MyFluidStorage extends FluidTank { + private final Runnable changed; + + public MyFluidStorage(Runnable changed) { + super(4000, stack -> stack.getFluid() == Fluids.WATER); + this.changed = changed; + } + + @Override + protected void onContentsChanged() { + changed.run(); + } +} +``` + +`MyTankHost.java` is the small contract between the element and your machine: + +```java +package com.example.demo; + +import net.neoforged.neoforge.fluids.capability.IFluidHandler; +import zone.moddev.mc.lib.gui.runtime.ScreenHost; + +public interface MyTankHost extends ScreenHost { + IFluidHandler uiTank(int index); +} +``` + +`MyMachineBlockEntity.java` stores the tank, marks changes for saving and persists its +contents. It has no machine item slots; the screen can still show the player's inventory. + +```java +package com.example.demo; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.neoforged.neoforge.fluids.capability.IFluidHandler; +import net.neoforged.neoforge.items.IItemHandlerModifiable; +import net.neoforged.neoforge.items.ItemStackHandler; + +public class MyMachineBlockEntity extends BlockEntity implements MyTankHost { + private final MyFluidStorage coolant = new MyFluidStorage(this::setChanged); + private final ItemStackHandler items = new ItemStackHandler(0); + + public MyMachineBlockEntity( + BlockEntityType type, BlockPos pos, BlockState state) { + super(type, pos, state); + } + + @Override + public IFluidHandler uiTank(int index) { + if (index != 0) throw new IllegalArgumentException("Unknown tank: " + index); + return coolant; + } + + @Override + public IItemHandlerModifiable screenItems() { + return items; + } + + @Override + protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.saveAdditional(tag, registries); + tag.put("coolant", coolant.writeToNBT(registries, new CompoundTag())); + } + + @Override + protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.loadAdditional(tag, registries); + coolant.readFromNBT(registries, tag.getCompound("coolant")); + } +} +``` + +Your block-entity factory constructs this class using the registered `demo:machine` +type, position and block state. Your existing block's interaction handler can open it +with `ScreenRuntime.open(player, blockEntity)` (`gui.runtime.ScreenRuntime`), or use +the `mmdlib:open_menu` callback for an MMDLib-defined block. See [opening screens](gui-screens.md). + +The UI accesses storage through `MyTankHost`, so it does not require a block fluid +capability. To support pipes too, separately register `Capabilities.FluidHandler.BLOCK` +for your machine type, returning the appropriate handler for each side. This example +does not add automation side rules or expose storage automatically. + +## 2. Own the snapshots and server clicks + +`MyFluidTankElement.java` inherits dimensions, numeric fluid display and ordinary click +dispatch. It replaces every inherited hook that would otherwise use +`ScreenHost.screenFluids()` and MMDLib's `FluidStorage`. + +```java +package com.example.demo; + +import java.util.List; +import java.util.Optional; + +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.capabilities.Capabilities; +import net.neoforged.neoforge.fluids.FluidUtil; +import net.neoforged.neoforge.fluids.capability.IFluidHandler; + +import zone.moddev.mc.lib.gui.model.Common; +import zone.moddev.mc.lib.gui.model.ElementSnapshot; +import zone.moddev.mc.lib.gui.model.Orientation; +import zone.moddev.mc.lib.gui.model.ResourceBinding; +import zone.moddev.mc.lib.gui.model.elements.FluidTankElement; +import zone.moddev.mc.lib.gui.runtime.ScreenClick; +import zone.moddev.mc.lib.gui.runtime.ScreenHost; + +public class MyFluidTankElement extends FluidTankElement { + public MyFluidTankElement(Common common, int tank) { + super(common, tank, Orientation.VERTICAL, 54); + } + + private IFluidHandler storage(ScreenHost host) { + return ((MyTankHost) host).uiTank(tank()); + } + + @Override + public void validateHostType(Class type) { + if (!MyTankHost.class.isAssignableFrom(type)) { + throw new IllegalArgumentException("Requires MyTankHost"); + } + } + + @Override + public void validateHost(ScreenHost host) { + if (!(host instanceof MyTankHost)) { + throw new IllegalArgumentException("Requires MyTankHost"); + } + if (storage(host).getTanks() != 1) { + throw new IllegalArgumentException("uiTank must expose exactly one tank"); + } + } + + @Override + public Optional resourceBinding() { + // Our tank is outside MMDLib's indexed resource bindings. + return Optional.empty(); + } + + @Override + public Optional snapshot(ScreenHost host) { + var storage = storage(host); + return Optional.of(new ElementSnapshot( + storage.getFluidInTank(0).copy(), + storage.getTankCapacity(0) + )); + } + + @Override + public List tooltip(ScreenHost host, ItemStack carried) { + var storage = storage(host); + var fluid = storage.getFluidInTank(0); + return List.of( + Component.translatable("demo.gui.tank.title"), + fluid.isEmpty() + ? Component.translatable("demo.gui.tank.empty") + : fluid.getHoverName(), + Component.translatable( + "demo.gui.tank.contents", fluid.getAmount(), storage.getTankCapacity(0)), + Component.translatable("demo.gui.tank.accepts"), + Component.translatable("demo.gui.tank.controls") + ); + } + + @Override + public boolean interact(ScreenClick click) { + // Called on the server after the menu validates the action. + if (!(click.menu().blockEntity instanceof MyTankHost host)) return false; + + var carried = click.menu().getCarried(); + if (carried.isEmpty() || FluidUtil.getFluidHandler(carried).isEmpty()) return false; + + var inventory = click.player().getCapability(Capabilities.ItemHandler.ENTITY); + if (inventory == null) return false; + + var storage = storage(host); + int limit = click.shift() ? Integer.MAX_VALUE : 1000; + var result = click.button() == 0 + ? FluidUtil.tryEmptyContainerAndStow( + carried, storage, inventory, limit, click.player(), true) + : FluidUtil.tryFillContainerAndStow( + carried, storage, inventory, limit, click.player(), true); + + if (result.isSuccess()) { + click.menu().setCarried(result.getResult()); + } + return true; + } +} +``` + +Left-click empties the cursor container into the tank; right-click fills it from the +tank. A normal click transfers up to 1,000 mB, and Shift removes that amount limit. +The helpers handle one cursor container per click, including stowing its result from +a stack of containers. Actual transfer amounts depend on the container's capabilities +and your storage's rules; an indivisible bucket cannot transfer a partial bucket. + +The server reads the live cursor and storage, rather than trusting client-supplied +fluid amounts. The storage's change callback handles persistence; MMDLib samples the +new copied `FluidStack` and capacity for the open menu. No custom packet or value codec +is needed. Replacing the transfer helper calls with your own storage operations is +also possible; keep validation and mutation on the server. + +Returning no `ResourceBinding` opts out of automatic matching to MMDLib fluid resource +groups. The host validates the custom tank index instead. The element stores only its +definition, not a block entity or live handler. Its current constructor adds no extra +configuration beyond the inherited fields; if you add fields later, include them in +the element's deterministic equality/hash/text representation for screen fingerprints. + +## 3. Declare and register the XML tag + +`MyElements.java` is common code and contains no client references: + +```java +package com.example.demo; + +import javax.xml.namespace.QName; +import zone.moddev.mc.lib.gui.ElementType; +import zone.moddev.mc.lib.gui.load.ScreenAttribute; + +public final class MyElements { + public static final ElementType FLUID_TANK = + ElementType.xml( + MyFluidTankElement.class, + new QName("https://demo.example/gui", "fluid-tank"), + xml -> new MyFluidTankElement(xml.common(), xml.integer("tank")) + ) + .attribute("tank", ScreenAttribute.required("xs:nonNegativeInteger")) + .build(); + + private MyElements() {} + + public static void register() { + FLUID_TANK.register(); + } +} +``` + +Call `MyElements.register()` **once from your existing mod constructor, on both +physical sides**, before MMDLib freezes XML declarations during loading. Do not defer +it to client setup. `ElementType` forwards to the existing `ScreenTags` registry. +The namespace URI identifies your XML vocabulary; it is not fetched from the internet. + +## 4. Attach client-only rendering + +Place `MyClientElements.java` in a client package. The event subscriber below registers +automatically for mod ID `demo`; do not also call it from common code. + +```java +package com.example.demo.client; + +import com.example.demo.MyElements; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent; +import zone.moddev.mc.lib.gui.client.ElementClient; + +@EventBusSubscriber(modid = "demo", bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT) +public final class MyClientElements { + private MyClientElements() {} + + @SubscribeEvent + public static void setup(FMLClientSetupEvent event) { + event.enqueueWork(() -> ElementClient.of(MyElements.FLUID_TANK) + .renderer((element, context) -> { + var g = context.graphics(); + var r = context.bounds(); + int border = context.hovered() ? 0xffffcc55 : 0xff555555; + + g.fill(r.x(), r.y(), r.right(), r.bottom(), border); + g.fill(r.x() + 1, r.y() + 1, + r.right() - 1, r.bottom() - 1, 0xff181818); + + int filled = (int) Math.round((r.height() - 4) * context.ratio()); + g.fill(r.x() + 2, r.bottom() - 2 - filled, + r.right() - 2, r.bottom() - 2, 0xff36bcd4); + }) + .register()); + } +} +``` + +This replaces the inherited glass gauge with a solid cyan bar and a gold hover border. +`context.ratio()` calls the element's inherited fluid amount/capacity calculation using +synchronized values. The renderer never reads the machine's live storage. + +No `.behavior(...)` attachment is needed: ordinary inherited/default click handling +already sends validated clicks to `interact()`. For custom dragging, scrolling or +keyboard input, attach a fresh behavior factory with `.behavior(MyTankBehavior::new)`. +Omitted behaviors and rendering passes preserve normal inheritance. Rendering and input +still use the existing registries; `ElementClient` only bundles the registrations. + +## 5. Add the screen and translations + +Create **`src/main/resources/mmdlib/screen/machine.xml`** in the `demo` mod: + +```xml + + + +``` + +The file produces screen ID `demo:machine`, which must match the registered +block-entity type ID. It lives under the mod's root `mmdlib/screen/`, not under +`assets/` or `data/`. The `demo:` prefix selects your XML namespace; `tank="0"` +selects `MyTankHost.uiTank(0)`. This custom tag has a fixed vertical length of 54 +pixels from its constructor. It needs no `value`, `max` or `interaction` attributes. + +Add **`src/main/resources/assets/demo/lang/en_us.json`** (merge with existing keys): + +```json +{ + "demo.gui.machine": "Coolant Machine", + "demo.gui.tank.title": "Coolant", + "demo.gui.tank.empty": "Empty", + "demo.gui.tank.contents": "%s / %s mB", + "demo.gui.tank.accepts": "Accepts water only", + "demo.gui.tank.controls": "Left: empty container. Right: fill container. Shift: remove transfer limit." +} +``` + +## Check it in your mod + +1. Open the registered machine's screen and verify the custom cyan gauge and tooltip. +2. Pick up a water bucket and left-click the gauge. The tank gains 1,000 mB and the + cursor/container result updates. Right-click with an empty bucket to retrieve it. +3. Try a lava bucket: storage refuses it. Check full-tank and empty-tank cases too. +4. Save and reopen the world; the tank retains its contents through the NBT hooks. +5. Open the screen from two clients: both should receive the server's updated contents. + +Continue with the [element declaration and rendering API](gui-screens.md) for additional +render passes, parent tooltip rows, custom actions and XML container contracts. diff --git a/docs/gui-screens.md b/docs/gui-screens.md index b89b5394..0764cc66 100644 --- a/docs/gui-screens.md +++ b/docs/gui-screens.md @@ -12,8 +12,11 @@ embedded editor without adding per-filter XML to each screen. | Piece | Where | State | |---|---|---| -| Schema | `mmdlib/schema/screen.xsd` (in MMDLib's jar) | Done | +| Element declarations | `gui.ElementType`, `gui.client.ElementClient`; built-ins in `gui.load.BuiltinElements` and `gui.client.BuiltinElementClients` | Implemented | +| XML tag contracts | `gui.load.ScreenTags`, `ScreenTag`, `ScreenAttribute`; `screen.xsd` supplies built-in declarations/shared types | Implemented | | Typed model | `gui.model`: `Screen`, `Element` (one record per XML element), `Common`, `Text`, `ValueRef` | Done | +| Tank component | `gui.model.elements.FluidTankElement`: binding, snapshots, tooltip and container interaction | Done | +| Energy component | `gui.model.elements.EnergyGaugeElement`: binding, snapshots and default tooltip | Done | | Reading and checks | `gui.load.ScreenReader`: hardened SAX parser (no DOCTYPE, no external entities), schema validation, then the checks below | Done | | Getter binding | `gui.load.ScreenBinder`: `#method` / `Class#method` resolved to method handles, return kinds checked per element | Done | | Discovery | `gui.load.ScreenLoader`, called at startup; results in `gui.Screens` | Done | @@ -21,22 +24,29 @@ embedded editor without adding per-filter XML to each screen. | Layout | `gui.layout.ScreenLayout`: positions in GUI pixels; tab fit checked at load | Done | | Menu and opening | `gui.runtime.MachineMenu`, `ScreenRuntime`, `BlockEvents.openMenu` | Implemented | | Value sync | `gui.runtime.ScreenNetwork`, `ScreenValues`, `ScreenSnapshots` | Implemented | +| Network registration | `gui.runtime.network.NetworkRegistry`; feature-owned codecs and handlers | Implemented | | Client rendering | `gui.client.MachineScreen`, `GaugeRenderer`, `ScreenText` | Implemented | | Optional JEI | `client.jei.MachineJei`, loaded only by the JEI plugin | Implemented | Checks made at load, beyond the schema: unique `id`s and slot indexes; `shows`, `hover-shows` and `side-config groups` name existing elements; buttons have a tooltip and text or an icon (generic processor controls supply their own defaults); a -`-fallback` attribute has its key; `max` has a `value`; gauges, meters and progress indicators -have both; checkboxes and radio groups have a `value`; info pop-outs have text and -warning/error pop-outs a `value`; `spacer` sits directly in a box; at most one -`player-inventory`, and none with `inventory="none"`; the block-entity class exists and is a +`-fallback` attribute has its key; `max` has a `value`; display gauges, meters and progress indicators +have both; fluid-tank components own their values and require a tank binding; +checkboxes and radio groups have a `value`; info pop-outs have text and +warning/error pop-outs a `value`; `spacer` sits directly in a box; at most one of each +player inventory/armour/offhand section, and none with `inventory="none"`; the block-entity class exists and is a `BlockEntity`; every getter exists, is public, and returns a kind the element can show; side tabs and pop-out tabs fit their edge. -Getter return kinds: whole numbers (`byte` to `long`, boxes, `BigInteger`), decimals (`float`, -`double`), a declared `Number`, `boolean`, `FluidStack` (fluid gauges), `Component` and -collections of components (warning and error pop-outs). +Element-local limits are checked by the elements during both XML loading and Java screen binding: +`SlotElement.validate()` restricts indexes to 0..4095, including item, ghost and custom slots; +`SideConfigElement.validate()` permits at most 682 groups. `ScreenRuntime.validate()` checks +screen-wide transport limits and registered callback references, without element-type branches. + +Built-in getter types: whole numbers (`byte` to `long`, boxes, `BigInteger`), decimals (`float`, +`double`), a declared `Number`, `boolean`, `FluidStack` (fluid gauges), `ItemStack`, `Component` +and collections of components (warning and error pop-outs). Mods can register additional types. Screens are declared in **XML**, in keeping with MMDLib's data-driven core. Each file is loaded at startup into a typed screen model and validated there, with the file and line number in any @@ -45,6 +55,599 @@ autocompletion in their editors. ## Wiring a machine +### Registering getter value types + +`gui.value.ValueTypeRegistry.GLOBAL` owns the getter and wire type catalog. Register common-side +`ValueType` definitions during mod construction, before screens load and freeze the registry. +`ValueTypes` supplies the built-ins through the same API. For example: + +```java +public record Reading(int amount) {} + +public static final ValueType READING = ValueType.builder( + ResourceLocation.parse("example:reading"), Reading.class, + () -> StreamCodec.of( + (buffer, value) -> buffer.writeInt(value.amount()), + buffer -> new Reading(buffer.readInt())), + 4) + .version(1) + .description("a machine reading") + .validate(value -> value.amount() >= 0) + .immutable() + .build(); + +// In the mod constructor, on both physical sides: +ValueTypeRegistry.GLOBAL.register(READING); + +// On a custom Element (or pass Set.of(READING) to WidgetType): +public Set> valueKinds(WidgetTypes widgets) { return Set.of(READING); } +``` + +Import `ValueType`, `ValueTypeRegistry` and `ValueTypes` from `gui.value`; codecs use +`StreamCodec`. An XML getter returning `Reading` now binds without +changes to the loader, menu or screen. Renderers receive the decoded value through +`access.value(element, false)`. `valueKinds` and `maximumKinds` contain registered descriptors; +the former `BoundValue.Kind` enum has been removed. + +Each type owns Java aliases (`javaTypes`), canonical normalization (`normalize`), runtime +validation, copying, equality and a bounded codec. Exact Java registrations win, followed by the +most specific registered superclass/interface; ambiguous matches and duplicate IDs/Java bindings +are rejected. Use `subtypes(false)` for exact Java bindings only (as the built-in declared `Number` +type does). The declared getter's descriptor normalizes and snapshots its result. Null means +unavailable and requires no type registration. Standard whole-number bindings normalize to +`BigInteger`; decimals normalize to finite `Double` values. + +Snapshots default to a codec round trip to detach mutable data. Override `copy` only with a deep +copy, or use `immutable()` for genuinely immutable values. Use `equality` when the value's ordinary +`equals` does not compare contents. Copy results still pass validation; transport always enforces +the byte limit. Built-in item/fluid copies do not require serialization or registry access. +The codec supplier is lazy so registration does not initialize Minecraft registries prematurely. +The maximum is 1 MiB per value; declare the smallest positive limit your codec needs. +Built-in text collections additionally allow at most 4096 components. + +Deltas and action-result corrections use the same registered codecs. Each non-null wire value +carries its namespaced type ID, positive schema version and bounded payload. Unknown IDs, version +mismatches, excess bytes, trailing bytes and invalid decoded values are rejected. Type declarations +enter the menu fingerprint in deterministic ID order. Bump the type's version when changing its +codec or semantics, and register matching definitions on the client and server. The core screen +protocol is now 19. Internal typed-state envelopes use a wire-only registration, excluded from +getter binding. + +`gui.model` holds the open `Element` interface, `Screen`, and shared data types such as +`Common`, `Text`, `ValueRef`, `Interaction` and `ResourceBinding`. + +`gui.model.containers` holds composable containers and layout elements: `BoxElement`, +`GroupElement`, `ColouredGroupElement`, `ContextElement`, `ViewElement`, `InventoryElement`, +`InventoryControlsElement`, `RadioGroupElement`, `SideTabElement`, and the placement elements +`SpacerElement`, `PlayerInventoryElement`, `PlayerArmourElement` and `PlayerOffhandElement`. +The player sections share `PlayerSlotsElement`. `BoxElement` represents both `hbox` and `vbox`. + +`gui.model.elements` holds leaf controls and displays, including slots, buttons, sprites, +fluid/energy gauges and popouts. `gui.layout` holds geometry and orchestration helpers such as +`Rect`, `TextMeasure`, `ElementLayouts`, `LayoutContext` and `ScreenLayout`; these are not +elements in the screen model. Java imports use these packages; XML names remain unchanged. + +Every built-in element is a standalone `*Element` class: for example, `ButtonElement`, +`BoxElement`, `CustomElement`, `ProgressElement` and `PopoutElement`. `Element` contains only +the common contract and sizing constants. Former nested references such as `Element.Button` +become `ButtonElement`; shared enums, `ViewRef` and `ElementSnapshot` live in `gui.model`. +The progress direction enum is `ProgressDirection`. XML names and attributes stay the same. + +Built-in elements own their measurement and child placement too. `ScreenLayout` composes the +outer panel, contributed footers and registered tabs, then delegates to `measure` and `childLayout`. +`stretchX`, `stretchY` and `stretchToPanel` opt into the available space; `spacer` opts into +leftover main-axis space in a box. `prepareLayout(LayoutContext)` supplies context-dependent +metadata before measurement, such as reserving the back-button header in alternate views. + +`Element.layoutContribution(context)` optionally returns a fresh `LayoutContribution` for each +layout. The context supplies the screen, measurement context and resolved drawer definitions. +Plans run in element traversal order. They can claim body roots for custom placement, request +centred footer space, and place their elements through generic `Panel` operations in `finish`. +`shiftBelow` moves already placed roots and pending footer allocations; `grow` increases panel +height. `beforeOffsets` observes final placement after every plan finishes; all elements then receive +authored offsets. `resolveOffsets` can align associated +elements, and `regions()` publishes final named rectangles. Duplicate root claims and region IDs +are rejected. Multiple contributions compose without a registry or element-type checks in +`ScreenLayout`; keep mutable plan state out of the element definition. + +Consumers use `layout.region(id)`. Player inventory publishes `PlayerInventoryElement.INVENTORY` +and `TOOLBAR`; these replace `layout.inventory()` and `layout.inventoryColors()`. Default slot +geometry and dimensions live in `PlayerInventoryElement.slot`, `WIDTH` and `HEIGHT`, replacing +the former `ScreenLayout.playerSlot` and `INVENTORY_*` aliases. Item coordinates come from +`SlotElement.itemPosition(bounds)`, including custom item sizes. Default native item, ghost and +player-slot factories honor this hook for direct `SlotElement` implementations as well as subclasses. +Drawer tab defaults live in +`DrawerPlacement`; drawer content insets live in `SideDrawerLayout`. `AutomationInventoryElement` +owns the addon/filter slot preset used by automation menus and layout. `InventoryElement` only +provides the reusable named inventory/grid; player sections use their three dedicated containers. + +`ScreenHost.screenAutomationInventory(group)` supplies a fresh `AutomationInventoryElement` per +menu/group. Its slot-factory constructor accepts `IntFunction`; the factory +runs once per local index and retains the returned element identities. Defaults are extensible +`AutomationSlotElement`s with `AUXILIARY` roles. Custom slots may override the native factory, +`itemPosition` and `role`, including direct `SlotElement` implementations. Keep all eight real, +non-player slots and their local indexes: addon/filter storage semantics remain feature-owned. +`AutomationMenu` also accepts an explicit inventory factory for custom component construction. + +Override `drawerSlotBounds`, `filterToggleBounds` or `contentHeight` on the automation inventory to +customize presentation. Native creation/repositioning, drawn frames, filter-toggle hit boxes and +drawer height consume that same inventory instance; item coordinates always use the slot's +`itemPosition`. The static `AutomationLayout` helpers describe the default preset. Keep definitions +consistent on client and server. These hooks use the existing menu component and renderer paths. + + +`Element` and `SlotElement` are open interfaces: mods can implement them in their own packages +and include those instances in a Java-built `Screen`. A custom element implements `common()` +and `measure(TextMeasure)`; containers also expose `children()` and `childLayout(bounds, text)` +with child rectangles relative to the container. Existing `validate`, `snapshot`, `interactive`, +`interact` and tooltip hooks apply to these elements too. All concrete built-in elements are +inheritable classes, including containers, item/ghost slots and `FilterControl` implementations. +Their existing constructors and accessor names remain available; configuration fields are final +and child collections are copied. Mods can extend a built-in and override only the relevant hooks. +Subclasses inherit their base type's renderer and behavior unless a more specific registration exists. +The converted classes retain structural equality within an exact runtime type. If a subclass adds +configuration fields, include them in `equals`, `hashCode` and a deterministic `toString`; screen +fingerprints include concrete element class names and model text. Keep per-screen mutable state in +registered behaviors. Non-element data records such as slot appearances and sprite layers remain records. + +Elements also own value presentation, independently of synchronization: + +- `displayValue(value)` converts a synchronized snapshot for display; its default returns the value. +- `displayText(value, detailed)` formats current or maximum values. The default formats the converted + value numerically, abbreviating large integers unless detailed output is requested. +- `fillRatio(value, maximum)` supplies a finite fraction between zero and one. Its default computes + a clamped numeric ratio of the converted values, preserving large integer/decimal precision. + +Fluid tanks and generic fluid gauges convert `FluidStack` snapshots into amounts themselves. +Shared tooltips call `displayText`; gauge, meter, sprite and widget rendering call `fillRatio`. +No presentation registry is needed. Override conversion for custom value objects or override the +formatting/fill hook directly. These hooks receive synchronized snapshots and must not mutate them. +The numeric utility `DisplayValues` contains only reusable formatting/arithmetic defaults. + +`GaugeElement.fill(value)` and `FluidTankElement.fill(value)` select a common-side `GaugeFill`. +Override these methods to return `GaugeFill.solid(rgb)`, `GaugeFill.fluid(stack)`, or +`GaugeFill.fluid(stack, argbTint)` while inheriting the existing renderer. Selection is independent +of `fillRatio`: a numeric snapshot can select a fluid texture, and a tank can select a solid colour. +Default gauges use their configured solid colour first, otherwise the snapshot fluid, otherwise +grey; tanks default to the snapshot fluid. Fluid fills copy their contents on construction and +access. The renderer resolves textures/default tint and draws the selected fill; it does not +interpret raw snapshot types. No additional registration is needed. + +```java +public class ReserveTankElement extends FluidTankElement { + public ReserveTankElement(Common common, int tank) { + super(common, tank, Orientation.VERTICAL, 54); + } + + @Override + public double fillRatio(Object value, Object maximum) { + // For example, visualize the square root of the normal fraction. + return Math.sqrt(super.fillRatio(value, maximum)); + } + + @Override + public GaugeFill fill(Object value) { + return GaugeFill.solid(0x5599cc); + } +} +``` + +This subclass retains the tank's snapshots, tooltip, container interaction and registered renderer. +Override `appendTooltip` to add custom rows, using `displayText` for any values you include. + +For a complete worked example, see [a custom fluid element](custom-fluid-element.md): mod-owned +storage and persistence, server clicks, client rendering, common/XML registration and screen resources. + +For a new element, declare its common definition once and attach its client implementation +separately. `ElementType` is an immutable declaration, not another registry. Its optional XML +contracts register into `ScreenTags`; `ElementClient` forwards drawing/input attachments into +`ElementRenderers` and `ElementBehaviors`. + +```java +// Common code: MyFluidTankElement(Common, int) is your mod's element class. +public static final ElementType MY_TANK = + ElementType.xml( + MyFluidTankElement.class, + new QName("https://example.org/gui", "fluid-tank"), + xml -> new MyFluidTankElement(xml.common(), xml.integer("tank"))) + .attribute("tank", ScreenAttribute.required("xs:nonNegativeInteger")) + .build(); + +// Mod construction, on both physical sides: +MY_TANK.register(); +``` + +```java +// Client setup only; keep this code in a client class. +ElementClient.of(MY_TANK) + .renderer(MyTankRenderer::render) + .register(); +``` + +Imports: `gui.ElementType`, `gui.client.ElementClient`, `gui.load.ScreenAttribute`, and +`javax.xml.namespace.QName`. Add `.behavior(MyTankBehavior::new)` when custom client input is needed; +ordinary tank clicks already use inherited/default behavior. Omitted render passes and behaviors +install nothing and preserve the existing most-specific inheritance rules. Factories still create +fresh behavior instances per screen. Missing drawing is checked when the screen is validated. + +Use `ElementType.of(MyElement.class)` for a Java-only element or an abstract/interface family. +`ElementType.xml(name, existingScreenTag)` wraps an existing tag contract. `withXml(name, tag)` returns +a new immutable declaration with another XML alias, leaving the original unchanged. The builder +exposes `groups(...)`, `children(group, min, max)` and `children(QName, min, max)` using the existing +schema contracts. Document roots, data children and template directives remain ordinary `ScreenTag` +registrations because they are not UI elements. + +Client attachments support `.overlay(...)`, `.foreground(...)`, `.top(...)` and +`.renderer(pass, clip, renderer)` for explicit clipping. Register the renderer object itself to retain +its `validate` hook. Duplicate attachments/registrations fail; intentional replacements still use the +existing registries' `replace` APIs. Common registration accepts an explicit `ScreenTags`, and client +registration accepts explicit `ElementRenderers`/`ElementBehaviors` for isolated tooling or tests. + +The common descriptor has no client-class references: use `ElementClient.of(MY_TANK)`, not a common +`MY_TANK.client()` method. Built-ins follow the same split. `BuiltinElements` owns their shared common +declarations and XML parsers; `BuiltinElementClients` pairs their drawing and input attachments. +Existing XML schemas, aliases and lower-level registration APIs remain supported. The legacy `gauge` +tag can construct two unrelated element classes, so its XML declaration is separate from the typed +`GAUGE`/`FLUID_TANK` client attachments. + +Register drawing during client setup with +`ElementRenderers.GLOBAL.register(MyElement.class, (element, context) -> { ... })`. +Built-ins use this same registry: `MachineScreen` has no element-specific drawing switch. +The context contains GUI bounds, synchronized values, mouse/frame inputs, and read-only +`context.access()` presentation services (font/text, element lookup, parent, bounds, typed +element state and displayed slot contents). It exposes neither the machine nor the menu. Treat synchronized +values as immutable; slot contents/hints supplied by the access object are copies. No client +classes are needed in the common element implementation. + +An exact registration wins; otherwise the most specific registered superclass/interface wins. +Thus custom `SlotElement` and `ButtonControl` implementations inherit shared drawing, and sprite +subclasses inherit `SpriteRenderer`. Register the concrete mod class to override its inherited +drawing. Unrelated matching interfaces are an error until an exact or more specific registration +resolves the ambiguity; selection never depends on registration order. Duplicate registrations +are rejected. Use `replace` to intentionally replace an existing registration for that exact type. +Missing background renderers and ambiguous matches are checked when a screen is created. +A renderer can also override `Renderer.validate(element)` to check client dependencies; the +resolved registration for each pass owns that check, including inherited and replaced registrations. +Throw `IllegalArgumentException` with a useful message; validation adds the element's original XML +source and line. The built-in custom-widget adapter checks its widget factory through this hook. +`MachineScreen` and `ElementFragment` share this validation for every rendering pass. Fragments +validate before creating behaviors; their host can override `source(element)` for XML diagnostics +(the default is the host ID). `ElementRenderers` also validates loaded screens at load completion; +`WidgetRenderers` only freezes its own factory registrations. + +```java +// In client setup; MyElement remains a common-side model class. +ElementRenderers.GLOBAL.register(MyElement.class, (element, context) -> { + var r = context.bounds(); + context.graphics().fill(r.x(), r.y(), r.right(), r.bottom(), 0xff228855); +}); +``` + +There are four passes, each with independent registration and inheritance: + +| Registration | Draw order | Default clipping | +|---|---|---| +| `register` | Element backgrounds in tree order | Element bounds | +| `registerOverlay` | After all element backgrounds, for outlines | Element bounds | +| `registerForeground` | After vanilla container rendering, for popout bodies | Screen viewport | +| `registerTop` | Above foreground content, for tabs | Element bounds | + +Optional passes can be omitted. Pure layout containers explicitly register no-op backgrounds. +`register(pass, type, clip, renderer)` supports an explicit clip policy; side-config highlights, +for example, use viewport clipping because their target is another element. Each call balances +the registry's pose/scissor scope and resets drawing colour; renderers must balance any additional +pose/scissor changes of their own. `context.elapsedTicks()` counts game ticks since the screen's +first frame, including partial ticks, for local animation. Renderers may be shared across screens; +keep per-screen editing/open/scroll state in the appropriate client instance. + +Custom slots inherit shared slot rendering and the default vanilla adapter, or override +`createSlot` for their own adapter. Ghost behavior follows `ghost()`, with overridable +`selection`, `read` and `accept` hooks. XML-defined extensions continue to use the registered +`` / `WidgetTypes` API. + +Client behavior is registered independently from drawing: + +```java +ElementBehaviors.GLOBAL.register(MyElement.class, MyElementBehavior::new); +``` + +The factory receives `(element, ElementAccess)` and must create a fresh `ElementBehavior` for +each element in each open screen. Registration uses the same exact-type/most-specific-base +rules as renderers, including explicit `replace`. Keep focus, drafts, drag state and local +selection in that instance. Unregistered elements use `DefaultElementBehavior`, which handles +common interactions, recipe links and click callbacks. Extend it and override its protected +`click` hook to retain those defaults, or implement `ElementBehavior` directly for full control. + +`ElementInput` routes mouse, keyboard, focus, capture, visibility, resize and removal for every +element. Return `CAPTURE` to receive drag/release outside the element; hiding or resizing cancels +the capture without committing. `ElementAccess` supplies current bounds, synchronized values, +presentation services and typed action submission. It exposes no block entity or raw menu. +Navigation, inline side configuration, radio controls and slot behavior use this same registry. +Real inventory gestures still pass through vanilla slots. Existing `WidgetInstance` factories +are adapted to the same lifecycle, preserving the XML custom-widget API. + +Screens and fragments both call `ElementInput.click(x, y, button)` after updating visibility. +It checks the viewport and visits visible hits in reverse traversal order; `PASS` continues to +the next hit. Unhandled presses clear focus and permit native fallback. An existing capture +consumes further presses even outside the viewport until its release or cancellation. Hosts only +handle overlays first and native fallback afterward. Scrolling uses the same viewport predicate. + +Hover tracking also belongs to `ElementInput`. Screens and fragments select the last visible +element under the pointer in traversal order, matching their topmost tooltip target. A fragment +also requires the pointer to be inside its viewport. `hoverChanged(true/false)` runs only when the +target changes, with the previous target's leave before the next target's enter. Repeated frames +do not repeat callbacks; geometry, visibility and clipping changes recheck a stationary pointer. +Removal sends the final leave before disposing behaviors. Focus/capture cancellation alone does +not change hover, and a captured drag's release still belongs to its original control. + +Hosts sample `hover(x, y)` each frame and forward actual movement through `mouseMoved(x, y)`. +`ElementFragment.Host.hoverChanged` receives the same transitions; machine-backed hosts use +`ElementHover.dispatch` to supply the menu context for declarative `on-hover` callbacks. The filter +drawer provides this bridge. `ElementHover.preview` resolves `hover-shows` for both screens and +fragments, with fragment previews retaining viewport clipping. No additional registry is needed. + +Radio options share the `OptionSelection` parent behavior capability for drawing and input. +`selected(option)` supplies the selected state; `activate(option, button, modifiers)` owns the +action and returns its input result. `RadioGroupBehavior` resolves membership by element identity, +reads the synchronized group index and submits the group's ordinary click action. It does not +predict a selection before the server updates the value. An option's explicit click bindings +take precedence over parent activation. Mods can register a behavior implementing `OptionSelection` +for their own group types, using their own state and typed actions; radio rendering/input requires +no concrete parent class. Return `PASS` for unknown options and `CONSUME` for handled options, +including when a pending action prevents another submission. + +`ElementBehavior` contains input, lifecycle and tooltip hooks only. Specialized presentation is +optional: `ValueLabelProvider` supplies a tooltip value label, `SideConfigPresentation` supplies +the inline side control's selection/modes, and `WidgetRenderer` supplies custom-widget drawing. +Renderers request these through `context.access().capability(element, Contract.class)`. +Mods can define their own interfaces; a behavior implements them directly, or an adapter implements +`BehaviorCapabilities` to forward requests. The existing widget adapter forwards these contracts. + +Tooltips belong to the element. Override `Element.appendTooltip(TooltipContext, List)` +to supply its rows. The default calls `context.appendDefaults(rows)`, which includes server rows, +the authored tooltip (or Shift variant), value substitutions, the Shift hint and `valueTooltip`. +Call it before adding rows to retain those defaults, or omit it to replace them. Styled components +retain their formatting when split into multiple lines. + +Containers can override `appendChildTooltip` to add context when a descendant is hovered: + +```java +@Override +public void appendChildTooltip(TooltipContext context, List rows) { + rows.add(context.text(new Text("screen.example.input_group", Optional.of("Inputs")))); +} +``` + +Composition visits the hovered element first, then its parent, then each ancestor to the root. +At each level the element hook runs before the corresponding registered behavior hook. +`ElementBehavior.appendTooltip` and `appendChildTooltip` support client-local state; the default +own-tooltip hook also calls existing `tooltip(mouseX, mouseY)` overrides. A parent's ordinary +tooltip is **not** repeated automatically; `appendChildTooltip` does nothing unless overridden. + +`TooltipContext.target()` always identifies the hovered element; `element()` identifies the +current contributor, and `child()` is the immediate child along the path (empty at the target). +It exposes mouse coordinates, modifiers, synchronized values and translated text, without machine +storage. `serverLines()` returns the contributor's own server rows; a parent can append these +explicitly in its child hook. `appendDefaults` never invokes other hooks or walks parents. + +`ElementTooltips` composes the rows for machine screens, JEI recipe areas and embedded fragments. +The screen draws the result once. Filled native slots retain vanilla item details and append the +same composed rows; empty slots use the ordinary element tooltip. Server-dependent content still +comes from `Element.tooltip(ScreenHost, carried)`, sampled per viewer by `TooltipMenuComponent` +through the registered `mmdlib:tooltips` component field. Its sparse map permits up to 4096 element +entries and 4096 rows per entry, within a total 65536-byte encoded field bound. Fragments without +a server tooltip source return no server rows. Core screen protocol 20 requires matching versions. + +Group captions and highlighting use that same mechanism. A behavior can implement +`GroupLabelProvider.groupLabel()` to supply an optional `Text`, and +`HighlightPresentation.highlight(graphics, bounds, request)` to draw its own outline or another +shape. Register the behavior with `ElementBehaviors.GLOBAL` as above; the element need not inherit +from a built-in group. Both capabilities also work through `BehaviorCapabilities` adapters. + +Consumers call `ElementRenderAccess.groupLabel(element)` and +`highlight(graphics, element, ElementHighlight)`. `MachineScreen.presentation()` exposes the same +read-only services to drawers and integrations. Without a caption provider, the label falls back +to the element's common tooltip; an explicit empty caption remains empty. Without a highlight +provider, a rectangular outline uses the request's colour and outset. Highlights skip hidden +targets and run with viewport clipping and isolated pose/colour state. + +`ElementHighlight` distinguishes persistent `DECORATION`, `EMPHASIS` of an existing decoration, +and a control's `SELECTION` marker. Factory methods supply the usual zero/one-pixel outsets; +providers may reuse `request.outline(bounds)` or `request.render(graphics, bounds)`. +`GroupBehavior` supplies panel captions. `ColouredGroupBehavior` suppresses duplicate persistent +decoration, emphasizes its own coloured outline, and retains the requesting control's selection +style. The inline side-config renderer and side-config drawer depend only on these contracts; +they do not recognize concrete group classes or construct temporary group wrappers. + +Declare server actions in the common element's `actions()` list using `ElementAction`. +Its bounded codec and handler work like the `WidgetAction` example below, but the handler's +context contains any `Element`. Call `access.submit(action, payload)` from the client behavior; +`actionResult(accepted)` runs after authoritative values are applied. Include +`ElementActions.CLICK` when overriding `actions()` if common interactions/callbacks should +remain available. The menu owns player/session/distance/visibility checks, replay protection, +rate limits and acknowledgement transport; the element's handler validates its own payload and +performs its operation. Built-in navigation, back, side-mode cycling and ghost selection all +declare their actions this way. Existing widget factories and action APIs remain supported; +client and server must use matching network protocol versions. + +For additional synchronized data, implement `gui.sync.SynchronizedElement` and return stable, +ordered `ElementSync` declarations from `synchronizedValues()`. Each declaration owns its ID, +bounded `StreamCodec`, initial value and validation. For example: + +```java +public static final ElementSync SELECTED = ElementSync.field( + ResourceLocation.parse("example:selected"), + StreamCodec.of((buffer, value) -> buffer.writeVarInt(value), buffer -> buffer.readVarInt()), + 5, element -> 0) + .validate((element, value) -> value >= 0 && value < 3) + .affectsLayout() + .build(); + +public List> synchronizedValues() { return List.of(SELECTED); } +``` + +Without a sampler, a field retains per-menu state. A server action updates it through +`context.menu().elementStates().set(context.element(), SELECTED, selection)` after validating +the operation. Use `.sample((source, previous) -> ...)` for machine-derived data; it runs only +on the server and receives the element, machine and viewer. Ordinary `value`/`max` getters +keep their existing shared machine/tick cache. Renderers read +`context.access().state().get(element, SELECTED)`; behaviors use `access.presentation().state()`. + +`ElementStates` owns per-menu storage, wire addresses, byte comparisons and transport validation. +Values are serialized defensively; reads cannot mutate stored data. Batches validate before +application, and `affectsLayout()` invalidates layout/visibility once per changed batch. +`SelectiveContainer` lets a container choose visible children from this state, and +`NavigableElement` supplies optional named navigation. `ContextElement` owns selected-view state; +`SideConfigElement` owns its side-mode codec and server sampling. Shared snapshot/menu code +contains no context or side-mode serialization branches. + +Any element can declare navigation through `Optional navigation()`. For example, a +custom control can return `Optional.of(new Navigation(new ViewRef("panel", "settings")))`. +`ButtonElement.shows` supplies this same binding. `Element` validates its destination and includes +`Navigation.NAVIGATE` in its default actions; controls that override `actions()` must retain that +action when using navigation. The server derives the destination from the element definition, +not from a client-provided view name, and ordinary visibility/session/action checks still apply. + +The destination implements `NavigableElement`: `defaultSelection()`, `hasSelection(name)`, +`selected(state)` and `select(states, name)`. It owns its synchronized fields, selection rules and +layout. Selecting an already-selected target returns to its default selection. Both server +selection and client prediction use the same `Navigation` calculation, with no button/context +casts. `ContextElement` implements this contract for its views; mod destinations can use another +representation. + +`DefaultElementBehavior` handles navigation clicks, prediction and acknowledgment/timeout +correction for ordinary elements; no navigation-specific behavior registration is needed for a +plain custom control. Custom behaviors extending it should delegate unhandled clicks to +`super.click(...)` and acknowledgments to `super.actionResult(...)`. `ButtonBehavior` adds only +button tooltip labels. `NavigableBehavior` is registered for destination implementations and owns +remembered-view restoration; `ContextBehavior` adds its back arrow and Escape behavior. +`NavigationMemory` restores through a visible control declaring the matching binding and typed +action, waits for server confirmation, and never edits destination state directly during restore. +Custom lifecycle implementations can reuse these services. `ButtonElement.NAVIGATE` is an alias +of the shared action for existing callers; the wire ID and click payload remain unchanged. + +Limits are 16 fields per element, 64 KiB per field, and 256 KiB including envelope accounting +per screen. Declaration IDs, order, bounds and layout flags must match on both sides and enter +the screen fingerprint. Field codecs and semantics must also match between mod versions. +Typed state travels in ordinary deltas and action acknowledgments, including rejections, before +`actionResult`. Local prediction never authorizes a server mutation; timeouts restore the last +confirmed typed state. Local-only focus, hover and selection need no synchronized field. + +Element definitions also own their load-time contracts: `validate()` checks local configuration, +`validateScreen(ScreenValidationContext)` checks references and shared constraints, and +`valueKinds(widgets)` / `maximumKinds()` constrain getter return types. The context exposes +`screen()` and `unique(domain, key, duplicateMessage)`: namespaced claims let built-in and mod +elements reject repeated bindings without adding branches to shared validation. Claims live only +for one validation pass. `SlotElement` claims inventory/local-index pairs; `PlayerSlotsElement` +owns section uniqueness and screen-inventory checks. Player-backed `SlotElement`s claim player indices. +`ValueContract.require` checks required +value/max bindings. These contracts apply to both XML and Java-built screens, with source/line +diagnostics. `ScreenValidation` keeps common tree checks such as duplicate IDs and fill/spacer +relationships; `ScreenBinder` resolves getters against the element's declared kinds. + +Runtime host validation calls `validateHost(ScreenValidationContext, ScreenHost)` with a fresh +context before constructing slots. Slots validate their named storage bounds through this hook; +custom elements can validate host services and claim bindings through the same path. The default +contextual hooks delegate to existing `validateScreen(Screen)` and `validateHost(ScreenHost)` +overrides. When overriding a contextual hook, call the inherited implementation to retain those +checks (including shared slot rules when implementing `SlotElement`). + +`InventoryElement` contains `ItemSlotElement` and `GhostSlotElement` children. Its `storage` +name selects an independent handler from `ScreenHost.screenInventories()`; child indexes are +local to that handler. For example, both inventories below have their own slot zero: + +```xml + + + + + + + +``` + +`SlotElement.Appearance` owns the shared background sprite, tint, empty icon, icon atlas, +item size (4–32 pixels, default 16), and ghost opacity (0–1, default 0.42). XML uses +`background`, `tint`, `icon`, `icon-atlas`, `item-size`, and `ghost-alpha`. Without an atlas, +icons are GUI sprites; equipment slots use vanilla's block atlas. Real slots can show a +remembered ghost through `screenSlotHint(inventory, slot)` when empty. Editable ghost slots +use `screenGhost(inventory, slot)` and `setScreenGhost(player, inventory, slot, filter)`; +left-click copies one carried item, right-click clears, and neither consumes an item. + +The same classes provide player equipment and addon slots. `InventoryElement` owns grid +placement and menu assembly; `ItemSlotElement` owns machine adapters and reservation-aware +clicks, while `PlayerSlotElement` owns equipment rules; `SlotElement` owns ghost and reservation presentation. Client +renderers supply drawing primitives. The menu retains vanilla coordination, synchronization, +packet checks and routing between inventories. + +Returning `ItemInventories` from `screenItems()` exposes its independent handlers automatically. +Custom hosts can override `screenInventories()`. Use named `ItemInventories.Address` values in +`screenItemDestinations(stack)` and `screenRecipeItems(category)` for shift-click and JEI routing. +JEI both checks eligibility and resolves inputs through `screenRecipeItems`; a host implementing +that named API does not need to implement the legacy `screenRecipeSlots` method. +Hosts may override `screenInventoryBinding(inventory, index, standalone)` for custom wrappers or +logical-to-physical mappings. It returns an `InventoryBinding` containing the physical handler, +physical slot index, legacy index (`-1` for none), and named address aliases. Validation uses this +same hook; logical XML indexes need not equal physical indexes. Keep handler identities stable: +assembly uses handler identity/local index to reject duplicate physical slots and share client +mirrors. Different slots cannot claim the same named address. Default resolution preserves +`ItemInventories` and `ItemStore` behavior; assembly does not inspect those storage types. + +Custom containers can implement `gui.model.InventoryScope` and return their storage name from +`inventory()`. `InventoryElement` implements this same contract. `InventoryScope.owner(screen, element)` +finds the nearest enclosing scope by element identity, through ordinary layout wrappers; nested +scopes override outer scopes. Body, tabs and implicit sections all participate. `InventoryScope.name` +returns the scoped name, or `items` for legacy unscoped slots. Binding, validation, group associations +and generated-machine highlight compilation use this lookup. These helpers replace the former +`InventoryElement.parent`/static `inventory` helpers. Keep names and children stable on both sides; +custom containers still own their normal layout, rendering and XML registration. + +`SlotElement.hint(host, inventory)` supplies the server-sampled remembered item shown in an empty +slot. The default calls `host.screenSlotHint` for machine slots and returns empty for player slots +or an absent host. Mods may override it for either kind of slot; the host argument can be null. +Player slots receive the name `player`. Every registered native slot with an element participates, +including contributed drawer slots outside the screen tree. Contributors can pass a logical inventory +name to `MenuSlots.Registrar.add(slot, element, role, inventory)`; the three-argument overload defaults +to `player` for player-backed elements and `items` otherwise. Sampling and lookup copy the stacks. +The existing `HINTS` field synchronizes native menu indexes (schema version 2, up to 32,767 slots, +within the existing 64 KiB field limit). Complete empty snapshots clear old hints. Schema versions +participate in the menu fingerprint, so both sides must match. No new registry is needed. +`AutomationSlotElement` defaults to no hint; override `hint` to customize addon/filter previews. +The side configuration drawer uses the same synchronized hints. Hint items never become stored contents. + +`screenLegacySlotCount()` reserves the legacy numbering range (default: `screenItems().getSlots()`). +Bindings with legacy indexes retain those menu indexes; assembly resolves all bindings before +allocating other slots, so later legacy indexes cannot collide with earlier automatic allocations. +`screenSlotAddresses(inventory, index, standalone)` supplies default logical aliases and aliases +for ghost slots, which do not have physical bindings. Override the binding's returned addresses +for custom real-slot routing. Both menu sides must produce matching addresses and numbering. + +`SlotElement.role()` supplies the native transfer role for both machine and player slots. +`MACHINE` remains the default for machine storage; inactive numbering placeholders retain it too. +Roles supply the defaults for reservation participation and shift-click routing. Override +`reservationOverlay(entry)` to choose whether the native bridge calls the element's overlay drawing; +`renderOverlay` owns its contents. Override `reservationSync(entry)` independently to select slots +whose plans participate in synchronization. Hiding an overlay does not disable reservation rules. +Synchronization requires a `SlotAdapter`; a plain native slot can still opt into custom overlay +rendering. Both hooks default to machine roles (drawing also requires a `SlotAdapter`). Keep sync +eligibility stable and consistent on both menu sides. + +Override `quickMoveTargets(SlotTransferContext)` to supply ordered native destination slots, or +return an empty list to refuse shift-click routing. The context exposes the source entry, all entries +in native registration order, a defensive stack copy, and lazy `machineDestinations()` from the +registered menu contributors. Default routing sends player storage to those destinations; other +sources target player storage in reverse registration order. Native contributions without an element +retain that same default policy. Treat the context's native slots as read-only when choosing targets. + +`MenuSlots` filters foreign, self, inactive and incompatible targets, removes duplicates by identity, +and merges existing stacks before filling empty slots, preserving the supplied order within each +pass. Source pickup checks and adapter-specific reserved extraction remain enforced by the shared +executor. These hooks require no new registry and retain the existing adapters and renderers. + +Legacy standalone `` elements and integer routing methods still use the combined `items` +view by default. `menu.slot(element)` and `menu.slotIndex(element)` resolve a local UI slot to its native +menu slot; a local index is not necessarily a menu index. + Implement `gui.runtime.ScreenHost` on the block entity. `screenItems()` exposes its player-facing modifiable inventory (`IItemHandlerModifiable`); using `ItemStorage` preserves output restrictions and item filters. The menu adds real slots by the XML indexes, then the optional player inventory. Upgrade slots additionally limit @@ -71,6 +674,22 @@ contents don't need to be synced. Return the `ItemStorage` itself, or a handler/wrapper implementing `ReservedSlots`, including `plan(slot)`, so both menu sides receive and enforce the same reservations. +`gui.runtime.slot.ReservationMenuComponent` owns reservation synchronization. It is registered as +`BuiltinMenuComponents.RESERVATIONS` with the typed `PLANS` field (`mmdlib:reservation_plans`). +The field contains a complete map from native menu slot index to `SlotPlan`, sampled from eligible +slots implementing `SlotAdapter`, including slots contributed by mods. By default, only machine +roles participate; elements can override `reservationSync(entry)` to opt other roles in or out. +Contributions without an element retain the machine-role default. Hidden underlying storage is not +sampled. Overlay visibility is independent of synchronization eligibility. + +The slot subsystem owns the bounded codec (64 KiB per snapshot), including input counts and output +item components. Registry allocation supplies the field's transport address; native slot indexes +are only keys inside its payload. Equal plan contents produce no delta. On a client update, +`SlotAdapter.applyPlan` refreshes eligible slots; `SlotPlan.NONE` and omitted entries clear previous +plans and insertion predictions. Server sampling never calls client adapters. `MachineMenu` and +`ScreenValues` no longer allocate reservation keys or serialize reservation-specific values. +Use the normal component snapshot path; `PLAN_KEYS` and `reservationSnapshot()` are removed. + When the machine has reserved items in a slot (see [capabilities](capabilities.md#reservations)), the menu keeps them from being taken or replaced: @@ -80,15 +699,15 @@ the menu keeps them from being taken or replaced: - Double-clicking a slot with a plan doesn't collect from elsewhere. Double-clicking another slot still takes the planned slot's free part. -Machine slots show reservations at the top right. Both reservation and held quantities use +By default, machine slots show reservations at the top right. Both reservation and held quantities use 87.5% of vanilla's font size: `-2` means two items reserved for input, and `+1` means space reserved for one output. The held quantity stays at the bottom right, using the same size even when no reservation is present. -An empty planned output shows its item model at 50% +An empty planned output shows its item model at the slot's configured ghost opacity with `+1` at the top, without creating a real stack. Mixed consume/produce plans show both signed counts on the top row. Tooltips provide reservation counts and the planned output's name. Clearing the plan removes the badge and ghost. Player inventory, equipment and filter -slots are unaffected. +slots are unaffected by default; custom elements can opt into overlays using the hooks above. Chance outputs show their reserved capacity, not a guarantee that the product will be rolled. No extra packet or XML binding is needed; `MachineMenu.reservationPlan(index)` exposes the existing synchronized snapshot for real machine slots. @@ -98,18 +717,43 @@ values, so clicks don't flicker. Bars and numbers still come from the getters na feature's own client sync (see [block entities](block-entities.md#machine-features)) doesn't replace them. -`screenDestinations` controls shift-click routing into the machine; its default tries all storage -indexes, subject to their filters. `screenRecipeSlots` explicitly names recipe inputs in recipe -order; its default disables JEI transfer. Both methods must work on the client as well as the -server. JEI uses its standard transfer handler with these slots, including its server validation. +`screenItemDestinations` controls shift-click routing into the machine. `screenRecipeItems` names +recipe inputs in recipe order; an empty result disables JEI transfer. The named methods adapt the +legacy integer methods `screenDestinations` and `screenRecipeSlots` for hosts with combined storage. +Routing methods must work on the client as well as the server. JEI uses its standard transfer +handler with these slots, including its server validation. Only visible normal slots participate in recipe transfer. Category IDs must also occur in the screen's `jei-recipes` attributes. Ghost filters are separate from the real inventory. Override `setScreenGhost(player, index, filter)` to validate and persist a filter, and `screenGhost(index)` to expose its authoritative value. The -menu sends at most one item as the filter; an empty stack clears it. Defaults reject changes and +ghost element normalizes the filter to one item; an empty stack clears it. Defaults reject changes and show empty filters. No real items move through this path. +By default, ghost clicks and JEI ingredient drops submit `GhostSlotElement.SELECT_GHOST` through +the typed element-action channel. `SlotElement.selectionAction()` selects the `ElementAction`; +return empty to opt out. The default `actions()` declares that action automatically. If overriding +`actions()` too, include the same action instance: the behavior checks declaration identity before +submission. `acceptsSelection(button, stack)` controls client eligibility, and `selection(button, +carried)` prepares the payload. Both receive detached stacks. The action handler remains responsible +for authoritative server validation. The default ghost `accept(host, inventory, player, stack)` calls +`selection(0, stack)` on the original slot element again before writing to the host; overrides work +for direct `SlotElement` implementations and subclasses. Make normalization idempotent because it +runs during client preparation and server acceptance. The default keeps one item, or clears an empty +selection. Override `accept` for different authoritative rules. Submitted and stored stacks are copied; +`read(host, inventory)` returns a detached ghost value. + +`selectionIngredient(typeId, ingredient)` converts a detached viewer ingredient into an optional +item selection. The default accepts `mmdlib:item`; mods can recognize other registered ingredient +IDs and convert them without changing the shared behavior. Unsupported inputs return empty. +`SlotBehavior` rechecks action declaration, eligibility and pending state when accepting a drop. + `MachineScreen.submitElementAction` supplies the shared client +submission path, including pending-request handling. The server dispatcher checks menu/session, +request order, reach and visibility before decoding. The ghost action delegates to the inventory +component; `InventoryElement.Session` resolves the named inventory and rejects inactive, +unregistered or non-ghost slots. The old `ScreenNetwork.Ghost` packet and `MachineMenu.ghost` +entry point are removed. Core screen protocol 20 requires matching client/server versions. + Bind the block's `block-right-click` event to `mmdlib:open_menu`. This handler first looks for an XML screen matching the clicked block entity's type; otherwise it uses the block's own vanilla menu provider. It consumes matching clicks on both sides and opens only on the server. A click @@ -158,11 +802,398 @@ For a side-configuration-only drawer, return `false` from ends below its face controls. Combine this with `inventory="none"` to omit player inventory and equipment drawers, as in the companion Creative Power Source. +## Client drawer classes + +`gui.client.Drawer` is the public base for client drawers. Its constructor takes the owning +screen, a registered `DrawerDefinition`, an `IconSprite` and an RGB colour. Subclasses choose +their appearance and own their content layout, input, tooltips and local state. `IconSprite.gui` +uses a GUI sprite ID; `atlas` uses an atlas sprite and `item` preserves a live item model. A custom +icon can also implement the drawing callback. The base draws the shared tab/frame. + +The built-ins are `SideConfigDrawer` (previously `AutomationDrawer`), `RedstoneDrawer`, +`StatsDrawer`, `ContainerDrawer` (backpack or Curios equipment), and `PopoutDrawer` (XML info, +warning and error popouts). Each interprets and restores its own `Drawer.Selection` preference. +`DrawerMemory` only stores connection-scoped preferences and retries restoration. Server-owned +inventory, configuration and acknowledgments remain in their menu/network classes. + +All drawers come from registrations, including built-ins and XML popouts. Register a common-side +provider during mod construction and its client factory during client setup: + +```java +// Common setup; use immutable definition/feature presence, not live storage values. +var type = ResourceLocation.parse("example:diagnostics"); +DrawerRegistry.GLOBAL.register(type, context -> List.of( + new DrawerDefinition(type, "example:diagnostics", + DrawerPlacement.top(Edge.RIGHT, 15)))); + +// Client setup only. The constructor calls super(screen, definition, icon, colour). +DrawerFactories.GLOBAL.register(type, DiagnosticsDrawer::new); +``` + +`gui.drawer.DrawerRegistry` providers return zero or more definitions for a screen/host. Definitions +declare stable IDs, edge, top/bottom anchor, order, tab dimensions, overlap, gap, optional model +element and inventory-toolbar width. Smaller order values sit closest to their anchor. Built-in +top orders are side configuration 0, redstone 10, XML side tabs 20, XML popouts 30 and statistics 40. +Backpack and Curios tabs use bottom orders 0 and 10. Equal orders use provider ID order, then the +provider's list order, so mod loading order does not affect geometry. IDs must be unique per edge. + +`DrawerTabs` packs tabs generically, and `ScreenLayout` reserves the required panel height and +toolbar space. Top tab positions stay reserved when hidden, keeping XML/vanilla slot geometry +stable; unavailable bottom drawers collapse out of the stack. Bottom tabs respect viewport and +overlay insets while keeping clear of the top stack. A viewport too short to fit the stack needs +a larger window or smaller GUI scale. Drawers use the inherited `tabBounds()` for their allocated +position and own the body geometry. Inventory drawers expose `contentLayout()` for integrations; +the content-layout helper accepts an allocated tab instead of guessing where other drawers sit. + +Every interactive definition needs a factory returning a fresh drawer for that screen. Duplicate +registrations fail; `replace` explicitly replaces an existing provider/factory. Missing factories, +duplicate identities and unclaimed model tabs fail explicitly. A layout-only definition can +place a persistent side tab without creating an interactive drawer. Mods can associate any custom +element with a tab by registering a provider; neither layout nor dispatch needs a new type branch. +Keep registrations and provider results stable while menus are open. Common definitions enter +the client/server opening fingerprint; client factories never load on a dedicated server. + +Override `open` and `close`, then whichever render/input/lifecycle hooks the drawer needs. +Use `drawFrame` for shared framing and override `drawIcon` for badges. The screen forwards events +through `Drawers`. Bounds returned by `exclusions` are also reserved for recipe-browser overlays. +Direct `screen.drawers().add(...)` has been replaced by the registry path above. + +Drawer bodies follow the definition's edge as well as its tab. `SideDrawerLayout.bodyX` and +`room` provide shared horizontal geometry; inventory bodies also check available viewport space. +Server-controlled drawers implement `gui.runtime.component.DrawerSession` on their menu component. +After authorizing an open request, call `menu.components().activate(this)`: registered sessions on +the same edge close through `closeServer()`. Sessions identify their registered drawer type, which +must occupy only one edge. Controllers do not name sibling drawers. + +`gui.runtime.MenuRequests` provides optional bookkeeping primitives for custom menu controllers, +without another registry or a shared payload protocol. Keep one `Client(timeoutTicks)` and one +`Server(perTick)` per controller/session, accessed on the game thread. `Client.begin()` returns a +positive request ID, or zero while pending/after sequence exhaustion. IDs never wrap or get reused. +`tick()` releases an expired request and reports the timeout; the controller chooses recovery. +Use `acknowledgeExact` for exact replies or `acknowledgeThrough` for cumulative snapshots (which +also advance the local sequence). Apply revision/session validation before acknowledging. +On the server, authorize first, then `receive(request)` rejects replays and consumes fresh IDs. +Call `allow(gameTime)` once per fresh request to apply the allowance. Feature rejection and rate +limiting still consume the request ID, allowing authoritative replies to acknowledge it. + +The built-in controllers retain their own policies: + +| Controller | Timeout | Recovery | Acknowledgment | Requests per tick | +|---|---:|---|---|---:| +| Automation | 100 ticks | Unlock | Exact | 8 | +| Redstone | 100 ticks | Send sync | Cumulative | 16 | +| Equipped containers | 20 ticks | Send sync, retain inventory lock | Cumulative | 32 | + +Controllers still own revisions, attachment generations, inventory locks, action validation, +state application and packet transport. These primitives do not replace `ElementActionSession`, +which supports multiple independently pending element actions and typed state correction. + +Local drawers call `openLocal()` before opening to close siblings. Each drawer decides whether +pending requests should delay opening: Statistics waits, while Info remains accessible during +a pending item transfer. Override `closeLocal()` to close local presentation when a server-controlled +sibling activates. Server-controlled drawers call `activated()` after accepting an open request; +their server handles mutually exclusive authoritative state. Use `remember(open, selection)` for +accepted user intent, and override `selection`/`restore` for preference persistence. Returning +`false` from `restore` waits for another snapshot or acknowledgment; `pending()` protects an +unconfirmed preference when the screen closes. `close()` itself should not record user intent +because it is also used when another drawer opens. + +`PopoutElement.colour()` supplies the RGB tab/body colour, and +`content(value, resolveText)` supplies ordered, unwrapped `Component` paragraphs. Empty components +represent blank rows. The defaults retain info/warning/error colours, translated fixed-text +emphasis and styled live text/list entries. Subclasses can override both hooks (and `valueKinds` +for other synchronized value types) while inheriting the existing drawer and renderers; no new +registration is needed. `PopoutDrawer` only wraps the supplied content and owns placement, +scrolling and open/close behavior. + +XML popouts still draw through `ElementRenderers`, whose registered adapter delegates to +`PopoutDrawer`. The generic `drawerPresentation(element, Contract.class)` bridge lets a drawer +expose read-only presentation to its element renderer. Mods can replace drawing passes as before. +`MachineScreen` holds no +drawer-specific open, scroll, layout or restoration logic. + +`ElementFragment.Host.state()` supplies the state used by renderers, behaviors and layout. +`Host.navigate(target, selection)` applies host-owned navigation/prediction, while `Host.submit` +retains action transport and authorization. The fragment forwards only declared actions from +visible, non-pending elements. After applying an authoritative result, call +`fragment.actionResult(element, accepted)` to notify its behavior. + +Fragments run element layout preparation and use `SelectiveContainer.childVisible` for every +ancestor, composed with `Host.visible(element)` and viewport clipping. Rendering, input and +tooltips share this visibility. State changes remeasure the tree and cancel focus/capture when +an element becomes hidden; unchanged bounds preserve interaction. `refresh()` reconciles state +explicitly; drawing, input, ticking, placement and height queries also refresh automatically. +Behavior initialization occurs on first placement, when the viewport is known. A fragment keeps +its element identities and behavior instances; replace it if the actual tree changes. + +Item-provided filter editors also use the element registries. `ElementFragment` hosts an independent +element tree inside a clipped viewport with the shared focus/capture lifecycle. Its host supplies +values and optional behavior services through `ElementAccess.service`; rendering access stays +read-only. See [filter panels](inventory-automation.md#item-provided-filter-panels) for control +registration, validation and the existing typed-widget adapter. + +### Registered network contributors + +`gui.runtime.network.NetworkRegistry.GLOBAL` collects feature-owned play payloads. Register on +both logical sides during mod construction, before NeoForge's payload-registration event: + +```java +NetworkRegistry.GLOBAL.register(ResourceLocation.parse("example:diagnostics"), "1", network -> { + network.playToServer(DiagnosticRequest.TYPE, DiagnosticRequest.CODEC, DiagnosticsNetwork::request); + network.playToClient(DiagnosticReply.TYPE, DiagnosticReply.CODEC, DiagnosticsNetwork::reply); +}); +``` + +The contributor supplies its protocol version; each payload supplies its NeoForge type, bounded +stream codec and handler. The callback runs immediately and its registrar closes when it returns. +Contributor IDs and payload IDs must be unique across this registry, including payloads declared +in opposite directions. A failed declaration registers nothing. Startup freezes the registry and +installs contributors in ID order; registering after that is an error. + +`BuiltinNetworks` declares the built-in contributors through the same API. `ScreenNetwork`, +`AutomationNetwork`, `RedstoneNetwork` and `ContainerDrawerNetwork` each own their version, +payloads, codecs and handlers. `ScreenRuntime` only installs the registry; the generic screen +network has no list of feature networks. NeoForge continues to negotiate versions and dispatch +payloads on its normal handler thread. Handlers remain responsible for menu/session identity, +authorization, revisions and rate limits. Each feature declares its own version; the core screen +protocol is 19, and automation, redstone and container drawers remain at version 16. + +### Registered menu components and native slots + +`gui.runtime.component.MenuComponentRegistry.GLOBAL` creates fresh state for each menu. Register +on both logical sides during mod construction, before opening screens: + +```java +public static final MenuComponentKey DIAGNOSTICS = + new MenuComponentKey<>(ResourceLocation.parse("example:diagnostics"), DiagnosticsMenu.class); + +MenuComponentRegistry.GLOBAL.register(DIAGNOSTICS, 50, context -> + supportsDiagnostics(context.host()) ? new DiagnosticsMenu(context) : null); + +var diagnostics = menu.components().get(DIAGNOSTICS); // null when unavailable +``` + +Factories receive the menu, player inventory, optional host and logical side. They construct their +own state; other components become available after all factories finish. A factory may return +`null` when its feature is absent. Registration order is the numeric order followed by ID; duplicate +IDs fail, and `replace` deliberately replaces an existing typed registration. Keep availability +and slot ordering identical on client and server. Registration IDs, types, order and synchronized +field schemas enter the menu opening fingerprint (core protocol 20). Keep factories and drawer declarations consistent when disabling +a built-in feature. + +`MenuComponent` receives client ticks, before/after interaction, before/after native broadcast and +removal hooks. Optional contracts supply additional behavior: + +| Contract | Responsibility | +|---|---| +| `MenuSlotContributor` | Add native slots, position them and supply ordered shift-click destinations | +| `MenuSnapshot` | Sample registered typed fields and receive validated state changes | +| `DrawerSession` | Close server state when another registered session on the same edge opens | +| `InventoryAccess` | Resolve legacy/named item addresses and handle inventory hints/ghost edits | + +Declare synchronized fields alongside the component registration. Each `ComponentSync` provides +a namespaced ID, schema version, `StreamCodec`, byte limit, initial-value +supplier and optional validator: + +```java +public static final ComponentSync COUNT = new ComponentSync<>( + ResourceLocation.parse("example:count"), 1, + StreamCodec.of((buffer, value) -> buffer.writeVarInt(value), buffer -> buffer.readVarInt()), + 5, () -> 0, value -> value >= 0); + +MenuComponentRegistry.GLOBAL.register(DIAGNOSTICS, 50, context -> + supportsDiagnostics(context.host()) ? new DiagnosticsMenu(context) : null, COUNT); + +// On DiagnosticsMenu implements MenuSnapshot: +@Override public void snapshot(ComponentSnapshot values) { + values.put(COUNT, sampleServerCount()); +} + +int displayedCount = menu.components().state(diagnostics, COUNT); +``` + +The registration owns its field namespace: two components may use the same field ID without +colliding. Fields receive stable transport addresses ordered by component ID and field ID; omitted +components retain their reserved addresses. Components never choose integer wire keys. A writer +accepts only the exact field definitions declared for its component, once per sampling pass. +`replace` replaces the field declarations too; supply the replacement's complete field list. +Bump a field's schema version when its codec or meaning changes, so incompatible peers fail the +opening fingerprint check. + +Each field is limited to 64 KiB, with a 256 KiB encoded-state packet budget shared with element +state. A registry may declare at most 4,096 component fields. Codecs must also bound collection +counts before allocating or reading them. Updates validate the complete component batch before +committing it; reads decode independent copies. Optional `applied(ComponentStateView values)` runs +once per changed component after all values commit, on both server sampling and client updates. +It can cache decoded presentation data. Initial values come from the supplier without a callback. +Inventory hints, statistics and reservation plans use this same registered path. + +`MenuComponent.presentationRoots()` supplies stable per-menu element trees outside the XML screen. +Return matching trees in the same order on client and server. Tooltip synchronization includes those +roots and descendants, followed by any remaining contributed native slot elements, deduplicated by +identity. The existing `TOOLTIPS.ROWS` field uses schema v2 and permits up to 32,767 element addresses +within its existing 64 KiB budget; both sides must have matching schemas. This is a presentation/ +tooltip contribution, not an automatic declaration of element actions or synchronized element fields. +`ElementFragment.Host` exposes `contents`, `hint`, `reservation` and `serverTooltip` for such hosts; +fragments never sample server storage themselves. No additional registry is required. + + +All contributors run `addSlots` before any run `finishSlots`; the built-in inventory component +adds player slots in the second phase. Use `MenuSlots.Registrar` so the menu knows each slot's +element (optional) and role. With the default routing policy, `PLAYER_STORAGE` receives extraction +from machine, auxiliary and equipment slots; insertion from player storage uses contributor destinations, ordered by +`transferPriority`, topping up matching stacks before empty slots. The native adapter still checks +activity, pickup and placement permissions. Storage lookups follow actual menu indexes even if a +mod contributes slots before the built-in inventory. + +Built-ins use these same contracts. Access them through `BuiltinMenuComponents` keys instead of +the removed `menu.automation`, `menu.redstone`, `menu.containerDrawer` and `menu.curiosDrawer` fields. +Statistics use `menu.components().require(BuiltinMenuComponents.STATS).pages()`: each page supplies +a stable timescale ID, selector label and formatted rows. + +`gui.runtime.slot.MenuSlots` owns native assembly and transfer execution; slot elements choose routing targets. Optional `SlotAdapter` +hooks own reservation plans, interaction cleanup and specialized extraction. Item slot adapters +extract only the available portion of a reserved stack. `NativeSlotRenderer` adapts native drawing, +scaling, highlights, hit bounds and item-tooltip routing to `SlotElement`; ghost and reservation +presentation still comes from the element. For filled slots, `SlotElement.nativeTooltip(stack, vanilla)` +selects the native item rows: retain, edit, replace, or return an empty list to suppress them. Its stack +and row components are detached copies; copy the list before adding/removing entries. The bridge then +appends the element's composed tooltip, including all ancestor rows, so replacing native rows never +removes parent context. The default returns vanilla rows unchanged. Empty slots use the ordinary +element tooltip. The bridge checks the registered +native slot's element identity, so unrelated hovered elements retain their own tooltips. +`MachineScreen` only forwards vanilla callbacks to that adapter. + +Drawers can claim an occupied native slot's tooltip through `Drawer.nativeTooltip(slot, vanilla, +mouseX, mouseY)`. Return empty to leave it to the screen, or composed rows when the drawer owns it. +`SideConfigDrawer` uses the same native bridge and an `ElementFragment` rooted in the selected +`AutomationInventoryElement`. Registered slot and inventory renderers run in all four passes, and +registered behaviors contribute tooltip rows. The fragment supplies copied contents/hints, +reservations and synchronized server rows. Empty slots use ordinary element/parent composition; +occupied slots apply `nativeTooltip` first and preserve all composed parent rows afterward. +The default compatible-addon caption belongs to `AutomationInventoryElement.appendChildTooltip`. +In this fragment, a slot's value is its copied contents and the inventory's value is its group-name +component; custom tooltip hooks can read them through `TooltipContext.value(element, false)`. +Override that hook to customize inventory context; each slot still controls its own rows. +`drawerSlotBounds` also drives the inventory's fragment layout, keeping native and rendered positions +aligned. Hidden filter slots are excluded, and closing/switching the drawer removes old behaviors. + +Backpack and equipment pages use `ContainerInventoryElement` with `ContainerSlotElement` children. +`ContainerDrawer.createInventory(Page)` is a protected factory: subclass the drawer through the existing +`DrawerFactories.replace` API to supply your inventory and slot subclasses. The inventory accepts an +index-to-slot factory, preserving absolute container indexes; it owns `cellBounds`, label/header bounds, +and `drawerValue`. The page supplies detached provider metadata and body-relative cell rectangles. +These virtual cells do not create vanilla slots or change container request validation. +`SlotElement.cellHitBounds(frame)` supplies the same geometry to hover highlighting, clicks, +drag-target selection and tooltips. Its default applies `itemPosition`, `hitBounds` and vanilla's +one-pixel tolerance; an override returns the final bounds. The drawer viewport still clips hits. + +`ContainerSlotElement` inherits slot appearance, item positioning, native bounds, rendering and +`nativeTooltip` hooks. Its registered renderer draws the frame, empty icon, real item/decorations, +placeholder and selection overlay. Register a renderer for your subclass to replace those defaults; +all four passes use the existing element registry. `Display` values hold copied contents and visual +drag selection; maximum values default to null. Slot and inventory tooltip hooks run through +`ElementFragment`. Filled cells prepend the slot's `nativeTooltip` rows, then append element and all +parent rows, including behavior contributions. Suppressing native rows preserves parent context. +Item tooltip images and NeoForge item-tooltip context are retained. + +`ContainerSlotElement.description()` supplies the default availability, label, placeholder, tooltip +rows and empty icon. Override it to replace the provider description; an explicitly supplied +`Appearance` retains its own icon. Each individual presentation hook remains overridable. +`SlotElement.cellLabel()` supplies an optional label; `ContainerInventoryElement.cellLabel(slot)` +and `headings()` can customize labels and headings without replacing the renderer. + +Container fragments route hover, clicks, scrolling, keyboard/text input, key releases and pointer +capture through the existing `ElementBehaviors` registrations. A consumed click suppresses the +container gesture; `PASS` leaves the validated drawer fallback in charge. Cell hits use +`cellHitBounds` for both routes, clipped to the drawer body. Drawer controls retain click priority. +Capture receives dragging/releases outside the body. Closing, replacing a page, resizing or receiving +a new revision cancels interaction; pending locks cancel focus/capture and block input independently +of visible contents/tooltips. A cancelled press's release is consumed without committing. +`ElementFragment.Host.hitBounds` and `inputEnabled` expose the same geometry/lock policies to other +fragment hosts. Virtual cells do not register server actions: custom local behaviours cannot bypass +the existing item-transfer protocol or its cursor, session, revision and gesture validation. + +Equipment providers supply `EquippedContainers.Source.presentation` and `selectionKey` separately +from the live capability's topology key. `Presentation` owns title, icon, colour, column headings and +header/tab instructions; `SlotInfo` owns labels, tooltip rows and unavailable placeholders. Components +are copied on construction and access. The Curios adapter supplies its own translations, fallback +labels and sprites; the generic drawer does not derive them from Curios identifiers or cosmetic flags. +The three-argument `Source` constructor retains the live item name and standard container presentation. +Container snapshots now use protocol 19, with bounded text, row counts, policy settings and gesture instructions. + +Each source accepts a `ContainerPolicy`. Construct it with `Settings`, or subclass it and override +`settings()` for provider-dependent choices. `STORAGE` and `EQUIPMENT` are the default presets. +Settings supply body width, columns, row limits, cell placement/stride/size, footer space, initial +page size, alignment, allowed gestures and source-item protection. The server samples settings into +`Presentation.policy`; the client consumes that snapshot. Policy changes advance the interaction +revision and realign an incompatible page. The source's policy is authoritative; changing client +presentation cannot enable a forbidden action. Curios explicitly supplies the equipment preset. +Slot descriptions work with either drawer identity. + +`ContainerPolicy.instructions(Settings)` receives the same sampled settings and supplies copied, +bounded gesture rows. Defaults independently describe enabled distribution, collection and hotbar +swapping, so partial policies still show their supported actions. Override this method for mod text; +`Presentation.gestureRows()` carries the result to the client. A drawer subclass may override +`gestureInstructions()` for client presentation. Instructions never authorize gestures. + +`ContainerDrawerLayout` lays out the supplied settings; the boolean convenience overloads only select +a preset. Inventory header/label bounds follow the policy's cell positions and remain overridable. +Mod layouts must fit the bounded page size and preserve their declared page alignment. + +Container-to-player gestures use `MachineScreen.nativeSlotAt`, including custom hit bounds and vanilla +tolerance. Pending inventory guards use the geometry-only overload to block slots even while their +native interaction is disabled. This never authorizes a transfer. Cursor, source, revision, page, +request and drag validation remain independent of presentation overrides. `ContainerDrawerMenu.targets()` +provides the menu-owned `ContainerTargets` context for registered player hits, target encoding and +copied stack lookup. Server distribution uses its live-source resolver. The ordinary 36-slot player +storage range, hotbar range and wire limits remain validation constraints in the runtime. + +Override `AutomationInventoryElement.slotVisible(slot, Presentation)` to select visible slots. +The read-only context supplies copied group labels and local-slot contents, plus filter support. +Both native activation and fragment rendering use this policy; filter-row height and controls follow +it too. Open/selected-group checks remain in the menu. Pending requests independently disable native +interaction, so slots do not disappear while a request is in flight. Visibility does not override +storage acceptance or server authorization. Keep the policy consistent on both menu sides. + +Override `drawerValue(element, maximum, Presentation)` for renderer and tooltip values, including +maximums. Defaults expose copied slot contents or the group label and return null for maximums. +The drawer delegates without selecting values by element type. Render contents and hints remain +separate services, so custom display values do not change the actual inventory. + +`MachineScreen.nativeSlotAt(x, y)` resolves the first active native slot in screen coordinates using +its overridable `hitBounds`, vanilla's one-pixel tolerance and menu order. Drawer click fallback and +native-tooltip ownership use this hit result instead of slot frames. Enlarged/offset hits can reach +vanilla interaction; shrunken hits leave the surrounding frame to the drawer. Empty-slot tooltips +compose against the hit element, and an occupied slot suppresses the fragment tooltip only on a +native hit. Drawer controls/editor regions retain priority, and pending slots cannot receive hits. + + +`ItemSlotElement.MenuSlot` and `PlayerSlotElement.PlayerMenuSlot` are subclassable with protected +constructors. Override the element's slot factory to return your adapter subclass and refine its +rules while retaining reservation handling or vanilla equipment behavior. A custom client storage +can implement `api.capabilities.ReservationReceiver.setPlan` to receive synchronized plans through +the default item adapter; implement `ReservedSlots` for the corresponding reservation rules. +There is no dependency on the built-in `ClientMenuItems` implementation in that adapter. + + +Slot elements also own native presentation policy. `renderNative()` controls whether the bridge +calls vanilla's slot drawing (default: true except for ghosts). `nativeBounds(vanillaBounds)` chooses +the destination rectangle; the bridge translates and scales vanilla's 16x16 drawing to it and restores +the graphics pose afterward. `highlightBounds` and `hitBounds` default to that rectangle but can be +overridden independently, including offsets and different widths/heights. All rectangles use panel +coordinates, with the original native slot rectangle supplied as input. Default bounds use the +configured item size. Native hit routing retains active-slot checks and vanilla hover tolerance; +non-slot hit tests retain their original bounds. Use the existing element rendering contract for +custom artwork, and disable native drawing when that artwork replaces the item. No new registration +is needed for these policy hooks. + + ## Statistics drawer The left Statistics drawer shows sections supplied by machine parts through -`api.stats.StatsProvider`. The menu knows only this interface, not concrete storage or processor -classes. Return the contributing parts from `ScreenHost.screenStats()` in display order: +`api.stats.StatsProvider`. `StatsMenuComponent` samples this interface without knowing concrete +storage or processor classes. Return the contributing parts from `ScreenHost.screenStats()` in display order: ```java @Override public List screenStats() { @@ -176,16 +1207,62 @@ classes. Return the contributing parts from `ScreenHost.screenStats()` in displa ``` Return stable part instances so the menu can retain counter history. Repeated references to the -same part are sampled once. Providers run only on the server, once per 20 game ticks per menu; -unit changes are local and use the existing synchronized values channel. +same part are sampled once. Providers run only on the server, once per 20 game ticks per host, +shared by its viewers; page selection is local and uses the synchronized component field. A provider returns `Statistics`: a translated section title, rows with stable IDs, and optional -energy-demand contributions. Rows describe a cumulative `Counter`, an exact `Rate`, explanatory -`Text`, or aggregate `MaximumUse`. Each row declares its label, unit, precision and whether it -scales with time. `row.whenUsed(resource)` hides it unless a provider reports positive -energy demand for that exact resource. The drawer handles sampling and formatting. Custom parts can implement this -interface without changing the menu or renderer. Each section supports at most 64 distinct rows; -the combined wire snapshot is limited to 4,096 lines. Empty sections add no title or rows. +energy-demand contributions. Built-in values are cumulative `Counter`, exact `Rate`, explanatory +`Text`, and aggregate `MaximumUse`. Each row declares its label, unit, precision and whether it +scales with time. `row.whenUsed(resource)` hides it unless a provider reports positive demand for +that exact resource. Empty sections add no title or rows. + +`Statistics.Value` is an open interface. A value owns `sample(StatsSamplingContext, previous)`, +returning a `Statistics.Sample` whose `format(StatsFormatContext)` produces a styled `Component`. +Sampling runs once per update, regardless of the number of timescales. Formatting must use the +sampled data without changing history or reading live storage. No statistic registration, renderer +or custom network codec is needed: + +```java +record Percentage(double fraction) implements Statistics.Value { + @Override public Statistics.Sample sample(StatsSamplingContext context, + Statistics.Sample previous) { + long percent = Math.round(fraction * 100); + return format -> Component.literal(percent + "%"); + } +} +``` + +Stateful values can reuse `previous`; it is scoped to provider identity and row ID. It is null on +first appearance, after removal, when game time moves backward, or when the value class or its +`samplingKey()` changes. Return an immutable configuration key (for example the counter window), +excluding the current reading. Fresh value descriptions and reordered rows retain history. The +sampling context exposes game time and aggregated resource demand; the formatting context exposes +the timescale, precision and units, plus `ticks()` and `number(...)` helpers. Numeric conversion +and rolling history helpers live in `api.stats.StatsNumbers` and `CounterRate`. + +Hosts supply ordered timescale data through `screenStatsTimescales()`. Defaults are tick, second +and minute. Add or replace entries without registration: + +```java +@Override public List screenStatsTimescales() { + var scales = new ArrayList<>(StatsTimescale.DEFAULTS); + scales.add(new StatsTimescale(ResourceLocation.parse("example:hour"), 72_000, + Component.translatable("example.stats.per_hour"), + Component.translatable("example.stats.hour_unit"))); + return List.copyOf(scales); +} +``` + +Timescales have a stable namespaced ID, positive tick duration, selector label and rate-suffix unit. +A host may return up to 16 uniquely identified entries, including an empty list. The drawer retains +the selected ID across reordering and falls back to the first remaining page when it disappears; +empty lists have no selection and recover when pages return. Left/right clicks cycle forward/back. + +`ScreenStats` only coordinates providers, sample lifecycle, combined demand and section assembly. +`StatsMenuComponent.PAGES` synchronizes bounded `StatsPage(id, label, lines)` values; the client +never needs the custom value implementation. The field schema is version 2, reflected in menu +fingerprints. Each section supports at most 64 distinct rows, each page at most 4,096 lines, and +the encoded field at most 64 KiB. `PAGES`/`pages()` replace the fixed `LINES`/`lines(unit)` API. Built-in providers: @@ -224,11 +1301,83 @@ as completions. Rates use game time at 20 ticks per second, not wall-clock time. shows a dash until enough time has elapsed. RF values are grouped integers; cycles have exactly two decimal places. For example: 24,000 RF/min and 12.00 cycles/min. -The compact header selector cycles Per tick / Per second / Per minute; right-click reverses. +The compact header selector cycles the host-provided pages, defaulting to Per tick / Per second / +Per minute; right-click reverses. Labels and styled rows come from the synchronized pages. Info, Statistics and equipment drawers replace each other on the left. Right-side automation and redstone drawers remain independent. Statistics scrolls when needed, reserves its bounds from JEI, closes on Escape, and remembers its open state for the connection. +## Registering XML tags + +Every screen tag, including built-ins, resolves through `ScreenTags`. Each registration supplies +its factory, XML attribute contract, allowed children and placement groups. `ScreenReader` handles +secure parsing and delegates construction to the registry. The runtime XSD is compiled from those +registrations: adding a tag also adds its validation contract, without editing MMDLib's schema. +The bundled `screen.xsd` remains the built-in type library and an editor reference for built-ins. + +Register during mod construction, on both physical sides, before screen loading freezes the +registry. For example, assuming your mod defines a `TemperatureElement`: + +```java +ScreenTags.GLOBAL.register(new QName("https://example.org/gui", "temperature"), + ScreenTag.element(TemperatureElement.class, xml -> new TemperatureElement( + xml.common(), xml.size("width", 1), xml.size("height", 1), xml.attribute("unit"))) + .attribute("width", ScreenAttribute.defaulted("pixels", "32")) + .attribute("height", ScreenAttribute.defaulted("pixels", "18")) + .attribute("unit", ScreenAttribute.choices("celsius", "kelvin").withDefault("celsius")) + .build()); +``` + +Imports are `javax.xml.namespace.QName` and `gui.load.ScreenTags`, `ScreenTag`, `ScreenAttribute`. +Use the tag inside ordinary built-in containers: + +```xml + + + + + +``` + +Tag identity is the namespace URI plus local name; prefixes are arbitrary. A URI identifies a +namespace and is never downloaded. `register("my-tag", definition)` uses the standard screen +namespace. Prefer a mod-owned namespace to avoid collisions. Duplicate registrations fail; +`replace(QName, definition)` explicitly replaces both the factory and XML contract before freeze. + +`ScreenTag.element` includes common attributes and membership in the `widget` placement group. +`ScreenAttribute.required(type)`, `optional(type)` and `defaulted(type, value)` accept standard +XSD types such as `xs:string`, `xs:int`, `xs:boolean`, or shared types such as `pixels`, `length`, +`color`, `resourceId` and `translationKey`. Enum choices are supported directly. The model's +validation/value contracts still apply after construction. + +For containers, add `.children("widget", 0, -1)` and construct children with `xml.children()`. +The count `-1` means unbounded. Each child goes through the registry, including nested mod tags. +`.children(QName, min, max)` accepts one exact child tag. Register configuration-only children +with `ScreenTag.data(DataRecord.class, factory)` and read them with `xml.children(DataRecord.class)`; +they do not need to be UI elements. Mods can define their own child groups by assigning tags +`.group("example_options")` and accepting that group in their parent contract. + +Built-in placement groups include `widget`, `slots`, `buttons`, `views`, `radios`, `controls` +and `dock`. `.group(...)` adds membership; `.groups(...)` replaces the default memberships. +For example, an XML factory returning a custom `SlotElement` can join `slots` to work inside +``. Register additional inventory-row controls in `buttons`. Context/view and radio +containers still expect their model types when parsing children; group membership does not +bypass those type checks. +Root `dock` membership places the resulting element in `Screen.tabs()`. + +`ScreenXml` supplies the current node/source, shared `common()`, text/ID/colour/size helpers, +translation-key expansion, recursive child construction and `error(message)` with file/line. +Keep client drawing and local interaction in `ElementRenderers` and `ElementBehaviors` as above. + +Namespaced tags survive inheritance, fills, generated content and embedded machine screens. +`ScreenReader.Node` retains the namespace; generators can use its namespace-bearing constructor. +For tools/tests, use `ScreenTags.builtins()` and the explicit-registry overloads of `ScreenReader.read` +or `ScreenLoader.read`. Unknown tags/attributes, invalid values and illegal child placement remain +errors. DOCTYPE, external entities and XInclude remain disabled; schema imports resolve only to +registered, in-memory contracts. + ## Registering custom widget types A consuming mod can add a leaf widget without replacing MMDLib's screen or menu. Register its @@ -239,10 +1388,10 @@ import java.util.Set; import net.minecraft.resources.ResourceLocation; import zone.moddev.mc.lib.gui.WidgetType; import zone.moddev.mc.lib.gui.WidgetTypes; -import zone.moddev.mc.lib.gui.load.BoundValue.Kind; +import zone.moddev.mc.lib.gui.value.ValueTypes; WidgetTypes.GLOBAL.register(ResourceLocation.parse("example:temperature"), - new WidgetType(Set.of(Kind.WHOLE, Kind.DECIMAL, Kind.NUMBER), widget -> { + new WidgetType(Set.of(ValueTypes.WHOLE, ValueTypes.DECIMAL, ValueTypes.NUMBER), widget -> { if (widget.common().value().isEmpty() || widget.common().max().isEmpty()) throw new IllegalArgumentException("Temperature needs value and max"); if (!widget.properties().isEmpty()) @@ -347,7 +1496,7 @@ mouse, scroll, key, character, tick, focus and lifecycle methods. Its click resu Only a captured press receives mouse drag/release callbacks. Releasing an inventory drag over a widget leaves the release with the inventory, regardless of which widget is under the pointer. -Tab and Shift+Tab move between visible focusable custom widgets in document order. While one is +Tab and Shift+Tab move between visible focusable element behaviors in document order. While one is focused, inventory shortcuts are consumed even when its `keyPressed` returns false, so typing E, Q or a number cannot close the screen or move a slot. `charTyped` is separate from `keyPressed`. Escape first cancels capture; otherwise it is offered to the focused instance. If unhandled, it @@ -386,17 +1535,17 @@ var setLimit = new WidgetAction(ResourceLocation.parse("example:set_lim }); WidgetTypes.GLOBAL.register(ResourceLocation.parse("example:limit_slider"), - new WidgetType(Set.of(Kind.WHOLE), widget -> { + new WidgetType(Set.of(ValueTypes.WHOLE), widget -> { if (widget.common().value().isEmpty()) throw new IllegalArgumentException("Limit slider needs value"); }, List.of(setLimit))); ``` The relevant imports are `gui.WidgetAction`, `gui.WidgetType`, `gui.WidgetTypes`, -`gui.load.BoundValue.Kind`, `net.minecraft.network.codec.StreamCodec`, and the ordinary Java +`gui.value.ValueTypes`, `net.minecraft.network.codec.StreamCodec`, and the ordinary Java collections. Codecs use `RegistryFriendlyByteBuf`, so structured records and registry-aware values are supported. Register matching action IDs and codecs on both sides. Each action declares -a maximum encoded size of 1–4096 bytes. The envelope is bounded before the mod codec runs, the +a maximum encoded size of 1–4096 bytes. The envelope is bounded before the mod codec runs, the encoder uses a bounded buffer, and trailing bytes are refused. **Codecs must additionally bound their own string and collection lengths before allocating.** @@ -406,14 +1555,14 @@ Codec runtime exceptions (including buffer overflow) are logged and return false a packet or marking the widget pending. Do not submit every render frame. For sliders, commit on release; throttle continuous controls yourself. -The server checks menu ownership, interaction distance, visibility, element type, allowed action, +The server checks menu ownership, interaction distance, visibility, the element's allowed action, menu-session token and increasing request number before dispatch. At most eight typed actions per menu per server tick are processed; excess edits receive an immediate rejection using the last sent values, without invoking their codec, getters or handler. Replayed requests are dropped. Handler exceptions are logged and rejected, but MMDLib cannot undo a handler's partial mutations. -The response includes accept/reject and fresh authoritative `value`/`max` snapshots after the -handler (or the last sent values for rate-limited edits). They are applied before +The response includes accept/reject, fresh authoritative `value`/`max` snapshots and typed element +state after the handler (cached values/state for rate-limited edits). They are applied before `WidgetInstance.actionResult(boolean)` runs and update the server's delta baseline. The next ordinary snapshot also refreshes those values, so a late acknowledgment ignored after timeout cannot leave the client stale. Ordinary snapshot sync continues for every viewer, including changes to other @@ -425,13 +1574,26 @@ or never applied the edit. The timer follows the player's open menu even while a covers it; result callbacks are delivered when the machine screen resumes. Responses for old sessions or obsolete requests are ignored. +`gui.runtime.ElementActionSession` owns this per-menu lifecycle: request IDs, pending/results, +timeout recovery, replay/rate checks, acknowledgment baselines and next-broadcast correction keys. +`MachineMenu` delegates action operations and supplies player/menu/distance authorization plus fresh +snapshots. Removing the menu closes the action session, clears pending/results and refuses later +requests or acknowledgments. Covering its screen leaves the menu session active. + +Direct integrations use `sessionId()`, `prepareElementAction`, `elementPending`, +`receiveElementResult`, `takeElementResult` and `tickElementActions`. `tickClientActions` also ticks +registered menu components. The older widget-named methods are forwarding aliases; the existing +`ScreenNetwork.Widget`/`WidgetResult` envelopes and wire IDs are retained. No action-session registry +or additional registration is needed. + The companion Settings view demonstrates all three cases: a named machine, an energy-reserve slider that holds processing below the chosen charge percentage, and a Run/Pause selector whose payload contains both lane index and state. The server restricts that selector to the lane named in its XML property. These settings persist with the machine. The graphical smoke run checks focus, typing, cancellation, resizing, view changes, release outside bounds and server rejection. -The expanded menu protocol is version **9**, including a session token in menu-opening data. +The expanded menu protocol is version **14**, including a session token and registered drawer +definitions in menu-opening validation, plus typed element state in deltas and acknowledgments. Client and server must use matching MMDLib builds. ## Files and IDs @@ -651,8 +1813,22 @@ boots, with the offhand slot beside the hotbar. Empty equipment slots show vanil armor uses NeoForge's equipment checks, a one-item limit, and vanilla binding-curse protection. The complete inventory panel is 184 by 94 GUI pixels, including its toolbar row. There is no inventory label: the toolbar shares the helmet's row, directly above the three storage rows. -Write `` only to place it somewhere else, or set `inventory="none"` on -`` to leave out the inventory and equipment together. +Write `` to move the inventory and its default equipment column, or place +the three sections separately with ``, `` and +``. Each section may appear once; omitted sections retain their default placement. +Set `inventory="none"` on `` to leave out all three sections. + +```xml + + + + + +``` + +Here the armour column is on the left and the offhand is independently placed on the right. +Containers and offsets work normally. When equipment is explicitly placed elsewhere, the inventory +section is 162 pixels wide; omitted equipment reserves the default right-hand column. Shift-clicking equipment moves it into the ordinary inventory/hotbar. Machine outputs also go there; they do not automatically equip armor or fill the offhand. Shift-clicking ordinary @@ -661,13 +1837,82 @@ only the ordinary inventory and hotbar, leaving equipped items alone. Optional `` goes after the main body and before tabs/popouts. Its button children form a right-aligned row above the player inventory (for example manual pause -toggles). Automation's reusable `InventoryColorButtonBar` shares that row on the left, with +toggles). Automation uses the reusable `ColourButtonBar` on the left, with scrollable overflow. Both sides align with the nine-column storage grid, leaving the equipment column clear. Flat controls fit in the inventory's existing top row; taller controls add only the extra height they need. Use ordinary button declarations inside it; no spacer or coordinates are needed. This element requires a player inventory and is available to every screen, not just companion machines. +Elements own group associations through `Element.associatedGroups(screen, groups)`. The default +matches the element ID against each `ElementGroup`'s explicit anchors, or matches an unanchored +group's resource type/index to `resourceBinding()`. `ResourceBinding.groupType()` maps unqualified +kinds such as `fluids` to `mmdlib:fluids`; mod kinds can use namespaced IDs directly, without a registry. +`SlotElement` additionally matches unanchored item groups by named inventory and local slot index; +unscoped slots use the group's `legacyIndices`. The three-argument `ElementGroup.Members` constructor +uses the same indices for both address spaces; generated machine metadata supplies them separately. +Explicit anchors always take precedence over resource/member fallback. +`ElementGroup` carries read-only IDs, anchors and optional resource-member metadata, without +exposing storage or automation operations. Mods can override the hook directly, including in +subclasses, and combine their associations with `super.associatedGroups(...)`. +`Element.highlightedGroups(screen, groups)` defaults to those same associations. Override it to +opt out of highlighting or choose a different set while retaining the ordering associations. +The drawer and generated-machine highlight compilation both use `GroupHighlights.of` to resolve +these hooks. Unknown group IDs are ignored, duplicates are removed per element, and targets retain +document order. A selected container can override `replacesChildHighlight(screen, descendant, group, +groups)` to represent a descendant's highlight for that group. Only actual ancestor targets can +suppress descendants; an unnamed container cannot suppress named targets during generated binding +compilation. `ColouredGroupElement` represents slots in its child inventory scope by default, leaving +nested scopes independent. Override its association or replacement hooks to change that policy. +Registered `HighlightPresentation` still owns drawing, visibility filtering and clipping. + +`GroupOrder.of` walks every element in document order (including inactive views), keeps the first +occurrence of each declared ID, then appends unassociated groups in declaration order. Unknown +IDs are ignored; declared IDs must be unique. It accepts an association function and has no slot +or resource-type checks. `ColourButtonBar` takes ordered `Entry(id, colour)` values and a +selection callback; it owns drawing, overflow, hit testing and selection visibility. Its RGB +colours and IDs can represent any caller's data. `SideConfigDrawer` supplies the metadata and +maps the resulting order back to stable automation indices. No additional registry is involved. + +`PlayerInventoryElement` owns the 36 storage/hotbar slots; `PlayerArmourElement` owns the four +armour slots; `PlayerOffhandElement` owns the offhand. Their shared `PlayerSlotsElement` base exposes +ordinary `PlayerSlotElement` children, inheriting item-slot rendering. `PlayerSlotElement` owns +player-slot creation, empty equipment icons, armour acceptance/stack limits, equip callbacks and +binding-curse pickup restrictions; `ItemSlotElement` owns machine-storage interactions. +`MachineMenu.playerSlotElements()` identifies player backing through `playerInventory()`, independently +of transfer roles, and retains native registration order. + +`SlotElement.playerInventory()` opts any implementation into player-slot assembly. Override +`createPlayerSlot(inventory, bounds, visible)`, `role()` and `playerSlotOrder()` to control the native +adapter, transfer routing and ordering. Lower order values come first; equal values retain tree +traversal order. `PlayerSlotElement` supplies vanilla equipment/storage roles and the usual main +inventory, hotbar, armour, offhand order. Subclasses may override each hook independently. +Custom player slots can appear in ordinary containers without a standard player-inventory region; +no registry is needed. Keep these definitions deterministic on client and server, and include +additional configuration in the element's model equality/text used by screen fingerprints. +All three containers accept `(Common, IntFunction)` constructors. The factory +runs once per standard player index and must return a player-backed slot with that index. Its +stable children participate in traversal, validation, layout, equality and screen fingerprints. +For example, `new PlayerArmourElement(common, MyArmourSlot::new)` keeps the standard armour layout +and uses the custom slots' appearance, native factory, role and ordering hooks. Constructors do not +call overridable methods on a partly initialized section. Custom `PlayerSlotsElement` subclasses +can instead pass a collection of already-created slot children to its protected constructor and +supply their own positioning. + +All three containers are extensible. Default sections are stable implicit model roots, included +in screen traversal, validation and fingerprints. `MachineScreen` uses the same rendering and +behavior traversal for every element, without a separate player-slot pass. + +Inventory placement owns the toolbar policy, automatic footer and extra space for tall controls. +`ScreenLayout` composes the measured regions and supplies generic placement operations. +Offsets on an explicit inventory or its parents move native slots, toolbar and controls together. +Implicit equipment follows the inventory's visibility; explicitly placed sections follow their +own parents. Player slots declare their backing inventory, so machine storage validation and +group-membership fallback do not treat them as machine slots. Native slot numbering and transfer +roles remain stable regardless of section placement. +`PlayerInventoryElement.slot` describes the default arrangement; +use `layout.bounds(slotElement)` for independently placed sections. + ## Layout - **Boxes.** `vbox` stacks children top to bottom and `hbox` left to right, so text of any @@ -714,7 +1959,7 @@ requires a player inventory and is available to every screen, not just companion boolean (`false` = 0, `true` = 1) or whole-number `value`. Optional `value-labels` supplies translated names for `{value}` in the tooltip. When both lists are present their lengths must match. Missing or out-of-range values display the first state. These buttons keep the standard - 20×20 icon-button size by default. Set `flat="true"` for a 14×14 control with a 12×12 + 20×20 icon-button size by default. Set `flat="true"` for a 14×14 control with a 12×12 icon, a uniform one-pixel dark border and a flat grey background that lightens on hover. Flat buttons do not take text captions. An `hbox` with a leading `spacer` places them against its right edge. @@ -747,12 +1992,26 @@ instance to call and is rejected at load. Indexed references support generated gauges for multiple tanks without one Java getter per tank. For example, `value="#getTankFluid(0)" max="#getTankCapacity(0)"`. -Getters run only on the server, each tick while the screen is open. The client never calls -them; it only receives the values. +Use `` for a complete tank component. The host only +exposes its `FluidStorage` through `ScreenHost.screenFluids()`; XML-defined machines provide +this automatically. `FluidTankElement` owns binding validation, fluid/capacity snapshots, +tooltips and container transfers. It needs no value getters or machine-specific callbacks. +It rejects explicit `value`, `max` and `interaction` attributes so display and interaction +cannot accidentally target different tanks. `` is an alias +for the same component; a plain `` remains a general display with getter bindings. + +With a fluid container on the cursor, **left-click fills the tank** and **right-click drains +it into the container**. Input and manual tanks can be drained by players. Insertion retains +fluid/recipe filters and rejects output tanks; both directions respect reservations. Each +click handles one container, including partial modded containers. Stacked results go into the +player inventory (or drop when full); creative containers remain unchanged. + +Getters and element-owned snapshots run only on the server, each tick while the screen is open. +The client only receives their values. -Values name Java getters, not storage handles. The `energy-gauge` convenience widget defaults -to `#getEnergy` and `#getEnergyCapacity`; the block entity must expose these public getters, -or the screen must override `value` and `max` to name its own. +Explicit `value` and `max` attributes name Java getters. Resource components own their snapshots: +`fluid-tank` reads `ScreenHost.screenFluids()` and `energy-gauge` reads `ScreenHost.screenEnergy()`. +Neither component needs value/capacity getters on the block entity. The loader checks at startup that each method exists, belongs to the right class, and returns a supported type. A fluid gauge's getter returns the fluid itself (its type and amount); energy @@ -765,11 +2024,12 @@ and some values (ProjectE's EMC, for one) exceed even a Java `long`. | Attribute | Runs on | Purpose | |---|---|---| | `id` | client | Names the element so others can refer to it (`hover-shows`, `side-config groups`); unique within the screen | -| `value`, `max` | server → client | What the element shows (see [Values](#values)) | +| `value`, `max` | server → client | What the element shows (see [Values](#values)) | | `tooltip` | client | Translation key; `{value}` and `{max}` are filled in, with large numbers shortened (`40.2 E`) | | `tooltip-shift` | client | Detailed tooltip shown while Shift is held; a "Shift for more" hint is added automatically | | `on-click`, `on-right-click` | server | Handler IDs from extension files; the handler receives the player, menu, element, and held modifier keys | | `on-shift-click`, `on-shift-right-click` | server | Optional: used instead of the plain handler while Shift is held | +| `interaction`, `interaction-target` | server | Reusable registered behavior and its screen-authored target; supported on every element except inventory slots | | `on-hover` | client only | Handler run when the mouse enters and leaves the element; display only, it cannot change game state | | `hover-shows` | client | ID of an element to show while hovering, without any handler | | `offset-x`, `offset-y` | client | Pixel nudge after layout | @@ -777,6 +2037,51 @@ and some values (ProjectE's EMC, for one) exceed even a Java `long`. Clicks are sent to the server through the menu's button channel, and the server checks the player, menu, and element before running the handler. Hover never reaches the server. +### Reusable element interactions + +An interaction belongs to the element, regardless of whether Java, XML, or a generator created +the screen. For example, a label can use the same fluid transfer behavior as a gauge: + +```xml + diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationClientSmoke.java index 46698dd8..db559592 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationClientSmoke.java @@ -10,7 +10,9 @@ import zone.moddev.mc.lib.api.capabilities.RelativeFace; import zone.moddev.mc.lib.gui.client.MachineScreen; import zone.moddev.mc.lib.gui.layout.AutomationLayout; -import zone.moddev.mc.lib.gui.layout.ScreenLayout; +import zone.moddev.mc.lib.gui.layout.SideDrawerLayout; +import zone.moddev.mc.lib.gui.model.containers.PlayerInventoryElement; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Real client/server round trips, screenshots and item-owned editor transitions. */ final class AutomationClientSmoke { @@ -32,18 +34,25 @@ private static void toggleFilter(MachineScreen screen) { } static boolean renderTooltipCapture(MachineScreen screen, GuiGraphics graphics, float partial) { - if (stage == 2 && !screen.getMenu().automation.filter(0, 0).isEmpty()) { + if (stage == 2 + && !screen + .getMenu() + .components() + .get(BuiltinMenuComponents.AUTOMATION) + .filter(0, 0) + .isEmpty()) { var slot = screen .getMenu() .slots .get( - screen.getMenu().automation.slotStart() + screen.getMenu().components().get(BuiltinMenuComponents.AUTOMATION).slotStart() + zone.moddev.mc.lib.api.automation.AutomationInventory.filterSlot(0, 0)); screen.render(graphics, screen.guiLeft() + slot.x + 6, screen.guiTop() + slot.y + 6, partial); return true; } - if (stage != 8 || !screen.getMenu().automation.open()) return false; + if (stage != 8 || !screen.getMenu().components().get(BuiltinMenuComponents.AUTOMATION).open()) + return false; var drawer = automationBounds(screen); screen.renderWithTooltip(graphics, drawer.x() + 81, drawer.y() + 52, partial); return true; @@ -52,7 +61,7 @@ static boolean renderTooltipCapture(MachineScreen screen, GuiGraphics graphics, static boolean run(MachineScreen screen) { var mc = Minecraft.getInstance(); var menu = screen.getMenu(); - var a = menu.automation; + var a = menu.components().get(BuiltinMenuComponents.AUTOMATION); if (screen.guiLeft() != (screen.width - screen.layout.panel().width()) / 2 || screen.guiTop() != (screen.height - screen.layout.panel().height()) / 2) throw new AssertionError("Drawers must never move the centred main panel"); @@ -60,14 +69,18 @@ static boolean run(MachineScreen screen) { if (stage == 0) { if (a.inventory.groups().size() != 7) throw new AssertionError("Seven colour selectors"); var info = - (zone.moddev.mc.lib.gui.model.Element.Popout) + (zone.moddev.mc.lib.gui.model.elements.PopoutElement) menu.bound.screen().find("info").orElseThrow(); - if (info.edge() != zone.moddev.mc.lib.gui.model.Element.Edge.LEFT) + if (info.edge() != zone.moddev.mc.lib.gui.model.Edge.LEFT) throw new AssertionError("Information stays on the left"); - var colours = screen.layout.inventoryColors().orElseThrow(); + var colours = screen.layout.region(PlayerInventoryElement.TOOLBAR).orElseThrow(); if (!screen.mouseClicked( screen.guiLeft() + colours.x() + 3, screen.guiTop() + colours.y() + 3, 0)) throw new AssertionError("Inventory colour bar opens the first displayed inventory"); + for (int tick = 0; tick < 99; tick++) a.tick(); + if (!a.pending()) throw new AssertionError("Automation remains pending before timeout"); + a.tick(); + if (a.pending()) throw new AssertionError("Automation timeout unlocks without resync"); stage++; return false; } @@ -117,11 +130,16 @@ static boolean run(MachineScreen screen) { if (stage == 3) { if (a.editor() != 0) throw new AssertionError("Filter editor opens"); var exclusions = screen.exclusions(); - if (exclusions.stream().filter(rect -> rect.x() >= screen.panelBounds().right()).count() - != 2 + (screen.getMenu().redstone == null ? 0 : 1)) + var ownExclusions = + screen.drawers().entries().stream() + .filter(zone.moddev.mc.lib.gui.client.SideConfigDrawer.class::isInstance) + .findFirst() + .orElseThrow() + .exclusions(); + if (ownExclusions.size() != 2 || !exclusions.containsAll(ownExclusions)) throw new AssertionError("Filter must remain inside the existing drawer exclusion"); var drawer = automationBounds(screen); - if (drawer.y() != screen.guiTop() + ScreenLayout.TAB_PADDING + if (drawer.y() != screen.guiTop() + SideDrawerLayout.MARGIN || drawer.height() <= compactHeight || drawer.bottom() <= screen.panelBounds().bottom() || drawer.bottom() > screen.drawerBottom()) diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationGameTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationGameTests.java index 02adccb8..ab7e3231 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationGameTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationGameTests.java @@ -12,6 +12,7 @@ import zone.moddev.mc.lib.api.automation.*; import zone.moddev.mc.lib.api.capabilities.*; import zone.moddev.mc.lib.gui.runtime.*; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; @GameTestHolder(CompanionMod.ID) @PrefixGameTestTemplate(false) @@ -41,12 +42,17 @@ public static void menuRecoveryReturnsOnlyTheCurrentPlayersDetachedFilter(GameTe machine.getBlockPos().getX(), machine.getBlockPos().getY(), machine.getBlockPos().getZ()); var menu = new MachineMenu(72, player.getInventory(), machine); player.containerMenu = menu; - menu.automation.action( - player, - new AutomationNetwork.Action( - 72, menu.widgetSession(), 1, "select", 0, -1, 0, 0, "", 0, "")); + menu.components() + .get(BuiltinMenuComponents.AUTOMATION) + .action( + player, + new AutomationNetwork.Action( + 72, menu.widgetSession(), 1, "select", 0, -1, 0, 0, "", 0, "")); menu.clicked( - menu.automation.slotStart(), 0, net.minecraft.world.inventory.ClickType.PICKUP, player); + menu.components().get(BuiltinMenuComponents.AUTOMATION).slotStart(), + 0, + net.minecraft.world.inventory.ClickType.PICKUP, + player); require( player.getInventory().countItem(CompanionAddons.ITEM_FILTER.get()) == 1, "Only the filter detached by this player's click goes to that player"); @@ -323,7 +329,7 @@ public static void menuActionsAreSessionAndGenerationBound(GameTestHelper h) machine.getBlockPos().getX(), machine.getBlockPos().getY(), machine.getBlockPos().getZ()); var menu = new MachineMenu(71, player.getInventory(), machine); player.containerMenu = menu; - var a = menu.automation; + var a = menu.components().get(BuiltinMenuComponents.AUTOMATION); a.action( player, new AutomationNetwork.Action( diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRecoveryTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRecoveryTests.java index 4dea68d6..20da430f 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRecoveryTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRecoveryTests.java @@ -23,6 +23,7 @@ import zone.moddev.mc.lib.api.automation.*; import zone.moddev.mc.lib.api.capabilities.*; import zone.moddev.mc.lib.gui.runtime.MachineMenu; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; @GameTestHolder(CompanionMod.ID) @PrefixGameTestTemplate(false) @@ -164,8 +165,8 @@ public static void cachesInvalidateAndUnchangedInventoriesSkipRevalidation(GameT a.group(0).sides().set(RelativeFace.FRONT, IoMode.DISABLED); require( a.viewRevision() > revision - && a.mode(ResourceType.ITEMS, 0, Direction.NORTH) == IoMode.DISABLED - && a.mode(ResourceType.ITEMS, 1, Direction.NORTH) == IoMode.OUTPUT, + && a.mode(ResourceType.ITEMS, "input", 0, Direction.NORTH) == IoMode.DISABLED + && a.mode(ResourceType.ITEMS, "output", 0, Direction.NORTH) == IoMode.OUTPUT, "Indexed membership preserves independent modes and notifies menu revision"); h.succeed(); } @@ -406,11 +407,21 @@ public static void unsidedEnergyIgnoresFaceModesAndSnapshotsExcludeAddonSlots(Ga field.setAccessible(true); var original = machine.items; try { - field.set(machine, new ItemStorage(5)); + field.set( + machine, + zone.moddev.mc.lib.api.capabilities.ItemInventories.partition( + java.util.List.of("input", "output", "output", "hidden", "hidden"))); var player = h.makeMockServerPlayerInLevel(); var menu = new MachineMenu(91, player.getInventory(), machine); + menu.components().snapshot(); require( - menu.automation.slotStart() == 3 && menu.reservationSnapshot().size() == 3, + menu.components().get(BuiltinMenuComponents.AUTOMATION).slotStart() == 3 + && menu.components() + .state( + menu.components().require(BuiltinMenuComponents.RESERVATIONS), + zone.moddev.mc.lib.gui.runtime.slot.ReservationMenuComponent.PLANS) + .keySet() + .equals(java.util.Set.of(0, 1, 2)), "Reservation snapshot includes only displayed base slots, not hidden underlying slots or" + " addon indices"); } finally { diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRoleTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRoleTests.java index 803e932c..87002988 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRoleTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRoleTests.java @@ -16,6 +16,7 @@ import zone.moddev.mc.companion.*; import zone.moddev.mc.lib.api.automation.*; import zone.moddev.mc.lib.gui.runtime.*; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; @GameTestHolder(CompanionMod.ID) @PrefixGameTestTemplate(false) @@ -78,11 +79,13 @@ public static void duplicateRolesRejectedByStorageAndMenu(GameTestHelper h) { machine.getBlockPos().getX(), machine.getBlockPos().getY(), machine.getBlockPos().getZ()); var menu = new MachineMenu(71, player.getInventory(), machine); player.containerMenu = menu; - menu.automation.action( - player, - new AutomationNetwork.Action( - 71, menu.widgetSession(), 1, "select", 0, -1, 0, 0, "", 0, "")); - int destination = menu.automation.slotStart() + 1; + menu.components() + .get(BuiltinMenuComponents.AUTOMATION) + .action( + player, + new AutomationNetwork.Action( + 71, menu.widgetSession(), 1, "select", 0, -1, 0, 0, "", 0, "")); + int destination = menu.components().get(BuiltinMenuComponents.AUTOMATION).slotStart() + 1; menu.setCarried(puller.copy()); menu.clicked(destination, 0, ClickType.PICKUP, player); require( diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/BackpackClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/BackpackClientSmoke.java index 7b99a727..b93ef718 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/BackpackClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/BackpackClientSmoke.java @@ -4,11 +4,13 @@ import net.minecraft.client.Screenshot; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; + import net.neoforged.neoforge.capabilities.Capabilities; + import zone.moddev.mc.lib.gui.client.MachineScreen; -import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout; import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; import zone.moddev.mc.lib.gui.runtime.MachineMenu; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Real negotiated client/server coverage for the optional Curios and backpack runtime fixtures. */ final class BackpackClientSmoke { @@ -18,20 +20,24 @@ final class BackpackClientSmoke { static boolean run(MachineScreen screen) { if (!BackpackFixture.installed()) { require( - screen.getMenu().containerDrawer.state().icons().isEmpty(), + screen + .getMenu() + .components() + .get(BuiltinMenuComponents.BACKPACK) + .state() + .icons() + .isEmpty(), "No backpack button without optional mods"); return true; } var mc = Minecraft.getInstance(); var menu = screen.getMenu(); - var drawer = menu.containerDrawer; + var drawer = menu.components().get(BuiltinMenuComponents.BACKPACK); if (drawer.pending()) return false; var state = drawer.state(); if (step == 5) return verified && state.icons().isEmpty(); if (state.icons().isEmpty()) return false; - var layout = - ContainerDrawerLayout.of( - screen.panelBounds(), screen.drawerBottom(), state.sizes().get(state.selected())); + var layout = DrawerExtensionClientSmoke.containerLayout(screen, false); require(layout.pageSize() > 0, "Smoke window has room for storage"); require(layout.columns() == 6, "Backpack storage wraps at six columns"); require( @@ -56,6 +62,10 @@ static boolean run(MachineScreen screen) { Screenshot.grab( mc.gameDirectory, "backpack-drawer.png", mc.getMainRenderTarget(), message -> {}); click(screen, layout.cell(0), 1); + for (int tick = 0; tick < 19; tick++) drawer.tickClient(); + require(drawer.pendingInventory(), "Container transfer stays locked before timeout"); + drawer.tickClient(); + require(drawer.pendingInventory(), "Recovery sync preserves the inventory lock"); step++; } case 2 -> { @@ -112,7 +122,10 @@ static boolean run(MachineScreen screen) { == 12, "Other storage remains intact"); BackpackFixture.unequip(player); - ((MachineMenu) player.containerMenu).containerDrawer.broadcast(); + ((MachineMenu) player.containerMenu) + .components() + .get(BuiltinMenuComponents.BACKPACK) + .broadcast(); verified = true; } catch (Throwable error) { CompanionClientSmoke.fail(error); diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/BackpackInteractionClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/BackpackInteractionClientSmoke.java index e8af5ec9..92757ed2 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/BackpackInteractionClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/BackpackInteractionClientSmoke.java @@ -3,10 +3,11 @@ import net.minecraft.client.Minecraft; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; + import zone.moddev.mc.lib.gui.client.MachineScreen; -import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout; import zone.moddev.mc.lib.gui.layout.Rect; import zone.moddev.mc.lib.gui.runtime.EquippedContainers; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Real mouse gestures and hotbar keys, including a double-click before pickup acknowledgement. */ final class BackpackInteractionClientSmoke { @@ -16,15 +17,13 @@ final class BackpackInteractionClientSmoke { static boolean run(MachineScreen screen) { var mc = Minecraft.getInstance(); var menu = screen.getMenu(); - var state = menu.containerDrawer.state(); + var state = menu.components().get(BuiltinMenuComponents.BACKPACK).state(); if (reported != step) { reported = step; zone.moddev.mc.lib.MMDLib.LOGGER.info("BACKPACK_INTERACTION_STEP {}", step); } - if (menu.containerDrawer.pending()) return false; - var layout = - ContainerDrawerLayout.of( - screen.panelBounds(), screen.drawerBottom(), state.sizes().getFirst()); + if (menu.components().get(BuiltinMenuComponents.BACKPACK).pending()) return false; + var layout = DrawerExtensionClientSmoke.containerLayout(screen, false); switch (step) { case 0 -> setup( diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/BasePoweredMachineTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/BasePoweredMachineTests.java index 00fc7f49..c1a9031f 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/BasePoweredMachineTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/BasePoweredMachineTests.java @@ -108,7 +108,7 @@ public static void ownedPowerSurvivesEvenWhenCustomLifecycleOmitsIt(GameTestHelp var restored = new Probe(helper, mode); restored.loadWithComponents(saved, registries); require( - restored.getEnergy() == 1234 && restored.getEnergyCapacity() == 3210, + restored.energy.stored() == 1234 && restored.energy.capacity() == 3210, "Base owns power persistence with no lifecycle, a raw lifecycle, or its factory"); require( restored.powerAtRestore == 1234 && restored.featureValue == 7, @@ -117,7 +117,7 @@ public static void ownedPowerSurvivesEvenWhenCustomLifecycleOmitsIt(GameTestHelp require(restored.items.getStackInSlot(0).getCount() == 3, "Custom storage also survives"); restored.clearContent(); require( - restored.getEnergy() == 0 + restored.energy.stored() == 0 && restored.featureValue == 0 && restored.items.getStackInSlot(0).isEmpty(), "Clear includes power even when the subclass omitted it"); @@ -149,7 +149,7 @@ public static void customClientUpdatesAreIsolatedFromPersistence(GameTestHelper client.featureValue == 9 && client.visual == 42 && client.clientReads == 1, "Normal update packets carry features and custom renderer state together"); require( - client.getEnergy() == 2345 + client.energy.stored() == 2345 && client.items.getStackInSlot(0).getCount() == 4 && client.metadata == 61 && client.worldReads == 0, diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java index 5d27bd66..fccaede9 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java @@ -29,16 +29,20 @@ import zone.moddev.mc.companion.ProcessorEntity; import zone.moddev.mc.lib.MMDLib; import zone.moddev.mc.lib.gui.client.MachineScreen; +import zone.moddev.mc.lib.gui.model.containers.PlayerInventoryElement; import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Opens all four networked menus in an isolated world and captures the rendered screens. */ @EventBusSubscriber(modid = CompanionMod.ID, value = Dist.CLIENT) public final class CompanionClientSmoke { private static final BlockPos POS = new BlockPos(0, 100, 0); + /** - * Time the whole smoke may take once the title screen is up. Launch time is not counted: it varies - * with the machine, and a slow CI runner with software rendering needs most of this budget for the - * menus alone. A menu that never renders still fails within 400 ticks (see {@link #tick}). + * Time the whole smoke may take once the title screen is up. Launch time is not counted: it + * varies with the machine, and a slow CI runner with software rendering needs most of this budget + * for the menus alone. A menu that never renders still fails within 400 ticks (see {@link + * #tick}). */ private static final long BUDGET_MILLIS = 360_000; @@ -60,6 +64,30 @@ public static void render(ScreenEvent.Render.Pre event) { screen, event.getGuiGraphics(), event.getPartialTick())) event.setCanceled(true); } + @SubscribeEvent + public static void filterRender(ScreenEvent.Render.Post event) { + if (Boolean.getBoolean("mmdlib.filterSmokeOnly") + && event.getScreen() instanceof MachineScreen) { + FilterElementClientSmoke.render(event.getGuiGraphics()); + FragmentNavigationClientSmoke.render(event.getGuiGraphics()); + } + } + + @SubscribeEvent + public static void slotPolicyRender(ScreenEvent.Render.Post event) { + if (Boolean.getBoolean("mmdlib.drawerSmokeOnly") + && event.getScreen() instanceof MachineScreen screen) + ContainerCellClientSmoke.rendered(screen, event.getGuiGraphics()); + if (Boolean.getBoolean("mmdlib.tooltipSmokeOnly") + && event.getScreen() instanceof MachineScreen screen) { + ContributedHintClientSmoke.rendered(screen, event.getGuiGraphics()); + TooltipClientSmoke.render(screen, event.getGuiGraphics()); + } + if (Boolean.getBoolean("mmdlib.reservationSmokeOnly") + && event.getScreen() instanceof MachineScreen) + SlotPolicyClientSmoke.render(event.getGuiGraphics()); + } + @SubscribeEvent public static void tick(ClientTickEvent.Post event) { if (!Boolean.getBoolean("mmdlib.companionSmoke") || finished) return; @@ -68,6 +96,27 @@ public static void tick(ClientTickEvent.Post event) { if (System.currentTimeMillis() > deadline) throw new AssertionError("Timed out on " + mc.screen); if (!started && mc.screen instanceof TitleScreen) { + if (Boolean.getBoolean("mmdlib.inheritanceSmokeOnly")) + ElementInheritanceClientSmoke.install(); + if (Boolean.getBoolean("mmdlib.statsSmokeOnly")) StatsExtensionClientSmoke.install(); + if (Boolean.getBoolean("mmdlib.popoutSmokeOnly")) PopoutClientSmoke.install(); + if (Boolean.getBoolean("mmdlib.tooltipSmokeOnly")) TooltipClientSmoke.install(); + if (Boolean.getBoolean("mmdlib.valueTypeSmokeOnly")) ValueTypeClientSmoke.install(); + if (Boolean.getBoolean("mmdlib.filterSmokeOnly")) { + FilterElementClientSmoke.install(); + FragmentNavigationClientSmoke.install(); + stage = 4; + } + if (Boolean.getBoolean("mmdlib.drawerSmokeOnly")) { + DrawerExtensionClientSmoke.install(); + ContainerCellClientSmoke.install(); + } + if (Boolean.getBoolean("mmdlib.rendererSmokeOnly")) ElementRendererClientSmoke.install(); + if (Boolean.getBoolean("mmdlib.behaviorSmokeOnly")) ElementBehaviorClientSmoke.install(); + if (Boolean.getBoolean("mmdlib.groupPresentationSmokeOnly")) { + GroupPresentationClientSmoke.install(); + stage = 4; + } deadline = System.currentTimeMillis() + BUDGET_MILLIS; if (!net.neoforged.fml.ModList.get().isLoaded("jade")) throw new AssertionError("Jade must be installed in companion runs"); @@ -143,7 +192,10 @@ public static void tick(ClientTickEvent.Post event) { if (!DrawerMemoryClientSmoke.run()) return; finished = true; MMDLib.LOGGER.info( - "COMPANION_CLIENT_PASS: all five menus rendered and received their values"); + Boolean.getBoolean("mmdlib.drawerSmokeOnly") + ? "COMPANION_CLIENT_PASS: focused drawers, equipment gestures and cross-machine" + + " restoration" + : "COMPANION_CLIENT_PASS: all five menus rendered and received their values"); mc.stop(); return; } @@ -242,6 +294,137 @@ public static void frame(RenderFrameEvent.Post event) { var expected = CompanionMod.MACHINES.get(stage).type.get(); if (menu.blockEntity.getType() != expected || ++frames < 25) return; try { + if (Boolean.getBoolean("mmdlib.statsSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (!StatsExtensionClientSmoke.run(screen)) return; + finished = true; + MMDLib.LOGGER.info( + "COMPANION_CLIENT_PASS: custom statistics and timescales, page synchronization," + + " cycling, reorder/removal and empty recovery"); + mc.stop(); + return; + } + if (Boolean.getBoolean("mmdlib.popoutSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (!PopoutClientSmoke.run(screen)) return; + finished = true; + MMDLib.LOGGER.info( + "COMPANION_CLIENT_PASS: element-owned popout colour/content, inherited rendering," + + " wrapping and scrolling"); + mc.stop(); + return; + } + if (Boolean.getBoolean("mmdlib.inheritanceSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (!ElementInheritanceClientSmoke.run(screen)) return; + finished = true; + MMDLib.LOGGER.info( + "COMPANION_CLIENT_PASS: inherited element rendering, display/fill overrides and tank" + + " bucket transfers"); + mc.stop(); + return; + } + if (Boolean.getBoolean("mmdlib.tooltipSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (!TooltipClientSmoke.run(screen)) return; + finished = true; + MMDLib.LOGGER.info( + "COMPANION_CLIENT_PASS: element/parent tooltip composition, server rows, native item" + + " merging and reservations"); + mc.stop(); + return; + } + if (Boolean.getBoolean("mmdlib.valueTypeSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (!ValueTypeClientSmoke.run(screen)) return; + finished = true; + MMDLib.LOGGER.info( + "COMPANION_CLIENT_PASS: custom getter types, mutable snapshots, deltas, null recovery" + + " and action correction"); + mc.stop(); + return; + } + if (Boolean.getBoolean("mmdlib.filterSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (!FilterElementClientSmoke.run() + || !FragmentNavigationClientSmoke.run() + || !AutomationClientSmoke.run(screen)) return; + finished = true; + MMDLib.LOGGER.info( + "COMPANION_CLIENT_PASS: focused filter elements, mod registration, focus/capture," + + " widgets and server editor transitions"); + mc.stop(); + return; + } + if (Boolean.getBoolean("mmdlib.reservationSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (!ReservationClientSmoke.run(screen) || !SlotPolicyClientSmoke.run()) return; + finished = true; + MMDLib.LOGGER.info( + "COMPANION_CLIENT_PASS: focused typed reservation sync, client rules, overlays and" + + " clearing"); + mc.stop(); + return; + } + if (Boolean.getBoolean("mmdlib.drawerSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (stage == 0) { + if (!DrawerExtensionClientSmoke.run(screen) + || !CuriosClientSmoke.run(screen) + || !BackpackClientSmoke.run(screen) + || !RedstoneClientSmoke.run(screen) + || !StatsClientSmoke.run(screen)) return; + ContainerCellClientSmoke.verify(); + stage = 4; + } else { + if (!AutomationClientSmoke.run(screen)) return; + stage = CompanionMod.MACHINES.size(); + } + opening = false; + menuRequested = false; + return; + } + if (Boolean.getBoolean("mmdlib.behaviorSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (!ElementBehaviorClientSmoke.run(screen)) return; + finished = true; + MMDLib.LOGGER.info( + "COMPANION_CLIENT_PASS: focused element behaviors, typed actions, focus, capture and" + + " navigation"); + mc.stop(); + return; + } + if (Boolean.getBoolean("mmdlib.groupPresentationSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (!GroupPresentationClientSmoke.run(screen)) return; + finished = true; + MMDLib.LOGGER.info( + "COMPANION_CLIENT_PASS: focused group labels, mod highlights, coloured groups and" + + " fallback outlines"); + mc.stop(); + return; + } + if (Boolean.getBoolean("mmdlib.rendererSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (!ElementRendererClientSmoke.run(screen)) return; + finished = true; + MMDLib.LOGGER.info( + "COMPANION_CLIENT_PASS: focused registered element renderers, mod overrides, popout and" + + " context passes"); + mc.stop(); + return; + } if (Boolean.getBoolean("mmdlib.processorButtonSmokeOnly")) { waitingTicks = 0; frames = 0; @@ -272,13 +455,14 @@ public static void frame(RenderFrameEvent.Post event) { waitingTicks = 0; return; } - if (menu.automation != null && menu.bound.screen().playerInventory()) { - var row = screen.layout.inventoryColors().orElseThrow(); - var inventory = screen.layout.inventory().orElseThrow(); + if (menu.components().get(BuiltinMenuComponents.AUTOMATION) != null + && menu.bound.screen().playerInventory()) { + var row = screen.layout.region(PlayerInventoryElement.TOOLBAR).orElseThrow(); + var inventory = screen.layout.region(PlayerInventoryElement.INVENTORY).orElseThrow(); if (row.x() != inventory.x()) throw new AssertionError("Colours align with inventory left edge"); - var firstStorage = zone.moddev.mc.lib.gui.layout.ScreenLayout.playerSlot(inventory, 9); - var lastStorage = zone.moddev.mc.lib.gui.layout.ScreenLayout.playerSlot(inventory, 17); + var firstStorage = PlayerInventoryElement.slot(inventory, 9); + var lastStorage = PlayerInventoryElement.slot(inventory, 17); if (row.y() != inventory.y() || row.bottom() + 4 != firstStorage.y()) throw new AssertionError("Toolbar shares the helmet row directly above inventory slots"); for (String id : new String[] {"redstone", "toggle_0", "toggle_1"}) { @@ -294,7 +478,7 @@ public static void frame(RenderFrameEvent.Post event) { } } var energy = menu.bound.screen().find("energy").orElseThrow(); - if (!(energy instanceof zone.moddev.mc.lib.gui.model.Element.EnergyGauge)) + if (!(energy instanceof zone.moddev.mc.lib.gui.model.elements.EnergyGaugeElement)) throw new AssertionError("Every companion screen must use the reusable battery gauge"); if (screen.layout.bounds(energy).right() != screen.layout.panel().width() - 7) throw new AssertionError("Energy must sit against the right panel margin"); @@ -386,7 +570,7 @@ public static void frame(RenderFrameEvent.Post event) { var expectedItem = fluidStage == 2 ? Items.BUCKET : Items.LAVA_BUCKET; if (!menu.getCarried().is(expectedItem)) return; var bounds = screen.bounds(tank); - if (!screen.mouseClicked(bounds.x() + 9, bounds.y() + 24, 0)) + if (!screen.mouseClicked(bounds.x() + 9, bounds.y() + 24, fluidStage == 2 ? 1 : 0)) throw new AssertionError("The tank gauge did not handle the container click"); fluidStage++; frames = 0; @@ -425,7 +609,7 @@ public static void frame(RenderFrameEvent.Post event) { } if (stage < 3) { var info = - (zone.moddev.mc.lib.gui.model.Element.Popout) + (zone.moddev.mc.lib.gui.model.elements.PopoutElement) menu.bound.screen().find("info").orElseThrow(); var tab = screen.bounds(info); if (!info.iconOrDefault().toString().equals("mmdlib:icon_info")) @@ -436,8 +620,14 @@ public static void frame(RenderFrameEvent.Post event) { var exclusions = screen.exclusions(); if (exclusions.size() != 4 - + (menu.redstone == null ? 0 : 1) - + (menu.curiosDrawer.state().icons().isEmpty() ? 0 : 1) + + (menu.components().get(BuiltinMenuComponents.REDSTONE) == null ? 0 : 1) + + (menu.components() + .get(BuiltinMenuComponents.CURIOS) + .state() + .icons() + .isEmpty() + ? 0 + : 1) || exclusions.get(1).right() != tab.x()) throw new AssertionError("The drawer must extend left while retaining its icon tab"); if (exclusions.get(0).bottom() != exclusions.get(1).bottom() @@ -475,17 +665,20 @@ public static void frame(RenderFrameEvent.Post event) { throw new AssertionError("Drawer swallowed mouse button " + button); if (screen.exclusions().size() != 4 - + (menu.redstone == null ? 0 : 1) - + (menu.curiosDrawer.state().icons().isEmpty() ? 0 : 1)) + + (menu.components().get(BuiltinMenuComponents.REDSTONE) == null ? 0 : 1) + + (menu.components().get(BuiltinMenuComponents.CURIOS).state().icons().isEmpty() + ? 0 + : 1)) throw new AssertionError("Extra mouse button changed the drawer state"); } screen.mouseScrolled(tab.x() - 10, tab.y() + 10, 0, -1); screen.mouseClicked(tab.x() + 10, tab.y() + 10, 0); if (screen.exclusions().size() != 3 - + (menu.redstone == null ? 0 : 1) - + (menu.curiosDrawer.state().icons().isEmpty() ? 0 : 1)) - throw new AssertionError("The icon tab did not close its drawer"); + + (menu.components().get(BuiltinMenuComponents.REDSTONE) == null ? 0 : 1) + + (menu.components().get(BuiltinMenuComponents.CURIOS).state().icons().isEmpty() + ? 0 + : 1)) throw new AssertionError("The icon tab did not close its drawer"); drawerStage = 0; if (stage == 0) { fluidStage = 1; diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java index 60dc779d..c572310d 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java @@ -30,6 +30,7 @@ import zone.moddev.mc.lib.gui.Screens; import zone.moddev.mc.lib.gui.runtime.MachineMenu; import zone.moddev.mc.lib.gui.runtime.ScreenNetwork; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; import zone.moddev.mc.lib.machine.feature.RecipeProcessor.Status; /** Exercises the consumer's registration and wiring with the real recipe files and block types. */ @@ -55,8 +56,18 @@ public static void poweredBasePreservesSavesAndSeparatesFeatureUpdates(GameTestH (zone.moddev.mc.lib.machine.neoforge.BasePoweredMachine) level.getBlockEntity(pos); machine.energy.setStored(7_321); require( - machine.getEnergy() == 7_321 && machine.getEnergyCapacity() == 100_000, - "Shared getters expose the owned buffer"); + machine.energy.stored() == 7_321 && machine.energy.capacity() == 100_000, + "The owned buffer preserves its full values"); + var bound = Screens.get(definition.type.getId()).orElseThrow(); + var gauge = bound.screen().find("energy").orElseThrow(); + var energySnapshot = gauge.snapshot(machine).orElseThrow(); + require( + machine.screenEnergy() == machine.energy + && energySnapshot.value().equals(7_321L) + && energySnapshot.maximum().equals(100_000L) + && !bound.values().containsKey(gauge) + && !bound.maxima().containsKey(gauge), + "Every powered machine uses the component-owned energy snapshot"); require( machine.screenFeatures() == machine.features && machine.screenStats().getFirst() == machine.energy, @@ -71,7 +82,7 @@ public static void poweredBasePreservesSavesAndSeparatesFeatureUpdates(GameTestH var copy = (zone.moddev.mc.lib.machine.neoforge.BasePoweredMachine) BlockEntity.loadStatic(pos, machine.getBlockState(), saved, registries); - require(copy.getEnergy() == 7_321, "Power survives a full save/load for every machine"); + require(copy.energy.stored() == 7_321, "Power survives a full save/load for every machine"); if (copy instanceof ProcessorEntity processor) { require( processor.getPaused() && processor.getReservePercent() == 25, @@ -96,7 +107,7 @@ public static void poweredBasePreservesSavesAndSeparatesFeatureUpdates(GameTestH java.util.Objects.equals(beforeUpdate.get(key), afterUpdate.get(key)), "Feature update preserves " + key); copy.clearContent(); - require(copy.getEnergy() == 0, "Shared clear resets owned power"); + require(copy.energy.stored() == 0, "Shared clear resets owned power"); machine.clearContent(); level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); } @@ -113,11 +124,9 @@ public static void keylessProcessorButtonSamplesAndTogglesTheFirstProcessor(Game var menu = new MachineMenu(43, player.getInventory(), machine); player.containerMenu = menu; var button = - (zone.moddev.mc.lib.gui.model.Element.Button) + (zone.moddev.mc.lib.gui.model.elements.ProcessorButtonElement) menu.bound.screen().find("toggle_0").orElseThrow(); - require( - button.processorControl() && button.processorKey().isEmpty(), - "Inherited control uses no machine-specific key"); + require(button.processorKey().isEmpty(), "Inherited control uses no machine-specific key"); // Mock players have no negotiated GUI channel; inspect the actual server snapshot directly. var read = Class.forName("zone.moddev.mc.lib.gui.runtime.ScreenSnapshots") @@ -171,13 +180,17 @@ public void restore(Integer value) {} "Explicit keys never fall back to the first processor"); var emptyHost = new zone.moddev.mc.lib.gui.runtime.ScreenHost() { - public zone.moddev.mc.lib.api.capabilities.ItemStorage screenItems() { + public net.neoforged.neoforge.items.IItemHandlerModifiable screenItems() { return machine.items; } public zone.moddev.mc.lib.machine.feature.FeatureHost screenFeatures() { return new zone.moddev.mc.lib.machine.feature.FeatureHost(() -> {}); } + + public zone.moddev.mc.lib.api.capabilities.EnergyBuffer screenEnergy() { + return machine.energy; + } }; try { zone.moddev.mc.lib.gui.runtime.ScreenInventoryValidation.validate( @@ -252,7 +265,7 @@ public static void genericProcessorButtonsRespectRedstoneAndLaneKeys(GameTestHel var menu = new MachineMenu(42, player.getInventory(), machine); player.containerMenu = menu; var button = - (zone.moddev.mc.lib.gui.model.Element.Button) + (zone.moddev.mc.lib.gui.model.elements.ProcessorButtonElement) menu.bound.screen().find("toggle_1").orElseThrow(); require( button.processorKey().orElseThrow().equals("lane_1") && !button.common().clicks().any(), @@ -268,27 +281,33 @@ public static void genericProcessorButtonsRespectRedstoneAndLaneKeys(GameTestHel machine.lanes.get(1).paused() && !machine.lanes.getFirst().paused(), "Only the keyed lane pauses"); require( - !menu.redstone.watching() && !menu.redstone.conflicts(true), + !menu.components().get(BuiltinMenuComponents.REDSTONE).watching() + && !menu.components().get(BuiltinMenuComponents.REDSTONE).conflicts(true), "No input watching means no conflict indicator"); machine.redstone.mode(0, zone.moddev.mc.lib.api.redstone.RedstoneControl.Mode.EMIT); - require(!menu.redstone.watching(), "Output-only redstone does not watch inputs"); + require( + !menu.components().get(BuiltinMenuComponents.REDSTONE).watching(), + "Output-only redstone does not watch inputs"); machine.redstone.mode(0, zone.moddev.mc.lib.api.redstone.RedstoneControl.Mode.READ); machine.redstone.range(0, 0, 15); machine.redstone.readInputs(); require( - menu.redstone.watching() - && menu.redstone.paused() - && !menu.redstone.conflicts(true) - && menu.redstone.conflicts(false), + menu.components().get(BuiltinMenuComponents.REDSTONE).watching() + && menu.components().get(BuiltinMenuComponents.REDSTONE).paused() + && !menu.components().get(BuiltinMenuComponents.REDSTONE).conflicts(true) + && menu.components().get(BuiltinMenuComponents.REDSTONE).conflicts(false), "A redstone hold conflicts only with an enabled manual state"); menu.action(player, action); require( - !machine.lanes.get(1).paused() && menu.redstone.paused(), + !machine.lanes.get(1).paused() + && menu.components().get(BuiltinMenuComponents.REDSTONE).paused(), "Resume changes the manual state while redstone still holds processing"); machine.redstone.outside(0, true); machine.redstone.readInputs(); require( - !menu.redstone.paused() && menu.redstone.conflicts(true) && !menu.redstone.conflicts(false), + !menu.components().get(BuiltinMenuComponents.REDSTONE).paused() + && menu.components().get(BuiltinMenuComponents.REDSTONE).conflicts(true) + && !menu.components().get(BuiltinMenuComponents.REDSTONE).conflicts(false), "Redstone allowing work conflicts with manual pause"); int[] changes = {0}, syncs = {0}; @@ -308,15 +327,8 @@ public static void genericProcessorButtonsRespectRedstoneAndLaneKeys(GameTestHel machine.lanes.get(1).setPaused(false); require(!machine.lanes.get(1).paused(), "The public pause setter resumes too"); var invalid = - new zone.moddev.mc.lib.gui.model.Element.Button( - button.common(), - button.text(), - button.icon(), - button.shows(), - button.icons(), - button.valueLabels(), - button.flat(), - java.util.Optional.of("missing")); + new zone.moddev.mc.lib.gui.model.elements.ProcessorButtonElement( + button.common(), java.util.Optional.of("missing"), button.text(), button.flat()); var original = menu.bound.screen(); var invalidScreen = new zone.moddev.mc.lib.gui.model.Screen( @@ -435,11 +447,72 @@ public static void statisticsProvidersDescribeCurrentRecipes(GameTestHelper help helper.succeed(); } + @GameTest(template = "empty") + public static void elementStateIsPerMenuAndRejectionsRestorePredictedViews( + GameTestHelper helper) { + var machine = place(helper, CompanionMod.CRUSHER); + var player = helper.makeMockServerPlayerInLevel(); + var pos = machine.getBlockPos(); + player.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5); + var server = new MachineMenu(55, player.getInventory(), machine); + var client = new MachineMenu(55, player.getInventory(), machine, server.widgetSession()); + var other = new MachineMenu(56, player.getInventory(), machine); + player.containerMenu = server; + var button = client.bound.screen().find("open_settings").orElseThrow(); + var action = zone.moddev.mc.lib.gui.model.elements.ButtonElement.NAVIGATE; + try { + var invalid = + client + .prepareElementAction( + button, action, new zone.moddev.mc.lib.gui.ElementActions.Click(0, 0, 1)) + .orElseThrow(); + client.navigate("panel", "settings"); + var rejected = server.elementAction(player, invalid); + require( + !rejected.accepted() && !rejected.states().isEmpty(), + "Rejection includes authoritative element state"); + client.receiveWidgetResult(rejected); + require( + client.views().get("panel").equals("machine"), + "Rejected prediction is corrected before the callback"); + client.takeElementResult(button); + var valid = + client + .prepareElementAction( + button, action, new zone.moddev.mc.lib.gui.ElementActions.Click(0, 0, 0)) + .orElseThrow(); + var accepted = server.elementAction(player, valid); + require(accepted.accepted(), "Valid navigation accepted"); + client.receiveWidgetResult(accepted); + require( + client.views().get("panel").equals("settings") + && other.views().get("panel").equals("machine"), + "Selected views are synchronized per menu, never shared across viewers"); + client.takeElementResult(button); + var panel = client.bound.screen().find("panel").orElseThrow(); + client + .prepareElementAction( + panel, + zone.moddev.mc.lib.gui.model.containers.ContextElement.BACK, + new zone.moddev.mc.lib.gui.ElementActions.Click(0, 0, 0)) + .orElseThrow(); + client.navigate("panel", "machine"); + for (int i = 0; i < 100; i++) client.tickWidgetActions(); + require( + client.views().get("panel").equals("settings"), + "Timeout restores the last confirmed view"); + } finally { + player.containerMenu = player.inventoryMenu; + helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + } + helper.succeed(); + } + private static ScreenNetwork.Widget widgetPacket( MachineMenu menu, String element, int request, - zone.moddev.mc.lib.gui.WidgetAction action, + zone.moddev.mc.lib.gui.ElementAction action, T value) { return new ScreenNetwork.Widget( menu.containerId, @@ -450,6 +523,70 @@ private static ScreenNetwork.Widget widgetPacket( action.encode(menu.blockEntity.getLevel().registryAccess(), value)); } + @GameTest(template = "empty") + public static void builtinElementsUseAuthorizedTypedActions(GameTestHelper helper) { + var machine = place(helper, CompanionMod.CRUSHER); + var player = helper.makeMockServerPlayerInLevel(); + var pos = machine.getBlockPos(); + player.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5); + var menu = new MachineMenu(53, player.getInventory(), machine); + player.containerMenu = menu; + var navigate = zone.moddev.mc.lib.gui.model.elements.ButtonElement.NAVIGATE; + var back = zone.moddev.mc.lib.gui.model.containers.ContextElement.BACK; + var click = new zone.moddev.mc.lib.gui.ElementActions.Click(0, 0, 0); + try { + require( + !menu.elementAction( + player, + widgetPacket( + menu, + "open_settings", + 1, + navigate, + new zone.moddev.mc.lib.gui.ElementActions.Click(0, 0, 1))) + .accepted(), + "Navigation validates its own payload"); + var packet = widgetPacket(menu, "open_settings", 2, navigate, click); + require( + menu.elementAction(player, packet).accepted() + && menu.views().get("panel").equals("settings"), + "Typed navigation selects its target view"); + require(menu.elementAction(player, packet) == null, "Built-in actions cannot be replayed"); + require( + !menu.elementAction(player, widgetPacket(menu, "open_settings", 3, navigate, click)) + .accepted(), + "A hidden navigation button cannot act"); + boolean paused = machine.getPaused(); + require( + !menu.elementAction( + player, + widgetPacket( + menu, "progress", 4, zone.moddev.mc.lib.gui.ElementActions.CLICK, click)) + .accepted() + && machine.getPaused() == paused, + "A hidden common callback cannot mutate the host"); + require( + menu.elementAction(player, widgetPacket(menu, "panel", 5, back, click)).accepted() + && menu.views().get("panel").equals("machine"), + "Context owns its typed back action"); + require( + menu.elementAction( + player, + widgetPacket( + menu, "progress", 6, zone.moddev.mc.lib.gui.ElementActions.CLICK, click)) + .accepted() + && machine.getPaused() != paused, + "Generic click dispatch preserves common callbacks"); + require( + !menu.elementAction(player, widgetPacket(menu, "progress", 7, navigate, click)) + .accepted(), + "A visible element cannot invoke another element type's action"); + } finally { + player.containerMenu = player.inventoryMenu; + } + helper.succeed(); + } + @GameTest(template = "empty") public static void typedWidgetActionsAuthorizeBeforeMutation(GameTestHelper helper) { var machine = place(helper, CompanionMod.CRUSHER); @@ -614,7 +751,7 @@ public static void structuredWidgetActionsBoundValuesRateAndPendingEdits(GameTes pos, machine.getBlockState(), saved, helper.getLevel().registryAccess()); require(copy.getReservePercent() == 25, "Reserve persists"); var name = - (zone.moddev.mc.lib.gui.model.Element.Custom) + (zone.moddev.mc.lib.gui.model.elements.CustomElement) menu.bound.screen().find("name_editor").orElseThrow(); require( menu.prepareWidgetAction( @@ -671,7 +808,7 @@ public static void widgetRejectionUnlocksImmediatelyAndAcknowledgmentsTrackDelta server.navigate("panel", "settings"); client.navigate("panel", "settings"); var name = - (zone.moddev.mc.lib.gui.model.Element.Custom) + (zone.moddev.mc.lib.gui.model.elements.CustomElement) server.bound.screen().find("name_editor").orElseThrow(); int key = server.index(name) * 2; var rename = zone.moddev.mc.companion.CompanionWidgets.RENAME; @@ -709,6 +846,91 @@ public static void widgetRejectionUnlocksImmediatelyAndAcknowledgmentsTrackDelta helper.succeed(); } + @GameTest(template = "empty") + public static void elementActionSessionTimeoutRetryAndClose(GameTestHelper helper) { + var machine = place(helper, CompanionMod.CRUSHER); + var player = helper.makeMockServerPlayerInLevel(); + var pos = machine.getBlockPos(); + player.setPos(pos.getX() + .5, pos.getY(), pos.getZ() + .5); + var server = new MachineMenu(55, player.getInventory(), machine); + var client = new MachineMenu(55, player.getInventory(), machine, server.sessionId()); + player.containerMenu = server; + server.navigate("panel", "settings"); + client.navigate("panel", "settings"); + // Establish confirmed navigation just as the initial server snapshot does in a real menu. + client.apply(new java.util.HashMap(server.elementStates().snapshot())); + var element = server.bound.screen().find("name_editor").orElseThrow(); + var rename = zone.moddev.mc.companion.CompanionWidgets.RENAME; + var original = machine.getMachineName(); + int key = server.index(element) * 2; + server.apply(java.util.Map.of(key, original)); + client.apply(java.util.Map.of(key, original)); + try { + var first = client.prepareElementAction(element, rename, "First").orElseThrow(); + var late = server.elementAction(player, first); + require(late.accepted(), "Server accepted the delayed action"); + for (int i = 0; i < 99; i++) client.tickElementActions(); + require( + client.elementPending(element), "Request remains pending before the timeout boundary"); + client.tickElementActions(); + require(!client.elementPending(element), "Timeout clears pending state"); + require(client.value(element, false).equals(original), "Timeout uses confirmed values"); + require( + client.prepareElementAction(element, rename, "Retry").isEmpty(), + "Timeout result must be consumed before retry"); + require(!client.takeElementResult(element).accepted(), "Timeout reports rejection"); + require(client.visible(element), "Timeout preserves the confirmed Settings view"); + var retry = client.prepareElementAction(element, rename, "Retry").orElseThrow(); + require(retry.request() > first.request(), "Retry never reuses request identity"); + client.receiveElementResult(late); + require( + client.elementPending(element) && client.value(element, false).equals(original), + "Late acknowledgment cannot settle or overwrite a newer request"); + client.receiveElementResult(server.elementAction(player, retry)); + require( + !client.elementPending(element) && client.takeElementResult(element).accepted(), + "Matching retry acknowledgment settles immediately"); + require( + ((net.minecraft.network.chat.Component) client.value(element, false)) + .getString() + .equals("Retry"), + "Retry carries fresh authoritative values"); + var pending = client.prepareElementAction(element, rename, "After close").orElseThrow(); + var afterClose = server.elementAction(player, pending); + client.removed(player); + client.receiveElementResult(afterClose); + client.tickElementActions(); + require( + !client.elementPending(element) && client.takeElementResult(element) == null, + "Closing clears pending requests and results and ignores later acknowledgments"); + require( + client.prepareElementAction(element, rename, "Closed").isEmpty(), + "Closed sessions refuse new requests"); + require( + ((net.minecraft.network.chat.Component) client.value(element, false)) + .getString() + .equals("Retry"), + "Closing prevents late authoritative result application"); + server.removed(player); + require( + server.elementAction( + player, + new ScreenNetwork.Widget( + server.containerId, + server.sessionId(), + server.index(element), + pending.request() + 1, + rename.id(), + rename.encode(player.registryAccess(), "Closed server"))) + == null, + "Closed server sessions refuse dispatch even while the owner still references the menu"); + } finally { + player.containerMenu = player.inventoryMenu; + helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + } + helper.succeed(); + } + @GameTest(template = "empty") public static void clearedProcessorsRestartWithoutManualResume(GameTestHelper helper) { for (var type : @@ -782,7 +1004,7 @@ public static void customWidgetUsesValidatedMenuActions(GameTestHelper helper) { try { var dial = menu.bound.screen().find("progress").orElseThrow(); require( - dial instanceof zone.moddev.mc.lib.gui.model.Element.Custom, + dial instanceof zone.moddev.mc.lib.gui.model.elements.CustomElement, "The consuming mod's widget loads on a dedicated server"); int index = menu.index(dial); menu.action(player, new ScreenNetwork.Action(47, index, 0, 0, 0, 1)); @@ -995,8 +1217,8 @@ public static void creativePowerSidesGatePushAndPullAndPersist(GameTestHelper he var menu = new MachineMenu(46, helper.makeMockServerPlayerInLevel().getInventory(), power); require( menu.slots.isEmpty() - && menu.automation.slotCount() == 0 - && !menu.automation.addonsEnabled(), + && menu.components().get(BuiltinMenuComponents.AUTOMATION).slotCount() == 0 + && !menu.components().get(BuiltinMenuComponents.AUTOMATION).addonsEnabled(), "Generator side configuration exposes no inventory slots"); require(menu.bound.screen().find("info").isPresent(), "Generator has an Info panel"); require(group.cycle(top, output, false), "Output side can be disabled"); @@ -1093,7 +1315,8 @@ public static void crusherSplitsOutputAcrossNearlyFullSlots(GameTestHelper helpe var pos = machine.getBlockPos(); require(machine.items.getSlots() == 3, "Crusher has one input and two outputs"); require( - machine.automation.group(1).members().equals(java.util.List.of(1, 2)), + machine.automation.group(1).members().equals(java.util.List.of(0, 1)) + && machine.items.inventory("input") != machine.items.inventory("output"), "Both outputs belong to one configurable inventory"); var player = helper.makeMockServerPlayerInLevel(); player.setPos(pos.getX() + .5, pos.getY() + .5, pos.getZ() + .5); @@ -1316,7 +1539,7 @@ public static void crusherTankRejectsMixingAndPersists(GameTestHelper helper) { require( handler.fill(new FluidStack(Fluids.WATER, 5_000), FluidAction.SIMULATE) == 4_000, "Simulated fill respects capacity"); - require(machine.getTankFluid().isEmpty(), "Simulation leaves the tank empty"); + require(machine.fluids.getFluidInTank(0).isEmpty(), "Simulation leaves the tank empty"); } var handler = level.getCapability(Capabilities.FluidHandler.BLOCK, pos, Direction.UP); require( @@ -1344,15 +1567,16 @@ public static void crusherTankRejectsMixingAndPersists(GameTestHelper helper) { (ProcessorEntity) BlockEntity.loadStatic(pos, machine.getBlockState(), saved, level.registryAccess()); require( - copy.getTankFluid().getFluid() == Fluids.LAVA && copy.getTankFluid().getAmount() == 2_000, + copy.fluids.getFluidInTank(0).getFluid() == Fluids.LAVA + && copy.fluids.getFluidInTank(0).getAmount() == 2_000, "Fluid type and amount survive save/load"); saved.remove("fluids"); var legacy = (ProcessorEntity) BlockEntity.loadStatic(pos, machine.getBlockState(), saved, level.registryAccess()); - require(legacy.getTankFluid().isEmpty(), "Older saves start with an empty tank"); + require(legacy.fluids.getFluidInTank(0).isEmpty(), "Older saves start with an empty tank"); machine.clearContent(); - require(machine.getTankFluid().isEmpty(), "Clearing the machine clears its fluid"); + require(machine.fluids.getFluidInTank(0).isEmpty(), "Clearing the machine clears its fluid"); for (var other : java.util.List.of(CompanionMod.TRANSMUTER, CompanionMod.DUAL_CRUSHER)) { place(helper, other); require( @@ -1378,7 +1602,8 @@ public static void crusherBucketsFillDrainAndRefuseMixing(GameTestHelper helper) == ItemInteractionResult.CONSUME, "Water bucket handled by the block"); require( - player.getMainHandItem().is(Items.BUCKET) && machine.getTankFluid().getAmount() == 1_000, + player.getMainHandItem().is(Items.BUCKET) + && machine.fluids.getFluidInTank(0).getAmount() == 1_000, "Filling consumes the water and returns an empty bucket"); player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.LAVA_BUCKET)); require( @@ -1387,24 +1612,26 @@ public static void crusherBucketsFillDrainAndRefuseMixing(GameTestHelper helper) "Mixed bucket stops before vanilla can place lava"); require( player.getMainHandItem().is(Items.LAVA_BUCKET) - && machine.getTankFluid().getFluid() == Fluids.WATER - && machine.getTankFluid().getAmount() == 1_000, + && machine.fluids.getFluidInTank(0).getFluid() == Fluids.WATER + && machine.fluids.getFluidInTank(0).getAmount() == 1_000, "Rejected transfer leaves the bucket and tank unchanged"); player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.BUCKET)); state.useItemOn(player.getMainHandItem(), level, player, InteractionHand.MAIN_HAND, hit); require( - player.getMainHandItem().is(Items.WATER_BUCKET) && machine.getTankFluid().isEmpty(), + player.getMainHandItem().is(Items.WATER_BUCKET) + && machine.fluids.getFluidInTank(0).isEmpty(), "An empty bucket drains water"); player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.LAVA_BUCKET)); state.useItemOn(player.getMainHandItem(), level, player, InteractionHand.MAIN_HAND, hit); require( player.getMainHandItem().is(Items.BUCKET) - && machine.getTankFluid().getFluid() == Fluids.LAVA, + && machine.fluids.getFluidInTank(0).getFluid() == Fluids.LAVA, "Lava can be put in after draining water"); player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.BUCKET)); state.useItemOn(player.getMainHandItem(), level, player, InteractionHand.MAIN_HAND, hit); require( - player.getMainHandItem().is(Items.LAVA_BUCKET) && machine.getTankFluid().isEmpty(), + player.getMainHandItem().is(Items.LAVA_BUCKET) + && machine.fluids.getFluidInTank(0).isEmpty(), "An empty bucket drains lava"); level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); helper.succeed(); @@ -1431,14 +1658,27 @@ public void broadcastChanges() {} menu.setCarried(new ItemStack(Items.WATER_BUCKET)); menu.clickMenuButton(player, left); require( - menu.getCarried().is(Items.BUCKET) && machine.getTankFluid().getAmount() == 1_000, + menu.getCarried().is(Items.BUCKET) && machine.fluids.getFluidInTank(0).getAmount() == 1_000, "Clicking the gauge empties a cursor bucket and returns the empty container"); + var component = menu.bound.screen().find("tank").orElseThrow(); + var snapshot = component.snapshot(machine).orElseThrow(); + ((FluidStack) snapshot.value()).shrink(500); + require( + machine.fluids.getFluidInTank(0).getAmount() == 1000 + && ((Number) snapshot.maximum()).intValue() == 4000, + "Tank component supplies independent snapshots without machine getters"); + var tooltip = component.tooltip(machine, new ItemStack(Items.WATER_BUCKET)); + require( + tooltip.size() == 4 + && tooltip.getLast().getStyle().getColor().getValue() + == net.minecraft.ChatFormatting.BLUE.getColor(), + "Handwritten tank uses the component tooltip without a machine callback"); menu.setCarried(new ItemStack(Items.LAVA_BUCKET)); menu.clickMenuButton(player, left); require( menu.getCarried().is(Items.LAVA_BUCKET) - && machine.getTankFluid().getFluid() == Fluids.WATER - && machine.getTankFluid().getAmount() == 1_000, + && machine.fluids.getFluidInTank(0).getFluid() == Fluids.WATER + && machine.fluids.getFluidInTank(0).getAmount() == 1_000, "Mixed cursor fluid leaves both sides untouched"); menu.setCarried(new ItemStack(Items.BUCKET, 2)); menu.clickMenuButton(player, right); @@ -1447,7 +1687,7 @@ public void broadcastChanges() {} "A stacked cursor processes exactly one container"); require( player.getInventory().countItem(Items.WATER_BUCKET) == 1 - && machine.getTankFluid().isEmpty(), + && machine.fluids.getFluidInTank(0).isEmpty(), "The filled bucket goes to inventory without duplicating water"); var container = new ItemStack(Items.GLASS_BOTTLE); @@ -1458,7 +1698,7 @@ public void broadcastChanges() {} menu.setCarried(container); menu.clickMenuButton(player, left); require( - machine.getTankFluid().getAmount() == 4_000 + machine.fluids.getFluidInTank(0).getAmount() == 4_000 && FluidUtil.getFluidHandler(menu.getCarried()) .orElseThrow() .getFluidInTank(0) @@ -1468,7 +1708,7 @@ public void broadcastChanges() {} machine.fluids.drain(Integer.MAX_VALUE, FluidAction.EXECUTE); menu.clickMenuButton(player, left); require( - machine.getTankFluid().getAmount() == 1_000 + machine.fluids.getFluidInTank(0).getAmount() == 1_000 && FluidUtil.getFluidHandler(menu.getCarried()) .orElseThrow() .getFluidInTank(0) @@ -1476,7 +1716,7 @@ public void broadcastChanges() {} "The partial container can be completely emptied"); menu.clickMenuButton(player, right); require( - machine.getTankFluid().isEmpty() + machine.fluids.getFluidInTank(0).isEmpty() && FluidUtil.getFluidHandler(menu.getCarried()) .orElseThrow() .getFluidInTank(0) @@ -1486,13 +1726,13 @@ public void broadcastChanges() {} menu.setCarried(new ItemStack(Items.IRON_INGOT)); menu.clickMenuButton(player, left); require( - menu.getCarried().is(Items.IRON_INGOT) && machine.getTankFluid().isEmpty(), + menu.getCarried().is(Items.IRON_INGOT) && machine.fluids.getFluidInTank(0).isEmpty(), "Non-container items are unchanged"); menu.setCarried(new ItemStack(Items.WATER_BUCKET)); player.setPos(pos.getX() + 100, pos.getY(), pos.getZ()); menu.clickMenuButton(player, left); require( - machine.getTankFluid().isEmpty() && menu.getCarried().is(Items.WATER_BUCKET), + machine.fluids.getFluidInTank(0).isEmpty() && menu.getCarried().is(Items.WATER_BUCKET), "A remote stale menu cannot transfer fluid"); helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3); helper.succeed(); @@ -1504,6 +1744,128 @@ private static ProcessorEntity place(GameTestHelper helper, CompanionMod.Machine return (ProcessorEntity) helper.getLevel().getBlockEntity(pos); } + @GameTest(template = "empty") + public static void gaugeTargetsExactlyOneTankAndProtectsOutputSpace(GameTestHelper helper) { + var machine = place(helper, CompanionMod.LOGISTICS); + var player = helper.makeMockServerPlayerInLevel(); + var pos = machine.getBlockPos(); + player.setPos(pos.getX() + .5, pos.getY(), pos.getZ() + .5); + player.getAbilities().instabuild = false; + var menu = + new MachineMenu(48, player.getInventory(), machine) { + @Override + public void broadcastChanges() {} + }; + player.containerMenu = menu; + int input = menu.index(menu.bound.screen().find("tank_1").orElseThrow()); + int output = menu.index(menu.bound.screen().find("tank_2").orElseThrow()); + menu.setCarried(new ItemStack(Items.WATER_BUCKET)); + menu.action(player, new ScreenNetwork.Action(48, input, 0, 0, 0, 0)); + require( + machine.fluids.getFluidInTank(0).isEmpty() + && machine.fluids.getFluidInTank(1).getAmount() == 1000, + "Second gauge fills only second tank"); + machine.fluids.setPlan( + 1, + new zone.moddev.mc.lib.machine.inventory.TankPlan( + 100, zone.moddev.mc.lib.machine.ingredient.FluidAmount.EMPTY)); + menu.setCarried(new ItemStack(Items.GLASS_BOTTLE)); + menu.action(player, new ScreenNetwork.Action(48, input, 0, 1, 0, 0)); + require( + machine.fluids.getFluidInTank(1).getAmount() == 100 + && FluidUtil.getFluidHandler(menu.getCarried()) + .orElseThrow() + .getFluidInTank(0) + .getAmount() + == 900, + "Partial container takes exactly the unreserved input fluid"); + menu.action(player, new ScreenNetwork.Action(48, input, 0, 0, 0, 0)); + machine.fluids.setPlan(1, zone.moddev.mc.lib.machine.inventory.TankPlan.NONE); + menu.setCarried(new ItemStack(Items.BUCKET)); + menu.action(player, new ScreenNetwork.Action(48, input, 0, 1, 0, 0)); + require( + menu.getCarried().is(Items.WATER_BUCKET) && machine.fluids.getFluidInTank(1).isEmpty(), + "Input tank can be drained manually"); + menu.action(player, new ScreenNetwork.Action(48, output, 0, 0, 0, 0)); + require( + machine.fluids.getFluidInTank(2).isEmpty() && menu.getCarried().is(Items.WATER_BUCKET), + "Output tank rejects player insertion"); + machine.fluids.setPlan( + 1, + new zone.moddev.mc.lib.machine.inventory.TankPlan( + 0, + zone.moddev.mc.lib.machine.neoforge.FluidStacks.amount( + new FluidStack(Fluids.WATER, 3500)))); + menu.action(player, new ScreenNetwork.Action(48, input, 0, 0, 0, 0)); + require( + machine.fluids.getFluidInTank(1).isEmpty() && menu.getCarried().is(Items.WATER_BUCKET), + "Bucket cannot consume reserved capacity"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void ordinaryLabelUsesSharedInteractionAndMenuAuthorization(GameTestHelper helper) + throws Exception { + var machine = place(helper, CompanionMod.CRUSHER); + var player = helper.makeMockServerPlayerInLevel(); + var pos = machine.getBlockPos(); + player.setPos(pos.getX() + .5, pos.getY(), pos.getZ() + .5); + player.getAbilities().instabuild = false; + var saved = zone.moddev.mc.lib.gui.Screens.all(); + var id = net.minecraft.resources.ResourceLocation.parse("mmdlib_companion:crusher"); + String xml = + """ + + + """; + var screen = + zone.moddev.mc.lib.gui.load.ScreenReader.read( + id, + "interaction-test.xml", + new java.io.ByteArrayInputStream( + xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + var bound = + zone.moddev.mc.lib.gui.load.ScreenBinder.bind( + screen, CompanionGameTests.class.getClassLoader()); + var changed = new java.util.HashMap<>(saved); + changed.put(id, bound); + try { + zone.moddev.mc.lib.gui.Screens.install(changed); + var menu = + new MachineMenu(49, player.getInventory(), machine) { + @Override + public void broadcastChanges() {} + }; + player.containerMenu = menu; + int target = menu.index(screen.find("transfer").orElseThrow()); + int hidden = menu.index(screen.find("hidden").orElseThrow()); + menu.setCarried(new ItemStack(Items.WATER_BUCKET)); + menu.action(player, new ScreenNetwork.Action(49, hidden, 0, 0, 0, 0)); + menu.action(player, new ScreenNetwork.Action(50, target, 0, 0, 0, 0)); + menu.action(player, new ScreenNetwork.Action(49, target, 0, 0, 0, 1)); + require( + machine.fluids.getFluidInTank(0).isEmpty(), + "Hidden, wrong-menu and malformed clicks are rejected"); + menu.action(player, new ScreenNetwork.Action(49, target, 0, 0, 1, 0)); + require( + machine.fluids.getFluidInTank(0).getAmount() == 1000 + && menu.getCarried().is(Items.BUCKET), + "Ordinary label runs shared interaction with modifiers"); + player.containerMenu = player.inventoryMenu; + menu.action(player, new ScreenNetwork.Action(49, target, 0, 1, 0, 0)); + require(machine.fluids.getFluidInTank(0).getAmount() == 1000, "Closed menu cannot transfer"); + } finally { + zone.moddev.mc.lib.gui.Screens.install(saved); + } + helper.succeed(); + } + private static void tick(ProcessorEntity machine, int ticks) { for (int i = 0; i < ticks; i++) machine.serverTick(); } diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java index 03dc445d..e5efc3a7 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java @@ -8,7 +8,9 @@ import zone.moddev.mc.companion.CompanionWidgets; import zone.moddev.mc.lib.gui.client.MachineScreen; -import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.containers.PlayerInventoryElement; +import zone.moddev.mc.lib.gui.model.elements.CustomElement; +import zone.moddev.mc.lib.gui.model.elements.SlotElement; /** Real client focus, text editing, captures, resize, navigation and acknowledged actions. */ final class CompanionWidgetSmoke { @@ -35,16 +37,16 @@ private static void type(MachineScreen s, String text) { throw new AssertionError("Inventory shortcut closed focused editor"); } - private static boolean nameIs(MachineScreen s, Element.Custom e, String name) { + private static boolean nameIs(MachineScreen s, CustomElement e, String name) { return s.getMenu().value(e, false) instanceof Component c && c.getString().equals(name); } static boolean run(MachineScreen s) { var mc = Minecraft.getInstance(); var menu = s.getMenu(); - var name = (Element.Custom) menu.bound.screen().find("name_editor").orElseThrow(); - var slider = (Element.Custom) menu.bound.screen().find("reserve_slider").orElseThrow(); - var selector = (Element.Custom) menu.bound.screen().find("lane_selector").orElseThrow(); + var name = (CustomElement) menu.bound.screen().find("name_editor").orElseThrow(); + var slider = (CustomElement) menu.bound.screen().find("reserve_slider").orElseThrow(); + var selector = (CustomElement) menu.bound.screen().find("lane_selector").orElseThrow(); var n = s.bounds(name); var r = s.bounds(slider); var l = s.bounds(selector); @@ -55,17 +57,19 @@ static boolean run(MachineScreen s) { throw new AssertionError("View change replaced menu slot"); if (slot.container == mc.player.getInventory()) { var expected = - zone.moddev.mc.lib.gui.layout.ScreenLayout.playerSlot( - s.layout.inventory().orElseThrow(), slot.getSlotIndex()); + PlayerInventoryElement.slot( + s.layout.region(PlayerInventoryElement.INVENTORY).orElseThrow(), + slot.getSlotIndex()); if (slot.x != expected.x() + 1 || slot.y != expected.y() + 1) throw new AssertionError("Resized player slot does not match its background"); } } for (var element : menu.elements) { - if (element instanceof Element.Slot slotElement) { - var slot = menu.slots.get(slotElement.index()); + if (element instanceof SlotElement slotElement) { + var slot = menu.slot(slotElement); var expected = - zone.moddev.mc.lib.gui.layout.ScreenLayout.itemPosition(s.layout.bounds(element)); + ((zone.moddev.mc.lib.gui.model.elements.SlotElement) element) + .itemPosition(s.layout.bounds(element)); if (slot.x != expected[0] || slot.y != expected[1]) throw new AssertionError("Resized machine slot does not match its background"); } diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/ContainerCellClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/ContainerCellClientSmoke.java new file mode 100644 index 00000000..f83c9927 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/ContainerCellClientSmoke.java @@ -0,0 +1,421 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.drawer.BuiltinDrawers; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.TooltipContext; +import zone.moddev.mc.lib.gui.model.containers.ContainerInventoryElement; +import zone.moddev.mc.lib.gui.model.elements.ContainerSlotElement; +import zone.moddev.mc.lib.gui.runtime.*; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; +import zone.moddev.mc.lib.gui.runtime.slot.MenuSlots; + +/** Uses the real drawer factories and synchronized pages, without replacing their gesture code. */ +final class ContainerCellClientSmoke { + private static int mode, hitCalls, headings, labels; + private static boolean inputProbe; + private static int clicks, drags, releases, keys, keyUps, characters, scrolls, cancels, enters; + private static boolean focused; + private static int probeIndex = -1; + private static final Set checked = new HashSet<>(); + private static final EnumSet passes = + EnumSet.noneOf(ElementRenderers.Pass.class); + + private static class Cell extends ContainerSlotElement { + Cell(int index, ContainerInventoryElement.Page page) { + super(index, page.description(index)); + } + + @Override + public Rect cellHitBounds(Rect frame) { + hitCalls++; + return index() == probeIndex + ? new Rect(frame.x() - 4, frame.y() + 4, 6, 6) + : super.cellHitBounds(frame); + } + + public List nativeTooltip(ItemStack stack, List vanilla) { + stack.setCount(1); + return mode == 0 + ? vanilla + : mode == 1 ? List.of(Component.literal("native cell replacement")) : List.of(); + } + + public void appendTooltip(TooltipContext c, List rows) { + super.appendTooltip(c, rows); + rows.add(Component.literal("cell row")); + } + } + + private static class Inventory extends ContainerInventoryElement { + Inventory(Page page) { + super(page, index -> new Cell(index, page)); + } + + public List headings() { + headings++; + return super.headings(); + } + + public Component cellLabel(zone.moddev.mc.lib.gui.model.elements.SlotElement slot) { + labels++; + return super.cellLabel(slot); + } + + public void appendChildTooltip(TooltipContext c, List rows) { + rows.add(Component.literal("parent cell row")); + } + } + + static void install() { + for (var type : List.of(BuiltinDrawers.BACKPACK, BuiltinDrawers.CURIOS)) + DrawerFactories.GLOBAL.replace( + type, + (screen, definition) -> + new ContainerDrawer(screen, definition, type.equals(BuiltinDrawers.CURIOS)) { + protected ContainerInventoryElement createInventory( + ContainerInventoryElement.Page page) { + return new Inventory(page); + } + }); + var base = ElementRenderers.GLOBAL.require(ContainerSlotElement.class); + for (var pass : ElementRenderers.Pass.values()) + ElementRenderers.GLOBAL.register( + pass, + Cell.class, + ElementRenderers.Clip.ELEMENT, + (cell, c) -> { + passes.add(pass); + if (pass == ElementRenderers.Pass.BACKGROUND) { + int before = hitCalls; + base.accept(c); + if (cell.available()) + require(hitCalls > before, "Default hover renderer consults overridable cell hits"); + } + if (pass == ElementRenderers.Pass.TOP) + c.graphics() + .renderOutline( + c.bounds().x(), + c.bounds().y(), + c.bounds().width(), + c.bounds().height(), + 0xff55aacc); + }); + ElementBehaviors.GLOBAL.register( + Cell.class, + (cell, access) -> + new SlotBehavior(cell, access) { + private boolean probing() { + return inputProbe && cell.index() == probeIndex; + } + + public boolean focusable() { + return probing(); + } + + public void setFocused(boolean value) { + focused = value; + } + + public Click mouseClicked(double x, double y, int button) { + if (!probing()) return super.mouseClicked(x, y, button); + clicks++; + return Click.CAPTURE; + } + + public boolean mouseDragged(double x, double y, int button, double dx, double dy) { + drags++; + return true; + } + + public boolean mouseReleased(double x, double y, int button) { + releases++; + return true; + } + + public boolean keyPressed(int key, int scan, int modifiers) { + keys++; + return true; + } + + public boolean keyReleased(int key, int scan, int modifiers) { + keyUps++; + return true; + } + + public boolean charTyped(char value, int modifiers) { + characters++; + return true; + } + + public boolean mouseScrolled(double x, double y, double dx, double dy) { + if (!probing()) return false; + scrolls++; + return true; + } + + public void cancelInteraction() { + cancels++; + } + + public void hoverChanged(boolean value) { + if (probing() && value) enters++; + } + }); + ElementBehaviors.GLOBAL.register( + Inventory.class, + (element, access) -> + new DefaultElementBehavior(access) { + public void appendChildTooltip(TooltipContext c, List rows) { + rows.add(Component.literal("parent behavior row")); + } + }); + } + + static void rendered(MachineScreen screen, net.minecraft.client.gui.GuiGraphics graphics) { + for (boolean equipment : new boolean[] {false, true}) { + String id = equipment ? "curios" : "backpack"; + if (checked.contains(id)) continue; + var model = + screen + .getMenu() + .components() + .require(equipment ? BuiltinMenuComponents.CURIOS : BuiltinMenuComponents.BACKPACK); + var state = model.state(); + if (!state.open() || state.contents().isEmpty() || model.pending()) continue; + var drawer = + (ContainerDrawer) + screen.drawers().entries().stream() + .filter(d -> d.id().equals(id)) + .findFirst() + .orElseThrow(); + var layout = drawer.contentLayout(); + if (state.pageSize() != layout.pageSize() || passes.size() != 4) continue; + int occupied = -1, empty = -1; + for (int i = 0; i < state.contents().size(); i++) { + if (state.contents().get(i).isEmpty()) empty = i; + else occupied = i; + } + if (occupied < 0 || empty < 0) continue; + var cell = layout.cell(occupied); + var before = state.contents().get(occupied).copy(); + try { + for (mode = 0; mode < 3; mode++) { + var rows = + drawer.cellTooltip(cell.x() + 8, cell.y() + 8).stream() + .map(Component::getString) + .toList(); + require( + rows.contains("cell row") + && rows.contains("parent cell row") + && rows.contains("parent behavior row"), + "Parent rows survive every native tooltip mode"); + require( + mode != 1 || rows.getFirst().equals("native cell replacement"), + "Native replacement used"); + require( + mode != 2 || !rows.contains(before.getHoverName().getString()), + "Native rows suppressed"); + require( + ItemStack.matches(before, state.contents().get(occupied)), + "Tooltip mutation cannot alter snapshot"); + if (equipment) + for (var row : state.descriptions().get(occupied).tooltip()) + require( + rows.contains(row.getString()), "Provider tooltip retained on occupied cells"); + } + } finally { + mode = 0; + } + var blank = layout.cell(empty); + var rows = + drawer.cellTooltip(blank.x() + 8, blank.y() + 8).stream() + .map(Component::getString) + .toList(); + require( + rows.contains("parent cell row") && rows.contains("cell row"), + "Empty cells compose rows"); + var first = layout.cell(0); + probeIndex = state.offset(); + try { + require( + drawer.cellTooltip(first.x() - 2, first.y() + 6).stream() + .anyMatch(row -> row.getString().equals("parent cell row")), + "Expanded hit outside frame composes parent rows"); + require( + drawer.cellTooltip(first.x() + 8, first.y() + 8).isEmpty(), + "Frame outside shrunken hit has no cell tooltip"); + } finally { + probeIndex = -1; + } + input(screen, drawer, model, first); + presentation(); + require(headings > 0 && labels > 0, "Renderer consults inventory presentation hooks"); + nativeHits(); + checked.add(id); + var mc = Minecraft.getInstance(); + graphics.flush(); + Screenshot.grab( + mc.gameDirectory, id + "-cell-elements.png", mc.getMainRenderTarget(), message -> {}); + } + } + + private static void input( + MachineScreen screen, ContainerDrawer drawer, ContainerDrawerMenu model, Rect frame) { + var state = model.state(); + var carried = screen.getMenu().getCarried().copy(); + probeIndex = state.offset(); + inputProbe = true; + clicks = drags = releases = keys = keyUps = characters = scrolls = cancels = enters = 0; + double x = frame.x() - 2, y = frame.y() + 6; + try { + screen.mouseMoved(frame.x() + 8, frame.y() + 8); + screen.mouseMoved(x, y); + require(enters == 1, "Generic hover respects expanded cell bounds"); + require( + screen.mouseClicked(x, y, 0) && clicks == 1 && focused, + "Generic cell click captures and focuses outside its frame"); + require( + screen.mouseDragged(-10, -10, 0, -1, -1) && drags == 1, + "Capture receives drag outside drawer"); + require( + screen.mouseReleased(-10, -10, 0) && releases == 1, + "Capture receives release outside drawer"); + require( + screen.keyPressed(65, 0, 0) + && screen.keyReleased(65, 0, 0) + && screen.charTyped('a', 0) + && keys == 1 + && keyUps == 1 + && characters == 1, + "Focused cells receive keyboard, release and text input"); + require(drawer.scroll(x, y, 1) && scrolls == 1, "Cell scrolling precedes page fallback"); + require( + !model.pending() && ItemStack.matches(carried, screen.getMenu().getCarried()), + "Consumed generic input does not trigger item gestures"); + screen.mouseClicked(x, y, 0); + var request = + model + .request(ContainerDrawerMenu.PICKUP, state.offset() * 2, state.pageSize()) + .orElseThrow(); + try { + screen.mouseDragged(x, y, 0, 1, 1); + screen.keyPressed(65, 0, 0); + screen.keyReleased(65, 0, 0); + screen.charTyped('b', 0); + drawer.scroll(x, y, 1); + require( + cancels > 0 + && !focused + && drags == 1 + && keys == 1 + && keyUps == 1 + && characters == 1 + && scrolls == 1, + "Pending lock cancels capture and blocks callbacks"); + } finally { + model.receive( + new ContainerDrawerNetwork.State( + state.menu(), + state.equipment(), + state.session(), + state.revision(), + request.request(), + state.open(), + state.selected(), + state.offset(), + state.pageSize(), + state.icons(), + state.keys(), + state.sizes(), + state.contents(), + state.descriptions(), + state.presentations())); + } + require( + screen.mouseReleased(x, y, 0) && releases == 1, + "Stale release after pending cancellation is consumed without committing"); + screen.mouseClicked(x, y, 0); + int cancelled = cancels; + drawer.resized(); + require( + cancels > cancelled && screen.mouseReleased(x, y, 0) && releases == 1, + "Resize cancels generic capture and consumes stale release"); + } finally { + drawer.resized(); + inputProbe = false; + probeIndex = -1; + } + } + + private static void presentation() { + var icon = net.minecraft.resources.ResourceLocation.parse("test:override"); + var info = + new EquippedContainers.SlotInfo( + "override", + 0, + false, + false, + icon, + Component.literal("overridden label"), + List.of(Component.literal("overridden row")), + Component.literal("overridden placeholder")); + var cell = + new ContainerSlotElement(0, Optional.empty()) { + public Optional description() { + return Optional.of(info); + } + }; + require( + !cell.available() + && cell.icon().orElseThrow().equals(icon) + && cell.cellLabel().getString().equals("overridden label") + && cell.placeholder().getString().equals("overridden placeholder"), + "Overridden description controls default cell presentation"); + } + + private static void nativeHits() { + var slots = new MenuSlots(); + boolean[] active = {true}; + var nativeSlot = + new net.minecraft.world.inventory.Slot( + new net.minecraft.world.SimpleContainer(1), 0, 20, 20) { + public boolean isActive() { + return active[0]; + } + }; + var element = + new ContainerSlotElement(0, Optional.empty()) { + public Rect hitBounds(Rect original) { + return new Rect(10, 22, 6, 6); + } + }; + slots.registrar(slot -> slot.index = 0).add(nativeSlot, element, MenuSlots.Role.PLAYER_STORAGE); + var hits = new NativeSlotRenderer(); + require( + hits.hit(slots, 9, 22) == nativeSlot && hits.hit(slots, 24, 24) == null, + "Custom hits and tolerance replace fixed vanilla bounds"); + active[0] = false; + require( + hits.hit(slots, 9, 22) == null && hits.hit(slots, 9, 22, false) == nativeSlot, + "Pending geometry stays blocked without enabling interaction"); + } + + static void verify() { + if (BackpackFixture.installed()) + require(checked.contains("backpack"), "Backpack cell policies were checked"); + if (net.neoforged.fml.ModList.get().isLoaded("curios")) + require(checked.contains("curios"), "Equipment cell policies were checked"); + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/ContributedHintClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/ContributedHintClientSmoke.java new file mode 100644 index 00000000..b14a723c --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/ContributedHintClientSmoke.java @@ -0,0 +1,367 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.client.MachineScreen; +import zone.moddev.mc.lib.gui.drawer.BuiltinDrawers; +import zone.moddev.mc.lib.gui.model.TooltipContext; +import zone.moddev.mc.lib.gui.model.containers.AutomationInventoryElement; +import zone.moddev.mc.lib.gui.model.elements.AutomationSlotElement; +import zone.moddev.mc.lib.gui.model.elements.SlotElement; +import zone.moddev.mc.lib.gui.runtime.AutomationMenu; +import zone.moddev.mc.lib.gui.runtime.ScreenHost; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; +import zone.moddev.mc.lib.gui.runtime.component.MenuComponentRegistry; + +/** Hints for elements outside the screen tree, through the real server snapshot and drawer. */ +final class ContributedHintClientSmoke { + private static volatile boolean enabled = true; + private static int stage, frames, nativeMode; + private static boolean tooltipChecked; + private static final Map> rendered = new HashMap<>(); + + private static class ProbeSlot extends AutomationSlotElement { + final boolean client; + final String inventory; + + ProbeSlot(int index, boolean client, String inventory) { + super( + AutomationInventoryElement.defaultSlot(index).common(), + index, + AutomationInventoryElement.defaultSlot(index).appearance()); + this.client = client; + this.inventory = inventory; + } + + public zone.moddev.mc.lib.gui.layout.Rect hitBounds(zone.moddev.mc.lib.gui.layout.Rect bounds) { + if (index() == 0) + return new zone.moddev.mc.lib.gui.layout.Rect(bounds.x() - 6, bounds.y(), 22, 16); + if (index() == 2) + return new zone.moddev.mc.lib.gui.layout.Rect(bounds.x() + 4, bounds.y() + 4, 6, 6); + return super.hitBounds(bounds); + } + + public ItemStack hint(ScreenHost host, String name) { + require(!client && name.equals(inventory), "Server-only contributed hint scope"); + return enabled && index() == 0 ? new ItemStack(Items.DIAMOND, 2) : ItemStack.EMPTY; + } + + public List tooltip(ScreenHost host, ItemStack carried) { + require(!client, "Server slot tooltip is never sampled on the client"); + return List.of(Component.literal("server drawer slot")); + } + + public void appendTooltip(TooltipContext context, List rows) { + super.appendTooltip(context, rows); + rows.add(Component.literal("client drawer slot")); + } + + public List nativeTooltip(ItemStack stack, List vanilla) { + return nativeMode == 0 + ? vanilla + : nativeMode == 1 ? List.of(Component.literal("custom native row")) : List.of(); + } + } + + private static class ProbeInventory extends AutomationInventoryElement { + final boolean client; + + ProbeInventory(String inventory, boolean client) { + super(inventory, index -> new ProbeSlot(index, client, inventory)); + this.client = client; + } + + public boolean slotVisible(SlotElement slot, Presentation context) { + return slot.index() != 1 && super.slotVisible(slot, context); + } + + public Object drawerValue( + zone.moddev.mc.lib.gui.model.Element element, boolean maximum, Presentation context) { + if (element == slots().get(2)) return maximum ? 888 : 777; + if (element == this && !maximum) return Component.literal("custom drawer caption"); + return super.drawerValue(element, maximum, context); + } + + public List tooltip(ScreenHost host, ItemStack carried) { + require(!client, "Server parent tooltip is never sampled on the client"); + return List.of(Component.literal("server drawer parent")); + } + + public void appendChildTooltip(TooltipContext context, List rows) { + super.appendChildTooltip(context, rows); + rows.addAll(context.serverLines()); + rows.add(Component.literal("client drawer parent")); + } + } + + static void install() { + MenuComponentRegistry.GLOBAL.replace( + BuiltinMenuComponents.AUTOMATION, + 0, + c -> + c.host() == null || c.host().screenAutomation() == null + ? null + : new AutomationMenu( + c.menu(), + c.host().screenAutomation(), + c.inventory().player, + c.client(), + group -> new ProbeInventory(group.id(), c.client()))); + var base = ElementRenderers.GLOBAL.require(SlotElement.class); + for (var pass : ElementRenderers.Pass.values()) + ElementRenderers.GLOBAL.register( + pass, + ProbeSlot.class, + ElementRenderers.Clip.ELEMENT, + (slot, context) -> { + rendered + .computeIfAbsent( + slot.index(), ignored -> EnumSet.noneOf(ElementRenderers.Pass.class)) + .add(pass); + if (slot.index() == 2) + require( + context.value().equals(777) && context.maximum().equals(888), + "Renderer receives overridden drawer values"); + if (pass == ElementRenderers.Pass.BACKGROUND) base.accept(context); + if (pass == ElementRenderers.Pass.TOP) + context + .graphics() + .renderOutline( + context.bounds().x(), + context.bounds().y(), + context.bounds().width(), + context.bounds().height(), + 0xffcc44ff); + }); + ElementBehaviors.GLOBAL.register( + ProbeSlot.class, + (element, access) -> + new SlotBehavior(element, access) { + public List tooltip(int x, int y) { + return List.of(Component.literal("behavior drawer slot")); + } + }); + ElementBehaviors.GLOBAL.register( + ProbeInventory.class, + (element, access) -> + new DefaultElementBehavior(access) { + public void appendChildTooltip(TooltipContext context, List rows) { + rows.add(Component.literal("behavior drawer parent")); + } + }); + } + + static void rendered(MachineScreen screen, GuiGraphics graphics) { + frames++; + if (stage != 3 || tooltipChecked) return; + var model = screen.getMenu().components().require(BuiltinMenuComponents.AUTOMATION); + var slot = screen.getMenu().getSlot(model.slotStart()); + if (slot.getItem().isEmpty() || model.pending()) return; + int x = screen.guiLeft() + slot.x + 8, y = screen.guiTop() + slot.y + 8; + nativeMode = 1; + screen.render(graphics, x, y, 0); + for (nativeMode = 0; nativeMode < 3; nativeMode++) { + try { + var method = + MachineScreen.class.getDeclaredMethod("getTooltipFromContainerItem", ItemStack.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + var rows = + ((List) method.invoke(screen, slot.getItem())) + .stream().map(Component::getString).toList(); + var suffix = + List.of( + "server drawer slot", + "client drawer slot", + "behavior drawer slot", + "server drawer parent", + "client drawer parent", + "behavior drawer parent"); + require( + rows.size() >= suffix.size() + && rows.subList(rows.size() - suffix.size(), rows.size()).equals(suffix), + "Drawer parent/behavior/server rows preserved: " + rows); + if (nativeMode == 1) + require( + rows.getFirst().equals("custom native row") && rows.size() == suffix.size() + 1, + "Custom drawer native replacement"); + if (nativeMode == 2) + require(rows.equals(suffix), "Native suppression preserves all composed rows"); + if (nativeMode == 0) require(rows.size() > suffix.size(), "Default native rows retained"); + if (nativeMode == 1) { + graphics.flush(); + var mc = Minecraft.getInstance(); + Screenshot.grab( + mc.gameDirectory, "drawer-slot-tooltip.png", mc.getMainRenderTarget(), ignored -> {}); + } + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + } + nativeMode = 0; + tooltipChecked = true; + } + + static boolean run(MachineScreen screen) { + var menu = screen.getMenu(); + var automation = menu.components().require(BuiltinMenuComponents.AUTOMATION); + if (stage == 4) return !automation.pending() && !automation.open(); + var element = automation.slotElement(automation.slotStart()); + var mc = Minecraft.getInstance(); + if (automation.pending()) return false; + if (stage == 0) { + if (menu.slotHint(element).isEmpty()) return false; + require( + menu.elements.stream().noneMatch(e -> e == element), + "Hint element is absent from screen tree"); + require( + menu.nativeSlots().slot(element).getItem().isEmpty(), + "Hints do not populate actual storage"); + require( + menu.slotHint(element).getCount() == 2, "Contributed hint arrives through typed field"); + menu.slotHint(element).setCount(1); + require(menu.slotHint(element).getCount() == 2, "Hint lookup returns detached stacks"); + var drawer = + screen.drawers().entries().stream() + .filter(d -> d.definition().type().equals(BuiltinDrawers.SIDES)) + .findFirst() + .orElseThrow(); + var tab = drawer.tabBounds(); + require(screen.mouseClicked(tab.x() + 2, tab.y() + 2, 0), "Open hint drawer through tab"); + stage = 1; + return false; + } + if (stage == 1) { + if (!automation.open()) return false; + if (automation.selected() != 0) { + require(automation.send("select", 0, -1, "", 0, ""), "Select hinted group"); + return false; + } + frames = 0; + stage = 2; + return false; + } + if (stage == 2) { + if (frames < 3) return false; + require( + rendered.getOrDefault(0, EnumSet.noneOf(ElementRenderers.Pass.class)).size() + == ElementRenderers.Pass.values().length, + "Custom drawer slot renderer participates in every pass"); + require(!rendered.containsKey(4), "Hidden filter slots are not rendered"); + require( + !rendered.containsKey(1) && !menu.getSlot(automation.slotStart() + 1).isActive(), + "Hidden addon is neither drawn nor interactive"); + var drawer = + screen.drawers().entries().stream() + .filter(d -> d.definition().type().equals(BuiltinDrawers.SIDES)) + .findFirst() + .orElseThrow(); + try { + var field = drawer.getClass().getDeclaredField("slotFragment"); + field.setAccessible(true); + var fragment = (ElementFragment) field.get(drawer); + var bounds = fragment.bounds(element); + var rows = + fragment.tooltip(bounds.x() + 2, bounds.y() + 2).stream() + .map(Component::getString) + .toList(); + require( + rows.contains("server drawer slot") + && rows.contains("server drawer parent") + && rows.contains("client drawer parent"), + "Empty drawer slots compose element and parent rows"); + require( + rows.stream() + .anyMatch( + row -> + row.startsWith( + Component.translatable("screen.mmdlib.automation.addon").getString() + + ":")), + "Default addon hint is supplied by parent inventory"); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + Screenshot.grab( + mc.gameDirectory, "contributed-slot-hints.png", mc.getMainRenderTarget(), ignored -> {}); + var small = menu.getSlot(automation.slotStart() + 2); + double sx = screen.guiLeft() + small.x + 1, sy = screen.guiTop() + small.y + 1; + require( + screen.nativeSlotAt(sx, sy) == null && drawer.click(sx, sy, 0), + "Shrunken native bounds leave frame clicks to the drawer"); + require( + drawer + .nativeTooltip(small, List.of(Component.literal("vanilla")), (int) sx, (int) sy) + .isEmpty(), + "Frame-only hover cannot claim a native tooltip"); + mc.getSingleplayerServer() + .execute( + () -> { + enabled = false; + var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst(); + var serverMenu = (zone.moddev.mc.lib.gui.runtime.MachineMenu) player.containerMenu; + serverMenu + .components() + .require(BuiltinMenuComponents.AUTOMATION) + .inventory + .slots() + .setStackInSlot( + 0, new ItemStack(zone.moddev.mc.companion.CompanionAddons.PULLER.get())); + serverMenu.broadcastChanges(); + }); + stage = 3; + return false; + } + var slot = menu.slot(element); + double x = screen.guiLeft() + slot.x - 4, y = screen.guiTop() + slot.y + 8; + if (stage == 3) { + if (!menu.slotHint(element).isEmpty() || !tooltipChecked) return false; + require(automation.send("select", 0, -1, "", 0, ""), "Begin pending request"); + require( + automation.visible(0, element) && !slot.isActive(), + "Pending interaction lock is independent of rendering"); + var drawer = + screen.drawers().entries().stream() + .filter(d -> d.definition().type().equals(BuiltinDrawers.SIDES)) + .findFirst() + .orElseThrow(); + require( + screen.nativeSlotAt(x, y) == null && drawer.click(x, y, 0), + "Pending expanded slot hit is consumed without native interaction"); + stage = 5; + return false; + } + if (stage == 5) { + require(screen.nativeSlotAt(x, y) == slot, "Enlarged hit area resolves original native slot"); + screen.mouseClicked(x, y, 0); + screen.mouseReleased(x, y, 0); + stage = 6; + return false; + } + if (stage == 6) { + if (menu.getCarried().isEmpty() || slot.hasItem()) return false; + require( + menu.getCarried().is(zone.moddev.mc.companion.CompanionAddons.PULLER.get()), + "Click outside frame extracts the actual addon"); + screen.mouseClicked(x, y, 0); + screen.mouseReleased(x, y, 0); + stage = 7; + return false; + } + if (!menu.getCarried().isEmpty() || !slot.hasItem()) return false; + require(automation.send("close", 0, -1, "", 0, ""), "Close drawer after native round trip"); + stage = 4; + return false; + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CreativePowerClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CreativePowerClientSmoke.java index daec29b2..f56972c2 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CreativePowerClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CreativePowerClientSmoke.java @@ -7,6 +7,8 @@ import zone.moddev.mc.lib.api.capabilities.RelativeFace; import zone.moddev.mc.lib.gui.client.MachineScreen; import zone.moddev.mc.lib.gui.layout.AutomationLayout; +import zone.moddev.mc.lib.gui.model.containers.PlayerInventoryElement; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Generator side settings round trip through the actual drawer and server. */ final class CreativePowerClientSmoke { @@ -14,7 +16,7 @@ final class CreativePowerClientSmoke { static boolean run(MachineScreen screen) { var menu = screen.getMenu(); - var automation = menu.automation; + var automation = menu.components().get(BuiltinMenuComponents.AUTOMATION); if (automation == null) throw new AssertionError("Generator must expose its energy drawer"); if (automation.pending()) return false; int top = RelativeFace.TOP.ordinal(); @@ -30,15 +32,24 @@ static boolean run(MachineScreen screen) { "Generator drawer places the western crusher on its left when facing south"); if (!menu.slots.isEmpty() || automation.addonsEnabled() - || screen.layout.inventory().isPresent() - || screen.layout.inventoryColors().isPresent() - || !menu.containerDrawer.state().icons().isEmpty() - || !menu.curiosDrawer.state().icons().isEmpty()) + || screen.layout.region(PlayerInventoryElement.INVENTORY).isPresent() + || screen.layout.region(PlayerInventoryElement.TOOLBAR).isPresent() + || !menu.components().get(BuiltinMenuComponents.BACKPACK).state().icons().isEmpty() + || !menu.components().get(BuiltinMenuComponents.CURIOS).state().icons().isEmpty()) throw new AssertionError("Generator has no inventory, addon slots or equipment drawers"); - if (menu.stats(0).size() != 3 - || menu.stats(0).stream().anyMatch(c -> c.getString().contains("RF used"))) + if (menu.components().require(BuiltinMenuComponents.STATS).pages().get(0).lines().size() + != 3 + || menu + .components() + .require(BuiltinMenuComponents.STATS) + .pages() + .get(0) + .lines() + .stream() + .anyMatch(c -> c.getString().contains("RF used"))) throw new AssertionError("Generator statistics only show output and output limit"); - if (menu.stats(2).stream().noneMatch(c -> c.getString().contains("1,200,000 RF/min"))) + if (menu.components().require(BuiltinMenuComponents.STATS).pages().get(2).lines().stream() + .noneMatch(c -> c.getString().contains("1,200,000 RF/min"))) throw new AssertionError("Generator maximum output follows the minute timescale"); var panel = screen.panelBounds(); click(screen, panel.right() + 5, panel.y() + AutomationLayout.TOP + 5); diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CuriosClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CuriosClientSmoke.java index 8d7b4875..890124e5 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CuriosClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CuriosClientSmoke.java @@ -1,17 +1,20 @@ package zone.moddev.mc.companion.test; import java.util.function.Consumer; + import net.minecraft.client.Minecraft; import net.minecraft.client.Screenshot; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; + import net.neoforged.fml.ModList; import net.neoforged.neoforge.network.PacketDistributor; + import zone.moddev.mc.lib.gui.client.MachineScreen; -import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout; import zone.moddev.mc.lib.gui.layout.Rect; import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Networked functional/cosmetic equipment, optional absence, paging and dynamic-slot checks. */ final class CuriosClientSmoke { @@ -22,7 +25,7 @@ final class CuriosClientSmoke { static boolean run(MachineScreen screen) { var mc = Minecraft.getInstance(); var menu = screen.getMenu(); - var drawer = menu.curiosDrawer; + var drawer = menu.components().get(BuiltinMenuComponents.CURIOS); if (reportedStep != step) { reportedStep = step; zone.moddev.mc.lib.MMDLib.LOGGER.info("CURIOS_DRAWER_STEP {}", step); @@ -32,36 +35,30 @@ static boolean run(MachineScreen screen) { return true; } if (step == 20) return !drawer.state().open(); - if (drawer.pending() || menu.containerDrawer.pending()) return false; + if (drawer.pending() || menu.components().get(BuiltinMenuComponents.BACKPACK).pending()) + return false; var state = drawer.state(); if (state.icons().isEmpty()) return false; - var layout = - ContainerDrawerLayout.equipment( - screen.panelBounds(), - screen.drawerBottom(), - state.sizes().getFirst(), - !menu.containerDrawer.state().icons().isEmpty()); + var layout = DrawerExtensionClientSmoke.containerLayout(screen, true); require( layout.pageSize() > 0 && layout.body().height() == screen.panelBounds().height() - 8, "Full-height equipment drawer fits"); switch (step) { case 0 -> { - var bag = - ContainerDrawerLayout.of( - screen.panelBounds(), - screen.drawerBottom(), - menu.containerDrawer.state().sizes().getFirst()); + var bag = DrawerExtensionClientSmoke.containerLayout(screen, false); click(screen, bag.tab(), 0); step++; } case 1 -> { - if (!menu.containerDrawer.state().open()) return false; + if (!menu.components().get(BuiltinMenuComponents.BACKPACK).state().open()) return false; click(screen, layout.tab(), 0); step++; } case 2 -> { if (!state.open() || state.pageSize() != layout.pageSize()) return false; - require(!menu.containerDrawer.state().open(), "Opening Curios closes backpack storage"); + require( + !menu.components().get(BuiltinMenuComponents.BACKPACK).state().open(), + "Opening Curios closes backpack storage"); require( state.descriptions().getFirst().identifier().equals("ring") && !state.descriptions().getFirst().cosmetic(), diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CuriosFixture.java b/src/companionTest/java/zone/moddev/mc/companion/test/CuriosFixture.java index 33c68847..10c9192f 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CuriosFixture.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CuriosFixture.java @@ -5,13 +5,17 @@ import net.minecraft.world.entity.ai.attributes.AttributeModifier; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; + import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.common.util.TriState; + import top.theillusivec4.curios.api.CuriosApi; import top.theillusivec4.curios.api.event.CurioCanUnequipEvent; + import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; import zone.moddev.mc.lib.gui.runtime.ContainerDrawerNetwork; import zone.moddev.mc.lib.gui.runtime.MachineMenu; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Only loaded in the real optional-mod client run. Slot definitions/tags are test resources. */ final class CuriosFixture { @@ -101,7 +105,7 @@ private static void checkInactive(ServerPlayer player) { static void resize(ServerPlayer player) { var menu = (MachineMenu) player.containerMenu; - var drawer = menu.curiosDrawer; + var drawer = menu.components().get(BuiltinMenuComponents.CURIOS); stale = new ContainerDrawerNetwork.Request( menu.containerId, @@ -124,7 +128,7 @@ static void resize(ServerPlayer player) { static void checkStale(ServerPlayer player) { var menu = (MachineMenu) player.containerMenu; - menu.curiosDrawer.action(player, stale); + menu.components().get(BuiltinMenuComponents.CURIOS).action(player, stale); require(menu.getCarried().isEmpty(), "Pre-resize request cannot mutate the new topology"); require( CuriosApi.getCuriosInventory(player) diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/DrawerExtensionClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/DrawerExtensionClientSmoke.java new file mode 100644 index 00000000..15e1be5c --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/DrawerExtensionClientSmoke.java @@ -0,0 +1,153 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.drawer.*; +import zone.moddev.mc.lib.gui.model.Edge; + +/** A mod drawer receives the generic hooks and supplies its own appearance and local behavior. */ +final class DrawerExtensionClientSmoke { + private static Probe probe; + private static boolean done; + + private static final ResourceLocation TYPE = ResourceLocation.parse("test:drawer"); + + static void install() { + DrawerRegistry.GLOBAL.register( + TYPE, + context -> + context.host() == null + ? List.of() + : List.of( + new DrawerDefinition(TYPE, "test:drawer", DrawerPlacement.top(Edge.LEFT, 50)), + new DrawerDefinition(TYPE, "test:right", DrawerPlacement.top(Edge.RIGHT, 5)))); + DrawerFactories.GLOBAL.register(TYPE, Probe::new); + } + + static boolean run(MachineScreen screen) { + if (done) return true; + if (probe == null) { + probe = + (Probe) + screen.drawers().entries().stream() + .filter(d -> d.id().equals("test:drawer")) + .findFirst() + .orElseThrow(); + var right = + screen.drawers().entries().stream() + .filter(d -> d.id().equals("test:right")) + .findFirst() + .orElseThrow(); + var redstone = + screen.drawers().entries().stream() + .filter(RedstoneDrawer.class::isInstance) + .findFirst() + .orElseThrow(); + var sides = + screen.drawers().entries().stream() + .filter(SideConfigDrawer.class::isInstance) + .findFirst() + .orElseThrow(); + if (right.tabBounds().y() < sides.tabBounds().bottom() + || right.tabBounds().bottom() > redstone.tabBounds().y()) + throw new AssertionError("Registered drawer must reserve space between built-ins"); + var before = right.tabBounds(); + screen.resize(net.minecraft.client.Minecraft.getInstance(), screen.width, screen.height); + if (!before.equals(right.tabBounds())) + throw new AssertionError("Drawer placement changed on equal resize"); + return false; + } + if (probe.frames == 0) throw new AssertionError("Custom drawer was not rendered"); + if (probe.colour() != 0x92bcb4 || probe.iconSprite() == null) + throw new AssertionError("Subclass appearance is retained"); + var tab = probe.tabBounds(); + screen.mouseClicked(tab.x() + 5, tab.y() + 5, 0); + screen.mouseDragged(tab.x() + 6, tab.y() + 6, 0, 1, 1); + screen.mouseReleased(tab.x() + 6, tab.y() + 6, 0); + screen.mouseScrolled(tab.x() + 5, tab.y() + 5, 0, 1); + screen.charTyped('x', 0); + if (!probe.open || !probe.dragged || !probe.released || !probe.scrolled || !probe.typed) + throw new AssertionError("Generic drawer input dispatch"); + var stats = + screen.drawers().entries().stream() + .filter(StatsDrawer.class::isInstance) + .findFirst() + .orElseThrow(); + var statsTab = stats.tabBounds(); + screen.mouseClicked(statsTab.x() + 5, statsTab.y() + 5, 0); + screen.mouseReleased(statsTab.x() + 5, statsTab.y() + 5, 0); + if (probe.open || !stats.open()) + throw new AssertionError("Opening a sibling closes the custom drawer"); + screen.keyPressed(256, 0, 0); + probe.enabled = false; + done = true; + return true; + } + + static zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout containerLayout( + MachineScreen screen, boolean equipment) { + return ((ContainerDrawer) + screen.drawers().entries().stream() + .filter(d -> d.id().equals(equipment ? "curios" : "backpack")) + .findFirst() + .orElseThrow()) + .contentLayout(); + } + + private static final class Probe extends Drawer { + private boolean enabled = true, open, dragged, released, scrolled, typed; + private int frames; + + Probe(MachineScreen screen, DrawerDefinition definition) { + super( + screen, definition, IconSprite.gui(ResourceLocation.parse("mmdlib:icon_info")), 0x92bcb4); + } + + public boolean available() { + return enabled; + } + + public boolean open() { + return open; + } + + public void close() { + open = false; + } + + public void closeLocal() { + close(); + } + + public void render(GuiGraphics g, int x, int y) { + frames++; + } + + public boolean click(double x, double y, int button) { + if (!tabContains(x, y)) return false; + openLocal(); + open = true; + return true; + } + + public boolean drag(double x, double y, int button, double dx, double dy) { + return dragged = open; + } + + public boolean release(double x, double y, int button) { + return released = open; + } + + public boolean scroll(double x, double y, double amount) { + return scrolled = open; + } + + public boolean character(char character, int modifiers) { + return typed = open; + } + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/DrawerMemoryClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/DrawerMemoryClientSmoke.java index ebcda1b2..aa015390 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/DrawerMemoryClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/DrawerMemoryClientSmoke.java @@ -2,11 +2,12 @@ import net.minecraft.client.Minecraft; import net.minecraft.core.BlockPos; + import zone.moddev.mc.companion.CompanionMod; import zone.moddev.mc.lib.gui.client.MachineScreen; -import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout; import zone.moddev.mc.lib.gui.layout.Rect; import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Reopens different real machine types on the same connection, including an explicit close. */ final class DrawerMemoryClientSmoke { @@ -24,22 +25,33 @@ static boolean run() { zone.moddev.mc.lib.MMDLib.LOGGER.info("DRAWER_MEMORY_STEP {}", step); } if (++waitTicks > 160) - throw new AssertionError("Drawer memory timed out at step " + step - + "; reopening=" + reopenAfterClose + "; client=" + describeClient(mc) - + "; server=" + serverMenu); + throw new AssertionError( + "Drawer memory timed out at step " + + step + + "; reopening=" + + reopenAfterClose + + "; client=" + + describeClient(mc) + + "; server=" + + serverMenu); if (waitTicks == 1 || waitTicks % 20 == 0) - server(player -> serverMenu = player.containerMenu.getClass().getSimpleName() - + "#" + player.containerMenu.containerId); + server( + player -> + serverMenu = + player.containerMenu.getClass().getSimpleName() + + "#" + + player.containerMenu.containerId); if (reopenAfterClose != null) { var target = reopenAfterClose; - server(player -> { - // The client close packet and server.execute use different queues. Never open a - // replacement menu until the server has actually handled the vanilla close. - if (reopenAfterClose == target && player.containerMenu == player.inventoryMenu) { - reopenAfterClose = null; - ScreenRuntime.open(player, player.serverLevel().getBlockEntity(target)); - } - }); + server( + player -> { + // The client close packet and server.execute use different queues. Never open a + // replacement menu until the server has actually handled the vanilla close. + if (reopenAfterClose == target && player.containerMenu == player.inventoryMenu) { + reopenAfterClose = null; + ScreenRuntime.open(player, player.serverLevel().getBlockEntity(target)); + } + }); return false; } if (step == 0) { @@ -69,15 +81,14 @@ static boolean run() { boolean first = menu.blockEntity.getBlockPos().equals(FIRST); boolean second = menu.blockEntity.getBlockPos().equals(SECOND); if (!first && !second - || menu.containerDrawer.pending() - || menu.curiosDrawer.pending() - || menu.automation != null && menu.automation.pending()) return false; - var bag = menu.containerDrawer.state(); - var curios = menu.curiosDrawer.state(); + || menu.components().get(BuiltinMenuComponents.BACKPACK).pending() + || menu.components().get(BuiltinMenuComponents.CURIOS).pending() + || menu.components().get(BuiltinMenuComponents.AUTOMATION) != null + && menu.components().get(BuiltinMenuComponents.AUTOMATION).pending()) return false; + var bag = menu.components().get(BuiltinMenuComponents.BACKPACK).state(); + var curios = menu.components().get(BuiltinMenuComponents.CURIOS).state(); if (bag.keys().isEmpty()) return false; - var bagLayout = - ContainerDrawerLayout.of( - screen.panelBounds(), screen.drawerBottom(), bag.sizes().getFirst()); + var bagLayout = DrawerExtensionClientSmoke.containerLayout(screen, false); switch (step) { case 1 -> { if (!first) return false; @@ -95,20 +106,23 @@ static boolean run() { bag.keys().get(bag.selected()).equals("back:0"), "Same equipped container restored across machines"); screen.keyPressed(256, 0, 0); + require( + menu.components().get(BuiltinMenuComponents.BACKPACK).pending(), + "Explicit close awaits acknowledgment"); + // Close the screen before that acknowledgment: the user's intent must survive capture. + open(FIRST); advance(); } case 4 -> { - if (bag.open()) return false; - open(FIRST); + if (!first || waitTicks < 15) return false; + require(!bag.open(), "Pending explicit close is remembered on another machine"); advance(); } case 5 -> { if (!first || waitTicks < 15) return false; require(!bag.open(), "Explicit close is remembered on another machine"); if (curios.icons().isEmpty()) return false; - var layout = - ContainerDrawerLayout.equipment( - screen.panelBounds(), screen.drawerBottom(), curios.sizes().getFirst(), true); + var layout = DrawerExtensionClientSmoke.containerLayout(screen, true); click(screen, layout.tab()); advance(); } @@ -127,8 +141,9 @@ static boolean run() { } case 8 -> { if (curios.open()) return false; - if (menu.automation == null) throw new AssertionError("Fixture has automation"); - if (!menu.automation.open()) { + if (menu.components().get(BuiltinMenuComponents.AUTOMATION) == null) + throw new AssertionError("Fixture has automation"); + if (!menu.components().get(BuiltinMenuComponents.AUTOMATION).open()) { var panel = screen.panelBounds(); click(screen, new Rect(panel.right(), panel.y() + 4, 22, 24)); return false; @@ -146,12 +161,15 @@ static boolean run() { advance(); } case 9 -> { - if (!first || menu.automation == null || !menu.automation.open()) return false; + if (!first + || menu.components().get(BuiltinMenuComponents.AUTOMATION) == null + || !menu.components().get(BuiltinMenuComponents.AUTOMATION).open()) return false; require( !bag.open() && !curios.open(), "Closed left drawers remain closed while right drawer restores"); // Deliberately drop a configuration request instead of sending it to the server. - menu.containerDrawer + menu.components() + .get(BuiltinMenuComponents.BACKPACK) .request(zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu.SYNC, 0, bag.pageSize()) .orElseThrow(); var slot = @@ -169,7 +187,8 @@ static boolean run() { case 10 -> { // The normal pending guard above waits for the one-second SYNC recovery. require(waitTicks >= 15, "Lost acknowledgment exercised timeout recovery"); - menu.containerDrawer + menu.components() + .get(BuiltinMenuComponents.BACKPACK) .request(zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu.PICKUP, 0, bag.pageSize()) .orElseThrow(); int exclusions = screen.exclusions().size(); @@ -187,11 +206,15 @@ static boolean run() { case 11 -> { if (!second || waitTicks < 15) return false; require( - !bag.open() && !curios.open() && !menu.automation.open(), + !bag.open() + && !curios.open() + && !menu.components().get(BuiltinMenuComponents.AUTOMATION).open(), "Logout clears drawer preferences before the next screen"); - if (menu.redstone != null) { + if (menu.components().get(BuiltinMenuComponents.REDSTONE) != null) { require( - menu.redstone.send(zone.moddev.mc.lib.gui.runtime.RedstoneMenu.SYNC, 0, 0, 0), + menu.components() + .get(BuiltinMenuComponents.REDSTONE) + .send(zone.moddev.mc.lib.gui.runtime.RedstoneMenu.SYNC, 0, 0, 0), "Redstone sync is pending before the next client tick"); require(!screen.keyPressed(65, 0, 0), "Pending redstone does not swallow unrelated keys"); screen.keyPressed(256, 0, 0); @@ -202,12 +225,14 @@ static boolean run() { } case 12 -> { if (!first || waitTicks < 15) return false; - if (!menu.automation.open()) { + if (!menu.components().get(BuiltinMenuComponents.AUTOMATION).open()) { var panel = screen.panelBounds(); click(screen, new Rect(panel.right(), panel.y() + 4, 22, 24)); return false; } - require(menu.automation.send("select", 0, 0, "", 0, ""), "Automation request is pending"); + require( + menu.components().get(BuiltinMenuComponents.AUTOMATION).send("select", 0, 0, "", 0, ""), + "Automation request is pending"); screen.keyPressed(256, 0, 0); require( mc.screen != screen, "Escape closes immediately during a pending automation request"); @@ -216,13 +241,13 @@ static boolean run() { } case 13 -> { if (!second || waitTicks < 15) return false; - if (!menu.automation.open()) { + if (!menu.components().get(BuiltinMenuComponents.AUTOMATION).open()) { var panel = screen.panelBounds(); click(screen, new Rect(panel.right(), panel.y() + 4, 22, 24)); return false; } require( - menu.automation.send("select", 0, 0, "", 0, ""), + menu.components().get(BuiltinMenuComponents.AUTOMATION).send("select", 0, 0, "", 0, ""), "Second automation request is pending"); screen.keyPressed(69, 0, 0); require(mc.screen != screen, "Inventory key closes during a pending automation request"); @@ -240,11 +265,21 @@ static boolean run() { private static String describeClient(Minecraft mc) { if (!(mc.screen instanceof MachineScreen screen)) return String.valueOf(mc.screen); var menu = screen.getMenu(); - return "MachineMenu#" + menu.containerId + " at " + menu.blockEntity.getBlockPos() - + ", backpackPending=" + menu.containerDrawer.pending() - + ", curiosPending=" + menu.curiosDrawer.pending() - + ", automation=" + (menu.automation == null ? "absent" - : "open:" + menu.automation.open() + ",pending:" + menu.automation.pending()); + return "MachineMenu#" + + menu.containerId + + " at " + + menu.blockEntity.getBlockPos() + + ", backpackPending=" + + menu.components().get(BuiltinMenuComponents.BACKPACK).pending() + + ", curiosPending=" + + menu.components().get(BuiltinMenuComponents.CURIOS).pending() + + ", automation=" + + (menu.components().get(BuiltinMenuComponents.AUTOMATION) == null + ? "absent" + : "open:" + + menu.components().get(BuiltinMenuComponents.AUTOMATION).open() + + ",pending:" + + menu.components().get(BuiltinMenuComponents.AUTOMATION).pending()); } private static void advance() { diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/ElementBehaviorClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/ElementBehaviorClientSmoke.java new file mode 100644 index 00000000..3a3e285f --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/ElementBehaviorClientSmoke.java @@ -0,0 +1,139 @@ +package zone.moddev.mc.companion.test; + +import java.util.List; + +import net.minecraft.network.chat.Component; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.ElementAction; +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.model.elements.SideConfigElement; +import zone.moddev.mc.lib.gui.sync.ElementSync; + +/** A plain Java element uses the same capture and acknowledged action path as custom widgets. */ +final class ElementBehaviorClientSmoke { + private static volatile int serverValue; + private static int phase, drags, releases, accepted, cancelled; + private static int enters, leaves; + static final ElementSync PROBE_STATE = + ElementSync.field( + ResourceLocation.parse("mmdlib_companion:probe_state"), + StreamCodec.of( + (buffer, value) -> buffer.writeVarInt(value), buffer -> buffer.readVarInt()), + 5, + element -> 0) + .sample((source, previous) -> serverValue) + .validate((element, value) -> value >= 0 && value <= 7) + .build(); + private static int initialSideMode; + static final ElementAction PROBE = + new ElementAction<>( + ResourceLocation.parse("mmdlib_companion:behavior_probe"), + StreamCodec.of( + (buffer, value) -> buffer.writeVarInt(value), buffer -> buffer.readVarInt()), + 5, + (context, value) -> { + if (value != 7) return false; + serverValue = value; + return true; + }); + + static void install() { + ElementRendererClientSmoke.install(); + NavigationClientSmoke.install(); + ElementBehaviors.GLOBAL.register( + ElementRendererClientSmoke.ModElement.class, + (element, access) -> + new ElementBehavior() { + public void hoverChanged(boolean hovered) { + if (hovered) enters++; + else leaves++; + } + + public Click mouseClicked(double x, double y, int button) { + return Click.CAPTURE; + } + + public boolean mouseDragged(double x, double y, int button, double dx, double dy) { + drags++; + return true; + } + + public boolean mouseReleased(double x, double y, int button) { + releases++; + if (!access.submit(PROBE, 7)) + throw new AssertionError("Plain element action was refused locally"); + return true; + } + + public void actionResult(boolean ok) { + if (!ok) throw new AssertionError("Plain element action was refused on the server"); + if (access.presentation().state().get(element, PROBE_STATE) != 7) + throw new AssertionError("Typed state was not applied before actionResult"); + accepted++; + } + + public void cancelInteraction() { + cancelled++; + } + + public List tooltip(int x, int y) { + return List.of(Component.literal("Plain element behavior")); + } + }); + } + + static boolean run(MachineScreen screen) { + var element = screen.getMenu().bound.screen().find("renderer_probe").orElseThrow(); + var bounds = screen.bounds(element); + if (phase == 0) { + screen.mouseMoved(-1, -1); + enters = leaves = 0; + screen.mouseMoved(bounds.x() + 2, bounds.y() + 2); + screen.mouseMoved(bounds.x() + 3, bounds.y() + 3); + if (enters != 1 || leaves != 0) throw new AssertionError("Screen hover must enter once"); + screen.mouseMoved(-1, -1); + screen.mouseMoved(-1, -1); + if (enters != 1 || leaves != 1) throw new AssertionError("Screen hover must leave once"); + if (screen.tooltipLines(element, bounds.x() + 2, bounds.y() + 2).stream() + .noneMatch(line -> line.getString().equals("Plain element behavior"))) + throw new AssertionError("Plain element tooltip missing"); + if (!screen.mouseClicked(bounds.x() + 2, bounds.y() + 2, 0)) + throw new AssertionError("Plain element did not capture"); + screen.mouseDragged(bounds.right() + 100, bounds.bottom() + 100, 0, 100, 100); + screen.mouseReleased(bounds.right() + 100, bounds.bottom() + 100, 0); + phase++; + return false; + } + if (phase == 1) { + if (accepted == 0) return false; + if (serverValue != 7 || drags != 1 || releases != 1) + throw new AssertionError("Plain element capture/action did not complete"); + int previous = cancelled; + screen.mouseClicked(bounds.x() + 2, bounds.y() + 2, 0); + screen.resize(net.minecraft.client.Minecraft.getInstance(), screen.width, screen.height); + screen.mouseReleased(bounds.x() + 2, bounds.y() + 2, 0); + if (cancelled <= previous || releases != 1 || screen.getMenu().elementPending(element)) + throw new AssertionError("Resize committed a cancelled plain-element capture"); + phase++; + } + var sides = + (SideConfigElement) screen.getMenu().bound.screen().find("renderer_sides").orElseThrow(); + if (phase == 2) { + initialSideMode = + screen.getMenu().elementStates().get(sides, SideConfigElement.MODES).getFirst(); + var face = sides.face(screen.bounds(sides), 0); + screen.mouseClicked(face.x() + 2, face.y() + 2, 0); + phase++; + return false; + } + if (phase == 3) { + if (screen.getMenu().elementPending(sides)) return false; + if (screen.getMenu().elementStates().get(sides, SideConfigElement.MODES).getFirst() + == initialSideMode) throw new AssertionError("Inline side mode did not synchronize"); + phase++; + } + return NavigationClientSmoke.run(screen) && CompanionWidgetSmoke.run(screen); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/ElementInheritanceClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/ElementInheritanceClientSmoke.java new file mode 100644 index 00000000..175f66ac --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/ElementInheritanceClientSmoke.java @@ -0,0 +1,244 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.*; + +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; + +import zone.moddev.mc.companion.ProcessorEntity; +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.load.ScreenBinder; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.model.containers.BoxElement; +import zone.moddev.mc.lib.gui.model.elements.*; + +/** + * Subclasses inherit production renderers, snapshots and tank actions without new registrations. + */ +final class ElementInheritanceClientSmoke { + static int energyRenders, progressRenders, tankFills, gaugeFills, stage, attempts; + static volatile boolean prepared; + + static class Tank extends FluidTankElement { + Tank() { + super( + ElementInheritanceClientSmoke.common("inherited_tank", false), + 0, + Orientation.VERTICAL, + 36); + } + + public GaugeFill fill(Object value) { + tankFills++; + return GaugeFill.solid(0xbb6699); + } + + public Component displayText(Object value, boolean detailed) { + return Component.literal("tank " + super.displayText(value, detailed).getString()); + } + + public void appendTooltip(TooltipContext context, List rows) { + super.appendTooltip(context, rows); + rows.add(displayText(context.value(), context.shiftDown())); + } + } + + static class Energy extends EnergyGaugeElement { + Energy() { + super( + ElementInheritanceClientSmoke.common("inherited_energy", false), + Orientation.VERTICAL, + 36, + 0xff44aa77); + } + + public double fillRatio(Object value, Object maximum) { + energyRenders++; + return .25; + } + + public Component displayText(Object value, boolean detailed) { + return Component.literal("custom " + super.displayText(value, detailed).getString()); + } + } + + static class Progress extends ProgressElement { + Progress() { + super( + ElementInheritanceClientSmoke.common("inherited_progress", true), + Optional.empty(), + ProgressDirection.RIGHT); + } + + public double fillRatio(Object value, Object maximum) { + progressRenders++; + return .75; + } + } + + static class Gauge extends GaugeElement { + Gauge() { + super( + ElementInheritanceClientSmoke.common("inherited_gauge", true), + Orientation.HORIZONTAL, + 36, + Optional.empty()); + } + + public GaugeFill fill(Object value) { + if (value != null && !(value instanceof Number)) + throw new AssertionError("Expected numeric gauge snapshot"); + gaugeFills++; + return GaugeFill.fluid( + new FluidStack(net.minecraft.world.level.material.Fluids.WATER, 1000), 0xff55aaff); + } + + public double fillRatio(Object value, Object maximum) { + return .5; + } + } + + static class Row extends BoxElement { + Row() { + super( + Common.empty(), + false, + 0, + 8, + Align.START, + List.of(new Tank(), new Energy(), new Progress(), new Gauge())); + } + } + + private static Common common(String id, boolean progress) { + var c = Common.empty(); + return new Common( + Optional.of(id), + progress ? Optional.of(ValueRef.parse("#getProgress")) : Optional.empty(), + progress ? Optional.of(ValueRef.parse("#getTime")) : Optional.empty(), + c.tooltip(), + c.tooltipShift(), + c.clicks(), + c.onHover(), + c.hoverShows(), + c.jeiRecipes(), + 0, + 0, + 0); + } + + static void install() { + var id = ResourceLocation.parse("mmdlib_companion:crusher"); + var old = Screens.get(id).orElseThrow().screen(); + var body = new ArrayList<>(old.body()); + body.add(new Row()); + var model = + new Screen( + old.id(), + old.source(), + old.title(), + old.blockEntity(), + old.playerInventory(), + body, + old.tabs()); + var screens = new HashMap<>(Screens.all()); + screens.put(id, ScreenBinder.bind(model, ElementInheritanceClientSmoke.class.getClassLoader())); + Screens.install(screens); + } + + static boolean run(MachineScreen screen) { + var menu = screen.getMenu(); + var tank = menu.bound.screen().find("inherited_tank").orElseThrow(); + if (!(menu.value(tank, false) instanceof FluidStack fluid)) return false; + if (++attempts > 40) + throw new AssertionError( + "Inherited tank interaction stuck at " + + stage + + ": " + + fluid + + ", cursor=" + + menu.getCarried()); + if (stage == 0) { + if (tankFills == 0 || gaugeFills == 0) + throw new AssertionError("Inherited renderer bypassed gauge fill selection"); + if (!GaugeFill.fluid(FluidStack.EMPTY).equals(GaugeFill.solid(0x777777))) + throw new AssertionError("Empty fluid must select the default solid fill"); + var original = new FluidStack(net.minecraft.world.level.material.Fluids.WATER, 750); + var selected = GaugeFill.fluid(original); + original.setAmount(1); + var returned = selected.fluid().orElseThrow(); + if (returned.getAmount() != 750) throw new AssertionError("Fill retained mutable input"); + returned.setAmount(2); + if (selected.fluid().orElseThrow().getAmount() != 750) + throw new AssertionError("Fill exposed mutable contents"); + var defaultGauge = + new GaugeElement(Common.empty(), Orientation.VERTICAL, 36, Optional.empty()); + if (defaultGauge.fill(original).fluid().isEmpty()) + throw new AssertionError("Default fluid fill lost"); + if (new GaugeElement(Common.empty(), Orientation.VERTICAL, 36, Optional.of(0x123456)) + .fill(original) + .fluid() + .isPresent()) throw new AssertionError("Configured solid must override fluid"); + if (energyRenders == 0 || progressRenders == 0) + throw new AssertionError("Inherited renderer bypassed element fillRatio"); + if (screen.tooltipLines(tank, 0, 0).stream() + .noneMatch(row -> row.getString().equals("tank " + fluid.getAmount()))) + throw new AssertionError("Tank subclass display hook not composed"); + var energy = menu.bound.screen().find("inherited_energy").orElseThrow(); + if (screen.tooltipLines(energy, 0, 0).stream() + .noneMatch(row -> row.getString().startsWith("custom "))) + throw new AssertionError("Inherited energy tooltip bypassed displayText"); + var mc = Minecraft.getInstance(); + Screenshot.grab( + mc.gameDirectory, "inherited-elements.png", mc.getMainRenderTarget(), ignored -> {}); + mc.getSingleplayerServer() + .execute( + () -> { + try { + var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst(); + var machine = + (ProcessorEntity) + ((zone.moddev.mc.lib.gui.runtime.MachineMenu) player.containerMenu) + .blockEntity; + machine.lanes.forEach(lane -> lane.reset()); + for (int i = 0; i < machine.items.getSlots(); i++) + machine.items.setStackInSlot(i, ItemStack.EMPTY); + machine.energy.setStored(0); + machine.fluids.drain(Integer.MAX_VALUE, FluidAction.EXECUTE); + player.getAbilities().instabuild = false; + player.containerMenu.setCarried(new ItemStack(Items.WATER_BUCKET)); + player.containerMenu.broadcastChanges(); + prepared = true; + } catch (Throwable failure) { + CompanionClientSmoke.fail(failure); + } + }); + stage = 1; + return false; + } + if (!prepared) return false; + var r = screen.bounds(tank); + if (stage == 1) { + if (!fluid.isEmpty() || !menu.getCarried().is(Items.WATER_BUCKET)) return false; + screen.mouseClicked(r.x() + 8, r.y() + 8, 0); + screen.mouseReleased(r.x() + 8, r.y() + 8, 0); + stage = 2; + return false; + } + if (stage == 2) { + if (fluid.getAmount() != 1000 || !menu.getCarried().is(Items.BUCKET)) return false; + screen.mouseClicked(r.x() + 8, r.y() + 8, 1); + screen.mouseReleased(r.x() + 8, r.y() + 8, 1); + stage = 3; + return false; + } + return fluid.isEmpty() && menu.getCarried().is(Items.WATER_BUCKET); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/ElementRendererClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/ElementRendererClientSmoke.java new file mode 100644 index 00000000..19fbeba4 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/ElementRendererClientSmoke.java @@ -0,0 +1,243 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.layout.*; +import zone.moddev.mc.lib.gui.load.ScreenBinder; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.model.containers.*; +import zone.moddev.mc.lib.gui.model.elements.*; +import zone.moddev.mc.lib.gui.sync.*; + +/** A focused real-screen fixture for external element types and the shared drawing passes. */ +final class ElementRendererClientSmoke { + private static final List passes = new ArrayList<>(); + private static final Set widgets = new HashSet<>(); + private static int playerSlots, machineSlots, playerOverlays, popoutBodies, phase; + + record ModElement(Common common) implements SynchronizedElement { + public List> synchronizedValues() { + return List.of(ElementBehaviorClientSmoke.PROBE_STATE); + } + + public List> actions() { + return List.of(ElementBehaviorClientSmoke.PROBE); + } + + public int[] measure(TextMeasure text) { + return new int[] {30, 14}; + } + } + + private record ModButton(Common common) implements ButtonControl { + public boolean flat() { + return true; + } + + public Optional text() { + return Optional.empty(); + } + + public Optional iconFor(Object value) { + return Optional.of(ResourceLocation.parse("mmdlib:icon_info")); + } + + public Optional labelFor(Object value) { + return Optional.empty(); + } + } + + private static Text text(String label) { + return new Text( + "renderer.test." + label.toLowerCase(Locale.ROOT).replace(' ', '_'), Optional.of(label)); + } + + private static Common common(String id, String value, String maximum) { + var empty = Common.empty(); + return new Common( + Optional.of(id), + Optional.ofNullable(value).map(ValueRef::parse), + Optional.ofNullable(maximum).map(ValueRef::parse), + Optional.empty(), + Optional.empty(), + empty.clicks(), + Optional.empty(), + Optional.empty(), + List.of(), + 0, + 0, + 0); + } + + static void install() { + var registry = ElementRenderers.GLOBAL; + registry.register( + ModElement.class, + (e, c) -> { + passes.clear(); + passes.add(ElementRenderers.Pass.BACKGROUND); + var r = c.bounds(); + c.graphics().fill(r.x(), r.y(), r.right(), r.bottom(), 0xff228855); + if (c.access().find("renderer_probe").orElseThrow() != e + || !c.access().bounds(e).equals(r)) + throw new AssertionError("Mod presentation services lost element identity"); + }); + registry.registerOverlay( + ModElement.class, + (e, c) -> { + passes.add(ElementRenderers.Pass.OVERLAY); + var r = c.bounds(); + c.graphics().renderOutline(r.x(), r.y(), r.width(), r.height(), 0xffffaa44); + }); + registry.registerForeground( + ModElement.class, (e, c) -> passes.add(ElementRenderers.Pass.FOREGROUND)); + registry.registerTop(ModElement.class, (e, c) -> passes.add(ElementRenderers.Pass.TOP)); + var sharedSlots = registry.require(SlotElement.class); + registry.register( + ItemSlotElement.class, + (e, c) -> { + sharedSlots.accept(c); + if (c.access().parent(e).isEmpty() && e.common().id().isEmpty()) playerSlots++; + else machineSlots++; + }); + registry.registerOverlay( + ItemSlotElement.class, + (e, c) -> { + if (c.access().parent(e).isEmpty() && e.common().id().isEmpty()) { + if (!c.access().visible(e) || !c.access().bounds(e).equals(c.bounds())) + throw new AssertionError( + "Implicit player slot has no presentation bounds/visibility"); + playerOverlays++; + } + }); + var sharedWidgets = registry.require(CustomElement.class); + registry.replace( + CustomElement.class, + (e, c) -> { + widgets.add(e.common().id().orElse("")); + sharedWidgets.accept(c); + }); + registry.replace( + ElementRenderers.Pass.FOREGROUND, + PopoutElement.class, + ElementRenderers.Clip.VIEWPORT, + (e, c) -> { + if (c.access().drawerPresentation(e, PopoutRenderer.Presentation.class).isPresent()) + popoutBodies++; + PopoutRenderer.body(e, c); + }); + var id = ResourceLocation.parse("mmdlib_companion:crusher"); + var original = Screens.get(id).orElseThrow().screen(); + var radio = + new RadioGroupElement( + common("renderer_radios", "#getReservePercent", null), + Optional.of(text("Modes")), + List.of( + new RadioElement(Common.empty(), text("One")), + new RadioElement(Common.empty(), text("Two")))); + var column = + new BoxElement( + Common.empty(), + true, + 0, + 3, + Align.START, + List.of( + new ModElement(common("renderer_probe", null, null)), + new ModButton(Common.empty()), + new CheckboxElement(common("renderer_checkbox", "#hasTank", null), text("Tank")), + new MeterElement( + common("renderer_meter", "#getProgress", "#getTime"), + Orientation.HORIZONTAL, + 10, + 60, + 0xff55cc88, + 3, + 1, + Optional.empty()))); + var row = + new BoxElement( + Common.empty(), + false, + 0, + 8, + Align.START, + List.of( + column, + radio, + new SideConfigElement( + common("renderer_sides", null, null), List.of("input", "output")))); + var group = + new GroupElement( + common("renderer_group", null, null), + Optional.of(text("Renderer extensions")), + Optional.empty(), + 4, + List.of(row)); + var body = new ArrayList<>(original.body()); + body.add(Math.max(0, body.size() - 1), group); + var screen = + new zone.moddev.mc.lib.gui.model.Screen( + original.id(), + original.source(), + original.title(), + original.blockEntity(), + original.playerInventory(), + body, + original.tabs()); + var screens = new HashMap<>(Screens.all()); + screens.put(id, ScreenBinder.bind(screen, ElementRendererClientSmoke.class.getClassLoader())); + Screens.install(screens); + } + + static boolean run(MachineScreen screen) { + var radio = + (RadioGroupElement) screen.getMenu().bound.screen().find("renderer_radios").orElseThrow(); + var selected = screen.getMenu().value(radio, false); + for (int i = 0; i < radio.options().size(); i++) + if (TextControlRenderer.selected(radio.options().get(i), screen.presentation()) + != java.math.BigInteger.valueOf(i).equals(selected)) + throw new AssertionError("Radio drawing disagrees with synchronized group selection"); + if (!passes.equals(List.of(ElementRenderers.Pass.values()))) + throw new AssertionError("Wrong element drawing passes: " + passes); + if (playerSlots == 0 || machineSlots == 0 || playerOverlays == 0) + throw new AssertionError("Slot override did not render both player and machine slots"); + if (!widgets.contains("progress")) throw new AssertionError("Custom widget stopped rendering"); + if (phase == 0) { + capture("renderer-elements.png"); + click(screen, "info"); + phase++; + return false; + } + if (phase == 1) { + if (popoutBodies == 0) throw new AssertionError("Popout foreground renderer did not run"); + capture("renderer-popout.png"); + click(screen, "info"); + click(screen, "open_settings"); + phase++; + return false; + } + if (!screen.getMenu().views().get("panel").equals("settings")) return false; + if (!widgets.contains("name_editor") || !widgets.contains("reserve_slider")) + throw new AssertionError("Context widgets did not render"); + capture("renderer-context.png"); + return true; + } + + private static void click(MachineScreen screen, String id) { + var r = screen.bounds(screen.getMenu().bound.screen().find(id).orElseThrow()); + if (!screen.mouseClicked(r.x() + r.width() / 2., r.y() + r.height() / 2., 0)) + throw new AssertionError("Click not consumed: " + id); + } + + private static void capture(String name) { + var mc = Minecraft.getInstance(); + Screenshot.grab(mc.gameDirectory, name, mc.getMainRenderTarget(), message -> {}); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/FilterElementClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/FilterElementClientSmoke.java new file mode 100644 index 00000000..41880b17 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/FilterElementClientSmoke.java @@ -0,0 +1,346 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +import zone.moddev.mc.companion.CompanionAddons; +import zone.moddev.mc.lib.api.automation.*; +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.model.elements.*; + +/** + * Real-client coverage for a mod filter element using only the shared registries and host service. + */ +final class FilterElementClientSmoke { + interface LeftLayer extends Element {} + + interface RightLayer extends Element {} + + record Probe(String id) implements FilterControl, LeftLayer, RightLayer {} + + static class ModSamples extends FilterControl.Samples { + private final SlotElement slot = + new GhostSlotElement( + Common.empty(), + 0, + new SlotElement.Appearance( + SlotSize.LARGE, + Optional.of(0x55aa88), + Optional.empty(), + ResourceLocation.parse("mmdlib:slot"), + .8f)); + + ModSamples() { + super("samples", 5, ResourceType.ENERGY); + } + + public int cellSize() { + return 30; + } + + public Rect sampleBounds(Rect bounds, int index) { + var cell = super.sampleBounds(bounds, index); + return new Rect(cell.x() + 2, cell.y() + 2, 26, 26); + } + + public SlotElement sampleSlot(int index) { + return slot; + } + + public ItemStack sampleDisplay(CompoundTag configuration, int index) { + sampleRenders++; + return new ItemStack(index == 4 ? Items.DIAMOND : Items.EMERALD); + } + + public List sampleTooltip(CompoundTag configuration, int index) { + return List.of( + Component.literal("Mod sample " + index), Component.literal("Custom second row")); + } + } + + private static final ModSamples samples = new ModSamples(); + private static int sampleRenders, submittedSample = -1, validations, behaviorCreations; + private static String submittedValue; + private static boolean rejectValidation = true; + + private static final Probe probe = new Probe("probe"); + private static final FilterControl.Text text = + new FilterControl.Text("text", 8, Component.literal("Text")); + private static final FilterControl.Number number = + new FilterControl.Number("number", 0, 3, 1, Component.literal("Number")); + private static final FilterControl.Custom custom = + new FilterControl.Custom("deny", CompanionAddons.TAG_MODE, 14); + private static ElementFragment fragment; + private static int frames, phase, drags, releases, cancels, removals; + private static int enters, leaves, hostEnters, hostLeaves, parentEnters; + private static boolean pending; + private static final CompoundTag config = new CompoundTag(); + + static void install() { + ElementRenderers.GLOBAL.register( + Probe.class, + new ElementRenderers.Renderer() { + public void validate(Probe element) { + validations++; + if (rejectValidation) throw new IllegalArgumentException("Missing probe dependency"); + } + + public void render(Probe element, ElementRenderers.Context c) { + frames++; + c.graphics() + .fill( + c.bounds().x(), + c.bounds().y(), + c.bounds().right(), + c.bounds().bottom(), + 0xff55aa88); + } + }); + ElementRenderers.GLOBAL.registerOverlay(LeftLayer.class, (e, c) -> {}); + ElementRenderers.GLOBAL.registerOverlay(RightLayer.class, (e, c) -> {}); + ElementBehaviors.GLOBAL.register( + Probe.class, + (element, access) -> + new ElementBehavior() { + { + behaviorCreations++; + } + + public void hoverChanged(boolean hovered) { + if (hovered) enters++; + else leaves++; + } + + public boolean focusable() { + return true; + } + + public Click mouseClicked(double x, double y, int button) { + return Click.CAPTURE; + } + + public boolean mouseDragged(double x, double y, int button, double dx, double dy) { + drags++; + return true; + } + + public boolean mouseReleased(double x, double y, int button) { + releases++; + return true; + } + + public void cancelInteraction() { + cancels++; + } + + public void removed() { + require(leaves == enters, "Fragment leaves hover before disposing its behavior"); + removals++; + } + }); + } + + static void render(GuiGraphics graphics) { + if (fragment != null) { + var r = fragment.bounds(probe); + fragment.render(graphics, phase == 1 ? r.x() + 2 : -1, phase == 1 ? r.y() + 2 : -1); + } + } + + static boolean run() { + if (phase == 0) { + var validationHost = + new ElementFragment.Host() { + public ResourceLocation screenId() { + return ResourceLocation.parse("test:validation"); + } + + public String source(Element element) { + return "mod-filter.xml"; + } + }; + expectInvalidFragment(validationHost, "mod-filter.xml: Missing probe dependency"); + rejectValidation = false; + expectInvalidFragment( + validationHost, "mod-filter.xml: Ambiguous element renderers for OVERLAY"); + require(behaviorCreations == 0, "Validation must finish before behaviors are created"); + ElementRenderers.GLOBAL.registerOverlay(Probe.class, (e, c) -> {}); + FilterEdits edits = + (element, entry, value) -> { + if (element == samples) { + submittedSample = entry; + submittedValue = value; + return true; + } + return element.edit( + new FilterControl.Edit(null, null, config, entry, value, null, null)); + }; + fragment = + new ElementFragment( + new FilterControl.Box("root", false, List.of(probe, text, number, custom, samples)), + 100, + new ElementFragment.Host() { + public ResourceLocation screenId() { + return ResourceLocation.parse("test:fragment"); + } + + public Object value(Element element, boolean maximum) { + return config.copy(); + } + + public boolean pending(Element element) { + return pending; + } + + public void hoverChanged(Element element, boolean entered) { + if (element == probe) { + if (entered) hostEnters++; + else hostLeaves++; + } + if (element instanceof FilterControl.Box && entered) parentEnters++; + } + + public Optional service(Class type) { + return type.isInstance(edits) ? Optional.of(type.cast(edits)) : Optional.empty(); + } + }); + fragment.place(5, 5, new Rect(5, 5, 100, fragment.height())); + phase++; + return false; + } + if (phase == 1) { + require(frames > 0, "Mod control uses registered renderer"); + require(validations >= 3 && behaviorCreations == 1, "Fragment shares renderer validation"); + require(sampleRenders > 0, "Sample subclass inherits renderer and display hook"); + require( + enters == 1 && leaves == 0 && hostEnters == 1 && parentEnters == 0, + "Repeated fragment frames enter only the topmost element once"); + fragment.place(5, 5, new Rect(5, 5, 1, fragment.height())); + require( + leaves == 1 && hostLeaves == 1, + "Clipping leaves even while the pointer is inside element bounds"); + fragment.place(5, 5, new Rect(5, 5, 100, fragment.height())); + require( + enters == 2 && hostEnters == 2, "Expanding the viewport reenters the stationary pointer"); + var r = fragment.bounds(probe); + fragment.click(r.x() + 2, r.y() + 2, 0); + fragment.drag(500, 500, 0, 3, 3); + fragment.release(500, 500, 0); + require(drags == 1 && releases == 1, "Captured drag/release survives leaving the viewport"); + fragment.click(r.x() + 2, r.y() + 2, 0); + int before = cancels; + fragment.place(6, 5, new Rect(6, 5, 100, fragment.height())); + fragment.release(500, 500, 0); + require(cancels > before && releases == 1, "Viewport movement cancels without committing"); + r = fragment.bounds(probe); + fragment.click(r.x() + 2, r.y() + 2, 0); + before = cancels; + fragment.place(6, 5, new Rect(6, 5, 100, 10)); + fragment.release(500, 500, 0); + require( + cancels > before && releases == 1, "Clipping resize cancels a partly visible capture"); + fragment.place(6, 5, new Rect(6, 5, 100, fragment.height())); + r = fragment.bounds(text); + fragment.click(r.x() + 3, r.y() + 5, 0); + fragment.release(r.x() + 3, r.y() + 5, 0); + fragment.character('x', 0); + fragment.key(257, 0, 0); + require(config.getString("text").equals("x"), "Text behavior submits its draft"); + fragment.key(256, 0, 0); + require(!fragment.character('y', 0), "Escape releases text focus"); + r = fragment.bounds(number); + fragment.click(r.right() - 2, r.y() + 5, 0); + require(config.getInt("number") == 1, "Number behavior edits its own value"); + pending = true; + fragment.click(r.right() - 2, r.y() + 5, 0); + require(config.getInt("number") == 1, "Pending requests suppress edits"); + pending = false; + r = fragment.bounds(custom); + fragment.click(r.x() + 3, r.y() + 3, 0); + fragment.release(r.x() + 3, r.y() + 3, 0); + require(config.getBoolean("deny"), "Existing filter widget adapts to shared routing"); + var sampleBounds = samples.sampleBounds(fragment.bounds(samples), 4); + var rows = fragment.tooltip(sampleBounds.x() + 1, sampleBounds.y() + 1); + require( + rows.stream().anyMatch(row -> row.getString().equals("Mod sample 4")) + && rows.stream().anyMatch(row -> row.getString().equals("Custom second row")), + "Custom sample tooltip rows"); + fragment.click(sampleBounds.x() + 1, sampleBounds.y() + 1, 0); + require( + submittedSample == 4 && submittedValue.equals("sample"), + "Custom geometry selects sample index"); + fragment.click(sampleBounds.x() + 1, sampleBounds.y() + 1, 1); + require( + submittedSample == 4 && submittedValue.equals("clear"), + "Custom geometry preserves clearing"); + submittedSample = -1; + fragment.click(sampleBounds.x() - 1, sampleBounds.y() + 1, 0); + require(submittedSample == -1, "Sample gaps do not submit edits"); + verifyDefaultSamples(); + phase++; + return false; + } + if (phase == 2) { + var mc = Minecraft.getInstance(); + Screenshot.grab( + mc.gameDirectory, "filter-element-fragment.png", mc.getMainRenderTarget(), message -> {}); + var r = fragment.bounds(probe); + fragment.mouseMoved(r.x() + 2, r.y() + 2); + fragment.removed(); + require( + enters == hostEnters && leaves == hostLeaves, + "Host and behavior observe the same transitions"); + fragment = null; + require(removals == 1, "Replacement/removal releases mod state"); + phase++; + } + return true; + } + + private static void expectInvalidFragment(ElementFragment.Host host, String message) { + try { + new ElementFragment(probe, 100, host); + throw new AssertionError("Invalid fragment was accepted"); + } catch (zone.moddev.mc.lib.gui.load.ScreenException expected) { + require( + expected.getMessage().startsWith(message), + "Fragment diagnostic: " + expected.getMessage()); + } + } + + private static void verifyDefaultSamples() { + var tags = new CompoundTag(); + var entries = new ListTag(); + entries.add(StringTag.valueOf("minecraft:stone")); + entries.add(StringTag.valueOf("minecraft:water")); + entries.add(StringTag.valueOf("invalid id")); + tags.put("samples", entries); + var item = new FilterControl.Samples("samples", 3, ResourceType.ITEMS); + var fluid = new FilterControl.Samples("samples", 3, ResourceType.FLUIDS); + require(item.sampleDisplay(tags, 0).is(Items.STONE), "Default item display preserved"); + require(fluid.sampleDisplay(tags, 1).is(Items.WATER_BUCKET), "Default fluid bucket preserved"); + require(item.sampleDisplay(tags, 2).isEmpty(), "Invalid sample remains empty"); + require( + item.sampleTooltip(tags, 0) + .getFirst() + .getString() + .equals(new ItemStack(Items.STONE).getHoverName().getString()), + "Default tooltip follows displayed sample"); + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/FragmentNavigationClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/FragmentNavigationClientSmoke.java new file mode 100644 index 00000000..ae1f5ee6 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/FragmentNavigationClientSmoke.java @@ -0,0 +1,234 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.ElementAction; +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.layout.*; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.model.containers.*; +import zone.moddev.mc.lib.gui.sync.*; + +/** Stateful fragments use the same navigation, selection and input rules as machine screens. */ +final class FragmentNavigationClientSmoke { + private record Control(Common common) implements Element { + public Optional navigation() { + return Optional.of(new Navigation(new ViewRef("pages", "details"))); + } + + public int[] measure(TextMeasure text) { + return new int[] {100, 18}; + } + } + + private record Probe(Common common, int height) implements Element { + public int[] measure(TextMeasure text) { + return new int[] {100, height}; + } + + public void appendTooltip(TooltipContext context, List rows) { + rows.add(Component.literal(common.id().orElseThrow())); + } + } + + private static Common named(String id) { + var c = Common.empty(); + return new Common( + Optional.of(id), + c.value(), + c.max(), + c.tooltip(), + c.tooltipShift(), + c.clicks(), + c.onHover(), + c.hoverShows(), + c.jeiRecipes(), + 0, + 0, + 0); + } + + private static final Control control = new Control(named("navigate")); + private static final Probe home = new Probe(named("home_probe"), 18), + details = new Probe(named("details_probe"), 36); + private static final ContextElement pages = + new ContextElement( + named("pages"), + List.of( + new ViewElement(named("home"), Optional.empty(), List.of(home)), + new ViewElement(named("details"), Optional.empty(), List.of(details)))); + private static ElementFragment fragment; + private static ElementStates state; + private static int phase, submissions, cancellations, results, homeDraws, detailDraws, resizes; + private static boolean focused, hidden; + + static void install() { + ElementRenderers.GLOBAL.register( + Control.class, + (e, c) -> { + var r = c.bounds(); + c.graphics().fill(r.x(), r.y(), r.right(), r.bottom(), 0xff406080); + c.graphics().drawString(c.access().font(), "Switch view", r.x() + 2, r.y() + 4, 0xffffff); + }); + ElementRenderers.GLOBAL.register( + Probe.class, + (e, c) -> { + require(c.access().state() == state, "Renderer receives host state"); + if (e == home) homeDraws++; + else detailDraws++; + var r = c.bounds(); + c.graphics() + .fill(r.x(), r.y(), r.right(), r.bottom(), e == home ? 0xff408060 : 0xff805040); + c.graphics() + .drawString( + c.access().font(), e.common().id().orElseThrow(), r.x() + 2, r.y() + 4, 0xffffff); + }); + ElementBehaviors.GLOBAL.register( + Probe.class, + (e, access) -> + new ElementBehavior() { + public boolean focusable() { + return true; + } + + public void setFocused(boolean value) { + focused = value; + } + + public Click mouseClicked(double x, double y, int button) { + return Click.CAPTURE; + } + + public void cancelInteraction() { + cancellations++; + } + + public void resized() { + resizes++; + } + + public void actionResult(boolean accepted) { + require( + !accepted && pages.selected(state).equals("home"), + "State applied before result"); + results++; + } + }); + } + + static void render(GuiGraphics graphics) { + if (fragment != null) fragment.render(graphics, -1, -1); + } + + static boolean run() { + if (phase == 0) { + var root = new BoxElement(Common.empty(), true, 0, 4, Align.START, List.of(control, pages)); + state = + new ElementStates( + ElementFragmentLayout.of(root, 100, TextMeasure.NONE).elements(), + Minecraft.getInstance().level.registryAccess(), + () -> {}); + fragment = + new ElementFragment( + root, + 100, + new ElementFragment.Host() { + public ResourceLocation screenId() { + return ResourceLocation.parse("mmdlib_companion:fragment_navigation"); + } + + public ElementStateView state() { + return state; + } + + public boolean visible(Element e) { + return e != pages || !hidden; + } + + public void navigate(String target, String selection) { + require(target.equals("pages"), "Host receives navigation target"); + pages.select(state, selection); + } + + public boolean submit(Element e, ElementAction action, T value) { + require( + e == control && action == Navigation.NAVIGATE, "Typed fragment navigation"); + submissions++; + return true; + } + }); + fragment.place(5, 5, new Rect(5, 5, 100, 100)); + phase++; + return false; + } + if (phase == 1) { + if (homeDraws == 0 && detailDraws == 0) return false; + require(homeDraws > 0 && detailDraws == 0, "Only selected view renders"); + int initialHeight = fragment.height(); + var r = fragment.bounds(home); + fragment.click(r.x() + 2, r.y() + 2, 0); + int before = cancellations, beforeResize = resizes; + fragment.refresh(); + fragment.tick(); + fragment.height(); + require( + cancellations == before && resizes == beforeResize && focused, + "Unchanged layout preserves capture and focus"); + pages.select(state, "details"); + fragment.refresh(); + require( + cancellations > before && !focused, "Hiding captured view cancels interaction and focus"); + require(fragment.height() > initialHeight, "Selection remeasures fragment"); + require( + fragment.bounds(details).y() > fragment.bounds(pages).y(), + "Layout preparation reserves context header"); + r = fragment.bounds(details); + require( + fragment.tooltip(r.x() + 2, r.y() + 2).stream() + .anyMatch(row -> row.getString().equals("details_probe")), + "Selected child owns tooltip"); + hidden = true; + require( + fragment.tooltip(r.x() + 2, r.y() + 2).isEmpty() + && !fragment.click(r.x() + 2, r.y() + 2, 0), + "Host-hidden ancestor hides descendant input and tooltip"); + hidden = false; + pages.select(state, "home"); + fragment.actionResult(home, false); + require(results == 1, "Fragment forwards action results"); + r = fragment.bounds(control); + fragment.click(r.x() + 2, r.y() + 2, 0); + fragment.refresh(); + require( + submissions == 1 && pages.selected(state).equals("details"), + "Generic control predicts via host navigation"); + homeDraws = detailDraws = 0; + phase++; + return false; + } + if (phase == 2) { + if (homeDraws == 0 && detailDraws == 0) return false; + require(homeDraws == 0 && detailDraws > 0, "Navigation changes rendered selection"); + var mc = Minecraft.getInstance(); + Screenshot.grab( + mc.gameDirectory, "fragment-navigation.png", mc.getMainRenderTarget(), message -> {}); + pages.select(state, "home"); + fragment.actionResult(control, false); + require(fragment.height() < 70, "Authoritative correction remeasures layout"); + fragment.removed(); + fragment = null; + phase++; + } + return true; + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/GroupPresentationClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/GroupPresentationClientSmoke.java new file mode 100644 index 00000000..97bb3525 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/GroupPresentationClientSmoke.java @@ -0,0 +1,357 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.layout.*; +import zone.moddev.mc.lib.gui.load.ScreenBinder; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.model.containers.*; +import zone.moddev.mc.lib.gui.model.elements.*; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; + +/** + * Real inline and drawer callers consume mod-owned labels/highlights through presentation services. + */ +final class GroupPresentationClientSmoke { + private static int phase, labelReads; + private static int customEmphasis, beforeAssociationClick, inventoryWait; + private static final Set renderedPlayerParts = new HashSet<>(); + + private static class ModArmour extends PlayerArmourElement { + ModArmour(Common common) { + super(common, ModPlayerSlot::new); + } + } + + private static class ModPlayerSlot + extends zone.moddev.mc.lib.gui.model.elements.PlayerSlotElement { + ModPlayerSlot(int index) { + super(Common.empty(), index, appearance(index)); + } + + private static Appearance appearance(int index) { + var a = player(index).appearance(); + return new Appearance( + a.size(), + Optional.of(0x88bbcc), + a.icon(), + a.background(), + a.ghostAlpha(), + a.iconAtlas(), + a.itemSize()); + } + } + + private static final Set customRequests = + EnumSet.noneOf(ElementHighlight.Kind.class); + private static final Set colouredRequests = + EnumSet.noneOf(ElementHighlight.Kind.class); + + private record ModGroup(Common common) implements Element { + public List associatedGroups(Screen screen, List groups) { + var ids = new ArrayList(); + ids.add("tank_3"); + ids.addAll(Element.super.associatedGroups(screen, groups)); + return List.copyOf(ids); + } + + public int[] measure(TextMeasure text) { + return new int[] {44, 22}; + } + } + + private static final class ModBehavior extends DefaultElementBehavior + implements GroupLabelProvider, HighlightPresentation { + ModBehavior(ModGroup element, ElementAccess access) { + super(access); + } + + public Optional groupLabel() { + labelReads++; + return Optional.of(text("Mod tank")); + } + + public void highlight(GuiGraphics graphics, Rect bounds, ElementHighlight request) { + customRequests.add(request.kind()); + if (request.kind() == ElementHighlight.Kind.EMPHASIS) customEmphasis++; + if (!bounds.equals(access.bounds())) + throw new AssertionError("Highlight lost element bounds"); + var r = request.outline(bounds); + graphics.renderOutline(r.x(), r.y(), r.width(), r.height(), 0xffff55cc); + graphics.fill(r.x(), r.y(), r.x() + 4, r.y() + 4, 0xff55ffff); + } + } + + private static Text text(String value) { + return new Text("test.group." + value.replace(' ', '_'), Optional.of(value)); + } + + private static Common common(String id, String tooltip) { + var e = Common.empty(); + return new Common( + Optional.of(id), + e.value(), + e.max(), + Optional.of(text(tooltip)), + e.tooltipShift(), + e.clicks(), + e.onHover(), + e.hoverShows(), + e.jeiRecipes(), + 0, + 0, + 0); + } + + static void install() { + ElementRenderers.GLOBAL.replace( + PlayerSlotsElement.class, (element, context) -> renderedPlayerParts.add(element.part())); + ElementBehaviors.GLOBAL.register(ModGroup.class, ModBehavior::new); + ElementRenderers.GLOBAL.register( + ModGroup.class, + (e, c) -> { + var r = c.bounds(); + c.graphics().fill(r.x(), r.y(), r.right(), r.bottom(), 0xff304050); + c.graphics().drawString(c.access().font(), "Mod", r.x() + 4, r.y() + 7, 0xffffff); + }); + ElementBehaviors.GLOBAL.replace( + ColouredGroupElement.class, + (element, access) -> + new ColouredGroupBehavior(element, access) { + public void highlight(GuiGraphics graphics, Rect bounds, ElementHighlight request) { + colouredRequests.add(request.kind()); + super.highlight(graphics, bounds, request); + } + }); + var id = ResourceLocation.parse("mmdlib_companion:logistics"); + var original = Screens.get(id).orElseThrow().screen(); + var body = new ArrayList(); + body.add( + new BoxElement( + Common.empty(), + false, + 0, + 6, + Align.START, + List.of( + new ModGroup(common("tank_1", "Wrong label")), + new GroupElement( + common("labelled_group", "Wrong label"), + Optional.of(text("Panel")), + Optional.empty(), + 3, + List.of(new LabelElement(Common.empty(), text("Body")))), + new ColouredGroupElement( + common("tank_2", "Coloured"), + 0xff55ff88, + new LabelElement(Common.empty(), text("Owned"))), + new LabelElement(common("tank_3", "Plain"), text("Plain")), + new SideConfigElement( + common("group_selector", "Select"), + List.of("tank_1", "labelled_group", "tank_2", "tank_3"))))); + body.add( + new BoxElement( + Common.empty(), + false, + 0, + 6, + Align.START, + List.of( + new ModArmour(common("player_armour", "Armour")), + new PlayerInventoryElement(common("player_items", "Inventory"), ModPlayerSlot::new), + new PlayerOffhandElement( + common("player_offhand", "Offhand"), ModPlayerSlot::new)))); + var screen = + new zone.moddev.mc.lib.gui.model.Screen( + original.id(), + original.source(), + original.title(), + original.blockEntity(), + original.playerInventory(), + body, + original.tabs()); + var screens = new HashMap<>(Screens.all()); + screens.put(id, ScreenBinder.bind(screen, GroupPresentationClientSmoke.class.getClassLoader())); + Screens.install(screens); + } + + static boolean run(MachineScreen screen) { + var menu = screen.getMenu(); + var automation = menu.components().require(BuiltinMenuComponents.AUTOMATION); + if (automation.pending()) return false; + var selector = menu.bound.screen().find("group_selector").orElseThrow(); + switch (phase) { + case 0 -> { + require( + labelReads > 0 && customRequests.contains(ElementHighlight.Kind.SELECTION), + "Inline side config reads the mod caption and invokes its highlight capability"); + require( + customRequests.contains(ElementHighlight.Kind.DECORATION), + "Drawer identification reaches the custom highlight capability"); + assertLabel(screen, "tank_1", "Mod tank"); + assertLabel(screen, "labelled_group", "Panel"); + assertLabel(screen, "tank_2", "Coloured"); + assertLabel(screen, "tank_3", "Plain"); + select(automation, "tank_1"); + } + case 1 -> { + require( + customRequests.contains(ElementHighlight.Kind.EMPHASIS), + "Opened drawer emphasizes the mod group through the same capability"); + capture("groups-custom.png"); + click(screen, selector); + } + case 2 -> { + click(screen, selector); + select(automation, "tank_2"); + } + case 3 -> { + require( + colouredRequests.containsAll(EnumSet.allOf(ElementHighlight.Kind.class)), + "Coloured wrapper handles decoration, emphasis and inline selection through its" + + " contract"); + capture("groups-coloured.png"); + click(screen, selector); + select(automation, "tank_3"); + } + case 4 -> { + capture("groups-fallback.png"); + automation.send("close", -1, -1, "", 0, ""); + } + case 5 -> { + beforeAssociationClick = customEmphasis; + var bar = screen.layout.region(PlayerInventoryElement.TOOLBAR).orElseThrow(); + require( + screen.mouseClicked(screen.guiLeft() + bar.x() + 3, screen.guiTop() + bar.y() + 3, 0), + "Generic colour bar accepts selection"); + screen.mouseReleased(screen.guiLeft() + bar.x() + 3, screen.guiTop() + bar.y() + 3, 0); + } + case 6 -> { + require( + automation.open() + && automation.inventory.group(automation.selected()).id().equals("tank_3"), + "Mod-owned association orders the bar and selects the original server group"); + require( + customEmphasis > beforeAssociationClick, + "Mod-owned association also controls the selected group's highlight targets"); + require( + renderedPlayerParts.containsAll(List.of("storage", "armour", "offhand")), + "All three player sections render through the registered inherited renderer"); + for (var slot : menu.playerSlotElements()) { + var nativeSlot = menu.slot(slot); + var bounds = screen.bounds(slot); + require( + nativeSlot.x + screen.guiLeft() == bounds.x() + 1 + && nativeSlot.y + screen.guiTop() == bounds.y() + 1, + "Native slot follows ordinary element bounds: " + slot.index()); + } + capture("groups-associations.png"); + automation.send("close", -1, -1, "", 0, ""); + var mc = Minecraft.getInstance(); + var id = mc.player.getUUID(); + mc.getSingleplayerServer() + .execute( + () -> { + var player = mc.getSingleplayerServer().getPlayerList().getPlayer(id); + player + .getInventory() + .setItem( + 40, + new net.minecraft.world.item.ItemStack( + net.minecraft.world.item.Items.SHIELD)); + player + .getInventory() + .setItem( + 39, + new net.minecraft.world.item.ItemStack( + net.minecraft.world.item.Items.DIAMOND_HELMET)); + player.containerMenu.broadcastChanges(); + }); + } + case 7 -> { + if (!Minecraft.getInstance() + .player + .getInventory() + .getItem(40) + .is(net.minecraft.world.item.Items.SHIELD)) { + require(++inventoryWait < 40, "Player inventory update timed out"); + return false; + } + click( + screen, + menu.playerSlotElements().stream() + .filter(slot -> slot.index() == 40) + .findFirst() + .orElseThrow()); + } + case 8 -> { + require( + menu.getCarried().is(net.minecraft.world.item.Items.SHIELD), + "Offhand element click picks up its native item"); + click( + screen, + menu.playerSlotElements().stream() + .filter(slot -> slot.index() == 40) + .findFirst() + .orElseThrow()); + } + case 9 -> { + require( + menu.getCarried().isEmpty() + && Minecraft.getInstance() + .player + .getInventory() + .getItem(40) + .is(net.minecraft.world.item.Items.SHIELD), + "Offhand element click returns its native item"); + capture("player-sections.png"); + } + default -> { + return true; + } + } + phase++; + return false; + } + + private static void assertLabel(MachineScreen screen, String id, String expected) { + var access = screen.presentation(); + require( + access + .text(access.groupLabel(access.find(id).orElseThrow()).orElseThrow()) + .getString() + .equals(expected), + "Unexpected presentation caption for " + id); + } + + private static void select(zone.moddev.mc.lib.gui.runtime.AutomationMenu automation, String id) { + for (int i = 0; i < automation.inventory.groups().size(); i++) + if (automation.inventory.group(i).id().equals(id)) { + require(automation.send("select", i, -1, "", 0, ""), "Select group " + id); + return; + } + throw new AssertionError("Missing resource group " + id); + } + + private static void click(MachineScreen screen, Element element) { + var r = screen.bounds(element); + require(screen.mouseClicked(r.x() + 3, r.y() + 3, 0), "Inline group selector accepts click"); + screen.mouseReleased(r.x() + 3, r.y() + 3, 0); + } + + private static void capture(String filename) { + var mc = Minecraft.getInstance(); + Screenshot.grab(mc.gameDirectory, filename, mc.getMainRenderTarget(), message -> {}); + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/NavigationClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/NavigationClientSmoke.java new file mode 100644 index 00000000..fd7065b2 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/NavigationClientSmoke.java @@ -0,0 +1,238 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; + +import net.neoforged.neoforge.network.PacketDistributor; + +import zone.moddev.mc.lib.gui.ElementActions; +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.layout.*; +import zone.moddev.mc.lib.gui.load.ScreenBinder; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.model.containers.*; +import zone.moddev.mc.lib.gui.runtime.MachineMenu; +import zone.moddev.mc.lib.gui.sync.*; + +/** Non-button controls and a non-context destination use the ordinary typed action lifecycle. */ +final class NavigationClientSmoke { + private static int phase; + private static volatile boolean reset; + private static final ElementSync SELECTED = + ElementSync.field( + ResourceLocation.parse("mmdlib_companion:navigation_selection"), + StreamCodec.of( + (buffer, value) -> buffer.writeVarInt(value), buffer -> buffer.readVarInt()), + 5, + element -> 0) + .validate((element, value) -> value == 0 || value == 1) + .affectsLayout() + .build(); + + private record ModControl(Common common, ViewRef target) implements Element { + public Optional navigation() { + return Optional.of(new Navigation(target)); + } + + public int[] measure(TextMeasure text) { + return new int[] {100, 18}; + } + } + + private record ModDestination(Common common) implements NavigableElement, SynchronizedElement { + public List> synchronizedValues() { + return List.of(SELECTED); + } + + public String defaultSelection() { + return "home"; + } + + public boolean hasSelection(String selection) { + return Set.of("home", "details").contains(selection); + } + + public String selected(ElementStateView state) { + return state.get(this, SELECTED) == 0 ? "home" : "details"; + } + + public boolean select(ElementStates state, String selection) { + return hasSelection(selection) && state.set(this, SELECTED, selection.equals("home") ? 0 : 1); + } + + public int[] measure(TextMeasure text) { + return new int[] {100, 18}; + } + } + + private static Common common(String id) { + var e = Common.empty(); + return new Common( + Optional.of(id), + e.value(), + e.max(), + e.tooltip(), + e.tooltipShift(), + e.clicks(), + e.onHover(), + e.hoverShows(), + e.jeiRecipes(), + 0, + 0, + 0); + } + + static void install() { + ElementRenderers.GLOBAL.register( + ModControl.class, + (e, c) -> { + var r = c.bounds(); + c.graphics().fill(r.x(), r.y(), r.right(), r.bottom(), 0xff406080); + c.graphics() + .drawString(c.access().font(), e.target().context(), r.x() + 2, r.y() + 4, 0xffffff); + }); + ElementRenderers.GLOBAL.register( + ModDestination.class, + (e, c) -> { + var r = c.bounds(); + c.graphics() + .drawString( + c.access().font(), + e.selected(c.access().state()), + r.x() + 2, + r.y() + 4, + 0xffffff); + }); + var id = ResourceLocation.parse("mmdlib_companion:crusher"); + var original = Screens.get(id).orElseThrow().screen(); + var body = new ArrayList<>(original.body()); + body.add( + new BoxElement( + Common.empty(), + false, + 0, + 4, + Align.START, + List.of( + new ModControl( + common("navigation_custom"), new ViewRef("navigation_destination", "details")), + new ModControl(common("navigation_builtin"), new ViewRef("panel", "settings")), + new ModDestination(common("navigation_destination"))))); + var screen = + new zone.moddev.mc.lib.gui.model.Screen( + original.id(), + original.source(), + original.title(), + original.blockEntity(), + original.playerInventory(), + body, + original.tabs()); + var screens = new HashMap<>(Screens.all()); + screens.put(id, ScreenBinder.bind(screen, NavigationClientSmoke.class.getClassLoader())); + Screens.install(screens); + } + + static boolean run(MachineScreen screen) { + if (phase > 10) return true; + var menu = screen.getMenu(); + var custom = menu.bound.screen().find("navigation_custom").orElseThrow(); + var builtin = menu.bound.screen().find("navigation_builtin").orElseThrow(); + var destination = + (NavigableElement) menu.bound.screen().find("navigation_destination").orElseThrow(); + if (menu.elementPending(custom) || menu.elementPending(builtin)) return false; + var selected = destination.selected(menu.elementStates()); + switch (phase) { + case 0 -> { + click(screen, custom); + require( + destination.selected(menu.elementStates()).equals("details"), + "Custom control predicts selection"); + } + case 1 -> { + require(selected.equals("details"), "Server accepts custom navigation destination"); + click(screen, custom); + } + case 2 -> { + require(selected.equals("home"), "Selecting the same target toggles to its default"); + click(screen, custom); + } + case 3 -> { + require(selected.equals("details"), "Custom selection confirmed before restoration"); + var server = Minecraft.getInstance().getSingleplayerServer(); + server.execute( + () -> { + var serverMenu = + (MachineMenu) server.getPlayerList().getPlayers().getFirst().containerMenu; + serverMenu.navigate("navigation_destination", "home"); + serverMenu.broadcastChanges(); + reset = true; + }); + } + case 4 -> { + if (!reset || !selected.equals("home")) return false; + screen.removed(); + screen.resize(Minecraft.getInstance(), screen.width, screen.height); + require( + menu.elementPending(custom), "Remembered custom destination uses its custom control"); + require( + destination.selected(menu.elementStates()).equals("home"), + "Restoration waits for server confirmation"); + } + case 5 -> { + require(selected.equals("details"), "Restored custom selection confirmed by server"); + click(screen, custom); + } + case 6 -> { + require(selected.equals("home"), "Custom destination reset before rejected action"); + var packet = + menu.prepareElementAction( + custom, Navigation.NAVIGATE, new ElementActions.Click(0, 0, 1)) + .orElseThrow(); + menu.navigate("navigation_destination", "details"); + PacketDistributor.sendToServer(packet); + } + case 7 -> { + require( + selected.equals("home"), "Rejected custom navigation restores authoritative selection"); + menu.prepareElementAction(custom, Navigation.NAVIGATE, new ElementActions.Click(0, 0, 0)) + .orElseThrow(); + menu.navigate("navigation_destination", "details"); + for (int i = 0; i < 100; i++) menu.tickClientActions(); + } + case 8 -> { + require(selected.equals("home"), "Custom navigation timeout restores confirmed selection"); + click(screen, builtin); + } + case 9 -> { + require( + menu.views().get("panel").equals("settings"), + "Custom control navigates a built-in context"); + click(screen, builtin); + } + default -> { + require( + menu.views().get("panel").equals("machine"), + "Custom control toggles built-in context back"); + phase = 11; + return true; + } + } + phase++; + return false; + } + + private static void click(MachineScreen screen, Element element) { + var r = screen.bounds(element); + require( + screen.mouseClicked(r.x() + 3, r.y() + 3, 0), "Custom navigation control consumes click"); + screen.mouseReleased(r.x() + 3, r.y() + 3, 0); + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/PopoutClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/PopoutClientSmoke.java new file mode 100644 index 00000000..09fff9ea --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/PopoutClientSmoke.java @@ -0,0 +1,169 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; +import java.util.function.Function; + +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.gui.WidgetTypes; +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.load.ScreenBinder; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.model.elements.PopoutElement; +import zone.moddev.mc.lib.gui.runtime.ScreenHost; +import zone.moddev.mc.lib.gui.value.*; + +/** Element-owned popout presentation using inherited production drawing and drawer behavior. */ +final class PopoutClientSmoke { + private static int stage, attempts; + private static boolean compact; + + private static class CustomPopout extends PopoutElement { + CustomPopout(PopoutElement original) { + super( + withoutGetter(original.common()), + original.kind(), + original.icon(), + original.edge(), + original.text()); + } + + public int colour() { + return 0x699fc4; + } + + public Set> valueKinds(WidgetTypes widgets) { + return Set.of(ValueTypes.WHOLE); + } + + public Optional snapshot(ScreenHost host) { + return Optional.of(new ElementSnapshot(7, null)); + } + + public List content(Object value, Function resolveText) { + var rows = new ArrayList(); + rows.add(Component.literal("Load: " + value).withStyle(ChatFormatting.BOLD)); + if (!compact) + for (int i = 0; i < 60; i++) + rows.add( + Component.literal( + "Paragraph " + i + ": this long line must wrap inside the popout body.")); + return rows; + } + } + + private static Common withoutGetter(Common c) { + return new Common( + c.id(), + Optional.empty(), + c.max(), + c.tooltip(), + c.tooltipShift(), + c.clicks(), + c.onHover(), + c.hoverShows(), + c.jeiRecipes(), + c.offsetX(), + c.offsetY(), + c.line(), + c.interaction()); + } + + static void install() { + var id = ResourceLocation.parse("mmdlib_companion:crusher"); + var old = Screens.get(id).orElseThrow().screen(); + var tabs = new ArrayList<>(old.tabs()); + var info = (PopoutElement) old.find("info").orElseThrow(); + tabs.set(tabs.indexOf(info), new CustomPopout(info)); + var model = + new Screen( + old.id(), + old.source(), + old.title(), + old.blockEntity(), + old.playerInventory(), + old.body(), + tabs, + old.origins(), + old.implicitElements()); + var screens = new HashMap<>(Screens.all()); + screens.put(id, ScreenBinder.bind(model, PopoutClientSmoke.class.getClassLoader())); + Screens.install(screens); + } + + static boolean run(MachineScreen screen) { + if (++attempts > 40) throw new AssertionError("Popout smoke stuck at " + stage); + var element = screen.getMenu().bound.screen().find("info").orElseThrow(); + var tab = screen.bounds(element); + var drawer = + screen.drawers().entries().stream() + .filter(d -> d.definition().element().orElse(null) == element) + .findFirst() + .orElseThrow(); + if (stage == 0) { + if (!java.math.BigInteger.valueOf(7).equals(screen.getMenu().value(element, false))) + return false; + if (drawer.colour() != 0x699fc4) throw new AssertionError("Element colour was ignored"); + drawer.close(); + screen.mouseClicked(tab.x() + 5, tab.y() + 5, 0); + } else if (stage == 1) { + var presentation = + screen + .presentation() + .drawerPresentation(element, PopoutRenderer.Presentation.class) + .orElseThrow(); + var first = new StringBuilder(); + presentation + .lines() + .getFirst() + .accept( + (index, style, codepoint) -> { + if (!style.isBold()) throw new AssertionError("Content style was discarded"); + first.appendCodePoint(codepoint); + return true; + }); + if (!first.toString().equals("Load: 7") || presentation.lines().size() <= 61) + throw new AssertionError("Custom numeric content was not consumed and wrapped"); + capture("popout-custom-content.png"); + var body = presentation.body(); + if (!screen.mouseScrolled(body.x() + 8, body.y() + 8, 0, -5)) + throw new AssertionError("Popout did not accept scrolling"); + } else if (stage == 2) { + var presentation = + screen + .presentation() + .drawerPresentation(element, PopoutRenderer.Presentation.class) + .orElseThrow(); + if (presentation.scroll() <= 0) throw new AssertionError("Popout did not scroll"); + compact = true; + } else if (stage == 3) { + var presentation = + screen + .presentation() + .drawerPresentation(element, PopoutRenderer.Presentation.class) + .orElseThrow(); + if (presentation.scroll() != 0 || presentation.lines().size() != 1) + throw new AssertionError("Popout scroll did not clamp after content shrank"); + capture("popout-custom-compact.png"); + screen.mouseClicked(tab.x() + 5, tab.y() + 5, 0); + } else { + if (screen + .presentation() + .drawerPresentation(element, PopoutRenderer.Presentation.class) + .isPresent()) throw new AssertionError("Popout did not close"); + return true; + } + stage++; + return false; + } + + private static void capture(String name) { + var mc = Minecraft.getInstance(); + Screenshot.grab(mc.gameDirectory, name, mc.getMainRenderTarget(), message -> {}); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/ProcessorButtonClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/ProcessorButtonClientSmoke.java index 97d634c6..aab1f897 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/ProcessorButtonClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/ProcessorButtonClientSmoke.java @@ -11,9 +11,10 @@ import zone.moddev.mc.companion.ProcessorEntity; import zone.moddev.mc.lib.api.redstone.RedstoneControl; import zone.moddev.mc.lib.gui.client.MachineScreen; -import zone.moddev.mc.lib.gui.model.Element; import zone.moddev.mc.lib.gui.model.ProcessorButtonState; +import zone.moddev.mc.lib.gui.model.elements.ProcessorButtonElement; import zone.moddev.mc.lib.gui.runtime.MachineMenu; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Real clicks and server snapshots for both directions of manual/redstone disagreement. */ final class ProcessorButtonClientSmoke { @@ -30,9 +31,8 @@ static boolean run(MachineScreen screen) { throw new AssertionError("Processor button smoke stalled at step " + step); if (step > 0 && !ready) return false; var menu = screen.getMenu(); - var button = (Element.Button) menu.bound.screen().find("toggle_0").orElseThrow(); - if (!button.processorControl()) - throw new AssertionError("Companion must use the generic control"); + var button = (ProcessorButtonElement) menu.bound.screen().find("toggle_0").orElseThrow(); + if (!button.interactive()) throw new AssertionError("Companion must use the generic control"); var wireState = menu.value(button, false); var state = ProcessorButtonState.fromWire(wireState).orElse(null); switch (step) { @@ -54,8 +54,9 @@ static boolean run(MachineScreen screen) { machine.features.setProcessorPaused("lane_0", false); }); case 1 -> { - if (state != ProcessorButtonState.PROCESSING || menu.redstone.watching()) return false; - require(!menu.processorConflict(button), "No monitoring means no red dot"); + if (state != ProcessorButtonState.PROCESSING + || menu.components().get(BuiltinMenuComponents.REDSTONE).watching()) return false; + require(!button.conflict(menu.value(button, true)), "No monitoring means no red dot"); tooltip( screen, button, @@ -74,13 +75,15 @@ static boolean run(MachineScreen screen) { }); } case 2 -> { - if (!menu.redstone.watching() - || !menu.redstone.paused() + if (!menu.components().get(BuiltinMenuComponents.REDSTONE).watching() + || !menu.components().get(BuiltinMenuComponents.REDSTONE).paused() || state != ProcessorButtonState.HELD) return false; require( button.iconFor(wireState).orElseThrow().getPath().equals("icon_processor_stop"), "A redstone-held processor retains work and shows Stop"); - require(menu.processorConflict(button), "Enabled processor conflicts with redstone hold"); + require( + button.conflict(menu.value(button, true)), + "Enabled processor conflicts with redstone hold"); tooltip( screen, button, @@ -95,7 +98,9 @@ static boolean run(MachineScreen screen) { case 3 -> { if (state != ProcessorButtonState.PAUSED) return false; require( - menu.redstone.paused() && !menu.processorConflict(button), "Both pause controls agree"); + menu.components().get(BuiltinMenuComponents.REDSTONE).paused() + && !button.conflict(menu.value(button, true)), + "Both pause controls agree"); tooltip(screen, button, "Paused", "Click to unpause"); require( button.iconFor(wireState).orElseThrow().getPath().equals("icon_processor_pause"), @@ -104,9 +109,9 @@ static boolean run(MachineScreen screen) { server(machine -> machine.redstone.outside(0, true)); } case 4 -> { - if (menu.redstone.paused()) return false; + if (menu.components().get(BuiltinMenuComponents.REDSTONE).paused()) return false; require( - state == ProcessorButtonState.PAUSED && menu.processorConflict(button), + state == ProcessorButtonState.PAUSED && button.conflict(menu.value(button, true)), "Clearing redstone preserves manual pause and shows the conflict"); tooltip( screen, @@ -121,7 +126,8 @@ static boolean run(MachineScreen screen) { case 5 -> { if (state != ProcessorButtonState.PROCESSING) return false; require( - menu.redstone.watching() && !menu.processorConflict(button), + menu.components().get(BuiltinMenuComponents.REDSTONE).watching() + && !button.conflict(menu.value(button, true)), "Both allow controls agree"); capture("processor-redstone-run.png"); server(machine -> machine.energy.setStored(0)); @@ -129,7 +135,8 @@ static boolean run(MachineScreen screen) { case 6 -> { if (state != ProcessorButtonState.STALLED) return false; require( - !menu.processorConflict(button), "Lack of power is not a manual/redstone disagreement"); + !button.conflict(menu.value(button, true)), + "Lack of power is not a manual/redstone disagreement"); tooltip( screen, button, @@ -157,7 +164,8 @@ static boolean run(MachineScreen screen) { return false; } - private static void tooltip(MachineScreen screen, Element.Button button, String... expected) { + private static void tooltip( + MachineScreen screen, ProcessorButtonElement button, String... expected) { var bounds = screen.bounds(button); var actual = screen.tooltipLines(button, bounds.x(), bounds.y()).stream() @@ -166,7 +174,7 @@ private static void tooltip(MachineScreen screen, Element.Button button, String. require(actual.equals(java.util.List.of(expected)), "Unexpected processor tooltip: " + actual); } - private static void click(MachineScreen screen, Element.Button button) { + private static void click(MachineScreen screen, ProcessorButtonElement button) { var bounds = screen.bounds(button); double x = bounds.x() + bounds.width() / 2., y = bounds.y() + bounds.height() / 2.; require(screen.mouseClicked(x, y, 0), "Generic button accepts the real click"); diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/RedstoneClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/RedstoneClientSmoke.java index 931e7d57..7aab3111 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/RedstoneClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/RedstoneClientSmoke.java @@ -2,10 +2,12 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.Screenshot; + import zone.moddev.mc.companion.*; import zone.moddev.mc.lib.api.redstone.*; import zone.moddev.mc.lib.gui.client.MachineScreen; import zone.moddev.mc.lib.gui.layout.*; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Real face controls, crossing range handles, output cycling and sibling exclusion. */ final class RedstoneClientSmoke { @@ -15,29 +17,39 @@ final class RedstoneClientSmoke { static boolean run(MachineScreen screen) { var mc = Minecraft.getInstance(); var menu = screen.getMenu(); - var model = menu.redstone; + var model = menu.components().get(BuiltinMenuComponents.REDSTONE); if (reported != step) { reported = step; zone.moddev.mc.lib.MMDLib.LOGGER.info("REDSTONE_DRAWER_STEP {}", step); } if (model == null) throw new AssertionError("Processor must expose redstone drawer"); - if (model.pending() || menu.automation != null && menu.automation.pending()) return false; + if (model.pending() + || menu.components().get(BuiltinMenuComponents.AUTOMATION) != null + && menu.components().get(BuiltinMenuComponents.AUTOMATION).pending()) return false; var panel = screen.panelBounds(); int x = panel.right() + AutomationLayout.RAIL, y = panel.y() + AutomationLayout.TOP; var tab = - new Rect( - panel.right(), - y + AutomationLayout.TAB_HEIGHT + 2, - AutomationLayout.RAIL, - AutomationLayout.TAB_HEIGHT); + screen.drawers().entries().stream() + .filter(zone.moddev.mc.lib.gui.client.RedstoneDrawer.class::isInstance) + .findFirst() + .orElseThrow() + .tabBounds(); switch (step) { case 0 -> { require(menu.bound.screen().find("redstone").isEmpty(), "Main redstone button removed"); - if (!model.open()) click(screen, tab, 0); + if (!model.open()) { + click(screen, tab, 0); + for (int tick = 0; tick < 99; tick++) model.tick(); + require(model.pending(), "Redstone remains pending before timeout"); + model.tick(); + require(model.pending(), "Redstone timeout starts a resync request"); + } } case 1 -> { if (!model.open()) return false; - require(!menu.automation.open(), "Redstone excludes inventory drawer on right"); + require( + !menu.components().get(BuiltinMenuComponents.AUTOMATION).open(), + "Redstone excludes inventory drawer on right"); click(screen, RedstoneLayout.face(model.selected()).offset(x, y), 0); } case 2 -> { @@ -168,7 +180,8 @@ static boolean run(MachineScreen screen) { 0); } case 14 -> { - if (!menu.automation.open() || model.open()) return false; + if (!menu.components().get(BuiltinMenuComponents.AUTOMATION).open() || model.open()) + return false; screen.keyPressed(256, 0, 0); server( player -> { @@ -182,7 +195,9 @@ static boolean run(MachineScreen screen) { }); } default -> { - return ready && !model.open() && !menu.automation.open(); + return ready + && !model.open() + && !menu.components().get(BuiltinMenuComponents.AUTOMATION).open(); } } step = @@ -197,13 +212,14 @@ static boolean run(MachineScreen screen) { private static void range(MachineScreen screen, int x, int y, boolean lower, int from, int to) { var bar = RedstoneLayout.bar().offset(x, y); int mouseY = lower ? bar.y() - 3 : bar.bottom() + 3; - int beforeLow = screen.getMenu().redstone.lower(0), - beforeHigh = screen.getMenu().redstone.upper(0); + int beforeLow = screen.getMenu().components().get(BuiltinMenuComponents.REDSTONE).lower(0), + beforeHigh = screen.getMenu().components().get(BuiltinMenuComponents.REDSTONE).upper(0); screen.mouseClicked(RedstoneLayout.signalX(bar, from), mouseY, 0); screen.mouseDragged(RedstoneLayout.signalX(bar, to), mouseY, 0, (to - from) * 6, 0); require( - screen.getMenu().redstone.lower(0) == beforeLow - && screen.getMenu().redstone.upper(0) == beforeHigh, + screen.getMenu().components().get(BuiltinMenuComponents.REDSTONE).lower(0) == beforeLow + && screen.getMenu().components().get(BuiltinMenuComponents.REDSTONE).upper(0) + == beforeHigh, "Dragging previews without applying early"); screen.mouseReleased(RedstoneLayout.signalX(bar, to), mouseY, 0); } diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/RedstoneSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/RedstoneSmoke.java index 2b8cd006..5791391c 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/RedstoneSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/RedstoneSmoke.java @@ -1,15 +1,18 @@ package zone.moddev.mc.companion.test; import java.util.List; + import net.minecraft.core.Direction; import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.world.item.*; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.entity.BlockEntity; + import zone.moddev.mc.companion.*; import zone.moddev.mc.lib.api.capabilities.RelativeFace; import zone.moddev.mc.lib.api.redstone.*; import zone.moddev.mc.lib.gui.runtime.*; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; final class RedstoneSmoke { static void check(GameTestHelper helper) { @@ -170,7 +173,7 @@ static void check(GameTestHelper helper) { var menu = new MachineMenu(87, player.getInventory(), machine); player.containerMenu = menu; require(menu.bound.screen().find("redstone").isEmpty(), "Old main-panel control is absent"); - var drawer = menu.redstone; + var drawer = menu.components().get(BuiltinMenuComponents.REDSTONE); drawer.action(player, request(menu, 1, RedstoneMenu.TOGGLE, 0, 0)); require(drawer.open(), "Drawer opens through session-bound action"); drawer.action(player, request(menu, 2, RedstoneMenu.MODE, 0, 1)); @@ -408,7 +411,7 @@ private static RedstoneNetwork.Action request( menu.containerId, menu.widgetSession(), seq, - menu.redstone.revision(), + menu.components().get(BuiltinMenuComponents.REDSTONE).revision(), kind, face, 0, diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/ReservationClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/ReservationClientSmoke.java index d39dd426..a21735a6 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/ReservationClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/ReservationClientSmoke.java @@ -47,12 +47,14 @@ static boolean run(MachineScreen screen) { .orElseThrow() .index; require(menu.reservationPlan(playerSlot).isNone(), "Player slots have no overlay"); + SlotTooltipClientSmoke.check(screen, 0, true); capture("reservations-counts.png"); server(player -> machine(player).items.setStackInSlot(1, ItemStack.EMPTY)); } case 2 -> { if (!menu.getSlot(1).getItem().isEmpty() || menu.reservationPlan(1).add().isEmpty()) return false; + SlotTooltipClientSmoke.check(screen, 1, false); capture("reservations-ghost.png"); var slot = menu.getSlot(1); double x = screen.panelBounds().x() + slot.x + 8; @@ -113,12 +115,9 @@ static boolean run(MachineScreen screen) { || menu.getSlot(1).getItem().getCount() != 63 || menu.getSlot(2).getItem().getCount() != 63) return false; require(menu.getSlot(2).isActive(), "Second crusher output is visible and interactive"); + var output = ((ProcessorEntity) menu.blockEntity).automation.group(1); require( - ((ProcessorEntity) menu.blockEntity) - .automation - .group(1) - .members() - .equals(java.util.List.of(1, 2)), + output.inventory().equals("output") && output.members().equals(java.util.List.of(0, 1)), "Both outputs share the same colour group"); capture("crusher-split-output-reservations.png"); server( diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java index 2e06f152..976ea6e2 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java @@ -19,6 +19,7 @@ import zone.moddev.mc.lib.MMDLib; import zone.moddev.mc.lib.gui.client.MachineScreen; import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; import zone.moddev.mc.lib.machine.neoforge.*; /** Focused real-client check of XML registration, generated layout, recipes and /reload. */ @@ -39,6 +40,7 @@ public final class SimpleCrusherClientSmoke { private static boolean recipesOpened; private static int frames; private static int tooltipStage; + private static int transferStage; @SubscribeEvent public static void tick(ClientTickEvent.Post event) { @@ -142,11 +144,64 @@ public static void rendered(RenderFrameEvent.Post event) { try { if (++frames < 30) return; var menu = screen.getMenu(); + var inventoryViews = + menu.bound.screen().elements().stream() + .filter(zone.moddev.mc.lib.gui.model.containers.InventoryElement.class::isInstance) + .map(zone.moddev.mc.lib.gui.model.containers.InventoryElement.class::cast) + .toList(); + if (inventoryViews.size() < 2) + throw new AssertionError("Generated independent inventory elements missing"); + for (var view : inventoryViews) { + var outline = + menu.elements.stream() + .filter( + zone.moddev.mc.lib.gui.model.containers.ColouredGroupElement.class::isInstance) + .map(zone.moddev.mc.lib.gui.model.containers.ColouredGroupElement.class::cast) + .filter(group -> group.child() == view) + .findFirst() + .orElseThrow(); + if (!menu.layout().bounds(outline).equals(menu.layout().bounds(view))) + throw new AssertionError("Group decoration changed inventory geometry"); + if (menu.components().get(BuiltinMenuComponents.AUTOMATION).inventory.groups().stream() + .filter(group -> group.inventory().equals(view.inventory())) + .noneMatch( + group -> + group + .highlights() + .equals(java.util.List.of(outline.common().id().orElseThrow())))) + throw new AssertionError("Automation does not highlight the inventory's outline"); + } + var seenSlots = new java.util.HashSet(); + for (var view : inventoryViews) + for (var slot : view.slots()) { + if (!seenSlots.add(menu.slotIndex(slot))) + throw new AssertionError("Local slots alias one menu index"); + var bounds = menu.layout().bounds(slot); + int[] expected = slot.itemPosition(bounds); + if (menu.slot(slot).x != expected[0] || menu.slot(slot).y != expected[1]) + throw new AssertionError("Inventory element did not position its native slot"); + } + if (menu.playerSlotElements().stream().filter(s -> s.icon().isPresent()).count() != 5) + throw new AssertionError("Shared slot appearance lost equipment placeholders"); var progress = menu.bound.screen().find("generated_progress").orElseThrow(); if (menu.value(progress, true) == null) throw new AssertionError("Progress maximum not synced"); if (MachineContent.recipeType(TYPE).recipes(mc.level.getRecipeManager()).isEmpty()) throw new AssertionError("Generated recipes not synced"); + if (!LAVA) { + var energy = menu.bound.screen().find("energy").orElseThrow(); + if (!(energy instanceof zone.moddev.mc.lib.gui.model.elements.EnergyGaugeElement) + || !(menu.value(energy, false) instanceof java.math.BigInteger stored) + || stored.signum() <= 0 + || !java.math.BigInteger.valueOf(100_000).equals(menu.value(energy, true))) + throw new AssertionError("Energy component did not synchronize"); + var tooltip = screen.tooltipLines(energy, 0, 0); + String expected = + zone.moddev.mc.lib.gui.client.ScreenText.number(menu.value(energy, false), false) + + " / 100 k FE"; + if (tooltip.size() != 1 || !tooltip.getFirst().getString().equals(expected)) + throw new AssertionError("Energy component tooltip changed: " + tooltip); + } if (LAVA) { var gauge = menu.bound.screen().find("tank_0").orElseThrow(); var gaugeBounds = screen.bounds(gauge); @@ -204,6 +259,47 @@ public static void rendered(RenderFrameEvent.Post event) { frames = 0; return; } + if (transferStage == 0) { + transferStage = 1; + frames = 0; + mc.getSingleplayerServer() + .execute( + () -> { + var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst(); + var machine = (DefinedMachineEntity) player.serverLevel().getBlockEntity(POS); + var features = machine.screenFeatures(); + features.setProcessorPaused( + features.processor(java.util.Optional.empty()).orElseThrow().key(), true); + machine.fluids.restoreFluid( + 0, + new net.neoforged.neoforge.fluids.FluidStack( + net.minecraft.world.level.material.Fluids.LAVA, 4000)); + player.getAbilities().instabuild = false; + player.containerMenu.setCarried(new ItemStack(Items.BUCKET)); + player.containerMenu.broadcastChanges(); + }); + return; + } + if (transferStage == 1 || transferStage == 2) { + var expected = transferStage == 1 ? Items.BUCKET : Items.LAVA_BUCKET; + int amount = transferStage == 1 ? 4000 : 3000; + if (!menu.getCarried().is(expected) || fluid.getAmount() != amount) return; + if (!screen.mouseClicked( + gaugeBounds.x() + 9, gaugeBounds.y() + 24, transferStage == 1 ? 1 : 0)) + throw new AssertionError("Generated gauge did not handle container click"); + transferStage++; + frames = 0; + return; + } + if (transferStage == 3) { + if (!menu.getCarried().is(Items.BUCKET) || fluid.getAmount() != 4000) return; + transferStage = 4; + Screenshot.grab( + mc.gameDirectory, NAME + "-container.png", mc.getMainRenderTarget(), message -> {}); + MMDLib.LOGGER.info( + "SIMPLE_LAVA_FURNACE_CONTAINER_PASS: right drain, left fill, cursor and tank" + + " synchronized"); + } } if (!LAVA) for (var name : java.util.List.of("cobblestone", "gravel")) diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/SimpleLavaFurnaceTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleLavaFurnaceTests.java index cd946c6f..8bafc6ab 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/SimpleLavaFurnaceTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleLavaFurnaceTests.java @@ -45,7 +45,13 @@ private static void require(boolean value, String message) { public static void tankTooltipDescribesContentsAndEachViewersContainer(GameTestHelper h) { var machine = place(h); var gauge = Screens.get(ID).orElseThrow().screen().find("tank_0").orElseThrow(); - var empty = machine.screenTankTooltip(gauge, ItemStack.EMPTY); + var displayFluid = new FluidStack(Fluids.LAVA, 250); + require(gauge.displayValue(displayFluid).equals(250), "Tank owns fluid display conversion"); + require(gauge.fillRatio(displayFluid, 1000) == .25, "Tank owns fluid fill ratio"); + require( + gauge.displayText(displayFluid, false).getString().equals("250"), "Fluid display amount"); + require(displayFluid.getAmount() == 250, "Presentation does not mutate synchronized fluid"); + var empty = gauge.tooltip(machine, ItemStack.EMPTY); require(empty.size() == 3 && empty.getFirst().getString().equals("fuel"), "Group name first"); require( ((net.minecraft.network.chat.contents.TranslatableContents) empty.get(1).getContents()) @@ -55,8 +61,8 @@ public static void tankTooltipDescribesContentsAndEachViewersContainer(GameTestH machine.fluids.fill(new FluidStack(Fluids.LAVA, 100), FluidAction.EXECUTE); var lava = new ItemStack(Items.LAVA_BUCKET); var water = new ItemStack(Items.WATER_BUCKET); - var allowed = machine.screenTankTooltip(gauge, lava); - var rejected = machine.screenTankTooltip(gauge, water); + var allowed = gauge.tooltip(machine, lava); + var rejected = gauge.tooltip(machine, water); var contents = (net.minecraft.network.chat.contents.TranslatableContents) allowed.get(1).getContents(); require( @@ -74,17 +80,17 @@ public static void tankTooltipDescribesContentsAndEachViewersContainer(GameTestH require( lava.is(Items.LAVA_BUCKET) && water.is(Items.WATER_BUCKET) - && machine.getTankFluid(0).getAmount() == 100, + && machine.fluids.getFluidInTank(0).getAmount() == 100, "Tooltip never transfers contents"); require( - machine.screenTankTooltip(gauge, new ItemStack(Items.BUCKET)).size() == 3, + gauge.tooltip(machine, new ItemStack(Items.BUCKET)).size() == 3, "Empty containers add no misleading fluid line"); var portable = new ItemStack(Items.GLASS_BOTTLE); portable.set(DataComponents.CUSTOM_NAME, net.minecraft.network.chat.Component.literal("Tank")); net.neoforged.neoforge.fluids.FluidUtil.getFluidHandler(portable) .orElseThrow() .fill(new FluidStack(Fluids.WATER, 350), FluidAction.EXECUTE); - var tankLine = machine.screenTankTooltip(gauge, portable).getLast(); + var tankLine = gauge.tooltip(machine, portable).getLast(); var tankText = (net.minecraft.network.chat.contents.TranslatableContents) tankLine.getContents(); require( @@ -105,16 +111,85 @@ public static void tankTooltipDescribesContentsAndEachViewersContainer(GameTestH var second = new zone.moddev.mc.lib.gui.runtime.MachineMenu(82, player.getInventory(), machine); first.setCarried(lava); second.setCarried(water); - require( - !first.tooltipSnapshot().equals(second.tooltipSnapshot()), + first.components().snapshot(); + second.components().snapshot(); + var firstTips = + first + .components() + .requireCapability(zone.moddev.mc.lib.gui.runtime.component.ServerTooltips.class); + var secondTips = + second + .components() + .requireCapability(zone.moddev.mc.lib.gui.runtime.component.ServerTooltips.class); + require( + firstTips.lines(gauge).equals(allowed) && secondTips.lines(gauge).equals(rejected), "Viewer-specific cursor is not shared between menus"); + ((net.minecraft.network.chat.MutableComponent) firstTips.lines(gauge).getFirst()) + .withStyle(net.minecraft.ChatFormatting.OBFUSCATED); + require( + firstTips.lines(gauge).equals(allowed), + "Reading tooltip rows cannot mutate synchronized state"); first.setCarried(ItemStack.EMPTY); + first.components().snapshot(); require( - first.tooltipSnapshot().values().stream().allMatch(value -> ((List) value).size() == 3), + firstTips.lines(gauge).size() == 3 && secondTips.lines(gauge).equals(rejected), "Removing cursor clears acceptance line"); h.succeed(); } + @GameTest(template = "empty") + public static void generatedGaugeFillsAndManuallyDrainsOnlyUnreservedFluid(GameTestHelper h) { + var machine = place(h); + var player = h.makeMockServerPlayerInLevel(); + var pos = machine.getBlockPos(); + player.setPos(pos.getX() + .5, pos.getY(), pos.getZ() + .5); + player.getAbilities().instabuild = false; + var menu = + new zone.moddev.mc.lib.gui.runtime.MachineMenu(47, player.getInventory(), machine) { + @Override + public void broadcastChanges() {} + }; + player.containerMenu = menu; + var gauge = menu.bound.screen().find("tank_0").orElseThrow(); + int index = menu.index(gauge); + var left = new zone.moddev.mc.lib.gui.runtime.ScreenNetwork.Action(47, index, 0, 0, 0, 0); + var right = new zone.moddev.mc.lib.gui.runtime.ScreenNetwork.Action(47, index, 0, 1, 0, 0); + menu.setCarried(new ItemStack(Items.WATER_BUCKET)); + menu.action(player, left); + require( + machine.fluids.getFluidInTank(0).isEmpty() && menu.getCarried().is(Items.WATER_BUCKET), + "Recipe filter rejects wrong fluid"); + menu.setCarried(new ItemStack(Items.LAVA_BUCKET)); + menu.action(player, right); + require(machine.fluids.getFluidInTank(0).isEmpty(), "Right click never pours into tank"); + menu.action(player, left); + require( + machine.fluids.getFluidInTank(0).getAmount() == 1000 && menu.getCarried().is(Items.BUCKET), + "Generated gauge pours into bound tank"); + menu.action(player, left); + require(machine.fluids.getFluidInTank(0).getAmount() == 1000, "Left click never drains tank"); + machine.fluids.setPlan( + 0, + new zone.moddev.mc.lib.machine.inventory.TankPlan( + 100, zone.moddev.mc.lib.machine.ingredient.FluidAmount.EMPTY)); + menu.action(player, right); + require( + machine.fluids.getFluidInTank(0).getAmount() == 1000 && menu.getCarried().is(Items.BUCKET), + "Bucket cannot take reserved fluid"); + machine.fluids.setPlan(0, zone.moddev.mc.lib.machine.inventory.TankPlan.NONE); + menu.action(player, right); + require( + machine.fluids.getFluidInTank(0).isEmpty() && menu.getCarried().is(Items.LAVA_BUCKET), + "Input tank allows manual bucket extraction"); + player.getAbilities().instabuild = true; + menu.action(player, left); + require( + machine.fluids.getFluidInTank(0).getAmount() == 1000 + && menu.getCarried().is(Items.LAVA_BUCKET), + "Creative transfer keeps container"); + h.succeed(); + } + @GameTest(template = "empty") public static void smeltsWithLavaAndResumesReservedCycle(GameTestHelper h) { var machine = place(h); @@ -250,7 +325,7 @@ public static void bucketsFillAndInvalidSavedFluidIsPreservedForRecovery(GameTes require( result == net.minecraft.world.ItemInteractionResult.CONSUME && player.getMainHandItem().is(Items.BUCKET) - && machine.getTankFluid(0).getAmount() == 1000, + && machine.fluids.getFluidInTank(0).getAmount() == 1000, "Bucket fills tank and returns empty bucket"); player.setItemInHand( net.minecraft.world.InteractionHand.MAIN_HAND, new ItemStack(Items.WATER_BUCKET)); @@ -267,13 +342,13 @@ public static void bucketsFillAndInvalidSavedFluidIsPreservedForRecovery(GameTes "Wrong fluid blocks vanilla bucket placement"); require( player.getMainHandItem().is(Items.WATER_BUCKET) - && machine.getTankFluid(0).getAmount() == 1000, + && machine.fluids.getFluidInTank(0).getAmount() == 1000, "Rejected bucket preserves contents"); machine.fluids.restoreFluid(0, new FluidStack(Fluids.WATER, 500)); var saved = machine.saveWithoutMetadata(h.getLevel().registryAccess()); machine.loadWithComponents(saved, h.getLevel().registryAccess()); require( - machine.getTankFluid(0).isEmpty() && !machine.fluids.recoveryContents().isEmpty(), + machine.fluids.getFluidInTank(0).isEmpty() && !machine.fluids.recoveryContents().isEmpty(), "Fluid rejected by changed tank filter is recoverable"); h.succeed(); } diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/SlotPolicyClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/SlotPolicyClientSmoke.java new file mode 100644 index 00000000..f518ea7c --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/SlotPolicyClientSmoke.java @@ -0,0 +1,243 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.*; + +import zone.moddev.mc.lib.gui.client.NativeSlotRenderer; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.model.elements.*; +import zone.moddev.mc.lib.gui.runtime.slot.*; +import zone.moddev.mc.lib.machine.inventory.SlotPlan; + +/** Real native drawing verifies policy opt-in/out independently of reservation support and role. */ +final class SlotPolicyClientSmoke { + private static int phase, frames; + private static MenuSlots.Entry[] entries; + private static final int[] overlays = new int[3]; + private static final int[] nativeDraws = new int[3]; + private static final int[] highlights = new int[3]; + private static final String[] vanillaCounts = new String[3]; + private static final NativeSlotRenderer renderer = new NativeSlotRenderer(); + + private static class Policy extends ItemSlotElement { + final int fixture; + + Policy(int fixture) { + super( + Common.empty(), + fixture, + SlotKind.NORMAL, + Appearance.standard(SlotSize.NORMAL, Optional.empty(), Optional.empty())); + this.fixture = fixture; + } + + public boolean renderNative() { + return fixture != 0; + } + + public Rect nativeBounds(Rect vanilla) { + return fixture == 1 + ? new Rect(vanilla.x() + 3, vanilla.y() + 2, 24, 12) + : super.nativeBounds(vanilla); + } + + public Rect hitBounds(Rect vanilla) { + return fixture == 2 + ? new Rect(vanilla.x() - 2, vanilla.y() + 1, 20, 10) + : super.hitBounds(vanilla); + } + + public Rect highlightBounds(Rect vanilla) { + highlights[fixture]++; + return fixture == 2 + ? new Rect(vanilla.x() - 1, vanilla.y() - 1, 18, 18) + : super.highlightBounds(vanilla); + } + + public boolean reservationOverlay(MenuSlots.Entry entry) { + return fixture != 1; + } + + public void renderOverlay( + OverlayPainter painter, ItemStack displayed, SlotPlan plan, String count) { + overlays[fixture]++; + super.renderOverlay(painter, displayed, plan, count); + if (fixture == 2) painter.quantity("custom", false); + } + } + + private static class Planned extends Slot implements SlotAdapter { + Planned(int x) { + super(new SimpleContainer(new ItemStack(Items.DIAMOND, 5)), 0, x, 24); + } + + public SlotPlan plan() { + return new SlotPlan(2, ItemStack.EMPTY); + } + } + + static boolean run() { + if (phase == 0) { + entries = + new MenuSlots.Entry[] { + new MenuSlots.Entry(new Planned(12), new Policy(0), MenuSlots.Role.AUXILIARY), + new MenuSlots.Entry(new Planned(72), new Policy(1), MenuSlots.Role.MACHINE), + new MenuSlots.Entry( + new Slot(new SimpleContainer(new ItemStack(Items.EMERALD, 3)), 0, 132, 24), + new Policy(2), + MenuSlots.Role.EQUIPMENT) + }; + var slots = new MenuSlots(); + var registrar = slots.registrar(slot -> slot.index = slots.entries().size()); + for (var entry : entries) registrar.add(entry.slot(), entry.element(), entry.role()); + for (var entry : entries) { + var slot = entry.slot(); + var vanilla = new Rect(slot.x, slot.y, 16, 16); + require( + renderer + .hitBounds(slots, slot.x, slot.y, 16, 16) + .equals(entry.element().hitBounds(vanilla)), + "Native hit bridge honors element geometry"); + } + require( + renderer.hitBounds(slots, 12, 24, 40, 9).equals(new Rect(12, 24, 40, 9)), + "Non-slot hit tests retain vanilla bounds"); + var ghost = new GhostSlotElement(Common.empty(), 0, entries[0].element().appearance()); + renderer.render( + null, + entries[0].slot(), + new MenuSlots.Entry(entries[0].slot(), ghost, MenuSlots.Role.MACHINE), + () -> { + throw new AssertionError("Default ghost draws native contents"); + }); + var customGhost = + new GhostSlotElement(Common.empty(), 0, ghost.appearance()) { + public boolean renderNative() { + return true; + } + }; + int[] draws = {0}; + renderer.render( + null, + entries[0].slot(), + new MenuSlots.Entry(entries[0].slot(), customGhost, MenuSlots.Role.MACHINE), + () -> draws[0]++); + require(draws[0] == 1, "Ghost subclasses can opt into native drawing"); + phase++; + return false; + } + if (phase == 1) { + if (frames == 0) return false; + require( + overlays[0] > 0 && overlays[1] == 0 && overlays[2] > 0, + "Slot overlay policy controls actual rendering"); + require( + "".equals(vanillaCounts[0]) + && "5".equals(vanillaCounts[1]) + && "".equals(vanillaCounts[2]), + "Disabled overlays retain vanilla quantity rendering"); + require( + nativeDraws[0] == 0 && nativeDraws[1] > 0 && nativeDraws[2] > 0, + "Native visibility is independent of slot kind and overlays"); + require(highlights[1] > 0 && highlights[2] > 0, "Native bridge requests highlight geometry"); + var mc = Minecraft.getInstance(); + Screenshot.grab( + mc.gameDirectory, "slot-overlay-policies.png", mc.getMainRenderTarget(), message -> {}); + entries = null; + phase++; + } + return true; + } + + static void render(GuiGraphics graphics) { + if (entries == null) return; + var font = Minecraft.getInstance().font; + String[] labels = {"Aux", "Hidden", "Custom"}; + for (int i = 0; i < entries.length; i++) { + final int index = i; + var entry = entries[i]; + var slot = entry.slot(); + graphics.fill(slot.x - 2, 8, slot.x + 58, 44, 0xee303030); + graphics.drawString(font, labels[i], slot.x - 2, 10, 0xffffff); + renderer.contents( + graphics, + font, + slot, + entry, + slot.getItem(), + Integer.toString(slot.getItem().getCount()), + count -> { + vanillaCounts[index] = count; + graphics.renderItem(slot.getItem(), slot.x, slot.y); + graphics.renderItemDecorations(font, slot.getItem(), slot.x, slot.y, count); + }); + } + graphics.fill(8, 48, 190, 106, 0xee303030); + String[] nativeLabels = {"No native", "Wide", "Hit area"}; + for (int i = 0; i < entries.length; i++) { + final int index = i; + var element = entries[i].element(); + var original = entries[i].slot(); + var slot = new Slot(original.container, 0, original.x, 72); + var entry = new MenuSlots.Entry(slot, element, entries[i].role()); + graphics.drawString(font, nativeLabels[i], slot.x - 2, 54, 0xffffff); + var before = new org.joml.Matrix4f(graphics.pose().last().pose()); + var target = element.nativeBounds(new Rect(slot.x, slot.y, 16, 16)); + renderer.render( + graphics, + slot, + entry, + () -> { + nativeDraws[index]++; + var actual = + graphics + .pose() + .last() + .pose() + .transformPosition(slot.x, slot.y, 0, new org.joml.Vector3f()); + var expected = + before.transformPosition(target.x(), target.y(), 0, new org.joml.Vector3f()); + require(actual.distance(expected) < .01f, "Native drawing applies element offset"); + var edge = + graphics + .pose() + .last() + .pose() + .transformPosition(slot.x + 16, slot.y + 16, 0, new org.joml.Vector3f()); + var expectedEdge = + before.transformPosition( + target.right(), target.bottom(), 0, new org.joml.Vector3f()); + require( + edge.distance(expectedEdge) < .01f, + "Native drawing applies independent width/height"); + graphics.renderItem(slot.getItem(), slot.x, slot.y); + }); + require(before.equals(graphics.pose().last().pose()), "Native renderer restores pose"); + if (i != 0) + renderer.highlight( + graphics, + slot, + entry, + 0x66ffffff, + () -> { + throw new AssertionError("Custom highlight delegated vanilla geometry"); + }); + if (i == 2) { + var hit = element.hitBounds(new Rect(slot.x, slot.y, 16, 16)); + graphics.renderOutline(hit.x(), hit.y(), hit.width(), hit.height(), 0xff00ff00); + } + } + frames++; + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/SlotTooltipClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/SlotTooltipClientSmoke.java new file mode 100644 index 00000000..8993295d --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/SlotTooltipClientSmoke.java @@ -0,0 +1,135 @@ +package zone.moddev.mc.companion.test; + +import java.util.List; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.gui.client.MachineScreen; +import zone.moddev.mc.lib.gui.client.NativeSlotRenderer; + +/** Exercises the real screen's vanilla tooltip bridge for occupied and empty reserved slots. */ +final class SlotTooltipClientSmoke { + private SlotTooltipClientSmoke() {} + + static void check(MachineScreen screen, int index, boolean filled) { + var menu = screen.getMenu(); + var entry = menu.nativeSlots().entry(index); + var slot = entry.slot(); + var element = entry.element(); + var bridge = new NativeSlotRenderer(); + require( + bridge.usesItemTooltip(menu.nativeSlots(), slot, element) == filled, + "Only filled slots route their lines through vanilla's tooltip"); + var other = menu.nativeSlots().entry(index == 0 ? 1 : 0).element(); + require( + !bridge.usesItemTooltip(menu.nativeSlots(), slot, other), + "A different element must keep its own tooltip even over a filled slot"); + List vanilla = List.of(Component.literal("vanilla")); + require( + bridge.itemTooltip( + menu.nativeSlots(), + slot, + other, + vanilla, + ignored -> { + throw new AssertionError("Unrelated tooltip was evaluated"); + }) + == vanilla, + "Unrelated elements leave the native item tooltip alone"); + require(!bridge.usesItemTooltip(menu.nativeSlots(), null, element), "No hovered native slot"); + + var bounds = screen.bounds(element); + int x = bounds.x() + bounds.width() / 2; + int y = bounds.y() + bounds.height() / 2; + var mc = Minecraft.getInstance(); + var graphics = new GuiGraphics(mc, mc.renderBuffers().bufferSource()); + screen.render(graphics, x, y, 0); + graphics.flush(); + var expected = screen.tooltipLines(element, x, y); + require(!expected.isEmpty(), "Reserved slot has element tooltip lines"); + if (!filled) return; // Empty slots render those lines as the element tooltip above. + policies(expected); + try { + var method = + MachineScreen.class.getDeclaredMethod("getTooltipFromContainerItem", ItemStack.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + var actual = (List) method.invoke(screen, slot.getItem()); + require(actual.size() > expected.size(), "Vanilla item tooltip is retained"); + require( + actual.subList(actual.size() - expected.size(), actual.size()).equals(expected), + "Slot lines append to the actual screen's item tooltip"); + for (var line : expected) + require( + actual.stream().filter(line::equals).count() == 1, "Slot tooltip line appears once"); + } catch (ReflectiveOperationException failure) { + throw new AssertionError("Cannot inspect native item tooltip", failure); + } + } + + private static void policies(List parents) { + if (Boolean.getBoolean("mmdlib.tooltipSmokeOnly")) + require( + parents.stream().anyMatch(row -> row.getString().equals("server outer")), + "Real ancestor rows reach the native bridge"); + for (int policy = 0; policy < 3; policy++) { + final int mode = policy; + var element = + new zone.moddev.mc.lib.gui.model.elements.ItemSlotElement( + zone.moddev.mc.lib.gui.model.Common.empty(), + 0, + zone.moddev.mc.lib.gui.model.SlotKind.NORMAL, + zone.moddev.mc.lib.gui.model.elements.SlotElement.Appearance.standard( + zone.moddev.mc.lib.gui.model.SlotSize.NORMAL, + java.util.Optional.empty(), + java.util.Optional.empty())) { + public List nativeTooltip(ItemStack stack, List vanilla) { + stack.setCount(2); + ((net.minecraft.network.chat.MutableComponent) vanilla.getFirst()).append(" edited"); + return switch (mode) { + case 0 -> vanilla; + case 1 -> List.of(Component.literal("replacement")); + default -> List.of(); + }; + } + }; + var slots = new zone.moddev.mc.lib.gui.runtime.slot.MenuSlots(); + var slot = + new net.minecraft.world.inventory.Slot( + new net.minecraft.world.SimpleContainer( + new ItemStack(net.minecraft.world.item.Items.DIAMOND)), + 0, + 0, + 0); + slots.registrar(value -> value.index = 0).add(slot, element, element.role()); + List vanilla = List.of(Component.literal("vanilla")); + var result = + new NativeSlotRenderer().itemTooltip(slots, slot, element, vanilla, ignored -> parents); + int nativeRows = mode == 2 ? 0 : 1; + require( + result.size() == nativeRows + parents.size(), + "Native tooltip policy controls only its own rows"); + require( + result.subList(nativeRows, result.size()).equals(parents), + "All element and parent rows survive replacement/suppression"); + if (nativeRows != 0) + require( + result.getFirst().getString().equals(mode == 0 ? "vanilla edited" : "replacement"), + "Native tooltip override applied"); + require( + slot.getItem().getCount() == 1 && vanilla.getFirst().getString().equals("vanilla"), + "Native hook receives detached inputs"); + ((net.minecraft.network.chat.MutableComponent) result.getLast()).append(" changed"); + require( + !parents.getLast().getString().endsWith(" changed"), + "Composed tooltip rows are detached"); + } + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/StatsClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/StatsClientSmoke.java index 7823daef..55eb73d8 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/StatsClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/StatsClientSmoke.java @@ -6,6 +6,7 @@ import zone.moddev.mc.lib.gui.client.MachineScreen; import zone.moddev.mc.lib.gui.layout.AutomationLayout; import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Real synced throughput rows, left drawer switching and the cycling timescale control. */ final class StatsClientSmoke { @@ -29,17 +30,25 @@ static boolean run(MachineScreen screen) { var mc = Minecraft.getInstance(); switch (step) { case 0 -> { - if (menu.stats(0).isEmpty() - || menu.containerDrawer.pending() - || menu.curiosDrawer.pending()) return false; + if (menu.components().require(BuiltinMenuComponents.STATS).pages().isEmpty() + || menu.components() + .require(BuiltinMenuComponents.STATS) + .pages() + .get(0) + .lines() + .isEmpty() + || menu.components().get(BuiltinMenuComponents.BACKPACK).pending() + || menu.components().get(BuiltinMenuComponents.CURIOS).pending()) return false; int processors = name.contains("dual") ? 2 : 1; boolean consuming = - menu.stats(0).stream().anyMatch(c -> c.getString().startsWith("RF used:")); - if (menu.stats(0).size() != (consuming ? 5 : 3) + 4 * processors) + menu.components().require(BuiltinMenuComponents.STATS).pages().get(0).lines().stream() + .anyMatch(c -> c.getString().startsWith("RF used:")); + if (menu.components().require(BuiltinMenuComponents.STATS).pages().get(0).lines().size() + != (consuming ? 5 : 3) + 4 * processors) throw new AssertionError( "Statistics should contain directional energy rows, conditional usage and two rows" + " under each processor header"); - if (menu.stats(0).stream() + if (menu.components().require(BuiltinMenuComponents.STATS).pages().get(0).lines().stream() .filter( c -> c.getString().contains("cycles/t") @@ -51,7 +60,8 @@ static boolean run(MachineScreen screen) { } case 1 -> { // Opening Info can close a restored equipment drawer through a server round trip. - if (menu.containerDrawer.pending() || menu.curiosDrawer.pending()) return false; + if (menu.components().get(BuiltinMenuComponents.BACKPACK).pending() + || menu.components().get(BuiltinMenuComponents.CURIOS).pending()) return false; click(screen, tab.x() + 5, tab.y() + 5, 0); } case 2 -> { @@ -107,9 +117,11 @@ static boolean run(MachineScreen screen) { private static void clickUnit(MachineScreen screen, int button) { try { - var field = MachineScreen.class.getDeclaredField("stats"); - field.setAccessible(true); - var drawer = field.get(screen); + var drawer = + screen.drawers().entries().stream() + .filter(zone.moddev.mc.lib.gui.client.StatsDrawer.class::isInstance) + .findFirst() + .orElseThrow(); var layout = drawer.getClass().getDeclaredMethod("layout"); layout.setAccessible(true); layout.invoke(drawer); @@ -123,23 +135,16 @@ private static void clickUnit(MachineScreen screen, int button) { } private static void requireUnit(MachineScreen screen, int expected) { - try { - var field = MachineScreen.class.getDeclaredField("stats"); - field.setAccessible(true); - var drawer = field.get(screen); - var unit = drawer.getClass().getDeclaredField("unit"); - unit.setAccessible(true); - if (unit.getInt(drawer) != expected) - throw new AssertionError( - "Timescale did not cycle to " - + expected - + "; actual=" - + unit.getInt(drawer) - + "; screen=" - + screen.getMenu().bound.screen().id()); - } catch (ReflectiveOperationException error) { - throw new AssertionError(error); - } + var drawer = + (zone.moddev.mc.lib.gui.client.StatsDrawer) + screen.drawers().entries().stream() + .filter(zone.moddev.mc.lib.gui.client.StatsDrawer.class::isInstance) + .findFirst() + .orElseThrow(); + var selected = drawer.selectedPage(); + var expectedId = zone.moddev.mc.lib.api.stats.StatsTimescale.DEFAULTS.get(expected).id(); + if (!selected.equals(java.util.Optional.of(expectedId))) + throw new AssertionError("Wrong statistics page: " + selected + ", expected " + expectedId); } private static void click(MachineScreen screen, int x, int y, int button) { diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/StatsExtensionClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/StatsExtensionClientSmoke.java new file mode 100644 index 00000000..c4f3564d --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/StatsExtensionClientSmoke.java @@ -0,0 +1,176 @@ +package zone.moddev.mc.companion.test; + +import java.math.BigInteger; +import java.util.*; + +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.api.stats.*; +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.runtime.*; +import zone.moddev.mc.lib.gui.runtime.component.*; + +/** + * A host-provided custom type and fourth timescale, through the production sampler and wire field. + */ +final class StatsExtensionClientSmoke { + private static final StatsTimescale HOUR = + new StatsTimescale( + ResourceLocation.parse("test:hour"), + 72000, + Component.literal("Per hour"), + Component.literal("h")); + private static final List ALL = + List.of(StatsTimescale.TICK, StatsTimescale.SECOND, StatsTimescale.MINUTE, HOUR); + private static volatile List timescales = ALL; + private static int step, attempts; + + private record CustomValue(int amount) implements Statistics.Value { + public Statistics.Sample sample(StatsSamplingContext context, Statistics.Sample previous) { + var squared = BigInteger.valueOf(amount).pow(2); + return format -> + format + .number(StatsNumbers.rate(squared, 1, format.ticks(), format.decimals())) + .copy() + .withStyle(ChatFormatting.DARK_PURPLE); + } + } + + private static final StatsProvider CUSTOM = + () -> + new Statistics( + Component.literal("Custom sensor"), + List.of( + new Statistics.Row( + "square", + Component.literal("Squared load"), + new CustomValue(7), + Component.literal("units"), + 0, + true))); + + static void install() { + // Inject only the test host through the existing component factory. Statistic types and + // timescales themselves have no registration, exactly as in a real ScreenHost implementation. + MenuComponentRegistry.GLOBAL.replace( + BuiltinMenuComponents.STATS, + 20, + c -> { + var host = + new ScreenHost() { + public net.neoforged.neoforge.items.IItemHandlerModifiable screenItems() { + return null; + } + + public List screenStats() { + var providers = new ArrayList(c.host().screenStats()); + providers.add(CUSTOM); + return providers; + } + + public List screenStatsTimescales() { + return timescales; + } + }; + return new StatsMenuComponent( + new MenuComponentRegistry.Context(c.menu(), c.inventory(), host, c.client())); + }, + StatsMenuComponent.PAGES); + } + + static boolean run(MachineScreen screen) { + if (++attempts > 240) throw new AssertionError("Statistics extension stuck at " + step); + var pages = screen.getMenu().components().require(BuiltinMenuComponents.STATS).pages(); + var drawer = + (StatsDrawer) + screen.drawers().entries().stream() + .filter(StatsDrawer.class::isInstance) + .findFirst() + .orElseThrow(); + if (step == 0) { + if (pages.size() != 4 || screen.drawers().pending()) return false; + if (pages.get(3).lines().stream() + .noneMatch(row -> row.getString().equals("Squared load: 3,528,000 units/h"))) + throw new AssertionError("Custom statistic was not sampled and synchronized"); + drawer.close(); + var tab = drawer.tabBounds(); + click(screen, tab, 0); + } else if (step == 1) { + requireSelected(drawer, StatsTimescale.TICK.id()); + clickUnit(screen, drawer, 0); + requireSelected(drawer, StatsTimescale.SECOND.id()); + clickUnit(screen, drawer, 0); + requireSelected(drawer, StatsTimescale.MINUTE.id()); + clickUnit(screen, drawer, 0); + requireSelected(drawer, HOUR.id()); + } else if (step == 2) { + capture("statistics-custom-hour.png"); + timescales = List.of(HOUR, StatsTimescale.MINUTE, StatsTimescale.TICK, StatsTimescale.SECOND); + } else if (step == 3) { + if (pages.isEmpty() || !pages.getFirst().id().equals(HOUR.id())) return false; + requireSelected(drawer, HOUR.id()); + clickUnit(screen, drawer, 0); + requireSelected(drawer, StatsTimescale.MINUTE.id()); + timescales = List.of(StatsTimescale.SECOND); + } else if (step == 4) { + if (pages.size() != 1) return false; + requireSelected(drawer, StatsTimescale.SECOND.id()); + clickUnit(screen, drawer, 1); + requireSelected(drawer, StatsTimescale.SECOND.id()); + timescales = List.of(); + } else if (step == 5) { + if (!pages.isEmpty()) return false; + if (drawer.selectedPage().isPresent()) + throw new AssertionError("Removed page remained selected"); + clickUnit(screen, drawer, 0); + capture("statistics-empty-pages.png"); + timescales = ALL; + } else if (step == 6) { + if (pages.size() != 4) return false; + requireSelected(drawer, StatsTimescale.TICK.id()); + clickUnit(screen, drawer, 1); + requireSelected(drawer, HOUR.id()); + clickUnit(screen, drawer, 0); + requireSelected(drawer, StatsTimescale.TICK.id()); + screen.keyPressed(256, 0, 0); + if (drawer.open() || Minecraft.getInstance().screen != screen) + throw new AssertionError("Escape failed to close only statistics"); + return true; + } + step++; + return false; + } + + private static void requireSelected(StatsDrawer drawer, ResourceLocation id) { + if (!drawer.selectedPage().equals(Optional.of(id))) + throw new AssertionError("Wrong page: " + drawer.selectedPage() + ", expected " + id); + } + + private static void clickUnit(MachineScreen screen, StatsDrawer drawer, int button) { + drawer.selectedPage(); // refresh geometry and selection + try { + var method = StatsDrawer.class.getDeclaredMethod("unitBounds"); + method.setAccessible(true); + click(screen, (Rect) method.invoke(drawer), button); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } + + private static void click(MachineScreen screen, Rect bounds, int button) { + int x = bounds.x() + bounds.width() / 2, y = bounds.y() + bounds.height() / 2; + if (!screen.mouseClicked(x, y, button)) + throw new AssertionError("Statistics click was ignored"); + screen.mouseReleased(x, y, button); + } + + private static void capture(String name) { + var mc = Minecraft.getInstance(); + Screenshot.grab(mc.gameDirectory, name, mc.getMainRenderTarget(), message -> {}); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/TooltipClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/TooltipClientSmoke.java new file mode 100644 index 00000000..3e9f1ed5 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/TooltipClientSmoke.java @@ -0,0 +1,197 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.layout.*; +import zone.moddev.mc.lib.gui.load.ScreenBinder; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.model.containers.BoxElement; +import zone.moddev.mc.lib.gui.runtime.ScreenHost; + +/** Real leaf/ancestor composition plus existing occupied/empty native slot regression checks. */ +final class TooltipClientSmoke { + record Leaf(Common common) implements Element { + public int[] measure(TextMeasure text) { + return new int[] {140, 14}; + } + + public Optional snapshot(ScreenHost host) { + return Optional.of(new ElementSnapshot(7, 9)); + } + + public List tooltip(ScreenHost host, ItemStack carried) { + return List.of(Component.literal("server leaf")); + } + } + + record Group(Common common, Element child, String label) implements Element { + public List children() { + return List.of(child); + } + + public int[] measure(TextMeasure text) { + return child.measure(text); + } + + public Map childLayout(Rect r, TextMeasure text) { + return Map.of(child, new Rect(0, 0, r.width(), r.height())); + } + + public List tooltip(ScreenHost host, ItemStack carried) { + return List.of(Component.literal("server " + label)); + } + + public void appendChildTooltip(TooltipContext c, List rows) { + rows.addAll(c.serverLines()); + rows.add( + Component.literal(label + " for " + c.target().common().id().orElse("slot")) + .withStyle(ChatFormatting.GOLD)); + } + } + + private static boolean checked; + private static boolean captureRequested, captured; + + static void render(MachineScreen screen, net.minecraft.client.gui.GuiGraphics graphics) { + if (!captureRequested || captured) return; + var leaf = screen.getMenu().bound.screen().find("tooltip_probe").orElseThrow(); + var r = screen.bounds(leaf); + screen.render(graphics, r.x() + 2, r.y() + 2, 0); + graphics.flush(); + var mc = Minecraft.getInstance(); + Screenshot.grab( + mc.gameDirectory, "tooltip-composition.png", mc.getMainRenderTarget(), ignored -> {}); + captured = true; + } + + static void install() { + ContributedHintClientSmoke.install(); + ElementRenderers.GLOBAL.register(Group.class, (e, c) -> {}); + ElementRenderers.GLOBAL.register( + Leaf.class, + (e, c) -> + c.graphics() + .drawString( + c.access().font(), + "Hover for composed tooltip", + c.bounds().x(), + c.bounds().y(), + 0x404040, + false)); + ElementBehaviors.GLOBAL.register( + Group.class, + (e, a) -> + new DefaultElementBehavior(a) { + public void appendChildTooltip(TooltipContext c, List rows) { + rows.add(Component.literal("behavior " + e.label())); + } + }); + ElementBehaviors.GLOBAL.register( + Leaf.class, + (e, a) -> + new DefaultElementBehavior(a) { + public List tooltip(int x, int y) { + return List.of(Component.literal("behavior leaf")); + } + }); + var id = ResourceLocation.parse("mmdlib_companion:crusher"); + var old = Screens.get(id).orElseThrow().screen(); + var c = Common.empty(); + var common = + new Common( + Optional.of("tooltip_probe"), + c.value(), + c.max(), + Optional.of(new Text("test.tooltip", Optional.of("Leaf {value}/{max}"))), + c.tooltipShift(), + c.clicks(), + c.onHover(), + c.hoverShows(), + c.jeiRecipes(), + 0, + 0, + 0); + var body = new ArrayList(); + body.add(new Leaf(common)); + body.addAll(old.body()); + var panel = new BoxElement(Common.empty(), true, 0, 4, Align.START, body); + var wrapper = new Group(Common.empty(), new Group(Common.empty(), panel, "inner"), "outer"); + var screen = + new Screen( + old.id(), + old.source(), + old.title(), + old.blockEntity(), + old.playerInventory(), + List.of(wrapper), + old.tabs()); + var screens = new HashMap<>(Screens.all()); + screens.put(id, ScreenBinder.bind(screen, TooltipClientSmoke.class.getClassLoader())); + Screens.install(screens); + } + + static boolean run(MachineScreen screen) { + if (!ContributedHintClientSmoke.run(screen)) return false; + if (!checked) { + var leaf = screen.getMenu().bound.screen().find("tooltip_probe").orElseThrow(); + var r = screen.bounds(leaf); + var rows = + screen.tooltipLines(leaf, r.x() + 2, r.y() + 2).stream() + .map(Component::getString) + .toList(); + if (!rows.contains("server outer")) return false; + var expected = + List.of( + "server leaf", + "Leaf 7/9", + "behavior leaf", + "server inner", + "inner for tooltip_probe", + "behavior inner", + "server outer", + "outer for tooltip_probe", + "behavior outer"); + if (!rows.equals(expected)) throw new AssertionError("Tooltip composition: " + rows); + if (!captured) { + captureRequested = true; + return false; + } + var fragment = + new ElementFragment( + new Group(Common.empty(), leaf, "fragment"), + 140, + new ElementFragment.Host() { + public ResourceLocation screenId() { + return screen.getMenu().bound.screen().id(); + } + + public Object value(Element element, boolean maximum) { + return maximum ? 9 : 7; + } + }); + try { + fragment.place(10, 20, new Rect(10, 20, 100, 14)); + var fragmentRows = fragment.tooltip(12, 22).stream().map(Component::getString).toList(); + if (!fragmentRows.equals( + List.of( + "Leaf 7/9", "behavior leaf", "fragment for tooltip_probe", "behavior fragment"))) + throw new AssertionError("Fragment tooltip composition: " + fragmentRows); + if (!fragment.tooltip(120, 22).isEmpty()) + throw new AssertionError("Tooltip outside clipped fragment"); + } finally { + fragment.removed(); + } + checked = true; + } + return ReservationClientSmoke.run(screen); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/ValueTypeClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/ValueTypeClientSmoke.java new file mode 100644 index 00000000..14d6cef7 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/ValueTypeClientSmoke.java @@ -0,0 +1,148 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.gui.client.*; +import zone.moddev.mc.lib.gui.load.ScreenBinder; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.runtime.ScreenValues; + +/** Custom getter snapshots traverse real delta packets and accepted/rejected action replies. */ +final class ValueTypeClientSmoke { + private static int phase, drawn, accepted, rejected; + private static ValueTypeFixture.Reading first; + + static void install() { + ElementRenderers.GLOBAL.register( + ValueTypeFixture.Display.class, + (e, c) -> { + var value = (ValueTypeFixture.Reading) c.access().value(e, false); + if (value != null) drawn++; + c.graphics() + .drawString( + c.access().font(), + "Mod reading: " + (value == null ? "--" : value.amount), + c.bounds().x(), + c.bounds().y(), + 0xffffff); + }); + ElementBehaviors.GLOBAL.register( + ValueTypeFixture.Display.class, + (e, access) -> + new ElementBehavior() { + public void actionResult(boolean ok) { + var value = (ValueTypeFixture.Reading) access.value(); + require( + value != null && value.amount == 7, + "Action result did not restore the custom value before callbacks"); + if (ok) accepted++; + else rejected++; + } + }); + var id = ResourceLocation.parse("mmdlib_companion:crusher"); + var old = Screens.get(id).orElseThrow().screen(); + var c = Common.empty(); + var binding = + new Common( + Optional.of("mod_reading"), + Optional.of(ValueRef.parse(ValueTypeFixture.class.getName() + "#reading")), + c.max(), + c.tooltip(), + c.tooltipShift(), + c.clicks(), + c.onHover(), + c.hoverShows(), + c.jeiRecipes(), + 0, + 0, + 0); + var body = new ArrayList<>(old.body()); + body.add(new ValueTypeFixture.Display(binding)); + var screen = + new Screen( + old.id(), + old.source(), + old.title(), + old.blockEntity(), + old.playerInventory(), + body, + old.tabs()); + var screens = new HashMap<>(Screens.all()); + screens.put(id, ScreenBinder.bind(screen, ValueTypeClientSmoke.class.getClassLoader())); + Screens.install(screens); + } + + static boolean run(MachineScreen screen) { + var menu = screen.getMenu(); + var element = menu.bound.screen().find("mod_reading").orElseThrow(); + var value = (ValueTypeFixture.Reading) menu.value(element, false); + if (menu.elementPending(element)) return false; + switch (phase) { + case 0 -> { + if (value == null || drawn == 0) return false; + require( + value.amount == 3 && value != ValueTypeFixture.live, + "Initial custom snapshot is not detached"); + first = value; + require( + ScreenValues.changed( + Map.of(0, value), Map.of(0, new ValueTypeFixture.Reading(3)), false) + .isEmpty(), + "Registered content equality sends unchanged values"); + require( + screen.submitElementAction(element, ValueTypeFixture.EDIT, 7), + "Custom action was not sent"); + } + case 1 -> { + if (accepted == 0) return false; + require( + value.amount == 7 && first.amount == 3, + "Mutable getter corrupted its previous snapshot"); + menu.apply(Map.of(menu.index(element) * 2, new ValueTypeFixture.Reading(99))); + require( + screen.submitElementAction(element, ValueTypeFixture.EDIT, -1), + "Rejected action was not sent"); + } + case 2 -> { + if (rejected == 0) return false; + require(value.amount == 7, "Rejected action did not correct custom value"); + Minecraft.getInstance() + .getSingleplayerServer() + .execute(() -> ValueTypeFixture.live.amount = 11); + } + case 3 -> { + if (value == null || value.amount != 11) return false; + var mc = Minecraft.getInstance(); + Screenshot.grab( + mc.gameDirectory, "custom-value.png", mc.getMainRenderTarget(), ignored -> {}); + mc.getSingleplayerServer().execute(() -> ValueTypeFixture.live = null); + } + case 4 -> { + if (value != null) return false; + Minecraft.getInstance() + .getSingleplayerServer() + .execute(() -> ValueTypeFixture.live = new ValueTypeFixture.Reading(13)); + } + case 5 -> { + if (value == null || value.amount != 13) return false; + require( + menu.value(menu.bound.screen().find("energy").orElseThrow(), false) + instanceof java.math.BigInteger, + "Built-in gauge values stopped synchronizing beside custom values"); + return true; + } + default -> throw new AssertionError("Unexpected value-type smoke phase"); + } + phase++; + return false; + } + + private static void require(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/ValueTypeFixture.java b/src/companionTest/java/zone/moddev/mc/companion/test/ValueTypeFixture.java new file mode 100644 index 00000000..6dad2464 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/ValueTypeFixture.java @@ -0,0 +1,75 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.entity.BlockEntity; + +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.fml.event.lifecycle.FMLConstructModEvent; + +import zone.moddev.mc.companion.CompanionMod; +import zone.moddev.mc.lib.gui.ElementAction; +import zone.moddev.mc.lib.gui.WidgetTypes; +import zone.moddev.mc.lib.gui.layout.TextMeasure; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.value.*; + +/** Common-only fixture registers before screen loading, exactly as an external mod would. */ +@EventBusSubscriber(modid = CompanionMod.ID, bus = EventBusSubscriber.Bus.MOD) +public final class ValueTypeFixture { + public static final class Reading { + int amount; + + Reading(int amount) { + this.amount = amount; + } + } + + static Reading live = new Reading(3); + static final ValueType TYPE = + ValueType.builder( + ResourceLocation.parse("mmdlib_companion:test_reading"), + Reading.class, + () -> StreamCodec.of((b, v) -> b.writeInt(v.amount), b -> new Reading(b.readInt())), + 4) + .validate(v -> v.amount >= 0) + .equality((a, b) -> a.amount == b.amount) + .build(); + + @SubscribeEvent + public static void register(FMLConstructModEvent event) { + ValueTypeRegistry.GLOBAL.register(TYPE); + } + + public static Reading reading(BlockEntity ignored) { + return live; + } + + static final ElementAction EDIT = + new ElementAction<>( + ResourceLocation.parse("mmdlib_companion:test_reading_edit"), + StreamCodec.of((b, v) -> b.writeInt(v), b -> b.readInt()), + 4, + (context, value) -> { + if (value < 0) return false; + live.amount = value; + return true; + }); + + record Display(Common common) implements Element { + public Set> valueKinds(WidgetTypes widgets) { + return Set.of(TYPE); + } + + public List> actions() { + return List.of(EDIT); + } + + public int[] measure(TextMeasure text) { + return new int[] {140, 14}; + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java index 2f337963..23017ce0 100644 --- a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java +++ b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java @@ -15,7 +15,6 @@ import net.neoforged.neoforge.capabilities.BlockCapability; import net.neoforged.neoforge.capabilities.BlockCapabilityCache; import net.neoforged.neoforge.items.IItemHandler; -import net.neoforged.neoforge.items.ItemStackHandler; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; @@ -45,7 +44,7 @@ public final class AutomationInventory { private CompoundTag unknown = new CompoundTag(); private CompoundTag transfers = new CompoundTag(); private boolean loading; - private final ItemStackHandler slots; + private final ItemInventories slots; public AutomationInventory(BlockEntity owner, List> groups) { if (groups.isEmpty() || groups.size() > MAX_GROUPS) @@ -55,60 +54,74 @@ public AutomationInventory(BlockEntity owner, List> groups) { generation = new long[groups.size() * COLUMNS]; revision = new long[generation.length]; var membership = new HashSet(); + var ambiguous = new HashSet(); for (int i = 0; i < groups.size(); i++) { var group = groups.get(i); if (ids.putIfAbsent(group.id(), i) != null) throw new IllegalArgumentException("Duplicate group ID"); for (int member : group.members()) - if (!membership.add(group.type().id() + "/" + member)) + if (!membership.add(group.type().id() + "/" + group.inventory() + "/" + member)) throw new IllegalArgumentException("Overlapping resource membership: " + group.id()); var indexed = members.computeIfAbsent(group.type(), ignored -> new Int2ObjectOpenHashMap<>()); - for (int member : group.members()) indexed.put(member, group); + for (int member : group.members()) { + String key = group.type().id() + "/" + member; + if (indexed.containsKey(member)) { + indexed.remove(member); + ambiguous.add(key); + } else if (!ambiguous.contains(key)) indexed.put(member, group); + } group.sides().onChange(this::viewChanged); } - slots = - new ItemStackHandler(groups.size() * SLOTS_PER_GROUP) { - @Override - public int getSlotLimit(int slot) { - return 1; - } + var inventories = new LinkedHashMap(); + for (int g = 0; g < groups.size(); g++) { + final int groupIndex = g; + inventories.put( + groups.get(g).id(), + new ItemStorage(SLOTS_PER_GROUP) { + @Override + public int getSlotLimit(int slot) { + return 1; + } - @Override - public boolean isItemValid(int slot, ItemStack stack) { - return accepts(slot, stack); - } + @Override + public boolean isItemValid(int slot, ItemStack stack) { + return accepts(groupIndex * SLOTS_PER_GROUP + slot, stack); + } - @Override - public void setStackInSlot(int slot, ItemStack stack) { - if (stack.getCount() > 1) - throw new IllegalArgumentException("One addon/filter per slot"); - if (!loading && !stack.isEmpty() && !accepts(slot, stack)) - throw new IllegalArgumentException("Incompatible or duplicate addon/filter role"); - // Even replacing an identical stack is a new attachment generation. - if (!loading && slot % SLOTS_PER_GROUP < COLUMNS) - detach(slot / SLOTS_PER_GROUP, slot % COLUMNS); - super.setStackInSlot(slot, stack); - } + @Override + public void setStackInSlot(int slot, ItemStack stack) { + if (stack.getCount() > 1) + throw new IllegalArgumentException("One addon/filter per slot"); + if (!loading + && !stack.isEmpty() + && !accepts(groupIndex * SLOTS_PER_GROUP + slot, stack)) + throw new IllegalArgumentException("Incompatible or duplicate addon/filter role"); + // Even replacing an identical stack is a new attachment generation. + if (!loading && slot % SLOTS_PER_GROUP < COLUMNS) detach(groupIndex, slot % COLUMNS); + super.setStackInSlot(slot, stack); + } - @Override - protected void onContentsChanged(int slot) { - if (loading) return; - int group = slot / SLOTS_PER_GROUP, column = slot % COLUMNS; - dirtyGroups.set(group); - int key = group * COLUMNS + column; - if (slot % SLOTS_PER_GROUP < COLUMNS) { - generation[key]++; - // extractItem mutates without calling setStackInSlot. - var addon = addon(group, column); - var filter = filter(group, column); - if (!filter.isEmpty() && addon.isEmpty()) detach(group, column); - } else { - generation[key]++; - revision[key]++; + @Override + protected void onContentsChanged(int slot) { + if (loading) return; + int group = groupIndex, column = slot % COLUMNS; + dirtyGroups.set(group); + int key = group * COLUMNS + column; + if (slot % SLOTS_PER_GROUP < COLUMNS) { + generation[key]++; + // extractItem mutates without calling setStackInSlot. + var addon = addon(group, column); + var filter = AutomationInventory.this.filter(group, column); + if (!filter.isEmpty() && addon.isEmpty()) detach(group, column); + } else { + generation[key]++; + revision[key]++; + } + viewChanged(); } - viewChanged(); - } - }; + }); + } + slots = new ItemInventories(inventories); } public BlockEntity owner() { @@ -127,7 +140,7 @@ public int index(String id) { return ids.getOrDefault(id, -1); } - public ItemStackHandler slots() { + public ItemInventories slots() { return slots; } @@ -273,7 +286,7 @@ private boolean acceptsFilter(ItemStack addon, ItemStack filter) { } private void detach(int group, int column) { - var filter = filter(group, column); + var filter = AutomationInventory.this.filter(group, column); if (!filter.isEmpty()) { recovery.add(filter.copy()); slots.setStackInSlot(filterSlot(group, column), ItemStack.EMPTY); @@ -366,6 +379,20 @@ public IoMode mode(ResourceType type, int member, net.minecraft.core.Directio return group == null ? IoMode.DISABLED : group.mode(side); } + /** Use a named inventory when several handlers have the same local slot numbers. */ + public IoMode mode( + ResourceType type, String inventory, int member, net.minecraft.core.Direction side) { + return groups.stream() + .filter( + group -> + group.type() == type + && group.inventory().equals(inventory) + && group.contains(member)) + .findFirst() + .map(group -> group.mode(side)) + .orElse(IoMode.DISABLED); + } + public CompoundTag save(HolderLookup.Provider registries) { var data = unknown.copy(); for (int i = 0; i < groups.size(); i++) { diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/FilterControl.java b/src/main/java/zone/moddev/mc/lib/api/automation/FilterControl.java index 15b364fd..0ddde67f 100644 --- a/src/main/java/zone/moddev/mc/lib/api/automation/FilterControl.java +++ b/src/main/java/zone/moddev/mc/lib/api/automation/FilterControl.java @@ -1,72 +1,580 @@ package zone.moddev.mc.lib.api.automation; -import java.util.List; +import java.util.*; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.*; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; -/** Common-side declarative editor fragment. Ghost cells never create real inventory slots. */ -public sealed interface FilterControl { +import zone.moddev.mc.lib.gui.layout.*; +import zone.moddev.mc.lib.gui.model.Common; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.SlotSize; +import zone.moddev.mc.lib.gui.model.elements.GhostSlotElement; +import zone.moddev.mc.lib.gui.model.elements.SlotElement; + +/** An open element contract for item-provided editors. Each control validates its own edits. */ +public interface FilterControl extends Element { String id(); - record Toggle( - String id, - ResourceLocation icon, - ResourceLocation selectedIcon, - Component tooltip, - Component selectedTooltip) - implements FilterControl { + default Common common() { + return new Common( + Optional.of(id()), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Common.Clicks.NONE, + Optional.empty(), + Optional.empty(), + List.of(), + 0, + 0, + 0); + } + + default int[] measure(TextMeasure text) { + return new int[] {18, 18}; + } + + default boolean stretchX() { + return true; + } + + /** Server-only candidate data, after the host has authorized the attachment and revision. */ + record Edit( + InventoryFilter provider, + ItemStack filter, + CompoundTag configuration, + int index, + String value, + ItemStack cursor, + HolderLookup.Provider registries) {} + + /** Refuse edits unless this control explicitly validates and applies the proposed value. */ + default boolean edit(Edit edit) { + return false; + } + + class Toggle implements FilterControl { + private final String id; + private final ResourceLocation icon; + private final ResourceLocation selectedIcon; + private final Component tooltip; + private final Component selectedTooltip; + + public Toggle( + String id, + ResourceLocation icon, + ResourceLocation selectedIcon, + Component tooltip, + Component selectedTooltip) { + this.id = id; + this.icon = icon; + this.selectedIcon = selectedIcon; + this.tooltip = tooltip; + this.selectedTooltip = selectedTooltip; + } + public Toggle(String id, ResourceLocation icon, Component tooltip) { this(id, icon, icon, tooltip, tooltip); } + + public int[] measure(TextMeasure text) { + return new int[] {14, 14}; + } + + public boolean stretchX() { + return false; + } + + public boolean edit(Edit edit) { + edit.configuration().putBoolean(id, !edit.configuration().getBoolean(id)); + return true; + } + + public String id() { + return id; + } + + public ResourceLocation icon() { + return icon; + } + + public ResourceLocation selectedIcon() { + return selectedIcon; + } + + public Component tooltip() { + return tooltip; + } + + public Component selectedTooltip() { + return selectedTooltip; + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + var other = (Toggle) object; + return java.util.Objects.equals(id, other.id) + && java.util.Objects.equals(icon, other.icon) + && java.util.Objects.equals(selectedIcon, other.selectedIcon) + && java.util.Objects.equals(tooltip, other.tooltip) + && java.util.Objects.equals(selectedTooltip, other.selectedTooltip); + } + + @Override + public int hashCode() { + int hash = 0; + hash = 31 * hash + java.util.Objects.hashCode(id); + hash = 31 * hash + java.util.Objects.hashCode(icon); + hash = 31 * hash + java.util.Objects.hashCode(selectedIcon); + hash = 31 * hash + java.util.Objects.hashCode(tooltip); + hash = 31 * hash + java.util.Objects.hashCode(selectedTooltip); + return hash; + } + + @Override + public String toString() { + return "Toggle[id=" + + id + + ", icon=" + + icon + + ", selectedIcon=" + + selectedIcon + + ", tooltip=" + + tooltip + + ", selectedTooltip=" + + selectedTooltip + + "]"; + } } - /** A registered leaf can provide specialised rendering/input and a typed, validated action. */ - record Custom(String id, FilterWidgetType type, int height) implements FilterControl { - public Custom { + /** + * Compatibility element for a registered filter widget; it uses shared behavior/render routing. + */ + class Custom implements FilterControl { + private final String id; + private final FilterWidgetType type; + private final int height; + + public Custom(String id, FilterWidgetType type, int height) { if (height < 1 || height > 256) throw new IllegalArgumentException("Custom control height"); - java.util.Objects.requireNonNull(type); + Objects.requireNonNull(type); + + this.id = id; + this.type = type; + this.height = height; + } + + public int[] measure(TextMeasure text) { + return new int[] {18, height}; + } + + public boolean edit(Edit edit) { + return type.dispatch(edit.configuration(), edit.value()); + } + + public String id() { + return id; + } + + public FilterWidgetType type() { + return type; + } + + public int height() { + return height; + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + var other = (Custom) object; + return java.util.Objects.equals(id, other.id) + && java.util.Objects.equals(type, other.type) + && java.util.Objects.equals(height, other.height); + } + + @Override + public int hashCode() { + int hash = 0; + hash = 31 * hash + java.util.Objects.hashCode(id); + hash = 31 * hash + java.util.Objects.hashCode(type); + hash = 31 * hash + java.util.Objects.hashCode(height); + return hash; + } + + @Override + public String toString() { + return "Custom[id=" + id + ", type=" + type + ", height=" + height + "]"; } } - record Text(String id, int maxLength, Component tooltip) implements FilterControl { - public Text { + class Text implements FilterControl { + private final String id; + private final int maxLength; + private final Component tooltip; + + public Text(String id, int maxLength, Component tooltip) { if (maxLength < 1 || maxLength > 256) throw new IllegalArgumentException("Text limit"); + + this.id = id; + this.maxLength = maxLength; + this.tooltip = tooltip; + } + + public boolean edit(Edit edit) { + if (edit.value().length() > maxLength + || edit.value().codePoints().anyMatch(Character::isISOControl)) return false; + edit.configuration().putString(id, edit.value()); + return true; + } + + public String id() { + return id; + } + + public int maxLength() { + return maxLength; + } + + public Component tooltip() { + return tooltip; + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + var other = (Text) object; + return java.util.Objects.equals(id, other.id) + && java.util.Objects.equals(maxLength, other.maxLength) + && java.util.Objects.equals(tooltip, other.tooltip); + } + + @Override + public int hashCode() { + int hash = 0; + hash = 31 * hash + java.util.Objects.hashCode(id); + hash = 31 * hash + java.util.Objects.hashCode(maxLength); + hash = 31 * hash + java.util.Objects.hashCode(tooltip); + return hash; + } + + @Override + public String toString() { + return "Text[id=" + id + ", maxLength=" + maxLength + ", tooltip=" + tooltip + "]"; } } - record Number(String id, int min, int max, int step, Component tooltip) implements FilterControl { - public Number { + class Number implements FilterControl { + private final String id; + private final int min; + private final int max; + private final int step; + private final Component tooltip; + + public Number(String id, int min, int max, int step, Component tooltip) { if (min > max || step < 1) throw new IllegalArgumentException("Number bounds"); + + this.id = id; + this.min = min; + this.max = max; + this.step = step; + this.tooltip = tooltip; + } + + public boolean edit(Edit edit) { + int proposed; + try { + proposed = Integer.parseInt(edit.value()); + } catch (NumberFormatException invalid) { + return false; + } + if (proposed < min || proposed > max) return false; + edit.configuration().putInt(id, proposed); + return true; + } + + public String id() { + return id; + } + + public int min() { + return min; + } + + public int max() { + return max; + } + + public int step() { + return step; + } + + public Component tooltip() { + return tooltip; + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + var other = (Number) object; + return java.util.Objects.equals(id, other.id) + && java.util.Objects.equals(min, other.min) + && java.util.Objects.equals(max, other.max) + && java.util.Objects.equals(step, other.step) + && java.util.Objects.equals(tooltip, other.tooltip); + } + + @Override + public int hashCode() { + int hash = 0; + hash = 31 * hash + java.util.Objects.hashCode(id); + hash = 31 * hash + java.util.Objects.hashCode(min); + hash = 31 * hash + java.util.Objects.hashCode(max); + hash = 31 * hash + java.util.Objects.hashCode(step); + hash = 31 * hash + java.util.Objects.hashCode(tooltip); + return hash; + } + + @Override + public String toString() { + return "Number[id=" + + id + + ", min=" + + min + + ", max=" + + max + + ", step=" + + step + + ", tooltip=" + + tooltip + + "]"; } } - record Samples(String id, int count, ResourceType resource) implements FilterControl { - public Samples { + class Samples implements FilterControl { + private static final SlotElement DEFAULT_SLOT = + new GhostSlotElement( + Common.empty(), + 0, + SlotElement.Appearance.standard(SlotSize.NORMAL, Optional.empty(), Optional.empty())); + private final String id; + private final int count; + private final ResourceType resource; + + public Samples(String id, int count, ResourceType resource) { if (count < 1 || count > 256) throw new IllegalArgumentException("Sample limit"); + + this.id = id; + this.count = count; + this.resource = resource; + } + + public int[] measure(TextMeasure text) { + return measure(text, cellSize()); + } + + public int[] measure(TextMeasure text, int width) { + int height = 0; + var bounds = new Rect(0, 0, width, 0); + for (int i = 0; i < count(); i++) height = Math.max(height, sampleBounds(bounds, i).bottom()); + return new int[] {width, height}; + } + + /** Grid pitch in pixels; override together with slot appearance for larger or spaced cells. */ + public int cellSize() { + return 18; + } + + /** + * Slot presentation only: these cells do not create native inventory slots or separate actions. + */ + public SlotElement sampleSlot(int index) { + return DEFAULT_SLOT; + } + + /** Absolute cell bounds, shared by measurement, drawing, tooltips and click targeting. */ + public Rect sampleBounds(Rect bounds, int index) { + int size = cellSize(); + if (size < 1) throw new IllegalArgumentException("Sample cell size must be positive"); + int columns = Math.max(1, bounds.width() / size); + return new Rect( + bounds.x() + index % columns * size, bounds.y() + index / columns * size, size, size); + } + + /** Last matching cell wins; unoccupied space and points outside the control do not hit. */ + public int sampleIndex(Rect bounds, double x, double y) { + if (!bounds.contains(x, y)) return -1; + for (int i = count() - 1; i >= 0; i--) if (sampleBounds(bounds, i).contains(x, y)) return i; + return -1; + } + + /** Selects the displayed stack from read-only synchronized configuration; mods may override. */ + public ItemStack sampleDisplay(CompoundTag configuration, int index) { + var key = + ResourceLocation.tryParse(configuration.getList(id(), Tag.TAG_STRING).getString(index)); + if (key == null) return ItemStack.EMPTY; + if (resource() == ResourceType.ITEMS) return new ItemStack(BuiltInRegistries.ITEM.get(key)); + if (resource() == ResourceType.FLUIDS) + return new ItemStack(BuiltInRegistries.FLUID.get(key).getBucket()); + return ItemStack.EMPTY; + } + + /** Tooltip rows for one cell; the default follows the element's displayed stack. */ + public List sampleTooltip(CompoundTag configuration, int index) { + var item = sampleDisplay(configuration, index); + return List.of( + item.isEmpty() + ? Component.translatable("screen.mmdlib.automation.sample") + : item.getHoverName()); + } + + public boolean edit(Edit edit) { + if (!edit.value().equals("sample") && !edit.value().equals("clear")) return false; + if (edit.index() < 0 + || edit.index() >= count + || resource != edit.provider().resource(edit.filter())) return false; + var entries = edit.configuration().getList(id, Tag.TAG_STRING).copy(); + while (entries.size() < count) entries.add(StringTag.valueOf("")); + String sample = + edit.value().equals("clear") + ? "" + : edit.provider().sample(edit.filter(), edit.cursor(), edit.registries()); + if (!edit.value().equals("clear") && sample.isEmpty()) return false; + entries.set(edit.index(), StringTag.valueOf(sample)); + edit.configuration().put(id, entries); + return true; + } + + public String id() { + return id; + } + + public int count() { + return count; + } + + public ResourceType resource() { + return resource; + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + var other = (Samples) object; + return java.util.Objects.equals(id, other.id) + && java.util.Objects.equals(count, other.count) + && java.util.Objects.equals(resource, other.resource); + } + + @Override + public int hashCode() { + int hash = 0; + hash = 31 * hash + java.util.Objects.hashCode(id); + hash = 31 * hash + java.util.Objects.hashCode(count); + hash = 31 * hash + java.util.Objects.hashCode(resource); + return hash; + } + + @Override + public String toString() { + return "Samples[id=" + id + ", count=" + count + ", resource=" + resource + "]"; } } - record Box(String id, boolean horizontal, List children) implements FilterControl { - public Box { + /** A wrapping row or stack; ordinary elements may be composed alongside filter controls. */ + class Box implements FilterControl { + private final String id; + private final boolean horizontal; + private final List children; + + public Box(String id, boolean horizontal, List children) { children = List.copyOf(children); + + this.id = id; + this.horizontal = horizontal; + this.children = children; + } + + public Box(String id, boolean horizontal, Collection children) { + this(id, horizontal, List.copyOf(children)); + } + + public int[] measure(TextMeasure text) { + return measure(text, 18); + } + + public int[] measure(TextMeasure text, int width) { + return new int[] {width, FlowLayout.of(children, horizontal, width, 2, text).height()}; + } + + public Map childLayout(Rect bounds, TextMeasure text) { + return FlowLayout.of(children, horizontal, bounds.width(), 2, text).children(); + } + + public String id() { + return id; + } + + public boolean horizontal() { + return horizontal; + } + + public List children() { + return children; + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + var other = (Box) object; + return java.util.Objects.equals(id, other.id) + && java.util.Objects.equals(horizontal, other.horizontal) + && java.util.Objects.equals(children, other.children); + } + + @Override + public int hashCode() { + int hash = 0; + hash = 31 * hash + java.util.Objects.hashCode(id); + hash = 31 * hash + java.util.Objects.hashCode(horizontal); + hash = 31 * hash + java.util.Objects.hashCode(children); + return hash; + } + + @Override + public String toString() { + return "Box[id=" + id + ", horizontal=" + horizontal + ", children=" + children + "]"; } } static List flatten(FilterControl root) { - var controls = new java.util.ArrayList(); - collect(root, controls, 0); - var ids = new java.util.HashSet(); - for (var control : controls) - if (!control.id().matches("[a-z0-9_.-]{1,64}") || !ids.add(control.id())) - throw new IllegalArgumentException("Invalid/duplicate editor control: " + control.id()); - return List.copyOf(controls); + var result = new ArrayList(); + collect(root, result, new HashSet<>(), Collections.newSetFromMap(new IdentityHashMap<>()), 0); + return List.copyOf(result); } - private static void collect(FilterControl control, List result, int depth) { - if (depth > 8 || result.size() >= 128) throw new IllegalArgumentException("Editor too large"); - result.add(control); - if (control instanceof Box box) - for (var child : box.children()) collect(child, result, depth + 1); + private static void collect( + Element element, List result, Set ids, Set seen, int depth) { + if (depth > 8 || seen.size() >= 128 || !seen.add(element)) + throw new IllegalArgumentException("Editor too large or cyclic"); + if (element instanceof FilterControl control) { + if (!control.id().matches("[a-z0-9_.-]{1,64}") || !ids.add(control.id())) + throw new IllegalArgumentException("Invalid/duplicate editor control: " + control.id()); + result.add(control); + } + for (var child : element.children()) collect(child, result, ids, seen, depth + 1); } } diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/InventoryFilter.java b/src/main/java/zone/moddev/mc/lib/api/automation/InventoryFilter.java index fd07a4ad..f83cb980 100644 --- a/src/main/java/zone/moddev/mc/lib/api/automation/InventoryFilter.java +++ b/src/main/java/zone/moddev/mc/lib/api/automation/InventoryFilter.java @@ -70,41 +70,10 @@ default boolean edit( .findFirst() .orElse(null); var config = configuration(copy); - switch (control) { - case FilterControl.Custom custom -> { - if (!custom.type().dispatch(config, value)) return false; - } - case FilterControl.Toggle toggle -> config.putBoolean(id, !config.getBoolean(id)); - case FilterControl.Text text -> { - if (value.length() > text.maxLength() - || value.codePoints().anyMatch(Character::isISOControl)) return false; - config.putString(id, value); - } - case FilterControl.Number number -> { - int proposed; - try { - proposed = Integer.parseInt(value); - } catch (NumberFormatException invalid) { - return false; - } - if (proposed < number.min() || proposed > number.max()) return false; - config.putInt(id, proposed); - } - case FilterControl.Samples samples -> { - if (!value.equals("sample") && !value.equals("clear")) return false; - if (index < 0 || index >= samples.count() || samples.resource() != resource(copy)) - return false; - var entries = config.getList(id, Tag.TAG_STRING).copy(); - while (entries.size() < samples.count()) entries.add(StringTag.valueOf("")); - String sample = value.equals("clear") ? "" : sample(copy, cursor, registries); - if (!value.equals("clear") && sample.isEmpty()) return false; - entries.set(index, StringTag.valueOf(sample)); - config.put(id, entries); - } - case null, default -> { - return false; - } - } + if (control == null + || !control.edit( + new FilterControl.Edit(this, copy, config, index, value, cursor, registries))) + return false; if (!validate(copy, config, registries)) return false; configure(copy, config); return true; diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/ResourceGroup.java b/src/main/java/zone/moddev/mc/lib/api/automation/ResourceGroup.java index 642d9eb3..b08ac352 100644 --- a/src/main/java/zone/moddev/mc/lib/api/automation/ResourceGroup.java +++ b/src/main/java/zone/moddev/mc/lib/api/automation/ResourceGroup.java @@ -21,6 +21,7 @@ public final class ResourceGroup { private final List highlights; private final SideConfig sides; private final String definitionKey; + private String inventory = ""; private Function port; private Supplier facing = () -> Direction.NORTH; @@ -60,6 +61,20 @@ public String id() { return id; } + /** Identity of the backing inventory; member indexes are local to this inventory. */ + public String inventory() { + return inventory; + } + + public ResourceGroup inventory(String name) { + if (port != null) + throw new IllegalStateException("Inventory identity must be set before binding"); + if (name == null || !name.matches("[a-z0-9_.-]{1,64}")) + throw new IllegalArgumentException("Invalid inventory identity"); + inventory = name; + return this; + } + public String definitionKey() { return definitionKey; } diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemInventories.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemInventories.java new file mode 100644 index 00000000..c027f67e --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemInventories.java @@ -0,0 +1,319 @@ +package zone.moddev.mc.lib.api.capabilities; + +import java.util.*; +import java.util.function.*; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.*; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +import net.neoforged.neoforge.items.IItemHandler; + +import zone.moddev.mc.lib.machine.inventory.SlotPlan; + +/** + * Named, independent inventories with a stable combined view for capabilities and legacy callers. + * Contents, filters and reservations belong to the individual ItemStorage instances. + */ +public final class ItemInventories implements ItemStore { + public record Address(String inventory, int slot) {} + + private final Map inventories; + private final List
addresses; + private final Map indexes; + private ListTag recovery = new ListTag(); + + public ItemInventories(Map inventories) { + this( + inventories, + inventories.entrySet().stream() + .flatMap( + entry -> + java.util.stream.IntStream.range(0, entry.getValue().getSlots()) + .mapToObj(slot -> new Address(entry.getKey(), slot))) + .toList()); + } + + private ItemInventories(Map inventories, List
addresses) { + var copy = new LinkedHashMap(); + inventories.forEach( + (name, storage) -> { + if (name == null || !name.matches("[a-z0-9_.-]{1,64}")) + throw new IllegalArgumentException("Invalid inventory name: " + name); + if (copy.containsValue(storage)) + throw new IllegalArgumentException("Inventory already registered"); + copy.put(name, Objects.requireNonNull(storage)); + for (int slot = 0; slot < storage.getSlots(); slot++) + if (storage.slotId(slot) == null) storage.slotId(slot, Integer.toString(slot)); + }); + this.inventories = Collections.unmodifiableMap(copy); + this.addresses = List.copyOf(addresses); + var indexes = new HashMap(); + for (int i = 0; i < addresses.size(); i++) indexes.put(addresses.get(i), i); + this.indexes = Map.copyOf(indexes); + } + + /** Allocate one real inventory per name, retaining the supplied legacy combined ordering. */ + public static ItemInventories partition(List names) { + var sizes = new LinkedHashMap(); + var addresses = new ArrayList
(); + for (String name : names) { + int local = sizes.getOrDefault(name, 0); + addresses.add(new Address(name, local)); + sizes.put(name, local + 1); + } + var stores = new LinkedHashMap(); + sizes.forEach((name, size) -> stores.put(name, new ItemStorage(size))); + return new ItemInventories(stores, addresses); + } + + public Map inventories() { + return inventories; + } + + public ItemStorage inventory(String name) { + var storage = inventories.get(name); + if (storage == null) throw new IllegalArgumentException("Unknown inventory: " + name); + return storage; + } + + public Address address(int slot) { + return addresses.get(slot); + } + + public int index(String inventory, int slot) { + return indexes.getOrDefault(new Address(inventory, slot), -1); + } + + public ItemStorage storage(int slot) { + return inventory(address(slot).inventory()); + } + + public int localSlot(int slot) { + return address(slot).slot(); + } + + public int getSlots() { + return addresses.size(); + } + + public ItemStack getStackInSlot(int slot) { + return storage(slot).getStackInSlot(localSlot(slot)); + } + + public void setStackInSlot(int slot, ItemStack stack) { + storage(slot).setStackInSlot(localSlot(slot), stack); + } + + public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) { + return storage(slot).insertItem(localSlot(slot), stack, simulate); + } + + public ItemStack extractItem(int slot, int amount, boolean simulate) { + return storage(slot).extractItem(localSlot(slot), amount, simulate); + } + + public int getSlotLimit(int slot) { + return storage(slot).getSlotLimit(localSlot(slot)); + } + + public boolean isItemValid(int slot, ItemStack stack) { + return storage(slot).isItemValid(localSlot(slot), stack); + } + + public Role role(int slot) { + return storage(slot).role(localSlot(slot)); + } + + public ItemInventories role(Role role, int... slots) { + for (int slot : slots) storage(slot).role(role, localSlot(slot)); + return this; + } + + public ItemInventories limit(int limit, int... slots) { + for (int slot : slots) storage(slot).limit(limit, localSlot(slot)); + return this; + } + + public ItemInventories slotId(int slot, String id) { + storage(slot).slotId(localSlot(slot), id); + return this; + } + + public ItemInventories restrict(Predicate filter, int... slots) { + for (int slot : slots) storage(slot).restrict(filter, localSlot(slot)); + return this; + } + + public ItemInventories onChange(Runnable changed) { + inventories.values().forEach(storage -> storage.onChange(changed)); + return this; + } + + public ItemStack insertResult(int slot, ItemStack stack, boolean simulate) { + return storage(slot).insertResult(localSlot(slot), stack, simulate); + } + + public void restoreStack(int slot, ItemStack stack) { + storage(slot).restoreStack(localSlot(slot), stack); + } + + public long revision() { + return inventories.values().stream().mapToLong(ItemStorage::revision).sum(); + } + + public void markChanged() { + inventories.values().forEach(ItemStorage::markChanged); + } + + public SlotPlan plan(int slot) { + return storage(slot).plan(localSlot(slot)); + } + + public void setPlan(int slot, SlotPlan plan) { + storage(slot).setPlan(localSlot(slot), plan); + } + + public boolean hasPlan(int slot) { + return slot >= 0 && slot < getSlots() && storage(slot).hasPlan(localSlot(slot)); + } + + public int freeToTake(int slot) { + return storage(slot).freeToTake(localSlot(slot)); + } + + public int maxHeld(int slot, ItemStack stack) { + return storage(slot).maxHeld(localSlot(slot), stack); + } + + public void clear() { + recovery.clear(); + inventories.values().forEach(ItemStorage::clear); + } + + public IItemHandler view(IntFunction mode) { + return new IItemHandler() { + private boolean valid(int slot) { + return slot >= 0 && slot < getSlots(); + } + + private IItemHandler port(int slot) { + return storage(slot).view(mode.apply(slot)); + } + + public int getSlots() { + return ItemInventories.this.getSlots(); + } + + public ItemStack getStackInSlot(int slot) { + return valid(slot) ? ItemInventories.this.getStackInSlot(slot) : ItemStack.EMPTY; + } + + public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) { + return valid(slot) ? port(slot).insertItem(localSlot(slot), stack, simulate) : stack; + } + + public ItemStack extractItem(int slot, int amount, boolean simulate) { + return valid(slot) + ? port(slot).extractItem(localSlot(slot), amount, simulate) + : ItemStack.EMPTY; + } + + public int getSlotLimit(int slot) { + return valid(slot) ? ItemInventories.this.getSlotLimit(slot) : 0; + } + + public boolean isItemValid(int slot, ItemStack stack) { + return valid(slot) && port(slot).isItemValid(localSlot(slot), stack); + } + }; + } + + public CompoundTag serializeNBT(HolderLookup.Provider registries) { + var result = new CompoundTag(); + var named = new CompoundTag(); + inventories.forEach( + (name, storage) -> { + var saved = storage.serializeNBT(registries); + // Preserve legacy position only for migration of old or unidentified slots. + for (var value : saved.getList("Items", Tag.TAG_COMPOUND)) { + var entry = (CompoundTag) value; + entry.putInt("LegacySlot", index(name, entry.getInt("Slot"))); + } + named.put(name, saved); + }); + result.put("Inventories", named); + result.put("Recovery", recovery.copy()); + return result; + } + + public void deserializeNBT(HolderLookup.Provider registries, CompoundTag tag) { + deserializeNBT(registries, tag, IntUnaryOperator.identity()); + } + + public void deserializeNBT( + HolderLookup.Provider registries, CompoundTag tag, IntUnaryOperator legacySlots) { + // Resolve the complete old/new save before restoring any processor reservations. + var entries = tag.getList("Items", Tag.TAG_COMPOUND).copy(); + entries.addAll(tag.getList("Recovery", Tag.TAG_COMPOUND).copy()); + // Old saves used globally unique IDs; new saves namespace each inventory's local IDs. + for (var value : entries) { + var entry = (CompoundTag) value; + String id = entry.getString("SlotId"); + if (!id.isEmpty() && !id.contains("::")) { + int found = -1; + for (int i = 0; i < getSlots(); i++) + if (id.equals(storage(i).slotId(localSlot(i)))) { + if (found != -1) { + found = -2; + break; + } + found = i; + } + if (found >= 0) entry.putString("SlotId", address(found).inventory() + "::" + id); + } + } + var named = tag.getCompound("Inventories"); + for (String name : named.getAllKeys()) { + var saved = named.getCompound(name); + var values = saved.getList("Items", Tag.TAG_COMPOUND).copy(); + values.addAll(saved.getList("Recovery", Tag.TAG_COMPOUND).copy()); + for (var value : values) { + var entry = (CompoundTag) value; + int local = entry.getInt("Slot"); + int current = index(name, local); + entry.putInt("Slot", current); + entry.putString( + "SlotId", + name + + "::" + + (entry.contains("SlotId") ? entry.getString("SlotId") : Integer.toString(local))); + entries.add(entry); + } + } + var migration = new ItemStorage(getSlots()); + for (int i = 0; i < getSlots(); i++) { + String id = storage(i).slotId(localSlot(i)); + if (id != null) migration.slotId(i, address(i).inventory() + "::" + id); + migration.limit(getSlotLimit(i), i); + } + var flat = new CompoundTag(); + flat.put("Items", entries); + migration.deserializeNBT( + registries, flat, named.isEmpty() ? legacySlots : IntUnaryOperator.identity()); + clear(); + for (int i = 0; i < getSlots(); i++) restoreStack(i, migration.getStackInSlot(i)); + recovery = migration.recoveryContents(); + } + + public ListTag recoveryContents() { + return recovery.copy(); + } + + public void dropAll(Level level, BlockPos pos, Consumer receiver) { + StorageRecovery.handoff(recovery, "mmdlib:items", "item", receiver); + inventories.values().forEach(storage -> storage.dropAll(level, pos, receiver)); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java index 27d5cd48..b41690da 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java @@ -28,7 +28,22 @@ * refuse placement, so players cannot put items into outputs. The machine fills outputs with {@link * #insertResult}. */ -public class ItemStorage extends PlannedItemHandler { +public class ItemStorage extends PlannedItemHandler implements ItemStore { + @Override + public ItemStorage storage(int slot) { + checked(slot); + return this; + } + + @Override + public int localSlot(int slot) { + return checked(slot); + } + + public String slotId(int slot) { + return slotIds[checked(slot)]; + } + private final Role[] roles; private final int[] limits; diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStore.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStore.java new file mode 100644 index 00000000..3c5a4ca7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStore.java @@ -0,0 +1,46 @@ +package zone.moddev.mc.lib.api.capabilities; + +import java.util.function.Consumer; +import java.util.function.IntFunction; +import java.util.function.IntUnaryOperator; +import java.util.function.Predicate; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +import net.neoforged.neoforge.items.IItemHandler; +import net.neoforged.neoforge.items.IItemHandlerModifiable; + +/** Item storage access, including a combined view over independently owned inventories. */ +public interface ItemStore extends IItemHandlerModifiable, ReservedSlots { + /** Physical owner and local index, used for reservation and transaction identity. */ + ItemStorage storage(int slot); + + int localSlot(int slot); + + Role role(int slot); + + ItemStore restrict(Predicate filter, int... slots); + + ItemStack insertResult(int slot, ItemStack stack, boolean simulate); + + void restoreStack(int slot, ItemStack stack); + + long revision(); + + void markChanged(); + + void clear(); + + CompoundTag serializeNBT(HolderLookup.Provider registries); + + void deserializeNBT( + HolderLookup.Provider registries, CompoundTag tag, IntUnaryOperator legacySlots); + + void dropAll(Level level, BlockPos pos, Consumer recovery); + + IItemHandler view(IntFunction mode); +} diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/ReservationReceiver.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/ReservationReceiver.java new file mode 100644 index 00000000..e75f2eeb --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/ReservationReceiver.java @@ -0,0 +1,8 @@ +package zone.moddev.mc.lib.api.capabilities; + +import zone.moddev.mc.lib.machine.inventory.SlotPlan; + +/** Optional storage contract for applying authoritative reservation snapshots to a menu mirror. */ +public interface ReservationReceiver { + void setPlan(int slot, SlotPlan plan); +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/CounterRate.java b/src/main/java/zone/moddev/mc/lib/api/stats/CounterRate.java similarity index 89% rename from src/main/java/zone/moddev/mc/lib/gui/runtime/CounterRate.java rename to src/main/java/zone/moddev/mc/lib/api/stats/CounterRate.java index 23c95a18..4e36ccaa 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/CounterRate.java +++ b/src/main/java/zone/moddev/mc/lib/api/stats/CounterRate.java @@ -1,9 +1,9 @@ -package zone.moddev.mc.lib.gui.runtime; +package zone.moddev.mc.lib.api.stats; import java.math.BigInteger; import java.util.ArrayDeque; -/** Per-viewer rolling sample of a provider counter, using game time. */ +/** Rolling sample of a provider counter, using game time. */ public final class CounterRate { private record Sample(long tick, BigInteger count) {} diff --git a/src/main/java/zone/moddev/mc/lib/api/stats/Statistics.java b/src/main/java/zone/moddev/mc/lib/api/stats/Statistics.java index 19e11d27..7862f93c 100644 --- a/src/main/java/zone/moddev/mc/lib/api/stats/Statistics.java +++ b/src/main/java/zone/moddev/mc/lib/api/stats/Statistics.java @@ -7,7 +7,8 @@ import net.minecraft.network.chat.Component; /** - * Immutable descriptions of a part's statistics; sampling, formatting and sync belong to the UI. + * Immutable descriptions of a part's statistics. Values own sampling and formatting; the UI + * transports their display pages. */ public record Statistics(Component title, List rows, List maximumUses) { public static final Statistics EMPTY = new Statistics(Component.empty(), List.of(), List.of()); @@ -58,10 +59,48 @@ public Row whenUsed(Object resource) { } } - public sealed interface Value permits Counter, Rate, Text, MaximumUse {} + /** An open statistic type; no registration or client implementation is required. */ + public interface Value { + /** Immutable equality key for sampling configuration, excluding the current reading. */ + default Object samplingKey() { + return getClass(); + } + + /** + * Called once per sampling update, independently of the number of timescales. Previous is null + * for a new row, a changed value class/configuration, or after a row/provider was removed. + * Return fresh sampled data or update the supplied history; never store it in the definition. + */ + Sample sample(StatsSamplingContext context, Sample previous); + } + + /** Per-row sampled data/history. Formatting must not change history or read live storage. */ + @FunctionalInterface + public interface Sample { + Component format(StatsFormatContext context); + } + + private record CounterSample(CounterRate rate) implements Sample { + public Component format(StatsFormatContext context) { + return context.number(rate.format(context.ticks(), context.decimals())); + } + } /** A lifetime counter, measured over the most recent window (20..1200 game ticks). */ public record Counter(BigInteger total, int windowTicks) implements Value { + public Object samplingKey() { + return windowTicks; + } + + public Sample sample(StatsSamplingContext context, Sample previous) { + var sampled = + previous == null + ? new CounterSample(new CounterRate(windowTicks)) + : (CounterSample) previous; + sampled.rate().sample(total, context.tick()); + return sampled; + } + public Counter { if (total.signum() < 0 || windowTicks < 20 || windowTicks > 1200) throw new IllegalArgumentException("Invalid statistics counter"); @@ -70,6 +109,11 @@ public record Counter(BigInteger total, int windowTicks) implements Value { /** Exact amount per number of game ticks; a non-timescale row uses this as a fixed quantity. */ public record Rate(BigInteger amount, long ticks) implements Value { + public Sample sample(StatsSamplingContext context, Sample previous) { + return format -> + format.number(StatsNumbers.rate(amount, ticks, format.ticks(), format.decimals())); + } + public Rate { if (amount.signum() < 0 || ticks <= 0) throw new IllegalArgumentException("Invalid statistics rate"); @@ -77,6 +121,11 @@ public record Rate(BigInteger amount, long ticks) implements Value { } public record Text(Component text) implements Value { + public Sample sample(StatsSamplingContext context, Sample previous) { + var sampled = text.copy(); + return format -> sampled.copy(); + } + public Text { text = Objects.requireNonNull(text).copy(); } @@ -84,6 +133,12 @@ public record Text(Component text) implements Value { /** Resolves the combined demands of all registered providers against this exact resource. */ public record MaximumUse(Object resource) implements Value { + public Sample sample(StatsSamplingContext context, Sample previous) { + var amount = context.demand(resource); + return format -> + format.number(StatsNumbers.rate(amount, 1, format.ticks(), format.decimals())); + } + public MaximumUse { Objects.requireNonNull(resource); } diff --git a/src/main/java/zone/moddev/mc/lib/api/stats/StatsFormatContext.java b/src/main/java/zone/moddev/mc/lib/api/stats/StatsFormatContext.java new file mode 100644 index 00000000..5b1e8bbe --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/api/stats/StatsFormatContext.java @@ -0,0 +1,30 @@ +package zone.moddev.mc.lib.api.stats; + +import java.util.Objects; + +import net.minecraft.network.chat.Component; + +/** Row formatting options; custom statistics may use these helpers or return their own text. */ +public record StatsFormatContext( + StatsTimescale timescale, Component unit, int decimals, boolean perTimescale) { + public StatsFormatContext { + Objects.requireNonNull(timescale); + unit = Objects.requireNonNull(unit).copy(); + if (decimals < 0 || decimals > 8) throw new IllegalArgumentException("Decimal precision"); + } + + public Component unit() { + return unit.copy(); + } + + public int ticks() { + return perTimescale ? timescale.ticks() : 1; + } + + /** Standard numeric value plus row unit and optional timescale suffix. */ + public Component number(String number) { + var result = Component.literal(number).append(" ").append(unit()); + if (perTimescale) result.append("/").append(timescale.unit()); + return result; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/StatsNumbers.java b/src/main/java/zone/moddev/mc/lib/api/stats/StatsNumbers.java similarity index 79% rename from src/main/java/zone/moddev/mc/lib/gui/runtime/StatsNumbers.java rename to src/main/java/zone/moddev/mc/lib/api/stats/StatsNumbers.java index 3b912ef9..38ce83e2 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/StatsNumbers.java +++ b/src/main/java/zone/moddev/mc/lib/api/stats/StatsNumbers.java @@ -1,4 +1,4 @@ -package zone.moddev.mc.lib.gui.runtime; +package zone.moddev.mc.lib.api.stats; import java.math.BigDecimal; import java.math.BigInteger; @@ -7,10 +7,10 @@ import java.util.Locale; /** Exact rate conversion with fixed precision and comma grouping, independent of server locale. */ -final class StatsNumbers { +public final class StatsNumbers { private StatsNumbers() {} - static String rate(BigInteger amount, long elapsed, int ticksPerUnit, int decimals) { + public static String rate(BigInteger amount, long elapsed, int ticksPerUnit, int decimals) { var value = new BigDecimal(amount) .multiply(BigDecimal.valueOf(ticksPerUnit)) diff --git a/src/main/java/zone/moddev/mc/lib/api/stats/StatsSamplingContext.java b/src/main/java/zone/moddev/mc/lib/api/stats/StatsSamplingContext.java new file mode 100644 index 00000000..b32a5dbc --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/api/stats/StatsSamplingContext.java @@ -0,0 +1,24 @@ +package zone.moddev.mc.lib.api.stats; + +import java.math.BigInteger; +import java.util.IdentityHashMap; +import java.util.Map; + +/** One server sample's game time and combined resource demands, matched by identity. */ +public final class StatsSamplingContext { + private final long tick; + private final Map demands; + + public StatsSamplingContext(long tick, Map demands) { + this.tick = tick; + this.demands = new IdentityHashMap<>(demands); + } + + public long tick() { + return tick; + } + + public BigInteger demand(Object resource) { + return demands.getOrDefault(resource, BigInteger.ZERO); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/api/stats/StatsTimescale.java b/src/main/java/zone/moddev/mc/lib/api/stats/StatsTimescale.java new file mode 100644 index 00000000..393028cd --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/api/stats/StatsTimescale.java @@ -0,0 +1,48 @@ +package zone.moddev.mc.lib.api.stats; + +import java.util.List; +import java.util.Objects; + +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; + +/** Data supplied by a host, without registration. Labels remain translatable on the client. */ +public record StatsTimescale(ResourceLocation id, int ticks, Component label, Component unit) { + public static final int MAX_TIMESCALES = 16; + public static final StatsTimescale TICK = builtin("tick", 1); + public static final StatsTimescale SECOND = builtin("second", 20); + public static final StatsTimescale MINUTE = builtin("minute", 1200); + public static final List DEFAULTS = List.of(TICK, SECOND, MINUTE); + + public StatsTimescale { + Objects.requireNonNull(id); + if (ticks <= 0) throw new IllegalArgumentException("Timescale ticks must be positive"); + label = Objects.requireNonNull(label).copy(); + unit = Objects.requireNonNull(unit).copy(); + } + + public Component label() { + return label.copy(); + } + + public Component unit() { + return unit.copy(); + } + + private static StatsTimescale builtin(String name, int ticks) { + return new StatsTimescale( + ResourceLocation.parse("mmdlib:" + name), + ticks, + Statistics.label("per_" + name), + Statistics.label(name)); + } + + /** Empty lists are allowed; the drawer then has no selectable pages. */ + public static List checked(List timescales) { + var result = List.copyOf(timescales); + if (result.size() > MAX_TIMESCALES + || result.stream().map(StatsTimescale::id).distinct().count() != result.size()) + throw new IllegalArgumentException("Too many statistics timescales or duplicate IDs"); + return result; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/JeiIngredientTypes.java b/src/main/java/zone/moddev/mc/lib/client/jei/JeiIngredientTypes.java new file mode 100644 index 00000000..15728451 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/client/jei/JeiIngredientTypes.java @@ -0,0 +1,42 @@ +package zone.moddev.mc.lib.client.jei; + +import java.util.*; + +import net.minecraft.resources.ResourceLocation; + +import mezz.jei.api.constants.VanillaTypes; +import mezz.jei.api.ingredients.IIngredientType; +import mezz.jei.api.neoforge.NeoForgeTypes; + +import zone.moddev.mc.lib.gui.client.IngredientType; +import zone.moddev.mc.lib.gui.client.IngredientTypes; + +/** Optional JEI bridge. Mods register mappings in their JEI plugin before GUI interaction. */ +public final class JeiIngredientTypes { + public static final JeiIngredientTypes GLOBAL = builtins(); + private final Map, IngredientType> types = new HashMap<>(); + private final Set ids = new HashSet<>(); + + private static JeiIngredientTypes builtins() { + var registry = new JeiIngredientTypes(); + registry.register(VanillaTypes.ITEM_STACK, IngredientTypes.ITEM); + registry.register(NeoForgeTypes.FLUID_STACK, IngredientTypes.FLUID); + return registry; + } + + public synchronized void register(IIngredientType source, IngredientType target) { + Objects.requireNonNull(source); + Objects.requireNonNull(target); + if (!target.valueClass().isAssignableFrom(source.getIngredientClass())) + throw new IllegalArgumentException("Incompatible ingredient classes: " + target.id()); + if (types.containsKey(source) || ids.contains(target.id())) + throw new IllegalArgumentException("Duplicate JEI ingredient mapping: " + target.id()); + types.put(source, target); + ids.add(target.id()); + } + + @SuppressWarnings("unchecked") + public synchronized Optional> find(IIngredientType source) { + return Optional.ofNullable((IngredientType) types.get(source)); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java b/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java index 40439d17..9b265ec8 100644 --- a/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java +++ b/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java @@ -5,9 +5,6 @@ import net.minecraft.client.renderer.Rect2i; import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.*; -import net.minecraft.world.item.ItemStack; - -import net.neoforged.neoforge.network.PacketDistributor; import mezz.jei.api.gui.builder.ITooltipBuilder; import mezz.jei.api.gui.handlers.*; @@ -21,6 +18,7 @@ import mezz.jei.api.runtime.IRecipesGui; import zone.moddev.mc.lib.MMDLib; +import zone.moddev.mc.lib.gui.client.IngredientDrops; import zone.moddev.mc.lib.gui.client.MachineScreen; import zone.moddev.mc.lib.gui.layout.Rect; import zone.moddev.mc.lib.gui.model.Element; @@ -113,11 +111,7 @@ public Collection getGuiClickableAreas( if (!types.isEmpty()) { var basic = IGuiClickableArea.createBasic( - r.x(), - r.y(), - r.width(), - r.height(), - types.toArray(RecipeType[]::new)); + r.x(), r.y(), r.width(), r.height(), types.toArray(RecipeType[]::new)); result.add(clickable(basic, screen, e, (int) mx, (int) my)); } } @@ -130,28 +124,27 @@ public Collection getGuiClickableAreas( @Override public List> getTargetsTyped( MachineScreen screen, ITypedIngredient ingredient, boolean start) { - if (!(ingredient.getIngredient() instanceof ItemStack)) return List.of(); + var type = JeiIngredientTypes.GLOBAL.find(ingredient.getType()).orElse(null); + if (type == null) return List.of(); var result = new ArrayList>(); - var menu = screen.getMenu(); - for (int i = 0; i < menu.elements.size(); i++) { - var e = menu.elements.get(i); - final int index = i; - if (e instanceof Element.Slot slot - && slot.kind() == Element.SlotKind.GHOST - && screen.visible(e)) - result.add( - new Target<>() { - public Rect2i getArea() { - return rect(screen.bounds(e)); - } + for (var target : + IngredientDrops.targets( + screen.presentation(), + screen.getMenu().elements, + type, + ingredient.getIngredient())) { + result.add( + new Target<>() { + public Rect2i getArea() { + return rect(target.bounds()); + } - public void accept(I value) { - if (value instanceof ItemStack stack) - PacketDistributor.sendToServer( - new ScreenNetwork.Ghost( - menu.containerId, index, stack.copyWithCount(1))); - } - }); + public void accept(I value) { + if (runtime != null + && net.minecraft.client.Minecraft.getInstance().screen == screen) + target.accept().test(value); + } + }); } return result; } @@ -190,7 +183,7 @@ public IRecipeTransferError transferRecipe( for (var id : categories) { var type = runtime.getRecipeManager().getRecipeType(id); if (type.isPresent() - && host.screenRecipeSlots(id).length > 0 + && !host.screenRecipeItems(id).isEmpty() && runtime .getRecipeManager() .createRecipeLookup(type.get()) @@ -234,20 +227,15 @@ public boolean canHandle(MachineMenu menu, R recipe) { public List getRecipeSlots(MachineMenu menu, R recipe) { if (!(menu.blockEntity instanceof ScreenHost host)) return List.of(); var result = new ArrayList(); - for (int index : host.screenRecipeSlots(type.getUid())) { - var element = - menu.elements.stream() - .filter( - e -> - e instanceof Element.Slot s - && s.index() == index - && s.kind() == Element.SlotKind.NORMAL) - .findFirst(); - if (element.isEmpty() - || !menu.visible(element.get()) - || index < 0 - || index >= menu.slots.size()) return List.of(); - result.add(menu.slots.get(index)); + for (var address : host.screenRecipeItems(type.getUid())) { + int menuIndex = menu.menuSlot(address); + var element = menu.slotElement(menuIndex); + if (element == null + || element.ghost() + || !menu.visible(element) + || menuIndex < 0 + || menuIndex >= menu.slots.size()) return List.of(); + result.add(menu.slots.get(menuIndex)); } return result; } diff --git a/src/main/java/zone/moddev/mc/lib/compat/curios/CuriosEquipment.java b/src/main/java/zone/moddev/mc/lib/compat/curios/CuriosEquipment.java index b1a6ce2a..fc6969c8 100644 --- a/src/main/java/zone/moddev/mc/lib/compat/curios/CuriosEquipment.java +++ b/src/main/java/zone/moddev/mc/lib/compat/curios/CuriosEquipment.java @@ -3,13 +3,17 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; + import net.minecraft.resources.ResourceLocation; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; + import net.neoforged.neoforge.items.IItemHandler; + import top.theillusivec4.curios.api.CuriosApi; import top.theillusivec4.curios.api.SlotContext; import top.theillusivec4.curios.api.type.ISlotType; + import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; import zone.moddev.mc.lib.gui.runtime.EquippedContainers; import zone.moddev.mc.lib.gui.runtime.EquippedContainers.SlotInfo; @@ -20,6 +24,38 @@ private CuriosEquipment() {} private record Entry(SlotInfo info, IItemHandler handler, boolean active, boolean render) {} + private static net.minecraft.network.chat.Component text(String name) { + return net.minecraft.network.chat.Component.translatable("screen.mmdlib.curios." + name); + } + + public static EquippedContainers.Presentation presentation() { + return new EquippedContainers.Presentation( + text("title"), + java.util.Optional.of(ResourceLocation.parse("curios:slot/empty_curio_slot")), + 0xa6a0ca, + List.of(text("equipment"), text("functional.short"), text("cosmetic.short")), + List.of(text("toggle")), + List.of(text("store")), + List.of(text("columns"), text("store"))); + } + + private static SlotInfo describe( + String identifier, int index, boolean cosmetic, boolean available, ResourceLocation icon) { + var name = + net.minecraft.network.chat.Component.translatableWithFallback( + "curios.identifier." + identifier, identifier); + if (index > 0) name.append(" " + (index + 1)); + return new SlotInfo( + identifier, + index, + cosmetic, + available, + icon, + cosmetic ? net.minecraft.network.chat.Component.empty() : name, + List.of(name, text(!available ? "unavailable" : cosmetic ? "cosmetic" : "functional")), + net.minecraft.network.chat.Component.literal("-")); + } + public static List find(Player player, ItemStack identity) { return discover(player, identity).equipment(); } @@ -81,13 +117,13 @@ public static EquippedContainers.Snapshot discover(Player player, ItemStack iden boolean render = i >= stacks.getRenders().size() || stacks.getRenders().get(i); entries.add( new Entry( - new SlotInfo(group.getKey(), i, false, active, icon), + describe(group.getKey(), i, false, active, icon), stacks.getStacks(), active, render)); entries.add( new Entry( - new SlotInfo( + describe( group.getKey(), i, true, @@ -172,6 +208,14 @@ public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) { } }; return new EquippedContainers.Snapshot( - backpacks, List.of(new EquippedContainers.Source(topology.toString(), identity, handler))); + backpacks, + List.of( + new EquippedContainers.Source( + topology.toString(), + identity, + handler, + presentation(), + "curios", + zone.moddev.mc.lib.gui.runtime.ContainerPolicy.EQUIPMENT))); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/ElementAction.java b/src/main/java/zone/moddev/mc/lib/gui/ElementAction.java new file mode 100644 index 00000000..7dda13b4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/ElementAction.java @@ -0,0 +1,93 @@ +package zone.moddev.mc.lib.gui; + +import java.util.Objects; +import java.util.function.BiPredicate; + +import net.minecraft.core.RegistryAccess; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; + +import io.netty.buffer.Unpooled; + +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.runtime.MachineMenu; + +/** A bounded typed action declared by an element. Authorization precedes decoding. */ +public class ElementAction { + public static final int MAX_BYTES = 4096; + private final ResourceLocation id; + private final StreamCodec codec; + private final int maxBytes; + private final BiPredicate handler; + + public ElementAction( + ResourceLocation id, + StreamCodec codec, + int maxBytes, + BiPredicate handler) { + this.id = Objects.requireNonNull(id); + this.codec = Objects.requireNonNull(codec); + this.handler = Objects.requireNonNull(handler); + if (maxBytes < 1 || maxBytes > MAX_BYTES) + throw new IllegalArgumentException("Element action limit must be 1..4096 bytes"); + this.maxBytes = maxBytes; + } + + public final ResourceLocation id() { + return id; + } + + public final StreamCodec codec() { + return codec; + } + + public final int maxBytes() { + return maxBytes; + } + + public record Context(ServerPlayer player, MachineMenu menu, Element element) {} + + /** Executes an already validated local payload; the caller must authorize the context first. */ + public boolean execute(Context context, T value) { + return handler.test(context, value); + } + + /** Encodes inside a bounded buffer, before any packet is sent. */ + public byte[] encode(RegistryAccess registries, T value) { + var buffer = + new RegistryFriendlyByteBuf(Unpooled.buffer(Math.min(64, maxBytes), maxBytes), registries); + try { + codec.encode(buffer, value); + byte[] data = new byte[buffer.readableBytes()]; + buffer.readBytes(data); + return data; + } finally { + buffer.release(); + } + } + + /** Decodes exactly one action, rejecting trailing data as well as oversized payloads. */ + public T decode(RegistryAccess registries, byte[] data) { + if (data.length > maxBytes) throw new IllegalArgumentException("Element action is too large"); + var buffer = new RegistryFriendlyByteBuf(Unpooled.wrappedBuffer(data), registries); + try { + T value = Objects.requireNonNull(codec.decode(buffer)); + if (buffer.isReadable()) throw new IllegalArgumentException("Trailing element action data"); + return value; + } finally { + buffer.release(); + } + } + + public boolean dispatch(Context context, byte[] data) { + T value; + try { + value = decode(context.player().registryAccess(), data); + } catch (RuntimeException malformed) { + return false; + } + return handler.test(context, value); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/ElementActions.java b/src/main/java/zone/moddev/mc/lib/gui/ElementActions.java new file mode 100644 index 00000000..f1028e93 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/ElementActions.java @@ -0,0 +1,59 @@ +package zone.moddev.mc.lib.gui; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.runtime.ScreenClick; + +/** Shared click payload and callback behavior; domain actions belong to their elements. */ +public final class ElementActions { + private ElementActions() {} + + public record Click(int button, int modifiers, int option) { + public Click { + if (button < 0 || button > 1 || modifiers < 0 || modifiers > 7 || option < 0 || option > 4095) + throw new IllegalArgumentException("Click outside protocol limits"); + } + + public static final StreamCodec CODEC = + StreamCodec.of( + (b, v) -> { + b.writeByte(v.button()); + b.writeByte(v.modifiers()); + b.writeVarInt(v.option()); + }, + b -> new Click(b.readUnsignedByte(), b.readUnsignedByte(), b.readVarInt())); + } + + public static final ElementAction CLICK = + new ElementAction<>( + ResourceLocation.parse("mmdlib:click"), + Click.CODEC, + 8, + (context, value) -> { + var element = context.element(); + if (!element.validClickOption(value.option())) return false; + var click = + new ScreenClick( + context.player(), + context.menu(), + element, + value.button(), + (value.modifiers() & 1) != 0, + (value.modifiers() & 2) != 0, + (value.modifiers() & 4) != 0, + value.option()); + if (element.interact(click)) return true; + var clicks = element.common().clicks(); + var normal = value.button() == 0 ? clicks.click() : clicks.rightClick(); + var shifted = value.button() == 0 ? clicks.shiftClick() : clicks.shiftRightClick(); + var handler = click.shift() ? shifted.or(() -> normal) : normal; + handler.ifPresent( + h -> + zone.moddev.mc.lib.api.MaterialExtensions.GLOBAL + .events() + .dispatch(h, zone.moddev.mc.lib.api.MaterialEventTypes.GUI_CLICK, click)); + return handler.isPresent(); + }); +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/ElementInteractions.java b/src/main/java/zone/moddev/mc/lib/gui/ElementInteractions.java new file mode 100644 index 00000000..f8116233 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/ElementInteractions.java @@ -0,0 +1,50 @@ +package zone.moddev.mc.lib.gui; + +import java.util.HashMap; +import java.util.Map; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.model.elements.FluidTankElement; +import zone.moddev.mc.lib.gui.runtime.ScreenClick; + +/** Reusable element behaviors. Register during mod construction, before screens load. */ +public final class ElementInteractions { + public interface Type { + /** Reject invalid screen-authored targets during loading. */ + void validate(String target); + + /** + * Runs only after the menu validates the player, distance and element visibility. Return true + * to consume the click; false allows its ordinary callback to run. + */ + boolean click(ScreenClick click, String target); + + /** Optional resource identity for validation and highlights. */ + default java.util.Optional resourceBinding( + String target) { + return java.util.Optional.empty(); + } + } + + private static final Map TYPES = new HashMap<>(); + + static { + register(FluidTankElement.INTERACTION_ID, FluidTankElement.containerInteractionType()); + } + + private ElementInteractions() {} + + public static synchronized void register(ResourceLocation id, Type type) { + java.util.Objects.requireNonNull(id); + java.util.Objects.requireNonNull(type); + if (TYPES.putIfAbsent(id, type) != null) + throw new IllegalArgumentException("Duplicate element interaction: " + id); + } + + public static synchronized Type require(ResourceLocation id) { + var type = TYPES.get(id); + if (type == null) throw new IllegalArgumentException("Unknown element interaction: " + id); + return type; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/ElementType.java b/src/main/java/zone/moddev/mc/lib/gui/ElementType.java new file mode 100644 index 00000000..4e5b7148 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/ElementType.java @@ -0,0 +1,99 @@ +package zone.moddev.mc.lib.gui; + +import java.util.*; +import java.util.function.Function; + +import javax.xml.namespace.QName; + +import zone.moddev.mc.lib.gui.load.*; +import zone.moddev.mc.lib.gui.model.Element; + +/** + * Common-side element declaration, not a registry. XML is optional; Java-only types may also be + * abstract classes or interfaces used for inherited client registrations. Contains no client types. + */ +public final class ElementType { + private final Class type; + private final Map> tags; + + private ElementType(Class type, Map> tags) { + this.type = Objects.requireNonNull(type); + this.tags = Collections.unmodifiableMap(new LinkedHashMap<>(tags)); + } + + public static ElementType of(Class type) { + return new ElementType<>(type, Map.of()); + } + + public static ElementType xml(QName name, ScreenTag tag) { + return of(tag.resultType()).withXml(name, tag); + } + + /** Build a namespaced element tag using the existing schema/attribute contract. */ + public static Builder xml( + Class type, QName name, Function factory) { + return new Builder<>(name, ScreenTag.element(type, factory)); + } + + /** Returns a new declaration with another XML spelling; the original remains unchanged. */ + public ElementType withXml(QName name, ScreenTag tag) { + Objects.requireNonNull(name); + Objects.requireNonNull(tag); + if (tag.role() != ScreenTag.Role.ELEMENT || !type.isAssignableFrom(tag.resultType())) + throw new IllegalArgumentException("Not an element tag for " + type.getName() + ": " + name); + var copy = new LinkedHashMap<>(tags); + if (copy.putIfAbsent(name, tag) != null) + throw new IllegalArgumentException("Duplicate element tag: " + name); + return new ElementType<>(type, copy); + } + + public Class type() { + return type; + } + + /** Register during mod construction, on both physical sides. */ + public void register() { + register(ScreenTags.GLOBAL); + } + + /** Isolated loaders use the same declaration without modifying global registrations. */ + public void register(ScreenTags registry) { + Objects.requireNonNull(registry); + tags.forEach(registry::register); + } + + /** Fluent convenience for the existing ScreenTag builder. */ + public static final class Builder { + private final QName name; + private final ScreenTag.Builder tag; + + private Builder(QName name, ScreenTag.Builder tag) { + this.name = Objects.requireNonNull(name); + this.tag = tag; + } + + public Builder attribute(String name, ScreenAttribute attribute) { + tag.attribute(name, attribute); + return this; + } + + public Builder groups(String... groups) { + tag.groups(groups); + return this; + } + + public Builder children(String group, int minimum, int maximum) { + tag.children(group, minimum, maximum); + return this; + } + + public Builder children(QName child, int minimum, int maximum) { + tag.children(child, minimum, maximum); + return this; + } + + public ElementType build() { + return ElementType.xml(name, tag.build()); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/WidgetAction.java b/src/main/java/zone/moddev/mc/lib/gui/WidgetAction.java index 6530a13f..d00e8364 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/WidgetAction.java +++ b/src/main/java/zone/moddev/mc/lib/gui/WidgetAction.java @@ -3,73 +3,42 @@ import java.util.Objects; import java.util.function.BiPredicate; -import net.minecraft.core.RegistryAccess; import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerPlayer; -import io.netty.buffer.Unpooled; - -import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.elements.CustomElement; import zone.moddev.mc.lib.gui.runtime.MachineMenu; -/** - * A typed action allowed by a widget type. Codecs must bound strings/collections before allocating. - * The server handler must validate permissions and data before mutating, returning false to reject. - * Register the same ID and codec on both sides; the handler only runs on the server thread. - */ -public record WidgetAction( - ResourceLocation id, - StreamCodec codec, - int maxBytes, - BiPredicate handler) { - public static final int MAX_BYTES = 4096; - - public WidgetAction { - Objects.requireNonNull(id); - Objects.requireNonNull(codec); - Objects.requireNonNull(handler); - if (maxBytes < 1 || maxBytes > MAX_BYTES) - throw new IllegalArgumentException("Widget action limit must be 1..4096 bytes"); - } - - public record Context(ServerPlayer player, MachineMenu menu, Element.Custom widget) {} - - /** Encodes inside a bounded buffer, before any packet is sent. */ - public byte[] encode(RegistryAccess registries, T value) { - var buffer = - new RegistryFriendlyByteBuf(Unpooled.buffer(Math.min(64, maxBytes), maxBytes), registries); - try { - codec.encode(buffer, value); - byte[] data = new byte[buffer.readableBytes()]; - buffer.readBytes(data); - return data; - } finally { - buffer.release(); - } +/** Compatibility specialization of the common element-action transport for custom widgets. */ +public final class WidgetAction extends ElementAction { + private final BiPredicate widgetHandler; + + public WidgetAction( + ResourceLocation id, + StreamCodec codec, + int maxBytes, + BiPredicate handler) { + super( + id, + codec, + maxBytes, + (context, value) -> + handler.test( + new Context(context.player(), context.menu(), (CustomElement) context.element()), + value)); + widgetHandler = Objects.requireNonNull(handler); } - /** Decodes exactly one action, rejecting trailing data as well as oversized payloads. */ - public T decode(RegistryAccess registries, byte[] data) { - if (data.length > maxBytes) throw new IllegalArgumentException("Widget action is too large"); - var buffer = new RegistryFriendlyByteBuf(Unpooled.wrappedBuffer(data), registries); - try { - T value = Objects.requireNonNull(codec.decode(buffer)); - if (buffer.isReadable()) throw new IllegalArgumentException("Trailing widget action data"); - return value; - } finally { - buffer.release(); - } + public BiPredicate handler() { + return widgetHandler; } public boolean dispatch(Context context, byte[] data) { - T value; - try { - value = decode(context.player().registryAccess(), data); - } catch (RuntimeException malformed) { - return false; - } - return handler.test(context, value); + return super.dispatch( + new ElementAction.Context(context.player(), context.menu(), context.widget()), data); } + + public record Context(ServerPlayer player, MachineMenu menu, CustomElement widget) {} } diff --git a/src/main/java/zone/moddev/mc/lib/gui/WidgetType.java b/src/main/java/zone/moddev/mc/lib/gui/WidgetType.java index 311e567e..05ab2fbc 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/WidgetType.java +++ b/src/main/java/zone/moddev/mc/lib/gui/WidgetType.java @@ -4,8 +4,8 @@ import java.util.Set; import java.util.function.Consumer; -import zone.moddev.mc.lib.gui.load.BoundValue.Kind; -import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.elements.CustomElement; +import zone.moddev.mc.lib.gui.value.ValueType; /** * A mod-defined leaf widget's common-side contract. Register it during mod construction, before @@ -15,10 +15,10 @@ * actions listed here may be invoked by this widget. Their handlers run on the server. */ public record WidgetType( - Set valueKinds, - Consumer validator, + Set> valueKinds, + Consumer validator, java.util.List> actions) { - public WidgetType(Set valueKinds, Consumer validator) { + public WidgetType(Set> valueKinds, Consumer validator) { this(valueKinds, validator, java.util.List.of()); } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/BehaviorCapabilities.java b/src/main/java/zone/moddev/mc/lib/gui/client/BehaviorCapabilities.java new file mode 100644 index 00000000..f7610247 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/BehaviorCapabilities.java @@ -0,0 +1,15 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.Optional; + +/** Optional behavior contracts, including presentation and ingredient drops through adapters. */ +public interface BehaviorCapabilities { + Optional capability(Class type); + + static Optional find(ElementBehavior behavior, Class type) { + if (type.isInstance(behavior)) return Optional.of(type.cast(behavior)); + return behavior instanceof BehaviorCapabilities provider + ? provider.capability(type) + : Optional.empty(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/BuiltinElementClients.java b/src/main/java/zone/moddev/mc/lib/gui/client/BuiltinElementClients.java new file mode 100644 index 00000000..3f87ee84 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/BuiltinElementClients.java @@ -0,0 +1,128 @@ +package zone.moddev.mc.lib.gui.client; + +import static zone.moddev.mc.lib.gui.load.BuiltinElements.*; + +import java.util.List; + +/** One client attachment per built-in type, using the same public API as mods. */ +final class BuiltinElementClients { + private BuiltinElementClients() {} + + static void registerRenderers(ElementRenderers registry) { + declarations().forEach(declaration -> declaration.registerRenderers(registry)); + } + + static void registerBehaviors(ElementBehaviors registry) { + declarations().forEach(declaration -> declaration.registerBehaviors(registry)); + } + + private static List> declarations() { + var slots = new SlotRenderer(); + return List.of( + ElementClient.of(FILTER_BOX).renderer((element, c) -> {}), + ElementClient.of(FILTER_TOGGLE) + .renderer(FilterToggleBehavior::render) + .behavior(FilterToggleBehavior::new), + ElementClient.of(FILTER_NUMBER) + .renderer(FilterNumberBehavior::render) + .behavior(FilterNumberBehavior::new), + ElementClient.of(FILTER_SAMPLES) + .renderer(FilterSamplesBehavior::render) + .behavior(FilterSamplesBehavior::new), + ElementClient.of(FILTER_TEXT) + .renderer( + (element, c) -> + c.access() + .capability(element, FilterControlPresentation.class) + .ifPresent(view -> view.render(c))) + .behavior(FilterTextBehavior::new), + ElementClient.of(FILTER_CUSTOM) + .renderer( + (element, c) -> + c.access() + .capability(element, FilterControlPresentation.class) + .ifPresent(view -> view.render(c))) + .behavior(FilterCustomBehavior::new), + ElementClient.of(SPRITE).renderer(SpriteRenderer::draw), + ElementClient.of(PROGRESS) + .renderer((element, c) -> SpriteRenderer.draw(element.spriteElement(), c)), + ElementClient.of(COLOURED_GROUP) + .renderer((element, c) -> {}) + .overlay( + (element, c) -> + ColouredGroupRenderer.draw(element, c.graphics(), c.bounds(), false)) + .behavior(ColouredGroupBehavior::new), + ElementClient.of(BOX).renderer((element, c) -> {}), + ElementClient.of(SPACER).renderer((element, c) -> {}), + ElementClient.of(INVENTORY_CONTROLS).renderer((element, c) -> {}), + ElementClient.of(PLAYER_SLOTS).renderer((element, c) -> {}), + ElementClient.of(INVENTORY).renderer((element, c) -> {}), + ElementClient.of(CONTAINER_INVENTORY).renderer(ContainerCellRenderer::inventory), + ElementClient.of(CONTAINER_SLOT).renderer(ContainerCellRenderer::slot), + ElementClient.of(GROUP) + .renderer(ContainerElementRenderer::group) + .behavior(GroupBehavior::new), + ElementClient.of(RADIO_GROUP) + .renderer(ContainerElementRenderer::radioGroup) + .behavior(RadioGroupBehavior::new), + ElementClient.of(VIEW).renderer(ContainerElementRenderer::view), + ElementClient.of(CONTEXT) + .renderer(ContainerElementRenderer::context) + .behavior(ContextBehavior::new), + ElementClient.of(SIDE_TAB).renderer(ContainerElementRenderer::sideTab), + ElementClient.of(BUTTON_CONTROL) + .renderer(ButtonRenderer::draw) + .behavior(ButtonBehavior::new), + ElementClient.of(LABEL).renderer(TextControlRenderer::label), + ElementClient.of(CHECKBOX).renderer(TextControlRenderer::checkbox), + ElementClient.of(RADIO).renderer(TextControlRenderer::radio).behavior(RadioBehavior::new), + ElementClient.of(SLOT) + .renderer( + (element, c) -> + slots.draw( + c.graphics(), + c.bounds(), + element, + c.access().contents(element), + c.access().hint(element))) + .behavior(SlotBehavior::new), + ElementClient.of(GAUGE) + .renderer( + (element, c) -> + GaugeRenderer.draw( + c.graphics(), + c.bounds(), + element.orientation(), + element.fill(c.value()), + c.ratio())), + ElementClient.of(FLUID_TANK) + .renderer( + (element, c) -> + GaugeRenderer.draw( + c.graphics(), + c.bounds(), + element.orientation(), + element.fill(c.value()), + c.ratio())), + ElementClient.of(ENERGY_GAUGE) + .renderer( + (element, c) -> + EnergyGaugeRenderer.draw(c.graphics(), c.bounds(), element, c.ratio())), + ElementClient.of(METER).renderer(MeterRenderer::draw), + ElementClient.of(SIDE_CONFIG) + .renderer(SideConfigRenderer::draw) + .renderer( + ElementRenderers.Pass.OVERLAY, + ElementRenderers.Clip.VIEWPORT, + SideConfigRenderer::highlight) + .behavior(SideConfigBehavior::new), + ElementClient.of(POPOUT) + .renderer(PopoutRenderer::tab) + .foreground(PopoutRenderer::body) + .top(PopoutRenderer::tab), + ElementClient.of(CUSTOM) + .renderer(WidgetRenderers.GLOBAL.elementRenderer()) + .behavior(CustomWidgetBehavior::new), + ElementClient.of(NAVIGABLE).behavior(NavigableBehavior::new)); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ButtonBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/ButtonBehavior.java new file mode 100644 index 00000000..d0b91faf --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ButtonBehavior.java @@ -0,0 +1,20 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.Optional; + +import zone.moddev.mc.lib.gui.model.Text; +import zone.moddev.mc.lib.gui.model.elements.ButtonControl; + +/** Button-local tooltip labels; navigation is shared by all default element behaviors. */ +public class ButtonBehavior extends DefaultElementBehavior implements ValueLabelProvider { + private final ButtonControl button; + + public ButtonBehavior(ButtonControl button, ElementAccess access) { + super(access); + this.button = button; + } + + public Optional valueLabel() { + return button.labelFor(access.value()); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ButtonRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/ButtonRenderer.java new file mode 100644 index 00000000..836edf2a --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ButtonRenderer.java @@ -0,0 +1,46 @@ +package zone.moddev.mc.lib.gui.client; + +import static zone.moddev.mc.lib.gui.client.ElementDrawing.*; + +import zone.moddev.mc.lib.gui.model.elements.ButtonControl; + +/** Shared presentation for built-in and mod-defined buttons. */ +public final class ButtonRenderer { + private ButtonRenderer() {} + + public static void draw(ButtonControl button, ElementRenderers.Context c) { + var g = c.graphics(); + var r = c.bounds(); + sprite( + g, + button.flat() + ? c.hovered() ? "button_flat_highlighted" : "button_flat" + : c.hovered() ? "minecraft:widget/button_highlighted" : "minecraft:widget/button", + r); + button + .text() + .ifPresent( + text -> + g.drawCenteredString( + c.access().font(), + c.access().text(text), + r.x() + r.width() / 2, + r.y() + 6, + 0xffffff)); + int size = button.flat() ? 12 : 16; + button + .iconFor(c.value()) + .ifPresent( + icon -> + blit( + g, + icon, + r.x() + (r.width() - size) / 2, + r.y() + (r.height() - size) / 2, + size, + size)); + button + .badge(c.value(), c.maximum()) + .ifPresent(icon -> g.blitSprite(icon, r.right() - 6, r.bottom() - 6, 5, 5)); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/InventoryColorButtonBar.java b/src/main/java/zone/moddev/mc/lib/gui/client/ColourButtonBar.java similarity index 61% rename from src/main/java/zone/moddev/mc/lib/gui/client/InventoryColorButtonBar.java rename to src/main/java/zone/moddev/mc/lib/gui/client/ColourButtonBar.java index 1a99a624..a0b08a3b 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/InventoryColorButtonBar.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ColourButtonBar.java @@ -1,40 +1,47 @@ package zone.moddev.mc.lib.gui.client; -import java.util.List; -import java.util.function.IntConsumer; +import java.util.*; +import java.util.function.Consumer; import net.minecraft.client.gui.GuiGraphics; -import zone.moddev.mc.lib.api.automation.ResourceGroup; -import zone.moddev.mc.lib.gui.layout.InventoryColorOrder; import zone.moddev.mc.lib.gui.layout.Rect; -import zone.moddev.mc.lib.gui.model.Screen; -/** Reusable, left-aligned inventory selector with automatic ordering and bounded overflow. */ -public final class InventoryColorButtonBar { +/** Reusable selector for ordered opaque IDs and RGB colours, with bounded overflow. */ +public final class ColourButtonBar { + public record Entry(K id, int colour) { + public Entry { + Objects.requireNonNull(id); + if ((colour & 0xff000000) != 0) throw new IllegalArgumentException("Expected an RGB colour"); + } + } + private static final int SIZE = 14, PITCH = 18, ARROW = 10; - private final List> groups; - private final List order; - private final IntConsumer select; + private final List> entries; + private final List order; + private final Consumer select; private Rect bounds; - private int offset, selected = -1; + private int offset; + private K selected; - public InventoryColorButtonBar(Screen screen, List> groups, IntConsumer select) { - this.groups = List.copyOf(groups); - order = InventoryColorOrder.of(screen, groups); - this.select = select; + public ColourButtonBar(List> entries, Consumer select) { + this.entries = List.copyOf(entries); + order = this.entries.stream().map(Entry::id).toList(); + if (new HashSet<>(order).size() != order.size()) + throw new IllegalArgumentException("Button IDs must be unique"); + this.select = Objects.requireNonNull(select); } - public List order() { + public List order() { return order; } - public void layout(Rect bounds, int selected) { + public void layout(Rect bounds, K selected) { boolean resized = !java.util.Objects.equals(this.bounds, bounds); this.bounds = bounds; if (bounds == null) return; int capacity = capacity(); - if (this.selected != selected || resized) { + if (!Objects.equals(this.selected, selected) || resized) { int index = order.indexOf(selected); if (index < offset) offset = index; if (index >= offset + capacity) offset = index - capacity + 1; @@ -63,14 +70,14 @@ private Rect button(int visible) { SIZE); } - /** Draws the bar and returns the hovered inventory index, or -1. */ - public int render(GuiGraphics graphics, int mouseX, int mouseY, boolean open) { - if (bounds == null) return -1; - int hovered = -1; + /** Draws the bar and returns the hovered ID, if any. */ + public Optional render(GuiGraphics graphics, int mouseX, int mouseY, boolean open) { + if (bounds == null || entries.isEmpty()) return Optional.empty(); + K hovered = null; for (int i = 0; i < capacity() && offset + i < order.size(); i++) { - int group = order.get(offset + i); + K group = order.get(offset + i); var rect = button(i); - boolean active = open && group == selected; + boolean active = open && Objects.equals(group, selected); int border = active ? 2 : 1; graphics.fill( rect.x(), rect.y(), rect.right(), rect.bottom(), active ? 0xffeeeeee : 0xff555555); @@ -79,14 +86,14 @@ public int render(GuiGraphics graphics, int mouseX, int mouseY, boolean open) { rect.y() + border, rect.right() - border, rect.bottom() - border, - 0xff000000 | groups.get(group).colour()); + 0xff000000 | entries.get(offset + i).colour()); if (rect.contains(mouseX, mouseY)) hovered = group; } if (overflow()) { arrow(graphics, bounds.x() + 2, offset > 0, false); arrow(graphics, bounds.right() - 7, offset + capacity() < order.size(), true); } - return hovered; + return Optional.ofNullable(hovered); } private void arrow(GuiGraphics graphics, int x, boolean enabled, boolean right) { @@ -98,7 +105,7 @@ private void arrow(GuiGraphics graphics, int x, boolean enabled, boolean right) } public boolean click(double x, double y) { - if (bounds == null || !bounds.contains(x, y)) return false; + if (bounds == null || entries.isEmpty() || !bounds.contains(x, y)) return false; if (overflow() && x < bounds.x() + ARROW) offset--; else if (overflow() && x >= bounds.right() - ARROW) offset++; else @@ -112,7 +119,7 @@ public boolean click(double x, double y) { } public boolean scroll(double x, double y, double amount) { - if (bounds == null || !bounds.contains(x, y)) return false; + if (bounds == null || entries.isEmpty() || !bounds.contains(x, y)) return false; offset += (int) -amount; clamp(); return true; diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ColouredGroupBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/ColouredGroupBehavior.java new file mode 100644 index 00000000..cf72d2b2 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ColouredGroupBehavior.java @@ -0,0 +1,24 @@ +package zone.moddev.mc.lib.gui.client; + +import net.minecraft.client.gui.GuiGraphics; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.containers.ColouredGroupElement; + +/** The wrapper owns its existing outline and how external controls emphasize it. */ +public class ColouredGroupBehavior extends DefaultElementBehavior implements HighlightPresentation { + private final ColouredGroupElement group; + + public ColouredGroupBehavior(ColouredGroupElement group, ElementAccess access) { + super(access); + this.group = group; + } + + public void highlight(GuiGraphics graphics, Rect bounds, ElementHighlight request) { + // Its registered overlay renderer already supplies the persistent decoration. + if (request.kind() == ElementHighlight.Kind.DECORATION) return; + if (request.kind() == ElementHighlight.Kind.EMPHASIS) + ColouredGroupRenderer.draw(group, graphics, bounds, true); + else request.render(graphics, bounds); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ColouredGroupRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/ColouredGroupRenderer.java new file mode 100644 index 00000000..f24f7c69 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ColouredGroupRenderer.java @@ -0,0 +1,17 @@ +package zone.moddev.mc.lib.gui.client; + +import net.minecraft.client.gui.GuiGraphics; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.containers.ColouredGroupElement; + +/** Outline pass runs after child backgrounds. */ +public final class ColouredGroupRenderer { + private ColouredGroupRenderer() {} + + public static void draw( + ColouredGroupElement group, GuiGraphics graphics, Rect bounds, boolean highlighted) { + Rect r = group.outline(bounds, highlighted); + graphics.renderOutline(r.x(), r.y(), r.width(), r.height(), group.colour()); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ContainerCellRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/ContainerCellRenderer.java new file mode 100644 index 00000000..fd7bb587 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ContainerCellRenderer.java @@ -0,0 +1,63 @@ +package zone.moddev.mc.lib.gui.client; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.containers.ContainerInventoryElement; +import zone.moddev.mc.lib.gui.model.elements.ContainerSlotElement; + +/** Registered drawing for virtual container cells; independent of drawer input and transport. */ +final class ContainerCellRenderer { + private static final SlotRenderer SLOTS = new SlotRenderer(); + + private ContainerCellRenderer() {} + + static void inventory(ContainerInventoryElement element, ElementRenderers.Context c) { + var headings = element.headings(); + for (int i = 0; i < headings.size(); i++) + label(c, headings.get(i).getString(), element.headingBounds(i)); + for (var slot : element.slots()) + label(c, element.cellLabel(slot).getString(), element.labelBounds(slot)); + } + + private static void label(ElementRenderers.Context c, String text, Rect relative) { + var font = c.access().font(); + c.graphics() + .drawString( + font, + font.plainSubstrByWidth(text, relative.width()), + c.bounds().x() + relative.x(), + c.bounds().y() + relative.y(), + 0xff404040, + false); + } + + static void slot(ContainerSlotElement element, ElementRenderers.Context c) { + var g = c.graphics(); + var b = c.bounds(); + if (!element.available()) { + g.drawString( + c.access().font(), element.placeholder(), b.x() + 6, b.y() + 5, 0xff666666, false); + return; + } + var contents = c.access().contents(element); + SLOTS.draw(g, b, element, contents, c.access().hint(element)); + int[] xy = element.itemPosition(b); + var original = new Rect(xy[0], xy[1], 16, 16); + var item = element.nativeBounds(original); + if (element.renderNative() && !contents.isEmpty()) { + g.pose().pushPose(); + try { + g.pose().translate(item.x(), item.y(), 0); + g.pose().scale(item.width() / 16f, item.height() / 16f, 1); + g.renderItem(contents, 0, 0); + g.renderItemDecorations(c.access().font(), contents, 0, 0); + } finally { + g.pose().popPose(); + } + } + if (element.cellHitBounds(b).contains(c.mouseX(), c.mouseY()) + || c.value() instanceof ContainerSlotElement.Display display && display.selected()) { + var hit = element.highlightBounds(original); + g.fill(hit.x(), hit.y(), hit.right(), hit.bottom(), 0x80ffffff); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ContainerDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/ContainerDrawer.java index b1e58f8b..c9b89aed 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/ContainerDrawer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ContainerDrawer.java @@ -1,28 +1,37 @@ package zone.moddev.mc.lib.gui.client; -import java.util.List; import java.util.LinkedHashSet; -import net.minecraft.world.item.ItemStack; +import java.util.List; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; + import net.neoforged.neoforge.network.PacketDistributor; import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout; import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.containers.ContainerInventoryElement; +import zone.moddev.mc.lib.gui.model.elements.SlotElement; import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; +import zone.moddev.mc.lib.gui.runtime.EquippedContainers; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Item-rendered equipment tab and paged, server-synced storage cells. */ -final class ContainerDrawer { - private final boolean equipment; - private final int color; - private final MachineScreen screen; +public class ContainerDrawer extends Drawer { private final ContainerDrawerMenu menu; private ContainerDrawerLayout layout; + private ContainerInventoryElement inventory; + private ElementFragment cells; + private ContainerInventoryElement.Page cellPage; + private String cellSource; private boolean handledPress; + private int elementButton = -1; + private long cellRevision = Long.MIN_VALUE; private int mouseX, mouseY; private final LinkedHashSet dragSlots = new LinkedHashSet<>(); private int dragButton = -1, firstSlot = -1, lastClickSlot = -1; @@ -33,38 +42,54 @@ final class ContainerDrawer { private String lastSource = ""; private int slotAt(double x, double y) { - if (!bodyVisible()) return -1; - var state = menu.state(); - for (int i = 0; i < Math.min(state.contents().size(), layout.pageSize()); i++) - if (layout.cell(i).contains(x, y)) return state.offset() + i; - return -1; + if (!bodyVisible() || !layout.body().contains(x, y)) return -1; + prepareCells(); + var hit = cells.hit(x, y); + return hit instanceof SlotElement slot ? slot.index() : -1; + } + + private boolean inputLocked() { + return screen.drawers().pending(edge()) || inventoryPending(); + } + + private void discardCells() { + if (cells != null) cells.removed(); + cells = null; + inventory = null; + cellPage = null; + cellSource = null; + } + + private boolean readyCells() { + layout(); + if (!bodyVisible()) { + discardCells(); + return false; + } + prepareCells(); + return true; + } + + private boolean cellInputArea(double x, double y) { + return layout.body().contains(x, y) + && !layout.selection().contains(x, y) + && !layout.previous().contains(x, y) + && !layout.next().contains(x, y); } private boolean inventoryPending() { - return screen.getMenu().containerDrawer.pendingInventory() - || screen.getMenu().curiosDrawer.pendingInventory(); + return screen.getMenu().components().pendingInventory(); } private boolean inventoryTarget(double x, double y) { // Outside clicks can drop the carried stack. Settings inside the panel remain usable. - return !screen.onDrawerTab(x, y) + return !screen.drawers().tabContains(x, y) && screen.hasClickedOutside(x, y, screen.guiLeft(), screen.guiTop(), 0) - || screen.getMenu().slots.stream().anyMatch(slot -> overSlot(slot, x, y)); - } - - private boolean overSlot(net.minecraft.world.inventory.Slot slot, double x, double y) { - return x >= screen.guiLeft() + slot.x - && x < screen.guiLeft() + slot.x + 16 - && y >= screen.guiTop() + slot.y - && y < screen.guiTop() + slot.y + 16; + || screen.nativeSlotAt(x, y, false) != null; } private int playerSlotAt(double x, double y) { - for (var slot : screen.getMenu().slots) - if (slot.container == Minecraft.getInstance().player.getInventory() - && slot.getContainerSlot() < 36 - && overSlot(slot, x, y)) return ContainerDrawerMenu.MAX_SLOTS + slot.getContainerSlot(); - return -1; + return menu.targets().playerTarget(screen.nativeSlotAt(x, y)); } void cancelGesture() { @@ -77,21 +102,15 @@ void cancelGesture() { private boolean gestureValid() { return bodyVisible() - && !menu.pending() + && !inputLocked() && menu.state().revision() == dragRevision && layout.body().equals(dragBody) && ItemStack.matches(screen.getMenu().getCarried(), dragStack); } private void addDragSlot(int slot) { - if (slot < 0 || dragSlots.size() >= ContainerDrawerMenu.MAX_PAGE + 36) return; - var stored = - slot >= ContainerDrawerMenu.MAX_SLOTS - ? Minecraft.getInstance() - .player - .getInventory() - .getItem(slot - ContainerDrawerMenu.MAX_SLOTS) - : menu.state().contents().get(slot - menu.state().offset()); + if (!menu.targets().contains(slot) || dragSlots.size() >= menu.targets().maxTargets()) return; + var stored = menu.targets().contents(slot); if ((stored.isEmpty() || ItemStack.isSameItemSameComponents(stored, dragStack)) && stored.getCount() < dragStack.getMaxStackSize() && dragSlots.size() < dragStack.getCount()) dragSlots.add(slot); @@ -109,7 +128,7 @@ private void beginDrag(int slot, int button) { } private void collect() { - if (!menu.pending()) { + if (!inputLocked()) { collectQueued = false; var state = menu.state(); if (bodyVisible() @@ -121,31 +140,37 @@ private void collect() { } } - ContainerDrawer(MachineScreen screen) { - this(screen, false); - } - - ContainerDrawer(MachineScreen screen, boolean equipment) { - this.equipment = equipment; - color = equipment ? 0xa6a0ca : 0xc5aa7b; - this.screen = screen; - menu = equipment ? screen.getMenu().curiosDrawer : screen.getMenu().containerDrawer; + public ContainerDrawer( + MachineScreen screen, + zone.moddev.mc.lib.gui.drawer.DrawerDefinition definition, + boolean equipment) { + super(screen, definition, (graphics, x, y) -> {}, 0xc5aa7b); + menu = + equipment + ? screen.getMenu().components().get(BuiltinMenuComponents.CURIOS) + : screen.getMenu().components().get(BuiltinMenuComponents.BACKPACK); } - private boolean available() { + public boolean available() { return !menu.state().icons().isEmpty(); } private void layout() { int slots = available() ? menu.state().sizes().get(menu.state().selected()) : 0; layout = - equipment - ? ContainerDrawerLayout.equipment( - screen.panelBounds(), - screen.drawerBottom(), - slots, - !screen.getMenu().containerDrawer.state().icons().isEmpty()) - : ContainerDrawerLayout.of(screen.panelBounds(), screen.drawerBottom(), slots); + ContainerDrawerLayout.of( + screen.panelBounds(), + screen.drawerBottom(), + slots, + super.tabBounds(), + menu.policy().layout(), + edge(), + screen.width); + } + + public ContainerDrawerLayout contentLayout() { + layout(); + return layout; } private boolean bodyVisible() { @@ -153,103 +178,265 @@ private boolean bodyVisible() { } private void send(int kind, int argument, int size) { + if (!menu.allows(kind)) return; menu.request(kind, argument, size) .ifPresent( request -> { - screen.rememberContainerIntent(equipment, menu.state(), kind, argument); + var state = menu.state(); + if (!state.keys().isEmpty()) { + if (kind == ContainerDrawerMenu.TOGGLE && state.open()) + remember(false, selection()); + else if (kind == ContainerDrawerMenu.TOGGLE + || kind == ContainerDrawerMenu.SELECT + || kind == ContainerDrawerMenu.PAGE) { + int index = kind == ContainerDrawerMenu.SELECT ? argument : state.selected(); + remember( + true, + new Selection( + state.keys().get(index), + kind == ContainerDrawerMenu.PAGE ? argument : state.offset())); + } + } PacketDistributor.sendToServer(request); }); } - void close() { + public void close() { + cancelGesture(); + if (cells != null) cells.cancelInteraction(); if (menu.state().open() && !menu.pending()) - send(ContainerDrawerMenu.TOGGLE, 0, menu.state().pageSize()); + menu.request(ContainerDrawerMenu.TOGGLE, 0, menu.state().pageSize()) + .ifPresent(PacketDistributor::sendToServer); } - List exclusions() { + public List exclusions() { layout(); if (!available()) return List.of(); return bodyVisible() ? List.of(layout.rail(), layout.body()) : List.of(layout.tab()); } - boolean contains(double x, double y) { + public boolean contains(double x, double y) { layout(); return available() && (layout.tab().contains(x, y) || bodyVisible() && (layout.body().contains(x, y) || layout.rail().contains(x, y))); } - boolean tabContains(double x, double y) { + public boolean tabContains(double x, double y) { layout(); return available() && layout.tab().contains(x, y); } + protected EquippedContainers.Presentation presentation() { + return menu.state().presentations().get(menu.state().selected()); + } + + /** Provider defaults may also be customized by a client drawer subclass. */ + protected List gestureInstructions() { + return presentation().gestureRows(); + } + private Component title() { - return equipment - ? Component.translatable("screen.mmdlib.curios.title") - : menu.state().icons().get(menu.state().selected()).getHoverName(); + return presentation().title(); + } + + @Override + public int colour() { + return available() ? presentation().colour() : super.colour(); + } + + @Override + public IconSprite iconSprite() { + if (!available()) return super.iconSprite(); + return presentation() + .icon() + .map(IconSprite::atlas) + .orElseGet( + () -> IconSprite.item(() -> menu.state().icons().get(menu.state().selected()).copy())); + } + + /** + * Called when source, page metadata or geometry changes. Override or supply custom slot + * factories. + */ + protected ContainerInventoryElement createInventory(ContainerInventoryElement.Page page) { + return new ContainerInventoryElement(page); + } + + private void prepareCells() { + var state = menu.state(); + int count = Math.min(state.contents().size(), layout.pageSize()); + var bounds = new java.util.ArrayList(); + for (int i = 0; i < count; i++) + bounds.add(layout.cell(i).offset(-layout.body().x(), -layout.body().y())); + var descriptions = + state.descriptions().isEmpty() + ? List.of() + : state.descriptions().subList(0, count); + var page = + new ContainerInventoryElement.Page( + state.offset(), + layout.body().width(), + layout.body().height(), + bounds, + descriptions, + presentation()); + String source = state.keys().get(state.selected()); + if (!page.equals(cellPage) || !source.equals(cellSource)) { + if (cells != null) cells.removed(); + cellPage = page; + cellSource = source; + inventory = java.util.Objects.requireNonNull(createInventory(page)); + cells = + new ElementFragment( + inventory, + page.width(), + new ElementFragment.Host() { + public ResourceLocation screenId() { + return ResourceLocation.parse("mmdlib:container_drawer"); + } + + public ItemStack contents(SlotElement element) { + int local = element.index() - menu.state().offset(); + return local >= 0 && local < menu.state().contents().size() + ? menu.state().contents().get(local).copy() + : ItemStack.EMPTY; + } + + public Object value(Element element, boolean maximum) { + return inventory.drawerValue( + element, + maximum, + element instanceof SlotElement slot ? contents(slot) : ItemStack.EMPTY, + element instanceof SlotElement slot + && dragButton >= 0 + && dragSlots.contains(slot.index())); + } + + public Rect hitBounds(Element element, Rect frame) { + return element instanceof SlotElement slot ? slot.cellHitBounds(frame) : frame; + } + + public boolean inputEnabled() { + return !inputLocked(); + } + + public boolean visible(Element element) { + return bodyVisible(); + } + + public boolean pending(Element element) { + return inputLocked(); + } + + public ItemStack carried() { + return screen.getMenu().getCarried().copy(); + } + }); + } + if (cellRevision != state.revision()) { + cells.cancelInteraction(); + cellRevision = state.revision(); + } + cells.place(layout.body().x(), layout.body().y(), layout.body()); } - void renderTab(GuiGraphics g) { + /** Composed virtual-cell tooltip, including all parent rows after the native item hook. */ + public List cellTooltip(int x, int y) { layout(); - if (!available()) return; - SideDrawerRenderer.tab(g, layout.tab(), color); - if (equipment) - emptyIcon( - g, - ResourceLocation.parse("curios:slot/empty_curio_slot"), - layout.tab().x() + 3, - layout.tab().y() + 4); - else - g.renderItem( - menu.state().icons().get(menu.state().selected()), - layout.tab().x() + 3, - layout.tab().y() + 4); - } - - private static void emptyIcon(GuiGraphics g, ResourceLocation icon, int x, int y) { - g.blit( - x, - y, - 0, - 16, - 16, - Minecraft.getInstance() - .getTextureAtlas(net.minecraft.world.inventory.InventoryMenu.BLOCK_ATLAS) - .apply(icon)); - } - - private static String slotName(zone.moddev.mc.lib.gui.runtime.EquippedContainers.SlotInfo info) { - String key = "curios.identifier." + info.identifier(); - return (net.minecraft.client.resources.language.I18n.exists(key) - ? Component.translatable(key).getString() - : info.identifier()) - + (info.index() == 0 ? "" : " " + (info.index() + 1)); - } - - void render(GuiGraphics g, int mx, int my) { + int index = slotAt(x, y); + if (index < 0) return List.of(); + var slot = inventory.slots().stream().filter(s -> s.index() == index).findFirst().orElseThrow(); + var stack = menu.state().contents().get(index - menu.state().offset()).copy(); + var rows = cells.tooltip(slot, x, y); + return stack.isEmpty() + ? rows + : NativeSlotRenderer.itemTooltip( + slot, stack, Screen.getTooltipFromItem(Minecraft.getInstance(), stack), rows); + } + + @Override + public void removed() { + discardCells(); + elementButton = -1; + cancelGesture(); + } + + @Override + public void tick() { + if (readyCells()) cells.tick(); + } + + public Rect tabBounds() { + layout(); + return layout.tab(); + } + + public boolean open() { + return menu.state().open(); + } + + public boolean pending() { + return menu.pending(); + } + + public void resized() { + if (cells != null) cells.cancelInteraction(); + cancelGesture(); + } + + public Selection selection() { + var state = menu.state(); + return state.keys().isEmpty() + ? new Selection() + : new Selection(state.keys().get(state.selected()), state.offset()); + } + + public boolean restore(Selection selection) { + if (!screen.getMenu().bound.screen().playerInventory()) return true; + var state = menu.state(); + if (state.keys().isEmpty() || pending()) return false; + int index = state.keys().indexOf(selection.key()); + if (index < 0) return true; + if (!state.open()) + menu.request(ContainerDrawerMenu.TOGGLE, 0, state.pageSize()) + .ifPresent(PacketDistributor::sendToServer); + else if (state.selected() != index) + menu.request(ContainerDrawerMenu.SELECT, index, state.pageSize()) + .ifPresent(PacketDistributor::sendToServer); + else { + int offset = Math.min(selection.offset(), Math.max(0, state.sizes().get(index) - 1)); + offset = offset / state.pageSize() * state.pageSize(); + if (state.offset() != offset) + menu.request(ContainerDrawerMenu.PAGE, offset, state.pageSize()) + .ifPresent(PacketDistributor::sendToServer); + activated(); + return true; + } + return false; + } + + public void render(GuiGraphics g, int mx, int my) { layout(); mouseX = mx; mouseY = my; - if (!available()) return; + if (!available()) { + discardCells(); + return; + } var state = menu.state(); - var icon = state.icons().get(state.selected()); - if (bodyVisible()) - SideDrawerRenderer.open( - g, layout.tab(), layout.body(), zone.moddev.mc.lib.gui.model.Element.Edge.LEFT, color); - renderTab(g); + drawFrame(g, layout.tab(), layout.body(), bodyVisible()); if (dragButton >= 0 && !gestureValid()) cancelGesture(); if (collectQueued) collect(); - if (!bodyVisible()) return; + if (!bodyVisible()) { + discardCells(); + return; + } if (layout.pageSize() != state.pageSize() && !menu.pending()) send(ContainerDrawerMenu.PAGE, state.offset(), layout.pageSize()); var font = Minecraft.getInstance().font; var body = layout.body(); - // The selected item itself is also the drawer header, including item tint/model components. - if (equipment) - emptyIcon( - g, ResourceLocation.parse("curios:slot/empty_curio_slot"), body.x() + 4, body.y() + 3); - else g.renderItem(icon, body.x() + 4, body.y() + 3); + iconSprite().draw(g, body.x() + 4, body.y() + 3); if (body.width() > 45) g.drawString( font, @@ -258,56 +445,8 @@ void render(GuiGraphics g, int mx, int my) { body.y() + 7, 0xff404040, false); - if (equipment) { - g.drawString( - font, - Component.translatable("screen.mmdlib.curios.equipment"), - body.x() + 6, - body.y() + 22, - 0xff404040, - false); - g.drawString( - font, - Component.translatable("screen.mmdlib.curios.functional.short"), - body.right() - 40, - body.y() + 22, - 0xff404040, - false); - g.drawString( - font, - Component.translatable("screen.mmdlib.curios.cosmetic.short"), - body.right() - 20, - body.y() + 22, - 0xff404040, - false); - } - for (int i = 0; i < Math.min(state.contents().size(), layout.pageSize()); i++) { - var cell = layout.cell(i); - var info = i < state.descriptions().size() ? state.descriptions().get(i) : null; - if (equipment && info == null) continue; - if (equipment && i % 2 == 0) - g.drawString( - font, - font.plainSubstrByWidth(slotName(info), body.width() - 54), - body.x() + 6, - cell.y() + 5, - 0xff404040, - false); - if (info != null && !info.available()) { - g.drawString(font, "-", cell.x() + 6, cell.y() + 5, 0xff666666, false); - continue; - } - g.blitSprite(ResourceLocation.parse("mmdlib:slot"), cell.x(), cell.y(), 18, 18); - var stack = state.contents().get(i); - if (stack.isEmpty() && info != null) emptyIcon(g, info.icon(), cell.x() + 1, cell.y() + 1); - if (!stack.isEmpty()) { - g.renderItem(stack, cell.x() + 1, cell.y() + 1); - g.renderItemDecorations(font, stack, cell.x() + 1, cell.y() + 1); - } - // Generic insertion may route to another slot. Highlight targets without inventing counts. - if (cell.contains(mx, my) || dragButton >= 0 && dragSlots.contains(state.offset() + i)) - g.fill(cell.x() + 1, cell.y() + 1, cell.right() - 1, cell.bottom() - 1, 0x80ffffff); - } + prepareCells(); + cells.render(g, mx, my); var previous = layout.previous(); var next = layout.next(); g.drawString( @@ -341,61 +480,34 @@ void render(GuiGraphics g, int mx, int my) { } } - void tooltip(GuiGraphics g, int mx, int my) { + public void tooltip(GuiGraphics g, int mx, int my) { if (!contains(mx, my)) return; var font = Minecraft.getInstance().font; var state = menu.state(); if (layout.tab().contains(mx, my)) { var lines = new java.util.ArrayList(); lines.add(title()); - lines.add( - Component.translatable( - equipment ? "screen.mmdlib.curios.toggle" : "screen.mmdlib.container.toggle")); - if (state.open()) - lines.add( - Component.translatable( - equipment ? "screen.mmdlib.curios.store" : "screen.mmdlib.container.store")); - if (!equipment) lines.add(Component.translatable("screen.mmdlib.container.gestures")); + lines.addAll(presentation().tabRows()); + if (state.open()) lines.addAll(presentation().openRows()); + lines.addAll(gestureInstructions()); if (layout.pageSize() == 0) lines.add(Component.translatable("screen.mmdlib.container.room")); g.renderComponentTooltip(font, lines, mx, my); } else if (bodyVisible()) { - if (equipment) { - for (int i = 0; i < Math.min(state.descriptions().size(), layout.pageSize()); i++) { - var info = state.descriptions().get(i); - var cell = layout.cell(i); - if (cell.contains(mx, my) && state.contents().get(i).isEmpty()) { - g.renderComponentTooltip( - font, - List.of( - Component.literal(slotName(info)), - Component.translatable( - !info.available() - ? "screen.mmdlib.curios.unavailable" - : info.cosmetic() - ? "screen.mmdlib.curios.cosmetic" - : "screen.mmdlib.curios.functional")), - mx, - my); - return; - } - } - if (my < layout.body().y() + 34) { - g.renderComponentTooltip( - font, - List.of( - title(), - Component.translatable("screen.mmdlib.curios.columns"), - Component.translatable("screen.mmdlib.curios.store")), - mx, - my); - return; - } + int hit = slotAt(mx, my); + if (hit >= 0) { + var rows = cellTooltip(mx, my); + var stack = state.contents().get(hit - state.offset()).copy(); + if (!rows.isEmpty()) g.renderTooltip(font, rows, stack.getTooltipImage(), stack, mx, my); + return; + } + if (!presentation().headerRows().isEmpty() + && my < layout.body().y() + menu.policy().layout().cellY()) { + var rows = new java.util.ArrayList(); + rows.add(title()); + rows.addAll(presentation().headerRows()); + g.renderComponentTooltip(font, rows, mx, my); + return; } - for (int i = 0; i < Math.min(state.contents().size(), layout.pageSize()); i++) - if (layout.cell(i).contains(mx, my) && !state.contents().get(i).isEmpty()) { - g.renderTooltip(font, state.contents().get(i), mx, my); - return; - } if (layout.selection().contains(mx, my)) g.renderComponentTooltip( font, @@ -410,13 +522,23 @@ void tooltip(GuiGraphics g, int mx, int my) { } } - boolean click(double x, double y, int button) { + public boolean click(double x, double y, int button) { layout(); mouseX = (int) x; mouseY = (int) y; int hit = slotAt(x, y); long now = net.minecraft.Util.getMillis(); - if (!equipment + if (readyCells() && !inputLocked() && dragButton < 0) { + if (elementButton >= 0 || cellInputArea(x, y)) { + if (cells.click(x, y, button)) { + cancelGesture(); + if (elementButton < 0) elementButton = button; + handledPress = true; + return true; + } + } else cells.cancelInteraction(); + } + if (menu.allows(ContainerDrawerMenu.COLLECT) && button == 0 && hit >= 0 && hit == lastClickSlot @@ -428,7 +550,7 @@ boolean click(double x, double y, int button) { lastClickTime = 0; return true; } - if (screen.getMenu().containerDrawer.pending() || screen.getMenu().curiosDrawer.pending()) { + if (inputLocked()) { // Configuration requests do not touch vanilla slots. Only serialize item-changing gestures. boolean blocked = contains(x, y) || inventoryPending() && inventoryTarget(x, y); handledPress = blocked; @@ -439,13 +561,13 @@ boolean click(double x, double y, int button) { if (layout.tab().contains(x, y)) { handledPress = true; if (button == 0) { - if (!state.open()) screen.closeLeftDrawers(); + if (!state.open()) activated(); send(ContainerDrawerMenu.TOGGLE, 0, state.pageSize()); } return true; } if (!bodyVisible()) return false; - if (!equipment + if (menu.allows(ContainerDrawerMenu.DISTRIBUTE) && button >= 0 && button <= 1 && !Screen.hasShiftDown() @@ -457,7 +579,7 @@ boolean click(double x, double y, int button) { return true; } } - if (layout.rail().contains(x, y) && !screen.onDrawerTab(x, y)) { + if (layout.rail().contains(x, y) && !screen.drawers().tabContains(x, y)) { handledPress = true; return true; } @@ -475,8 +597,8 @@ boolean click(double x, double y, int button) { else if (layout.next().contains(x, y)) page(1); else for (int i = 0; i < Math.min(state.contents().size(), layout.pageSize()); i++) - if (layout.cell(i).contains(x, y)) { - if (!equipment && button == 0 && !Screen.hasShiftDown()) { + if (state.offset() + i == hit) { + if (menu.allows(ContainerDrawerMenu.COLLECT) && button == 0 && !Screen.hasShiftDown()) { lastClickSlot = state.offset() + i; lastClickTime = now; lastClickStack = state.contents().get(i).copy(); @@ -490,13 +612,10 @@ boolean click(double x, double y, int button) { } return true; } - if (button == 0 && Screen.hasShiftDown()) { + if (button == 0 && Screen.hasShiftDown() && menu.allows(ContainerDrawerMenu.QUICK_STORE)) { int slot = playerSlotAt(x, y); if (slot >= 0) { - send( - ContainerDrawerMenu.QUICK_STORE, - slot - ContainerDrawerMenu.MAX_SLOTS, - state.pageSize()); + send(ContainerDrawerMenu.QUICK_STORE, menu.targets().playerIndex(slot), state.pageSize()); handledPress = true; return true; } @@ -511,22 +630,27 @@ private void page(int direction) { send(ContainerDrawerMenu.PAGE, offset, state.pageSize()); } - boolean scroll(double x, double y, double amount) { + public boolean scroll(double x, double y, double amount) { if (!contains(x, y) || !menu.state().open()) return false; - if (!menu.pending() && amount != 0) page(amount < 0 ? 1 : -1); + if (readyCells() && !inputLocked() && dragButton < 0 && cells.scroll(x, y, amount)) return true; + if (!inputLocked() && amount != 0) page(amount < 0 ? 1 : -1); return true; } - boolean key(int key, int scan) { + public boolean key(int key, int scan, int modifiers) { layout(); if (key == 256 && dragButton >= 0) { cancelGesture(); return true; } - if (!equipment && bodyVisible() && !menu.pending()) { + if (readyCells() && !inputLocked() && dragButton < 0 && cells.key(key, scan, modifiers)) + return true; + if (menu.allows(ContainerDrawerMenu.SWAP) && bodyVisible() && !inputLocked()) { int slot = slotAt(mouseX, mouseY); if (slot >= 0) { - for (int hotbar = 0; hotbar < 9; hotbar++) + for (int hotbar = 0; + hotbar < Minecraft.getInstance().options.keyHotbarSlots.length; + hotbar++) if (Minecraft.getInstance().options.keyHotbarSlots[hotbar].matches(key, scan)) { cancelGesture(); menu.request(ContainerDrawerMenu.SWAP, hotbar, menu.state().pageSize(), List.of(slot)) @@ -536,7 +660,7 @@ boolean key(int key, int scan) { } } if (key == 256 && menu.state().open()) { - close(); + send(ContainerDrawerMenu.TOGGLE, 0, menu.state().pageSize()); return true; } if (key == 256) return false; @@ -548,13 +672,34 @@ boolean key(int key, int scan) { && !Minecraft.getInstance().options.keyInventory.matches(key, scan); } - void mouseMoved(double x, double y) { + @Override + public boolean character(char character, int modifiers) { + return readyCells() + && !inputLocked() + && dragButton < 0 + && cells.character(character, modifiers); + } + + @Override + public boolean keyReleased(int key, int scan, int modifiers) { + return readyCells() + && !inputLocked() + && dragButton < 0 + && cells.keyReleased(key, scan, modifiers); + } + + public void mouseMoved(double x, double y) { mouseX = (int) x; mouseY = (int) y; + if (readyCells() && !inputLocked() && dragButton < 0) cells.mouseMoved(x, y); } - boolean drag(double x, double y, int button) { + public boolean drag(double x, double y, int button, double dx, double dy) { layout(); + if (elementButton >= 0) { + if (readyCells() && !inputLocked()) cells.drag(x, y, button, dx, dy); + return true; + } if (dragButton >= 0) { if (!gestureValid() || button != dragButton) cancelGesture(); else { @@ -566,7 +711,15 @@ boolean drag(double x, double y, int button) { return handledPress; } - boolean release(double x, double y, int button) { + public boolean release(double x, double y, int button) { + if (elementButton >= 0) { + if (readyCells() && !inputLocked()) cells.release(x, y, button); + if (button == elementButton) { + elementButton = -1; + handledPress = false; + } + return true; + } boolean handled = handledPress; handledPress = false; layout(); diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ContainerElementRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/ContainerElementRenderer.java new file mode 100644 index 00000000..08b5356e --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ContainerElementRenderer.java @@ -0,0 +1,53 @@ +package zone.moddev.mc.lib.gui.client; + +import static zone.moddev.mc.lib.gui.client.ElementDrawing.*; + +import net.minecraft.network.chat.Component; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.Edge; +import zone.moddev.mc.lib.gui.model.containers.*; + +/** Backgrounds and captions for containers; child traversal remains the screen's responsibility. */ +public final class ContainerElementRenderer { + private ContainerElementRenderer() {} + + public static void group(GroupElement element, ElementRenderers.Context c) { + var r = c.bounds(); + tinted(c.graphics(), "group", r, element.tint().orElse(0xffffff)); + element + .label() + .ifPresent(text -> caption(c, text, r.x() + element.padding(), r.y() + element.padding())); + } + + public static void radioGroup(RadioGroupElement element, ElementRenderers.Context c) { + element.label().ifPresent(text -> caption(c, text, c.bounds().x(), c.bounds().y())); + } + + public static void view(ViewElement element, ElementRenderers.Context c) { + element.title().ifPresent(text -> caption(c, text, c.bounds().x() + 12, c.bounds().y())); + } + + public static void context(ContextElement element, ElementRenderers.Context c) { + String selected = element.selected(c.access().state()); + if (!selected.equals(element.defaultView().common().id().orElseThrow())) + caption(c, Component.literal("‹"), c.bounds().x(), c.bounds().y()); + } + + public static void sideTab(SideTabElement element, ElementRenderers.Context c) { + var g = c.graphics(); + var r = c.bounds(); + if (element.edge() == Edge.RIGHT) { + sprite(g, "side_tab", r); + return; + } + g.pose().pushPose(); + try { + g.pose().translate(r.right(), r.y(), 0); + g.pose().scale(-1, 1, 1); + sprite(g, "side_tab", new Rect(0, 0, r.width(), r.height())); + } finally { + g.pose().popPose(); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ContextBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/ContextBehavior.java new file mode 100644 index 00000000..4dd849dc --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ContextBehavior.java @@ -0,0 +1,38 @@ +package zone.moddev.mc.lib.gui.client; + +import zone.moddev.mc.lib.gui.model.containers.ContextElement; + +/** Context back controls and per-screen remembered views. Definitions remain immutable. */ +public class ContextBehavior extends NavigableBehavior { + private final ContextElement context; + + public ContextBehavior(ContextElement context, ElementAccess access) { + super(context, access); + this.context = context; + } + + private String id() { + return context.common().id().orElseThrow(); + } + + private String defaultView() { + return context.defaultView().common().id().orElseThrow(); + } + + private boolean back() { + if (context.selected(access.presentation().state()).equals(defaultView())) return false; + if (access.click(ContextElement.BACK, 0, 0)) { + NavigationMemory.navigate(access, id(), defaultView()); + } + return true; + } + + protected Click click(double x, double y, int button) { + var bounds = access.bounds(); + return x < bounds.x() + 12 && y < bounds.y() + 12 && back() ? Click.CONSUME : Click.PASS; + } + + public boolean shortcut(int key, int scan, int modifiers) { + return key == 256 && back(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/CustomWidgetBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/CustomWidgetBehavior.java new file mode 100644 index 00000000..92c5223e --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/CustomWidgetBehavior.java @@ -0,0 +1,139 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; +import java.util.Optional; + +import zone.moddev.mc.lib.gui.WidgetAction; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.elements.CustomElement; + +/** Adapts existing mod widget factories to the common element input/lifecycle system. */ +final class CustomWidgetBehavior extends DefaultElementBehavior + implements WidgetRenderer, BehaviorCapabilities { + private final WidgetInstance widget; + + CustomWidgetBehavior(CustomElement element, ElementAccess access) { + super(access); + widget = + WidgetRenderers.GLOBAL + .require(element.type()) + .create( + new WidgetAccess() { + public CustomElement element() { + return element; + } + + public Rect bounds() { + return access.bounds(); + } + + public Object value() { + return access.value(); + } + + public Object maximum() { + return access.maximum(); + } + + public boolean pending() { + return access.pending(element); + } + + public boolean submit(WidgetAction action, T value) { + return access.submit(action, value); + } + }); + } + + protected Click click(double x, double y, int button) { + return widget.mouseClicked(x, y, button); + } + + public Object stateIdentity() { + return widget.stateIdentity(); + } + + public void mouseMoved(double x, double y) { + widget.mouseMoved(x, y); + } + + public void hoverChanged(boolean hovered) { + widget.hoverChanged(hovered); + } + + public boolean focusable() { + return widget.focusable(); + } + + public void setFocused(boolean focused) { + widget.setFocused(focused); + } + + public boolean mouseDragged(double x, double y, int button, double dx, double dy) { + return widget.mouseDragged(x, y, button, dx, dy); + } + + public boolean mouseReleased(double x, double y, int button) { + return widget.mouseReleased(x, y, button); + } + + public boolean mouseScrolled(double x, double y, double dx, double dy) { + return widget.mouseScrolled(x, y, dx, dy); + } + + public boolean keyPressed(int key, int scan, int modifiers) { + return widget.keyPressed(key, scan, modifiers); + } + + public boolean keyReleased(int key, int scan, int modifiers) { + return widget.keyReleased(key, scan, modifiers); + } + + public boolean charTyped(char character, int modifiers) { + return widget.charTyped(character, modifiers); + } + + public void initialized() { + widget.initialized(); + } + + public boolean shortcut(int key, int scan, int modifiers) { + return widget.shortcut(key, scan, modifiers); + } + + public List tooltip(int x, int y) { + return widget.tooltip(x, y); + } + + public Optional capability(Class type) { + return BehaviorCapabilities.find(widget, type); + } + + public void tick() { + widget.tick(); + } + + public void resized() { + widget.resized(); + } + + public void visibilityChanged(boolean visible) { + widget.visibilityChanged(visible); + } + + public void cancelInteraction() { + widget.cancelInteraction(); + } + + public void actionResult(boolean accepted) { + widget.actionResult(accepted); + } + + public void removed() { + widget.removed(); + } + + public void render(WidgetRenderer.Context context) { + widget.render(context); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/DefaultElementBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/DefaultElementBehavior.java new file mode 100644 index 00000000..4d6b87b0 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/DefaultElementBehavior.java @@ -0,0 +1,63 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.Objects; + +import zone.moddev.mc.lib.gui.ElementActions; +import zone.moddev.mc.lib.gui.model.Navigation; + +/** Common click bindings and recipe links, reusable by mod behavior implementations. */ +public class DefaultElementBehavior implements ElementBehavior { + protected final ElementAccess access; + private Navigation pendingNavigation; + + public DefaultElementBehavior(ElementAccess access) { + this.access = Objects.requireNonNull(access); + } + + protected boolean directInteraction() { + return true; + } + + protected Click click(double x, double y, int button) { + var binding = access.element().navigation().orElse(null); + if (binding == null) return Click.PASS; + var destination = binding.resolve(access.presentation()::find).orElse(null); + if (destination == null) return Click.PASS; + if (access.click(Navigation.NAVIGATE, button, 0)) { + pendingNavigation = binding; + NavigationMemory.navigate( + access, + binding.target().context(), + binding.destination(destination, access.presentation().state())); + } + return Click.CONSUME; + } + + public void actionResult(boolean accepted) { + var binding = + pendingNavigation != null ? pendingNavigation : access.element().navigation().orElse(null); + if (binding != null) { + binding + .resolve(access.presentation()::find) + .ifPresent(destination -> NavigationMemory.remember(access, destination)); + } + pendingNavigation = null; + } + + public Click mouseClicked(double x, double y, int button) { + if (button < 0 || button > 1) return Click.PASS; + if (directInteraction() && access.element().interactive()) { + access.click(ElementActions.CLICK, button, 0); + return Click.CONSUME; + } + var result = click(x, y, button); + if (result != Click.PASS) return result; + if (!access.element().common().jeiRecipes().isEmpty() && access.showRecipes()) + return Click.CONSUME; + if (access.element().common().clicks().any()) { + access.click(ElementActions.CLICK, button, 0); + return Click.CONSUME; + } + return Click.PASS; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/Drawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/Drawer.java new file mode 100644 index 00000000..5e7b3026 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/Drawer.java @@ -0,0 +1,209 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; +import java.util.Objects; + +import net.minecraft.client.gui.GuiGraphics; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.Edge; + +/** + * Client-owned drawer behavior. Subclasses supply their appearance, geometry, content, input and + * preference restoration. The screen dispatches these hooks without knowing the drawer's type. + */ +public abstract class Drawer { + protected final MachineScreen screen; + private final String id; + private final Edge edge; + private final IconSprite iconSprite; + private final int colour; + private zone.moddev.mc.lib.gui.drawer.DrawerDefinition definition; + + protected Drawer( + MachineScreen screen, + zone.moddev.mc.lib.gui.drawer.DrawerDefinition definition, + IconSprite iconSprite, + int colour) { + this(screen, definition.id(), definition.placement().edge(), iconSprite, colour); + } + + final void attach(zone.moddev.mc.lib.gui.drawer.DrawerDefinition definition) { + if (this.definition != null + || !id.equals(definition.id()) + || edge != definition.placement().edge()) + throw new IllegalArgumentException( + "Drawer instance does not match its registered identity: " + definition.id()); + this.definition = definition; + } + + public final zone.moddev.mc.lib.gui.drawer.DrawerDefinition definition() { + return Objects.requireNonNull(definition, "Drawer was not created by a registered factory"); + } + + private Drawer(MachineScreen screen, String id, Edge edge, IconSprite iconSprite, int colour) { + this.screen = Objects.requireNonNull(screen); + this.id = Objects.requireNonNull(id); + this.edge = Objects.requireNonNull(edge); + this.iconSprite = Objects.requireNonNull(iconSprite); + this.colour = colour; + } + + public final String id() { + return id; + } + + public final Edge edge() { + return edge; + } + + public IconSprite iconSprite() { + return iconSprite; + } + + public int colour() { + return colour; + } + + /** Stable, drawer-defined preference data; never a server authority or inventory snapshot. */ + public record Selection(String key, int offset) { + public Selection { + Objects.requireNonNull(key); + } + + public Selection() { + this("", 0); + } + } + + public boolean available() { + return true; + } + + public boolean pending() { + return false; + } + + public abstract boolean open(); + + public Rect tabBounds() { + return screen.drawers().tabBounds(this); + } + + /** Optional read-only presentation for an element renderer associated with this drawer. */ + public java.util.Optional presentation(Class type) { + return java.util.Optional.empty(); + } + + public abstract void close(); + + /** Close purely local presentation when a server-owned drawer is opened. */ + public void closeLocal() {} + + /** Return false to retry after a server snapshot/acknowledgment arrives. */ + public boolean restore(Selection selection) { + return true; + } + + public Selection selection() { + return new Selection(); + } + + protected final void remember(boolean open, Selection selection) { + screen.drawers().remember(this, open, selection); + } + + protected final void activated() { + screen.drawers().activated(this); + } + + protected final void openLocal() { + screen.drawers().openLocal(this); + } + + protected final boolean otherTab(double x, double y) { + return screen.drawers().otherTab(this, x, y); + } + + public boolean tabContains(double x, double y) { + return available() && tabBounds().contains(x, y); + } + + protected final void drawFrame(GuiGraphics graphics, Rect tab, Rect body, boolean expanded) { + if (expanded) SideDrawerRenderer.open(graphics, tab, body, edge, colour()); + else SideDrawerRenderer.tab(graphics, tab, colour()); + drawIcon(graphics, tab); + } + + protected void drawIcon(GuiGraphics graphics, Rect tab) { + iconSprite().draw(graphics, tab.x() + 3, tab.y() + 4); + } + + public void renderTab(GuiGraphics graphics) { + if (!available()) return; + var tab = tabBounds(); + SideDrawerRenderer.tab(graphics, tab, colour()); + drawIcon(graphics, tab); + } + + public void render(GuiGraphics graphics, int mouseX, int mouseY) {} + + public void tooltip(GuiGraphics graphics, int mouseX, int mouseY) {} + + /** + * Optional native tooltip ownership for this drawer; empty leaves the screen bridge in charge. + */ + public java.util.Optional> nativeTooltip( + net.minecraft.world.inventory.Slot slot, + List vanilla, + int mouseX, + int mouseY) { + return java.util.Optional.empty(); + } + + public List exclusions() { + return available() ? List.of(tabBounds()) : List.of(); + } + + public boolean contains(double x, double y) { + return tabContains(x, y); + } + + public boolean click(double x, double y, int button) { + return false; + } + + public boolean key(int key, int scan, int modifiers) { + return false; + } + + public boolean keyReleased(int key, int scan, int modifiers) { + return false; + } + + public boolean character(char character, int modifiers) { + return false; + } + + public boolean scroll(double x, double y, double amount) { + return false; + } + + public boolean drag(double x, double y, int button, double dx, double dy) { + return false; + } + + public boolean release(double x, double y, int button) { + return false; + } + + public void mouseMoved(double x, double y) {} + + public void resized() {} + + public void tick() {} + + public void removed() { + resized(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/DrawerFactories.java b/src/main/java/zone/moddev/mc/lib/gui/client/DrawerFactories.java new file mode 100644 index 00000000..f0c9657d --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/DrawerFactories.java @@ -0,0 +1,67 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; +import java.util.function.BiFunction; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.drawer.*; + +/** Client factories keyed by common drawer type. Register during client setup. */ +public final class DrawerFactories { + public static final DrawerFactories GLOBAL = builtins(); + private final Map> + factories = new HashMap<>(); + private final Set issued = Collections.newSetFromMap(new WeakHashMap<>()); + + public static DrawerFactories builtins() { + var registry = new DrawerFactories(); + registry.register(BuiltinDrawers.STATS, StatsDrawer::new); + registry.register( + BuiltinDrawers.BACKPACK, + (screen, definition) -> new ContainerDrawer(screen, definition, false)); + registry.register( + BuiltinDrawers.CURIOS, + (screen, definition) -> new ContainerDrawer(screen, definition, true)); + registry.register(BuiltinDrawers.SIDES, SideConfigDrawer::new); + registry.register(BuiltinDrawers.REDSTONE, RedstoneDrawer::new); + registry.register(BuiltinDrawers.POPOUT, PopoutDrawer::new); + return registry; + } + + public synchronized void register( + ResourceLocation type, BiFunction factory) { + Objects.requireNonNull(type); + Objects.requireNonNull(factory); + if (factories.putIfAbsent(type, factory) != null) + throw new IllegalArgumentException("Duplicate drawer factory: " + type); + } + + public synchronized void replace( + ResourceLocation type, BiFunction factory) { + if (!factories.containsKey(type)) + throw new IllegalArgumentException("Unknown drawer factory: " + type); + factories.put(type, Objects.requireNonNull(factory)); + } + + public synchronized void validate(List definitions) { + for (var definition : definitions) + if (definition.interactive() && !factories.containsKey(definition.type())) + throw new IllegalArgumentException( + "No client drawer factory registered for " + definition.type()); + } + + synchronized Drawer create(MachineScreen screen, DrawerDefinition definition) { + var factory = factories.get(definition.type()); + if (factory == null) + throw new IllegalArgumentException( + "No client drawer factory registered for " + definition.type()); + var drawer = + Objects.requireNonNull(factory.apply(screen, definition), "Drawer factory returned null"); + if (drawer.screen != screen || !issued.add(drawer)) + throw new IllegalArgumentException( + "Drawer factories must create fresh instances for this screen"); + drawer.attach(definition); + return drawer; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/DrawerMemory.java b/src/main/java/zone/moddev/mc/lib/gui/client/DrawerMemory.java index efb8f7c1..4f72540b 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/DrawerMemory.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/DrawerMemory.java @@ -1,23 +1,21 @@ package zone.moddev.mc.lib.gui.client; import java.util.EnumMap; +import java.util.List; import java.util.Map; import net.minecraft.client.Minecraft; -import net.neoforged.neoforge.network.PacketDistributor; +import zone.moddev.mc.lib.gui.model.Edge; -import zone.moddev.mc.lib.gui.model.Element; -import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; - -/** Connection-scoped drawer preferences shared by every MMDLib machine screen. */ +/** Connection-scoped storage; each drawer interprets and restores its own preference. */ final class DrawerMemory { - private record Selection(String kind, String key, int offset) {} + private record Preference(String id, Drawer.Selection selection) {} private static java.lang.ref.WeakReference connection = new java.lang.ref.WeakReference<>(null); - private static final Map remembered = new EnumMap<>(Element.Edge.class); - private final Map restoring = new EnumMap<>(Element.Edge.class); + private static final Map remembered = new EnumMap<>(Edge.class); + private final Map restoring = new EnumMap<>(Edge.class); private int ticks; DrawerMemory() { @@ -38,178 +36,46 @@ void userInput() { restoring.clear(); } - void statsIntent(boolean open) { - if (open) remembered.put(Element.Edge.LEFT, new Selection("stats", "", 0)); - else remembered.remove(Element.Edge.LEFT); + void intent(Drawer drawer, boolean open, Drawer.Selection selection) { + if (open) remembered.put(drawer.edge(), new Preference(drawer.id(), selection)); + else remembered.remove(drawer.edge()); } - void restore(MachineScreen screen, java.util.Set open) { + void restore(List drawers) { if (++ticks > 100) { restoring.clear(); return; } - var menu = screen.getMenu(); - for (var edge : Element.Edge.values()) { - var selection = restoring.get(edge); - if (selection == null) continue; - if (selection.kind().equals("backpack") || selection.kind().equals("curios")) { - boolean equipment = selection.kind().equals("curios"); - var drawer = equipment ? menu.curiosDrawer : menu.containerDrawer; - if (!menu.bound.screen().playerInventory()) { - restoring.remove(edge); - continue; - } - var state = drawer.state(); - if (state.keys().isEmpty() || drawer.pending()) continue; - int index = state.keys().indexOf(selection.key()); - if (index < 0) { - restoring.remove(edge); - continue; - } - if (!state.open()) { - drawer - .request(ContainerDrawerMenu.TOGGLE, 0, state.pageSize()) - .ifPresent(PacketDistributor::sendToServer); - } else if (state.selected() != index) { - drawer - .request(ContainerDrawerMenu.SELECT, index, state.pageSize()) - .ifPresent(PacketDistributor::sendToServer); - } else { - int offset = Math.min(selection.offset(), Math.max(0, state.sizes().get(index) - 1)); - offset = offset / state.pageSize() * state.pageSize(); - if (state.offset() != offset) - drawer - .request(ContainerDrawerMenu.PAGE, offset, state.pageSize()) - .ifPresent(PacketDistributor::sendToServer); - restoring.remove(edge); - screen.closeLeftDrawers(); - } - } else if (selection.kind().equals("stats")) { - if (!screen.stats.show()) continue; - restoring.remove(edge); - } else if (selection.kind().equals("redstone")) { - var redstone = menu.redstone; - if (redstone == null) { - restoring.remove(edge); - continue; - } - if (redstone.pending()) continue; - if (!redstone.open()) - redstone.send(zone.moddev.mc.lib.gui.runtime.RedstoneMenu.TOGGLE, 0, 0, 0); - else { - if (redstone.selected() != selection.offset()) - redstone.send( - zone.moddev.mc.lib.gui.runtime.RedstoneMenu.SELECT, selection.offset(), 0, 0); - restoring.remove(edge); - screen.closeRightPopouts(); - } - } else if (selection.kind().equals("automation")) { - var automation = menu.automation; - if (automation == null) { - restoring.remove(edge); - continue; - } - if (automation.pending()) continue; - int group = automation.inventory.index(selection.key()); - if (group < 0) group = 0; - automation.send("select", group, -1, "", 0, ""); - restoring.remove(edge); - } else { - menu.bound.screen().tabs().stream() - .filter( - e -> - e instanceof Element.Popout p - && p.edge() == edge - && p.kind().name().equals(selection.kind())) - .map(Element.Popout.class::cast) - .findFirst() - .ifPresent(open::add); - restoring.remove(edge); - } - } - } - - void containerIntent( - boolean equipment, - zone.moddev.mc.lib.gui.runtime.ContainerDrawerNetwork.State state, - int kind, - int argument) { - if (state.keys().isEmpty()) return; - if (kind == ContainerDrawerMenu.TOGGLE && state.open()) remembered.remove(Element.Edge.LEFT); - else if (kind == ContainerDrawerMenu.TOGGLE - || kind == ContainerDrawerMenu.SELECT - || kind == ContainerDrawerMenu.PAGE) { - int index = kind == ContainerDrawerMenu.SELECT ? argument : state.selected(); - remembered.put( - Element.Edge.LEFT, - new Selection( - equipment ? "curios" : "backpack", - state.keys().get(index), - kind == ContainerDrawerMenu.PAGE ? argument : state.offset())); + for (var edge : Edge.values()) { + var preference = restoring.get(edge); + if (preference == null) continue; + var drawer = find(drawers, edge, preference.id()); + if (drawer == null || drawer.restore(preference.selection())) restoring.remove(edge); } } - void redstoneIntent(boolean open, int kind, int face) { - if (kind == zone.moddev.mc.lib.gui.runtime.RedstoneMenu.TOGGLE && open) - remembered.remove(Element.Edge.RIGHT); - else remembered.put(Element.Edge.RIGHT, new Selection("redstone", "", face)); - } - - void automationIntent(String kind, String group) { - if (kind.equals("close")) remembered.remove(Element.Edge.RIGHT); - else if (kind.equals("select")) - remembered.put(Element.Edge.RIGHT, new Selection("automation", group, 0)); - } - - void popoutIntent(Element.Popout popout, boolean open) { - if (open) remembered.put(popout.edge(), new Selection(popout.kind().name(), "", 0)); - else remembered.remove(popout.edge()); - } - - void capture(MachineScreen screen, java.util.Set open) { - var menu = screen.getMenu(); - for (var edge : Element.Edge.values()) { - // Do not erase a preference while waiting for a fresh menu's equipment snapshot. + void capture(List drawers) { + for (var edge : Edge.values()) { if (restoring.containsKey(edge) - || edge == Element.Edge.LEFT - && (menu.containerDrawer.pending() || menu.curiosDrawer.pending()) - || edge == Element.Edge.RIGHT && menu.automation != null && menu.automation.pending()) - continue; - if (edge == Element.Edge.RIGHT && menu.redstone != null && menu.redstone.pending()) continue; - Selection selection = null; - if (edge == Element.Edge.LEFT) { - for (var drawer : java.util.List.of(menu.containerDrawer, menu.curiosDrawer)) { - var state = drawer.state(); - if (state.open() && !state.keys().isEmpty()) - selection = - new Selection( - state.equipment() ? "curios" : "backpack", - state.keys().get(state.selected()), - state.offset()); - } - if (screen.stats.open) selection = new Selection("stats", "", 0); - } else if (menu.redstone != null && menu.redstone.open()) { - selection = new Selection("redstone", "", menu.redstone.selected()); - } else if (menu.automation != null && menu.automation.open()) { - var a = menu.automation; - selection = new Selection("automation", a.inventory.group(a.selected()).id(), 0); - } - if (selection == null) - for (var popout : open) - if (popout.edge() == edge) selection = new Selection(popout.kind().name(), "", 0); - if (selection != null) remembered.put(edge, selection); - else { + || drawers.stream().anyMatch(d -> d.edge() == edge && d.pending())) continue; + var open = + drawers.stream().filter(d -> d.edge() == edge && d.available() && d.open()).findFirst(); + if (open.isPresent()) { + var drawer = open.get(); + remembered.put(edge, new Preference(drawer.id(), drawer.selection())); + } else { var previous = remembered.get(edge); - boolean unavailable = - previous != null - && (previous.kind().equals("backpack") - && menu.containerDrawer.state().keys().isEmpty() - || previous.kind().equals("curios") - && menu.curiosDrawer.state().keys().isEmpty() - || previous.kind().equals("automation") && menu.automation == null - || previous.kind().equals("redstone") && menu.redstone == null); - if (!unavailable) remembered.remove(edge); + var drawer = previous == null ? null : find(drawers, edge, previous.id()); + // Missing equipment/features on this machine must not erase the connection preference. + if (previous == null || drawer != null && drawer.available()) remembered.remove(edge); } } } + + private Drawer find(List drawers, Edge edge, String id) { + return drawers.stream() + .filter(d -> d.edge() == edge && d.id().equals(id)) + .findFirst() + .orElse(null); + } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/Drawers.java b/src/main/java/zone/moddev/mc/lib/gui/client/Drawers.java new file mode 100644 index 00000000..0e6f60a3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/Drawers.java @@ -0,0 +1,202 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; +import java.util.function.Predicate; + +import net.minecraft.client.gui.GuiGraphics; + +import zone.moddev.mc.lib.gui.drawer.*; +import zone.moddev.mc.lib.gui.layout.DrawerTabs; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.Edge; +import zone.moddev.mc.lib.gui.model.Element; + +/** Generic dispatch, edge arbitration and connection-scoped preferences for drawers. */ +public final class Drawers { + private final List entries = new ArrayList<>(); + private final DrawerMemory memory = new DrawerMemory(); + private final MachineScreen screen; + private final Map elements = new IdentityHashMap<>(); + + private Drawers(MachineScreen screen) { + this.screen = screen; + } + + static Drawers create(MachineScreen screen) { + var result = new Drawers(screen); + var definitions = screen.layout.drawers(); + DrawerFactories.GLOBAL.validate(definitions); + for (var definition : definitions) + if (definition.interactive()) { + var drawer = DrawerFactories.GLOBAL.create(screen, definition); + result.entries.add(drawer); + definition.element().ifPresent(element -> result.elements.put(element, drawer)); + } + return result; + } + + public List entries() { + return List.copyOf(entries); + } + + public Optional presentation(Element element, Class type) { + var drawer = elements.get(element); + return drawer == null ? Optional.empty() : drawer.presentation(type); + } + + public Rect tabBounds(Drawer drawer) { + if (!entries.contains(drawer)) + throw new IllegalArgumentException("Drawer is not on this screen"); + var definition = drawer.definition(); + var tabs = + DrawerTabs.place( + screen.layout.drawers(), + screen.panelBounds(), + screen.drawerBottom(), + candidate -> + candidate.placement().anchor() == DrawerPlacement.Anchor.TOP + || !candidate.interactive() + || entries.stream() + .anyMatch( + d -> + d.id().equals(candidate.id()) + && d.edge() == candidate.placement().edge() + && d.available())); + // An unavailable drawer can still compute content bounds while waiting for a snapshot. + var bounds = tabs.get(definition); + return bounds != null + ? bounds + : DrawerTabs.place( + List.of(definition), screen.panelBounds(), screen.drawerBottom(), d -> true) + .get(definition); + } + + public boolean pending() { + return entries.stream().anyMatch(Drawer::pending); + } + + public boolean pending(Edge edge) { + return entries.stream().anyMatch(d -> d.edge() == edge && d.pending()); + } + + void activated(Drawer drawer) { + entries.stream() + .filter(d -> d != drawer && d.edge() == drawer.edge()) + .forEach(Drawer::closeLocal); + } + + void openLocal(Drawer drawer) { + entries.stream().filter(d -> d != drawer && d.edge() == drawer.edge()).forEach(Drawer::close); + } + + void remember(Drawer drawer, boolean open, Drawer.Selection selection) { + memory.intent(drawer, open, selection); + } + + public boolean tabContains(double x, double y) { + return entries.stream().anyMatch(d -> d.tabContains(x, y)); + } + + boolean otherTab(Drawer drawer, double x, double y) { + return entries.stream() + .anyMatch(d -> d != drawer && d.edge() == drawer.edge() && d.tabContains(x, y)); + } + + public void render(GuiGraphics graphics, int x, int y) { + entries.forEach(d -> d.render(graphics, x, y)); + } + + /** Navigation tabs stay visible above every drawer body, including XML foreground popouts. */ + public void renderTabs(GuiGraphics graphics) { + entries.forEach(d -> d.renderTab(graphics)); + } + + public void tooltip(GuiGraphics graphics, int x, int y) { + entries.forEach(d -> d.tooltip(graphics, x, y)); + } + + public Optional> nativeTooltip( + net.minecraft.world.inventory.Slot slot, + List vanilla, + int x, + int y) { + for (var drawer : entries.reversed()) { + var result = drawer.nativeTooltip(slot, vanilla, x, y); + if (result.isPresent()) return result; + } + return Optional.empty(); + } + + public List exclusions() { + var result = new ArrayList(); + entries.forEach(drawer -> result.addAll(drawer.exclusions())); + for (var definition : screen.layout.drawers()) + if (!definition.interactive()) + definition + .element() + .filter(screen::visible) + .ifPresent(element -> result.add(screen.bounds(element))); + return List.copyOf(result); + } + + public boolean contains(double x, double y) { + return entries.stream().anyMatch(d -> d.contains(x, y)); + } + + private boolean dispatch(Predicate input) { + return entries.stream().anyMatch(input); + } + + public boolean click(double x, double y, int button) { + memory.userInput(); + return dispatch(d -> !otherTab(d, x, y) && d.click(x, y, button)); + } + + public void userInput() { + memory.userInput(); + } + + public boolean key(int key, int scan, int modifiers) { + return dispatch(d -> d.key(key, scan, modifiers)); + } + + public boolean keyReleased(int key, int scan, int modifiers) { + return dispatch(d -> d.keyReleased(key, scan, modifiers)); + } + + public boolean character(char character, int modifiers) { + return dispatch(d -> d.character(character, modifiers)); + } + + public boolean scroll(double x, double y, double amount) { + return dispatch(d -> d.scroll(x, y, amount)); + } + + public boolean drag(double x, double y, int button, double dx, double dy) { + return dispatch(d -> d.drag(x, y, button, dx, dy)); + } + + public boolean release(double x, double y, int button) { + boolean handled = false; + for (var drawer : entries) handled |= drawer.release(x, y, button); + return handled; + } + + public void mouseMoved(double x, double y) { + entries.forEach(d -> d.mouseMoved(x, y)); + } + + public void resized() { + entries.forEach(Drawer::resized); + } + + public void tick() { + memory.restore(entries); + entries.forEach(Drawer::tick); + } + + public void removed() { + memory.capture(entries); + entries.forEach(Drawer::removed); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementAccess.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementAccess.java new file mode 100644 index 00000000..041786e5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementAccess.java @@ -0,0 +1,61 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.gui.ElementAction; +import zone.moddev.mc.lib.gui.ElementActions; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.elements.SlotElement; +import zone.moddev.mc.lib.machine.inventory.SlotPlan; + +/** Live services for one client behavior; no host getters or raw packet/menu access. */ +public interface ElementAccess { + Element element(); + + Rect bounds(); + + Object value(); + + Object maximum(); + + ElementRenderAccess presentation(); + + /** Optional host-specific behavior services; render access stays read-only. */ + default java.util.Optional service(Class type) { + return java.util.Optional.empty(); + } + + List elements(); + + ResourceLocation screenId(); + + boolean pending(Element element); + + boolean submit(Element element, ElementAction action, T value); + + default boolean submit(ElementAction action, T value) { + return submit(element(), action, value); + } + + default boolean click(ElementAction action, int button, int option) { + return submit(action, new ElementActions.Click(button, modifiers(), option)); + } + + default int modifiers() { + return (net.minecraft.client.gui.screens.Screen.hasShiftDown() ? 1 : 0) + | (net.minecraft.client.gui.screens.Screen.hasControlDown() ? 2 : 0) + | (net.minecraft.client.gui.screens.Screen.hasAltDown() ? 4 : 0); + } + + void navigate(String context, String view); + + ItemStack carried(); + + SlotPlan reservation(SlotElement element); + + boolean showRecipes(); +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementBehavior.java new file mode 100644 index 00000000..a6347cc1 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementBehavior.java @@ -0,0 +1,97 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; + +/** + * One element's state for one open screen. Factories must return fresh instances. Coordinates are + * absolute GUI pixels. Only a captured press receives dragging and release, even outside its + * bounds. Hidden widgets lose focus and capture; resize preserves the instance and focus but + * cancels capture without committing. + */ +public interface ElementBehavior { + enum Click { + PASS, + CONSUME, + CAPTURE + } + + default boolean focusable() { + return false; + } + + default void setFocused(boolean focused) {} + + default Click mouseClicked(double x, double y, int button) { + return Click.PASS; + } + + default boolean mouseDragged(double x, double y, int button, double dx, double dy) { + return false; + } + + default boolean mouseReleased(double x, double y, int button) { + return false; + } + + default boolean mouseScrolled(double x, double y, double dx, double dy) { + return false; + } + + default boolean keyPressed(int key, int scan, int modifiers) { + return false; + } + + default boolean keyReleased(int key, int scan, int modifiers) { + return false; + } + + default boolean charTyped(char character, int modifiers) { + return false; + } + + default void tick() {} + + default void resized() {} + + default void visibilityChanged(boolean visible) {} + + default void cancelInteraction() {} + + /** Values have already been updated. False also covers an unconfirmed edit after timeout. */ + default void actionResult(boolean accepted) {} + + default void removed() {} + + default void initialized() {} + + /** Identity of mutable state, including through compatibility adapters. */ + default Object stateIdentity() { + return this; + } + + default void mouseMoved(double x, double y) {} + + /** Enter/leave transitions for the topmost visible hit target; leave precedes removal. */ + default void hoverChanged(boolean hovered) {} + + /** Unfocused keyboard shortcuts (for example, a context's Escape/back). */ + default boolean shortcut(int key, int scan, int modifiers) { + return false; + } + + default List tooltip(int x, int y) { + return List.of(); + } + + /** Client-local additions after the element's own rows. Retains existing tooltip overrides. */ + default void appendTooltip( + zone.moddev.mc.lib.gui.model.TooltipContext context, + List rows) { + rows.addAll(tooltip(context.mouseX(), context.mouseY())); + } + + /** Optional client-local additions for a hovered descendant. */ + default void appendChildTooltip( + zone.moddev.mc.lib.gui.model.TooltipContext context, + List rows) {} +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementBehaviors.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementBehaviors.java new file mode 100644 index 00000000..e9c3dd0d --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementBehaviors.java @@ -0,0 +1,63 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; + +import zone.moddev.mc.lib.gui.model.Element; + +/** Public client factory registry. Exact types override the most-specific registered base. */ +public final class ElementBehaviors { + public static final ElementBehaviors GLOBAL = builtins(); + + @FunctionalInterface + public interface Factory { + ElementBehavior create(E element, ElementAccess access); + } + + private final Map< + Class, java.util.function.Function> + factories = new HashMap<>(); + + public static ElementBehaviors builtins() { + var registry = new ElementBehaviors(); + BuiltinElementClients.registerBehaviors(registry); + return registry; + } + + public synchronized void register(Class type, Factory factory) { + Objects.requireNonNull(type); + Objects.requireNonNull(factory); + if (factories.putIfAbsent(type, access -> factory.create(type.cast(access.element()), access)) + != null) + throw new IllegalArgumentException("Duplicate element behavior: " + type.getName()); + } + + public synchronized void replace(Class type, Factory factory) { + Objects.requireNonNull(factory); + if (!factories.containsKey(type)) + throw new IllegalArgumentException("Missing element behavior: " + type.getName()); + factories.put(type, access -> factory.create(type.cast(access.element()), access)); + } + + public synchronized ElementBehavior create(ElementAccess access) { + var type = access.element().getClass(); + var exact = factories.get(type); + if (exact != null) return Objects.requireNonNull(exact.apply(access)); + var candidates = factories.keySet().stream().filter(key -> key.isAssignableFrom(type)).toList(); + var specific = + candidates.stream() + .filter( + key -> + candidates.stream() + .noneMatch(other -> key != other && key.isAssignableFrom(other))) + .toList(); + if (specific.size() > 1) + throw new IllegalArgumentException( + "Ambiguous element behaviors for " + + type.getName() + + ": " + + specific.stream().map(Class::getName).sorted().toList()); + return specific.isEmpty() + ? new DefaultElementBehavior(access) + : Objects.requireNonNull(factories.get(specific.getFirst()).apply(access)); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementClient.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementClient.java new file mode 100644 index 00000000..0d03ae72 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementClient.java @@ -0,0 +1,84 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; + +import zone.moddev.mc.lib.gui.ElementType; +import zone.moddev.mc.lib.gui.model.Element; + +/** Client-only registration bundle. Delegates to the existing renderer and behavior registries. */ +public final class ElementClient { + private record Drawing( + ElementRenderers.Clip clip, ElementRenderers.Renderer renderer) {} + + private final ElementType element; + private final Map> drawings = + new EnumMap<>(ElementRenderers.Pass.class); + private ElementBehaviors.Factory behavior; + + private ElementClient(ElementType element) { + this.element = Objects.requireNonNull(element); + } + + public static ElementClient of(ElementType element) { + return new ElementClient<>(element); + } + + public ElementClient renderer(ElementRenderers.Renderer renderer) { + return renderer(ElementRenderers.Pass.BACKGROUND, ElementRenderers.Clip.ELEMENT, renderer); + } + + public ElementClient overlay(ElementRenderers.Renderer renderer) { + return renderer(ElementRenderers.Pass.OVERLAY, ElementRenderers.Clip.ELEMENT, renderer); + } + + public ElementClient foreground(ElementRenderers.Renderer renderer) { + return renderer(ElementRenderers.Pass.FOREGROUND, ElementRenderers.Clip.VIEWPORT, renderer); + } + + public ElementClient top(ElementRenderers.Renderer renderer) { + return renderer(ElementRenderers.Pass.TOP, ElementRenderers.Clip.VIEWPORT, renderer); + } + + public ElementClient renderer( + ElementRenderers.Pass pass, + ElementRenderers.Clip clip, + ElementRenderers.Renderer renderer) { + Objects.requireNonNull(pass); + var drawing = new Drawing<>(Objects.requireNonNull(clip), Objects.requireNonNull(renderer)); + if (drawings.putIfAbsent(pass, drawing) != null) + throw new IllegalArgumentException( + "Duplicate " + pass + " renderer for " + element.type().getName()); + return this; + } + + public ElementClient behavior(ElementBehaviors.Factory factory) { + Objects.requireNonNull(factory); + if (behavior != null) + throw new IllegalArgumentException("Duplicate behavior for " + element.type().getName()); + behavior = factory; + return this; + } + + /** Register during client setup. Omitted passes/behavior retain the normal inheritance rules. */ + public void register() { + register(ElementRenderers.GLOBAL, ElementBehaviors.GLOBAL); + } + + public void register(ElementRenderers renderers, ElementBehaviors behaviors) { + registerRenderers(renderers); + registerBehaviors(behaviors); + } + + /** Separate registry bootstrap and isolated tests need not initialize the other registry. */ + public void registerRenderers(ElementRenderers registry) { + Objects.requireNonNull(registry); + drawings.forEach( + (pass, drawing) -> + registry.register(pass, element.type(), drawing.clip(), drawing.renderer())); + } + + public void registerBehaviors(ElementBehaviors registry) { + Objects.requireNonNull(registry); + if (behavior != null) registry.register(element.type(), behavior); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementDrawing.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementDrawing.java new file mode 100644 index 00000000..da9ce6ce --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementDrawing.java @@ -0,0 +1,47 @@ +package zone.moddev.mc.lib.gui.client; + +import com.mojang.blaze3d.systems.RenderSystem; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.Text; + +/** Shared drawing primitives; element-specific decisions belong to their renderers. */ +public final class ElementDrawing { + private ElementDrawing() {} + + public static void blit( + GuiGraphics g, ResourceLocation icon, int x, int y, int width, int height) { + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + g.blitSprite(icon, x, y, width, height); + RenderSystem.disableBlend(); + } + + public static void sprite(GuiGraphics g, String id, Rect r) { + blit( + g, + ResourceLocation.parse(id.contains(":") ? id : "mmdlib:" + id), + r.x(), + r.y(), + r.width(), + r.height()); + } + + public static void tinted(GuiGraphics g, String sprite, Rect r, int color) { + g.setColor(((color >> 16) & 255) / 255f, ((color >> 8) & 255) / 255f, (color & 255) / 255f, 1); + sprite(g, sprite, r); + g.setColor(1, 1, 1, 1); + } + + public static void caption(ElementRenderers.Context c, Text text, int x, int y) { + caption(c, c.access().text(text), x, y); + } + + public static void caption(ElementRenderers.Context c, Component text, int x, int y) { + c.graphics().drawString(c.access().font(), text, x, y, 0xff404040, false); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementFragment.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementFragment.java new file mode 100644 index 00000000..665f0067 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementFragment.java @@ -0,0 +1,446 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.gui.ElementAction; +import zone.moddev.mc.lib.gui.layout.*; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.elements.SlotElement; +import zone.moddev.mc.lib.gui.sync.ElementStateView; +import zone.moddev.mc.lib.machine.inventory.SlotPlan; + +/** + * Hosts an ordinary element tree in a clipped viewport using the shared rendering/input registries. + */ +public final class ElementFragment { + public interface Host { + ResourceLocation screenId(); + + /** Original XML source when available; Java-built fragments default to the host ID. */ + default String source(Element element) { + return screenId().toString(); + } + + default Object value(Element element, boolean maximum) { + return null; + } + + /** Current synchronized or local state, also used for layout and selective child visibility. */ + default ElementStateView state() { + return ElementStateView.DEFAULTS; + } + + /** Host-owned navigation/prediction; the fragment does not mutate server storage. */ + default void navigate(String context, String selection) {} + + /** Additional host policy, composed with every ancestor's visibility. */ + default boolean visible(Element element) { + return true; + } + + /** Interaction geometry independent of layout/render bounds, in screen coordinates. */ + default Rect hitBounds(Element element, Rect frame) { + return frame; + } + + /** Host-level input lock, independent of display visibility and tooltip composition. */ + default boolean inputEnabled() { + return true; + } + + default boolean pending(Element element) { + return false; + } + + default boolean submit(Element element, ElementAction action, T value) { + return false; + } + + default Optional service(Class type) { + return Optional.empty(); + } + + /** Optional host-side notification, also used to bridge declarative callbacks. */ + default void hoverChanged(Element element, boolean entered) {} + + /** Detached native-slot presentation supplied by the owning menu. */ + default ItemStack contents(SlotElement element) { + return ItemStack.EMPTY; + } + + default ItemStack hint(SlotElement element) { + return ItemStack.EMPTY; + } + + default SlotPlan reservation(SlotElement element) { + return SlotPlan.NONE; + } + + default List serverTooltip(Element element) { + return List.of(); + } + + default ItemStack carried() { + return ItemStack.EMPTY; + } + } + + private ElementFragmentLayout layout; + private final Element root; + private final int width; + private Map shown; + private boolean refreshing, initialized; + private final Host host; + private final ElementInput input; + private Rect viewport = new Rect(0, 0, 0, 0); + private int x, y; + private final long started = System.nanoTime(); + private final ElementRenderAccess presentation = + new ElementRenderAccess() { + public Font font() { + return Minecraft.getInstance().font; + } + + public Rect viewport() { + return viewport; + } + + public Rect bounds(Element element) { + return ElementFragment.this.bounds(element); + } + + public boolean visible(Element element) { + return ElementFragment.this.visible(element); + } + + public ElementStateView state() { + return host.state(); + } + + public Object value(Element element, boolean maximum) { + return host.value(element, maximum); + } + + public ItemStack contents(SlotElement element) { + return host.contents(element).copy(); + } + + public ItemStack hint(SlotElement element) { + return host.hint(element).copy(); + } + + public List serverTooltip(Element element) { + return host.serverTooltip(element).stream() + .map(Component::copy) + .map(Component.class::cast) + .toList(); + } + + public Optional find(String id) { + return layout.elements().stream() + .filter(e -> e.common().id().filter(id::equals).isPresent()) + .findFirst(); + } + + public Optional parent(Element child) { + return layout.elements().stream() + .filter(e -> e.children().stream().anyMatch(c -> c == child)) + .findFirst(); + } + + public Optional capability(Element element, Class type) { + return BehaviorCapabilities.find(input.instance(element), type); + } + }; + + public ElementFragment(Element root, int width, Host host) { + this.host = Objects.requireNonNull(host); + this.root = Objects.requireNonNull(root); + this.width = width; + layout = measure(); + shown = layout.visibility(host.state(), host::visible); + ElementRenderers.GLOBAL.validate(layout.elements(), host::source); + input = + new ElementInput<>( + layout.elements(), + e -> ElementBehaviors.GLOBAL.create(access(e)), + this::visible, + e -> host.hitBounds(e, bounds(e)), + (px, py) -> viewport.contains(px, py), + host::hoverChanged); + } + + private ElementFragmentLayout measure() { + return ElementFragmentLayout.of( + root, + width, + text -> Minecraft.getInstance().font.width(ScreenText.component(text)), + host.state()); + } + + /** Reconcile host state with layout and visibility without recreating element behaviors. */ + public void refresh() { + if (refreshing) return; + refreshing = true; + try { + var next = measure(); + if (next.elements().size() != layout.elements().size()) + throw new IllegalStateException("Replace the fragment when its element tree changes"); + for (int i = 0; i < next.elements().size(); i++) + if (next.elements().get(i) != layout.elements().get(i)) + throw new IllegalStateException("Replace the fragment when its element tree changes"); + boolean resized = + next.elements().stream() + .anyMatch( + element -> !next.bounds().get(element).equals(layout.bounds().get(element))); + layout = next; + shown = layout.visibility(host.state(), host::visible); + input.update(); + if (!host.inputEnabled()) input.cancelInteraction(); + if (resized) input.resized(); + } finally { + refreshing = false; + } + } + + /** Deliver completion after the host has applied authoritative values/state. */ + public void actionResult(Element element, boolean accepted) { + refresh(); + var behavior = input.instance(element); + if (behavior == null) throw new IllegalArgumentException("Element is not in this fragment"); + behavior.actionResult(accepted); + } + + private ElementAccess access(Element element) { + return new ElementAccess() { + public Element element() { + return element; + } + + public Rect bounds() { + return ElementFragment.this.bounds(element); + } + + public Object value() { + return host.value(element, false); + } + + public Object maximum() { + return host.value(element, true); + } + + public ElementRenderAccess presentation() { + return presentation; + } + + public List elements() { + return layout.elements(); + } + + public ResourceLocation screenId() { + return host.screenId(); + } + + public boolean pending(Element e) { + return host.pending(e); + } + + public boolean submit(Element e, ElementAction action, T value) { + return visible(e) + && e.action(action.id()).orElse(null) == action + && !host.pending(e) + && host.submit(e, action, value); + } + + public Optional service(Class type) { + return host.service(type); + } + + public void navigate(String context, String view) { + host.navigate(context, view); + } + + public ItemStack carried() { + return host.carried().copy(); + } + + public SlotPlan reservation(SlotElement slot) { + return host.reservation(slot); + } + + public boolean showRecipes() { + return false; + } + }; + } + + public int height() { + refresh(); + return layout.height(); + } + + public Rect bounds(Element element) { + return layout.bounds().get(element).offset(x, y); + } + + private boolean visible(Element element) { + if (!Boolean.TRUE.equals(shown.get(element))) return false; + var r = bounds(element); + return viewport.width() > 0 + && viewport.height() > 0 + && r.right() > viewport.x() + && r.x() < viewport.right() + && r.bottom() > viewport.y() + && r.y() < viewport.bottom(); + } + + public void place(int x, int y, Rect viewport) { + if (this.x != x || this.y != y || !this.viewport.equals(viewport)) input.cancelInteraction(); + this.x = x; + this.y = y; + this.viewport = viewport; + refresh(); + if (!initialized) { + initialized = true; + input.initialized(); + refresh(); + } + } + + public void render(GuiGraphics graphics, int mouseX, int mouseY) { + refresh(); + input.hover(mouseX, mouseY); + graphics.enableScissor(viewport.x(), viewport.y(), viewport.right(), viewport.bottom()); + try { + for (var pass : ElementRenderers.Pass.values()) { + if (pass == ElementRenderers.Pass.FOREGROUND) + ElementHover.preview( + input.hovered(), presentation, e -> renderPreview(graphics, e, mouseX, mouseY)); + for (var e : layout.elements()) + if (visible(e)) { + ElementRenderers.GLOBAL.render( + pass, + new ElementRenderers.Context( + graphics, + e, + bounds(e), + host.value(e, false), + host.value(e, true), + viewport.contains(mouseX, mouseY) ? mouseX : Integer.MIN_VALUE, + viewport.contains(mouseX, mouseY) ? mouseY : Integer.MIN_VALUE, + 0, + (System.nanoTime() - started) / 50000000d, + presentation)); + } + } + } finally { + graphics.disableScissor(); + } + } + + private void renderPreview(GuiGraphics graphics, Element element, int mouseX, int mouseY) { + ElementRenderers.GLOBAL.render( + new ElementRenderers.Context( + graphics, + element, + bounds(element), + host.value(element, false), + host.value(element, true), + mouseX, + mouseY, + 0, + (System.nanoTime() - started) / 50000000d, + presentation)); + } + + public void mouseMoved(double x, double y) { + refresh(); + if (host.inputEnabled()) input.mouseMoved(x, y); + } + + public Element hit(double x, double y) { + refresh(); + return input.hit(x, y); + } + + public boolean keyReleased(int key, int scan, int modifiers) { + refresh(); + return host.inputEnabled() && input.keyReleased(key, scan, modifiers); + } + + public Element hovered(int x, int y) { + refresh(); + input.hover(x, y); + return input.hovered(); + } + + public List tooltip(int x, int y) { + var hovered = hovered(x, y); + return hovered == null ? List.of() : tooltip(hovered, x, y); + } + + /** Compose a registered fragment element selected by an external native hit-test. */ + public List tooltip(Element element, int x, int y) { + refresh(); + if (!layout.bounds().containsKey(element) || !viewport.contains(x, y)) return List.of(); + return ElementTooltips.collect( + element, + presentation, + input::instance, + x, + y, + net.minecraft.client.gui.screens.Screen.hasShiftDown(), + net.minecraft.client.gui.screens.Screen.hasControlDown(), + net.minecraft.client.gui.screens.Screen.hasAltDown()); + } + + public boolean click(double x, double y, int button) { + refresh(); + return host.inputEnabled() && input.click(x, y, button); + } + + public boolean key(int key, int scan, int modifiers) { + refresh(); + return host.inputEnabled() && input.keyPressed(key, scan, modifiers); + } + + public boolean character(char c, int modifiers) { + refresh(); + return host.inputEnabled() && input.charTyped(c, modifiers); + } + + public boolean drag(double x, double y, int button, double dx, double dy) { + refresh(); + return host.inputEnabled() && input.drag(x, y, button, dx, dy); + } + + public boolean release(double x, double y, int button) { + refresh(); + return host.inputEnabled() && input.release(x, y, button); + } + + public boolean scroll(double x, double y, double amount) { + refresh(); + return host.inputEnabled() && input.scroll(x, y, 0, amount); + } + + public void tick() { + refresh(); + if (host.inputEnabled()) input.tick(); + } + + public void cancelInteraction() { + input.cancelInteraction(); + } + + public void removed() { + input.removed(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementHighlight.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementHighlight.java new file mode 100644 index 00000000..fd0d85e4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementHighlight.java @@ -0,0 +1,50 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.Objects; + +import net.minecraft.client.gui.GuiGraphics; + +import zone.moddev.mc.lib.gui.layout.Rect; + +/** Presentation intent and fallback outline style, independent of the requesting UI feature. */ +public record ElementHighlight(Kind kind, int colour, int outset) { + public enum Kind { + /** Persistent identification, for example a resource group's colour. */ + DECORATION, + /** Emphasize the element's existing decoration, for example while hovering a group selector. */ + EMPHASIS, + /** Mark a selected target with the requesting control's selection style. */ + SELECTION + } + + public ElementHighlight { + Objects.requireNonNull(kind); + if (outset < 0) throw new IllegalArgumentException("Highlight outset must not be negative"); + } + + public static ElementHighlight decoration(int colour) { + return new ElementHighlight(Kind.DECORATION, colour, 0); + } + + public static ElementHighlight emphasis(int colour) { + return new ElementHighlight(Kind.EMPHASIS, colour, 1); + } + + public static ElementHighlight selection(int colour) { + return new ElementHighlight(Kind.SELECTION, colour, 0); + } + + public Rect outline(Rect bounds) { + return new Rect( + bounds.x() - outset, + bounds.y() - outset, + bounds.width() + 2 * outset, + bounds.height() + 2 * outset); + } + + /** Standard rectangular fallback; custom presentations may reuse it or draw another shape. */ + public void render(GuiGraphics graphics, Rect bounds) { + var r = outline(bounds); + graphics.renderOutline(r.x(), r.y(), r.width(), r.height(), colour); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementHover.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementHover.java new file mode 100644 index 00000000..857b51c2 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementHover.java @@ -0,0 +1,31 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.function.Consumer; + +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.runtime.MachineMenu; +import zone.moddev.mc.lib.gui.runtime.ScreenHover; + +/** Shared adapters for declarative hover callbacks and hover-shows presentation. */ +public final class ElementHover { + private ElementHover() {} + + /** Machine-backed hosts supply the menu context; hover tracking itself has no menu dependency. */ + public static void dispatch(MachineMenu menu, Element element, boolean entered) { + element + .common() + .onHover() + .ifPresent( + handler -> + zone.moddev.mc.lib.api.MaterialExtensions.GLOBAL + .events() + .dispatch( + handler, + zone.moddev.mc.lib.api.MaterialEventTypes.GUI_HOVER, + new ScreenHover(menu, element, entered))); + } + + public static void preview(Element hovered, ElementRenderAccess access, Consumer draw) { + if (hovered != null) hovered.common().hoverShows().flatMap(access::find).ifPresent(draw); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementInput.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementInput.java new file mode 100644 index 00000000..3c7f85f3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementInput.java @@ -0,0 +1,315 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.function.Predicate; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.Element; + +/** + * Focus, capture and lifecycle routing, independent of vanilla's inventory key handling. Call + * {@link #update()} once before routing an input event, tick or frame. All operations in that event + * share its visibility snapshot. + */ +public class ElementInput { + private final List order; + private final Map instances = new IdentityHashMap<>(); + private final Map shown = new IdentityHashMap<>(); + private final Predicate visible; + private final Function bounds; + private final BiPredicate hitArea; + private final BiConsumer hoverListener; + private E focused, captured, hovered; + private double mouseX, mouseY; + private boolean pointerKnown; + private int capturedButton = -1, cancelledButton = -1; + private boolean closed; + + public ElementInput( + List elements, Function factory, Predicate visible, Function bounds) { + this(elements, factory, visible, bounds, (x, y) -> true, (element, entered) -> {}); + } + + public ElementInput( + List elements, + Function factory, + Predicate visible, + Function bounds, + BiPredicate hitArea, + BiConsumer hoverListener) { + order = List.copyOf(elements); + this.visible = visible; + this.bounds = bounds; + this.hitArea = Objects.requireNonNull(hitArea); + this.hoverListener = Objects.requireNonNull(hoverListener); + for (var element : order) + instances.put(element, Objects.requireNonNull(factory.apply(element))); + var unique = Collections.newSetFromMap(new IdentityHashMap()); + for (var instance : instances.values()) + if (!unique.add(Objects.requireNonNull(instance.stateIdentity()))) + throw new IllegalArgumentException("Element factories must return fresh instances"); + } + + public B instance(E element) { + return instances.get(element); + } + + public void mouseMoved(double x, double y) { + hover(x, y); + if (!closed) + for (var element : order) if (isShown(element)) instances.get(element).mouseMoved(x, y); + } + + /** Sample the pointer each frame without synthesizing mouse-movement events. */ + public void hover(double x, double y) { + if (closed) return; + pointerKnown = true; + mouseX = x; + mouseY = y; + refreshHover(); + } + + public E hovered() { + return hovered; + } + + /** Leave before disposal or suspension; a later pointer sample can enter again. */ + public void clearHover() { + pointerKnown = false; + changeHover(null); + } + + private void refreshHover() { + if (closed) return; + changeHover(pointerKnown ? hit(mouseX, mouseY) : null); + } + + /** Topmost visible hit without changing pointer or focus state. */ + public E hit(double x, double y) { + if (!closed && hitArea.test(x, y)) + for (var element : order.reversed()) + if (isShown(element) && bounds.apply(element).contains(x, y)) return element; + return null; + } + + private void changeHover(E next) { + if (next == hovered) return; + var previous = hovered; + hovered = next; + if (previous != null) { + instances.get(previous).hoverChanged(false); + hoverListener.accept(previous, false); + } + if (next != null && !closed && hovered == next) { + instances.get(next).hoverChanged(true); + hoverListener.accept(next, true); + } + } + + public E focused() { + return focused; + } + + public boolean capturing() { + return captured != null; + } + + /** A new physical press ends any suppression left over from a cancelled earlier gesture. */ + public void beginClick() { + if (captured == null) cancelledButton = -1; + } + + public void update() { + if (closed) return; + for (var element : order) { + boolean now = visible.test(element); + Boolean previous = shown.put(element, now); + if (!Objects.equals(previous, now)) { + if (!now) { + if (captured == element) cancelCapture(); + else instances.get(element).cancelInteraction(); + if (focused == element) focus(null); + } + instances.get(element).visibilityChanged(now); + } + } + refreshHover(); + } + + private boolean isShown(E element) { + return Boolean.TRUE.equals(shown.get(element)); + } + + public void tick() { + if (!closed) for (var e : order) if (isShown(e)) instances.get(e).tick(); + } + + public void blurUnless(Object element) { + if (focused != element) focus(null); + } + + public void clearFocus() { + focus(null); + } + + /** + * Routes a physical press through visible hits, last element first. Hosts handle overlays before + * this call and may fall back to native input when it returns false. Capture consumes presses + * even outside the viewport; a missed or unhandled press clears focus. + */ + public boolean click(double x, double y, int button) { + if (closed) return false; + if (captured != null) return true; + beginClick(); + if (hitArea.test(x, y)) + for (var element : order.reversed()) { + if (!isShown(element) || !bounds.apply(element).contains(x, y)) continue; + blurUnless(element); + if (click(element, x, y, button)) return true; + } + clearFocus(); + return false; + } + + /** Cancel the current gesture and focus when a fragment closes or its viewport moves. */ + public void cancelInteraction() { + if (focused != null && focused != captured) instances.get(focused).cancelInteraction(); + cancelCapture(); + focus(null); + } + + private void focus(E element) { + if (focused == element) return; + if (focused != null) instances.get(focused).setFocused(false); + focused = element; + if (focused != null) instances.get(focused).setFocused(true); + } + + /** Called in screen hit order, after checking drawers and other elements above this one. */ + public boolean click(E element, double x, double y, int button) { + if (closed || !isShown(element)) return false; + if (captured != null) return true; + cancelledButton = -1; + var instance = instances.get(element); + var result = instance.mouseClicked(x, y, button); + if (result == ElementBehavior.Click.PASS) return false; + focus(instance.focusable() ? element : null); + if (result == ElementBehavior.Click.CAPTURE) { + captured = element; + capturedButton = button; + } + return true; + } + + public boolean drag(double x, double y, int button, double dx, double dy) { + if (closed) return false; + if (captured == null) return button == cancelledButton; + if (button == capturedButton) instances.get(captured).mouseDragged(x, y, button, dx, dy); + return true; + } + + public boolean release(double x, double y, int button) { + if (closed) return false; + if (button == cancelledButton) { + cancelledButton = -1; + return true; + } + // A release belongs to the control that captured the press, not the current hover target. + if (captured == null) return false; + if (button == capturedButton) { + var instance = instances.get(captured); + captured = null; + capturedButton = -1; + instance.mouseReleased(x, y, button); + } + return true; + } + + public boolean scroll(double x, double y, double dx, double dy) { + if (closed) return false; + if (captured != null) return true; + if (!hitArea.test(x, y)) return false; + for (var e : order.reversed()) + if (isShown(e) + && bounds.apply(e).contains(x, y) + && instances.get(e).mouseScrolled(x, y, dx, dy)) return true; + return false; + } + + public boolean keyPressed(int key, int scan, int modifiers) { + if (closed) return false; + if (key == 258) { // Tab / Shift+Tab. + cancelCapture(); + var candidates = + order.stream().filter(this::isShown).filter(e -> instances.get(e).focusable()).toList(); + if (candidates.isEmpty()) return false; + int index = -1; + for (int i = 0; i < candidates.size(); i++) if (candidates.get(i) == focused) index = i; + int direction = (modifiers & 1) != 0 ? -1 : 1; + focus( + candidates.get( + index < 0 + ? (direction > 0 ? 0 : candidates.size() - 1) + : Math.floorMod(index + direction, candidates.size()))); + return true; + } + if (key == 256 && captured != null) { + cancelCapture(); + return true; + } + if (focused == null) { + for (var e : order) + if (isShown(e) && instances.get(e).shortcut(key, scan, modifiers)) return true; + return false; + } + boolean handled = instances.get(focused).keyPressed(key, scan, modifiers); + if (key == 256 && !handled) focus(null); + // Even unhandled inventory shortcuts must not reach slots while an editor has focus. + return true; + } + + public boolean keyReleased(int key, int scan, int modifiers) { + if (closed) return false; + if (focused == null) return false; + instances.get(focused).keyReleased(key, scan, modifiers); + return true; + } + + public boolean charTyped(char character, int modifiers) { + if (closed) return false; + if (focused == null) return false; + instances.get(focused).charTyped(character, modifiers); + return true; + } + + private void cancelCapture() { + if (captured == null) return; + cancelledButton = capturedButton; + instances.get(captured).cancelInteraction(); + captured = null; + capturedButton = -1; + } + + public void resized() { + if (closed) return; + cancelCapture(); + instances.values().forEach(ElementBehavior::resized); + refreshHover(); + } + + public void removed() { + if (closed) return; + closed = true; + clearHover(); + cancelCapture(); + focus(null); + instances.values().forEach(ElementBehavior::removed); + } + + public void initialized() { + if (!closed) for (var element : order) instances.get(element).initialized(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementRenderAccess.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementRenderAccess.java new file mode 100644 index 00000000..101ac6b9 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementRenderAccess.java @@ -0,0 +1,95 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.Optional; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.Text; +import zone.moddev.mc.lib.gui.model.elements.*; +import zone.moddev.mc.lib.gui.sync.ElementStateView; + +/** Read-only client presentation services. No machine, menu, packets or storage are exposed. */ +public interface ElementRenderAccess { + Font font(); + + Rect viewport(); + + default Component text(Text text) { + return ScreenText.component(text); + } + + default Optional find(String id) { + return Optional.empty(); + } + + default Optional parent(Element element) { + return Optional.empty(); + } + + default Object value(Element element, boolean maximum) { + return null; + } + + /** Synchronized server-authored tooltip rows; no live host sampling on the client. */ + default java.util.List serverTooltip(Element element) { + return java.util.List.of(); + } + + default Rect bounds(Element element) { + throw new IllegalArgumentException("No bounds for element"); + } + + default boolean visible(Element element) { + return false; + } + + default ElementStateView state() { + return ElementStateView.DEFAULTS; + } + + default Optional capability(Element element, Class type) { + return Optional.empty(); + } + + /** A registered group caption, or the ordinary tooltip for elements without that capability. */ + default Optional groupLabel(Element element) { + return capability(element, GroupLabelProvider.class) + .map(GroupLabelProvider::groupLabel) + .orElseGet(() -> element.common().tooltip()); + } + + /** Draw an element-owned highlight, falling back to an outline; hidden targets are ignored. */ + default void highlight(GuiGraphics graphics, Element element, ElementHighlight request) { + if (!visible(element)) return; + var clip = viewport(); + graphics.pose().pushPose(); + graphics.enableScissor(clip.x(), clip.y(), clip.right(), clip.bottom()); + try { + capability(element, HighlightPresentation.class) + .orElse(HighlightPresentation.OUTLINE) + .highlight(graphics, bounds(element), request); + } finally { + graphics.setColor(1, 1, 1, 1); + graphics.disableScissor(); + graphics.pose().popPose(); + } + } + + /** Returned stacks are display copies, never mutable menu storage. */ + default ItemStack contents(SlotElement element) { + return ItemStack.EMPTY; + } + + default ItemStack hint(SlotElement element) { + return ItemStack.EMPTY; + } + + default Optional drawerPresentation(Element element, Class type) { + return Optional.empty(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementRenderers.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementRenderers.java new file mode 100644 index 00000000..e0534e88 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementRenderers.java @@ -0,0 +1,287 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Function; + +import net.minecraft.client.gui.GuiGraphics; + +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.fml.event.lifecycle.FMLLoadCompleteEvent; + +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.load.ScreenException; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.Screen; + +/** Client renderer registry. Register built-in or mod-owned element classes during client setup. */ +public final class ElementRenderers { + public static final ElementRenderers GLOBAL = builtins(); + + public enum Pass { + BACKGROUND, + OVERLAY, + FOREGROUND, + TOP + } + + public enum Clip { + ELEMENT, + VIEWPORT + } + + private record Entry(Consumer renderer, Consumer validator, Clip clip) {} + + private final Map, Entry>> entries = new EnumMap<>(Pass.class); + private final Map, Optional>> resolved = + new EnumMap<>(Pass.class); + + public ElementRenderers() { + for (var pass : Pass.values()) { + entries.put(pass, new HashMap<>()); + resolved.put(pass, new HashMap<>()); + } + } + + /** Fresh registry containing the defaults; useful for isolated extensions and tests. */ + public static ElementRenderers builtins() { + var registry = new ElementRenderers(); + BuiltinElementClients.registerRenderers(registry); + return registry; + } + + @FunctionalInterface + public interface Renderer { + void render(E element, Context context); + + /** Checks client dependencies before a screen opens; throw with a descriptive message. */ + default void validate(E element) {} + } + + /** Immutable frame inputs; values are synchronized snapshots and coordinates are GUI pixels. */ + public record Context( + GuiGraphics graphics, + Element element, + Rect bounds, + Object value, + Object maximum, + int mouseX, + int mouseY, + float partialTick, + double elapsedTicks, + ElementRenderAccess access) { + public Context( + GuiGraphics graphics, + Element element, + Rect bounds, + Object value, + Object maximum, + int mouseX, + int mouseY, + float partialTick) { + this( + graphics, + element, + bounds, + value, + maximum, + mouseX, + mouseY, + partialTick, + partialTick, + null); + } + + public Context( + GuiGraphics graphics, + Element element, + Rect bounds, + Object value, + Object maximum, + int mouseX, + int mouseY, + float partialTick, + double elapsedTicks) { + this( + graphics, + element, + bounds, + value, + maximum, + mouseX, + mouseY, + partialTick, + elapsedTicks, + null); + } + + public boolean hovered() { + return bounds.contains(mouseX, mouseY); + } + + public double ratio() { + return element.fillRatio(value, maximum); + } + + public ElementRenderAccess access() { + return Objects.requireNonNull(access, "This render context has no presentation services"); + } + } + + public void register(Class type, Renderer renderer) { + register(Pass.BACKGROUND, type, Clip.ELEMENT, renderer); + } + + public void registerOverlay(Class type, Renderer renderer) { + register(Pass.OVERLAY, type, Clip.ELEMENT, renderer); + } + + public void registerForeground(Class type, Renderer renderer) { + register(Pass.FOREGROUND, type, Clip.VIEWPORT, renderer); + } + + public void registerTop(Class type, Renderer renderer) { + register(Pass.TOP, type, Clip.ELEMENT, renderer); + } + + /** Exact type wins; otherwise the most specific registered superclass/interface wins. */ + public synchronized void register( + Pass pass, Class type, Clip clip, Renderer renderer) { + var entry = entry(type, clip, renderer); + if (entries.get(pass).putIfAbsent(type, entry) != null) + throw new IllegalArgumentException( + "Duplicate element renderer for " + pass + ": " + type.getName()); + resolved.get(pass).clear(); + } + + /** Intentional replacement of an existing registration; ordinary register rejects duplicates. */ + public void replace(Class type, Renderer renderer) { + replace(Pass.BACKGROUND, type, Clip.ELEMENT, renderer); + } + + public synchronized void replace( + Pass pass, Class type, Clip clip, Renderer renderer) { + var entry = entry(type, clip, renderer); + if (!entries.get(pass).containsKey(type)) + throw new IllegalArgumentException("No renderer registered for " + type.getName()); + entries.get(pass).put(type, entry); + resolved.get(pass).clear(); + } + + private static Entry entry(Class type, Clip clip, Renderer renderer) { + Objects.requireNonNull(type); + Objects.requireNonNull(clip); + Objects.requireNonNull(renderer); + return new Entry( + c -> renderer.render(type.cast(c.element()), c), + e -> renderer.validate(type.cast(e)), + clip); + } + + private synchronized Optional resolve(Pass pass, Class type) { + var cache = resolved.get(pass); + if (cache.containsKey(type)) return cache.get(type); + var registrations = entries.get(pass); + var exact = registrations.get(type); + if (exact != null) { + var result = Optional.of(exact); + cache.put(type, result); + return result; + } + var candidates = + registrations.keySet().stream().filter(key -> key.isAssignableFrom(type)).toList(); + var specific = + candidates.stream() + .filter( + key -> + candidates.stream() + .noneMatch(other -> key != other && key.isAssignableFrom(other))) + .toList(); + if (specific.size() > 1) + throw new IllegalArgumentException( + "Ambiguous element renderers for " + + pass + + " " + + type.getName() + + ": " + + specific.stream().map(Class::getName).sorted().toList() + + "; register its exact class"); + var result = + specific.isEmpty() + ? Optional.empty() + : Optional.of(registrations.get(specific.getFirst())); + cache.put(type, result); + return result; + } + + public Consumer require(Class type) { + return require(Pass.BACKGROUND, type); + } + + public Consumer require(Pass pass, Class type) { + return resolve(pass, type) + .orElseThrow( + () -> + new IllegalArgumentException( + "Missing element renderer for " + pass + ": " + type.getName())) + .renderer(); + } + + /** Fail at screen creation rather than halfway through a frame. Optional passes may be absent. */ + public void validate(Screen screen) { + validate(screen.elements(), element -> screen.source(element.common())); + } + + /** Validates a host's complete traversal, with a source name for each element's diagnostics. */ + public void validate(Iterable elements, Function source) { + for (var element : elements) { + try { + require(element.getClass()); + for (var pass : Pass.values()) + resolve(pass, element.getClass()).ifPresent(entry -> entry.validator().accept(element)); + } catch (IllegalArgumentException failure) { + throw new ScreenException( + source.apply(element), element.common().line(), failure.getMessage(), failure); + } + } + } + + public void render(Context context) { + render(Pass.BACKGROUND, context); + } + + public void renderOverlay(Context context) { + render(Pass.OVERLAY, context); + } + + public void render(Pass pass, Context context) { + var entry = resolve(pass, context.element().getClass()); + if (entry.isEmpty()) { + if (pass == Pass.BACKGROUND) require(context.element().getClass()); + return; + } + var graphics = context.graphics(); + var clip = entry.get().clip() == Clip.ELEMENT ? context.bounds() : context.access().viewport(); + graphics.pose().pushPose(); + graphics.enableScissor(clip.x(), clip.y(), clip.right(), clip.bottom()); + try { + entry.get().renderer().accept(context); + } finally { + graphics.setColor(1, 1, 1, 1); + graphics.disableScissor(); + graphics.pose().popPose(); + } + } + + @EventBusSubscriber(modid = "mmdlib", bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT) + public static final class Registration { + @SubscribeEvent + public static void complete(FMLLoadCompleteEvent event) { + event.enqueueWork( + () -> Screens.all().values().forEach(bound -> GLOBAL.validate(bound.screen()))); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementTooltips.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementTooltips.java new file mode 100644 index 00000000..7fa6987f --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementTooltips.java @@ -0,0 +1,117 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; +import java.util.function.Function; + +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; + +import zone.moddev.mc.lib.gui.model.*; + +/** Shared leaf-to-root composition for screens, native item tooltips, JEI and fragments. */ +public final class ElementTooltips { + private ElementTooltips() {} + + public static List collect( + Element target, + ElementRenderAccess access, + Function behaviors, + int x, + int y, + boolean shift, + boolean control, + boolean alt) { + if (!access.visible(target)) return List.of(); + var rows = new ArrayList(); + var visited = Collections.newSetFromMap(new IdentityHashMap()); + Element current = target, child = null; + while (current != null && access.visible(current)) { + if (!visited.add(current)) throw new IllegalArgumentException("Cyclic tooltip parent chain"); + var context = + new Context( + target, current, Optional.ofNullable(child), x, y, shift, control, alt, access); + var behavior = behaviors.apply(current); + if (child == null) { + current.appendTooltip(context, rows); + behavior.appendTooltip(context, rows); + } else { + current.appendChildTooltip(context, rows); + behavior.appendChildTooltip(context, rows); + } + child = current; + current = access.parent(current).orElse(null); + } + return rows.stream().map(Component::copy).map(Component.class::cast).toList(); + } + + private record Context( + Element target, + Element element, + Optional child, + int mouseX, + int mouseY, + boolean shiftDown, + boolean controlDown, + boolean altDown, + ElementRenderAccess access) + implements TooltipContext { + public Object value(Element e, boolean maximum) { + return access.value(e, maximum); + } + + public Component text(Text value) { + return access.text(value); + } + + public List serverLines() { + return access.serverTooltip(element); + } + + public void appendDefaults(List rows) { + rows.addAll(serverLines()); + var common = element.common(); + var authored = + shiftDown ? common.tooltipShift().or(element::tooltipText) : element.tooltipText(); + authored.ifPresent( + t -> { + var arguments = new LinkedHashMap(); + arguments.put( + "value", + access + .capability(element, ValueLabelProvider.class) + .flatMap(ValueLabelProvider::valueLabel) + .map(label -> text(label).getString()) + .orElseGet(() -> element.displayText(value(), shiftDown).getString())); + arguments.put("max", element.displayText(maximum(), shiftDown).getString()); + element + .tooltipArguments(value(), maximum()) + .forEach((key, v) -> arguments.put(key, text(v).getString())); + appendLines(text(t), arguments, rows); + }); + if (common.tooltipShift().isPresent() && !shiftDown) + rows.add(Component.translatable("screen.mmdlib.shift_for_more")); + element.valueTooltip(value(), maximum()).forEach(line -> appendLines(line, Map.of(), rows)); + } + } + + /** Split translated rows while retaining each component span's style. */ + private static void appendLines( + Component component, Map arguments, List rows) { + var parts = new ArrayList(); + parts.add(Component.empty()); + component.visit( + (style, span) -> { + for (var entry : arguments.entrySet()) + span = span.replace("{" + entry.getKey() + "}", entry.getValue()); + var lines = span.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (i > 0) parts.add(Component.empty()); + parts.getLast().append(Component.literal(lines[i]).withStyle(style)); + } + return Optional.empty(); + }, + Style.EMPTY); + if (parts.size() > 1 && parts.getLast().getString().isEmpty()) parts.removeLast(); + rows.addAll(parts); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/EnergyGaugeRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/EnergyGaugeRenderer.java index da7838ef..58d04fab 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/EnergyGaugeRenderer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/EnergyGaugeRenderer.java @@ -3,7 +3,8 @@ import net.minecraft.client.gui.GuiGraphics; import zone.moddev.mc.lib.gui.layout.Rect; -import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.Orientation; +import zone.moddev.mc.lib.gui.model.elements.EnergyGaugeElement; /** Thin, lightly glossy charge bars inside the shared slim casing, without graduations. */ final class EnergyGaugeRenderer { @@ -12,8 +13,8 @@ final class EnergyGaugeRenderer { private EnergyGaugeRenderer() {} - static void draw(GuiGraphics g, Rect r, Element.EnergyGauge gauge, double ratio) { - boolean vertical = gauge.orientation() == Element.Orientation.VERTICAL; + static void draw(GuiGraphics g, Rect r, EnergyGaugeElement gauge, double ratio) { + boolean vertical = gauge.orientation() == Orientation.VERTICAL; int length = vertical ? r.height() : r.width(); GaugeRenderer.background(g, r, vertical); int inside = length - 4; @@ -33,8 +34,7 @@ static void draw(GuiGraphics g, Rect r, Element.EnergyGauge gauge, double ratio) if (lit >= 3) { fill(g, r, vertical, 3, 2 + position, 12, 1, shade(gauge.color(), -22)); } - if (lit >= 2) - fill(g, r, vertical, 4, 1 + position + lit, 10, 1, shade(gauge.color(), 28)); + if (lit >= 2) fill(g, r, vertical, 4, 1 + position + lit, 10, 1, shade(gauge.color(), 28)); } position += size + 1; } @@ -54,13 +54,22 @@ private static int shade(int color, int light) { /** Coordinates run from the bottom upwards, or left to right for a horizontal gauge. */ private static void fill( - GuiGraphics g, Rect r, boolean vertical, int cross, int along, int width, int length, int color) { + GuiGraphics g, + Rect r, + boolean vertical, + int cross, + int along, + int width, + int length, + int color) { if (width <= 0 || length <= 0) return; if (vertical) - g.fill(r.x() + cross, r.bottom() - along - length, - r.x() + cross + width, r.bottom() - along, color); - else - g.fill(r.x() + along, r.y() + cross, - r.x() + along + length, r.y() + cross + width, color); + g.fill( + r.x() + cross, + r.bottom() - along - length, + r.x() + cross + width, + r.bottom() - along, + color); + else g.fill(r.x() + along, r.y() + cross, r.x() + along + length, r.y() + cross + width, color); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/FilterControlPresentation.java b/src/main/java/zone/moddev/mc/lib/gui/client/FilterControlPresentation.java new file mode 100644 index 00000000..808a8d96 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/FilterControlPresentation.java @@ -0,0 +1,6 @@ +package zone.moddev.mc.lib.gui.client; + +/** Read-only drawing of an editor's local text or compatibility-widget state. */ +interface FilterControlPresentation { + void render(ElementRenderers.Context context); +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/FilterCustomBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/FilterCustomBehavior.java new file mode 100644 index 00000000..d8b18c07 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/FilterCustomBehavior.java @@ -0,0 +1,82 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; + +import net.minecraft.network.chat.Component; + +import zone.moddev.mc.lib.api.automation.FilterControl; + +/** Adapts the existing filter-widget API to the shared element lifecycle and capture path. */ +final class FilterCustomBehavior implements ElementBehavior, FilterControlPresentation { + private final FilterWidgets.Instance instance; + private final ElementAccess access; + + FilterCustomBehavior(FilterControl.Custom element, ElementAccess access) { + this.access = access; + instance = + FilterWidgets.create( + element, + access::bounds, + () -> FilterDrawing.configuration(access.value()).copy(), + () -> access.pending(element), + value -> FilterEdits.submit(access, element, 0, value)); + } + + public boolean focusable() { + return instance != null; + } + + public void setFocused(boolean focused) { + if (instance != null) instance.focus(focused); + } + + public Click mouseClicked(double x, double y, int button) { + return instance != null && instance.click(x, y, button) ? Click.CAPTURE : Click.PASS; + } + + public boolean keyPressed(int key, int scan, int modifiers) { + return instance != null && instance.key(key, scan, modifiers); + } + + public boolean charTyped(char c, int modifiers) { + return instance != null && instance.character(c, modifiers); + } + + public boolean mouseDragged(double x, double y, int button, double dx, double dy) { + return instance != null && instance.drag(x, y, button, dx, dy); + } + + public boolean mouseReleased(double x, double y, int button) { + return instance != null && instance.release(x, y, button); + } + + public boolean mouseScrolled(double x, double y, double dx, double dy) { + return instance != null && instance.scroll(x, y, dy); + } + + public void cancelInteraction() { + if (instance != null) instance.cancel(); + } + + public void removed() { + if (instance != null) instance.removed(); + } + + public List tooltip(int x, int y) { + var tip = instance == null ? null : instance.tooltip(); + return tip == null ? List.of() : List.of(tip); + } + + public void render(ElementRenderers.Context c) { + if (instance == null) + c.graphics() + .drawString(c.access().font(), "?", c.bounds().x() + 2, c.bounds().y() + 2, 0xffffff); + else + instance.render( + c.graphics(), + c.bounds(), + FilterDrawing.configuration(access.value()).copy(), + c.mouseX(), + c.mouseY()); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/FilterDrawing.java b/src/main/java/zone/moddev/mc/lib/gui/client/FilterDrawing.java new file mode 100644 index 00000000..4154627d --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/FilterDrawing.java @@ -0,0 +1,25 @@ +package zone.moddev.mc.lib.gui.client; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.nbt.CompoundTag; + +import zone.moddev.mc.lib.gui.layout.Rect; + +final class FilterDrawing { + private FilterDrawing() {} + + static CompoundTag configuration(Object value) { + return value instanceof CompoundTag tag ? tag : new CompoundTag(); + } + + static void arrow(GuiGraphics g, Rect r, boolean forward) { + for (int i = 0; i < 3; i++) { + int d = forward ? i : 2 - i; + g.fill(r.x() + d, r.y() + i, r.x() + d + 1, r.bottom() - i, 0xffeeeeee); + } + } + + static void button(GuiGraphics g, Rect r) { + ElementDrawing.sprite(g, "slot", r); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/FilterEdits.java b/src/main/java/zone/moddev/mc/lib/gui/client/FilterEdits.java new file mode 100644 index 00000000..f10e279c --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/FilterEdits.java @@ -0,0 +1,19 @@ +package zone.moddev.mc.lib.gui.client; + +import zone.moddev.mc.lib.api.automation.FilterControl; + +/** + * Behavior service supplied by a filter-editor host; server authorization remains in its transport. + */ +@FunctionalInterface +public interface FilterEdits { + boolean submit(FilterControl control, int entry, String value); + + static boolean submit(ElementAccess access, FilterControl control, int entry, String value) { + return !access.pending(control) + && access + .service(FilterEdits.class) + .map(service -> service.submit(control, entry, value)) + .orElse(false); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/FilterNumberBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/FilterNumberBehavior.java new file mode 100644 index 00000000..142076d2 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/FilterNumberBehavior.java @@ -0,0 +1,65 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; + +import net.minecraft.network.chat.Component; + +import zone.moddev.mc.lib.api.automation.FilterControl; +import zone.moddev.mc.lib.gui.layout.Rect; + +final class FilterNumberBehavior implements ElementBehavior { + private final FilterControl.Number element; + private final ElementAccess access; + + FilterNumberBehavior(FilterControl.Number element, ElementAccess access) { + this.element = element; + this.access = access; + } + + public boolean focusable() { + return true; + } + + private void step(boolean forward) { + long current = FilterDrawing.configuration(access.value()).getInt(element.id()); + FilterEdits.submit( + access, + element, + 0, + Long.toString( + Math.clamp( + current + (forward ? (long) element.step() : -(long) element.step()), + element.min(), + element.max()))); + } + + public Click mouseClicked(double x, double y, int button) { + if (button < 0 || button > 1) return Click.PASS; + step(x >= access.bounds().x() + access.bounds().width() / 2); + return Click.CONSUME; + } + + public boolean keyPressed(int key, int scan, int modifiers) { + if (key != 262 && key != 263) return false; + step(key == 262); + return true; + } + + public List tooltip(int x, int y) { + return List.of(element.tooltip()); + } + + static void render(FilterControl.Number element, ElementRenderers.Context c) { + var r = c.bounds(); + var g = c.graphics(); + FilterDrawing.button(g, r); + g.drawCenteredString( + c.access().font(), + Integer.toString(FilterDrawing.configuration(c.value()).getInt(element.id())), + r.x() + r.width() / 2, + r.y() + 5, + 0xffffff); + FilterDrawing.arrow(g, new Rect(r.x() + 2, r.y() + 4, 6, 8), false); + FilterDrawing.arrow(g, new Rect(r.right() - 8, r.y() + 4, 6, 8), true); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/FilterSamplesBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/FilterSamplesBehavior.java new file mode 100644 index 00000000..892a92ce --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/FilterSamplesBehavior.java @@ -0,0 +1,47 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; + +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.api.automation.*; + +final class FilterSamplesBehavior implements ElementBehavior { + private static final SlotRenderer RENDERER = new SlotRenderer(); + private final FilterControl.Samples element; + private final ElementAccess access; + + FilterSamplesBehavior(FilterControl.Samples element, ElementAccess access) { + this.element = element; + this.access = access; + } + + private int index(double x, double y) { + return element.sampleIndex(access.bounds(), x, y); + } + + public Click mouseClicked(double x, double y, int button) { + int index = index(x, y); + if (index < 0 || button < 0 || button > 1) return Click.PASS; + FilterEdits.submit(access, element, index, button == 1 ? "clear" : "sample"); + return Click.CONSUME; + } + + public List tooltip(int x, int y) { + int index = index(x, y); + if (index < 0) return List.of(); + return element.sampleTooltip(FilterDrawing.configuration(access.value()), index); + } + + static void render(FilterControl.Samples element, ElementRenderers.Context c) { + var r = c.bounds(); + for (int i = 0; i < element.count(); i++) + RENDERER.draw( + c.graphics(), + element.sampleBounds(r, i), + element.sampleSlot(i), + element.sampleDisplay(FilterDrawing.configuration(c.value()), i), + ItemStack.EMPTY); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/FilterTextBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/FilterTextBehavior.java new file mode 100644 index 00000000..172f1e28 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/FilterTextBehavior.java @@ -0,0 +1,94 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; + +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.network.chat.Component; + +import zone.moddev.mc.lib.api.automation.FilterControl; +import zone.moddev.mc.lib.gui.layout.Rect; + +final class FilterTextBehavior implements ElementBehavior, FilterControlPresentation { + private final FilterControl.Text element; + private final ElementAccess access; + private final EditBox field; + + FilterTextBehavior(FilterControl.Text element, ElementAccess access) { + this.element = element; + this.access = access; + field = new EditBox(access.presentation().font(), 0, 0, 80, 16, element.tooltip()); + field.setMaxLength(element.maxLength()); + sync(); + } + + private void sync() { + var r = access.bounds(); + field.setX(r.x()); + field.setY(r.y()); + field.setWidth(Math.max(12, r.width() - 12)); + if (!field.isFocused()) + field.setValue(FilterDrawing.configuration(access.value()).getString(element.id())); + } + + public boolean focusable() { + return true; + } + + public void setFocused(boolean focused) { + field.setFocused(focused); + } + + public void tick() { + sync(); + } + + public Click mouseClicked(double x, double y, int button) { + if (button < 0 || button > 1) return Click.PASS; + sync(); + if (x >= access.bounds().right() - 12) { + FilterEdits.submit(access, element, 0, field.getValue()); + return Click.CONSUME; + } + field.setFocused(true); + field.mouseClicked(x, y, button); + return Click.CAPTURE; + } + + public boolean keyPressed(int key, int scan, int modifiers) { + if (key == 256) return false; + if (key == 257 || key == 335) { + FilterEdits.submit(access, element, 0, field.getValue()); + return true; + } + return field.keyPressed(key, scan, modifiers); + } + + public boolean charTyped(char c, int modifiers) { + return field.charTyped(c, modifiers); + } + + public boolean mouseDragged(double x, double y, int button, double dx, double dy) { + return field.mouseDragged(x, y, button, dx, dy); + } + + public boolean mouseReleased(double x, double y, int button) { + return field.mouseReleased(x, y, button); + } + + public void cancelInteraction() { + field.setFocused(false); + } + + public List tooltip(int x, int y) { + return List.of(element.tooltip()); + } + + public void render(ElementRenderers.Context c) { + sync(); + field.render(c.graphics(), c.mouseX(), c.mouseY(), c.partialTick()); + var r = c.bounds(); + var submit = new Rect(r.right() - 10, r.y() + 3, 8, 10); + FilterDrawing.button(c.graphics(), submit); + FilterDrawing.arrow(c.graphics(), submit, true); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/FilterToggleBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/FilterToggleBehavior.java new file mode 100644 index 00000000..f477fd6c --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/FilterToggleBehavior.java @@ -0,0 +1,50 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; + +import net.minecraft.network.chat.Component; + +import zone.moddev.mc.lib.api.automation.FilterControl; + +final class FilterToggleBehavior implements ElementBehavior { + private final FilterControl.Toggle element; + private final ElementAccess access; + + FilterToggleBehavior(FilterControl.Toggle element, ElementAccess access) { + this.element = element; + this.access = access; + } + + public boolean focusable() { + return true; + } + + public Click mouseClicked(double x, double y, int button) { + if (button < 0 || button > 1) return Click.PASS; + FilterEdits.submit(access, element, 0, ""); + return Click.CONSUME; + } + + public boolean keyPressed(int key, int scan, int modifiers) { + if (key != 257 && key != 32) return false; + mouseClicked(0, 0, 0); + return true; + } + + public List tooltip(int x, int y) { + return List.of( + FilterDrawing.configuration(access.value()).getBoolean(element.id()) + ? element.selectedTooltip() + : element.tooltip()); + } + + static void render(FilterControl.Toggle element, ElementRenderers.Context c) { + var r = c.bounds(); + var g = c.graphics(); + boolean selected = FilterDrawing.configuration(c.value()).getBoolean(element.id()); + ElementDrawing.sprite(g, c.hovered() ? "button_flat_highlighted" : "button_flat", r); + g.blitSprite(selected ? element.selectedIcon() : element.icon(), r.x() + 2, r.y() + 2, 10, 10); + if (selected && element.icon().equals(element.selectedIcon())) + g.fill(r.x() + 3, r.bottom() - 3, r.right() - 3, r.bottom() - 2, 0xffeeeeee); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/GaugeRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/GaugeRenderer.java index 9ab9fb5c..298b7908 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/GaugeRenderer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/GaugeRenderer.java @@ -15,7 +15,8 @@ import org.lwjgl.opengl.GL14; import zone.moddev.mc.lib.gui.layout.Rect; -import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.GaugeFill; +import zone.moddev.mc.lib.gui.model.Orientation; /** Graduated glass gauge, with clipped fluid tiles kept upright in both orientations. */ final class GaugeRenderer { @@ -36,12 +37,12 @@ static void background(GuiGraphics g, Rect r, boolean vertical) { } } - static void draw(GuiGraphics g, Rect r, Element.Gauge gauge, Object value, Object max) { + static void draw(GuiGraphics g, Rect r, Orientation orientation, GaugeFill fill, double ratio) { var state = RenderState.capture(); try { g.flush(); g.setColor(1, 1, 1, 1); - drawGauge(g, r, gauge, value, max); + drawGauge(g, r, orientation, fill, ratio); } finally { try { g.flush(); @@ -52,20 +53,15 @@ static void draw(GuiGraphics g, Rect r, Element.Gauge gauge, Object value, Objec } private static void drawGauge( - GuiGraphics g, Rect r, Element.Gauge gauge, Object value, Object max) { - boolean vertical = gauge.orientation() == Element.Orientation.VERTICAL; + GuiGraphics g, Rect r, Orientation orientation, GaugeFill fill, double ratio) { + boolean vertical = orientation == Orientation.VERTICAL; int length = vertical ? r.height() : r.width(); shell(g, r, vertical, length, "body"); - int filled = (int) Math.round((length - 2) * ScreenText.ratio(value, max)); - FluidStack fluid = value instanceof FluidStack f ? f : null; + int filled = (int) Math.round((length - 2) * ratio); + FluidStack fluid = fill.fluid().orElse(null); int color = - gauge - .color() - .orElseGet( - () -> - fluid == null || fluid.isEmpty() - ? 0xff777777 - : IClientFluidTypeExtensions.of(fluid.getFluid()).getTintColor(fluid)); + fill.colour() + .orElseGet(() -> IClientFluidTypeExtensions.of(fluid.getFluid()).getTintColor(fluid)); for (int step = 0; step < filled; step++) { int row = length - 2 - step; int inset = (row == 1 || row == length - 2) ? 2 : 1; @@ -73,16 +69,14 @@ private static void drawGauge( vertical ? new Rect(r.x() + inset, r.y() + row, 18 - 2 * inset, 1) : new Rect(r.x() + length - 1 - row, r.y() + inset, 1, 18 - 2 * inset); - if (fluid != null && !fluid.isEmpty() && gauge.color().isEmpty()) - fluid(g, strip, r, fluid, color); + if (fluid != null) fluid(g, strip, r, fluid, color); else g.fill(strip.x(), strip.y(), strip.right(), strip.bottom(), color | 0xff000000); if (vertical && step == filled - 1) g.fill(strip.x(), strip.y(), strip.right(), strip.bottom(), 0x4dffffff); } // Keep the shared empty background flat. Only the contents get curved lighting. if (filled > 0) { - if (vertical) - g.enableScissor(r.x(), r.bottom() - 1 - filled, r.right(), r.bottom() - 1); + if (vertical) g.enableScissor(r.x(), r.bottom() - 1 - filled, r.right(), r.bottom() - 1); else g.enableScissor(r.x() + 1, r.y(), r.x() + 1 + filled, r.bottom()); try { shell(g, r, vertical, length, "glass"); diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/GhostItems.java b/src/main/java/zone/moddev/mc/lib/gui/client/GhostItems.java index 929a97ef..2305998a 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/GhostItems.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/GhostItems.java @@ -15,9 +15,13 @@ /** Translucent samples retain their item's model, texture and lighting, including opaque blocks. */ final class GhostItems { - private final Map translucent = new IdentityHashMap<>(); + private final Map> translucent = new java.util.HashMap<>(); void render(GuiGraphics graphics, ItemStack stack, int x, int y) { + render(graphics, stack, x, y, .50f); + } + + void render(GuiGraphics graphics, ItemStack stack, int x, int y, float alpha) { var minecraft = Minecraft.getInstance(); var renderer = minecraft.getItemRenderer(); var model = renderer.getModel(stack, minecraft.level, minecraft.player, 0); @@ -35,7 +39,10 @@ void render(GuiGraphics graphics, ItemStack stack, int x, int y) { type -> graphics .bufferSource() - .getBuffer(translucent.computeIfAbsent(type, original -> fade(original, .50f))), + .getBuffer( + translucent + .computeIfAbsent(alpha, ignored -> new IdentityHashMap<>()) + .computeIfAbsent(type, original -> fade(original, alpha))), 15728880, OverlayTexture.NO_OVERLAY, model); diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/GroupBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/GroupBehavior.java new file mode 100644 index 00000000..fce3d415 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/GroupBehavior.java @@ -0,0 +1,20 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.Optional; + +import zone.moddev.mc.lib.gui.model.Text; +import zone.moddev.mc.lib.gui.model.containers.GroupElement; + +/** A panel supplies its caption to any UI that presents it as a group. */ +public class GroupBehavior extends DefaultElementBehavior implements GroupLabelProvider { + private final GroupElement group; + + public GroupBehavior(GroupElement group, ElementAccess access) { + super(access); + this.group = group; + } + + public Optional groupLabel() { + return group.label(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/GroupLabelProvider.java b/src/main/java/zone/moddev/mc/lib/gui/client/GroupLabelProvider.java new file mode 100644 index 00000000..f9f57789 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/GroupLabelProvider.java @@ -0,0 +1,10 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.Optional; + +import zone.moddev.mc.lib.gui.model.Text; + +/** Optional group caption supplied by an element's registered client behavior. */ +public interface GroupLabelProvider { + Optional groupLabel(); +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/HighlightPresentation.java b/src/main/java/zone/moddev/mc/lib/gui/client/HighlightPresentation.java new file mode 100644 index 00000000..73ae63df --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/HighlightPresentation.java @@ -0,0 +1,13 @@ +package zone.moddev.mc.lib.gui.client; + +import net.minecraft.client.gui.GuiGraphics; + +import zone.moddev.mc.lib.gui.layout.Rect; + +/** Optional element-owned highlight drawing exposed through its registered client behavior. */ +@FunctionalInterface +public interface HighlightPresentation { + HighlightPresentation OUTLINE = (graphics, bounds, request) -> request.render(graphics, bounds); + + void highlight(GuiGraphics graphics, Rect bounds, ElementHighlight request); +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/IconSprite.java b/src/main/java/zone/moddev/mc/lib/gui/client/IconSprite.java new file mode 100644 index 00000000..548941e3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/IconSprite.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.lib.gui.client; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.inventory.InventoryMenu; +import net.minecraft.world.item.ItemStack; + +/** A 16-pixel drawer icon, supplied by a GUI sprite, an atlas sprite, or an item model. */ +@FunctionalInterface +public interface IconSprite { + void draw(GuiGraphics graphics, int x, int y); + + static IconSprite gui(ResourceLocation sprite) { + return (graphics, x, y) -> ElementDrawing.blit(graphics, sprite, x, y, 16, 16); + } + + static IconSprite atlas(ResourceLocation sprite) { + return (graphics, x, y) -> + graphics.blit( + x, + y, + 0, + 16, + 16, + Minecraft.getInstance().getTextureAtlas(InventoryMenu.BLOCK_ATLAS).apply(sprite)); + } + + static IconSprite item(java.util.function.Supplier item) { + return (graphics, x, y) -> graphics.renderItem(item.get(), x, y); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/IngredientDropHandler.java b/src/main/java/zone/moddev/mc/lib/gui/client/IngredientDropHandler.java new file mode 100644 index 00000000..36b29067 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/IngredientDropHandler.java @@ -0,0 +1,23 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +import zone.moddev.mc.lib.gui.layout.Rect; + +/** Optional capability supplied by registered element behaviors or capability adapters. */ +public interface IngredientDropHandler { + /** Pure discovery: no edits or drag-start side effects. Bounds use screen coordinates. */ + List> targets(IngredientType type, T ingredient); + + /** Stable per-element ID, drop region, and acceptance through the element's ordinary action. */ + record Target(String id, Rect bounds, Predicate accept) { + public Target { + Objects.requireNonNull(id); + Objects.requireNonNull(bounds); + Objects.requireNonNull(accept); + if (id.isBlank()) throw new IllegalArgumentException("Empty ingredient drop target ID"); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/IngredientDrops.java b/src/main/java/zone/moddev/mc/lib/gui/client/IngredientDrops.java new file mode 100644 index 00000000..55bb830e --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/IngredientDrops.java @@ -0,0 +1,57 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.Element; + +/** Generic discovery and stale-target checks shared by ingredient-viewer integrations. */ +public final class IngredientDrops { + private IngredientDrops() {} + + public static List> targets( + ElementRenderAccess access, List elements, IngredientType type, T ingredient) { + var result = new ArrayList>(); + for (var element : elements) { + for (var target : current(access, element, type, ingredient)) { + result.add( + new IngredientDropHandler.Target<>( + target.id(), + target.bounds(), + value -> { + for (var next : current(access, element, type, value)) + if (next.id().equals(target.id()) && next.bounds().equals(target.bounds())) + return next.accept().test(type.snapshot(value)); + return false; + })); + } + } + return List.copyOf(result); + } + + private static List> current( + ElementRenderAccess access, Element element, IngredientType type, T ingredient) { + if (!access.visible(element)) return List.of(); + var handler = access.capability(element, IngredientDropHandler.class).orElse(null); + if (handler == null) return List.of(); + var result = new ArrayList>(); + var ids = new HashSet(); + for (var target : handler.targets(type, type.snapshot(ingredient))) { + if (!ids.add(target.id())) + throw new IllegalArgumentException("Duplicate ingredient target: " + target.id()); + var bounds = intersection(target.bounds(), access.viewport()); + if (bounds.width() > 0 && bounds.height() > 0) + result.add(new IngredientDropHandler.Target<>(target.id(), bounds, target.accept())); + } + return result; + } + + private static Rect intersection(Rect a, Rect b) { + int x = Math.max(a.x(), b.x()), y = Math.max(a.y(), b.y()); + return new Rect( + x, + y, + Math.max(0, Math.min(a.right(), b.right()) - x), + Math.max(0, Math.min(a.bottom(), b.bottom()) - y)); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/IngredientType.java b/src/main/java/zone/moddev/mc/lib/gui/client/IngredientType.java new file mode 100644 index 00000000..5c2772ee --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/IngredientType.java @@ -0,0 +1,20 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.Objects; +import java.util.function.UnaryOperator; + +import net.minecraft.resources.ResourceLocation; + +/** Client ingredient identity and defensive copying, independent of any recipe viewer. */ +public record IngredientType(ResourceLocation id, Class valueClass, UnaryOperator copy) { + public IngredientType { + Objects.requireNonNull(id); + Objects.requireNonNull(valueClass); + Objects.requireNonNull(copy); + } + + /** A detached ingredient; providers must never receive the viewer's mutable original. */ + public T snapshot(T value) { + return valueClass.cast(Objects.requireNonNull(copy.apply(valueClass.cast(value)))); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/IngredientTypes.java b/src/main/java/zone/moddev/mc/lib/gui/client/IngredientTypes.java new file mode 100644 index 00000000..34efd424 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/IngredientTypes.java @@ -0,0 +1,17 @@ +package zone.moddev.mc.lib.gui.client; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; + +import net.neoforged.neoforge.fluids.FluidStack; + +/** Shared ingredient identities; integrations register their viewer-specific mappings. */ +public final class IngredientTypes { + private IngredientTypes() {} + + public static final IngredientType ITEM = + new IngredientType<>(ResourceLocation.parse("mmdlib:item"), ItemStack.class, ItemStack::copy); + public static final IngredientType FLUID = + new IngredientType<>( + ResourceLocation.parse("mmdlib:fluid"), FluidStack.class, FluidStack::copy); +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java b/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java index ae95b5b4..c126597c 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java @@ -15,26 +15,85 @@ import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent; import net.neoforged.neoforge.network.PacketDistributor; -import zone.moddev.mc.lib.api.automation.AutomationInventory; +import zone.moddev.mc.lib.gui.ElementAction; import zone.moddev.mc.lib.gui.layout.*; import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.elements.SlotElement; import zone.moddev.mc.lib.gui.runtime.*; +import zone.moddev.mc.lib.gui.sync.ElementStateView; /** Client presentation of a data-defined machine; getters are never invoked here. */ public class MachineScreen extends AbstractContainerScreen { + private long animationStart = -1; + private double elapsedTicks; public ScreenLayout layout; - private final AutomationDrawer automation; - private final RedstoneDrawer redstone; - final StatsDrawer stats = new StatsDrawer(this); - private final ReservationOverlay reservations = new ReservationOverlay(); - private final ContainerDrawer containerDrawer; - private final ContainerDrawer curiosDrawer; - private WidgetInput widgetInput; - private boolean widgetsRemoved; - private final Set open = Collections.newSetFromMap(new IdentityHashMap<>()); - private final Map scroll = new IdentityHashMap<>(); - private final Map groups = new IdentityHashMap<>(); - private static final Map> REMEMBERED = new HashMap<>(); + private final Drawers drawers; + private final NativeSlotRenderer nativeSlots = new NativeSlotRenderer(); + private ElementInput elementInput; + private boolean behaviorsRemoved; + private int tooltipMouseX, tooltipMouseY; + private final Map renderParents = new IdentityHashMap<>(); + private final ElementRenderAccess renderAccess = + new ElementRenderAccess() { + public net.minecraft.client.gui.Font font() { + return MachineScreen.this.font; + } + + public Rect viewport() { + return new Rect(0, 0, width, height); + } + + public Optional find(String id) { + return menu.bound.screen().find(id); + } + + public Optional parent(Element element) { + return Optional.ofNullable(renderParents.get(element)); + } + + public Object value(Element element, boolean maximum) { + return menu.value(element, maximum); + } + + public Component text(Text text) { + return MachineScreen.this.text(text); + } + + public List serverTooltip(Element element) { + return menu.components() + .requireCapability(zone.moddev.mc.lib.gui.runtime.component.ServerTooltips.class) + .lines(element); + } + + public Rect bounds(Element element) { + return MachineScreen.this.bounds(element); + } + + public boolean visible(Element element) { + return MachineScreen.this.visible(element); + } + + public ElementStateView state() { + return menu.elementStates(); + } + + public Optional capability(Element element, Class type) { + return BehaviorCapabilities.find(elementInput.instance(element), type); + } + + public ItemStack contents(SlotElement element) { + return menu.slot(element).getItem().copy(); + } + + public ItemStack hint(SlotElement element) { + return menu.slotHint(element).copy(); + } + + public Optional drawerPresentation(Element element, Class type) { + return drawers.presentation(element, type); + } + }; /** Optional integration entry point, installed only by JEI. */ public static java.util.function.BiPredicate recipes = (s, e) -> false; @@ -53,63 +112,91 @@ public int drawerBottom() { public static java.util.function.BiPredicate recipeTooltip = (s, e) -> false; - private Element hovered; - private boolean restored; - private final DrawerMemory drawerMemory = new DrawerMemory(); - public MachineScreen(MachineMenu menu, Inventory inventory, Component title) { super(menu, inventory, title); - WidgetRenderers.GLOBAL.validate(menu.bound.screen()); + ElementRenderers.GLOBAL.validate(menu.bound.screen()); + for (var parent : menu.elements) + for (var child : parent.children()) renderParents.put(child, parent); layout = menu.layout(); imageWidth = layout.panel().width(); imageHeight = layout.panel().height(); - widgetInput = createWidgets(); - automation = menu.automation == null ? null : new AutomationDrawer(this); - redstone = menu.redstone == null ? null : new RedstoneDrawer(this); - containerDrawer = new ContainerDrawer(this); - curiosDrawer = new ContainerDrawer(this, true); + elementInput = createBehaviors(); + drawers = Drawers.create(this); } - private WidgetInput createWidgets() { - return new WidgetInput( - menu.elements.stream() - .filter(Element.Custom.class::isInstance) - .map(Element.Custom.class::cast) - .toList(), - widget -> - WidgetRenderers.GLOBAL - .require(widget.type()) - .create( - new WidgetAccess() { - public Element.Custom element() { - return widget; - } - - public Rect bounds() { - return MachineScreen.this.bounds(widget); - } - - public Object value() { - return menu.value(widget, false); - } - - public Object maximum() { - return menu.value(widget, true); - } - - public boolean pending() { - return menu.widgetPending(widget); - } - - public boolean submit( - zone.moddev.mc.lib.gui.WidgetAction action, T value) { - var packet = menu.prepareWidgetAction(widget, action, value); - packet.ifPresent(PacketDistributor::sendToServer); - return packet.isPresent(); - } - }), + /** Client drawer extension point; drawers own their rendering and interactions. */ + public Drawers drawers() { + return drawers; + } + + /** Read-only presentation services shared by renderers, drawers and optional integrations. */ + public ElementRenderAccess presentation() { + return renderAccess; + } + + private ElementInput createBehaviors() { + return new ElementInput<>( + menu.elements, + element -> + ElementBehaviors.GLOBAL.create( + new ElementAccess() { + public Element element() { + return element; + } + + public Rect bounds() { + return MachineScreen.this.bounds(element); + } + + public Object value() { + return menu.value(element, false); + } + + public Object maximum() { + return menu.value(element, true); + } + + public ElementRenderAccess presentation() { + return renderAccess; + } + + public List elements() { + return List.copyOf(menu.elements); + } + + public ResourceLocation screenId() { + return menu.bound.screen().id(); + } + + public boolean pending(Element target) { + return menu.elementPending(target); + } + + public boolean submit(Element target, ElementAction action, T value) { + return submitElementAction(target, action, value); + } + + public void navigate(String context, String view) { + menu.navigate(context, view); + } + + public ItemStack carried() { + return menu.getCarried().copy(); + } + + public zone.moddev.mc.lib.machine.inventory.SlotPlan reservation( + SlotElement slot) { + return menu.reservationPlan(menu.slotIndex(slot)); + } + + public boolean showRecipes() { + return recipes.test(MachineScreen.this, element); + } + }), this::visible, - this::bounds); + this::bounds, + (x, y) -> renderAccess.viewport().contains(x, y), + (element, entered) -> ElementHover.dispatch(menu, element, entered)); } @EventBusSubscriber(modid = "mmdlib", bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT) @@ -135,11 +222,7 @@ public static void logout( public static void tick(net.neoforged.neoforge.client.event.ClientTickEvent.Post event) { var player = net.minecraft.client.Minecraft.getInstance().player; if (player != null && player.containerMenu instanceof MachineMenu menu) { - menu.tickWidgetActions(); - menu.containerDrawer.tickClient(); - menu.curiosDrawer.tickClient(); - if (menu.automation != null) menu.automation.tick(); - if (menu.redstone != null) menu.redstone.tick(); + menu.tickClientActions(); } } } @@ -147,42 +230,14 @@ public static void tick(net.neoforged.neoforge.client.event.ClientTickEvent.Post @Override protected void init() { super.init(); - containerDrawer.cancelGesture(); - curiosDrawer.cancelGesture(); - if (automation != null) automation.closeEditorInput(); + drawers.resized(); // Recipe browsers can return to the same screen object after removed() was called. - if (widgetsRemoved) { - widgetInput = createWidgets(); - widgetsRemoved = false; - } - widgetInput.resized(); - if (restored) return; - restored = true; - if (REMEMBERED.containsKey(menu.bound.screen().id())) { - for (var entry : REMEMBERED.get(menu.bound.screen().id()).entrySet()) { - var c = menu.bound.screen().find(entry.getKey()).orElse(null); - if (c instanceof Element.Context context - && !context.defaultView().common().id().orElse("").equals(entry.getValue())) { - var button = - menu.elements.stream() - .filter( - e -> - e instanceof Element.Button b - && b.shows() - .filter( - ref -> - ref.context().equals(entry.getKey()) - && ref.view().equals(entry.getValue())) - .isPresent()) - .findFirst(); - button.ifPresent( - e -> { - send(e, 1, 0, 0); - menu.navigate(entry.getKey(), entry.getValue()); - }); - } - } + if (behaviorsRemoved) { + elementInput = createBehaviors(); + behaviorsRemoved = false; } + elementInput.resized(); + elementInput.initialized(); } public Component text(Text text) { @@ -209,68 +264,8 @@ private void updateLayout() { hoveredSlot = null; quickCraftSlots.clear(); isQuickCrafting = false; - widgetInput.resized(); - containerDrawer.cancelGesture(); - curiosDrawer.cancelGesture(); - if (automation != null) automation.closeEditorInput(); - } - - void rememberContainerIntent( - boolean equipment, ContainerDrawerNetwork.State state, int kind, int argument) { - drawerMemory.containerIntent(equipment, state, kind, argument); - } - - void rememberAutomationIntent(String kind, int group) { - drawerMemory.automationIntent(kind, menu.automation.inventory.group(group).id()); - } - - void rememberRedstoneIntent(int kind, int face) { - drawerMemory.redstoneIntent(menu.redstone.open(), kind, face); - } - - void rememberStatsIntent() { - drawerMemory.statsIntent(stats.open); - } - - void closeRightPopouts() { - open.removeIf(p -> p.edge() == Element.Edge.RIGHT); - } - - void closeLeftDrawers() { - stats.open = false; - open.removeIf(p -> p.edge() == Element.Edge.LEFT); - } - - void openStatsDrawer() { - closeLeftDrawers(); - containerDrawer.close(); - curiosDrawer.close(); - } - - boolean onOtherLeftDrawerTab(double x, double y) { - return containerDrawer.tabContains(x, y) - || curiosDrawer.tabContains(x, y) - || menu.bound.screen().tabs().stream() - .anyMatch( - e -> - e instanceof Element.Popout p - && p.edge() == Element.Edge.LEFT - && bounds(e).contains(x, y)); - } - - boolean onDrawerTab(double x, double y) { - return stats.tabContains(x, y) - || containerDrawer.tabContains(x, y) - || curiosDrawer.tabContains(x, y) - || menu.bound.screen().tabs().stream().anyMatch(e -> bounds(e).contains(x, y)); - } - - private void positionAutomation() { - updateLayout(); - if (automation == null) return; - if (menu.automation.open() && !menu.automation.pending()) - open.removeIf(p -> p.edge() == Element.Edge.RIGHT); - automation.layout(); + elementInput.resized(); + drawers.resized(); } public int guiLeft() { @@ -285,684 +280,121 @@ public boolean visible(Element e) { return menu.visible(e); } - private void blit(GuiGraphics g, ResourceLocation icon, int x, int y, int width, int height) { - com.mojang.blaze3d.systems.RenderSystem.enableBlend(); - com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc(); - g.blitSprite(icon, x, y, width, height); - com.mojang.blaze3d.systems.RenderSystem.disableBlend(); - } - - private void sprite(GuiGraphics g, String id, Rect r) { - blit( - g, - ResourceLocation.parse(id.contains(":") ? id : "mmdlib:" + id), - r.x(), - r.y(), - r.width(), - r.height()); - } - - private void sideTab(GuiGraphics g, Rect r, Element.Edge edge) { - if (edge == Element.Edge.RIGHT) { - sprite(g, "side_tab", r); - return; - } - g.pose().pushPose(); - g.pose().translate(r.right(), r.y(), 0); - g.pose().scale(-1, 1, 1); - sprite(g, "side_tab", new Rect(0, 0, r.width(), r.height())); - g.pose().popPose(); - } - - private void tinted(GuiGraphics g, String sprite, Rect r, int color) { - g.setColor(((color >> 16) & 255) / 255f, ((color >> 8) & 255) / 255f, (color & 255) / 255f, 1); - sprite(g, sprite, r); - g.setColor(1, 1, 1, 1); - } - - private void caption(GuiGraphics g, Component text, int x, int y) { - g.drawString(font, text, x, y, 0xff404040, false); - } - - private void caption(GuiGraphics g, Text text, Rect r) { - caption(g, text(text), r.x(), r.y()); - } - @Override protected void renderLabels(GuiGraphics g, int x, int y) {} @Override protected void renderBg(GuiGraphics g, float partial, int mx, int my) { - sprite(g, "panel", layout.panel().offset(leftPos, topPos)); - caption(g, text(menu.bound.screen().title()), leftPos + 7, topPos + 6); - for (var e : menu.elements) if (visible(e)) draw(g, e, bounds(e), mx, my, partial); - layout - .inventory() - .ifPresent( - r -> { - for (int index = 0; index <= 40; index++) - sprite(g, "slot", ScreenLayout.playerSlot(r, index).offset(leftPos, topPos)); - }); - if (automation != null) automation.drawBackground(g, mx, my); - if (redstone != null) redstone.render(g, mx, my); - containerDrawer.render(g, mx, my); - curiosDrawer.render(g, mx, my); - stats.render(g, mx, my); - containerDrawer.renderTab(g); - curiosDrawer.renderTab(g); - if (automation != null) automation.renderTab(g); - if (redstone != null) redstone.renderTab(g); - } - - private void draw(GuiGraphics g, Element e, Rect r, int mx, int my, float partial) { - switch (e) { - case Element.Custom custom -> { - g.pose().pushPose(); - g.enableScissor(r.x(), r.y(), r.right(), r.bottom()); - try { - widgetInput - .instance(custom) - .render( - new WidgetRenderer.Context( - g, custom, r, menu.value(e, false), menu.value(e, true), mx, my, partial)); - } finally { - g.setColor(1, 1, 1, 1); - g.disableScissor(); - g.pose().popPose(); - } - } - case Element.Group group -> { - tinted(g, "group", r, group.tint().orElse(0xffffff)); - group - .label() - .ifPresent( - t -> - caption( - g, t, new Rect(r.x() + group.padding(), r.y() + group.padding(), 0, 0))); - } - case Element.Slot slot -> { - tinted( - g, - "slot", - r, - slot.tint().orElse(slot.kind() == Element.SlotKind.UPGRADE ? 0xf0cd82 : 0xffffff)); - if (menu.slots.get(slot.index()).getItem().isEmpty()) - slot.icon() - .ifPresent( - icon -> - blit( - g, - icon, - r.x() + (r.width() - 16) / 2, - r.y() + (r.height() - 16) / 2, - 16, - 16)); - } - case Element.Label label -> caption(g, label.text(), r); - case Element.Button b -> { - if (b.flat()) { - sprite( - g, r.contains(mx, my) ? "mmdlib:button_flat_highlighted" : "mmdlib:button_flat", r); - } else - sprite( - g, - r.contains(mx, my) - ? "minecraft:widget/button_highlighted" - : "minecraft:widget/button", - r); - b.text() - .ifPresent( - t -> - g.drawCenteredString( - font, text(t), r.x() + r.width() / 2, r.y() + 6, 0xffffff)); - int iconSize = b.flat() ? 12 : 16; - b.iconFor(menu.value(e, false)) - .ifPresent( - icon -> - blit( - g, - icon, - r.x() + (r.width() - iconSize) / 2, - r.y() + (r.height() - iconSize) / 2, - iconSize, - iconSize)); - if (menu.processorConflict(b)) - g.blitSprite( - ResourceLocation.parse("mmdlib:icon_processor_conflict"), - r.right() - 6, - r.bottom() - 6, - 5, - 5); - } - case Element.Checkbox c -> { - sprite( - g, - Boolean.TRUE.equals(menu.value(e, false)) ? "checkbox_selected" : "checkbox", - new Rect(r.x(), r.y(), 10, 10)); - caption(g, c.text(), new Rect(r.x() + 14, r.y() + 1, 0, 0)); - } - case Element.Radio radio -> { - var parent = - menu.elements.stream() - .filter( - p -> - p instanceof Element.RadioGroup group - && group.options().stream().anyMatch(option -> option == radio)) - .findFirst() - .orElse(null); - boolean selected = - parent instanceof Element.RadioGroup group - && java.math.BigInteger.valueOf(radioIndex(group, radio)) - .equals(menu.value(parent, false)); - sprite(g, selected ? "radio_selected" : "radio", new Rect(r.x(), r.y(), 10, 10)); - caption(g, radio.text(), new Rect(r.x() + 14, r.y() + 1, 0, 0)); - } - case Element.RadioGroup group -> group.label().ifPresent(t -> caption(g, t, r)); - case Element.View view -> - view.title().ifPresent(t -> caption(g, t, new Rect(r.x() + 12, r.y(), 0, 0))); - case Element.Context c -> { - if (!menu.views() - .get(c.common().id().orElseThrow()) - .equals(c.defaultView().common().id().orElseThrow())) - caption(g, Component.literal("‹"), r.x(), r.y()); - } - case Element.SideTab tab -> sideTab(g, r, tab.edge()); - case Element.Popout pop -> { - SideDrawerRenderer.tab(g, r, pop.kind()); - blit(g, pop.iconOrDefault(), r.x() + 3, r.y() + 3, 16, 16); - } - case Element.SideConfig config -> drawSides(g, config, r); - case Element.Gauge gauge -> - GaugeRenderer.draw(g, r, gauge, menu.value(e, false), menu.value(e, true)); - case Element.EnergyGauge gauge -> - EnergyGaugeRenderer.draw( - g, r, gauge, ScreenText.ratio(menu.value(e, false), menu.value(e, true))); - case Element.Meter meter -> - meter(g, r, meter, ScreenText.ratio(menu.value(e, false), menu.value(e, true))); - case Element.Progress progress -> { - var icon = progress.icon().orElse(ResourceLocation.parse("mmdlib:progress_arrow")); - if (progress.icon().isPresent()) g.setColor(.25f, .25f, .25f, 1); - progressSprite(g, icon, r, progress); - g.setColor(1, 1, 1, 1); - double ratio = ScreenText.ratio(menu.value(e, false), menu.value(e, true)); - int w = (int) Math.round(r.width() * ratio), h = (int) Math.round(r.height() * ratio); - Rect fill = - switch (progress.direction()) { - case RIGHT -> new Rect(r.x(), r.y(), w, r.height()); - case LEFT -> new Rect(r.right() - w, r.y(), w, r.height()); - case UP -> new Rect(r.x(), r.bottom() - h, r.width(), h); - case DOWN -> new Rect(r.x(), r.y(), r.width(), h); - }; - if (fill.width() > 0 && fill.height() > 0) { - g.enableScissor(fill.x(), fill.y(), fill.right(), fill.bottom()); - progressSprite( - g, - progress.icon().orElse(ResourceLocation.parse("mmdlib:progress_arrow_filled")), - r, - progress); - g.disableScissor(); - } - } - default -> {} - } - } - - private static int radioIndex(Element.RadioGroup group, Element.Radio radio) { - for (int i = 0; i < group.options().size(); i++) if (group.options().get(i) == radio) return i; - return -1; - } - - private void progressSprite( - GuiGraphics g, ResourceLocation icon, Rect r, Element.Progress progress) { - if (progress.icon().isPresent()) { - blit(g, icon, r.x(), r.y(), r.width(), r.height()); - return; - } - float angle = - switch (progress.direction()) { - case RIGHT -> 0; - case LEFT -> (float) Math.PI; - case UP -> (float) -Math.PI / 2; - case DOWN -> (float) Math.PI / 2; - }; - g.pose().pushPose(); - g.pose().translate(r.x() + r.width() / 2f, r.y() + r.height() / 2f, 0); - g.pose().mulPose(new org.joml.Quaternionf().rotationZ(angle)); - blit(g, icon, -12, -8, 24, 16); - g.pose().popPose(); - } - - private void meter(GuiGraphics g, Rect r, Element.Meter m, double ratio) { - g.fill(r.x(), r.y(), r.right(), r.bottom(), m.gapColor().orElse(0xff000000)); - boolean vertical = m.orientation() == Element.Orientation.VERTICAL; - int length = (vertical ? r.height() : r.width()) - 2; - int count = Math.max(1, Math.round((float) (length - m.gap()) / (m.segment() + m.gap()))); - int space = length - m.gap() - count * m.gap(), pos = 1 + m.gap(); - for (int i = 0; i < count; i++) { - int size = space / count + (i < space % count ? 1 : 0); - int color = i < Math.round(count * ratio) ? m.color() : dark(m.color()); - if (vertical) - g.fill(r.x() + 1, r.bottom() - pos - size, r.right() - 1, r.bottom() - pos, color); - else g.fill(r.x() + pos, r.y() + 1, r.x() + pos + size, r.bottom() - 1, color); - pos += size + m.gap(); - } - sprite(g, "compact_frame", r); + long tick = minecraft.level == null ? 0 : minecraft.level.getGameTime(); + if (animationStart < 0) animationStart = tick; + elapsedTicks = Math.max(0, tick - animationStart + partial); + ElementDrawing.sprite(g, "panel", layout.panel().offset(leftPos, topPos)); + g.drawString( + font, text(menu.bound.screen().title()), leftPos + 7, topPos + 6, 0xff404040, false); + renderElements(ElementRenderers.Pass.BACKGROUND, g, partial, mx, my); + renderElements(ElementRenderers.Pass.OVERLAY, g, partial, mx, my); + drawers.render(g, mx, my); + } + + private ElementRenderers.Context renderContext( + GuiGraphics g, Element e, Rect bounds, int mx, int my, float partial) { + return new ElementRenderers.Context( + g, + e, + bounds, + renderAccess.value(e, false), + renderAccess.value(e, true), + mx, + my, + partial, + elapsedTicks, + renderAccess); } - private static int dark(int color) { - return 0xff000000 | ((color & 0xfefefe) >> 1 & 0x7f7f7f); + private void draw(GuiGraphics g, Element e, Rect bounds, int mx, int my, float partial) { + ElementRenderers.GLOBAL.render(renderContext(g, e, bounds, mx, my, partial)); } - private static final int[][] FACES = {{1, 1}, {2, 2}, {0, 1}, {2, 1}, {1, 0}, {1, 2}}; - - private Rect face(Rect r, int face) { - return new Rect(r.x() + FACES[face][0] * 22, r.y() + 20 + FACES[face][1] * 22, 20, 20); - } - - private void drawSides(GuiGraphics g, Element.SideConfig config, Rect r) { - int group = groups.getOrDefault(config, 0), index = menu.index(config); - sprite(g, "minecraft:widget/button", new Rect(r.x(), r.y(), r.width(), 20)); - var target = menu.bound.screen().find(config.groups().get(group)).orElseThrow(); - Component label = - target instanceof Element.Group g1 - ? g1.label() - .map(this::text) - .orElseGet(() -> Component.translatable("screen.mmdlib.side_config")) - : target - .common() - .tooltip() - .map(this::text) - .orElseGet(() -> Component.translatable("screen.mmdlib.side_config")); - g.drawCenteredString( - font, - font.plainSubstrByWidth(label.getString(), r.width() - 6), - r.x() + r.width() / 2, - r.y() + 6, - 0xffffff); - int[] colors = {0x777777, 0x679fea, 0xef994e, 0xad6fea}; - for (int i = 0; i < 6; i++) { - Object value = menu.wireValue(-1 - index * 65536 - group * 6 - i); - int mode = value instanceof java.math.BigInteger n ? n.intValue() : 0; - Rect f = face(r, i); - tinted(g, "slot", f, colors[Math.clamp(mode, 0, 3)]); - g.drawCenteredString( - font, - Component.translatable( - "screen.mmdlib.face.short." - + zone.moddev.mc.lib.api.capabilities.RelativeFace.values()[i] - .getSerializedName()), - f.x() + 10, - f.y() + 6, - 0xffffff); - } - if (visible(target)) { - Rect highlight = bounds(target); - g.renderOutline( - highlight.x(), highlight.y(), highlight.width(), highlight.height(), 0xfff0cf55); - } + private void renderElements( + ElementRenderers.Pass pass, GuiGraphics g, float partial, int mx, int my) { + for (var e : menu.elements) + if (visible(e)) + ElementRenderers.GLOBAL.render(pass, renderContext(g, e, bounds(e), mx, my, partial)); } public List exclusions() { - var result = new ArrayList(); - for (var e : menu.bound.screen().tabs()) { - if (e instanceof Element.Popout p && open.contains(p)) { - result.add(drawerRail(p)); - result.add(popBounds(p)); - } else result.add(bounds(e)); - } - if (automation != null) result.addAll(automation.exclusions()); - if (redstone != null) result.addAll(redstone.exclusions()); - result.addAll(containerDrawer.exclusions()); - result.addAll(curiosDrawer.exclusions()); - result.addAll(stats.exclusions()); - return result; - } - - private int popWidth(Element.Popout p) { - Rect tab = bounds(p); - int room = p.edge() == Element.Edge.LEFT ? tab.x() - 4 : width - tab.right() - 4; - return Math.max(0, Math.min(150, room)); - } - - private Rect popBounds(Element.Popout p) { - Rect tab = bounds(p); - int w = popWidth(p); - int top = SideDrawerLayout.top(panelBounds()); - int h = Math.max(tab.bottom() - top, popLines(p).size() * 10 + 12); - return SideDrawerLayout.body( - panelBounds(), - drawerBottom(), - p.edge() == Element.Edge.LEFT ? tab.x() - w : tab.right(), - top, - w, - h); - } - - private Rect drawerRail(Element.Popout p) { - Rect tab = bounds(p); - return SideDrawerLayout.rail(tab, popBounds(p)); - } - - private List popLines(Element.Popout p) { - Object value = menu.value(p, false); - var result = new ArrayList(); - int textWidth = Math.max(1, popWidth(p) - 12); - if (p.text().isPresent()) - result.addAll(font.split(ScreenText.markup(text(p.text().get()).getString()), textWidth)); - if (!result.isEmpty() - && (value instanceof Component || value instanceof List list && !list.isEmpty())) - result.add(net.minecraft.util.FormattedCharSequence.EMPTY); - if (value instanceof Component c) result.addAll(font.split(c, textWidth)); - else if (value instanceof List list) - for (var c : list) - if (c instanceof Component text) { - result.addAll(font.split(text, textWidth)); - result.add(net.minecraft.util.FormattedCharSequence.EMPTY); - } - return result; + return drawers.exclusions(); } @Override public void render(GuiGraphics g, int mx, int my, float partial) { - positionAutomation(); - updateWidgets(); + tooltipMouseX = mx; + tooltipMouseY = my; + updateLayout(); + updateBehaviors(); + elementInput.hover(mx, my); super.render(g, mx, my, partial); - Element next = null; - for (var e : menu.elements) if (visible(e) && bounds(e).contains(mx, my)) next = e; - if (next != hovered) { - hover(hovered, false); - hovered = next; - hover(hovered, true); - } - if (hovered != null) - hovered - .common() - .hoverShows() - .flatMap(id -> menu.bound.screen().find(id)) - .ifPresent(e -> draw(g, e, bounds(e), mx, my, partial)); - for (var p : open) { - Rect r = popBounds(p); - Rect tab = bounds(p); - SideDrawerRenderer.open(g, tab, r, p.edge(), p.kind()); - blit(g, p.iconOrDefault(), tab.x() + 3, tab.y() + 3, 16, 16); - var lines = popLines(p); - int max = Math.max(0, lines.size() * 10 - (r.height() - 12)); - int offset = Math.clamp(scroll.getOrDefault(p, 0), 0, max); - scroll.put(p, offset); - g.enableScissor(r.x() + 5, r.y() + 5, r.right() - 5, r.bottom() - 5); - for (int i = 0; i < lines.size(); i++) - g.drawString(font, lines.get(i), r.x() + 6, r.y() + 6 + i * 10 - offset, 0xff303030, false); - g.disableScissor(); - } - // Keep every drawer tab clickable and visible on the expanded rail. - for (var e : menu.bound.screen().tabs()) - if (e instanceof Element.Popout p) { - Rect tab = bounds(p); - SideDrawerRenderer.tab(g, tab, p.kind()); - blit(g, p.iconOrDefault(), tab.x() + 3, tab.y() + 3, 16, 16); - } + ElementHover.preview( + elementInput.hovered(), renderAccess, e -> draw(g, e, bounds(e), mx, my, partial)); + renderElements(ElementRenderers.Pass.FOREGROUND, g, partial, mx, my); + drawers.renderTabs(g); + renderElements(ElementRenderers.Pass.TOP, g, partial, mx, my); renderTooltip(g, mx, my); - if (hovered != null) tooltip(g, hovered, mx, my); - if (automation != null) automation.tooltip(g, mx, my); - if (redstone != null) redstone.tooltip(g, mx, my); - containerDrawer.tooltip(g, mx, my); - curiosDrawer.tooltip(g, mx, my); - stats.tooltip(g, mx, my); - } - - private void hover(Element e, boolean enter) { - if (e != null) - e.common() - .onHover() - .ifPresent( - handler -> - zone.moddev.mc.lib.api.MaterialExtensions.GLOBAL - .events() - .dispatch( - handler, - zone.moddev.mc.lib.api.MaterialEventTypes.GUI_HOVER, - new ScreenHover(menu, e, enter))); + if (elementInput.hovered() != null) tooltip(g, elementInput.hovered(), mx, my); + drawers.tooltip(g, mx, my); } private void tooltip(GuiGraphics g, Element e, int mx, int my) { if (!e.common().jeiRecipes().isEmpty() && recipeTooltip.test(this, e)) return; - // A filled slot's lines ride along in the item's own tooltip (getTooltipFromContainerItem). - if (e instanceof Element.Slot && hoveredSlot != null && hoveredSlot.hasItem()) return; + if (nativeSlots.usesItemTooltip(menu.nativeSlots(), hoveredSlot, e)) return; var lines = tooltipLines(e, mx, my); if (!lines.isEmpty()) g.renderComponentTooltip(font, lines, mx, my); } @Override protected List getTooltipFromContainerItem(ItemStack stack) { - var lines = super.getTooltipFromContainerItem(stack); - if (hovered instanceof Element.Slot slot && menu.slots.get(slot.index()) == hoveredSlot) { - lines = new ArrayList<>(lines); - lines.addAll(tooltipLines(slot, 0, 0)); - } - return lines; + var vanilla = super.getTooltipFromContainerItem(stack); + return drawers + .nativeTooltip(hoveredSlot, vanilla, tooltipMouseX, tooltipMouseY) + .orElseGet( + () -> + nativeSlots.itemTooltip( + menu.nativeSlots(), + hoveredSlot, + elementInput.hovered(), + vanilla, + element -> tooltipLines(element, tooltipMouseX, tooltipMouseY))); } /** The tooltip lines of {@code e} with the mouse at {@code mx, my}; empty for none. */ public List tooltipLines(Element e, int mx, int my) { - var c = e.common(); - boolean shift = hasShiftDown(); - var text = shift ? c.tooltipShift().or(c::tooltip) : c.tooltip(); - var lines = new ArrayList(); - lines.addAll(menu.tooltip(e)); - text.ifPresent( - t -> { - String translated = - this.text(t) - .getString() - .replace( - "{value}", - e instanceof Element.Button button - ? button - .labelFor(menu.value(e, false)) - .map(label -> this.text(label).getString()) - .orElseGet(() -> ScreenText.number(menu.value(e, false), shift)) - : ScreenText.number(menu.value(e, false), shift)) - .replace("{max}", ScreenText.number(menu.value(e, true), shift)); - if (e instanceof Element.Button button && button.processorControl()) { - var action = - ProcessorButtonState.fromWire(menu.value(button, false)) - .map(ProcessorButtonState::action) - .map(key -> Component.translatable(key).getString()) - .orElse(""); - translated = translated.replace("{action}", action); - } - translated.lines().map(Component::literal).forEach(lines::add); - }); - if (c.tooltipShift().isPresent() && !shift) - lines.add(Component.translatable("screen.mmdlib.shift_for_more")); - if (e instanceof Element.Button button && menu.processorConflict(button)) - Component.translatable( - menu.redstone.paused() - ? "screen.mmdlib.processor.conflict.redstone" - : "screen.mmdlib.processor.conflict.manual") - .getString() - .lines() - .map(Component::literal) - .forEach(lines::add); - if (e instanceof Element.Slot slot) { - var plan = menu.reservationPlan(slot.index()); - if (plan.take() > 0) - lines.add(Component.translatable("screen.mmdlib.reservation.input", plan.take())); - var output = plan.add(); - if (!output.isEmpty()) - lines.add( - Component.translatable( - "screen.mmdlib.reservation.output", output.getCount(), output.getHoverName())); - } - if (e instanceof Element.SideConfig config) { - int group = groups.getOrDefault(config, 0); - for (int i = 0; i < 6; i++) - if (face(bounds(e), i).contains(mx, my)) { - Object mode = menu.wireValue(-1 - menu.index(e) * 65536 - group * 6 - i); - int n = mode instanceof java.math.BigInteger b ? Math.clamp(b.intValue(), 0, 3) : 0; - lines.add( - Component.translatable( - "screen.mmdlib.face." - + zone.moddev.mc.lib.api.capabilities.RelativeFace.values()[i] - .getSerializedName())); - lines.add( - Component.translatable( - "screen.mmdlib.mode." - + zone.moddev.mc.lib.api.capabilities.IoMode.values()[n] - .getSerializedName())); - } - } - return lines; - } - - private void send(Element e, int kind, int button, int option) { - int modifiers = (hasShiftDown() ? 1 : 0) | (hasControlDown() ? 2 : 0) | (hasAltDown() ? 4 : 0); - minecraft.gameMode.handleInventoryButtonClick( - menu.containerId, - new ScreenNetwork.Action(menu.containerId, menu.index(e), kind, button, modifiers, option) - .pack()); + return ElementTooltips.collect( + e, + renderAccess, + elementInput::instance, + mx, + my, + hasShiftDown(), + hasControlDown(), + hasAltDown()); } @Override public boolean mouseClicked(double mx, double my, int button) { - drawerMemory.userInput(); - if (stats.click(mx, my, button)) { - widgetInput.clearFocus(); - return true; - } - if (containerDrawer.click(mx, my, button) || curiosDrawer.click(mx, my, button)) { - widgetInput.clearFocus(); - return true; - } - positionAutomation(); - if (redstone != null && redstone.click(mx, my, button)) { - widgetInput.clearFocus(); - return true; - } - if (automation != null && automation.click(mx, my, button)) { - widgetInput.clearFocus(); + updateLayout(); + if (drawers.click(mx, my, button)) { + elementInput.clearFocus(); return true; } - updateWidgets(); - if (widgetInput.capturing()) return true; - widgetInput.beginClick(); - boolean onDrawerTab = - menu.bound.screen().tabs().stream() - .anyMatch(e -> e instanceof Element.Popout && bounds(e).contains(mx, my)); - for (var p : open) - if (button >= 0 - && button <= 1 - && (popBounds(p).contains(mx, my) || drawerRail(p).contains(mx, my) && !onDrawerTab)) { - widgetInput.clearFocus(); - return true; - } - for (int i = menu.elements.size() - 1; i >= 0; i--) { - var e = menu.elements.get(i); - Rect r = bounds(e); - if (!visible(e) || !r.contains(mx, my)) continue; - widgetInput.blurUnless(e); - if (e instanceof Element.Custom custom && widgetInput.click(custom, mx, my, button)) - return true; - if (button > 1) continue; - if (e instanceof Element.Popout p) { - if (p.edge() == Element.Edge.LEFT) { - stats.open = false; - containerDrawer.close(); - curiosDrawer.close(); - } - if (p.edge() == Element.Edge.RIGHT && menu.redstone != null && menu.redstone.open()) - menu.redstone.send(RedstoneMenu.TOGGLE, menu.redstone.selected(), 0, 0); - if (automation != null && p.edge() == Element.Edge.RIGHT && menu.automation.open()) - menu.automation.send("close", menu.automation.selected(), -1, "", 0, ""); - if (!open.remove(p)) { - open.removeIf(other -> other.edge() == p.edge()); - open.add(p); - } - drawerMemory.popoutIntent(p, open.contains(p)); - return true; - } - if (e instanceof Element.Slot s && s.kind() == Element.SlotKind.GHOST) { - PacketDistributor.sendToServer( - new ScreenNetwork.Ghost( - menu.containerId, - i, - button == 1 ? ItemStack.EMPTY : menu.getCarried().copyWithCount(1))); - return true; - } - if (e instanceof Element.Button b && b.processorControl()) { - if (button == 0) send(e, 0, button, 0); - return true; - } - if (e instanceof Element.Button b && b.shows().isPresent()) { - send(e, 1, button, 0); - var ref = b.shows().get(); - var c = (Element.Context) menu.bound.screen().find(ref.context()).orElseThrow(); - menu.navigate( - ref.context(), - menu.views().get(ref.context()).equals(ref.view()) - ? c.defaultView().common().id().orElseThrow() - : ref.view()); - remember(); - return true; - } - if (e instanceof Element.Context c - && mx < r.x() + 12 - && my < r.y() + 12 - && !menu.views() - .get(c.common().id().orElseThrow()) - .equals(c.defaultView().common().id().orElseThrow())) { - back(c); - return true; - } - if (e instanceof Element.SideConfig config) { - int group = groups.getOrDefault(config, 0); - if (my < r.y() + 20) { - groups.put(config, (group + 1) % config.groups().size()); - return true; - } - for (int f = 0; f < 6; f++) - if (face(r, f).contains(mx, my)) { - send(e, 3, button, group * 6 + f); - return true; - } - } - if (!e.common().jeiRecipes().isEmpty() && recipes.test(this, e)) return true; - if (e instanceof Element.Radio radio && !e.common().clicks().any()) { - for (var parent : menu.elements) - if (parent instanceof Element.RadioGroup group - && group.options().stream().anyMatch(option -> option == radio)) { - send(parent, 0, button, radioIndex(group, radio)); - return true; - } - } - if (e.common().clicks().any()) { - send(e, 0, button, 0); - return true; - } - } - widgetInput.clearFocus(); - return super.mouseClicked(mx, my, button); - } - - private void remember() { - REMEMBERED.put(menu.bound.screen().id(), menu.views()); - } - - private void back(Element.Context c) { - send(c, 2, 0, 0); - menu.navigate(c.common().id().orElseThrow(), c.defaultView().common().id().orElseThrow()); - remember(); + updateBehaviors(); + return elementInput.click(mx, my, button) || super.mouseClicked(mx, my, button); } @Override public boolean keyPressed(int key, int scan, int modifiers) { - drawerMemory.userInput(); - if (stats.key(key)) return true; + drawers.userInput(); // A lost drawer acknowledgment must never trap the player in the screen. - if ((menu.containerDrawer.pending() - || menu.curiosDrawer.pending() - || menu.redstone != null && menu.redstone.pending() - || menu.automation != null && menu.automation.pending()) + if (drawers.pending() && (key == 256 || net.minecraft.client.Minecraft.getInstance() .options @@ -971,126 +403,91 @@ public boolean keyPressed(int key, int scan, int modifiers) { onClose(); return true; } - if (containerDrawer.key(key, scan) || curiosDrawer.key(key, scan)) return true; - if (redstone != null && redstone.key(key)) return true; - if (automation != null && automation.key(key, scan, modifiers)) return true; - updateWidgets(); - if (widgetInput.keyPressed(key, scan, modifiers)) return true; - if (key == 256) - for (var e : menu.elements) - if (e instanceof Element.Context c - && !menu.views() - .get(c.common().id().orElseThrow()) - .equals(c.defaultView().common().id().orElseThrow())) { - back(c); - return true; - } + if (drawers.key(key, scan, modifiers)) return true; + updateBehaviors(); + if (elementInput.keyPressed(key, scan, modifiers)) return true; return super.keyPressed(key, scan, modifiers); } @Override public void removed() { - drawerMemory.capture(this, open); - containerDrawer.cancelGesture(); - curiosDrawer.cancelGesture(); - if (automation != null) automation.removed(); - widgetInput.removed(); - widgetsRemoved = true; - hover(hovered, false); - hovered = null; + drawers.removed(); + elementInput.removed(); + behaviorsRemoved = true; super.removed(); } @Override public boolean mouseScrolled(double mx, double my, double dx, double dy) { - if (stats.scroll(mx, my, dy)) return true; - if (curiosDrawer.scroll(mx, my, dy) || containerDrawer.scroll(mx, my, dy)) return true; - if (redstone != null && redstone.scroll(mx, my, dy)) return true; - if (automation != null && automation.scroll(mx, my, dy)) return true; - updateWidgets(); - for (var p : open) - if (popBounds(p).contains(mx, my)) { - scroll.merge(p, (int) (-dy * 10), Integer::sum); - return true; - } - return widgetInput.scroll(mx, my, dx, dy) || super.mouseScrolled(mx, my, dx, dy); + if (drawers.scroll(mx, my, dy)) return true; + updateBehaviors(); + return elementInput.scroll(mx, my, dx, dy) || super.mouseScrolled(mx, my, dx, dy); + } + + /** Shared typed submission for element behaviors and external UI integrations such as JEI. */ + public boolean submitElementAction(Element target, ElementAction action, T value) { + var packet = menu.prepareElementAction(target, action, value); + packet.ifPresent(PacketDistributor::sendToServer); + return packet.isPresent(); } - private void updateWidgets() { + private void updateBehaviors() { updateLayout(); - widgetInput.update(); - for (var e : menu.elements) - if (e instanceof Element.Custom custom) { - var result = menu.takeWidgetResult(custom); - if (result != null) widgetInput.instance(custom).actionResult(result.accepted()); - } + elementInput.update(); + for (var element : menu.elements) { + var result = menu.takeElementResult(element); + if (result != null) elementInput.instance(element).actionResult(result.accepted()); + } } @Override protected void containerTick() { super.containerTick(); - drawerMemory.restore(this, open); - updateWidgets(); - widgetInput.tick(); + drawers.tick(); + updateBehaviors(); + elementInput.tick(); } @Override public void mouseMoved(double x, double y) { - containerDrawer.mouseMoved(x, y); - curiosDrawer.mouseMoved(x, y); + drawers.mouseMoved(x, y); + updateBehaviors(); + elementInput.mouseMoved(x, y); super.mouseMoved(x, y); } @Override public boolean mouseDragged(double x, double y, int button, double dx, double dy) { - if (stats.drag()) return true; - if (containerDrawer.drag(x, y, button) || curiosDrawer.drag(x, y, button)) return true; - if (redstone != null && redstone.drag(x, y, button)) return true; - if (automation != null && automation.drag(x, y, button, dx, dy)) return true; - updateWidgets(); - return widgetInput.drag(x, y, button, dx, dy) || super.mouseDragged(x, y, button, dx, dy); + if (drawers.drag(x, y, button, dx, dy)) return true; + updateBehaviors(); + return elementInput.drag(x, y, button, dx, dy) || super.mouseDragged(x, y, button, dx, dy); } @Override public boolean mouseReleased(double x, double y, int button) { - if (stats.release()) return true; - boolean curiosReleased = curiosDrawer.release(x, y, button); - if (containerDrawer.release(x, y, button) || curiosReleased) return true; - if (redstone != null && redstone.release(x, y, button)) return true; - if (automation != null && automation.release(x, y, button)) return true; - updateWidgets(); - return widgetInput.release(x, y, button) || super.mouseReleased(x, y, button); + if (drawers.release(x, y, button)) return true; + updateBehaviors(); + return elementInput.release(x, y, button) || super.mouseReleased(x, y, button); } @Override public boolean charTyped(char character, int modifiers) { - if (automation != null && automation.character(character, modifiers)) return true; - updateWidgets(); - return widgetInput.charTyped(character, modifiers) || super.charTyped(character, modifiers); + if (drawers.character(character, modifiers)) return true; + updateBehaviors(); + return elementInput.charTyped(character, modifiers) || super.charTyped(character, modifiers); } @Override public boolean keyReleased(int key, int scan, int modifiers) { - updateWidgets(); - return widgetInput.keyReleased(key, scan, modifiers) || super.keyReleased(key, scan, modifiers); + if (drawers.keyReleased(key, scan, modifiers)) return true; + updateBehaviors(); + return elementInput.keyReleased(key, scan, modifiers) + || super.keyReleased(key, scan, modifiers); } @Override protected boolean hasClickedOutside(double x, double y, int left, int top, int button) { - return !stats.contains(x, y) - && (redstone == null || !redstone.contains(x, y)) - && (automation == null || !automation.contains(x, y)) - && !containerDrawer.contains(x, y) - && !curiosDrawer.contains(x, y) - && super.hasClickedOutside(x, y, left, top, button); - } - - private boolean isFilterSlot(net.minecraft.world.inventory.Slot slot) { - return menu.automation != null - && slot.index >= menu.automation.slotStart() - && slot.index < menu.automation.slotStart() + menu.automation.slotCount() - && (slot.index - menu.automation.slotStart()) % AutomationInventory.SLOTS_PER_GROUP - >= AutomationInventory.COLUMNS; + return !drawers.contains(x, y) && super.hasClickedOutside(x, y, left, top, button); } @Override @@ -1100,37 +497,21 @@ protected void renderSlotHighlight( int mouseX, int mouseY, float partialTick) { - if (!isFilterSlot(slot)) { - super.renderSlotHighlight(graphics, slot, mouseX, mouseY, partialTick); - return; - } - if (slot.isHighlightable()) { - int colour = getSlotColor(slot.index); - graphics.fillGradient( - net.minecraft.client.renderer.RenderType.guiOverlay(), - slot.x, - slot.y, - slot.x + AutomationLayout.FILTER_ITEM_SIZE, - slot.y + AutomationLayout.FILTER_ITEM_SIZE, - colour, - colour, - 0); - } + nativeSlots.highlight( + graphics, + slot, + menu.nativeSlots().entry(slot.index), + getSlotColor(slot.index), + () -> super.renderSlotHighlight(graphics, slot, mouseX, mouseY, partialTick)); } @Override protected void renderSlot(GuiGraphics graphics, net.minecraft.world.inventory.Slot slot) { - if (!isFilterSlot(slot)) { - super.renderSlot(graphics, slot); - return; - } - graphics.pose().pushPose(); - graphics.pose().translate(slot.x, slot.y, 0); - float scale = AutomationLayout.FILTER_ITEM_SIZE / 16f; - graphics.pose().scale(scale, scale, 1); - graphics.pose().translate(-slot.x, -slot.y, 0); - super.renderSlot(graphics, slot); - graphics.pose().popPose(); + nativeSlots.render( + graphics, + slot, + menu.nativeSlots().entry(slot.index), + () -> super.renderSlot(graphics, slot)); } @Override @@ -1139,28 +520,30 @@ protected void renderSlotContents( ItemStack displayed, net.minecraft.world.inventory.Slot slot, @org.jetbrains.annotations.Nullable String countString) { - if (!menu.isMachineItemSlot(slot.index)) { - super.renderSlotContents(graphics, displayed, slot, countString); - return; - } - // Keep vanilla item/drag rendering and decorations, then draw both quantities at one scale. - super.renderSlotContents(graphics, displayed, slot, ""); - reservations.render( - graphics, font, slot, displayed, menu.reservationPlan(slot.index), countString); + nativeSlots.contents( + graphics, + font, + slot, + menu.nativeSlots().entry(slot.index), + displayed, + countString, + count -> super.renderSlotContents(graphics, displayed, slot, count)); + } + + /** Native slot hit testing for overlay hosts, in screen coordinates. */ + public net.minecraft.world.inventory.Slot nativeSlotAt(double x, double y) { + return nativeSlotAt(x, y, true); + } + + /** Include inactive geometry only when blocking pending inventory gestures. */ + public net.minecraft.world.inventory.Slot nativeSlotAt(double x, double y, boolean activeOnly) { + updateLayout(); + return nativeSlots.hit(menu.nativeSlots(), x - leftPos, y - topPos, activeOnly); } @Override protected boolean isHovering(int x, int y, int w, int h, double mx, double my) { - if (menu.automation != null && menu.automation.open() && w == 16 && h == 16) - for (int c = 0; c < AutomationInventory.COLUMNS; c++) { - int i = - menu.automation.slotStart() - + AutomationInventory.filterSlot(menu.automation.selected(), c); - var slot = menu.slots.get(i); - if (slot.isActive() && slot.x == x && slot.y == y) - return super.isHovering( - x, y, AutomationLayout.FILTER_ITEM_SIZE, AutomationLayout.FILTER_ITEM_SIZE, mx, my); - } - return super.isHovering(x, y, w, h, mx, my); + var bounds = nativeSlots.hitBounds(menu.nativeSlots(), x, y, w, h); + return super.isHovering(bounds.x(), bounds.y(), bounds.width(), bounds.height(), mx, my); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/MeterRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/MeterRenderer.java new file mode 100644 index 00000000..9e80caee --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/MeterRenderer.java @@ -0,0 +1,31 @@ +package zone.moddev.mc.lib.gui.client; + +import zone.moddev.mc.lib.gui.model.Orientation; +import zone.moddev.mc.lib.gui.model.elements.MeterElement; + +/** Segmented meter drawing, independent of the machine providing its numeric values. */ +public final class MeterRenderer { + private MeterRenderer() {} + + public static void draw(MeterElement m, ElementRenderers.Context c) { + var g = c.graphics(); + var r = c.bounds(); + g.fill(r.x(), r.y(), r.right(), r.bottom(), m.gapColor().orElse(0xff000000)); + boolean vertical = m.orientation() == Orientation.VERTICAL; + int length = (vertical ? r.height() : r.width()) - 2; + int count = Math.max(1, Math.round((float) (length - m.gap()) / (m.segment() + m.gap()))); + int space = length - m.gap() - count * m.gap(), pos = 1 + m.gap(); + for (int i = 0; i < count; i++) { + int size = space / count + (i < space % count ? 1 : 0); + int color = + i < Math.round(count * c.ratio()) + ? m.color() + : 0xff000000 | ((m.color() & 0xfefefe) >> 1 & 0x7f7f7f); + if (vertical) + g.fill(r.x() + 1, r.bottom() - pos - size, r.right() - 1, r.bottom() - pos, color); + else g.fill(r.x() + pos, r.y() + 1, r.x() + pos + size, r.bottom() - 1, color); + pos += size + m.gap(); + } + ElementDrawing.sprite(g, "compact_frame", r); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/NativeSlotRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/NativeSlotRenderer.java new file mode 100644 index 00000000..a4b18ff7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/NativeSlotRenderer.java @@ -0,0 +1,151 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.network.chat.Component; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.runtime.slot.MenuSlots; + +/** Adapts native slot drawing and hit bounds to the owning slot element's presentation. */ +public final class NativeSlotRenderer { + private final ReservationOverlay reservations = new ReservationOverlay(); + + private boolean ownsTooltip(MenuSlots slots, Slot hovered, Element element) { + if (hovered == null || element == null) return false; + var entry = slots.entry(hovered.index); + return entry != null && entry.slot() == hovered && entry.element() == element; + } + + /** Filled native slots append element lines to vanilla's item tooltip. */ + public boolean usesItemTooltip(MenuSlots slots, Slot hovered, Element element) { + return ownsTooltip(slots, hovered, element) && hovered.hasItem(); + } + + /** Merge only the element belonging to the hovered native slot, leaving other tooltips alone. */ + public List itemTooltip( + MenuSlots slots, + Slot hovered, + Element element, + List vanilla, + Function> additional) { + if (!ownsTooltip(slots, hovered, element)) return vanilla; + var slotElement = slots.entry(hovered.index).element(); + return itemTooltip(slotElement, hovered.getItem(), vanilla, additional.apply(element)); + } + + /** Shared composition for native adapters and virtual cells; parents survive overrides. */ + public static List itemTooltip( + zone.moddev.mc.lib.gui.model.elements.SlotElement element, + ItemStack stack, + List vanilla, + List additional) { + var input = vanilla.stream().map(Component::copy).map(Component.class::cast).toList(); + var lines = new ArrayList<>(element.nativeTooltip(stack.copy(), input)); + lines.addAll(additional); + return lines.stream().map(Component::copy).map(Component.class::cast).toList(); + } + + private static Rect vanillaBounds(Slot slot) { + return new Rect(slot.x, slot.y, 16, 16); + } + + public void render(GuiGraphics graphics, Slot slot, MenuSlots.Entry entry, Runnable vanilla) { + var element = entry == null ? null : entry.element(); + if (element != null && !element.renderNative()) return; + var original = vanillaBounds(slot); + var bounds = element == null ? original : element.nativeBounds(original); + if (bounds.equals(original)) { + vanilla.run(); + return; + } + graphics.pose().pushPose(); + try { + graphics.pose().translate(bounds.x(), bounds.y(), 0); + graphics.pose().scale(bounds.width() / 16f, bounds.height() / 16f, 1); + graphics.pose().translate(-slot.x, -slot.y, 0); + vanilla.run(); + } finally { + graphics.pose().popPose(); + } + } + + public void highlight( + GuiGraphics graphics, Slot slot, MenuSlots.Entry entry, int colour, Runnable vanilla) { + var original = vanillaBounds(slot); + var bounds = + entry == null || entry.element() == null + ? original + : entry.element().highlightBounds(original); + if (bounds.equals(original)) { + vanilla.run(); + return; + } + if (slot.isHighlightable()) + graphics.fillGradient( + RenderType.guiOverlay(), + bounds.x(), + bounds.y(), + bounds.right(), + bounds.bottom(), + colour, + colour, + 0); + } + + public void contents( + GuiGraphics graphics, + Font font, + Slot slot, + MenuSlots.Entry entry, + ItemStack displayed, + String count, + Consumer vanilla) { + if (entry == null || !entry.overlay() || entry.element() == null) { + vanilla.accept(count); + return; + } + vanilla.accept(""); + reservations.render(graphics, font, slot, entry.element(), displayed, entry.plan(), count); + } + + /** First active native hit, using vanilla's one-pixel tolerance and menu ordering. */ + public Slot hit(MenuSlots slots, double x, double y) { + return hit(slots, x, y, true); + } + + /** Geometry-only hits are for blocking pending gestures, never authorizing interaction. */ + public Slot hit(MenuSlots slots, double x, double y, boolean activeOnly) { + for (var entry : slots.entries()) { + if (activeOnly && !entry.slot().isActive()) continue; + var bounds = hitBounds(entry); + if (x >= bounds.x() - 1 + && x < bounds.right() + 1 + && y >= bounds.y() - 1 + && y < bounds.bottom() + 1) return entry.slot(); + } + return null; + } + + public Rect hitBounds(MenuSlots.Entry entry) { + var bounds = vanillaBounds(entry.slot()); + return entry.element() == null ? bounds : entry.element().hitBounds(bounds); + } + + public Rect hitBounds(MenuSlots slots, int x, int y, int width, int height) { + if (width == 16 && height == 16) + for (var entry : slots.entries()) + if (entry.slot().isActive() && entry.slot().x == x && entry.slot().y == y) + return hitBounds(entry); + return new Rect(x, y, width, height); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/NavigableBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/NavigableBehavior.java new file mode 100644 index 00000000..ac6a09b3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/NavigableBehavior.java @@ -0,0 +1,25 @@ +package zone.moddev.mc.lib.gui.client; + +import zone.moddev.mc.lib.gui.model.containers.NavigableElement; + +/** Remembered selection lifecycle shared by built-in and mod navigation destinations. */ +public class NavigableBehavior extends DefaultElementBehavior { + private final NavigableElement destination; + private boolean initialized; + + public NavigableBehavior(NavigableElement destination, ElementAccess access) { + super(access); + this.destination = destination; + } + + public void initialized() { + if (initialized) return; + initialized = true; + NavigationMemory.restore(access, destination); + } + + public void actionResult(boolean accepted) { + super.actionResult(accepted); + NavigationMemory.remember(access, destination); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/NavigationMemory.java b/src/main/java/zone/moddev/mc/lib/gui/client/NavigationMemory.java new file mode 100644 index 00000000..fe440e4b --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/NavigationMemory.java @@ -0,0 +1,57 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.HashMap; +import java.util.Map; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.ElementActions; +import zone.moddev.mc.lib.gui.model.Navigation; +import zone.moddev.mc.lib.gui.model.containers.NavigableElement; + +/** Shared prediction and per-screen remembered selections, without concrete control types. */ +public final class NavigationMemory { + private static final Map> REMEMBERED = new HashMap<>(); + + private NavigationMemory() {} + + public static void navigate(ElementAccess access, String context, String selection) { + access.navigate(context, selection); + REMEMBERED + .computeIfAbsent(access.screenId(), ignored -> new HashMap<>()) + .put(context, selection); + } + + /** Called after authoritative state has been applied, including rejections and timeouts. */ + public static void remember(ElementAccess access, NavigableElement destination) { + destination + .common() + .id() + .ifPresent( + id -> + REMEMBERED + .computeIfAbsent(access.screenId(), ignored -> new HashMap<>()) + .put(id, destination.selected(access.presentation().state()))); + } + + public static void restore(ElementAccess access, NavigableElement destination) { + var id = destination.common().id().orElse(null); + if (id == null) return; + var selected = REMEMBERED.getOrDefault(access.screenId(), Map.of()).get(id); + if (selected == null + || selected.equals(destination.defaultSelection()) + || selected.equals(destination.selected(access.presentation().state())) + || !destination.hasSelection(selected)) return; + for (var element : access.elements()) { + var binding = element.navigation().orElse(null); + if (binding != null + && binding.target().context().equals(id) + && binding.target().view().equals(selected) + && access.presentation().visible(element) + && element.action(Navigation.NAVIGATE.id()).orElse(null) == Navigation.NAVIGATE + && access.submit(element, Navigation.NAVIGATE, new ElementActions.Click(0, 0, 0))) + // Restore only through an authorized control. Do not predict on another behavior's request. + return; + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/OptionSelection.java b/src/main/java/zone/moddev/mc/lib/gui/client/OptionSelection.java new file mode 100644 index 00000000..a617f9f4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/OptionSelection.java @@ -0,0 +1,22 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.Optional; + +import zone.moddev.mc.lib.gui.model.Element; + +/** + * Parent behavior capability shared by option drawing and input; implementations own selection + * policy. + */ +public interface OptionSelection { + boolean selected(Element option); + + /** Return CONSUME for a recognized option, even when a pending action prevents submission. */ + ElementBehavior.Click activate(Element option, int button, int modifiers); + + static Optional parent(Element option, ElementRenderAccess access) { + return access + .parent(option) + .flatMap(parent -> access.capability(parent, OptionSelection.class)); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/PopoutDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/PopoutDrawer.java new file mode 100644 index 00000000..9199df93 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/PopoutDrawer.java @@ -0,0 +1,172 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.util.FormattedCharSequence; + +import zone.moddev.mc.lib.gui.layout.*; +import zone.moddev.mc.lib.gui.model.Edge; +import zone.moddev.mc.lib.gui.model.elements.PopoutElement; + +/** XML information, warning and error drawers, including text layout, scrolling and input. */ +public final class PopoutDrawer extends Drawer { + private final PopoutElement element; + private boolean open; + private int scroll; + + public PopoutDrawer( + MachineScreen screen, zone.moddev.mc.lib.gui.drawer.DrawerDefinition definition) { + this(screen, definition, (PopoutElement) definition.element().orElseThrow()); + } + + private PopoutDrawer( + MachineScreen screen, + zone.moddev.mc.lib.gui.drawer.DrawerDefinition definition, + PopoutElement element) { + super(screen, definition, IconSprite.gui(element.iconOrDefault()), element.colour()); + this.element = element; + } + + public boolean available() { + return screen.visible(element); + } + + public boolean open() { + return open; + } + + public void close() { + open = false; + } + + public void closeLocal() { + close(); + } + + public Rect tabBounds() { + return screen.bounds(element); + } + + public boolean restore(Selection selection) { + if (!available()) return true; + openLocal(); + open = true; + return true; + } + + // ElementRenderers retains control of the rendering passes and mod overrides. + public void renderTab(GuiGraphics graphics) {} + + private int bodyWidth() { + var tab = tabBounds(); + int room = edge() == Edge.LEFT ? tab.x() - 4 : screen.width - tab.right() - 4; + return Math.max(0, Math.min(150, room)); + } + + private List lines() { + var font = Minecraft.getInstance().font; + var content = element.content(screen.getMenu().value(element, false), screen::text); + var result = new ArrayList(); + int width = Math.max(1, bodyWidth() - 12); + for (var paragraph : content) { + if (paragraph.getString().isEmpty()) result.add(FormattedCharSequence.EMPTY); + else result.addAll(font.split(paragraph, width)); + } + return result; + } + + private Rect body() { + var tab = tabBounds(); + int width = bodyWidth(); + int top = SideDrawerLayout.top(screen.panelBounds()); + int height = Math.max(tab.bottom() - top, lines().size() * 10 + 12); + return SideDrawerLayout.body( + screen.panelBounds(), + screen.drawerBottom(), + edge() == Edge.LEFT ? tab.x() - width : tab.right(), + top, + width, + height); + } + + public Optional presentation(Class type) { + return type == PopoutRenderer.Presentation.class + ? presentation().map(type::cast) + : Optional.empty(); + } + + private Optional presentation() { + if (!open || !available()) return Optional.empty(); + var body = body(); + var lines = lines(); + scroll = Math.clamp(scroll, 0, Math.max(0, lines.size() * 10 - (body.height() - 12))); + return Optional.of(new PopoutRenderer.Presentation(body, lines, scroll)); + } + + public List exclusions() { + if (!available()) return List.of(); + return open + ? List.of(body(), SideDrawerLayout.rail(tabBounds(), body())) + : List.of(tabBounds()); + } + + public boolean contains(double x, double y) { + return available() + && (tabContains(x, y) + || open + && (body().contains(x, y) + || SideDrawerLayout.rail(tabBounds(), body()).contains(x, y) + && !otherTab(x, y))); + } + + public boolean click(double x, double y, int button) { + if (button < 0 || button > 1 || !contains(x, y)) return false; + if (tabContains(x, y)) { + if (open) close(); + else { + openLocal(); + open = true; + } + remember(open, selection()); + } + return true; + } + + public boolean scroll(double x, double y, double amount) { + if (!open || !available() || !body().contains(x, y)) return false; + scroll += (int) (-amount * 10); + return true; + } + + static void drawTab(PopoutElement element, ElementRenderers.Context context) { + var tab = context.bounds(); + SideDrawerRenderer.tab(context.graphics(), tab, element.colour()); + IconSprite.gui(element.iconOrDefault()).draw(context.graphics(), tab.x() + 3, tab.y() + 3); + } + + static void drawBody( + PopoutElement element, + ElementRenderers.Context context, + PopoutRenderer.Presentation presentation) { + var g = context.graphics(); + var body = presentation.body(); + var tab = context.bounds(); + SideDrawerRenderer.open(g, tab, body, element.edge(), element.colour()); + IconSprite.gui(element.iconOrDefault()).draw(g, tab.x() + 3, tab.y() + 3); + g.enableScissor(body.x() + 5, body.y() + 5, body.right() - 5, body.bottom() - 5); + try { + for (int i = 0; i < presentation.lines().size(); i++) + g.drawString( + context.access().font(), + presentation.lines().get(i), + body.x() + 6, + body.y() + 6 + i * 10 - presentation.scroll(), + 0xff303030, + false); + } finally { + g.disableScissor(); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/PopoutRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/PopoutRenderer.java new file mode 100644 index 00000000..788173ba --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/PopoutRenderer.java @@ -0,0 +1,29 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; + +import net.minecraft.util.FormattedCharSequence; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.elements.PopoutElement; + +/** Registered renderer adapter for the client-owned {@link PopoutDrawer}. */ +public final class PopoutRenderer { + private PopoutRenderer() {} + + public record Presentation(Rect body, List lines, int scroll) { + public Presentation { + lines = List.copyOf(lines); + } + } + + public static void tab(PopoutElement element, ElementRenderers.Context c) { + PopoutDrawer.drawTab(element, c); + } + + public static void body(PopoutElement element, ElementRenderers.Context c) { + c.access() + .drawerPresentation(element, Presentation.class) + .ifPresent(p -> PopoutDrawer.drawBody(element, c, p)); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/RadioBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/RadioBehavior.java new file mode 100644 index 00000000..e82a0a71 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/RadioBehavior.java @@ -0,0 +1,20 @@ +package zone.moddev.mc.lib.gui.client; + +import zone.moddev.mc.lib.gui.model.elements.RadioElement; + +/** A radio option delegates activation to its parent's registered selection capability. */ +public class RadioBehavior extends DefaultElementBehavior { + private final RadioElement radio; + + public RadioBehavior(RadioElement radio, ElementAccess access) { + super(access); + this.radio = radio; + } + + protected Click click(double x, double y, int button) { + if (radio.common().clicks().any()) return Click.PASS; + return OptionSelection.parent(radio, access.presentation()) + .map(selection -> selection.activate(radio, button, access.modifiers())) + .orElse(Click.PASS); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/RadioGroupBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/RadioGroupBehavior.java new file mode 100644 index 00000000..0418a3bf --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/RadioGroupBehavior.java @@ -0,0 +1,34 @@ +package zone.moddev.mc.lib.gui.client; + +import java.math.BigInteger; + +import zone.moddev.mc.lib.gui.ElementActions; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.containers.RadioGroupElement; + +/** Owns radio membership, synchronized selection and activation of the group's server action. */ +public class RadioGroupBehavior extends DefaultElementBehavior implements OptionSelection { + private final RadioGroupElement group; + + public RadioGroupBehavior(RadioGroupElement group, ElementAccess access) { + super(access); + this.group = group; + } + + private int index(Element option) { + for (int i = 0; i < group.options().size(); i++) if (group.options().get(i) == option) return i; + return -1; + } + + public boolean selected(Element option) { + int index = index(option); + return index >= 0 && BigInteger.valueOf(index).equals(access.value()); + } + + public Click activate(Element option, int button, int modifiers) { + int index = index(option); + if (index < 0 || button < 0 || button > 1) return Click.PASS; + access.submit(ElementActions.CLICK, new ElementActions.Click(button, modifiers, index)); + return Click.CONSUME; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java index 519ff17d..ed380d04 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java @@ -16,15 +16,13 @@ import zone.moddev.mc.lib.api.capabilities.RelativeFace; import zone.moddev.mc.lib.api.redstone.*; import zone.moddev.mc.lib.gui.layout.*; -import zone.moddev.mc.lib.gui.model.Element; import zone.moddev.mc.lib.gui.runtime.RedstoneMenu; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Per-face redstone setup using the same neighbour grid and frame as the inventory drawer. */ -final class RedstoneDrawer { - private final MachineScreen screen; +public final class RedstoneDrawer extends Drawer { private final RedstoneMenu model; private final FaceConfigRenderer faces = new FaceConfigRenderer(); - private final ItemStack tabIcon = new ItemStack(Items.REDSTONE); private static final RelativeFace[] FACES = RelativeFace.values(); private static final ResourceLocation DISABLED = ResourceLocation.parse("mmdlib:side_mode_disabled"), @@ -48,30 +46,22 @@ final class RedstoneDrawer { private int sliderHandle = -1, sliderFace, previewLow, previewHigh; private long sliderRevision; - RedstoneDrawer(MachineScreen screen) { - this.screen = screen; - model = screen.getMenu().redstone; + public RedstoneDrawer( + MachineScreen screen, zone.moddev.mc.lib.gui.drawer.DrawerDefinition definition) { + super(screen, definition, IconSprite.item(() -> new ItemStack(Items.REDSTONE)), 0xc78181); + model = screen.getMenu().components().get(BuiltinMenuComponents.REDSTONE); } void layout() { var panel = screen.panelBounds(); - tab = - new Rect( - panel.right(), - panel.y() - + AutomationLayout.TOP - + (screen.getMenu().automation == null - ? 0 - : AutomationLayout.TAB_HEIGHT + ScreenLayout.TAB_GAP), - AutomationLayout.RAIL, - AutomationLayout.TAB_HEIGHT); + tab = super.tabBounds(); if (!model.open()) return; int height = contentHeight(); body = SideDrawerLayout.body( panel, screen.drawerBottom(), - tab.right(), + SideDrawerLayout.bodyX(edge(), tab, RedstoneLayout.WIDTH), panel.y() + AutomationLayout.TOP, RedstoneLayout.WIDTH, height, @@ -99,12 +89,10 @@ private static Component text(String key, Object... args) { return Component.translatable("screen.mmdlib.redstone." + key, args); } - void render(GuiGraphics g, int mx, int my) { + public void render(GuiGraphics g, int mx, int my) { layout(); tooltip = List.of(); - if (model.open()) SideDrawerRenderer.open(g, tab, body, Element.Edge.RIGHT, 0xc78181); - else SideDrawerRenderer.tab(g, tab, 0xc78181); - renderTabIcon(g); + drawFrame(g, tab, body, model.open()); if (tab.contains(mx, my)) tooltip = List.of(text("title")); if (!model.open()) return; g.enableScissor(body.x(), body.y(), body.right(), body.bottom()); @@ -387,18 +375,51 @@ private void smallText(GuiGraphics g, Component text, float x, float y, float sc g.pose().popPose(); } - void tooltip(GuiGraphics g, int x, int y) { + public void tooltip(GuiGraphics g, int x, int y) { if (!tooltip.isEmpty()) g.renderComponentTooltip(Minecraft.getInstance().font, tooltip, x, y); } - void renderTab(GuiGraphics g) { + public Rect tabBounds() { layout(); - SideDrawerRenderer.tab(g, tab, 0xc78181); - renderTabIcon(g); + return tab; } - private void renderTabIcon(GuiGraphics g) { - g.renderItem(tabIcon, tab.x() + 3, tab.y() + 4); + public boolean open() { + return model.open(); + } + + public boolean pending() { + return model.pending(); + } + + public void close() { + if (open() && !pending()) model.send(RedstoneMenu.TOGGLE, model.selected(), 0, 0); + } + + public Selection selection() { + return new Selection("", model.selected()); + } + + public boolean restore(Selection selection) { + if (pending()) return false; + if (!open()) { + model.send(RedstoneMenu.TOGGLE, 0, 0, 0); + return false; + } + if (model.selected() != selection.offset()) + model.send(RedstoneMenu.SELECT, selection.offset(), 0, 0); + activated(); + return true; + } + + public void resized() { + sliderHandle = -1; + handled = false; + } + + @Override + protected void drawIcon(GuiGraphics g, Rect tab) { + super.drawIcon(g, tab); if (model.watching()) { g.pose().pushPose(); g.pose().translate(0, 0, 200); @@ -407,12 +428,12 @@ private void renderTabIcon(GuiGraphics g) { } } - List exclusions() { + public List exclusions() { layout(); return model.open() ? List.of(SideDrawerLayout.rail(tab, body), body) : List.of(tab); } - boolean contains(double x, double y) { + public boolean contains(double x, double y) { layout(); return tab.contains(x, y) || model.open() && (body.contains(x, y) || SideDrawerLayout.rail(tab, body).contains(x, y)); @@ -421,20 +442,13 @@ boolean contains(double x, double y) { private void send(int kind, int face, int column, int value) { if (model.send(kind, face, column, value)) { if (kind == RedstoneMenu.TOGGLE || kind == RedstoneMenu.SELECT) - screen.rememberRedstoneIntent(kind, face); - if (kind == RedstoneMenu.TOGGLE && !model.open()) screen.closeRightPopouts(); + remember(kind != RedstoneMenu.TOGGLE || !model.open(), new Selection("", face)); + if (kind == RedstoneMenu.TOGGLE && !model.open()) activated(); } } - boolean click(double x, double y, int button) { - var panel = screen.panelBounds(); - if (screen.getMenu().automation != null - && new Rect( - panel.right(), - panel.y() + AutomationLayout.TOP, - AutomationLayout.RAIL, - AutomationLayout.TAB_HEIGHT) - .contains(x, y)) return false; + public boolean click(double x, double y, int button) { + if (otherTab(x, y)) return false; if (!contains(x, y)) return false; if (button < 0 || button > 1 || model.pending()) { handled = true; @@ -500,7 +514,7 @@ && controlAt(RedstoneLayout.group()).contains(x, y)) { return true; } - boolean scroll(double x, double y, double amount) { + public boolean scroll(double x, double y, double amount) { if (sliderHandle >= 0) return true; if (!contains(x, y) || !model.open()) return false; editorScroll = @@ -511,7 +525,7 @@ boolean scroll(double x, double y, double amount) { return true; } - boolean key(int key) { + public boolean key(int key, int scan, int modifiers) { if (key == 256 && sliderHandle >= 0) { sliderHandle = -1; handled = false; @@ -536,12 +550,12 @@ private void updateSlider(double x) { } } - boolean drag(double x, double y, int button) { + public boolean drag(double x, double y, int button, double dx, double dy) { if (sliderHandle >= 0 && button == 0) updateSlider(x); return handled; } - boolean release(double x, double y, int button) { + public boolean release(double x, double y, int button) { if (sliderHandle >= 0) { if (button != 0) return true; updateSlider(x); diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ReservationOverlay.java b/src/main/java/zone/moddev/mc/lib/gui/client/ReservationOverlay.java index 16a247d4..1f3f4b3f 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/ReservationOverlay.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ReservationOverlay.java @@ -1,61 +1,38 @@ package zone.moddev.mc.lib.gui.client; -import java.util.HashMap; -import java.util.Map; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.ItemStack; + import zone.moddev.mc.lib.machine.inventory.SlotPlan; /** Visual reservations only: preview items never become slot contents. */ final class ReservationOverlay { private static final float COUNT_SCALE = .875f; private final GhostItems ghosts = new GhostItems(); - private final Map previews = new HashMap<>(); - - private static final class Preview { - final SlotPlan plan; - final ItemStack output; - final String label; - - Preview(SlotPlan plan) { - this.plan = plan; - output = plan.add(); - String input = plan.take() > 0 ? "-" + plan.take() : ""; - String added = output.isEmpty() ? "" : "+" + output.getCount(); - label = input + (!input.isEmpty() && !added.isEmpty() ? " " : "") + added; - } - } void render( GuiGraphics g, Font font, Slot slot, + zone.moddev.mc.lib.gui.model.elements.SlotElement element, ItemStack displayed, SlotPlan plan, - @org.jetbrains.annotations.Nullable String countString) { - if (plan.isNone()) { - previews.remove(slot.index); - } else { - var preview = previews.get(slot.index); - if (preview == null || preview.plan != plan) { - preview = new Preview(plan); - previews.put(slot.index, preview); - } - // Vanilla may be showing a drag preview in an otherwise empty slot. Keep that preview. - if (displayed.isEmpty() && !preview.output.isEmpty()) - ghosts.render(g, preview.output, slot.x, slot.y); - label(g, font, preview.label, slot.x, slot.y); - } - // Match vanilla's count visibility and any coloured drag-limit override. - if (!displayed.isEmpty() && (displayed.getCount() != 1 || countString != null)) - label( - g, - font, - countString == null ? Integer.toString(displayed.getCount()) : countString, - slot.x, - slot.y + 9); + String countString) { + element.renderOverlay( + new zone.moddev.mc.lib.gui.model.elements.SlotElement.OverlayPainter() { + public void ghost(ItemStack stack, float alpha) { + ghosts.render(g, stack, slot.x, slot.y, alpha); + } + + public void quantity(String text, boolean bottom) { + label(g, font, text, slot.x, slot.y + (bottom ? 9 : 0)); + } + }, + displayed, + plan, + countString); } private static void label(GuiGraphics g, Font font, String text, int x, int y) { diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ScreenText.java b/src/main/java/zone/moddev/mc/lib/gui/client/ScreenText.java index 54097ce6..f795202a 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/ScreenText.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ScreenText.java @@ -1,6 +1,5 @@ package zone.moddev.mc.lib.gui.client; -import java.math.*; import java.util.*; import net.minecraft.locale.Language; @@ -27,80 +26,17 @@ public static Component component(Text text) { .orElseGet(() -> Component.translatable(text.key())); } + /** Numeric compatibility helper; use the element's displayText hook for UI values. */ public static String number(Object value, boolean full) { - if (value == null) return "?"; - if (value instanceof net.neoforged.neoforge.fluids.FluidStack fluid) - return Integer.toString(fluid.getAmount()); - if (value instanceof BigInteger n && !full) { - String[] units = {"", " k", " M", " G", " T", " P", " E", " Z", " Y"}; - int exponent = Math.max(0, (n.abs().toString().length() - 1) / 3); - if (exponent >= units.length) - return new BigDecimal(n) - .round(new MathContext(3)) - .stripTrailingZeros() - .toEngineeringString(); - int unit = exponent; - if (unit > 0) - return new BigDecimal(n) - .divide(BigDecimal.TEN.pow(unit * 3), 1, RoundingMode.HALF_UP) - .stripTrailingZeros() - .toPlainString() - + units[unit]; - } - return value.toString(); + return zone.moddev.mc.lib.gui.model.DisplayValues.number(value, full); } + /** Numeric compatibility helper; renderers use the element's fillRatio hook. */ public static double ratio(Object value, Object max) { - if (value instanceof net.neoforged.neoforge.fluids.FluidStack f) - value = BigInteger.valueOf(f.getAmount()); - if (!(value instanceof Number) || !(max instanceof Number)) return 0; - try { - BigDecimal a = - value instanceof BigInteger b - ? new BigDecimal(b) - : BigDecimal.valueOf(((Number) value).doubleValue()); - BigDecimal b = - max instanceof BigInteger n - ? new BigDecimal(n) - : BigDecimal.valueOf(((Number) max).doubleValue()); - return b.signum() <= 0 - ? 0 - : Math.clamp(a.divide(b, 8, RoundingMode.HALF_UP).doubleValue(), 0, 1); - } catch (ArithmeticException | NumberFormatException ex) { - return 0; - } + return zone.moddev.mc.lib.gui.model.DisplayValues.ratio(value, max); } public static Component markup(String text) { - var result = Component.empty(); - var buffer = new StringBuilder(); - Style style = Style.EMPTY; - for (int i = 0; i < text.length(); i++) { - char c = text.charAt(i); - if (c == '\\' && i + 1 < text.length() && "*/_".indexOf(text.charAt(i + 1)) >= 0) { - buffer.append(text.charAt(++i)); - continue; - } - boolean mark = "*/_".indexOf(c) >= 0; - boolean opening = - (i == 0 || !Character.isLetterOrDigit(text.charAt(i - 1))) - && i + 1 < text.length() - && Character.isLetterOrDigit(text.charAt(i + 1)); - boolean closing = - i > 0 - && Character.isLetterOrDigit(text.charAt(i - 1)) - && (i + 1 == text.length() || !Character.isLetterOrDigit(text.charAt(i + 1))); - boolean active = - c == '*' ? style.isBold() : c == '/' ? style.isItalic() : style.isUnderlined(); - if (mark && ((opening && !active && text.indexOf(c, i + 1) > i) || (closing && active))) { - result.append(Component.literal(buffer.toString()).setStyle(style)); - buffer.setLength(0); - style = - c == '*' - ? style.withBold(!active) - : c == '/' ? style.withItalic(!active) : style.withUnderlined(!active); - } else buffer.append(c); - } - return result.append(Component.literal(buffer.toString()).setStyle(style)); + return zone.moddev.mc.lib.gui.model.TextMarkup.markup(text); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigBehavior.java new file mode 100644 index 00000000..98e6bcb6 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigBehavior.java @@ -0,0 +1,53 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.*; + +import net.minecraft.network.chat.Component; + +import zone.moddev.mc.lib.api.capabilities.*; +import zone.moddev.mc.lib.gui.model.elements.SideConfigElement; + +/** Local group selection, face hit testing and tooltips for the inline side control. */ +public class SideConfigBehavior extends DefaultElementBehavior implements SideConfigPresentation { + private final SideConfigElement config; + private int group; + + public SideConfigBehavior(SideConfigElement config, ElementAccess access) { + super(access); + this.config = config; + } + + public int selection() { + return group; + } + + public int sideMode(int group, int face) { + return access.presentation().state().get(config, SideConfigElement.MODES).get(group * 6 + face); + } + + protected Click click(double x, double y, int button) { + var bounds = access.bounds(); + if (y < bounds.y() + 20) { + group = (group + 1) % config.groups().size(); + return Click.CONSUME; + } + for (int face = 0; face < 6; face++) + if (config.face(bounds, face).contains(x, y)) { + access.click(SideConfigElement.CYCLE_FACE, button, group * 6 + face); + return Click.CONSUME; + } + return Click.PASS; + } + + public List tooltip(int x, int y) { + for (int face = 0; face < 6; face++) + if (config.face(access.bounds(), face).contains(x, y)) + return List.of( + Component.translatable( + "screen.mmdlib.face." + RelativeFace.values()[face].getSerializedName()), + Component.translatable( + "screen.mmdlib.mode." + + IoMode.values()[sideMode(group, face)].getSerializedName())); + return List.of(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigDrawer.java similarity index 50% rename from src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java rename to src/main/java/zone/moddev/mc/lib/gui/client/SideConfigDrawer.java index 99c26b5b..d9dc4066 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigDrawer.java @@ -6,8 +6,6 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.gui.components.EditBox; -import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.item.ItemStack; @@ -15,49 +13,82 @@ import zone.moddev.mc.lib.api.automation.*; import zone.moddev.mc.lib.api.capabilities.RelativeFace; import zone.moddev.mc.lib.gui.layout.*; +import zone.moddev.mc.lib.gui.model.ElementGroup; +import zone.moddev.mc.lib.gui.model.containers.PlayerInventoryElement; import zone.moddev.mc.lib.gui.runtime.AutomationMenu; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** * Compact inventory-first drawer. Its selector belongs inside the body, not the navigation rail. */ -public final class AutomationDrawer { +public final class SideConfigDrawer extends Drawer { + private final NativeSlotRenderer nativeSlots = new NativeSlotRenderer(); + private ElementFragment slotFragment; + private int slotGroup = -1; public static final int WIDTH = AutomationLayout.WIDTH, RAIL = AutomationLayout.RAIL; - private final MachineScreen screen; private final AutomationMenu model; private final Minecraft mc = Minecraft.getInstance(); - private final GhostItems ghostItems = new GhostItems(); private final FaceConfigRenderer faces = new FaceConfigRenderer(); private int verticalOffset, editorScroll, keyboardGroup; - private final InventoryColorButtonBar colourBar; + private final ColourButtonBar colourBar; private final List displayOrder; + private final Map> groupHighlights; private String editorKey = ""; private FilterControl root; - private FilterPanelLayout panelLayout; - private List controls = List.of(); - private final Map textFields = new HashMap<>(); - private final Map customWidgets = new HashMap<>(); - private FilterWidgets.Instance focusedCustom; + private ElementFragment editor; private Component tooltip; private List faceTooltip = List.of(); private Rect body, tab, selector, editorArea; - private final Map controlBounds = new HashMap<>(); private int lastSelected = -1; - public AutomationDrawer(MachineScreen screen) { - this.screen = screen; - model = screen.getMenu().automation; + public SideConfigDrawer( + MachineScreen screen, zone.moddev.mc.lib.gui.drawer.DrawerDefinition definition) { + super( + screen, + definition, + IconSprite.gui(ResourceLocation.parse("mmdlib:icon_side_config")), + 0x819bc2); + model = screen.getMenu().components().get(BuiltinMenuComponents.AUTOMATION); + var groups = model.inventory.groups(); + var associations = + groups.stream() + .map( + group -> + new ElementGroup( + group.id(), + group.highlights(), + Optional.of( + new ElementGroup.Members( + group.type().id(), group.inventory(), group.members())))) + .toList(); + var indices = new LinkedHashMap(); + for (int i = 0; i < groups.size(); i++) indices.put(groups.get(i).id(), i); + var definitionModel = screen.getMenu().bound.screen(); + groupHighlights = GroupHighlights.of(definitionModel, associations); + displayOrder = + GroupOrder.of( + definitionModel, + associations.stream().map(ElementGroup::id).toList(), + element -> element.associatedGroups(definitionModel, associations)) + .stream() + .map(indices::get) + .toList(); colourBar = - new InventoryColorButtonBar( - screen.getMenu().bound.screen(), - model.inventory.groups(), + new ColourButtonBar<>( + displayOrder.stream() + .map(index -> new ColourButtonBar.Entry<>(index, groups.get(index).colour())) + .toList(), group -> send("select", group, -1, "", 0, "")); - displayOrder = colourBar.order(); } private boolean send( String kind, int group, int column, String control, int entry, String value) { if (!model.send(kind, group, column, control, entry, value)) return false; - screen.rememberAutomationIntent(kind, group); + if (kind.equals("close")) remember(false, selection()); + else if (kind.equals("select")) { + activated(); + remember(true, new Selection(model.inventory.group(group).id(), 0)); + } return true; } @@ -70,15 +101,24 @@ private Rect face(int index) { } private Rect filterToggle(int column) { - return AutomationLayout.filterToggle(column).offset(body.x(), body.y()); + return model + .inventoryView(model.selected()) + .filterToggleBounds(column) + .offset(body.x(), body.y()); } private Rect module(int column) { - return AutomationLayout.addon(column).offset(body.x(), body.y()); + return model + .inventoryView(model.selected()) + .drawerSlotBounds(column) + .offset(body.x(), body.y()); } private Rect filter(int column) { - return AutomationLayout.filter(column).offset(body.x(), body.y()); + return model + .inventoryView(model.selected()) + .drawerSlotBounds(COLUMNS + column) + .offset(body.x(), body.y()); } private int count() { @@ -87,18 +127,17 @@ private int count() { public void layout() { Rect panel = panel(); - tab = - new Rect( - panel.right(), panel.y() + AutomationLayout.TOP, RAIL, AutomationLayout.TAB_HEIGHT); + tab = super.tabBounds(); syncEditor(); boolean hasFilterSlot = false, hasFilter = false; for (int c = 0; c < COLUMNS; c++) { - hasFilterSlot |= model.supportsFilter(model.selected(), c); - hasFilter |= !model.filter(model.selected(), c).isEmpty(); + boolean visible = model.slotVisible(model.selected(), COLUMNS + c); + hasFilterSlot |= visible; + hasFilter |= visible && !model.filter(model.selected(), c).isEmpty(); } int contentHeight = model.addonsEnabled() - ? AutomationLayout.contentHeight(hasFilterSlot, hasFilter) + ? model.inventoryView(model.selected()).contentHeight(hasFilterSlot, hasFilter) : AutomationLayout.face(RelativeFace.BOTTOM.ordinal()).bottom() + AutomationLayout.PADDING; int baseHeight = @@ -109,7 +148,7 @@ public void layout() { * AutomationLayout.SELECTOR_PITCH); boolean editing = model.open() && model.editor() >= 0; int editorHeight = - (panelLayout == null ? 18 : panelLayout.height()) + (2 * AutomationLayout.EDITOR_PADDING); + (editor == null ? 18 : editor.height()) + (2 * AutomationLayout.EDITOR_PADDING); int desiredHeight = editing ? Math.max( @@ -119,11 +158,13 @@ public void layout() { SideDrawerLayout.body( panel, screen.drawerBottom(), - tab.right(), + SideDrawerLayout.bodyX(edge(), tab, WIDTH), tab.y(), WIDTH, desiredHeight, SideDrawerLayout.HeightLimit.SCREEN); + model.position(body.x() - panel.x(), body.y() - panel.y()); + syncSlots(); int shown = Math.max( 1, @@ -132,7 +173,11 @@ public void layout() { (Math.min(baseHeight, body.height()) - 16) / AutomationLayout.SELECTOR_PITCH)); selector = new Rect(body.x() + 5, body.y() + 8, 14, shown * AutomationLayout.SELECTOR_PITCH); colourBar.layout( - screen.layout.inventoryColors().map(r -> r.offset(panel.x(), panel.y())).orElse(null), + screen + .layout + .region(PlayerInventoryElement.TOOLBAR) + .map(r -> r.offset(panel.x(), panel.y())) + .orElse(null), model.selected()); if (lastSelected != model.selected()) { int position = displayOrder.indexOf(model.selected()); @@ -152,32 +197,80 @@ public void layout() { editorHeight, body.height() - AutomationLayout.EDITOR_Y - AutomationLayout.PADDING)) : null; - controlBounds.clear(); - if (editorArea != null && panelLayout != null) { - int viewport = Math.max(0, editorArea.height() - (2 * AutomationLayout.EDITOR_PADDING)); - editorScroll = Math.clamp(editorScroll, 0, Math.max(0, panelLayout.height() - viewport)); - panelLayout - .controls() - .forEach( - (id, r) -> - controlBounds.put( - id, - r.offset( - editorArea.x() + 6, - editorArea.y() + AutomationLayout.EDITOR_PADDING - editorScroll))); - textFields.forEach( - (id, field) -> { - Rect r = controlBounds.get(id); - if (r != null) { - field.setX(r.x()); - field.setY(r.y()); - field.setWidth(Math.max(12, r.width() - 12)); - if (r.bottom() <= editorArea.y() + AutomationLayout.EDITOR_PADDING - || r.y() >= editorArea.bottom() - AutomationLayout.EDITOR_PADDING) - field.setFocused(false); - } - }); + if (editor != null) { + if (editorArea == null) editor.place(0, 0, new Rect(0, 0, 0, 0)); + else { + int viewport = Math.max(0, editorArea.height() - 2 * AutomationLayout.EDITOR_PADDING); + editorScroll = Math.clamp(editorScroll, 0, Math.max(0, editor.height() - viewport)); + editor.place( + editorArea.x() + 6, + editorArea.y() + AutomationLayout.EDITOR_PADDING - editorScroll, + new Rect( + editorArea.x() + 4, + editorArea.y() + AutomationLayout.EDITOR_PADDING, + editorArea.width() - 8, + viewport)); + } + } + } + + private void syncSlots() { + if (!model.open() || !model.addonsEnabled()) { + if (slotFragment != null) slotFragment.removed(); + slotFragment = null; + slotGroup = -1; + return; + } + int group = model.selected(); + if (slotFragment == null || slotGroup != group) { + if (slotFragment != null) slotFragment.removed(); + var inventory = model.inventoryView(group); + slotFragment = + new ElementFragment( + inventory, + WIDTH, + new ElementFragment.Host() { + public ResourceLocation screenId() { + return screen.getMenu().bound.screen().id(); + } + + public boolean visible(zone.moddev.mc.lib.gui.model.Element element) { + return model.visible(group, element); + } + + public Object value(zone.moddev.mc.lib.gui.model.Element element, boolean maximum) { + return model.drawerValue(group, element, maximum); + } + + public ItemStack contents(zone.moddev.mc.lib.gui.model.elements.SlotElement slot) { + return screen.getMenu().slot(slot).getItem().copy(); + } + + public ItemStack hint(zone.moddev.mc.lib.gui.model.elements.SlotElement slot) { + return screen.getMenu().slotHint(slot); + } + + public zone.moddev.mc.lib.machine.inventory.SlotPlan reservation( + zone.moddev.mc.lib.gui.model.elements.SlotElement slot) { + return screen.getMenu().reservationPlan(screen.getMenu().slotIndex(slot)); + } + + public List serverTooltip(zone.moddev.mc.lib.gui.model.Element element) { + return screen + .getMenu() + .components() + .requireCapability( + zone.moddev.mc.lib.gui.runtime.component.ServerTooltips.class) + .lines(element); + } + + public ItemStack carried() { + return screen.getMenu().getCarried().copy(); + } + }); + slotGroup = group; } + slotFragment.place(body.x(), body.y(), body); } private void syncEditor() { @@ -206,11 +299,6 @@ private void syncEditor() { try { if (root.equals(provider.controls(stack))) { editorKey = key; - var config = provider.configuration(stack); - textFields.forEach( - (id, field) -> { - if (!field.isFocused()) field.setValue(config.getString(id)); - }); return; } } catch (RuntimeException invalid) { @@ -218,58 +306,58 @@ private void syncEditor() { } } if (!sameAttachment) editorScroll = 0; - closeEditorInput(); - customWidgets.values().forEach(FilterWidgets.Instance::removed); - customWidgets.clear(); + if (editor != null) editor.removed(); + editor = null; editorKey = key; root = null; - panelLayout = null; - controls = List.of(); - for (var field : textFields.values()) field.setFocused(false); - textFields.clear(); if (key.isEmpty()) return; try { root = provider.controls(stack); - controls = FilterControl.flatten(root); - panelLayout = FilterPanelLayout.of(root, WIDTH - 40); - var config = provider.configuration(stack); - for (var control : controls) - if (control instanceof FilterControl.Text text) { - var field = new EditBox(mc.font, 0, 0, 80, 16, text.tooltip()); - field.setMaxLength(text.maxLength()); - field.setValue(config.getString(text.id())); - textFields.put(text.id(), field); - } else if (control instanceof FilterControl.Custom custom) { - var instance = - FilterWidgets.create( - custom, - () -> controlBounds.get(custom.id()), - () -> provider.configuration(model.filter(g, c)).copy(), - model::pending, - value -> edit(custom.id(), 0, value)); - if (instance != null) customWidgets.put(custom.id(), instance); - } + var controls = FilterControl.flatten(root); + FilterEdits edits = + (control, entry, value) -> + controls.stream().anyMatch(candidate -> candidate == control) + && entry >= 0 + && entry < 256 + && value.length() <= 256 + && send("edit", g, c, control.id(), entry, value); + editor = + new ElementFragment( + root, + WIDTH - 40, + new ElementFragment.Host() { + public ResourceLocation screenId() { + return screen.getMenu().bound.screen().id(); + } + + public Object value(zone.moddev.mc.lib.gui.model.Element element, boolean maximum) { + return provider.configuration(model.filter(g, c)).copy(); + } + + public boolean pending(zone.moddev.mc.lib.gui.model.Element element) { + return model.pending(); + } + + public void hoverChanged( + zone.moddev.mc.lib.gui.model.Element element, boolean entered) { + ElementHover.dispatch(screen.getMenu(), element, entered); + } + + public ItemStack carried() { + return screen.getMenu().getCarried().copy(); + } + + public Optional service(Class type) { + return type.isInstance(edits) ? Optional.of(type.cast(edits)) : Optional.empty(); + } + }); } catch (RuntimeException invalid) { root = null; - controls = List.of(); - panelLayout = null; + editor = null; zone.moddev.mc.lib.MMDLib.LOGGER.warn("Invalid filter editor", invalid); } } - private static void sprite(GuiGraphics g, String name, Rect r) { - g.blitSprite(ResourceLocation.parse("mmdlib:" + name), r.x(), r.y(), r.width(), r.height()); - } - - private void icon(GuiGraphics g, String name, Rect r) { - sprite(g, name, r); - } - - private void button(GuiGraphics g, Rect r, boolean selected) { - sprite(g, "slot", r); - if (selected) g.renderOutline(r.x(), r.y(), r.width(), r.height(), 0xfff5da72); - } - private void swatch(GuiGraphics g, Rect r, int group, int mx, int my) { boolean selected = model.selected() == group && model.open(); g.fill(r.x(), r.y(), r.right(), r.bottom(), selected ? 0xffeeeeee : 0xff555555); @@ -286,34 +374,20 @@ private void swatch(GuiGraphics g, Rect r, int group, int mx, int my) { } } - public void drawBackground(GuiGraphics g, int mx, int my) { + public void render(GuiGraphics g, int mx, int my) { layout(); tooltip = null; faceTooltip = List.of(); - if (model.open()) - SideDrawerRenderer.open( - g, tab, body, zone.moddev.mc.lib.gui.model.Element.Edge.RIGHT, 0x819bc2); - else SideDrawerRenderer.tab(g, tab, 0x819bc2); - icon(g, "icon_side_config", new Rect(tab.x() + 3, tab.y() + 4, 16, 16)); + drawFrame(g, tab, body, model.open()); for (int index = 0; index < count(); index++) { var group = model.inventory.group(index); - for (String id : group.highlights()) + for (var element : groupHighlights.get(group.id())) screen - .getMenu() - .bound - .screen() - .find(id) - .ifPresent( - e -> { - if (screen.visible(e)) { - var r = screen.bounds(e); - g.renderOutline( - r.x(), r.y(), r.width(), r.height(), 0xff000000 | group.colour()); - } - }); + .presentation() + .highlight(g, element, ElementHighlight.decoration(0xff000000 | group.colour())); } if (tab.contains(mx, my)) tooltip = Component.translatable("screen.mmdlib.automation.open"); - int hoveredGroup = colourBar.render(g, mx, my, model.open()); + int hoveredGroup = colourBar.render(g, mx, my, model.open()).orElse(-1); if (hoveredGroup >= 0) { tooltip = model.inventory.group(hoveredGroup).name(); highlight(g, hoveredGroup); @@ -339,17 +413,9 @@ public void drawBackground(GuiGraphics g, int mx, int my) { } for (int f = 0; f < 6; f++) drawFace(g, f, mx, my); for (int c = 0; model.addonsEnabled() && c < COLUMNS; c++) { - sprite(g, "slot", module(c)); - if (module(c).contains(mx, my) && model.addon(model.selected(), c).isEmpty()) - tooltip = - Component.translatable("screen.mmdlib.automation.addon") - .append(": ") - .append(model.inventory.group(model.selected()).name()); - if (model.supportsFilter(model.selected(), c) - || !model.filter(model.selected(), c).isEmpty()) { + if (model.slotVisible(model.selected(), COLUMNS + c)) { var f = filter(c); g.fill(f.x() + 6, module(c).bottom(), f.x() + 7, f.y(), 0xff425878); - sprite(g, "slot", f); if (!model.filter(model.selected(), c).isEmpty()) { var toggle = filterToggle(c); boolean expanded = model.editor() == c; @@ -380,30 +446,17 @@ public void drawBackground(GuiGraphics g, int mx, int my) { } } } + if (slotFragment != null) slotFragment.render(g, mx, my); if (model.open()) highlight(g, model.selected()); drawEditor(g, mx, my); } private void highlight(GuiGraphics g, int group) { var selected = model.inventory.group(group); - for (String id : selected.highlights()) + for (var element : groupHighlights.get(selected.id())) screen - .getMenu() - .bound - .screen() - .find(id) - .ifPresent( - e -> { - if (screen.getMenu().visible(e)) { - Rect r = screen.bounds(e); - g.renderOutline( - r.x() - 1, - r.y() - 1, - r.width() + 2, - r.height() + 2, - 0xff000000 | selected.colour()); - } - }); + .presentation() + .highlight(g, element, ElementHighlight.emphasis(0xff000000 | selected.colour())); } private void drawFace(GuiGraphics g, int index, int mx, int my) { @@ -426,10 +479,45 @@ private void drawFace(GuiGraphics g, int index, int mx, int my) { Component.translatable("screen.mmdlib.mode." + mode.getSerializedName())); } - void renderTab(GuiGraphics g) { + public Rect tabBounds() { + layout(); + return tab; + } + + public boolean open() { + return model.open(); + } + + public boolean pending() { + return model.pending(); + } + + public void close() { + if (model.open() && !model.pending()) model.send("close", model.selected(), -1, "", 0, ""); + } + + public Selection selection() { + return new Selection(model.inventory.group(model.selected()).id(), 0); + } + + public boolean restore(Selection selection) { + if (pending()) return false; + int group = model.inventory.index(selection.key()); + if (!model.send("select", Math.max(0, group), -1, "", 0, "")) return false; + activated(); + return true; + } + + public void tick() { + if (open() && !pending()) activated(); layout(); - SideDrawerRenderer.tab(g, tab, 0x819bc2); - icon(g, "icon_side_config", new Rect(tab.x() + 3, tab.y() + 4, 16, 16)); + if (editor != null) editor.tick(); + if (slotFragment != null) slotFragment.tick(); + } + + public void resized() { + closeEditorInput(); + if (slotFragment != null) slotFragment.cancelInteraction(); } private static void arrow(GuiGraphics g, Rect r, boolean forward, boolean vertical) { @@ -445,20 +533,17 @@ private void drawEditor(GuiGraphics g, int mx, int my) { g.fill(editorArea.x(), editorArea.y(), editorArea.right(), editorArea.bottom(), 0xff7189aa); g.renderOutline( editorArea.x(), editorArea.y(), editorArea.width(), editorArea.height(), 0xff526788); - int c = model.editor(); - var stack = model.filter(model.selected(), c); - var provider = stack.getCapability(AddonCapabilities.FILTER); - if (provider == null || panelLayout == null) { + if (editor == null) { g.drawString(mc.font, "?", editorArea.x() + 6, editorArea.y() + 4, 0xffffff); return; } int viewport = editorArea.height() - (2 * AutomationLayout.EDITOR_PADDING); - if (panelLayout.height() > viewport && viewport > 0) { - int track = viewport, thumb = Math.max(6, track * viewport / panelLayout.height()); + if (editor.height() > viewport && viewport > 0) { + int track = viewport, thumb = Math.max(6, track * viewport / editor.height()); int y = editorArea.y() + AutomationLayout.EDITOR_PADDING - + (track - thumb) * editorScroll / Math.max(1, panelLayout.height() - viewport); + + (track - thumb) * editorScroll / Math.max(1, editor.height() - viewport); g.fill( editorArea.right() - 4, editorArea.y() + AutomationLayout.EDITOR_PADDING, @@ -467,103 +552,48 @@ private void drawEditor(GuiGraphics g, int mx, int my) { 0xff526788); g.fill(editorArea.right() - 4, y, editorArea.right() - 2, y + thumb, 0xffd2dfef); } - boolean editorHovered = - mx >= editorArea.x() + 4 - && mx < editorArea.right() - 4 - && my >= editorArea.y() + AutomationLayout.EDITOR_PADDING - && my < editorArea.bottom() - AutomationLayout.EDITOR_PADDING; - var config = provider.configuration(stack); - g.enableScissor( - editorArea.x() + 4, - editorArea.y() + AutomationLayout.EDITOR_PADDING, - editorArea.right() - 4, - editorArea.bottom() - AutomationLayout.EDITOR_PADDING); - try { - for (var control : controls) { - Rect r = controlBounds.get(control.id()); - if (r == null - || r.bottom() <= editorArea.y() + AutomationLayout.EDITOR_PADDING - || r.y() >= editorArea.bottom() - AutomationLayout.EDITOR_PADDING) continue; - switch (control) { - case FilterControl.Custom custom -> { - var instance = customWidgets.get(custom.id()); - if (instance != null) { - g.enableScissor( - r.x(), - Math.max(r.y(), editorArea.y() + AutomationLayout.EDITOR_PADDING), - r.right(), - Math.min(r.bottom(), editorArea.bottom() - AutomationLayout.EDITOR_PADDING)); - try { - instance.render(g, r, config.copy(), mx, my); - } finally { - g.disableScissor(); - } - if (editorHovered && r.contains(mx, my)) tooltip = instance.tooltip(); - } else g.drawString(mc.font, "?", r.x() + 2, r.y() + 2, 0xffffff); - } - case FilterControl.Toggle toggle -> { - boolean selected = config.getBoolean(toggle.id()); - boolean hovered = editorHovered && r.contains(mx, my); - sprite(g, hovered ? "button_flat_highlighted" : "button_flat", r); - g.blitSprite( - selected ? toggle.selectedIcon() : toggle.icon(), r.x() + 2, r.y() + 2, 10, 10); - if (selected && toggle.icon().equals(toggle.selectedIcon())) - g.fill(r.x() + 3, r.bottom() - 3, r.right() - 3, r.bottom() - 2, 0xffeeeeee); - if (hovered) tooltip = selected ? toggle.selectedTooltip() : toggle.tooltip(); - } - case FilterControl.Samples samples -> { - var entries = config.getList(samples.id(), net.minecraft.nbt.Tag.TAG_STRING); - int columns = Math.max(1, r.width() / 18); - for (int i = 0; i < samples.count(); i++) { - Rect cell = new Rect(r.x() + (i % columns) * 18, r.y() + (i / columns) * 18, 18, 18); - sprite(g, "slot", cell); - var id = ResourceLocation.tryParse(entries.getString(i)); - if (id != null) { - ItemStack display = ItemStack.EMPTY; - if (samples.resource() == ResourceType.ITEMS) - display = new ItemStack(BuiltInRegistries.ITEM.get(id)); - else if (samples.resource() == ResourceType.FLUIDS) - display = new ItemStack(BuiltInRegistries.FLUID.get(id).getBucket()); - if (!display.isEmpty()) { - ghostItems.render(g, display, cell.x() + 1, cell.y() + 1); - if (editorHovered && cell.contains(mx, my)) tooltip = display.getHoverName(); - } - } - if (editorHovered && cell.contains(mx, my) && tooltip == null) - tooltip = Component.translatable("screen.mmdlib.automation.sample"); - } - } - case FilterControl.Text text -> { - var field = textFields.get(text.id()); - if (field != null) field.render(g, mx, my, 0); - var submit = new Rect(r.right() - 10, r.y() + 3, 8, 10); - button(g, submit, false); - arrow(g, submit, true, false); - if (editorHovered && r.contains(mx, my)) tooltip = text.tooltip(); - } - case FilterControl.Number number -> { - button(g, r, false); - g.drawCenteredString( - mc.font, - Integer.toString(config.getInt(number.id())), - r.x() + r.width() / 2, - r.y() + 5, - 0xffffff); - arrow(g, new Rect(r.x() + 2, r.y() + 4, 6, 8), false, false); - arrow(g, new Rect(r.right() - 8, r.y() + 4, 6, 8), true, false); - if (editorHovered && r.contains(mx, my)) tooltip = number.tooltip(); - } - default -> {} - } - } - } finally { - g.disableScissor(); - } + editor.render(g, mx, my); + var lines = editor.tooltip(mx, my); + if (!lines.isEmpty()) faceTooltip = lines; } public void tooltip(GuiGraphics g, int mx, int my) { if (!faceTooltip.isEmpty()) g.renderComponentTooltip(mc.font, faceTooltip, mx, my); else if (tooltip != null) g.renderTooltip(mc.font, tooltip, mx, my); + else if (slotFragment != null) { + var hit = slotAt(mx, my); + if (hit != null && hit.hasItem()) return; + var rows = + hit == null + ? slotFragment.tooltip(mx, my) + : slotFragment.tooltip(model.slotElement(hit.index), mx, my); + if (!rows.isEmpty()) g.renderComponentTooltip(mc.font, rows, mx, my); + } + } + + private net.minecraft.world.inventory.Slot slotAt(double x, double y) { + if (slotFragment == null + || !body.contains(x, y) + || editorArea != null && editorArea.contains(x, y)) return null; + var slot = screen.nativeSlotAt(x, y); + if (slot == null) return null; + var element = model.slotElement(slot.index); + return element != null && model.visible(model.selected(), element) ? slot : null; + } + + @Override + public Optional> nativeTooltip( + net.minecraft.world.inventory.Slot slot, List vanilla, int x, int y) { + layout(); + if (slot == null || slotAt(x, y) != slot) return Optional.empty(); + var element = model.slotElement(slot.index); + return Optional.of( + nativeSlots.itemTooltip( + screen.getMenu().nativeSlots(), + slot, + element, + vanilla, + e -> slotFragment.tooltip(e, x, y))); } public List exclusions() { @@ -593,7 +623,8 @@ public boolean click(double x, double y, int button) { } if (colourBar.click(x, y)) return true; if (!model.open()) return false; - if (SideDrawerLayout.rail(tab, body).contains(x, y) && !screen.onDrawerTab(x, y)) return true; + if (SideDrawerLayout.rail(tab, body).contains(x, y) && !screen.drawers().tabContains(x, y)) + return true; if (!body.contains(x, y)) closeEditorInput(); if (x >= body.x() + 2 && x < body.x() + AutomationLayout.SELECTOR_WIDTH @@ -621,74 +652,24 @@ else if (y >= selector.bottom()) return true; } for (int c = 0; c < COLUMNS; c++) { - if (model.supportsFilter(model.selected(), c) + if (model.slotVisible(model.selected(), COLUMNS + c) + && model.supportsFilter(model.selected(), c) && !model.filter(model.selected(), c).isEmpty() && filterToggle(c).contains(x, y)) { send("editor", model.selected(), c, "", 0, ""); return true; } - if (module(c).contains(x, y) - || (model.supportsFilter(model.selected(), c) - || !model.filter(model.selected(), c).isEmpty()) - && filter(c).contains(x, y)) return model.pending(); } if (editorArea != null && editorArea.contains(x, y)) { - if (y < editorArea.y() + AutomationLayout.EDITOR_PADDING - || y >= editorArea.bottom() - AutomationLayout.EDITOR_PADDING) return true; - var stack = model.filter(model.selected(), model.editor()); - var provider = stack.getCapability(AddonCapabilities.FILTER); - for (var control : controls) { - Rect r = controlBounds.get(control.id()); - if (r == null || !r.contains(x, y) || provider == null) continue; - switch (control) { - case FilterControl.Custom custom -> { - closeEditorInput(); - focusedCustom = customWidgets.get(custom.id()); - if (focusedCustom != null) { - focusedCustom.focus(true); - focusedCustom.click(x, y, button); - } - } - case FilterControl.Toggle toggle -> edit(toggle.id(), 0, ""); - case FilterControl.Samples samples -> { - int index = - ((int) y - r.y()) / 18 * Math.max(1, r.width() / 18) + ((int) x - r.x()) / 18; - if (index < samples.count()) - edit(samples.id(), index, button == 1 ? "clear" : "sample"); - } - case FilterControl.Text text -> { - var field = textFields.get(text.id()); - if (x >= r.right() - 12) { - edit(text.id(), 0, field.getValue()); - field.setFocused(false); - } else { - textFields.values().forEach(f -> f.setFocused(false)); - field.setFocused(true); - field.mouseClicked(x, y, button); - } - } - case FilterControl.Number number -> { - long current = provider.configuration(stack).getInt(number.id()); - long next = current + (x < r.x() + r.width() / 2 ? -number.step() : number.step()); - edit(number.id(), 0, Long.toString(Math.clamp(next, number.min(), number.max()))); - } - default -> {} - } - return true; - } - return true; - } - if (body.contains(x, y)) { - textFields.values().forEach(f -> f.setFocused(false)); + if (editor != null) editor.click(x, y, button); return true; } + if (slotAt(x, y) != null) return false; + closeEditorInput(); + if (body.contains(x, y)) return true; return false; } - private void edit(String id, int entry, String value) { - send("edit", model.selected(), model.editor(), id, entry, value); - } - public boolean scroll(double x, double y, double amount) { layout(); if (model.open() @@ -700,10 +681,8 @@ public boolean scroll(double x, double y, double amount) { return true; } if (editorArea != null && editorArea.contains(x, y)) { - for (var entry : customWidgets.entrySet()) - if (controlBounds.get(entry.getKey()).contains(x, y) - && entry.getValue().scroll(x, y, amount)) return true; - if (focusedCustom != null) focusedCustom.cancel(); + if (editor != null && editor.scroll(x, y, amount)) return true; + closeEditorInput(); editorScroll += (int) (-amount * 18); return true; } @@ -712,20 +691,8 @@ public boolean scroll(double x, double y, double amount) { } public boolean key(int key, int scan, int modifiers) { - if (focusedCustom != null && focusedCustom.key(key, scan, modifiers)) return true; - for (var entry : textFields.entrySet()) - if (entry.getValue().isFocused()) { - if (key == 256) { - entry.getValue().setFocused(false); - return true; - } - if (key == 257 || key == 335) { - edit(entry.getKey(), 0, entry.getValue().getValue()); - return true; - } - entry.getValue().keyPressed(key, scan, modifiers); - return true; - } + layout(); + if (editor != null && editor.key(key, scan, modifiers)) return true; if (model.open() && key == 256) { send(model.editor() >= 0 ? "collapse" : "close", model.selected(), -1, "", 0, ""); return true; @@ -755,30 +722,28 @@ public boolean key(int key, int scan, int modifiers) { } public boolean character(char c, int modifiers) { - if (focusedCustom != null && focusedCustom.character(c, modifiers)) return true; - for (var field : textFields.values()) - if (field.isFocused()) { - field.charTyped(c, modifiers); - return true; - } - return false; + layout(); + return editor != null && editor.character(c, modifiers); } public boolean drag(double x, double y, int button, double dx, double dy) { - return focusedCustom != null && focusedCustom.drag(x, y, button, dx, dy); + layout(); + return editor != null && editor.drag(x, y, button, dx, dy); } public boolean release(double x, double y, int button) { - return focusedCustom != null && focusedCustom.release(x, y, button); + layout(); + return editor != null && editor.release(x, y, button); + } + + public void mouseMoved(double x, double y) { + layout(); + if (editor != null) editor.mouseMoved(x, y); + if (slotFragment != null) slotFragment.mouseMoved(x, y); } public void closeEditorInput() { - textFields.values().forEach(f -> f.setFocused(false)); - if (focusedCustom != null) { - focusedCustom.cancel(); - focusedCustom.focus(false); - focusedCustom = null; - } + if (editor != null) editor.cancelInteraction(); } private boolean selectorFocused; @@ -789,8 +754,11 @@ private void narrateSelection() { public void removed() { closeEditorInput(); - customWidgets.values().forEach(FilterWidgets.Instance::removed); - customWidgets.clear(); + if (slotFragment != null) slotFragment.removed(); + slotFragment = null; + slotGroup = -1; + if (editor != null) editor.removed(); + editor = null; editorKey = ""; } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigPresentation.java b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigPresentation.java new file mode 100644 index 00000000..67d52a0f --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigPresentation.java @@ -0,0 +1,8 @@ +package zone.moddev.mc.lib.gui.client; + +/** Read-only presentation of an inline side configuration control. */ +public interface SideConfigPresentation { + int selection(); + + int sideMode(int group, int face); +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigRenderer.java new file mode 100644 index 00000000..186d4303 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigRenderer.java @@ -0,0 +1,72 @@ +package zone.moddev.mc.lib.gui.client; + +import static zone.moddev.mc.lib.gui.client.ElementDrawing.*; + +import net.minecraft.network.chat.Component; + +import zone.moddev.mc.lib.api.capabilities.RelativeFace; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.elements.SideConfigElement; + +/** Side configuration visuals; selections and face modes are read-only presentation inputs. */ +public final class SideConfigRenderer { + private SideConfigRenderer() {} + + public static void draw(SideConfigElement element, ElementRenderers.Context c) { + var g = c.graphics(); + var r = c.bounds(); + var access = c.access(); + int group = access.capability(element, SideConfigPresentation.class).orElseThrow().selection(); + sprite(g, "minecraft:widget/button", new Rect(r.x(), r.y(), r.width(), 20)); + var target = access.find(element.groups().get(group)).orElseThrow(); + var label = + access + .groupLabel(target) + .map(access::text) + .orElseGet(() -> Component.translatable("screen.mmdlib.side_config")); + g.drawCenteredString( + access.font(), + access.font().plainSubstrByWidth(label.getString(), r.width() - 6), + r.x() + r.width() / 2, + r.y() + 6, + 0xffffff); + int[] colors = {0x777777, 0x679fea, 0xef994e, 0xad6fea}; + for (int i = 0; i < 6; i++) { + var face = element.face(r, i); + tinted( + g, + "slot", + face, + colors[ + Math.clamp( + access + .capability(element, SideConfigPresentation.class) + .orElseThrow() + .sideMode(group, i), + 0, + 3)]); + g.drawCenteredString( + access.font(), + Component.translatable( + "screen.mmdlib.face.short." + RelativeFace.values()[i].getSerializedName()), + face.x() + 10, + face.y() + 6, + 0xffffff); + } + } + + public static void highlight(SideConfigElement element, ElementRenderers.Context c) { + c.access() + .find( + element + .groups() + .get( + c.access() + .capability(element, SideConfigPresentation.class) + .orElseThrow() + .selection())) + .ifPresent( + target -> + c.access().highlight(c.graphics(), target, ElementHighlight.selection(0xfff0cf55))); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java index b898aaae..6818904c 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java @@ -4,8 +4,7 @@ import net.minecraft.resources.ResourceLocation; import zone.moddev.mc.lib.gui.layout.Rect; -import zone.moddev.mc.lib.gui.model.Element.Edge; -import zone.moddev.mc.lib.gui.model.Element.PopoutKind; +import zone.moddev.mc.lib.gui.model.Edge; /** Coloured edge tabs and full-height drawer backgrounds from the machine GUI reference. */ public final class SideDrawerRenderer { @@ -14,14 +13,6 @@ public final class SideDrawerRenderer { private SideDrawerRenderer() {} - private static int color(PopoutKind kind) { - return switch (kind) { - case INFO -> 0xf1d462; - case WARNING -> 0xe7ac50; - case ERROR -> 0xd85c59; - }; - } - private static void sprite(GuiGraphics graphics, ResourceLocation sprite, Rect rect, int color) { com.mojang.blaze3d.systems.RenderSystem.enableBlend(); com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc(); @@ -32,18 +23,10 @@ private static void sprite(GuiGraphics graphics, ResourceLocation sprite, Rect r com.mojang.blaze3d.systems.RenderSystem.disableBlend(); } - public static void tab(GuiGraphics graphics, Rect tab, PopoutKind kind) { - tab(graphics, tab, color(kind)); - } - public static void tab(GuiGraphics graphics, Rect tab, int color) { sprite(graphics, TAB, tab, color); } - public static void open(GuiGraphics graphics, Rect tab, Rect body, Edge edge, PopoutKind kind) { - open(graphics, tab, body, edge, color(kind)); - } - public static void open(GuiGraphics graphics, Rect tab, Rect body, Edge edge, int color) { int x = Math.min(tab.x(), body.x()); var whole = new Rect(x, body.y(), body.width() + tab.width(), body.height()); diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SlotBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/SlotBehavior.java new file mode 100644 index 00000000..23e641ee --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SlotBehavior.java @@ -0,0 +1,64 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; + +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.gui.ElementAction; +import zone.moddev.mc.lib.gui.model.elements.SlotElement; + +/** Ghost selection and reservation tooltips; real inventory gestures remain vanilla-owned. */ +public class SlotBehavior extends DefaultElementBehavior implements IngredientDropHandler { + private final SlotElement slot; + + public SlotBehavior(SlotElement slot, ElementAccess access) { + super(access); + this.slot = slot; + } + + protected boolean directInteraction() { + return false; + } + + protected Click click(double x, double y, int button) { + if (selectionAction() == null) return Click.PASS; + if (access.pending(slot)) return Click.CONSUME; + var carried = access.carried().copy(); + if (!slot.acceptsSelection(button, carried.copy())) return Click.PASS; + submitSelection(button, carried); + return Click.CONSUME; + } + + public List tooltip(int x, int y) { + return slot.reservationTooltip(access.reservation(slot)); + } + + private ElementAction selectionAction() { + return slot.selectionAction() + .filter(action -> slot.action(action.id()).orElse(null) == action) + .orElse(null); + } + + private boolean submitSelection(int button, ItemStack value) { + var action = selectionAction(); + return action != null + && !access.pending(slot) + && slot.acceptsSelection(button, value.copy()) + && access.submit(action, slot.selection(button, value.copy()).copy()); + } + + public List> targets(IngredientType type, T ingredient) { + if (selectionAction() == null || access.pending(slot)) return List.of(); + var selection = slot.selectionIngredient(type.id(), type.snapshot(ingredient)); + if (selection.isEmpty() || !slot.acceptsSelection(0, selection.get().copy())) return List.of(); + return List.of( + new IngredientDropHandler.Target<>( + "selection", + access.bounds(), + value -> + slot.selectionIngredient(type.id(), type.snapshot(value)) + .map(stack -> submitSelection(0, stack)) + .orElse(false))); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SlotRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/SlotRenderer.java new file mode 100644 index 00000000..c5c66b16 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SlotRenderer.java @@ -0,0 +1,53 @@ +package zone.moddev.mc.lib.gui.client; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.elements.SlotElement; + +/** Client drawing primitives; SlotElement decides backgrounds, icons and ghost visibility. */ +final class SlotRenderer { + private final GhostItems ghosts = new GhostItems(); + + void draw( + GuiGraphics graphics, Rect bounds, SlotElement element, ItemStack contents, ItemStack hint) { + int[] item = element.itemPosition(bounds); + element.render( + new SlotElement.Painter() { + public void background(ResourceLocation sprite, int tint) { + graphics.setColor( + (tint >> 16 & 255) / 255f, (tint >> 8 & 255) / 255f, (tint & 255) / 255f, 1); + graphics.blitSprite(sprite, bounds.x(), bounds.y(), bounds.width(), bounds.height()); + graphics.setColor(1, 1, 1, 1); + } + + public void icon(ResourceLocation sprite, java.util.Optional atlas) { + int size = element.appearance().itemSize(); + if (atlas.isEmpty()) graphics.blitSprite(sprite, item[0], item[1], size, size); + else + graphics.blit( + item[0], + item[1], + 0, + size, + size, + net.minecraft.client.Minecraft.getInstance() + .getTextureAtlas(atlas.get()) + .apply(sprite)); + } + + public void ghost(ItemStack stack, float alpha) { + graphics.pose().pushPose(); + graphics.pose().translate(item[0], item[1], 0); + float scale = element.appearance().itemSize() / 16f; + graphics.pose().scale(scale, scale, 1); + ghosts.render(graphics, stack, 0, 0, alpha); + graphics.pose().popPose(); + } + }, + contents, + hint); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SpriteRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/SpriteRenderer.java new file mode 100644 index 00000000..10ec9ab1 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SpriteRenderer.java @@ -0,0 +1,35 @@ +package zone.moddev.mc.lib.gui.client; + +import zone.moddev.mc.lib.gui.model.elements.SpriteElement; + +/** Executes the layers selected by a sprite element. */ +public final class SpriteRenderer { + private SpriteRenderer() {} + + public static void draw(SpriteElement element, ElementRenderers.Context context) { + var bounds = context.bounds(); + var g = context.graphics(); + for (var layer : element.layers(bounds, context.elapsedTicks(), context.ratio())) { + var clip = layer.clip().offset(bounds.x(), bounds.y()); + if (clip.width() <= 0 || clip.height() <= 0) continue; + g.enableScissor(clip.x(), clip.y(), clip.right(), clip.bottom()); + g.pose().pushPose(); + try { + int tint = layer.tint(); + g.setColor( + ((tint >> 16) & 255) / 255f, + ((tint >> 8) & 255) / 255f, + (tint & 255) / 255f, + ((tint >>> 24) & 255) / 255f); + g.pose().translate(bounds.x() + bounds.width() / 2f, bounds.y() + bounds.height() / 2f, 0); + g.pose().mulPose(new org.joml.Quaternionf().rotationZ(layer.rotation())); + g.pose().translate(-layer.width() / 2f, -layer.height() / 2f, 0); + g.blitSprite(layer.sprite(), 0, 0, layer.width(), layer.height()); + } finally { + g.setColor(1, 1, 1, 1); + g.pose().popPose(); + g.disableScissor(); + } + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java index ed6a6343..683f72f7 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/StatsDrawer.java @@ -5,16 +5,19 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; import net.minecraft.util.FormattedCharSequence; import zone.moddev.mc.lib.gui.layout.*; -import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.runtime.StatsPage; +import zone.moddev.mc.lib.gui.runtime.component.BuiltinMenuComponents; /** Read-only statistics; unit selection and scrolling are entirely local. */ -final class StatsDrawer { - private final MachineScreen screen; - boolean open; - private int unit, scroll; +public final class StatsDrawer extends Drawer { + private boolean open; + private int scroll; + private ResourceLocation selected; + private StatsPage page; private boolean handled; private Rect tab, body; private List lines = List.of(); @@ -25,30 +28,77 @@ final class StatsDrawer { private static final int COLOUR = 0xa7cdd6; private static final float SCALE = .75f; - StatsDrawer(MachineScreen screen) { - this.screen = screen; + public StatsDrawer( + MachineScreen screen, zone.moddev.mc.lib.gui.drawer.DrawerDefinition definition) { + super( + screen, + definition, + (g, x, y) -> { + for (int i = 0; i < 3; i++) + g.fill(x + 2 + i * 5, y + 10 - i * 4, x + 5 + i * 5, y + 15, 0xff30545e); + }, + COLOUR); + } + + public boolean open() { + return open; + } + + public Rect tabBounds() { + layout(); + return tab; + } + + public void close() { + open = false; + } + + public void closeLocal() { + close(); + } + + public void resized() { + handled = false; + } + + public boolean restore(Selection selection) { + return show(); } private Component text(String key) { return Component.translatable("screen.mmdlib.stats." + key); } + /** Stable selected page identity, retained when the host reorders its timescales. */ + public Optional selectedPage() { + layout(); + return Optional.ofNullable(selected); + } + + private List pages() { + return screen.getMenu().components().require(BuiltinMenuComponents.STATS).pages(); + } + + private void updatePage() { + var pages = pages(); + page = + pages.stream() + .filter(p -> p.id().equals(selected)) + .findFirst() + .orElse(pages.isEmpty() ? null : pages.getFirst()); + var next = page == null ? null : page.id(); + if (!Objects.equals(selected, next)) scroll = 0; + selected = next; + } + void layout() { + updatePage(); var panel = screen.panelBounds(); - int y = panel.y() + AutomationLayout.TOP; - for (var e : screen.getMenu().bound.screen().tabs()) - if (e instanceof Element.Popout p && p.edge() == Element.Edge.LEFT) - y = Math.max(y, screen.bounds(e).bottom() + 2); - tab = - new Rect( - panel.x() - AutomationLayout.RAIL, - y, - AutomationLayout.RAIL, - AutomationLayout.TAB_HEIGHT); + tab = super.tabBounds(); if (!open) return; - int width = Math.max(0, Math.min(160, tab.x() - 4)); + int width = Math.max(0, Math.min(160, SideDrawerLayout.room(edge(), tab, screen.width))); var font = Minecraft.getInstance().font; - var text = screen.getMenu().stats(unit); + var text = page == null ? List.of(text("empty")) : page.lines(); var language = net.minecraft.locale.Language.getInstance(); if (!text.equals(cachedText) || width != cachedWidth @@ -67,7 +117,7 @@ void layout() { SideDrawerLayout.body( panel, screen.drawerBottom(), - tab.x() - width, + SideDrawerLayout.bodyX(edge(), tab, width), panel.y() + AutomationLayout.TOP, width, 28 + lines.size() * 9, @@ -76,44 +126,44 @@ void layout() { } boolean show() { - var menu = screen.getMenu(); - if (menu.containerDrawer.pending() || menu.curiosDrawer.pending()) return false; - screen.openStatsDrawer(); + if (screen.drawers().pending(edge())) return false; + openLocal(); open = true; return true; } - void render(GuiGraphics g, int mx, int my) { + public void render(GuiGraphics g, int mx, int my) { layout(); - if (open) SideDrawerRenderer.open(g, tab, body, Element.Edge.LEFT, COLOUR); - else SideDrawerRenderer.tab(g, tab, COLOUR); - // Small bar-chart glyph, drawn at native GUI resolution. - for (int i = 0; i < 3; i++) - g.fill( - tab.x() + 5 + i * 5, tab.y() + 14 - i * 4, tab.x() + 8 + i * 5, tab.y() + 19, 0xff30545e); + drawFrame(g, tab, body, open); if (!open || body.width() < 12 || body.height() < 12) return; var font = Minecraft.getInstance().font; g.enableScissor(body.x() + 5, body.y() + 4, body.right() - 5, body.bottom() - 4); var r = unitBounds(); - g.enableScissor(body.x() + 5, body.y() + 4, Math.max(body.x() + 5, r.x() - 4), body.y() + 19); + g.enableScissor( + body.x() + 5, + body.y() + 4, + Math.max(body.x() + 5, page == null ? body.right() - 5 : r.x() - 4), + body.y() + 19); g.drawString(font, text("title"), body.x() + 6, body.y() + 6, 0xff263e45, false); g.disableScissor(); - boolean hover = r.contains(mx, my); - g.fill(r.x(), r.y(), r.right(), r.bottom(), 0xff486e78); - g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.bottom() - 1, hover ? 0xffc2e3e9 : 0xffa7cdd6); - g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.y() + 2, 0xffd6edf1); - var label = text(new String[] {"per_tick", "per_second", "per_minute"}[unit]); - g.pose().pushPose(); - g.pose() - .translate( - r.x() + Math.max(3, (r.width() - 10 - font.width(label) * SCALE) / 2), r.y() + 4, 0); - g.pose().scale(SCALE, SCALE, 1); - g.drawString(font, label, 0, 0, 0xff263e45, false); - g.pose().popPose(); - int arrowX = r.right() - 6, arrowY = r.y() + 6; - g.fill(arrowX - 1, arrowY - 2, arrowX, arrowY + 3, 0xff41616b); - g.fill(arrowX, arrowY - 1, arrowX + 1, arrowY + 2, 0xff41616b); - g.fill(arrowX + 1, arrowY, arrowX + 2, arrowY + 1, 0xff41616b); + if (page != null) { + boolean hover = r.contains(mx, my); + g.fill(r.x(), r.y(), r.right(), r.bottom(), 0xff486e78); + g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.bottom() - 1, hover ? 0xffc2e3e9 : 0xffa7cdd6); + g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.y() + 2, 0xffd6edf1); + var label = page == null ? Component.empty() : page.label(); + g.pose().pushPose(); + g.pose() + .translate( + r.x() + Math.max(3, (r.width() - 10 - font.width(label) * SCALE) / 2), r.y() + 4, 0); + g.pose().scale(SCALE, SCALE, 1); + g.drawString(font, label, 0, 0, 0xff263e45, false); + g.pose().popPose(); + int arrowX = r.right() - 6, arrowY = r.y() + 6; + g.fill(arrowX - 1, arrowY - 2, arrowX, arrowY + 3, 0xff41616b); + g.fill(arrowX, arrowY - 1, arrowX + 1, arrowY + 2, 0xff41616b); + g.fill(arrowX + 1, arrowY, arrowX + 2, arrowY + 1, 0xff41616b); + } g.enableScissor( body.x() + 5, Math.min(body.bottom() - 4, body.y() + 24), @@ -130,17 +180,21 @@ void render(GuiGraphics g, int mx, int my) { } private Rect unitBounds() { - int width = Math.min(64, Math.max(0, body.width() - 12)); + int labelWidth = + page == null + ? 0 + : (int) Math.ceil(Minecraft.getInstance().font.width(page.label()) * SCALE); + int width = Math.min(Math.max(64, labelWidth + 14), Math.max(0, body.width() - 12)); return new Rect(body.right() - 6 - width, body.y() + 4, width, 14); } - boolean tabContains(double x, double y) { + public boolean tabContains(double x, double y) { layout(); return tab.contains(x, y); } - void tooltip(GuiGraphics g, int x, int y) { - if (tab.contains(x, y) || open && unitBounds().contains(x, y)) + public void tooltip(GuiGraphics g, int x, int y) { + if (tab.contains(x, y) || open && page != null && unitBounds().contains(x, y)) g.renderComponentTooltip( Minecraft.getInstance().font, List.of(text("title"), text("cycle_tip"), text("tip")), @@ -148,7 +202,7 @@ void tooltip(GuiGraphics g, int x, int y) { y); } - boolean contains(double x, double y) { + public boolean contains(double x, double y) { layout(); return tab.contains(x, y) || open @@ -156,11 +210,7 @@ boolean contains(double x, double y) { || SideDrawerLayout.rail(tab, body).contains(x, y) && !otherTab(x, y)); } - private boolean otherTab(double x, double y) { - return screen.onOtherLeftDrawerTab(x, y); - } - - boolean click(double x, double y, int button) { + public boolean click(double x, double y, int button) { if (!contains(x, y)) { if ((button == 0 || button == 1) && otherTab(x, y)) open = false; return false; @@ -170,38 +220,43 @@ boolean click(double x, double y, int button) { if (tab.contains(x, y)) { if (open) open = false; else show(); - screen.rememberStatsIntent(); + remember(open, selection()); } else if (open && unitBounds().contains(x, y)) { - unit = Math.floorMod(unit + (button == 0 ? 1 : -1), 3); + var pages = pages(); + if (pages.isEmpty()) return true; + int index = 0; + for (int i = 0; i < pages.size(); i++) if (pages.get(i).id().equals(selected)) index = i; + selected = pages.get(Math.floorMod(index + (button == 0 ? 1 : -1), pages.size())).id(); + updatePage(); scroll = 0; } return true; } - boolean release() { + public boolean release(double x, double y, int button) { boolean result = handled; handled = false; return result; } - boolean drag() { + public boolean drag(double x, double y, int button, double dx, double dy) { return handled; } - boolean key(int key) { + public boolean key(int key, int scan, int modifiers) { if (key != 256 || !open) return false; open = false; - screen.rememberStatsIntent(); + remember(open, selection()); return true; } - boolean scroll(double x, double y, double amount) { + public boolean scroll(double x, double y, double amount) { if (!open || !contains(x, y)) return false; scroll += amount < 0 ? 18 : -18; return true; } - List exclusions() { + public List exclusions() { layout(); return open ? List.of(tab, body, SideDrawerLayout.rail(tab, body)) : List.of(tab); } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/TextControlRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/TextControlRenderer.java new file mode 100644 index 00000000..15f784e5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/TextControlRenderer.java @@ -0,0 +1,39 @@ +package zone.moddev.mc.lib.gui.client; + +import static zone.moddev.mc.lib.gui.client.ElementDrawing.*; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.elements.*; + +/** Text and small selection controls. State comes only from synchronized display values. */ +public final class TextControlRenderer { + private TextControlRenderer() {} + + public static void label(LabelElement element, ElementRenderers.Context c) { + caption(c, element.text(), c.bounds().x(), c.bounds().y()); + } + + public static void checkbox(CheckboxElement element, ElementRenderers.Context c) { + var r = c.bounds(); + sprite( + c.graphics(), + Boolean.TRUE.equals(c.value()) ? "checkbox_selected" : "checkbox", + new Rect(r.x(), r.y(), 10, 10)); + caption(c, element.text(), r.x() + 14, r.y() + 1); + } + + public static boolean selected(RadioElement element, ElementRenderAccess access) { + return OptionSelection.parent(element, access) + .map(selection -> selection.selected(element)) + .orElse(false); + } + + public static void radio(RadioElement element, ElementRenderers.Context c) { + var r = c.bounds(); + sprite( + c.graphics(), + selected(element, c.access()) ? "radio_selected" : "radio", + new Rect(r.x(), r.y(), 10, 10)); + caption(c, element.text(), r.x() + 14, r.y() + 1); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ValueLabelProvider.java b/src/main/java/zone/moddev/mc/lib/gui/client/ValueLabelProvider.java new file mode 100644 index 00000000..fd2cd4e8 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ValueLabelProvider.java @@ -0,0 +1,10 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.Optional; + +import zone.moddev.mc.lib.gui.model.Text; + +/** Optional human-readable label for a behavior's current value. */ +public interface ValueLabelProvider { + Optional valueLabel(); +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetAccess.java b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetAccess.java index 0feca586..a144a614 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetAccess.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetAccess.java @@ -2,11 +2,11 @@ import zone.moddev.mc.lib.gui.WidgetAction; import zone.moddev.mc.lib.gui.layout.Rect; -import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.elements.CustomElement; /** Live, client-only access for one widget instance. Values are server snapshots, never getters. */ public interface WidgetAccess { - Element.Custom element(); + CustomElement element(); Rect bounds(); diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInput.java b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInput.java index 1d0008db..f75c6af6 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInput.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInput.java @@ -1,209 +1,19 @@ package zone.moddev.mc.lib.gui.client; -import java.util.*; +import java.util.List; import java.util.function.Function; import java.util.function.Predicate; import zone.moddev.mc.lib.gui.layout.Rect; -import zone.moddev.mc.lib.gui.model.Element.Custom; - -/** - * Focus, capture and lifecycle routing, independent of vanilla's inventory key handling. Call - * {@link #update()} once before routing an input event, tick or frame. All operations in that event - * share its visibility snapshot. - */ -public final class WidgetInput { - private final List order; - private final Map instances = new IdentityHashMap<>(); - private final Map shown = new IdentityHashMap<>(); - private final Predicate visible; - private final Function bounds; - private Custom focused, captured; - private int capturedButton = -1, cancelledButton = -1; - private boolean closed; +import zone.moddev.mc.lib.gui.model.elements.CustomElement; +/** Compatibility facade using the same generic input router as built-in elements. */ +public final class WidgetInput extends ElementInput { public WidgetInput( - List elements, - Function factory, - Predicate visible, - Function bounds) { - order = List.copyOf(elements); - this.visible = visible; - this.bounds = bounds; - for (var element : order) - instances.put(element, Objects.requireNonNull(factory.apply(element))); - var unique = Collections.newSetFromMap(new IdentityHashMap()); - for (var instance : instances.values()) - if (!unique.add(instance)) - throw new IllegalArgumentException("Widget factories must return fresh instances"); - } - - public WidgetInstance instance(Custom element) { - return instances.get(element); - } - - public Custom focused() { - return focused; - } - - public boolean capturing() { - return captured != null; - } - - /** A new physical press ends any suppression left over from a cancelled earlier gesture. */ - public void beginClick() { - if (captured == null) cancelledButton = -1; - } - - public void update() { - if (closed) return; - for (var element : order) { - boolean now = visible.test(element); - Boolean previous = shown.put(element, now); - if (!Objects.equals(previous, now)) { - if (!now) { - if (captured == element) cancelCapture(); - else instances.get(element).cancelInteraction(); - if (focused == element) focus(null); - } - instances.get(element).visibilityChanged(now); - } - } - } - - private boolean isShown(Custom element) { - return Boolean.TRUE.equals(shown.get(element)); - } - - public void tick() { - if (!closed) for (var e : order) if (isShown(e)) instances.get(e).tick(); - } - - public void blurUnless(Object element) { - if (focused != element) focus(null); - } - - public void clearFocus() { - focus(null); - } - - private void focus(Custom element) { - if (focused == element) return; - if (focused != null) instances.get(focused).setFocused(false); - focused = element; - if (focused != null) instances.get(focused).setFocused(true); - } - - /** Called in screen hit order, after checking drawers and other elements above this one. */ - public boolean click(Custom element, double x, double y, int button) { - if (closed || !isShown(element)) return false; - if (captured != null) return true; - cancelledButton = -1; - var instance = instances.get(element); - var result = instance.mouseClicked(x, y, button); - if (result == WidgetInstance.Click.PASS) return false; - focus(instance.focusable() ? element : null); - if (result == WidgetInstance.Click.CAPTURE) { - captured = element; - capturedButton = button; - } - return true; - } - - public boolean drag(double x, double y, int button, double dx, double dy) { - if (closed) return false; - if (captured == null) return button == cancelledButton; - if (button == capturedButton) instances.get(captured).mouseDragged(x, y, button, dx, dy); - return true; - } - - public boolean release(double x, double y, int button) { - if (closed) return false; - if (button == cancelledButton) { - cancelledButton = -1; - return true; - } - // A release belongs to the control that captured the press, not the current hover target. - if (captured == null) return false; - if (button == capturedButton) { - var instance = instances.get(captured); - captured = null; - capturedButton = -1; - instance.mouseReleased(x, y, button); - } - return true; - } - - public boolean scroll(double x, double y, double dx, double dy) { - if (closed) return false; - if (captured != null) return true; - for (var e : order.reversed()) - if (isShown(e) - && bounds.apply(e).contains(x, y) - && instances.get(e).mouseScrolled(x, y, dx, dy)) return true; - return false; - } - - public boolean keyPressed(int key, int scan, int modifiers) { - if (closed) return false; - if (key == 258) { // Tab / Shift+Tab. - cancelCapture(); - var candidates = - order.stream().filter(this::isShown).filter(e -> instances.get(e).focusable()).toList(); - if (candidates.isEmpty()) return false; - int index = -1; - for (int i = 0; i < candidates.size(); i++) if (candidates.get(i) == focused) index = i; - int direction = (modifiers & 1) != 0 ? -1 : 1; - focus( - candidates.get( - index < 0 - ? (direction > 0 ? 0 : candidates.size() - 1) - : Math.floorMod(index + direction, candidates.size()))); - return true; - } - if (key == 256 && captured != null) { - cancelCapture(); - return true; - } - if (focused == null) return false; - boolean handled = instances.get(focused).keyPressed(key, scan, modifiers); - if (key == 256 && !handled) focus(null); - // Even unhandled inventory shortcuts must not reach slots while an editor has focus. - return true; - } - - public boolean keyReleased(int key, int scan, int modifiers) { - if (closed) return false; - if (focused == null) return false; - instances.get(focused).keyReleased(key, scan, modifiers); - return true; - } - - public boolean charTyped(char character, int modifiers) { - if (closed) return false; - if (focused == null) return false; - instances.get(focused).charTyped(character, modifiers); - return true; - } - - private void cancelCapture() { - if (captured == null) return; - cancelledButton = capturedButton; - instances.get(captured).cancelInteraction(); - captured = null; - capturedButton = -1; - } - - public void resized() { - cancelCapture(); - instances.values().forEach(WidgetInstance::resized); - } - - public void removed() { - if (closed) return; - closed = true; - cancelCapture(); - focus(null); - instances.values().forEach(WidgetInstance::removed); + List elements, + Function factory, + Predicate visible, + Function bounds) { + super(elements, factory, visible, bounds); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInstance.java b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInstance.java index 5ac749ef..5d32ac9e 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInstance.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInstance.java @@ -1,62 +1,4 @@ package zone.moddev.mc.lib.gui.client; -/** - * One widget's state for one open screen. Factories must return fresh instances. Coordinates are - * absolute GUI pixels. Only a captured press receives dragging and release, even outside its - * bounds. Hidden widgets lose focus and capture; resize preserves the instance and focus but - * cancels capture without committing. - */ -public interface WidgetInstance extends WidgetRenderer { - enum Click { - PASS, - CONSUME, - CAPTURE - } - - default boolean focusable() { - return false; - } - - default void setFocused(boolean focused) {} - - default Click mouseClicked(double x, double y, int button) { - return Click.PASS; - } - - default boolean mouseDragged(double x, double y, int button, double dx, double dy) { - return false; - } - - default boolean mouseReleased(double x, double y, int button) { - return false; - } - - default boolean mouseScrolled(double x, double y, double dx, double dy) { - return false; - } - - default boolean keyPressed(int key, int scan, int modifiers) { - return false; - } - - default boolean keyReleased(int key, int scan, int modifiers) { - return false; - } - - default boolean charTyped(char character, int modifiers) { - return false; - } - - default void tick() {} - - default void resized() {} - - default void visibilityChanged(boolean visible) {} - - default void cancelInteraction() {} - - /** Values have already been updated. False also covers an unconfirmed edit after timeout. */ - default void actionResult(boolean accepted) {} - - default void removed() {} -} +/** Existing widget contract combining input and rendering. */ +public interface WidgetInstance extends ElementBehavior, WidgetRenderer {} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderer.java index 7c22e51b..72ebb3c8 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderer.java @@ -3,7 +3,7 @@ import net.minecraft.client.gui.GuiGraphics; import zone.moddev.mc.lib.gui.layout.Rect; -import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.elements.CustomElement; /** * Client-only drawing for a registered custom widget. Rendering is clipped to the widget bounds. @@ -22,7 +22,7 @@ public interface WidgetRenderer { */ record Context( GuiGraphics graphics, - Element.Custom widget, + CustomElement widget, Rect bounds, Object value, Object maximum, @@ -31,7 +31,7 @@ record Context( float partialTick) { /** A clamped 0..1 numeric ratio, including large integers; unavailable values give zero. */ public double ratio() { - return ScreenText.ratio(value, maximum); + return widget.fillRatio(value, maximum); } public boolean hovered() { diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderers.java b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderers.java index 34b81ff4..a75334c3 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderers.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderers.java @@ -11,11 +11,8 @@ import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.fml.event.lifecycle.FMLLoadCompleteEvent; -import zone.moddev.mc.lib.gui.Screens; import zone.moddev.mc.lib.gui.WidgetTypes; -import zone.moddev.mc.lib.gui.load.ScreenException; -import zone.moddev.mc.lib.gui.model.Element; -import zone.moddev.mc.lib.gui.model.Screen; +import zone.moddev.mc.lib.gui.model.elements.CustomElement; /** Client-only renderer registry. Register during client mod construction or client setup. */ public final class WidgetRenderers { @@ -54,16 +51,31 @@ public synchronized WidgetFactory require(ResourceLocation type) { return renderer; } - /** Checks a screen before it opens, naming the file and element line for missing renderers. */ - public void validate(Screen screen) { - for (var element : screen.elements()) - if (element instanceof Element.Custom custom) { - try { - require(custom.type()); - } catch (IllegalArgumentException ex) { - throw new ScreenException(screen.source(), custom.common().line(), ex.getMessage(), ex); - } + /** Adapts widget factories to the ordinary element renderer and validation lifecycle. */ + public ElementRenderers.Renderer elementRenderer() { + return new ElementRenderers.Renderer<>() { + @Override + public void validate(CustomElement element) { + require(element.type()); } + + @Override + public void render(CustomElement element, ElementRenderers.Context c) { + c.access() + .capability(element, WidgetRenderer.class) + .orElseThrow() + .render( + new WidgetRenderer.Context( + c.graphics(), + element, + c.bounds(), + c.value(), + c.maximum(), + c.mouseX(), + c.mouseY(), + c.partialTick())); + } + }; } /** Freezes registration and catches renderer IDs with no common-side definition. */ @@ -76,11 +88,7 @@ public synchronized void freeze(WidgetTypes types) { public static final class Registration { @SubscribeEvent public static void complete(FMLLoadCompleteEvent event) { - event.enqueueWork( - () -> { - GLOBAL.freeze(WidgetTypes.GLOBAL); - Screens.all().values().forEach(bound -> GLOBAL.validate(bound.screen())); - }); + event.enqueueWork(() -> GLOBAL.freeze(WidgetTypes.GLOBAL)); } } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/drawer/BuiltinDrawers.java b/src/main/java/zone/moddev/mc/lib/gui/drawer/BuiltinDrawers.java new file mode 100644 index 00000000..11488696 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/drawer/BuiltinDrawers.java @@ -0,0 +1,108 @@ +package zone.moddev.mc.lib.gui.drawer; + +import java.util.*; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.layout.ElementLayouts; +import zone.moddev.mc.lib.gui.model.Edge; +import zone.moddev.mc.lib.gui.model.containers.SideTabElement; +import zone.moddev.mc.lib.gui.model.elements.PopoutElement; + +/** Built-in providers use the same registration path as mods. */ +public final class BuiltinDrawers { + public static final ResourceLocation SIDES = id("side_config"), + REDSTONE = id("redstone"), + STATS = id("stats"), + BACKPACK = id("backpack"), + CURIOS = id("curios"), + POPOUT = id("popout"), + SIDE_TAB = id("side_tab"); + + private BuiltinDrawers() {} + + private static ResourceLocation id(String name) { + return ResourceLocation.fromNamespaceAndPath("mmdlib", name); + } + + static void register(DrawerRegistry registry) { + registry.register( + SIDES, + c -> + c.enabled().contains(SIDES) || c.host() != null && c.host().screenAutomation() != null + ? List.of( + new DrawerDefinition( + SIDES, + "automation", + DrawerPlacement.top(Edge.RIGHT, 0), + Optional.empty(), + 34, + true)) + : List.of()); + registry.register( + REDSTONE, + c -> + c.enabled().contains(REDSTONE) || c.host() != null && c.host().screenRedstone() != null + ? List.of( + new DrawerDefinition(REDSTONE, "redstone", DrawerPlacement.top(Edge.RIGHT, 10))) + : List.of()); + registry.register( + STATS, + c -> + c.host() == null + ? List.of() + : List.of( + new DrawerDefinition(STATS, "stats", DrawerPlacement.top(Edge.LEFT, 40)))); + registry.register( + BACKPACK, + c -> + c.host() == null || !c.screen().playerInventory() + ? List.of() + : List.of( + new DrawerDefinition( + BACKPACK, "backpack", DrawerPlacement.bottom(Edge.LEFT, 0)))); + registry.register( + CURIOS, + c -> + c.host() == null || !c.screen().playerInventory() + ? List.of() + : List.of( + new DrawerDefinition(CURIOS, "curios", DrawerPlacement.bottom(Edge.LEFT, 10)))); + registry.register( + POPOUT, + c -> { + var result = new ArrayList(); + for (var tab : c.screen().tabs()) + if (tab instanceof PopoutElement element) + result.add( + new DrawerDefinition( + POPOUT, + element.drawerId(c.screen()), + DrawerPlacement.top(element.edge(), 30), + Optional.of(element), + 0, + true)); + return result; + }); + registry.register( + SIDE_TAB, + c -> { + var result = new ArrayList(); + int index = 0; + for (var tab : c.screen().tabs()) + if (tab instanceof SideTabElement element) { + var size = ElementLayouts.size(element, c.text()); + result.add( + new DrawerDefinition( + SIDE_TAB, + "side-tab:" + index++, + new DrawerPlacement( + element.edge(), DrawerPlacement.Anchor.TOP, 20, size[0], size[1], 4, 2), + Optional.of(element), + 0, + false)); + } + return result; + }); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerDefinition.java b/src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerDefinition.java new file mode 100644 index 00000000..dde03ddd --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerDefinition.java @@ -0,0 +1,30 @@ +package zone.moddev.mc.lib.gui.drawer; + +import java.util.Objects; +import java.util.Optional; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.model.Element; + +/** Common-side drawer declaration; no client classes, mutable state or storage snapshots. */ +public record DrawerDefinition( + ResourceLocation type, + String id, + DrawerPlacement placement, + Optional element, + int toolbarWidth, + boolean interactive) { + public DrawerDefinition { + Objects.requireNonNull(type); + Objects.requireNonNull(id); + Objects.requireNonNull(placement); + Objects.requireNonNull(element); + if (id.isBlank() || toolbarWidth < 0 || toolbarWidth > 8192) + throw new IllegalArgumentException("Invalid drawer definition"); + } + + public DrawerDefinition(ResourceLocation type, String id, DrawerPlacement placement) { + this(type, id, placement, Optional.empty(), 0, true); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerPlacement.java b/src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerPlacement.java new file mode 100644 index 00000000..3ad62936 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerPlacement.java @@ -0,0 +1,38 @@ +package zone.moddev.mc.lib.gui.drawer; + +import java.util.Objects; + +import zone.moddev.mc.lib.gui.model.Edge; + +/** Shared tab geometry. Smaller order values sit closest to the chosen anchor. */ +public record DrawerPlacement( + Edge edge, Anchor anchor, int order, int width, int height, int overlap, int gap) { + public static final int DEFAULT_WIDTH = 22, DEFAULT_HEIGHT = 24, DEFAULT_GAP = 2; + + public enum Anchor { + TOP, + BOTTOM + } + + public DrawerPlacement { + Objects.requireNonNull(edge); + Objects.requireNonNull(anchor); + if (width < 1 + || height < 1 + || width > 8192 + || height > 8192 + || overlap < 0 + || overlap >= width + || gap < 0 + || gap > 8192) throw new IllegalArgumentException("Invalid drawer tab geometry"); + } + + public static DrawerPlacement top(Edge edge, int order) { + return new DrawerPlacement( + edge, Anchor.TOP, order, DEFAULT_WIDTH, DEFAULT_HEIGHT, 0, DEFAULT_GAP); + } + + public static DrawerPlacement bottom(Edge edge, int order) { + return new DrawerPlacement(edge, Anchor.BOTTOM, order, DEFAULT_WIDTH, DEFAULT_HEIGHT, 0, 0); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerRegistry.java b/src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerRegistry.java new file mode 100644 index 00000000..09f58f0d --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/drawer/DrawerRegistry.java @@ -0,0 +1,94 @@ +package zone.moddev.mc.lib.gui.drawer; + +import java.util.*; +import java.util.function.Function; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.layout.TextMeasure; +import zone.moddev.mc.lib.gui.model.Screen; +import zone.moddev.mc.lib.gui.runtime.ScreenHost; + +/** Mod-extensible common-side providers. Register during mod construction on both sides. */ +public final class DrawerRegistry { + public static final DrawerRegistry GLOBAL = builtins(); + + public record Context( + Screen screen, TextMeasure text, ScreenHost host, Set enabled) { + public Context { + Objects.requireNonNull(screen); + Objects.requireNonNull(text); + enabled = Set.copyOf(enabled); + } + + public Context(Screen screen, TextMeasure text, ScreenHost host) { + this(screen, text, host, Set.of()); + } + } + + private final Map>> providers = + new TreeMap<>(Comparator.comparing(ResourceLocation::toString)); + + public static DrawerRegistry builtins() { + var registry = new DrawerRegistry(); + BuiltinDrawers.register(registry); + return registry; + } + + public synchronized void register( + ResourceLocation type, Function> provider) { + Objects.requireNonNull(type); + Objects.requireNonNull(provider); + if (providers.putIfAbsent(type, provider) != null) + throw new IllegalArgumentException("Duplicate drawer provider: " + type); + } + + public synchronized void replace( + ResourceLocation type, Function> provider) { + if (!providers.containsKey(type)) + throw new IllegalArgumentException("Unknown drawer provider: " + type); + providers.put(type, Objects.requireNonNull(provider)); + } + + public synchronized List resolve(Context context) { + var result = new ArrayList(); + var ids = new HashSet(); + var elements = + Collections.newSetFromMap( + new IdentityHashMap()); + for (var entry : providers.entrySet()) + for (var definition : List.copyOf(entry.getValue().apply(context))) { + if (!definition.type().equals(entry.getKey())) + throw new IllegalArgumentException( + "Drawer provider returned another type: " + definition.type()); + if (!ids.add(definition.placement().edge() + "/" + definition.id())) + throw new IllegalArgumentException("Duplicate drawer: " + definition.id()); + definition + .element() + .ifPresent( + element -> { + if (context.screen().tabs().stream().noneMatch(tab -> tab == element) + || !elements.add(element)) + throw new IllegalArgumentException( + "Drawer element must be a unique screen tab"); + }); + result.add(definition); + if (result.size() > 4096) + throw new IllegalArgumentException("Too many drawer declarations"); + } + for (var tab : context.screen().tabs()) + if (!elements.contains(tab)) + throw new IllegalArgumentException("No registered drawer layout for tab: " + tab); + result.sort(Comparator.comparingInt(definition -> definition.placement().order())); + return List.copyOf(result); + } + + /** Adapter for the older standalone layout overloads. Runtime menus use the host context. */ + public static Context legacy( + Screen screen, TextMeasure text, boolean automation, boolean redstone) { + var enabled = new HashSet(); + if (automation) enabled.add(BuiltinDrawers.SIDES); + if (redstone) enabled.add(BuiltinDrawers.REDSTONE); + return new Context(screen, text, null, enabled); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/AutomationLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/AutomationLayout.java index 094a2669..683726c4 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/layout/AutomationLayout.java +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/AutomationLayout.java @@ -2,10 +2,15 @@ import static zone.moddev.mc.lib.api.automation.AutomationInventory.COLUMNS; +import zone.moddev.mc.lib.gui.drawer.DrawerPlacement; +import zone.moddev.mc.lib.gui.model.containers.AutomationInventoryElement; + /** Shared geometry for automation rendering, real menu slots, hit boxes and exclusions. */ public final class AutomationLayout { - public static final int WIDTH = 112, RAIL = ScreenLayout.POPOUT_TAB; - public static final int TOP = ScreenLayout.TAB_PADDING, TAB_HEIGHT = ScreenLayout.POPOUT_HEIGHT; + private static final AutomationInventoryElement SLOTS = new AutomationInventoryElement("addons"); + public static final int WIDTH = 112, RAIL = DrawerPlacement.DEFAULT_WIDTH; + public static final int TOP = SideDrawerLayout.MARGIN, + TAB_HEIGHT = DrawerPlacement.DEFAULT_HEIGHT; public static final int SELECTOR_WIDTH = 22, SELECTOR_PITCH = 16, SELECTOR_LIMIT = 8; public static final int EDITOR_X = SELECTOR_WIDTH + 2, EDITOR_Y = 123, PADDING = 4; public static final int EDITOR_PADDING = 4, FILTER_ITEM_SIZE = 12; @@ -19,29 +24,26 @@ public static Rect face(int side) { } public static Rect addon(int column) { - return new Rect(26 + column * 20, 74, 18, 18); + return SLOTS.drawerSlotBounds(column); } public static Rect filter(int column) { - return new Rect(28 + column * 20, 96, 14, 14); + return SLOTS.drawerSlotBounds(COLUMNS + column); } public static Rect filterToggle(int column) { - var filter = filter(column); - return new Rect(filter.x(), filter.bottom() + 2, filter.width(), 7); + return SLOTS.filterToggleBounds(column); } /** Item bounds inside a frame, relative to the drawer body; addon slots precede filters. */ public static Rect item(int subSlot) { - var frame = subSlot < COLUMNS ? addon(subSlot) : filter(subSlot - COLUMNS); - int size = subSlot < COLUMNS ? 16 : FILTER_ITEM_SIZE; - return new Rect(frame.x() + 1, frame.y() + 1, size, size); + var element = SLOTS.slots().get(subSlot); + var position = element.itemPosition(SLOTS.drawerSlotBounds(subSlot)); + int size = element.appearance().itemSize(); + return new Rect(position[0], position[1], size, size); } public static int contentHeight(boolean filterSlot, boolean configuredFilter) { - return (configuredFilter - ? filterToggle(0).bottom() - : filterSlot ? filter(0).bottom() : addon(0).bottom()) - + PADDING; + return SLOTS.contentHeight(filterSlot, configuredFilter); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/ContainerDrawerLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/ContainerDrawerLayout.java index 6a2b2caf..b4a4e83f 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/layout/ContainerDrawerLayout.java +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/ContainerDrawerLayout.java @@ -1,44 +1,77 @@ package zone.moddev.mc.lib.gui.layout; -/** Six-column left drawer filling the shared inset panel height, with a tab beside the hotbar. */ -public record ContainerDrawerLayout(Rect tab, Rect body, int columns, int rows, boolean equipment) { - private static final int BODY_WIDTH = 6 * 18 + 12; +/** Container page geometry from provider settings, within the shared inset drawer body. */ +public record ContainerDrawerLayout( + Rect tab, + Rect body, + int columns, + int rows, + zone.moddev.mc.lib.gui.runtime.ContainerPolicy.Layout policy) { + public ContainerDrawerLayout(Rect tab, Rect body, int columns, int rows, boolean equipment) { + this(tab, body, columns, rows, preset(equipment)); + } + + private static zone.moddev.mc.lib.gui.runtime.ContainerPolicy.Layout preset(boolean equipment) { + return (equipment + ? zone.moddev.mc.lib.gui.runtime.ContainerPolicy.EQUIPMENT + : zone.moddev.mc.lib.gui.runtime.ContainerPolicy.STORAGE) + .settings() + .layout(); + } public ContainerDrawerLayout(Rect tab, Rect body, int columns, int rows) { this(tab, body, columns, rows, false); } - public static ContainerDrawerLayout equipment( - Rect panel, int bottom, int slots, boolean backpack) { - var base = of(panel, bottom, slots); - var tab = base.tab().offset(0, backpack ? -24 : 0); - var body = base.body(); - int height = body.height(); - int rows = Math.min(27, Math.max(0, (height - 56) / 22)); - if (tab.x() - BODY_WIDTH < 4 || rows == 0) - return new ContainerDrawerLayout(tab, new Rect(0, 0, 0, 0), 0, 0, true); - rows = Math.min(rows, Math.max(1, (slots + 1) / 2)); - return new ContainerDrawerLayout(tab, body, 2, rows, true); + /** Content geometry consumes the tab allocated by the drawer registry. */ + public static ContainerDrawerLayout of( + Rect panel, int bottom, int slots, Rect tab, boolean equipment) { + return of( + panel, + bottom, + slots, + tab, + equipment, + zone.moddev.mc.lib.gui.model.Edge.LEFT, + Integer.MAX_VALUE); + } + + public static ContainerDrawerLayout of( + Rect panel, + int bottom, + int slots, + Rect tab, + boolean equipment, + zone.moddev.mc.lib.gui.model.Edge edge, + int screenWidth) { + return of(panel, bottom, slots, tab, preset(equipment), edge, screenWidth); } - public static ContainerDrawerLayout of(Rect panel, int bottom, int slots) { - var tab = - new Rect( - Math.max(0, panel.x() - 22), - Math.max(SideDrawerLayout.top(panel), SideDrawerLayout.bottom(panel, bottom) - 24), - 22, - 24); - int columns = 6; - int width = BODY_WIDTH; + public static ContainerDrawerLayout of( + Rect panel, + int bottom, + int slots, + Rect tab, + zone.moddev.mc.lib.gui.runtime.ContainerPolicy.Layout policy, + zone.moddev.mc.lib.gui.model.Edge edge, + int screenWidth) { var body = SideDrawerLayout.body( - panel, bottom, tab.x() - width, SideDrawerLayout.top(panel), width, Integer.MAX_VALUE); - int height = body.height(); - int rows = Math.min(9, Math.max(0, (height - 44) / 18)); - if (tab.x() - width < 4 || rows == 0) - return new ContainerDrawerLayout(tab, new Rect(0, 0, 0, 0), 0, 0); + panel, + bottom, + SideDrawerLayout.bodyX(edge, tab, policy.width()), + SideDrawerLayout.top(panel), + policy.width(), + Integer.MAX_VALUE); + int columns = policy.columns(); + int rows = + Math.min( + policy.maxRows(), + Math.max(0, (body.height() - policy.cellY() - policy.footer()) / policy.strideY())); + if (SideDrawerLayout.room(edge, tab, screenWidth) < policy.width() || rows == 0) + return new ContainerDrawerLayout(tab, new Rect(0, 0, 0, 0), 0, 0, policy); rows = Math.min(rows, Math.max(1, (slots + columns - 1) / columns)); - return new ContainerDrawerLayout(tab, body, columns, rows); + return new ContainerDrawerLayout(tab, body, columns, rows, policy); } public int pageSize() { @@ -50,11 +83,7 @@ public Rect rail() { } public Rect cell(int visible) { - if (equipment) - return new Rect( - body.right() - 44 + visible % 2 * 20, body.y() + 34 + visible / 2 * 22, 18, 18); - return new Rect( - body.x() + 6 + visible % columns * 18, body.y() + 22 + visible / columns * 18, 18, 18); + return policy.cell(visible).offset(body.x(), body.y()); } public Rect previous() { diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/DrawerTabs.java b/src/main/java/zone/moddev/mc/lib/gui/layout/DrawerTabs.java new file mode 100644 index 00000000..fff6fc04 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/DrawerTabs.java @@ -0,0 +1,74 @@ +package zone.moddev.mc.lib.gui.layout; + +import java.util.*; +import java.util.function.Predicate; + +import zone.moddev.mc.lib.gui.drawer.*; +import zone.moddev.mc.lib.gui.model.Edge; + +/** Type-agnostic packing of registered tabs on either edge and anchor. */ +public final class DrawerTabs { + private DrawerTabs() {} + + private static int height(List entries) { + int result = 0; + for (var entry : entries) result += entry.placement().height() + entry.placement().gap(); + return result - (entries.isEmpty() ? 0 : entries.getLast().placement().gap()); + } + + private static List select( + List entries, Edge edge, DrawerPlacement.Anchor anchor) { + return entries.stream() + .filter(d -> d.placement().edge() == edge && d.placement().anchor() == anchor) + .sorted(Comparator.comparingInt(d -> d.placement().order())) + .toList(); + } + + public static int minimumHeight(List entries) { + int height = 0; + for (var edge : Edge.values()) { + var top = select(entries, edge, DrawerPlacement.Anchor.TOP); + var bottom = select(entries, edge, DrawerPlacement.Anchor.BOTTOM); + height = + Math.max( + height, + height(top) + + height(bottom) + + 2 * SideDrawerLayout.MARGIN + + (top.isEmpty() || bottom.isEmpty() ? 0 : top.getLast().placement().gap())); + } + return height; + } + + public static Map place( + List entries, Rect panel, int bottom, Predicate include) { + var active = entries.stream().filter(include).toList(); + var result = new LinkedHashMap(); + for (var edge : Edge.values()) { + int y = SideDrawerLayout.top(panel); + var top = select(active, edge, DrawerPlacement.Anchor.TOP); + for (var definition : top) { + var p = definition.placement(); + result.put(definition, rect(panel, p, y)); + y += p.height() + p.gap(); + } + var lower = select(active, edge, DrawerPlacement.Anchor.BOTTOM); + int end = Math.max(SideDrawerLayout.bottom(panel, bottom), y + height(lower)); + for (var definition : lower) { + var p = definition.placement(); + end -= p.height(); + result.put(definition, rect(panel, p, end)); + end -= p.gap(); + } + } + return Collections.unmodifiableMap(result); + } + + private static Rect rect(Rect panel, DrawerPlacement p, int y) { + return new Rect( + p.edge() == Edge.LEFT ? panel.x() - p.width() + p.overlap() : panel.right() - p.overlap(), + y, + p.width(), + p.height()); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/ElementFragmentLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/ElementFragmentLayout.java new file mode 100644 index 00000000..19375327 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/ElementFragmentLayout.java @@ -0,0 +1,82 @@ +package zone.moddev.mc.lib.gui.layout; + +import java.util.*; +import java.util.function.Predicate; + +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.containers.SelectiveContainer; +import zone.moddev.mc.lib.gui.sync.ElementStateView; + +/** A reusable element tree measured and positioned independently of a machine screen. */ +public record ElementFragmentLayout(List elements, Map bounds, int height) { + public static ElementFragmentLayout of(Element root, int width, TextMeasure text) { + return of(root, width, text, ElementStateView.DEFAULTS); + } + + public static ElementFragmentLayout of( + Element root, int width, TextMeasure text, ElementStateView state) { + var context = new LayoutContext(text, state); + prepare(root, context, Collections.newSetFromMap(new IdentityHashMap<>()), 0); + return arrange(root, width, context); + } + + private static void prepare( + Element element, LayoutContext context, Set seen, int depth) { + if (depth > 8 || seen.size() >= 128 || !seen.add(element)) + throw new IllegalArgumentException("Invalid/oversized fragment tree"); + element.prepareLayout(context); + for (var child : element.children()) prepare(child, context, seen, depth + 1); + } + + /** Parent selection and host visibility compose before viewport clipping. */ + public Map visibility(ElementStateView state, Predicate allowed) { + var shown = new IdentityHashMap(); + for (var element : elements) { + boolean visible = shown.getOrDefault(element, true) && allowed.test(element); + shown.put(element, visible); + for (var child : element.children()) + shown.put( + child, + visible + && (!(element instanceof SelectiveContainer selective) + || selective.childVisible(child, state))); + } + return Collections.unmodifiableMap(shown); + } + + private static ElementFragmentLayout arrange(Element root, int width, TextMeasure text) { + if (width < 1) throw new IllegalArgumentException("Fragment width"); + var bounds = new IdentityHashMap(); + var elements = new ArrayList(); + int[] size = root.measure(text, width); + if (size.length != 2 || size[0] < 0 || size[1] < 0) + throw new IllegalArgumentException("Fragment size"); + place( + root, + new Rect(0, 0, root.stretchX() ? width : Math.min(width, size[0]), size[1]), + text, + elements, + bounds, + 0); + return new ElementFragmentLayout( + List.copyOf(elements), Collections.unmodifiableMap(bounds), size[1]); + } + + private static void place( + Element element, + Rect rect, + TextMeasure text, + List elements, + Map bounds, + int depth) { + if (depth > 8 || elements.size() >= 128 || bounds.containsKey(element)) + throw new IllegalArgumentException("Invalid/oversized fragment tree"); + bounds.put(element, rect); + elements.add(element); + var children = element.childLayout(new Rect(0, 0, rect.width(), rect.height()), text); + for (var child : element.children()) { + var position = Objects.requireNonNull(children.get(child), "Missing fragment child bounds"); + place(child, position.offset(rect.x(), rect.y()), text, elements, bounds, depth + 1); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/ElementLayouts.java b/src/main/java/zone/moddev/mc/lib/gui/layout/ElementLayouts.java new file mode 100644 index 00000000..35cf02e3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/ElementLayouts.java @@ -0,0 +1,67 @@ +package zone.moddev.mc.lib.gui.layout; + +import java.util.*; + +import zone.moddev.mc.lib.gui.model.Align; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.Orientation; + +/** Reusable geometry operations. Element types choose which operations to use. */ +public final class ElementLayouts { + private ElementLayouts() {} + + public static int[] size(Element element, TextMeasure text) { + int[] size = element.measure(text); + if (size == null || size.length != 2 || size[0] < 0 || size[1] < 0) + throw new IllegalArgumentException( + "Invalid measured size for " + element.getClass().getName()); + return size; + } + + public static int[] stack(List children, int gap, TextMeasure text) { + int w = 0, h = Math.max(0, children.size() - 1) * gap; + for (var child : children) { + var size = size(child, text); + w = Math.max(w, size[0]); + h += size[1]; + } + return new int[] {w, h}; + } + + public static int[] row(List children, int gap, TextMeasure text) { + int w = Math.max(0, children.size() - 1) * gap, h = 0; + for (var child : children) { + var size = size(child, text); + w += size[0]; + h = Math.max(h, size[1]); + } + return new int[] {w, h}; + } + + public static int[] oriented(Orientation orientation, int thickness, int length) { + return orientation == Orientation.VERTICAL + ? new int[] {thickness, length} + : new int[] {length, thickness}; + } + + public static Map stackPositions( + List children, Rect bounds, int gap, TextMeasure text) { + var result = new IdentityHashMap(); + int y = bounds.y(); + for (var child : children) { + var size = size(child, text); + result.put( + child, new Rect(bounds.x(), y, child.stretchX() ? bounds.width() : size[0], size[1])); + y += size[1] + gap; + } + return result; + } + + public static int align(Align align, int room, int size) { + return switch (align) { + case START -> 0; + case CENTER -> (room - size) / 2; + case END -> room - size; + }; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/FilterPanelLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/FilterPanelLayout.java index 0c068ef9..9fcac9f6 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/layout/FilterPanelLayout.java +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/FilterPanelLayout.java @@ -4,53 +4,15 @@ import zone.moddev.mc.lib.api.automation.FilterControl; -/** - * Measures an item-provided fragment at the drawer's available width; all output is clipped by its - * host. - */ +/** Named bounds for integrations inspecting an item-provided element fragment. */ public record FilterPanelLayout(Map controls, int height) { public static FilterPanelLayout of(FilterControl root, int width) { if (width < 18) throw new IllegalArgumentException("Editor width"); - FilterControl.flatten(root); + var controls = FilterControl.flatten(root); + var layout = ElementFragmentLayout.of(root, width, TextMeasure.NONE); var result = new LinkedHashMap(); - int height = place(root, 0, 0, width, result); - return new FilterPanelLayout(Collections.unmodifiableMap(result), height); - } - - private static int place( - FilterControl control, int x, int y, int width, Map result) { - if (control instanceof FilterControl.Box box) { - int height = 0, offset = 0; - if (box.horizontal()) { - int rowY = y; - int fixed = 0, flexible = 0; - for (var child : box.children()) - if (child instanceof FilterControl.Toggle) fixed += 16; - else flexible++; - int share = flexible == 0 ? 14 : Math.max(18, (width - fixed) / flexible - 2); - for (var child : box.children()) { - int w = child instanceof FilterControl.Toggle ? 14 : share; - if (offset + w > width && offset > 0) { - rowY += height + 2; - offset = 0; - height = 0; - } - height = Math.max(height, place(child, x + offset, rowY, Math.min(width, w), result)); - offset += w + 2; - } - return rowY - y + height; - } - for (var child : box.children()) height += place(child, x, y + height, width, result) + 2; - return Math.max(0, height - 2); - } - int height = - control instanceof FilterControl.Samples samples - ? ((samples.count() + Math.max(1, width / 18) - 1) / Math.max(1, width / 18)) * 18 - : control instanceof FilterControl.Custom custom - ? custom.height() - : control instanceof FilterControl.Toggle ? 14 : 18; - result.put( - control.id(), new Rect(x, y, control instanceof FilterControl.Toggle ? 14 : width, height)); - return height; + for (var control : controls) + if (control.children().isEmpty()) result.put(control.id(), layout.bounds().get(control)); + return new FilterPanelLayout(Collections.unmodifiableMap(result), layout.height()); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/FlowLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/FlowLayout.java new file mode 100644 index 00000000..6350bde2 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/FlowLayout.java @@ -0,0 +1,44 @@ +package zone.moddev.mc.lib.gui.layout; + +import java.util.*; + +import zone.moddev.mc.lib.gui.model.Element; + +/** Width-constrained rows/stacks. Children declare their size and whether they share row width. */ +public record FlowLayout(Map children, int height) { + public static FlowLayout of( + List children, boolean horizontal, int width, int gap, TextMeasure text) { + var result = new IdentityHashMap(); + int fixed = 0, flexible = 0, minimum = 0; + for (var child : children) { + if (child.stretchX()) { + flexible++; + minimum = Math.max(minimum, ElementLayouts.size(child, text)[0]); + } else fixed += ElementLayouts.size(child, text)[0] + gap; + } + int share = flexible == 0 ? width : Math.max(minimum, (width - fixed) / flexible - gap); + int x = 0, y = 0, rowHeight = 0; + for (var child : children) { + int w = + Math.min( + width, + child.stretchX() + ? (horizontal ? share : width) + : ElementLayouts.size(child, text)[0]); + int h = child.measure(text, w)[1]; + if (h < 0) throw new IllegalArgumentException("Negative element height"); + if (horizontal && x + w > width && x > 0) { + y += rowHeight + gap; + x = 0; + rowHeight = 0; + } + result.put(child, new Rect(horizontal ? x : 0, y, w, h)); + if (horizontal) { + x += w + gap; + rowHeight = Math.max(rowHeight, h); + } else y += h + gap; + } + return new FlowLayout( + Collections.unmodifiableMap(result), horizontal ? y + rowHeight : Math.max(0, y - gap)); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/GroupHighlights.java b/src/main/java/zone/moddev/mc/lib/gui/layout/GroupHighlights.java new file mode 100644 index 00000000..11ca05c6 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/GroupHighlights.java @@ -0,0 +1,66 @@ +package zone.moddev.mc.lib.gui.layout; + +import java.util.*; +import java.util.function.Predicate; + +import zone.moddev.mc.lib.gui.model.*; + +/** + * Shared highlight selection. Elements choose groups; selected containers may represent + * descendants. + */ +public final class GroupHighlights { + private GroupHighlights() {} + + private record Claim(Element element, String group) {} + + public static Map> of(Screen screen, List groups) { + return of(screen, groups, element -> true); + } + + /** + * Eligibility limits targets, not traversal. An ineligible container cannot suppress descendants. + */ + public static Map> of( + Screen screen, List groups, Predicate eligible) { + groups = List.copyOf(groups); + var result = new LinkedHashMap>(); + for (var group : groups) result.putIfAbsent(group.id(), new ArrayList<>()); + var roots = new ArrayList(screen.body()); + roots.addAll(screen.tabs()); + roots.addAll(screen.implicitElements()); + for (var root : roots) collect(root, screen, groups, eligible, new ArrayList<>(), result); + result.replaceAll((id, targets) -> List.copyOf(targets)); + return Collections.unmodifiableMap(result); + } + + private static void collect( + Element element, + Screen screen, + List groups, + Predicate eligible, + List ancestors, + Map> result) { + int count = ancestors.size(); + if (eligible.test(element)) { + for (var group : new LinkedHashSet<>(element.highlightedGroups(screen, groups))) { + if (!result.containsKey(group)) continue; + boolean represented = + ancestors.stream() + .anyMatch( + claim -> + claim.group().equals(group) + && claim + .element() + .replacesChildHighlight(screen, element, group, groups)); + if (!represented) { + result.get(group).add(element); + ancestors.add(new Claim(element, group)); + } + } + } + for (var child : element.children()) + collect(child, screen, groups, eligible, ancestors, result); + ancestors.subList(count, ancestors.size()).clear(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/GroupOrder.java b/src/main/java/zone/moddev/mc/lib/gui/layout/GroupOrder.java new file mode 100644 index 00000000..0b1fdfc7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/GroupOrder.java @@ -0,0 +1,24 @@ +package zone.moddev.mc.lib.gui.layout; + +import java.util.*; +import java.util.function.Function; + +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.Screen; + +/** Orders opaque group IDs by their first associated element, then by declaration order. */ +public final class GroupOrder { + private GroupOrder() {} + + public static List of( + Screen screen, List groups, Function> associations) { + var declared = new LinkedHashSet<>(groups); + if (declared.size() != groups.size() || declared.contains(null)) + throw new IllegalArgumentException("Group IDs must be unique and non-null"); + var order = new LinkedHashSet(); + for (var element : screen.elements()) + for (var id : associations.apply(element)) if (declared.contains(id)) order.add(id); + order.addAll(declared); + return List.copyOf(order); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/InventoryColorOrder.java b/src/main/java/zone/moddev/mc/lib/gui/layout/InventoryColorOrder.java deleted file mode 100644 index de54f3d5..00000000 --- a/src/main/java/zone/moddev/mc/lib/gui/layout/InventoryColorOrder.java +++ /dev/null @@ -1,34 +0,0 @@ -package zone.moddev.mc.lib.gui.layout; - -import java.util.LinkedHashSet; -import java.util.List; - -import zone.moddev.mc.lib.api.automation.ResourceGroup; -import zone.moddev.mc.lib.api.automation.ResourceType; -import zone.moddev.mc.lib.gui.model.Element; -import zone.moddev.mc.lib.gui.model.Screen; - -/** Screen reading order, independent of persistent group IDs and real menu slot indices. */ -public final class InventoryColorOrder { - private InventoryColorOrder() {} - - public static List of(Screen screen, List> groups) { - var order = new LinkedHashSet(); - // Boxes expose their children in their displayed order (rows left-to-right, stacks top-down). - // An inventory with several anchors appears once, at its first screen occurrence. - for (var element : screen.elements()) { - for (int i = 0; i < groups.size(); i++) { - var group = groups.get(i); - boolean anchor = element.common().id().filter(group.highlights()::contains).isPresent(); - if (anchor - || (group.highlights().isEmpty() - && element instanceof Element.Slot slot - && group.type() == ResourceType.ITEMS - && group.contains(slot.index()))) order.add(i); - } - } - // Groups without a screen anchor remain reachable, in their declared order. - for (int i = 0; i < groups.size(); i++) order.add(i); - return List.copyOf(order); - } -} diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/LayoutContext.java b/src/main/java/zone/moddev/mc/lib/gui/layout/LayoutContext.java new file mode 100644 index 00000000..b012f193 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/LayoutContext.java @@ -0,0 +1,56 @@ +package zone.moddev.mc.lib.gui.layout; + +import java.util.*; + +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.Text; +import zone.moddev.mc.lib.gui.sync.ElementStateView; + +/** Per-layout inputs shared with elements, without exposing the screen layout engine. */ +public final class LayoutContext implements TextMeasure { + private final TextMeasure text; + private final Map minimumWidths = new IdentityHashMap<>(); + private final Map selections; + private final ElementStateView state; + private final Set headers = Collections.newSetFromMap(new IdentityHashMap<>()); + + public LayoutContext(TextMeasure text, Map selections) { + this.text = text; + this.selections = Map.copyOf(selections); + this.state = ElementStateView.DEFAULTS; + } + + public LayoutContext(TextMeasure text, ElementStateView state) { + this.text = text; + this.state = Objects.requireNonNull(state); + selections = Map.of(); + } + + public void reserveWidth(Element element, int width) { + minimumWidths.merge(element, width, Math::max); + } + + public int minimumWidth(Element element, int intrinsic) { + return Math.max(intrinsic, minimumWidths.getOrDefault(element, 0)); + } + + public ElementStateView state() { + return state; + } + + public int width(Text value) { + return text.width(value); + } + + public String selected(String id) { + return selections.get(id); + } + + public void reserveHeader(Element element) { + headers.add(element); + } + + public boolean hasHeader(Element element) { + return headers.contains(element); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/LayoutContribution.java b/src/main/java/zone/moddev/mc/lib/gui/layout/LayoutContribution.java new file mode 100644 index 00000000..73855bbb --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/LayoutContribution.java @@ -0,0 +1,70 @@ +package zone.moddev.mc.lib.gui.layout; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.drawer.DrawerDefinition; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.Screen; + +/** + * A fresh per-layout plan supplied by an element. Contributions run in screen traversal order; each + * may take ownership of body roots, append a footer and publish named regions. No registration is + * required. Element definitions must not retain the mutable plan. + */ +public interface LayoutContribution { + record Context(Screen screen, LayoutContext text, List drawers) { + public Context { + drawers = List.copyOf(drawers); + } + } + + record Size(int width, int height) { + public Size { + if (width < 0 || height < 0) throw new IllegalArgumentException("Negative footer size"); + } + } + + /** Body roots this plan places itself; duplicate claims are rejected by identity. */ + default List claimedRoots() { + return List.of(); + } + + /** Additional centred footer space, measured before the main body is placed. */ + default Optional footerSize() { + return Optional.empty(); + } + + /** Receives the allocated footer rectangle before finish. */ + default void footer(Rect bounds) {} + + /** Places owned elements and adjusts the panel before tabs and authored offsets are applied. */ + default void finish(Panel panel) {} + + /** Observes final placement after every plan finishes, before authored offsets are applied. */ + default void beforeOffsets(Panel panel) {} + + /** Resolves associations after authored offsets; changes must not resize the panel here. */ + default void resolveOffsets(Panel panel) {} + + /** Final named regions for feature consumers; duplicate keys are rejected. */ + default Map regions() { + return Map.of(); + } + + interface Panel { + Rect bounds(Element element); + + void place(Element element, Rect bounds); + + void shift(Element element, int dx, int dy); + + /** Shifts placed roots and pending footer allocations at or below y. */ + void shiftBelow(int y, int amount); + + void grow(int amount); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java index a8995190..1fd0e9f0 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java @@ -1,83 +1,40 @@ package zone.moddev.mc.lib.gui.layout; -import java.util.ArrayList; -import java.util.IdentityHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.gui.drawer.*; import zone.moddev.mc.lib.gui.model.Element; -import zone.moddev.mc.lib.gui.model.Element.*; import zone.moddev.mc.lib.gui.model.Screen; /** - * Places every element of a screen, in GUI pixels relative to the main panel's top-left corner. - * - *

The main panel stacks the screen's top-level elements, then (unless the screen places it or - * leaves it out) the player inventory, centred. Boxes stack their children; inside a {@code vbox} a - * child {@code hbox} stretches to the full width, and inside an {@code hbox} a child {@code vbox} - * stretches to the full height, so {@code spacer}s have room to fill. A context area sizes itself - * to its selected view and fills the available width in vertical layouts. Side tabs and pop-out - * tabs stack down their panel edge, side tabs first. - * - *

Fixed sizes come from the sprites ({@code docs/gui-sprites.md}): slots 18 px (26 large), - * gauges 18 px thick, progress arrows 24x16, checkboxes and radios 10 px, buttons 20 px high, - * side-configuration faces 20 px in a 3x3 grid. + * Composes measured element trees, registered drawer tabs and element-owned layout contributions. + * Coordinates are GUI pixels relative to the main panel's top-left corner. Elements own their + * measurement and child placement; contributions own footer and associated-root placement. */ public final class ScreenLayout { - /** Three inventory rows and a hotbar, with a toolbar and equipment column on the right. */ - public static final int INVENTORY_WIDTH = 162 + 4 + 18, INVENTORY_HEIGHT = 18 + 3 * 18 + 4 + 18; - - /** Slot background for a vanilla player inventory index (0..40), relative to the panel. */ - public static Rect playerSlot(Rect inventory, int index) { - if (index < 0 || index > 40) throw new IllegalArgumentException("Player slot: " + index); - int x, y; - if (index < 36) { - x = (index % 9) * 18; - y = index < 9 ? 76 : 18 + (index / 9 - 1) * 18; - } else { - x = 166; - y = index == 40 ? 76 : (39 - index) * 18; - } - return new Rect(inventory.x() + x, inventory.y() + y, 18, 18); - } - /** Margin around the panel's content, as vanilla screens. */ public static final int PANEL_MARGIN = 7; /** Gap between stacked top-level elements, and before the player inventory. */ public static final int GAP = 4; - /** Space around the content of a side tab, and the tab's overlap with the panel. */ - public static final int TAB_PADDING = SideDrawerLayout.MARGIN, TAB_OVERLAP = 4, TAB_BORDER = 3; - - /** Width of a pop-out edge tab: a 16 px icon with a 3 px border. */ - public static final int POPOUT_TAB = 22; - - /** Height of a compact rectangular drawer tab. */ - public static final int POPOUT_HEIGHT = 24; - - /** Gap between tabs stacked on one edge. */ - public static final int TAB_GAP = 2; - - /** Size of one side-configuration face, and the gap between faces. */ - public static final int FACE = 20, FACE_GAP = 2; + private final Map bounds = new IdentityHashMap<>(); + private final LayoutContext text; + private List drawers = List.of(); - /** Height of the side-configuration group selector above the faces. */ - public static final int SELECTOR = 20; + public List drawers() { + return drawers; + } - private final Map bounds = new IdentityHashMap<>(); - private final java.util.Set alternateViews = - java.util.Collections.newSetFromMap(new IdentityHashMap<>()); - private final TextMeasure text; - private final Map views; private Rect panel; - private Rect inventory; - private Rect inventoryColors; + private List contributions = List.of(); + private final Map footers = new IdentityHashMap<>(); + private final Map regions = new HashMap<>(); - private ScreenLayout(TextMeasure text, Map views) { + private ScreenLayout(LayoutContext text) { this.text = text; - this.views = Map.copyOf(views); } /** @@ -107,48 +64,107 @@ public static ScreenLayout of( boolean automation, boolean redstone, Map views) { - var layout = new ScreenLayout(text, views); - layout.place(screen, (automation ? 1 : 0) + (redstone ? 1 : 0)); - var controls = - screen.body().stream() - .filter(InventoryControls.class::isInstance) - .map(InventoryControls.class::cast) - .findFirst() - .orElse(null); - if (controls != null && layout.inventory == null) - throw new IllegalArgumentException("inventory-controls requires a player inventory"); - if ((automation || controls != null) && layout.inventory != null) { - int start = layout.inventory.y(); - int controlsWidth = controls == null ? 0 : layout.size(controls)[0]; - int rowHeight = - Math.max( - AutomationLayout.SELECTOR_ROW_HEIGHT, - controls == null ? 0 : layout.size(controls)[1] + GAP); - int storageRight = playerSlot(layout.inventory, 17).right(); - int colourWidth = - storageRight - layout.inventory.x() - (controlsWidth == 0 ? 0 : controlsWidth + GAP); - if (colourWidth < (automation ? 34 : 0)) - throw new IllegalArgumentException( - "Inventory controls leave too little room for colour selectors"); - // The inventory already reserves one slot row beside the helmet. Only taller controls - // need more space; ordinary flat controls and colour selectors use that existing row. - int extraHeight = Math.max(0, rowHeight - 18); - for (var element : screen.body()) layout.shiftInventoryBody(element, start, extraHeight); - layout.inventoryColors = new Rect(layout.inventory.x(), start, colourWidth, rowHeight - GAP); - if (controls != null) - layout.placeAt( - controls, storageRight - controlsWidth, start, controlsWidth, rowHeight - GAP); - layout.inventory = layout.inventory.offset(0, extraHeight); - layout.panel = new Rect(0, 0, layout.panel.width(), layout.panel.height() + extraHeight); + var context = new LayoutContext(text, views); + return arrange( + screen, + context, + DrawerRegistry.GLOBAL.resolve( + DrawerRegistry.legacy(screen, context, automation, redstone))); + } + + /** Layout consumes typed state without knowing which elements declare it. */ + public static ScreenLayout of( + Screen screen, + TextMeasure text, + boolean automation, + boolean redstone, + zone.moddev.mc.lib.gui.sync.ElementStateView state) { + var context = new LayoutContext(text, state); + return arrange( + screen, + context, + DrawerRegistry.GLOBAL.resolve( + DrawerRegistry.legacy(screen, context, automation, redstone))); + } + + public static ScreenLayout of( + Screen screen, + TextMeasure text, + zone.moddev.mc.lib.gui.runtime.ScreenHost host, + zone.moddev.mc.lib.gui.sync.ElementStateView state) { + var context = new LayoutContext(text, state); + return arrange( + screen, + context, + DrawerRegistry.GLOBAL.resolve(new DrawerRegistry.Context(screen, context, host))); + } + + public static ScreenLayout of( + Screen screen, + TextMeasure text, + List drawers, + zone.moddev.mc.lib.gui.sync.ElementStateView state) { + return arrange(screen, new LayoutContext(text, state), List.copyOf(drawers)); + } + + private static ScreenLayout arrange( + Screen screen, LayoutContext text, List drawers) { + var layout = new ScreenLayout(text); + layout.drawers = drawers; + var context = new LayoutContribution.Context(screen, text, drawers); + layout.contributions = + screen.elements().stream() + .flatMap(element -> element.layoutContribution(context).stream()) + .toList(); + layout.place(screen); + var placement = + new LayoutContribution.Panel() { + public Rect bounds(Element element) { + return layout.bounds(element); + } + + public void place(Element element, Rect r) { + layout.placeAt(element, r.x(), r.y(), r.width(), r.height()); + } + + public void shift(Element element, int dx, int dy) { + layout.shift(element, dx, dy); + } + + public void shiftBelow(int y, int amount) { + for (var element : screen.body()) layout.shiftBelow(element, y, amount); + for (var element : screen.implicitElements()) layout.shiftBelow(element, y, amount); + layout.footers.replaceAll( + (plan, rect) -> rect.y() >= y ? rect.offset(0, amount) : rect); + } + + public void grow(int amount) { + layout.panel = new Rect(0, 0, layout.panel.width(), layout.panel.height() + amount); + } + }; + for (var plan : layout.contributions) { + var footer = layout.footers.remove(plan); + if (footer != null) plan.footer(footer); + plan.finish(placement); } + layout.placeTabs(); + layout.contributions.forEach(plan -> plan.beforeOffsets(placement)); layout.applyOffsets(screen); + layout.contributions.forEach(plan -> plan.resolveOffsets(placement)); + for (var plan : layout.contributions) + plan.regions() + .forEach( + (id, rect) -> { + if (layout.regions.putIfAbsent(id, rect) != null) + throw new IllegalArgumentException("Duplicate layout region: " + id); + }); return layout; } - private void shiftInventoryBody(Element element, int start, int dy) { + private void shiftBelow(Element element, int start, int dy) { var rect = bounds.get(element); if (rect != null && rect.y() >= start) bounds.put(element, rect.offset(0, dy)); - for (var child : element.children()) shiftInventoryBody(child, start, dy); + for (var child : element.children()) shiftBelow(child, start, dy); } /** The main panel, at (0, 0). */ @@ -156,14 +172,9 @@ public Rect panel() { return panel; } - /** Bounds of the player inventory, including the toolbar row and equipment column. */ - public Optional inventory() { - return Optional.ofNullable(inventory); - } - - /** Left-hand region of the shared inventory toolbar, excluding its right-hand controls. */ - public Optional inventoryColors() { - return Optional.ofNullable(inventoryColors); + /** A final region published by an element's layout contribution. */ + public Optional region(ResourceLocation id) { + return Optional.ofNullable(regions.get(id)); } /** Where {@code element} is drawn. */ @@ -173,327 +184,83 @@ public Rect bounds(Element element) { return rect; } - /** - * Where a menu slot puts its item, as vanilla {@code Slot} coordinates: 1 px inside an 18 px - * slot, centred in a 26 px one. - */ - public static int[] itemPosition(Rect slot) { - int inset = slot.width() == 26 ? 5 : 1; - return new int[] {slot.x() + inset, slot.y() + inset}; - } - - // Measuring - private int[] size(Element element) { - return switch (element) { - case Box box -> boxSize(box); - case InventoryControls controls -> { - int width = Math.max(0, controls.children().size() - 1) * 2, height = 0; - for (var child : controls.children()) { - int[] size = size(child); - width += size[0]; - height = Math.max(height, size[1]); - } - yield new int[] {width, height}; - } - case Spacer spacer -> new int[] {0, 0}; - case Custom custom -> new int[] {custom.width(), custom.height()}; - case Group group -> { - int[] inner = stack(group.children(), 2); - int label = group.label().isPresent() ? TextMeasure.LINE_HEIGHT + 2 : 0; - int width = Math.max(inner[0], group.label().map(text::width).orElse(0)); - yield new int[] {width + 2 * group.padding(), inner[1] + label + 2 * group.padding()}; - } - case Slot slot -> slot.size() == SlotSize.LARGE ? new int[] {26, 26} : new int[] {18, 18}; - // A fill length counts as its minimum here; placement stretches it. - case Gauge gauge -> oriented(gauge.orientation(), 18, gauge.fills() ? 8 : gauge.length()); - case EnergyGauge gauge -> - oriented(gauge.orientation(), 18, gauge.fills() ? 8 : gauge.length()); - case Meter meter -> - oriented(meter.orientation(), meter.thickness(), meter.fills() ? 4 : meter.length()); - case Progress progress -> - progress.direction() == Direction.UP || progress.direction() == Direction.DOWN - ? new int[] {16, 24} - : new int[] {24, 16}; - case Label label -> new int[] {text.width(label.text()), TextMeasure.LINE_HEIGHT}; - case Button button -> - button.flat() - ? new int[] {14, 14} - : button.text().isPresent() - ? new int[] {Math.max(20, text.width(button.text().get()) + 8), 20} - : new int[] {20, 20}; - case Checkbox box -> new int[] {10 + 4 + text.width(box.text()), 10}; - case Radio radio -> new int[] {10 + 4 + text.width(radio.text()), 10}; - case RadioGroup group -> { - int[] inner = stack(group.children(), 2); - int label = group.label().isPresent() ? TextMeasure.LINE_HEIGHT + 2 : 0; - yield new int[] { - Math.max(inner[0], group.label().map(text::width).orElse(0)), inner[1] + label - }; - } - case Context context -> size(selectedView(context)); - case View view -> { - int[] inner = stack(view.children(), GAP); - int title = viewHeader(view); - yield new int[] { - Math.max(inner[0], view.title().map(text::width).orElse(0) + 12), inner[1] + title - }; - } - case SideConfig config -> { - int grid = 3 * FACE + 2 * FACE_GAP; - yield new int[] {grid, SELECTOR + GAP + grid}; - } - case PlayerInventory inventory -> new int[] {INVENTORY_WIDTH, INVENTORY_HEIGHT}; - case SideTab tab -> { - int[] inner = stack(tab.children(), 2); - yield new int[] { - inner[0] + 2 * TAB_PADDING + TAB_OVERLAP + TAB_BORDER, inner[1] + 2 * TAB_PADDING - }; - } - case Popout popout -> new int[] {POPOUT_TAB, POPOUT_HEIGHT}; - }; - } - - private static int[] oriented(Orientation orientation, int thickness, int length) { - return orientation == Orientation.VERTICAL - ? new int[] {thickness, length} - : new int[] {length, thickness}; - } - - private int[] boxSize(Box box) { - int[] inner = box.vertical() ? stack(box.children(), box.spacing()) : row(box); - return new int[] {inner[0] + 2 * box.padding(), inner[1] + 2 * box.padding()}; - } - - private int[] row(Box box) { - int width = 0, height = 0; - for (var child : box.children()) { - int[] size = size(child); - width += size[0]; - height = Math.max(height, size[1]); - } - width += box.spacing() * Math.max(0, box.children().size() - 1); - return new int[] {width, height}; + return ElementLayouts.size(element, text); } - /** Children stacked top to bottom: widest child, summed heights plus gaps. */ - private int[] stack(List children, int spacing) { - int width = 0, height = 0; - for (var child : children) { - int[] size = size(child); - width = Math.max(width, size[0]); - height += size[1]; - } - height += spacing * Math.max(0, children.size() - 1); - return new int[] {width, height}; - } - - // Placing - - private int viewHeader(View view) { - return view.title().isPresent() || alternateViews.contains(view) - ? TextMeasure.LINE_HEIGHT + GAP - : 0; - } - - private View selectedView(Context context) { - String selected = views.get(context.common().id().orElse("")); - return context.views().stream() - .filter(view -> view.common().id().orElse("").equals(selected)) - .findFirst() - .orElse(context.defaultView()); + private int[] stack(List children, int gap) { + return ElementLayouts.stack(children, gap, text); } - private void place(Screen screen, int rightDrawers) { - screen.forEach( - e -> { - if (e instanceof Context context) - alternateViews.addAll(context.views().subList(1, context.views().size())); - }); - var content = screen.body().stream().filter(e -> !(e instanceof InventoryControls)).toList(); - int[] body = stack(content, GAP); - boolean addInventory = - screen.playerInventory() - && screen.elements().stream().noneMatch(PlayerInventory.class::isInstance); + private void place(Screen screen) { + screen.forEach(element -> element.prepareLayout(text)); + var claimed = java.util.Collections.newSetFromMap(new IdentityHashMap()); + for (var plan : contributions) + for (var root : plan.claimedRoots()) { + if (screen.body().stream().noneMatch(element -> element == root)) + throw new IllegalArgumentException("Layout contribution must claim a body root"); + if (!claimed.add(root)) throw new IllegalArgumentException("Body root claimed twice"); + } + var bodyElements = + screen.body().stream().filter(element -> !claimed.contains(element)).toList(); + int[] body = stack(bodyElements, GAP); + var footers = contributions.stream().map(LayoutContribution::footerSize).toList(); int width = Math.max(body[0], text.width(screen.title()) + 2 * PANEL_MARGIN); int titleHeight = PANEL_MARGIN + TextMeasure.LINE_HEIGHT + GAP; int height = body[1] + titleHeight; - if (addInventory) { - width = Math.max(width, INVENTORY_WIDTH + 2 * PANEL_MARGIN); - height += GAP + INVENTORY_HEIGHT + PANEL_MARGIN; - } + for (var footer : footers) + if (footer.isPresent()) { + width = Math.max(width, footer.get().width() + 2 * PANEL_MARGIN); + height += GAP + footer.get().height(); + } + if (footers.stream().anyMatch(Optional::isPresent)) height += PANEL_MARGIN; int y = titleHeight; - for (var element : content) { + for (var element : bodyElements) { int[] size = size(element); // Top-level boxes and context areas span the panel, as a vbox's children do. - int w = - element instanceof Box || element instanceof Context || fills(element) ? width : size[0]; + int w = element.stretchToPanel() ? width : size[0]; placeAt(element, 0, y, w, size[1]); y += size[1] + GAP; } panel = new Rect(0, 0, width, height); - if (addInventory) - inventory = - new Rect( - (width - INVENTORY_WIDTH) / 2, - body[1] + titleHeight + GAP, - INVENTORY_WIDTH, - INVENTORY_HEIGHT); - for (var element : screen.elements()) - if (element instanceof PlayerInventory) inventory = bounds.get(element); - if (rightDrawers > 0) { - int tabsHeight = TAB_PADDING + rightDrawers * (POPOUT_HEIGHT + TAB_GAP) - TAB_GAP; - for (var tab : screen.tabs()) - if (tab instanceof SideTab side && side.edge() == Edge.RIGHT - || tab instanceof Popout popout && popout.edge() == Edge.RIGHT) - tabsHeight += TAB_GAP + size(tab)[1]; - panel = new Rect(0, 0, width, Math.max(height, tabsHeight + TAB_PADDING)); + int footerY = body[1] + titleHeight; + for (int i = 0; i < contributions.size(); i++) { + var footer = footers.get(i); + if (footer.isEmpty()) continue; + var size = footer.get(); + footerY += GAP; + this.footers.put( + contributions.get(i), + new Rect((width - size.width()) / 2, footerY, size.width(), size.height())); + footerY += size.height(); } - placeTabs(screen, Edge.LEFT, 0); - placeTabs(screen, Edge.RIGHT, rightDrawers); + if (drawers.stream().anyMatch(d -> d.element().isEmpty())) + panel = new Rect(0, 0, width, Math.max(height, DrawerTabs.minimumHeight(drawers))); } private void placeAt(Element element, int x, int y, int width, int height) { bounds.put(element, new Rect(x, y, width, height)); - switch (element) { - case Box box -> placeBox(box, x, y, width, height); - case InventoryControls controls -> { - int left = x; - for (var child : controls.children()) { - int[] size = size(child); - placeAt(child, left, y + (height - size[1]) / 2, size[0], size[1]); - left += size[0] + 2; - } - } - case Group group -> { - int top = - y + group.padding() + (group.label().isPresent() ? TextMeasure.LINE_HEIGHT + 2 : 0); - placeStack(group.children(), x + group.padding(), top, width - 2 * group.padding(), 2); - } - case RadioGroup group -> { - int top = y + (group.label().isPresent() ? TextMeasure.LINE_HEIGHT + 2 : 0); - placeStack(group.children(), x, top, width, 2); - } - case Context context -> { - for (var view : context.views()) { - // Hidden elements still need bounds for their persistent menu slots and widgets. - int[] size = size(view); - placeAt( - view, - x, - y, - Math.max(width, size[0]), - view == selectedView(context) ? height : size[1]); - } - } - case View view -> { - int top = y + (viewHeader(view)); - placeStack(view.children(), x, top, width, GAP); - } - case SideTab tab -> { - int inner = - tab.edge() == Edge.RIGHT ? x + TAB_OVERLAP + TAB_PADDING : x + TAB_BORDER + TAB_PADDING; - placeStack( - tab.children(), - inner, - y + TAB_PADDING, - width - 2 * TAB_PADDING - TAB_OVERLAP - TAB_BORDER, - 2); - } - default -> {} - } + element + .childLayout(new Rect(0, 0, width, height), text) + .forEach( + (child, rect) -> + placeAt(child, x + rect.x(), y + rect.y(), rect.width(), rect.height())); } - private void placeStack(List children, int x, int y, int width, int spacing) { - for (var child : children) { - int[] size = size(child); - int w = - (child instanceof Box box && !box.vertical()) || child instanceof Context || fills(child) - ? width - : size[0]; - placeAt(child, x, y, w, size[1]); - y += size[1] + spacing; - } - } - - private void placeBox(Box box, int x, int y, int width, int height) { - int innerX = x + box.padding(), innerY = y + box.padding(); - int innerW = width - 2 * box.padding(), innerH = height - 2 * box.padding(); - var sizes = new ArrayList(); - int used = 0, spacers = 0; - for (var child : box.children()) { - int[] size = size(child); - sizes.add(size); - used += box.vertical() ? size[1] : size[0]; - if (child instanceof Spacer) spacers++; - } - used += box.spacing() * Math.max(0, box.children().size() - 1); - int leftover = Math.max(0, (box.vertical() ? innerH : innerW) - used); - int position = box.vertical() ? innerY : innerX; - int spacerIndex = 0; - for (int i = 0; i < box.children().size(); i++) { - var child = box.children().get(i); - int[] size = sizes.get(i); - int main = box.vertical() ? size[1] : size[0]; - if (child instanceof Spacer) { - // Share the leftover room; the first spacers take any remainder pixel. - main = leftover / spacers + (spacerIndex++ < leftover % spacers ? 1 : 0); - } - if (box.vertical()) { - boolean stretch = - (child instanceof Box b && !b.vertical()) || child instanceof Context || fills(child); - int w = stretch ? innerW : size[0]; - placeAt(child, innerX + align(box.align(), innerW, w), position, w, main); - } else { - boolean stretch = (child instanceof Box b && b.vertical()) || fills(child); - int h = stretch ? innerH : size[1]; - placeAt(child, position, innerY + align(box.align(), innerH, h), main, h); - } - position += main + box.spacing(); - } - } - - /** Whether {@code element} has {@code length="fill"}; the reader checks it sits where it can. */ - private static boolean fills(Element element) { - return (element instanceof Gauge gauge && gauge.fills()) - || (element instanceof EnergyGauge energy && energy.fills()) - || (element instanceof Meter meter && meter.fills()); - } - - private static int align(Align align, int room, int size) { - return switch (align) { - case START -> 0; - case CENTER -> (room - size) / 2; - case END -> room - size; - }; - } - - private void placeTabs(Screen screen, Edge edge, int runtimeTabs) { - int y = TAB_PADDING + runtimeTabs * (POPOUT_HEIGHT + TAB_GAP); - // Side tabs first, then pop-outs, each in document order. - var ordered = new ArrayList(); - for (var tab : screen.tabs()) - if (tab instanceof SideTab side && side.edge() == edge) ordered.add(tab); - for (var tab : screen.tabs()) - if (tab instanceof Popout popout && popout.edge() == edge) ordered.add(tab); - for (var tab : ordered) { - int[] size = size(tab); - int x = - edge == Edge.RIGHT - ? panel.width() - (tab instanceof SideTab ? TAB_OVERLAP : 0) - : -size[0] + (tab instanceof SideTab ? TAB_OVERLAP : 0); - placeAt(tab, x, y, size[0], size[1]); - y += size[1] + TAB_GAP; - } - if (y - TAB_GAP > panel.height()) - throw new IllegalArgumentException( - "The tabs on the " - + edge.name().toLowerCase(java.util.Locale.ROOT) - + " edge need " - + (y - TAB_GAP) - + " px but the panel is " - + panel.height() - + " px high; move some to the other edge"); + private void placeTabs() { + var tabs = DrawerTabs.place(drawers, panel, panel.bottom(), definition -> true); + tabs.forEach( + (definition, rect) -> { + if (rect.bottom() > panel.height()) + throw new IllegalArgumentException( + "The tabs on the " + + definition.placement().edge().name().toLowerCase(java.util.Locale.ROOT) + + " edge need more panel height"); + definition + .element() + .ifPresent( + element -> placeAt(element, rect.x(), rect.y(), rect.width(), rect.height())); + }); } /** Nudges each offset element together with everything inside it. */ diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/SideDrawerLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/SideDrawerLayout.java index c03514c2..9b6a3192 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/layout/SideDrawerLayout.java +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/SideDrawerLayout.java @@ -12,6 +12,18 @@ public enum HeightLimit { private SideDrawerLayout() {} + public static int bodyX(zone.moddev.mc.lib.gui.model.Edge edge, Rect tab, int width) { + return edge == zone.moddev.mc.lib.gui.model.Edge.LEFT ? tab.x() - width : tab.right(); + } + + public static int room(zone.moddev.mc.lib.gui.model.Edge edge, Rect tab, int screenWidth) { + return Math.max( + 0, + edge == zone.moddev.mc.lib.gui.model.Edge.LEFT + ? tab.x() - MARGIN + : screenWidth - tab.right() - MARGIN); + } + public static int top(Rect panel) { return Math.max(MARGIN, panel.y() + MARGIN); } diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/BoundValue.java b/src/main/java/zone/moddev/mc/lib/gui/load/BoundValue.java index 5155b7e2..d4c8c70d 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/BoundValue.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/BoundValue.java @@ -2,103 +2,41 @@ import java.lang.invoke.MethodHandle; import java.math.BigInteger; -import java.util.Collection; import java.util.Objects; -import net.minecraft.network.chat.Component; - -import net.neoforged.neoforge.fluids.FluidStack; +import net.minecraft.core.RegistryAccess; import zone.moddev.mc.lib.gui.model.ValueRef; +import zone.moddev.mc.lib.gui.value.ValueType; +import zone.moddev.mc.lib.gui.value.ValueTypes; /** - * A {@code value} or {@code max} getter resolved at load: a method handle taking the block entity - * and returning the value, and the kind of value it returns. Handles are bound to an instance only - * when a screen opens, on the server. + * A getter resolved at load time against its registered value contract. * * @param ref the reference as written - * @param kind what the getter returns + * @param kind the registered type responsible for normalizing and copying the result * @param handle {@code (Object blockEntity) -> Object} */ -public record BoundValue(ValueRef ref, Kind kind, MethodHandle handle) { - /** Checks the fields. */ +public record BoundValue(ValueRef ref, ValueType kind, MethodHandle handle) { public BoundValue { Objects.requireNonNull(ref); Objects.requireNonNull(kind); Objects.requireNonNull(handle); } - /** The kinds of value a getter can return. */ - public enum Kind { - /** - * {@code byte}, {@code short}, {@code int}, {@code long}, their boxes, or {@code BigInteger}. - */ - WHOLE, - /** {@code float}, {@code double} or their boxes. */ - DECIMAL, - /** A declared {@code Number}: whole or decimal, decided when read. */ - NUMBER, - /** {@code boolean} or {@code Boolean}. */ - BOOLEAN, - /** A {@code FluidStack}: a fluid and its amount. */ - FLUID, - /** A text {@code Component}. */ - TEXT, - /** A collection of text {@code Component}s, checked when read. */ - TEXT_LIST; - - /** Whether this kind is numeric. */ - public boolean numeric() { - return this == WHOLE || this == DECIMAL || this == NUMBER; - } - - /** The kind of {@code type}, or null if a getter may not return it. */ - public static Kind of(Class type) { - if (type == byte.class - || type == short.class - || type == int.class - || type == long.class - || type == Byte.class - || type == Short.class - || type == Integer.class - || type == Long.class - || type == BigInteger.class) return WHOLE; - if (type == float.class - || type == double.class - || type == Float.class - || type == Double.class) return DECIMAL; - if (type == Number.class) return NUMBER; - if (type == boolean.class || type == Boolean.class) return BOOLEAN; - if (type == FluidStack.class) return FLUID; - if (Component.class.isAssignableFrom(type)) return TEXT; - if (Collection.class.isAssignableFrom(type)) return TEXT_LIST; - return null; - } - } - - /** - * Calls the getter on {@code blockEntity}. - * - * @throws Throwable whatever the getter throws; callers log it once and show the value as - * unavailable - */ + /** Calls the getter; failures are logged once by the snapshot collector. */ public Object read(Object blockEntity) throws Throwable { return handle.invoke(blockEntity); } - /** - * A whole-number value as a {@code BigInteger}, so values of any size display correctly. - * - * @throws IllegalStateException if the value is not a whole number - */ + /** Copies through the declared getter contract, preserving null as unavailable. */ + public Object snapshot(Object blockEntity, RegistryAccess registries) throws Throwable { + var value = read(blockEntity); + return value == null ? null : kind.snapshot(value, registries); + } + + /** Converts a supported whole number without losing precision. */ public static BigInteger whole(Object value) { - return switch (value) { - case BigInteger big -> big; - case Long l -> BigInteger.valueOf(l); - case Integer i -> BigInteger.valueOf(i); - case Short s -> BigInteger.valueOf(s); - case Byte b -> BigInteger.valueOf(b); - default -> throw new IllegalStateException("Not a whole number: " + value); - }; + return ValueTypes.whole(value); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/BuiltinElements.java b/src/main/java/zone/moddev/mc/lib/gui/load/BuiltinElements.java new file mode 100644 index 00000000..0236c95b --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/load/BuiltinElements.java @@ -0,0 +1,518 @@ +package zone.moddev.mc.lib.gui.load; + +import java.util.*; +import java.util.function.Function; + +import javax.xml.namespace.QName; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.api.automation.FilterControl; +import zone.moddev.mc.lib.gui.ElementType; +import zone.moddev.mc.lib.gui.model.*; +import zone.moddev.mc.lib.gui.model.containers.*; +import zone.moddev.mc.lib.gui.model.elements.*; + +/** Common built-in declarations shared by XML loading and client setup; no runtime lookup table. */ +public final class BuiltinElements { + private BuiltinElements() {} + + public static final ElementType BOX = + builtin("vbox", BoxElement.class, x -> box(x, true), "widget") + .withXml( + name("hbox"), + ScreenSchemas.builtin( + "hbox", BoxElement.class, x -> box(x, false), ScreenTag.Role.ELEMENT, "widget")); + public static final ElementType SPACER = + builtin("spacer", SpacerElement.class, BuiltinElements::spacer, "widget"); + public static final ElementType INVENTORY_CONTROLS = + builtin( + "inventory-controls", + InventoryControlsElement.class, + BuiltinElements::inventoryControls, + "controls"); + public static final ElementType CUSTOM = + builtin("custom", CustomElement.class, BuiltinElements::custom, "widget"); + public static final ElementType GROUP = + builtin("group", GroupElement.class, BuiltinElements::group, "widget"); + public static final ElementType INVENTORY = + builtin("inventory", InventoryElement.class, BuiltinElements::inventory, "widget"); + public static final ElementType SLOT = + builtin("slot", SlotElement.class, x -> slot(x, false), "widget", "slots") + .withXml( + name("item-slot"), + ScreenSchemas.builtin( + "item-slot", + SlotElement.class, + x -> slot(x, false), + ScreenTag.Role.ELEMENT, + "widget", + "slots")); + public static final ElementType GHOST_SLOT = + builtin( + "ghost-slot", + GhostSlotElement.class, + x -> (GhostSlotElement) slot(x, true), + "widget", + "slots"); + // The legacy gauge tag may construct either GaugeElement or FluidTankElement. + public static final ElementType LEGACY_GAUGE = + builtin("gauge", Element.class, BuiltinElements::gauge, "widget"); + public static final ElementType ENERGY_GAUGE = + builtin("energy-gauge", EnergyGaugeElement.class, BuiltinElements::energyGauge, "widget"); + public static final ElementType METER = + builtin("meter", MeterElement.class, BuiltinElements::meter, "widget"); + public static final ElementType SPRITE = + builtin("sprite", SpriteElement.class, BuiltinElements::sprite, "widget"); + public static final ElementType ANIMATED_SPRITE = + builtin( + "animated-sprite", + AnimatedSpriteElement.class, + BuiltinElements::animatedSprite, + "widget"); + public static final ElementType PROGRESS_SPRITE = + builtin( + "progress-sprite", + ProgressSpriteElement.class, + BuiltinElements::progressSprite, + "widget"); + public static final ElementType COLOURED_GROUP = + builtin( + "coloured-group", ColouredGroupElement.class, BuiltinElements::colouredGroup, "widget"); + public static final ElementType PROGRESS = + builtin("progress", ProgressElement.class, BuiltinElements::progress, "widget"); + public static final ElementType LABEL = + builtin("label", LabelElement.class, BuiltinElements::label, "widget"); + public static final ElementType PROCESSOR_BUTTON = + builtin( + "processor-button", + ProcessorButtonElement.class, + BuiltinElements::processorButton, + "widget", + "buttons"); + public static final ElementType BUTTON = + builtin("button", ButtonElement.class, BuiltinElements::button, "widget", "buttons"); + public static final ElementType CHECKBOX = + builtin("checkbox", CheckboxElement.class, BuiltinElements::checkbox, "widget"); + public static final ElementType RADIO_GROUP = + builtin("radio-group", RadioGroupElement.class, BuiltinElements::radioGroup, "widget"); + public static final ElementType RADIO = + builtin("radio", RadioElement.class, BuiltinElements::radio, "radios"); + public static final ElementType CONTEXT = + builtin("context", ContextElement.class, BuiltinElements::context, "widget"); + public static final ElementType VIEW = + builtin("view", ViewElement.class, BuiltinElements::view, "views"); + public static final ElementType SIDE_CONFIG = + builtin("side-config", SideConfigElement.class, BuiltinElements::sideConfig, "widget"); + public static final ElementType PLAYER_INVENTORY = + builtin( + "player-inventory", + PlayerInventoryElement.class, + BuiltinElements::playerInventory, + "widget"); + public static final ElementType PLAYER_ARMOUR = + builtin( + "player-armour", + PlayerArmourElement.class, + x -> new PlayerArmourElement(x.common()), + "widget"); + public static final ElementType PLAYER_OFFHAND = + builtin( + "player-offhand", + PlayerOffhandElement.class, + x -> new PlayerOffhandElement(x.common()), + "widget"); + public static final ElementType SIDE_TAB = + builtin("side-tab", SideTabElement.class, BuiltinElements::sideTab, "dock"); + public static final ElementType POPOUT = + builtin("popout", PopoutElement.class, BuiltinElements::popout, "dock"); + public static final ElementType FLUID_TANK = + builtin("fluid-tank", FluidTankElement.class, BuiltinElements::fluidTank, "widget"); + public static final ElementType GAUGE = ElementType.of(GaugeElement.class); + public static final ElementType BUTTON_CONTROL = + ElementType.of(ButtonControl.class); + public static final ElementType NAVIGABLE = + ElementType.of(NavigableElement.class); + public static final ElementType PLAYER_SLOTS = + ElementType.of(PlayerSlotsElement.class); + public static final ElementType CONTAINER_INVENTORY = + ElementType.of(ContainerInventoryElement.class); + public static final ElementType CONTAINER_SLOT = + ElementType.of(ContainerSlotElement.class); + public static final ElementType FILTER_BOX = + ElementType.of(FilterControl.Box.class); + public static final ElementType FILTER_TOGGLE = + ElementType.of(FilterControl.Toggle.class); + public static final ElementType FILTER_NUMBER = + ElementType.of(FilterControl.Number.class); + public static final ElementType FILTER_SAMPLES = + ElementType.of(FilterControl.Samples.class); + public static final ElementType FILTER_TEXT = + ElementType.of(FilterControl.Text.class); + public static final ElementType FILTER_CUSTOM = + ElementType.of(FilterControl.Custom.class); + + private static QName name(String local) { + return new QName(ScreenReader.NAMESPACE, local); + } + + private static ElementType builtin( + String local, Class type, Function factory, String... groups) { + return ElementType.xml( + name(local), ScreenSchemas.builtin(local, type, factory, ScreenTag.Role.ELEMENT, groups)); + } + + static void register(ScreenTags tags) { + for (var element : + List.of( + BOX, + SPACER, + INVENTORY_CONTROLS, + CUSTOM, + GROUP, + INVENTORY, + SLOT, + GHOST_SLOT, + LEGACY_GAUGE, + ENERGY_GAUGE, + METER, + SPRITE, + ANIMATED_SPRITE, + PROGRESS_SPRITE, + COLOURED_GROUP, + PROGRESS, + LABEL, + PROCESSOR_BUTTON, + BUTTON, + CHECKBOX, + RADIO_GROUP, + RADIO, + CONTEXT, + VIEW, + SIDE_CONFIG, + PLAYER_INVENTORY, + PLAYER_ARMOUR, + PLAYER_OFFHAND, + SIDE_TAB, + POPOUT, + FLUID_TANK)) element.register(tags); + tags.register( + "screen", + ScreenSchemas.builtin( + "screen", Screen.class, BuiltinElements::screen, ScreenTag.Role.DOCUMENT)); + tags.register( + "property", + ScreenSchemas.builtin( + "property", + Property.class, + x -> new Property(x.attribute("name"), x.attribute("value")), + ScreenTag.Role.DATA)); + tags.register( + "placeholder", + ScreenSchemas.builtin( + "placeholder", + Object.class, + x -> { + throw x.error("Unexpanded placeholder"); + }, + ScreenTag.Role.PLACEHOLDER, + "widget")); + tags.register( + "fill", + ScreenSchemas.builtin( + "fill", + Object.class, + x -> { + throw x.error("Unexpanded fill"); + }, + ScreenTag.Role.FILL)); + } + + private record Property(String name, String value) {} + + private static Screen screen(ScreenXml x) { + var body = new ArrayList(); + var tabs = new ArrayList(); + boolean ordinary = false; + for (var child : x.node().children()) { + var tag = x.tags().require(child); + var element = x.read(child, Element.class); + if (tag.groups().contains("dock")) tabs.add(element); + else { + body.add(element); + if (!tag.groups().contains("controls")) ordinary = true; + } + } + if (!ordinary) throw x.error("A screen requires at least one body element"); + return new Screen( + x.screenId(), + x.node().source(), + x.text("title").orElseThrow(), + x.attribute("block-entity"), + !x.optional("inventory").orElse("default").equals("none"), + body, + tabs, + x.origins()); + } + + private static FluidTankElement fluidTank(ScreenXml x) { + if (x.optional("color").isPresent()) throw x.error("A fluid tank uses its fluid texture"); + return new FluidTankElement( + x.common(), x.integer("tank"), x.enumValue(Orientation.class, "orientation"), x.length(8)); + } + + private static CustomElement customElement(ScreenXml x) { + var properties = new LinkedHashMap(); + for (var child : x.node().children()) { + var property = x.read(child, Property.class); + if (properties.putIfAbsent(property.name(), property.value()) != null) + throw new ScreenException( + child.source(), child.line(), "Duplicate widget property: " + property.name()); + } + return new CustomElement( + x.common(), + x.id("type").orElseThrow(), + x.size("width", 1), + x.size("height", 1), + properties); + } + + private static BoxElement box(ScreenXml x, boolean vertical) { + var node = x.node(); + var c = x.common(); + return new BoxElement( + c, + vertical, + x.size("padding", 0), + x.size("spacing", 0), + x.enumValue(Align.class, "align"), + x.children()); + } + + private static SpacerElement spacer(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new SpacerElement(c); + } + + private static InventoryControlsElement inventoryControls(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new InventoryControlsElement(c, x.children()); + } + + private static CustomElement custom(ScreenXml x) { + var node = x.node(); + return customElement(x); + } + + private static GroupElement group(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new GroupElement( + c, x.text("label"), x.color("tint"), x.size("padding", 0), x.children()); + } + + private static InventoryElement inventory(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new InventoryElement( + c, + node.attributes().get("storage"), + x.integer("columns"), + x.size("spacing", 0), + x.children()); + } + + private static SlotElement slot(ScreenXml x, boolean ghost) { + var node = x.node(); + var c = x.common(); + var appearance = + new SlotElement.Appearance( + x.enumValue(SlotSize.class, "size"), + x.color("tint"), + x.id("icon"), + x.id("background").orElse(ResourceLocation.parse("mmdlib:slot")), + Float.parseFloat(node.attributes().getOrDefault("ghost-alpha", ".42")), + x.id("icon-atlas"), + x.size("item-size", 16)); + var kind = x.enumValue(SlotKind.class, "kind"); + return ghost || kind == SlotKind.GHOST + ? new GhostSlotElement(c, x.integer("index"), appearance) + : new ItemSlotElement(c, x.integer("index"), kind, appearance); + } + + private static Element gauge(ScreenXml x) { + var node = x.node(); + var c = x.common(); + if (node.attributes().containsKey("tank")) return fluidTank(x); + return new GaugeElement( + c, x.enumValue(Orientation.class, "orientation"), x.length(8), x.color("color")); + } + + private static EnergyGaugeElement energyGauge(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new EnergyGaugeElement( + c, + x.enumValue(Orientation.class, "orientation"), + x.length(8), + x.color("color").orElseThrow()); + } + + private static MeterElement meter(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new MeterElement( + c, + x.enumValue(Orientation.class, "orientation"), + x.integer("thickness"), + x.length(4), + x.color("color").orElseThrow(), + x.size("segment", 1), + x.size("gap", 0), + x.color("gap-color")); + } + + private static SpriteElement sprite(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new SpriteElement( + c, + x.id("sprite").orElseThrow(), + x.size("width", 1), + x.size("height", 1), + x.color("tint").orElse(0xffffffff)); + } + + private static AnimatedSpriteElement animatedSprite(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new AnimatedSpriteElement( + c, + x.words("frames").stream().map(ResourceLocation::parse).toList(), + x.size("width", 1), + x.size("height", 1), + x.color("tint").orElse(0xffffffff), + x.integer("ticks-per-frame"), + Set.of("true", "1").contains(node.attributes().get("loop"))); + } + + private static ProgressSpriteElement progressSprite(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new ProgressSpriteElement( + c, + x.id("background").orElseThrow(), + x.id("foreground").orElseThrow(), + x.size("width", 1), + x.size("height", 1), + x.enumValue(ProgressDirection.class, "direction"), + x.color("tint").orElse(0xffffffff), + x.color("background-tint").orElse(0xffffffff), + false); + } + + private static ColouredGroupElement colouredGroup(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new ColouredGroupElement( + c, x.color("color").orElseThrow(), x.read(node.children().getFirst(), Element.class)); + } + + private static ProgressElement progress(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new ProgressElement(c, x.id("icon"), x.enumValue(ProgressDirection.class, "direction")); + } + + private static LabelElement label(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new LabelElement(c, x.text("text").orElseThrow()); + } + + private static ProcessorButtonElement processorButton(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new ProcessorButtonElement( + c, + Optional.ofNullable(node.attributes().get("processor-key")), + x.text("text"), + Set.of("true", "1").contains(node.attributes().getOrDefault("flat", "true"))); + } + + private static ButtonElement button(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new ButtonElement( + c, + x.text("text"), + x.id("icon"), + Optional.ofNullable(node.attributes().get("shows")).map(ViewRef::parse), + x.words("icons").stream().map(ResourceLocation::parse).toList(), + x.words("value-labels").stream() + .map(key -> new Text(x.translationKey(key), Optional.empty())) + .toList(), + Set.of("true", "1").contains(node.attributes().getOrDefault("flat", "false"))); + } + + private static CheckboxElement checkbox(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new CheckboxElement(c, x.text("text").orElseThrow()); + } + + private static RadioGroupElement radioGroup(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new RadioGroupElement(c, x.text("label"), x.children(RadioElement.class)); + } + + private static RadioElement radio(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new RadioElement(c, x.text("text").orElseThrow()); + } + + private static ContextElement context(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new ContextElement(c, x.children(ViewElement.class)); + } + + private static ViewElement view(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new ViewElement(c, x.text("title"), x.children()); + } + + private static SideConfigElement sideConfig(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new SideConfigElement(c, List.of(node.attributes().get("groups").trim().split("\\s+"))); + } + + private static PlayerInventoryElement playerInventory(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new PlayerInventoryElement(c); + } + + private static SideTabElement sideTab(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new SideTabElement(c, x.enumValue(Edge.class, "edge"), x.children()); + } + + private static PopoutElement popout(ScreenXml x) { + var node = x.node(); + var c = x.common(); + return new PopoutElement( + c, + x.enumValue(PopoutKind.class, "kind"), + x.id("icon"), + x.enumValue(Edge.class, "edge"), + x.text("text")); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ResourceValidationContext.java b/src/main/java/zone/moddev/mc/lib/gui/load/ResourceValidationContext.java new file mode 100644 index 00000000..3197d621 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ResourceValidationContext.java @@ -0,0 +1,29 @@ +package zone.moddev.mc.lib.gui.load; + +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import zone.moddev.mc.lib.gui.model.Screen; + +/** Immutable definition-time inputs. Resource kinds and binding domains are open string keys. */ +public record ResourceValidationContext( + Screen screen, + Map resources, + Map inventories, + Map> bindings) { + public ResourceValidationContext { + Objects.requireNonNull(screen); + resources = Map.copyOf(resources); + inventories = Map.copyOf(inventories); + bindings = + bindings.entrySet().stream() + .collect( + Collectors.toUnmodifiableMap( + Map.Entry::getKey, entry -> Set.copyOf(entry.getValue()))); + if (resources.values().stream().anyMatch(count -> count < 0) + || inventories.values().stream().anyMatch(count -> count < 0)) + throw new IllegalArgumentException("Negative resource count"); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenAttribute.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenAttribute.java new file mode 100644 index 00000000..3d4bd085 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenAttribute.java @@ -0,0 +1,39 @@ +package zone.moddev.mc.lib.gui.load; + +import java.util.List; +import java.util.Objects; + +/** An XML attribute contract. Types are XSD types (xs:int) or shared screen types (pixels). */ +public record ScreenAttribute( + String type, boolean required, String defaultValue, List choices) { + public ScreenAttribute { + Objects.requireNonNull(type); + choices = List.copyOf(choices); + if (required && defaultValue != null) + throw new IllegalArgumentException("A required attribute cannot have a default"); + } + + public static ScreenAttribute optional(String type) { + return new ScreenAttribute(type, false, null, List.of()); + } + + public static ScreenAttribute required(String type) { + return new ScreenAttribute(type, true, null, List.of()); + } + + public static ScreenAttribute defaulted(String type, String value) { + return new ScreenAttribute(type, false, Objects.requireNonNull(value), List.of()); + } + + public static ScreenAttribute choices(String... values) { + return new ScreenAttribute("xs:string", false, null, List.of(values)); + } + + public ScreenAttribute asRequired() { + return new ScreenAttribute(type, true, null, choices); + } + + public ScreenAttribute withDefault(String value) { + return new ScreenAttribute(type, false, Objects.requireNonNull(value), choices); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java index a94c674e..ddaf08a0 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java @@ -11,11 +11,11 @@ import java.util.Set; import zone.moddev.mc.lib.gui.WidgetTypes; -import zone.moddev.mc.lib.gui.load.BoundValue.Kind; import zone.moddev.mc.lib.gui.model.Element; -import zone.moddev.mc.lib.gui.model.Element.*; import zone.moddev.mc.lib.gui.model.Screen; import zone.moddev.mc.lib.gui.model.ValueRef; +import zone.moddev.mc.lib.gui.value.ValueType; +import zone.moddev.mc.lib.gui.value.ValueTypeRegistry; /** * Resolves a screen's {@code value} and {@code max} getters against its block-entity class at load, @@ -62,6 +62,13 @@ public static Bound bind(Screen screen, ClassLoader loader) { /** Binds a screen read with an explicit widget registry. */ public static Bound bind(Screen screen, ClassLoader loader, WidgetTypes widgets) { + return bind(screen, loader, widgets, ValueTypeRegistry.GLOBAL); + } + + /** Binds using explicit registries, useful for isolated loaders and extension tests. */ + public static Bound bind( + Screen screen, ClassLoader loader, WidgetTypes widgets, ValueTypeRegistry types) { + ScreenValidation.check(screen, widgets); var source = screen.source(); Class owner = load(screen.blockEntity(), loader, source, 0, "block-entity class"); Class base = @@ -73,11 +80,15 @@ public static Bound bind(Screen screen, ClassLoader loader, WidgetTypes widgets) for (var element : screen.elements()) { var c = element.common(); var elementSource = screen.source(c); - if (element instanceof Button button - && button.processorControl() + if (element.resourceBinding().isPresent() && !zone.moddev.mc.lib.gui.runtime.ScreenHost.class.isAssignableFrom(owner)) throw new ScreenException( - elementSource, c.line(), " requires a ScreenHost"); + elementSource, c.line(), "Resource-bound elements require a ScreenHost"); + try { + element.validateHostType(owner); + } catch (IllegalArgumentException failure) { + throw new ScreenException(elementSource, c.line(), failure.getMessage()); + } if (c.value().isPresent()) values.put( element, @@ -87,51 +98,43 @@ public static Bound bind(Screen screen, ClassLoader loader, WidgetTypes widgets) loader, elementSource, c.line(), - valueKinds(element, widgets))); + element.valueKinds(widgets), + types)); if (c.max().isPresent()) maxima.put( - element, resolve(c.max().get(), owner, loader, elementSource, c.line(), NUMBERS)); + element, + resolve( + c.max().get(), + owner, + loader, + elementSource, + c.line(), + element.maximumKinds(), + types)); } return new Bound(screen, owner, values, maxima); } - private static final Set NUMBERS = Set.of(Kind.WHOLE, Kind.DECIMAL, Kind.NUMBER); - - /** What each element's {@code value} may return. */ - private static Set valueKinds(Element element, WidgetTypes widgets) { - return switch (element) { - case Custom custom -> widgets.require(custom.type()).valueKinds(); - case Gauge gauge -> - gauge.color().isPresent() - ? NUMBERS - : Set.of(Kind.WHOLE, Kind.DECIMAL, Kind.NUMBER, Kind.FLUID); - case Meter meter -> NUMBERS; - case EnergyGauge gauge -> NUMBERS; - case Progress progress -> NUMBERS; - case Checkbox checkbox -> Set.of(Kind.BOOLEAN); - case Button button when !button.icons().isEmpty() || !button.valueLabels().isEmpty() -> - Set.of(Kind.BOOLEAN, Kind.WHOLE); - case RadioGroup group -> Set.of(Kind.WHOLE); - case Popout popout -> Set.of(Kind.TEXT, Kind.TEXT_LIST); - // Anything else only feeds {value} into its tooltip. - default -> Set.of(Kind.values()); - }; - } - private static BoundValue resolve( ValueRef ref, Class owner, ClassLoader loader, String source, int line, - Set allowed) { + Set> allowed, + ValueTypeRegistry types) { Method method = switch (ref) { case ValueRef.Instance instance -> instanceMethod(owner, instance, source, line); case ValueRef.Indexed indexed -> indexedMethod(owner, indexed, source, line); case ValueRef.Static fixed -> staticMethod(owner, fixed, loader, source, line); }; - var kind = Kind.of(method.getReturnType()); + ValueType kind; + try { + kind = types.binding(method.getReturnType()).orElse(null); + } catch (IllegalArgumentException failure) { + throw new ScreenException(source, line, failure.getMessage(), failure); + } if (kind == null || !allowed.contains(kind)) throw new ScreenException( source, @@ -219,22 +222,11 @@ private static Class load( } } - private static String describe(Set kinds) { - if (kinds.equals(NUMBERS)) return "a number"; + private static String describe(Set> kinds) { if (kinds.isEmpty()) return "no value binding"; return kinds.stream() - .sorted() - .map( - kind -> - switch (kind) { - case WHOLE -> "a whole number"; - case DECIMAL -> "a decimal number"; - case NUMBER -> "a declared Number"; - case BOOLEAN -> "a boolean"; - case FLUID -> "a FluidStack"; - case TEXT -> "a Component"; - case TEXT_LIST -> "a collection of Components"; - }) + .sorted(java.util.Comparator.comparing(ValueType::id)) + .map(ValueType::description) .collect(java.util.stream.Collectors.joining(" or ")); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java index ad92bcb7..b2536641 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java @@ -44,6 +44,8 @@ public static Map load( Map embedded, ScreenFillGenerator generator) { WidgetTypes.GLOBAL.freeze(); + zone.moddev.mc.lib.gui.value.ValueTypeRegistry.GLOBAL.freeze(); + ScreenTags.GLOBAL.freeze(); var discovered = discover(sources); var screens = new TreeMap(); embedded.forEach( @@ -59,13 +61,22 @@ public static Map load( /** Reads and composes concrete screens without loading host classes, for layout tools. */ public static Map read(List sources, WidgetTypes widgets) { - return read(discover(sources).nodes(), widgets); + return read(sources, widgets, ScreenTags.GLOBAL); + } + + public static Map read( + List sources, WidgetTypes widgets, ScreenTags tags) { + return read(discover(sources, tags).nodes(), widgets, ScreenFillGenerator.UNAVAILABLE, tags); } private record Definitions( Map nodes, Map loaders) {} private static Definitions discover(List sources) { + return discover(sources, ScreenTags.GLOBAL); + } + + private static Definitions discover(List sources, ScreenTags tags) { var definitions = new TreeMap(); var loaders = new TreeMap(); for (var source : sources) { @@ -76,7 +87,7 @@ private static Definitions discover(List sources) { files.filter(Files::isRegularFile).filter(ScreenLoader::isXml).sorted().toList()) { var id = id(source.namespace(), folder, file); try (var input = Files.newInputStream(file)) { - var definition = ScreenReader.parse(file.toString(), input); + var definition = ScreenReader.parse(file.toString(), input, tags); if (definitions.putIfAbsent(id, definition) != null) throw new IllegalArgumentException("Duplicate screen " + id + ": " + file); loaders.put(id, source.classLoader()); @@ -99,13 +110,25 @@ public static Map read( Map definitions, WidgetTypes widgets, ScreenFillGenerator generator) { + return read(definitions, widgets, generator, ScreenTags.GLOBAL); + } + + /** Compiles using an explicit registry, including embedded and generated namespaced nodes. */ + public static Map read( + Map definitions, + WidgetTypes widgets, + ScreenFillGenerator generator, + ScreenTags tags) { + var validated = new TreeMap(); + definitions.forEach((id, node) -> validated.put(id, ScreenReader.validateExpanded(node, tags))); + definitions = validated; var screens = new TreeMap(); - var templates = new ScreenTemplates(definitions, generator); + var templates = new ScreenTemplates(definitions, generator, tags); // Discover all definitions first: bases may be in later files or another mod. for (var id : definitions.keySet()) templates.resolve(id); for (var id : definitions.keySet()) { if (ScreenTemplates.flag(definitions.get(id), "abstract")) continue; - screens.put(id, ScreenReader.build(id, templates.expand(id), widgets)); + screens.put(id, ScreenReader.build(id, templates.expand(id), widgets, tags)); } return screens; } diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java index 2629552c..23224f3b 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java @@ -3,20 +3,15 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; -import java.util.Set; import javax.xml.XMLConstants; +import javax.xml.namespace.QName; import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.SAXParserFactory; -import javax.xml.transform.stream.StreamSource; import javax.xml.validation.Schema; -import javax.xml.validation.SchemaFactory; import net.minecraft.resources.ResourceLocation; @@ -28,19 +23,12 @@ import org.xml.sax.helpers.DefaultHandler; import zone.moddev.mc.lib.gui.WidgetTypes; -import zone.moddev.mc.lib.gui.model.Common; -import zone.moddev.mc.lib.gui.model.Element; -import zone.moddev.mc.lib.gui.model.Element.*; -import zone.moddev.mc.lib.gui.model.ProcessorButtonState; import zone.moddev.mc.lib.gui.model.Screen; -import zone.moddev.mc.lib.gui.model.Text; -import zone.moddev.mc.lib.gui.model.ValueRef; /** - * Reads a screen file into a {@link Screen}. The file is validated against {@code - * mmdlib/schema/screen.xsd} (structure, attribute names and value formats), then checked for what a - * schema cannot express: unique IDs and slot indexes, references between elements, required - * pairings, and each element's needs. Errors name the file and line. + * Reads a screen file into a {@link Screen}. Registered tag contracts validate structure, attribute + * names and value formats. Model validation then checks unique IDs and slot indexes, references + * between elements, required pairings, and each element's needs. Errors name the file and line. * *

The parser is hardened: DOCTYPE declarations, external entities and XInclude are refused, so a * screen file cannot read other files or expand entities. @@ -49,8 +37,6 @@ public final class ScreenReader { /** The XML namespace of screen files. */ public static final String NAMESPACE = "https://moddev.zone/mmdlib/screen"; - private static final Schema SCHEMA = loadSchema(); - private ScreenReader() {} /** @@ -70,15 +56,29 @@ public static Screen read(ResourceLocation id, String source, InputStream input) public static Screen read( ResourceLocation id, String source, InputStream input, WidgetTypes widgets) throws IOException { - Node root = parse(source, input); - return build(id, new ScreenTemplates(Map.of(id, root)).expand(id), widgets); + return read(id, source, input, widgets, ScreenTags.GLOBAL); + } + + /** Reads with an isolated XML registry, including namespaced mod tags. */ + public static Screen read( + ResourceLocation id, String source, InputStream input, WidgetTypes widgets, ScreenTags tags) + throws IOException { + Node root = parse(source, input, tags); + return build( + id, + new ScreenTemplates(Map.of(id, root), ScreenFillGenerator.UNAVAILABLE, tags).expand(id), + widgets, + tags); } static Screen build(ResourceLocation id, Node root, WidgetTypes widgets) { - root = validateExpanded(root); - String source = root.source(); - var screen = new Builder(id, source, widgets).screen(id, root); - check(screen); + return build(id, root, widgets, ScreenTags.GLOBAL); + } + + static Screen build(ResourceLocation id, Node root, WidgetTypes widgets, ScreenTags tags) { + root = validateExpanded(root, tags); + var screen = new ScreenXml(id, root, widgets, tags).read(root, Screen.class); + ScreenValidation.check(screen, widgets); return screen; } @@ -86,22 +86,40 @@ static Screen build(ResourceLocation id, Node root, WidgetTypes widgets) { /** One parsed element, with its attributes as written (schema defaults filled in). */ public record Node( - String name, Map attributes, List children, String source, int line) { + String name, + Map attributes, + List children, + String source, + int line, + String namespace) { + public Node( + String name, Map attributes, List children, String source, int line) { + this(name, attributes, children, source, line, NAMESPACE); + } + + public QName tag() { + return new QName(namespace, name); + } + public Node withChildren(List children) { - return new Node(name, attributes, children, source, line); + return new Node(name, attributes, children, source, line, namespace); } } static Node parse(String source, InputStream input) throws IOException { - return parseTree(source, input, true); + return parse(source, input, ScreenTags.GLOBAL); + } + + static Node parse(String source, InputStream input, ScreenTags tags) throws IOException { + return parseTree(source, input, tags.schema()); } /** Secure, source-aware tree reader for machine definitions containing embedded screens. */ public static Node parseMachine(String source, InputStream input) throws IOException { - return parseTree(source, input, false); + return parseTree(source, input, null); } - private static Node parseTree(String source, InputStream input, boolean screen) + private static Node parseTree(String source, InputStream input, Schema schema) throws IOException { try { var factory = SAXParserFactory.newInstance(); @@ -112,8 +130,8 @@ private static Node parseTree(String source, InputStream input, boolean screen) factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - if (screen) factory.setSchema(SCHEMA); - var handler = new TreeHandler(source, screen); + if (schema != null) factory.setSchema(schema); + var handler = new TreeHandler(source, schema == null); var parser = factory.newSAXParser(); parser.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); parser.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); @@ -132,15 +150,16 @@ private static Node freeze(Node node) { Map.copyOf(node.attributes()), node.children().stream().map(ScreenReader::freeze).toList(), node.source(), - node.line()); + node.line(), + node.namespace()); } /** Check placement again after fills are spliced into their destination containers. */ - private static Node validateExpanded(Node root) { - var validator = SCHEMA.newValidatorHandler(); + static Node validateExpanded(Node root, ScreenTags tags) { + var validator = tags.schema().newValidatorHandler(); var locator = new org.xml.sax.helpers.LocatorImpl(); validator.setDocumentLocator(locator); - var tree = new TreeHandler(root.source(), true); + var tree = new TreeHandler(root.source(), false); tree.setDocumentLocator(locator); validator.setContentHandler(tree); validator.setErrorHandler( @@ -176,24 +195,14 @@ private static void validateNode( var attributes = new org.xml.sax.helpers.AttributesImpl(); node.attributes() .forEach((name, value) -> attributes.addAttribute("", name, name, "CDATA", value)); - validator.startElement(NAMESPACE, node.name(), node.name(), attributes); + // The URI, not the author's chosen prefix, is the registry identity. + validator.startPrefixMapping("", node.namespace()); + validator.startElement(node.namespace(), node.name(), node.name(), attributes); for (var child : node.children()) validateNode(child, validator, locator); locator.setSystemId(node.source()); locator.setLineNumber(node.line()); - validator.endElement(NAMESPACE, node.name(), node.name()); - } - - private static Schema loadSchema() { - try (var stream = ScreenReader.class.getResourceAsStream("/mmdlib/schema/screen.xsd")) { - if (stream == null) throw new IllegalStateException("Missing mmdlib/schema/screen.xsd"); - var factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); - factory.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); - return factory.newSchema(new StreamSource(stream)); - } catch (IOException | SAXException ex) { - throw new IllegalStateException("Cannot load the screen schema", ex); - } + validator.endElement(node.namespace(), node.name(), node.name()); + validator.endPrefixMapping(""); } private static final class TreeHandler extends DefaultHandler { @@ -202,11 +211,11 @@ private static final class TreeHandler extends DefaultHandler { private final List stack = new ArrayList<>(); private Locator locator; private Node root; - private final boolean screen; + private final boolean machine; - TreeHandler(String source, boolean screen) { + TreeHandler(String source, boolean machine) { this.source = source; - this.screen = screen; + this.machine = machine; } @Override @@ -217,9 +226,8 @@ public void setDocumentLocator(Locator locator) { @Override public void startElement(String uri, String local, String qName, Attributes attributes) throws SAXException { - if (!NAMESPACE.equals(uri) && (screen || !"https://moddev.zone/mmdlib/machine".equals(uri))) - throw new SAXParseException( - "Element <" + qName + "> is not in the screen namespace " + NAMESPACE, locator); + if (uri.isEmpty()) + throw new SAXParseException("Screen elements require an XML namespace", locator); var values = new LinkedHashMap(); for (int i = 0; i < attributes.getLength(); i++) if (attributes.getURI(i).isEmpty()) @@ -231,13 +239,20 @@ public void startElement(String uri, String local, String qName, Attributes attr values, children, locator.getSystemId() == null ? source : locator.getSystemId(), - locator.getLineNumber()); + locator.getLineNumber(), + machine && uri.equals("https://moddev.zone/mmdlib/machine") ? NAMESPACE : uri); if (stack.isEmpty()) root = node; else open.getLast().add(node); stack.add(node); open.add(children); } + @Override + public void characters(char[] text, int start, int length) throws SAXException { + if (!new String(text, start, length).isBlank()) + throw new SAXParseException("Inline text is not allowed; use text attributes", locator); + } + @Override public void endElement(String uri, String local, String qName) { stack.removeLast(); @@ -254,491 +269,4 @@ public void fatalError(SAXParseException ex) throws SAXException { throw ex; } } - - // Conversion to the model - - private static final class Builder { - private final String machineId; - private final String source; - private final WidgetTypes widgets; - private final Map origins = new java.util.IdentityHashMap<>(); - - Builder(ResourceLocation id, String source, WidgetTypes widgets) { - this.machineId = id.toString(); - this.source = source; - this.widgets = widgets; - } - - Screen screen(ResourceLocation id, Node root) { - var body = new ArrayList(); - var tabs = new ArrayList(); - for (var child : root.children()) { - var element = element(child); - if (element instanceof SideTab || element instanceof Popout) tabs.add(element); - else body.add(element); - } - if (body.isEmpty() || body.getFirst() instanceof InventoryControls) - throw error(root, "A screen requires at least one body element"); - return new Screen( - id, - source, - text(root, "title").orElseThrow(), - root.attributes().get("block-entity"), - !"none".equals(root.attributes().get("inventory")), - body, - tabs, - origins); - } - - private List children(Node node) { - return node.children().stream().map(this::element).toList(); - } - - private Element element(Node node) { - if (node.name().equals("energy-gauge")) node = energyDefaults(node); - if (node.name().equals("processor-button")) node = processorDefaults(node); - var c = common(node); - return switch (node.name()) { - case "vbox", "hbox" -> - new Box( - c, - node.name().equals("vbox"), - size(node, "padding", 0), - size(node, "spacing", 0), - enumValue(Align.class, node, "align"), - children(node)); - case "spacer" -> new Spacer(c); - case "inventory-controls" -> new InventoryControls(c, children(node)); - case "custom" -> custom(node, c); - case "group" -> - new Group( - c, - text(node, "label"), - color(node, "tint"), - size(node, "padding", 0), - children(node)); - case "slot" -> - new Slot( - c, - integer(node, "index"), - enumValue(SlotSize.class, node, "size"), - enumValue(SlotKind.class, node, "kind"), - color(node, "tint"), - id(node, "icon")); - case "gauge" -> - new Gauge( - c, - enumValue(Orientation.class, node, "orientation"), - length(node, 8), - color(node, "color")); - case "energy-gauge" -> - new EnergyGauge( - c, - enumValue(Orientation.class, node, "orientation"), - length(node, 8), - color(node, "color").orElseThrow()); - case "meter" -> - new Meter( - c, - enumValue(Orientation.class, node, "orientation"), - integer(node, "thickness"), - length(node, 4), - color(node, "color").orElseThrow(), - size(node, "segment", 1), - size(node, "gap", 0), - color(node, "gap-color")); - case "progress" -> - new Progress(c, id(node, "icon"), enumValue(Direction.class, node, "direction")); - case "label" -> new Label(c, text(node, "text").orElseThrow()); - case "button", "processor-button" -> - new Button( - c, - text(node, "text"), - id(node, "icon"), - Optional.ofNullable(node.attributes().get("shows")).map(ViewRef::parse), - words(node, "icons").stream().map(ResourceLocation::parse).toList(), - words(node, "value-labels").stream() - .map(key -> new Text(translationKey(key), Optional.empty())) - .toList(), - java.util.Set.of("true", "1") - .contains(node.attributes().getOrDefault("flat", "false")), - Optional.ofNullable(node.attributes().get("processor-key")), - node.name().equals("processor-button")); - case "checkbox" -> new Checkbox(c, text(node, "text").orElseThrow()); - case "radio-group" -> - new RadioGroup( - c, - text(node, "label"), - node.children().stream().map(child -> (Radio) element(child)).toList()); - case "radio" -> new Radio(c, text(node, "text").orElseThrow()); - case "context" -> - new Context(c, node.children().stream().map(child -> (View) element(child)).toList()); - case "view" -> new View(c, text(node, "title"), children(node)); - case "side-config" -> - new SideConfig(c, List.of(node.attributes().get("groups").trim().split("\\s+"))); - case "player-inventory" -> new PlayerInventory(c); - case "side-tab" -> new SideTab(c, enumValue(Edge.class, node, "edge"), children(node)); - case "popout" -> - new Popout( - c, - enumValue(PopoutKind.class, node, "kind"), - id(node, "icon"), - enumValue(Edge.class, node, "edge"), - text(node, "text")); - default -> throw error(node, "Unknown element <" + node.name() + ">"); - }; - } - - private Custom custom(Node node, Common common) { - var properties = new LinkedHashMap(); - for (var property : node.children()) { - var name = property.attributes().get("name"); - if (properties.putIfAbsent(name, property.attributes().get("value")) != null) - throw error(property, "Duplicate widget property: " + name); - } - var element = - new Custom( - common, - id(node, "type").orElseThrow(), - size(node, "width", 1), - size(node, "height", 1), - properties); - try { - var type = widgets.require(element.type()); - if (common.value().isPresent() && type.valueKinds().isEmpty()) - throw new IllegalArgumentException("This widget does not accept a value binding"); - type.validator().accept(element); - } catch (RuntimeException ex) { - throw new ScreenException( - node.source(), node.line(), element.type() + ": " + ex.getMessage(), ex); - } - return element; - } - - /** Standard energy bindings can be overridden for machines with several buffers. */ - private Node energyDefaults(Node node) { - var attributes = new LinkedHashMap<>(node.attributes()); - attributes.putIfAbsent("value", "#getEnergy"); - attributes.putIfAbsent("max", "#getEnergyCapacity"); - if (!attributes.containsKey("tooltip")) { - attributes.put("tooltip", "screen.mmdlib.energy"); - attributes.putIfAbsent("tooltip-fallback", "{value} / {max} FE"); - } - return new Node(node.name(), attributes, node.children(), node.source(), node.line()); - } - - private Node processorDefaults(Node node) { - if (node.attributes().containsKey("processor-key") - && node.attributes().get("processor-key").isBlank()) - throw error(node, "processor-key must not be blank"); - var attributes = new LinkedHashMap<>(node.attributes()); - attributes.put( - "icons", - java.util.Arrays.stream(ProcessorButtonState.values()) - .map(state -> state.icon().toString()) - .collect(java.util.stream.Collectors.joining(" "))); - attributes.put( - "value-labels", - java.util.Arrays.stream(ProcessorButtonState.values()) - .map(ProcessorButtonState::label) - .collect(java.util.stream.Collectors.joining(" "))); - if (!attributes.containsKey("tooltip")) - attributes.put("tooltip", "screen.mmdlib.processor.toggle"); - return new Node(node.name(), attributes, node.children(), node.source(), node.line()); - } - - private Common common(Node node) { - var a = node.attributes(); - var recipes = a.get("jei-recipes"); - var common = - new Common( - Optional.ofNullable(a.get("id")), - value(node, "value"), - value(node, "max"), - text(node, "tooltip"), - text(node, "tooltip-shift"), - new Common.Clicks( - id(node, "on-click"), - id(node, "on-right-click"), - id(node, "on-shift-click"), - id(node, "on-shift-right-click")), - id(node, "on-hover"), - Optional.ofNullable(a.get("hover-shows")), - recipes == null || recipes.isBlank() - ? List.of() - : java.util.Arrays.stream(recipes.trim().split("\\s+")) - .map(ResourceLocation::parse) - .toList(), - a.containsKey("offset-x") ? offset(node, "offset-x") : 0, - a.containsKey("offset-y") ? offset(node, "offset-y") : 0, - node.line()); - origins.put(common, node.source()); - return common; - } - - private Optional text(Node node, String name) { - var key = node.attributes().get(name); - var fallback = Optional.ofNullable(node.attributes().get(name + "-fallback")); - if (key == null) { - if (fallback.isPresent()) - throw error(node, name + "-fallback needs " + name + " (a translation key)"); - return Optional.empty(); - } - return Optional.of(new Text(translationKey(key), fallback)); - } - - private String translationKey(String key) { - return key.replace("{machineId}", machineId); - } - - private List words(Node node, String name) { - String value = node.attributes().getOrDefault(name, "").trim(); - return value.isEmpty() ? List.of() : List.of(value.split("\\s+")); - } - - private Optional value(Node node, String name) { - return Optional.ofNullable(node.attributes().get(name)).map(ValueRef::parse); - } - - private Optional id(Node node, String name) { - var value = node.attributes().get(name); - if (value == null) return Optional.empty(); - var id = ResourceLocation.tryParse(value); - if (id == null) throw error(node, "Invalid ID in " + name + ": " + value); - return Optional.of(id); - } - - private Optional color(Node node, String name) { - var value = node.attributes().get(name); - if (value == null) return Optional.empty(); - long rgb = Long.parseLong(value.substring(1), 16); - return Optional.of((int) (value.length() == 7 ? 0xFF000000L | rgb : rgb)); - } - - /** - * A size attribute in GUI pixels: a plain number is pixels, an {@code s} suffix counts slots - * (18 px each), rounded to the nearest pixel. - */ - private int size(Node node, String name, int minimum) { - int pixels = pixels(node, name); - if (pixels < minimum || pixels > 1024) - throw error(node, name + " must be from " + minimum + " to 1024 px, not " + pixels); - return pixels; - } - - private int length(Node node, int minimum) { - return "fill".equals(node.attributes().get("length")) - ? Element.FILL - : size(node, "length", minimum); - } - - private int offset(Node node, String name) { - int pixels = pixels(node, name); - if (Math.abs(pixels) > 64) - throw error(node, name + " must be within 64 px either way, not " + pixels); - return pixels; - } - - private int pixels(Node node, String name) { - var value = node.attributes().get(name).trim(); - try { - if (!value.endsWith("s")) return Integer.parseInt(value); - var slots = new java.math.BigDecimal(value.substring(0, value.length() - 1)); - return slots - .multiply(java.math.BigDecimal.valueOf(Element.SLOT)) - .setScale(0, java.math.RoundingMode.HALF_UP) - .intValueExact(); - } catch (NumberFormatException | ArithmeticException ex) { - throw error(node, name + " is too large: " + value); - } - } - - private int integer(Node node, String name) { - return Integer.parseInt(node.attributes().get(name).trim()); - } - - private > E enumValue(Class type, Node node, String name) { - return Enum.valueOf( - type, node.attributes().get(name).toUpperCase(java.util.Locale.ROOT).replace('-', '_')); - } - - private ScreenException error(Node node, String message) { - return new ScreenException(node.source(), node.line(), message); - } - } - - // Checks a schema cannot express - - private static void check(Screen screen) { - var ids = new HashMap(); - var slots = new HashMap(); - var contexts = new HashMap>(); - Element inventory = null; - for (var element : screen.elements()) { - var c = element.common(); - var source = screen.source(c); - if (c.id().isPresent() && ids.putIfAbsent(c.id().get(), element) != null) - throw new ScreenException(source, c.line(), "Duplicate id: " + c.id().get()); - if (element instanceof Slot slot && slots.putIfAbsent(slot.index(), element) != null) - throw new ScreenException( - source, c.line(), "Slot " + slot.index() + " is placed twice on this screen"); - if (element instanceof Context context) { - if (c.id().isEmpty()) - throw new ScreenException(source, c.line(), " needs an id for buttons to name"); - var views = new HashSet(); - for (var view : context.views()) - if (view.common().id().isEmpty()) - throw new ScreenException( - screen.source(view.common()), - view.common().line(), - " needs an id for buttons to name"); - else views.add(view.common().id().get()); - contexts.put(c.id().get(), views); - } - if (element instanceof PlayerInventory) { - if (inventory != null) - throw new ScreenException(source, c.line(), " may appear only once"); - inventory = element; - } - } - if (inventory != null && !screen.playerInventory()) - throw new ScreenException( - screen.source(inventory.common()), - inventory.common().line(), - " cannot be placed on a screen with inventory=\"none\""); - for (var element : screen.elements()) { - var c = element.common(); - var source = screen.source(c); - int line = c.line(); - if (c.hoverShows().isPresent() && !ids.containsKey(c.hoverShows().get())) - throw new ScreenException( - source, line, "hover-shows names no element: " + c.hoverShows().get()); - if (c.max().isPresent() && c.value().isEmpty()) - throw new ScreenException(source, line, "max needs value"); - switch (element) { - case Button button -> { - if (button.processorControl() - && (c.value().isPresent() - || c.max().isPresent() - || c.clicks().any() - || !c.jeiRecipes().isEmpty())) - throw new ScreenException( - source, - line, - " owns its state and click action; value, max, click handlers and" - + " jei-recipes are not allowed"); - if (button.flat() && button.text().isPresent()) - throw new ScreenException( - source, line, "A flat button uses an icon, not a text caption"); - if (c.tooltip().isEmpty()) - throw new ScreenException(source, line, "