Repository: wisp-forest/owo-lib Branch: braid-ui Commit: 1fb7971326ba Files: 813 Total size: 2.5 MB Directory structure: gitextract_0mpxqol_/ ├── .github/ │ └── workflows/ │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── braid-reload-agent/ │ ├── .gitattributes │ ├── .gitignore │ ├── build.gradle.kts │ ├── gradle/ │ │ ├── libs.versions.toml │ │ └── wrapper/ │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties │ ├── gradlew │ ├── gradlew.bat │ ├── settings.gradle.kts │ └── src/ │ └── main/ │ └── java/ │ └── io/ │ └── wispforest/ │ └── BraidReloadAgent.java ├── build.gradle ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── jitpack.yml ├── owo-sentinel/ │ ├── build.gradle │ ├── gradle.properties │ └── src/ │ └── main/ │ ├── java/ │ │ └── io/ │ │ └── wispforest/ │ │ └── owosentinel/ │ │ ├── DownloadTask.java │ │ ├── Maldenhagen.java │ │ ├── OwoSentinel.java │ │ ├── SentinelConsole.java │ │ └── SentinelWindow.java │ └── resources/ │ └── fabric.mod.json ├── owo-ui.xsd ├── settings.gradle ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── io/ │ │ │ └── wispforest/ │ │ │ └── owo/ │ │ │ ├── Owo.java │ │ │ ├── blockentity/ │ │ │ │ ├── LinearProcess.java │ │ │ │ └── LinearProcessExecutor.java │ │ │ ├── braid/ │ │ │ │ ├── animation/ │ │ │ │ │ ├── AlignmentLerp.java │ │ │ │ │ ├── Animation.java │ │ │ │ │ ├── AutomaticallyAnimatedWidget.java │ │ │ │ │ ├── ColorLerp.java │ │ │ │ │ ├── DoubleLerp.java │ │ │ │ │ ├── Easing.java │ │ │ │ │ ├── InsetsLerp.java │ │ │ │ │ ├── Lerp.java │ │ │ │ │ └── NullableLerp.java │ │ │ │ ├── core/ │ │ │ │ │ ├── Aabb2d.java │ │ │ │ │ ├── Alignment.java │ │ │ │ │ ├── AppState.java │ │ │ │ │ ├── BraidGraphics.java │ │ │ │ │ ├── BraidHotReloadCallback.java │ │ │ │ │ ├── BraidRenderPipelines.java │ │ │ │ │ ├── BraidScreen.java │ │ │ │ │ ├── BraidUtils.java │ │ │ │ │ ├── BraidWindow.java │ │ │ │ │ ├── BraidWindowScheduler.java │ │ │ │ │ ├── Color.java │ │ │ │ │ ├── CompoundListenable.java │ │ │ │ │ ├── Constraints.java │ │ │ │ │ ├── EventBinding.java │ │ │ │ │ ├── Insets.java │ │ │ │ │ ├── KeyModifiers.java │ │ │ │ │ ├── LayoutAxis.java │ │ │ │ │ ├── Listenable.java │ │ │ │ │ ├── ListenableValue.java │ │ │ │ │ ├── RelativePosition.java │ │ │ │ │ ├── Size.java │ │ │ │ │ ├── Surface.java │ │ │ │ │ ├── TextLayout.java │ │ │ │ │ ├── TextureSurface.java │ │ │ │ │ ├── cursor/ │ │ │ │ │ │ ├── CursorController.java │ │ │ │ │ │ ├── CursorStyle.java │ │ │ │ │ │ └── SystemCursorStyle.java │ │ │ │ │ ├── element/ │ │ │ │ │ │ ├── BraidBlockElement.java │ │ │ │ │ │ ├── BraidDashedLineElement.java │ │ │ │ │ │ ├── BraidEntityElement.java │ │ │ │ │ │ └── BraidItemElement.java │ │ │ │ │ └── events/ │ │ │ │ │ ├── CharInputEvent.java │ │ │ │ │ ├── CloseEvent.java │ │ │ │ │ ├── FilesDroppedEvent.java │ │ │ │ │ ├── KeyPressEvent.java │ │ │ │ │ ├── KeyReleaseEvent.java │ │ │ │ │ ├── MouseButtonPressEvent.java │ │ │ │ │ ├── MouseButtonReleaseEvent.java │ │ │ │ │ ├── MouseMoveEvent.java │ │ │ │ │ ├── MouseScrollEvent.java │ │ │ │ │ └── UserEvent.java │ │ │ │ ├── display/ │ │ │ │ │ ├── BraidDisplay.java │ │ │ │ │ ├── BraidDisplayBinding.java │ │ │ │ │ └── DisplayQuad.java │ │ │ │ ├── framework/ │ │ │ │ │ ├── BuildContext.java │ │ │ │ │ ├── instance/ │ │ │ │ │ │ ├── CustomWidgetTransform.java │ │ │ │ │ │ ├── Hit.java │ │ │ │ │ │ ├── HitTestState.java │ │ │ │ │ │ ├── InspectorProperty.java │ │ │ │ │ │ ├── InstanceHost.java │ │ │ │ │ │ ├── LeafWidgetInstance.java │ │ │ │ │ │ ├── MouseListener.java │ │ │ │ │ │ ├── MultiChildWidgetInstance.java │ │ │ │ │ │ ├── OptionalChildWidgetInstance.java │ │ │ │ │ │ ├── SingleChildWidgetInstance.java │ │ │ │ │ │ ├── TooltipProvider.java │ │ │ │ │ │ ├── WidgetInstance.java │ │ │ │ │ │ └── WidgetTransform.java │ │ │ │ │ ├── proxy/ │ │ │ │ │ │ ├── BuildScope.java │ │ │ │ │ │ ├── ComposedProxy.java │ │ │ │ │ │ ├── InheritedProxy.java │ │ │ │ │ │ ├── InstanceWidgetProxy.java │ │ │ │ │ │ ├── LeafInstanceWidgetProxy.java │ │ │ │ │ │ ├── MultiChildInstanceWidgetProxy.java │ │ │ │ │ │ ├── OptionalChildInstanceWidgetProxy.java │ │ │ │ │ │ ├── ProxyHost.java │ │ │ │ │ │ ├── SingleChildInstanceWidgetProxy.java │ │ │ │ │ │ ├── StatefulProxy.java │ │ │ │ │ │ ├── StatelessProxy.java │ │ │ │ │ │ ├── WidgetProxy.java │ │ │ │ │ │ └── WidgetState.java │ │ │ │ │ └── widget/ │ │ │ │ │ ├── InheritedWidget.java │ │ │ │ │ ├── InstanceWidget.java │ │ │ │ │ ├── Key.java │ │ │ │ │ ├── LeafInstanceWidget.java │ │ │ │ │ ├── MultiChildInstanceWidget.java │ │ │ │ │ ├── OptionalChildInstanceWidget.java │ │ │ │ │ ├── SingleChildInstanceWidget.java │ │ │ │ │ ├── StatefulWidget.java │ │ │ │ │ ├── StatelessWidget.java │ │ │ │ │ ├── Widget.java │ │ │ │ │ └── WidgetSetupCallback.java │ │ │ │ ├── util/ │ │ │ │ │ ├── BraidGuiRenderer.java │ │ │ │ │ ├── BraidHudElement.java │ │ │ │ │ ├── BraidToast.java │ │ │ │ │ ├── BraidTooltipComponent.java │ │ │ │ │ ├── EmbedderRoot.java │ │ │ │ │ ├── kdl/ │ │ │ │ │ │ ├── BraidKdlEndecs.java │ │ │ │ │ │ ├── KdlDeserializer.java │ │ │ │ │ │ ├── KdlElement.java │ │ │ │ │ │ ├── KdlEntityWidget.java │ │ │ │ │ │ ├── KdlMapper.java │ │ │ │ │ │ └── WidgetEndec.java │ │ │ │ │ └── layers/ │ │ │ │ │ ├── AnchorJustification.java │ │ │ │ │ ├── BraidLayersBinding.java │ │ │ │ │ ├── Justify.java │ │ │ │ │ ├── LayerAlignment.java │ │ │ │ │ └── LayerContext.java │ │ │ │ └── widgets/ │ │ │ │ ├── BraidApp.java │ │ │ │ ├── BraidLogo.java │ │ │ │ ├── Dialog.java │ │ │ │ ├── HoverStyledLabel.java │ │ │ │ ├── Marquee.java │ │ │ │ ├── Navigator.java │ │ │ │ ├── SpriteWidget.java │ │ │ │ ├── animated/ │ │ │ │ │ ├── AnimatedAlign.java │ │ │ │ │ ├── AnimatedBox.java │ │ │ │ │ ├── AnimatedPadding.java │ │ │ │ │ └── AnimatedSized.java │ │ │ │ ├── basic/ │ │ │ │ │ ├── Align.java │ │ │ │ │ ├── AspectRatio.java │ │ │ │ │ ├── Blur.java │ │ │ │ │ ├── Box.java │ │ │ │ │ ├── Builder.java │ │ │ │ │ ├── Center.java │ │ │ │ │ ├── Clip.java │ │ │ │ │ ├── Constrain.java │ │ │ │ │ ├── ConstraintWidget.java │ │ │ │ │ ├── ControlsOverride.java │ │ │ │ │ ├── CustomDraw.java │ │ │ │ │ ├── EmptyWidget.java │ │ │ │ │ ├── HitTestTrap.java │ │ │ │ │ ├── HoverableBuilder.java │ │ │ │ │ ├── IntrinsicHeight.java │ │ │ │ │ ├── IntrinsicWidth.java │ │ │ │ │ ├── LayoutBuilder.java │ │ │ │ │ ├── ListenableBuilder.java │ │ │ │ │ ├── MouseArea.java │ │ │ │ │ ├── Padding.java │ │ │ │ │ ├── Panel.java │ │ │ │ │ ├── RotatedLayout.java │ │ │ │ │ ├── Sized.java │ │ │ │ │ ├── TextureWidget.java │ │ │ │ │ ├── Tooltip.java │ │ │ │ │ ├── Transform.java │ │ │ │ │ ├── Visibility.java │ │ │ │ │ └── VisitorWidget.java │ │ │ │ ├── button/ │ │ │ │ │ ├── Button.java │ │ │ │ │ ├── ButtonPanel.java │ │ │ │ │ ├── ButtonStyle.java │ │ │ │ │ ├── Clickable.java │ │ │ │ │ ├── DefaultButtonStyle.java │ │ │ │ │ └── MessageButton.java │ │ │ │ ├── checkbox/ │ │ │ │ │ ├── Checkbox.java │ │ │ │ │ ├── CheckboxStyle.java │ │ │ │ │ ├── DefaultCheckboxStyle.java │ │ │ │ │ └── TogglingClickable.java │ │ │ │ ├── collapsible/ │ │ │ │ │ ├── Collapsible.java │ │ │ │ │ ├── CollapsibleCallback.java │ │ │ │ │ └── LazyCollapsible.java │ │ │ │ ├── combobox/ │ │ │ │ │ ├── ComboBox.java │ │ │ │ │ ├── ComboBoxButtons.java │ │ │ │ │ └── ComboBoxButtonsState.java │ │ │ │ ├── cycle/ │ │ │ │ │ ├── Cycler.java │ │ │ │ │ ├── CyclingButton.java │ │ │ │ │ ├── CyclingClickable.java │ │ │ │ │ └── MessageCyclingButton.java │ │ │ │ ├── drag/ │ │ │ │ │ ├── DragArena.java │ │ │ │ │ ├── DragArenaElement.java │ │ │ │ │ ├── DragArenaInstance.java │ │ │ │ │ └── DragParentData.java │ │ │ │ ├── eventstream/ │ │ │ │ │ ├── BraidEventSource.java │ │ │ │ │ ├── BraidEventStream.java │ │ │ │ │ └── StreamListenerState.java │ │ │ │ ├── flex/ │ │ │ │ │ ├── Column.java │ │ │ │ │ ├── CrossAxisAlignment.java │ │ │ │ │ ├── Flex.java │ │ │ │ │ ├── FlexInstance.java │ │ │ │ │ ├── FlexParentData.java │ │ │ │ │ ├── Flexible.java │ │ │ │ │ ├── MainAxisAlignment.java │ │ │ │ │ └── Row.java │ │ │ │ ├── focus/ │ │ │ │ │ ├── FocusClickArea.java │ │ │ │ │ ├── FocusLevel.java │ │ │ │ │ ├── FocusPolicy.java │ │ │ │ │ ├── FocusScope.java │ │ │ │ │ ├── FocusStateProvider.java │ │ │ │ │ ├── FocusTraversalDirection.java │ │ │ │ │ ├── Focusable.java │ │ │ │ │ └── RootFocusScope.java │ │ │ │ ├── grid/ │ │ │ │ │ └── Grid.java │ │ │ │ ├── inspector/ │ │ │ │ │ ├── BraidInspector.java │ │ │ │ │ ├── CollapsibleEntry.java │ │ │ │ │ ├── InspectorState.java │ │ │ │ │ ├── InspectorWidget.java │ │ │ │ │ ├── InstanceDetails.java │ │ │ │ │ ├── InstancePicker.java │ │ │ │ │ ├── InstanceTitle.java │ │ │ │ │ ├── InstanceTreeView.java │ │ │ │ │ └── RevealInstanceEvent.java │ │ │ │ ├── intents/ │ │ │ │ │ ├── Action.java │ │ │ │ │ ├── Actions.java │ │ │ │ │ ├── AdjustIntent.java │ │ │ │ │ ├── Intent.java │ │ │ │ │ ├── Interactable.java │ │ │ │ │ ├── PrimaryActionIntent.java │ │ │ │ │ ├── SecondaryActionIntent.java │ │ │ │ │ ├── ShortcutDecoder.java │ │ │ │ │ ├── ShortcutTrigger.java │ │ │ │ │ ├── Shortcuts.java │ │ │ │ │ ├── TraverseFocusAction.java │ │ │ │ │ ├── TraverseFocusIntent.java │ │ │ │ │ ├── Trigger.java │ │ │ │ │ └── TriggerType.java │ │ │ │ ├── label/ │ │ │ │ │ ├── DefaultLabelStyle.java │ │ │ │ │ ├── Label.java │ │ │ │ │ ├── LabelStyle.java │ │ │ │ │ └── RawLabel.java │ │ │ │ ├── object/ │ │ │ │ │ ├── BlockWidget.java │ │ │ │ │ ├── EntityWidget.java │ │ │ │ │ ├── ItemStackWidget.java │ │ │ │ │ └── RawBlockWidget.java │ │ │ │ ├── overlay/ │ │ │ │ │ ├── Overlay.java │ │ │ │ │ ├── OverlayEntry.java │ │ │ │ │ ├── OverlayEntryBuilder.java │ │ │ │ │ ├── OverlayParentData.java │ │ │ │ │ ├── OverlayProvider.java │ │ │ │ │ ├── RawOverlay.java │ │ │ │ │ └── RawOverlayElement.java │ │ │ │ ├── owoui/ │ │ │ │ │ ├── OwoUIWidget.java │ │ │ │ │ └── OwoUIWidgetWrapper.java │ │ │ │ ├── recipeviewer/ │ │ │ │ │ ├── RecipeViewerExclusionZone.java │ │ │ │ │ ├── RecipeViewerStack.java │ │ │ │ │ └── StackDropArea.java │ │ │ │ ├── scroll/ │ │ │ │ │ ├── ButtonScrollbar.java │ │ │ │ │ ├── DefaultScrollAnimationSettings.java │ │ │ │ │ ├── FlatScrollbar.java │ │ │ │ │ ├── HorizontallyScrollable.java │ │ │ │ │ ├── RawScrollView.java │ │ │ │ │ ├── ScrollAnimationSettings.java │ │ │ │ │ ├── ScrollController.java │ │ │ │ │ ├── Scrollable.java │ │ │ │ │ ├── ScrollableWithBars.java │ │ │ │ │ ├── Scrollbar.java │ │ │ │ │ └── VerticallyScrollable.java │ │ │ │ ├── sharedstate/ │ │ │ │ │ ├── ShareableState.java │ │ │ │ │ ├── SharedState.java │ │ │ │ │ └── SharedStateProvider.java │ │ │ │ ├── slider/ │ │ │ │ │ ├── DefaultSliderHandle.java │ │ │ │ │ ├── Incrementor.java │ │ │ │ │ ├── SliderStyle.java │ │ │ │ │ ├── drag/ │ │ │ │ │ │ ├── Drag.java │ │ │ │ │ │ ├── DragFunction.java │ │ │ │ │ │ ├── MessageDrag.java │ │ │ │ │ │ └── RawDrag.java │ │ │ │ │ ├── range/ │ │ │ │ │ │ ├── DefaultRangeSliderStyle.java │ │ │ │ │ │ ├── MessageRangeSlider.java │ │ │ │ │ │ ├── RangeSlider.java │ │ │ │ │ │ ├── RangeSliderCallback.java │ │ │ │ │ │ └── RangeSliderStyle.java │ │ │ │ │ ├── slider/ │ │ │ │ │ │ ├── DefaultSliderStyle.java │ │ │ │ │ │ ├── MessageSlider.java │ │ │ │ │ │ ├── Slider.java │ │ │ │ │ │ ├── SliderCallback.java │ │ │ │ │ │ └── SliderFunction.java │ │ │ │ │ └── xlyder/ │ │ │ │ │ ├── DefaultXlyderStyle.java │ │ │ │ │ ├── MessageXlyder.java │ │ │ │ │ ├── Xlyder.java │ │ │ │ │ └── XlyderCallback.java │ │ │ │ ├── splitpane/ │ │ │ │ │ ├── MultiSplitPane.java │ │ │ │ │ └── SplitPane.java │ │ │ │ ├── stack/ │ │ │ │ │ ├── Stack.java │ │ │ │ │ ├── StackBase.java │ │ │ │ │ └── StackParentData.java │ │ │ │ ├── textinput/ │ │ │ │ │ ├── CopyTextIntent.java │ │ │ │ │ ├── DeleteLineIntent.java │ │ │ │ │ ├── DeleteTextIntent.java │ │ │ │ │ ├── EditableText.java │ │ │ │ │ ├── InsertNewlineIntent.java │ │ │ │ │ ├── InsertTabIntent.java │ │ │ │ │ ├── MaxLengthFormatter.java │ │ │ │ │ ├── MoveCursorIntent.java │ │ │ │ │ ├── PasteTextIntent.java │ │ │ │ │ ├── PatternFormatter.java │ │ │ │ │ ├── SelectAllIntent.java │ │ │ │ │ ├── TeleportCursorIntent.java │ │ │ │ │ ├── TextBox.java │ │ │ │ │ ├── TextEditingController.java │ │ │ │ │ ├── TextEditingValue.java │ │ │ │ │ ├── TextInput.java │ │ │ │ │ └── TextSelection.java │ │ │ │ ├── vanilla/ │ │ │ │ │ ├── VanillaWidget.java │ │ │ │ │ └── VanillaWidgetWrapper.java │ │ │ │ └── window/ │ │ │ │ ├── Window.java │ │ │ │ └── WindowController.java │ │ │ ├── client/ │ │ │ │ ├── OwoClient.java │ │ │ │ ├── screens/ │ │ │ │ │ ├── MenuNetworkingInternals.java │ │ │ │ │ ├── MenuUtils.java │ │ │ │ │ ├── OwoAbstractContainerMenu.java │ │ │ │ │ ├── ScreenhandlerMessageData.java │ │ │ │ │ ├── SlotGenerator.java │ │ │ │ │ ├── SyncedProperty.java │ │ │ │ │ └── ValidatingSlot.java │ │ │ │ └── texture/ │ │ │ │ ├── AnimatedTextureDrawable.java │ │ │ │ └── SpriteSheetMetadata.java │ │ │ ├── command/ │ │ │ │ ├── EnumArgumentType.java │ │ │ │ └── debug/ │ │ │ │ ├── CcaDataCommand.java │ │ │ │ ├── DumpdataCommand.java │ │ │ │ ├── HealCommand.java │ │ │ │ ├── MakeLootContainerCommand.java │ │ │ │ └── OwoDebugCommands.java │ │ │ ├── compat/ │ │ │ │ ├── emi/ │ │ │ │ │ ├── EmiStackUtil.java │ │ │ │ │ └── OwoEmiPlugin.java │ │ │ │ ├── modmenu/ │ │ │ │ │ └── OwoModMenuPlugin.java │ │ │ │ └── rei/ │ │ │ │ ├── OwoReiPlugin.java │ │ │ │ ├── ReiStackUtil.java │ │ │ │ ├── ReiUIAdapter.java │ │ │ │ └── ReiWidgetComponent.java │ │ │ ├── config/ │ │ │ │ ├── ConfigAP.java │ │ │ │ ├── ConfigSynchronizer.java │ │ │ │ ├── ConfigWrapper.java │ │ │ │ ├── Option.java │ │ │ │ ├── OwoConfigCommand.java │ │ │ │ ├── annotation/ │ │ │ │ │ ├── Config.java │ │ │ │ │ ├── ExcludeFromScreen.java │ │ │ │ │ ├── Expanded.java │ │ │ │ │ ├── Hook.java │ │ │ │ │ ├── Modmenu.java │ │ │ │ │ ├── Nest.java │ │ │ │ │ ├── PredicateConstraint.java │ │ │ │ │ ├── RangeConstraint.java │ │ │ │ │ ├── RegexConstraint.java │ │ │ │ │ ├── RestartRequired.java │ │ │ │ │ ├── SectionHeader.java │ │ │ │ │ ├── Sync.java │ │ │ │ │ └── WithAlpha.java │ │ │ │ └── ui/ │ │ │ │ ├── ConfigScreen.java │ │ │ │ ├── ConfigScreenProviders.java │ │ │ │ ├── OptionComponentFactory.java │ │ │ │ ├── OptionComponents.java │ │ │ │ ├── RestartRequiredScreen.java │ │ │ │ └── component/ │ │ │ │ ├── ConfigEnumButton.java │ │ │ │ ├── ConfigSlider.java │ │ │ │ ├── ConfigTextBox.java │ │ │ │ ├── ConfigToggleButton.java │ │ │ │ ├── ListOptionContainer.java │ │ │ │ ├── OptionValueProvider.java │ │ │ │ └── SearchAnchorComponent.java │ │ │ ├── ext/ │ │ │ │ ├── DerivedComponentMap.java │ │ │ │ └── OwoItem.java │ │ │ ├── itemgroup/ │ │ │ │ ├── Icon.java │ │ │ │ ├── ItemGroupReference.java │ │ │ │ ├── OwoItemGroup.java │ │ │ │ ├── OwoItemSettingsExtension.java │ │ │ │ ├── gui/ │ │ │ │ │ ├── ItemGroupButton.java │ │ │ │ │ ├── ItemGroupButtonWidget.java │ │ │ │ │ └── ItemGroupTab.java │ │ │ │ └── json/ │ │ │ │ ├── OwoItemGroupLoader.java │ │ │ │ └── WrapperGroup.java │ │ │ ├── mixin/ │ │ │ │ ├── AbstractContainerMenuInvoker.java │ │ │ │ ├── AbstractContainerMenuMixin.java │ │ │ │ ├── ClientCommonPacketListenerImplAccessor.java │ │ │ │ ├── ClientConfigurationPacketListenerImplMixin.java │ │ │ │ ├── ClientHandshakePacketListenerImplAccessor.java │ │ │ │ ├── ConnectionMixin.java │ │ │ │ ├── Copenhagen.java │ │ │ │ ├── GuiGraphicsMixin.java │ │ │ │ ├── MainMixin.java │ │ │ │ ├── MinecraftMixin.java │ │ │ │ ├── ServerCommonPacketListenerImplAccessor.java │ │ │ │ ├── ServerPlayerGameModeMixin.java │ │ │ │ ├── ServerPlayerMixin.java │ │ │ │ ├── SetComponentsFunctionAccessor.java │ │ │ │ ├── TagLoaderMixin.java │ │ │ │ ├── braid/ │ │ │ │ │ ├── ClickableStyleFinderAccessor.java │ │ │ │ │ ├── GameRendererAccessor.java │ │ │ │ │ ├── GuiRendererAccessor.java │ │ │ │ │ ├── GuiRendererMixin.java │ │ │ │ │ ├── KeyboardHandlerMixin.java │ │ │ │ │ ├── LevelRendererMixin.java │ │ │ │ │ ├── Matrix3x2fStackAccessor.java │ │ │ │ │ ├── RenderTypeInvoker.java │ │ │ │ │ ├── ScreenMixin.java │ │ │ │ │ └── ToastManagerMixin.java │ │ │ │ ├── ext/ │ │ │ │ │ ├── ItemMixin.java │ │ │ │ │ ├── ItemStackMixin.java │ │ │ │ │ ├── PatchedDataComponentMapAccessor.java │ │ │ │ │ └── PatchedDataComponentMapMixin.java │ │ │ │ ├── extension/ │ │ │ │ │ ├── SimpleJsonResourceReloadListenerMixin.java │ │ │ │ │ ├── json5/ │ │ │ │ │ │ ├── FallbackResourceManagerMixin.java │ │ │ │ │ │ ├── FileToIdConverterMixin.java │ │ │ │ │ │ ├── LanguageReaderMixin.java │ │ │ │ │ │ └── MultiPackResourceManagerMixin.java │ │ │ │ │ └── recipe/ │ │ │ │ │ ├── RecipeManagerAccessor.java │ │ │ │ │ └── ResultSlotMixin.java │ │ │ │ ├── itemgroup/ │ │ │ │ │ ├── CreativeModeInventoryScreenAccessor.java │ │ │ │ │ ├── CreativeModeInventoryScreenMixin.java │ │ │ │ │ ├── CreativeModeTabAccessor.java │ │ │ │ │ ├── EffectsInInventoryMixin.java │ │ │ │ │ ├── ItemMixin.java │ │ │ │ │ ├── ItemSettingsMixin.java │ │ │ │ │ ├── MinecraftMixin.java │ │ │ │ │ └── MixinCreativeModeInventoryScreenMixin.java │ │ │ │ ├── registry/ │ │ │ │ │ ├── MappedRegistryMixin.java │ │ │ │ │ └── ReferenceAccessor.java │ │ │ │ ├── serialization/ │ │ │ │ │ ├── CachedRegistryInfoGetterAccessor.java │ │ │ │ │ ├── CompoundTagMixin.java │ │ │ │ │ ├── DataComponentTypeBuilderMixin.java │ │ │ │ │ ├── DataResultMixin.java │ │ │ │ │ ├── DelegatingOpsAccessor.java │ │ │ │ │ ├── FriendlyByteBufMixin.java │ │ │ │ │ ├── RegistryOpsAccessor.java │ │ │ │ │ ├── TagValueInputMixin.java │ │ │ │ │ ├── TagValueOutputMixin.java │ │ │ │ │ ├── ValueInputMixin.java │ │ │ │ │ └── ValueOutputMixin.java │ │ │ │ ├── shader/ │ │ │ │ │ └── GlProgramAccessor.java │ │ │ │ ├── text/ │ │ │ │ │ ├── ClientLanguageMixin.java │ │ │ │ │ ├── ComponentSerializationMixin.java │ │ │ │ │ ├── LanguageMixin.java │ │ │ │ │ ├── TranslatableContentsAccessor.java │ │ │ │ │ ├── TranslatableContentsMixin.java │ │ │ │ │ └── stapi/ │ │ │ │ │ └── SystemDelegatedLanguageFixin.java │ │ │ │ ├── tweaks/ │ │ │ │ │ ├── EditBoxMixin.java │ │ │ │ │ ├── EulaMixin.java │ │ │ │ │ ├── LevelSettingsMixin.java │ │ │ │ │ └── OperatingSystemMixin.java │ │ │ │ └── ui/ │ │ │ │ ├── AbstractContainerScreenMixin.java │ │ │ │ ├── AbstractSliderButtonMixin.java │ │ │ │ ├── AbstractWidgetMixin.java │ │ │ │ ├── ChatScreenMixin.java │ │ │ │ ├── CubeMapMixin.java │ │ │ │ ├── EditBoxMixin.java │ │ │ │ ├── GuiRendererMixin.java │ │ │ │ ├── MinecraftMixin.java │ │ │ │ ├── MultiLineEditBoxMixin.java │ │ │ │ ├── ScreenMixin.java │ │ │ │ ├── SlotAccessor.java │ │ │ │ ├── SlotMixin.java │ │ │ │ ├── access/ │ │ │ │ │ ├── AbstractWidgetAccessor.java │ │ │ │ │ ├── BaseOwoHandledScreenAccessor.java │ │ │ │ │ ├── BlockEntityAccessor.java │ │ │ │ │ ├── ButtonAccessor.java │ │ │ │ │ ├── CheckboxAccessor.java │ │ │ │ │ ├── EditBoxAccessor.java │ │ │ │ │ ├── EntityRendererAccessor.java │ │ │ │ │ ├── GlCommandEncoderAccessor.java │ │ │ │ │ ├── GuiGraphicsAccessor.java │ │ │ │ │ ├── MultiLineEditBoxAccessor.java │ │ │ │ │ ├── MultilineTextFieldAccessor.java │ │ │ │ │ ├── RenderSystemAccessor.java │ │ │ │ │ └── TextBoxComponentAccessor.java │ │ │ │ ├── display/ │ │ │ │ │ ├── GameRendererMixin.java │ │ │ │ │ ├── GuiMixin.java │ │ │ │ │ ├── MinecraftMixin.java │ │ │ │ │ └── MouseHandlerMixin.java │ │ │ │ └── layers/ │ │ │ │ ├── AbstractContainerScreenAccessor.java │ │ │ │ ├── KeyboardHandlerMixin.java │ │ │ │ ├── MouseHandlerMixin.java │ │ │ │ └── ScreenMixin.java │ │ │ ├── moddata/ │ │ │ │ ├── ModDataConsumer.java │ │ │ │ └── ModDataLoader.java │ │ │ ├── network/ │ │ │ │ ├── ClientAccess.java │ │ │ │ ├── NetworkException.java │ │ │ │ ├── OwoClientConnectionExtension.java │ │ │ │ ├── OwoHandshake.java │ │ │ │ ├── OwoNetChannel.java │ │ │ │ ├── QueuedChannelSet.java │ │ │ │ └── ServerAccess.java │ │ │ ├── ops/ │ │ │ │ ├── ItemOps.java │ │ │ │ ├── LevelOps.java │ │ │ │ ├── LootOps.java │ │ │ │ └── TextOps.java │ │ │ ├── particles/ │ │ │ │ ├── ClientParticles.java │ │ │ │ └── systems/ │ │ │ │ ├── ParticleSystem.java │ │ │ │ ├── ParticleSystemController.java │ │ │ │ └── ParticleSystemExecutor.java │ │ │ ├── registration/ │ │ │ │ ├── ComplexRegistryAction.java │ │ │ │ ├── RegistryHelper.java │ │ │ │ ├── annotations/ │ │ │ │ │ ├── AssignedName.java │ │ │ │ │ ├── IterationIgnored.java │ │ │ │ │ └── RegistryNamespace.java │ │ │ │ └── reflect/ │ │ │ │ ├── AutoRegistryContainer.java │ │ │ │ ├── BlockEntityRegistryContainer.java │ │ │ │ ├── FieldProcessingSubject.java │ │ │ │ ├── FieldRegistrationHandler.java │ │ │ │ └── SimpleFieldProcessingSubject.java │ │ │ ├── renderdoc/ │ │ │ │ ├── RenderDoc.java │ │ │ │ ├── RenderdocLibrary.java │ │ │ │ └── RenderdocScreen.java │ │ │ ├── serialization/ │ │ │ │ ├── CodecUtils.java │ │ │ │ ├── EndecRecipeSerializer.java │ │ │ │ ├── OwoDataComponentTypeBuilder.java │ │ │ │ ├── RegistriesAttribute.java │ │ │ │ ├── endec/ │ │ │ │ │ ├── EitherEndec.java │ │ │ │ │ ├── KeyedEndecDecodeError.java │ │ │ │ │ ├── KeyedEndecEncodeError.java │ │ │ │ │ ├── MinecraftEndecs.java │ │ │ │ │ ├── NonNullListEndec.java │ │ │ │ │ └── StructEitherEndec.java │ │ │ │ └── format/ │ │ │ │ ├── ContextHolder.java │ │ │ │ ├── DynamicOpsWithContext.java │ │ │ │ ├── edm/ │ │ │ │ │ └── EdmOps.java │ │ │ │ └── nbt/ │ │ │ │ ├── NbtDeserializer.java │ │ │ │ ├── NbtEndec.java │ │ │ │ └── NbtSerializer.java │ │ │ ├── text/ │ │ │ │ ├── CursedTranslatableContents.java │ │ │ │ ├── CustomTextRegistry.java │ │ │ │ ├── InsertingTextContent.java │ │ │ │ ├── LanguageAccess.java │ │ │ │ ├── NestedLangHandler.java │ │ │ │ ├── TextLanguage.java │ │ │ │ └── TranslationContext.java │ │ │ ├── ui/ │ │ │ │ ├── base/ │ │ │ │ │ ├── BaseOwoContainerScreen.java │ │ │ │ │ ├── BaseOwoScreen.java │ │ │ │ │ ├── BaseOwoToast.java │ │ │ │ │ ├── BaseOwoTooltipComponent.java │ │ │ │ │ ├── BaseParentUIComponent.java │ │ │ │ │ ├── BaseUIComponent.java │ │ │ │ │ ├── BaseUIModelContainerScreen.java │ │ │ │ │ └── BaseUIModelScreen.java │ │ │ │ ├── component/ │ │ │ │ │ ├── BlockComponent.java │ │ │ │ │ ├── BoxComponent.java │ │ │ │ │ ├── BraidComponent.java │ │ │ │ │ ├── ButtonComponent.java │ │ │ │ │ ├── CheckboxComponent.java │ │ │ │ │ ├── ColorPickerComponent.java │ │ │ │ │ ├── DiscreteSliderComponent.java │ │ │ │ │ ├── DropdownComponent.java │ │ │ │ │ ├── EntityComponent.java │ │ │ │ │ ├── ItemComponent.java │ │ │ │ │ ├── LabelComponent.java │ │ │ │ │ ├── SliderComponent.java │ │ │ │ │ ├── SlimSliderComponent.java │ │ │ │ │ ├── SmallCheckboxComponent.java │ │ │ │ │ ├── SpacerComponent.java │ │ │ │ │ ├── SpriteComponent.java │ │ │ │ │ ├── TextAreaComponent.java │ │ │ │ │ ├── TextBoxComponent.java │ │ │ │ │ ├── TextureComponent.java │ │ │ │ │ ├── UIComponents.java │ │ │ │ │ └── VanillaWidgetComponent.java │ │ │ │ ├── container/ │ │ │ │ │ ├── CollapsibleContainer.java │ │ │ │ │ ├── DraggableContainer.java │ │ │ │ │ ├── FlowLayout.java │ │ │ │ │ ├── GridLayout.java │ │ │ │ │ ├── OverlayContainer.java │ │ │ │ │ ├── ScrollContainer.java │ │ │ │ │ ├── StackLayout.java │ │ │ │ │ ├── UIContainers.java │ │ │ │ │ └── WrappingParentUIComponent.java │ │ │ │ ├── core/ │ │ │ │ │ ├── Animatable.java │ │ │ │ │ ├── AnimatableProperty.java │ │ │ │ │ ├── Animation.java │ │ │ │ │ ├── Color.java │ │ │ │ │ ├── CursorStyle.java │ │ │ │ │ ├── Easing.java │ │ │ │ │ ├── HorizontalAlignment.java │ │ │ │ │ ├── Insets.java │ │ │ │ │ ├── OwoUIAdapter.java │ │ │ │ │ ├── OwoUIGraphics.java │ │ │ │ │ ├── OwoUIPipelines.java │ │ │ │ │ ├── ParentUIComponent.java │ │ │ │ │ ├── PositionedRectangle.java │ │ │ │ │ ├── Positioning.java │ │ │ │ │ ├── Size.java │ │ │ │ │ ├── Sizing.java │ │ │ │ │ ├── Surface.java │ │ │ │ │ ├── UIComponent.java │ │ │ │ │ └── VerticalAlignment.java │ │ │ │ ├── event/ │ │ │ │ │ ├── CharTyped.java │ │ │ │ │ ├── ClientRenderCallback.java │ │ │ │ │ ├── FocusGained.java │ │ │ │ │ ├── FocusLost.java │ │ │ │ │ ├── KeyPress.java │ │ │ │ │ ├── MouseDown.java │ │ │ │ │ ├── MouseDrag.java │ │ │ │ │ ├── MouseEnter.java │ │ │ │ │ ├── MouseLeave.java │ │ │ │ │ ├── MouseScroll.java │ │ │ │ │ ├── MouseUp.java │ │ │ │ │ └── WindowResizeCallback.java │ │ │ │ ├── hud/ │ │ │ │ │ ├── Hud.java │ │ │ │ │ ├── HudContainer.java │ │ │ │ │ └── HudInspectorScreen.java │ │ │ │ ├── inject/ │ │ │ │ │ ├── GreedyInputUIComponent.java │ │ │ │ │ └── UIComponentStub.java │ │ │ │ ├── layers/ │ │ │ │ │ ├── Layer.java │ │ │ │ │ └── Layers.java │ │ │ │ ├── parsing/ │ │ │ │ │ ├── ConfigureHotReloadScreen.java │ │ │ │ │ ├── IncompatibleUIModelException.java │ │ │ │ │ ├── UIModel.java │ │ │ │ │ ├── UIModelLoader.java │ │ │ │ │ ├── UIModelParsingException.java │ │ │ │ │ └── UIParsing.java │ │ │ │ ├── renderstate/ │ │ │ │ │ ├── BlockElementRenderState.java │ │ │ │ │ ├── BlurQuadElementRenderState.java │ │ │ │ │ ├── CircleElementRenderState.java │ │ │ │ │ ├── CubeMapElementRenderState.java │ │ │ │ │ ├── EntityElementRenderState.java │ │ │ │ │ ├── GradientQuadElementRenderState.java │ │ │ │ │ ├── LineElementRenderState.java │ │ │ │ │ ├── OwoItemElementRenderState.java │ │ │ │ │ ├── OwoSpecialGuiElementRenderers.java │ │ │ │ │ └── RingElementRenderState.java │ │ │ │ └── util/ │ │ │ │ ├── CommandOpenedScreen.java │ │ │ │ ├── CursorAdapter.java │ │ │ │ ├── Delta.java │ │ │ │ ├── DisposableScreen.java │ │ │ │ ├── FocusHandler.java │ │ │ │ ├── MatrixStackTransformer.java │ │ │ │ ├── MountingHelper.java │ │ │ │ ├── NinePatchTexture.java │ │ │ │ ├── SpriteUtilInvoker.java │ │ │ │ ├── UIErrorToast.java │ │ │ │ └── UISounds.java │ │ │ └── util/ │ │ │ ├── DataExtensionUtil.java │ │ │ ├── EventSource.java │ │ │ ├── EventStream.java │ │ │ ├── ImplementedContainer.java │ │ │ ├── KawaiiUtil.java │ │ │ ├── Maldenhagen.java │ │ │ ├── NumberReflection.java │ │ │ ├── Observable.java │ │ │ ├── OwoFreezer.java │ │ │ ├── RecipeRemainderStorage.java │ │ │ ├── ReflectionUtils.java │ │ │ ├── Scary.java │ │ │ ├── ServicesFrozenException.java │ │ │ ├── StackTraceSupplier.java │ │ │ ├── TagInjector.java │ │ │ ├── VectorRandomUtils.java │ │ │ ├── VectorSerializer.java │ │ │ ├── ViewerStack.java │ │ │ ├── Wisdom.java │ │ │ └── pond/ │ │ │ ├── BraidGuiRendererExtension.java │ │ │ ├── OwoAbstractContainerMenuExtension.java │ │ │ ├── OwoCreativeInventoryScreenExtensions.java │ │ │ ├── OwoItemExtensions.java │ │ │ ├── OwoScreenExtension.java │ │ │ ├── OwoSimpleRegistryExtensions.java │ │ │ ├── OwoSlotExtension.java │ │ │ ├── OwoTextRendererExtension.java │ │ │ └── package-info.java │ │ └── resources/ │ │ ├── META-INF/ │ │ │ └── services/ │ │ │ └── javax.annotation.processing.Processor │ │ ├── architectury.common.json │ │ ├── assets/ │ │ │ └── owo/ │ │ │ ├── lang/ │ │ │ │ ├── en_us.json5 │ │ │ │ └── tt_ru.json5 │ │ │ ├── nine_patch_textures/ │ │ │ │ ├── braid_combobox/ │ │ │ │ │ ├── active.json │ │ │ │ │ ├── disabled.json │ │ │ │ │ └── hovered.json │ │ │ │ ├── braid_debug_focused.json │ │ │ │ ├── braid_debug_highlighted.json │ │ │ │ ├── braid_inspector_selected.json │ │ │ │ ├── button/ │ │ │ │ │ ├── active.json │ │ │ │ │ ├── disabled.json │ │ │ │ │ └── hovered.json │ │ │ │ ├── panel/ │ │ │ │ │ ├── dark.json │ │ │ │ │ ├── default.json │ │ │ │ │ └── inset.json │ │ │ │ ├── scrollbar/ │ │ │ │ │ ├── track.json │ │ │ │ │ ├── vanilla_flat.json │ │ │ │ │ ├── vanilla_horizontal.json │ │ │ │ │ ├── vanilla_horizontal_disabled.json │ │ │ │ │ ├── vanilla_vertical.json │ │ │ │ │ └── vanilla_vertical_disabled.json │ │ │ │ └── slim_slider_track.json │ │ │ ├── owo_ui/ │ │ │ │ ├── config.xml │ │ │ │ ├── configure_hot_reload.xml │ │ │ │ └── restart_required.xml │ │ │ ├── shaders/ │ │ │ │ └── core/ │ │ │ │ ├── blur.fsh │ │ │ │ ├── blur.vsh │ │ │ │ └── spectrum.fsh │ │ │ ├── sounds/ │ │ │ │ └── ui_interaction.ogg │ │ │ └── sounds.json │ │ ├── fabric.mod.json │ │ ├── owo-json5 │ │ ├── owo.accesswidener │ │ └── owo.mixins.json │ └── testmod/ │ ├── java/ │ │ └── io/ │ │ └── wispforest/ │ │ ├── owo/ │ │ │ └── samples/ │ │ │ └── braid/ │ │ │ ├── BraidSamplesItem.java │ │ │ ├── LayoutWidgetExamples.java │ │ │ ├── SharedCounter.java │ │ │ ├── SimpleCounter.java │ │ │ └── layout/ │ │ │ ├── BottomRightLogo.java │ │ │ ├── Checkerboard.java │ │ │ ├── LargeLogo.java │ │ │ ├── LavaLogo.java │ │ │ ├── NormalRow.java │ │ │ ├── PaddedColumn.java │ │ │ ├── PaddedLogo.java │ │ │ ├── RGBStack.java │ │ │ ├── SizeFactorLogo.java │ │ │ ├── SquishedLogo.java │ │ │ └── VerticalFlex.java │ │ └── uwu/ │ │ ├── EpicMenu.java │ │ ├── FabledBananasClass.java │ │ ├── Uwu.java │ │ ├── block/ │ │ │ ├── BraidDisplayBlock.java │ │ │ └── BraidDisplayBlockEntity.java │ │ ├── blockentity/ │ │ │ └── ProcessBlockEntity.java │ │ ├── client/ │ │ │ ├── Bikeshed.java │ │ │ ├── BraidDisplayBlockEntityRenderer.java │ │ │ ├── ComponentTestScreen.java │ │ │ ├── EpicContainerModelScreen.java │ │ │ ├── EpicContainerScreen.java │ │ │ ├── HudTestWidget.java │ │ │ ├── LayersTestWidget.java │ │ │ ├── ParseFailScreen.java │ │ │ ├── ScissorTestScreen.java │ │ │ ├── SelectUwuScreenScreen.java │ │ │ ├── SizingTestScreen.java │ │ │ ├── SmolComponentTestScreen.java │ │ │ ├── TestConfigScreen.java │ │ │ ├── TestParseScreen.java │ │ │ ├── TooManyComponentsScreen.java │ │ │ ├── UwuClient.java │ │ │ ├── UwuConfigScreen.java │ │ │ └── braid/ │ │ │ ├── SliderTests.java │ │ │ └── TestSelector.java │ │ ├── config/ │ │ │ ├── UowouConfigModel.java │ │ │ └── UwuConfigModel.java │ │ ├── items/ │ │ │ ├── UwuBraidItem.java │ │ │ ├── UwuCounterItem.java │ │ │ ├── UwuItems.java │ │ │ ├── UwuScreenShardItem.java │ │ │ └── UwuTestStickItem.java │ │ ├── mixin/ │ │ │ ├── GlRenderPassMixin.java │ │ │ └── TitleScreenMixin.java │ │ ├── network/ │ │ │ ├── DispatchedInterface.java │ │ │ ├── DispatchedSubclassOne.java │ │ │ ├── DispatchedSubclassTwo.java │ │ │ ├── KeycodePacket.java │ │ │ ├── MaldingPacket.java │ │ │ ├── NullablePacket.java │ │ │ ├── SealedSubclassOne.java │ │ │ ├── SealedSubclassTwo.java │ │ │ ├── SealedTestClass.java │ │ │ ├── StringPacket.java │ │ │ ├── UwuNetworkExample.java │ │ │ ├── UwuNetworkTest.java │ │ │ └── UwuOptionalNetExample.java │ │ ├── recipe/ │ │ │ └── UwuShapedRecipe.java │ │ ├── rei/ │ │ │ ├── UiCategory.java │ │ │ └── UwuReiPlugin.java │ │ └── text/ │ │ └── BasedTextContent.java │ └── resources/ │ ├── assets/ │ │ ├── uowou/ │ │ │ ├── items/ │ │ │ │ └── owo_ingot.json5 │ │ │ └── models/ │ │ │ └── item/ │ │ │ └── owo_ingot.json5 │ │ └── uwu/ │ │ ├── blockstates/ │ │ │ └── braid_display.json5 │ │ ├── items/ │ │ │ ├── braid.json5 │ │ │ ├── braid_display.json5 │ │ │ ├── braid_samples.json5 │ │ │ ├── screen_shard.json │ │ │ ├── screen_shard.json5 │ │ │ ├── test_stick.json │ │ │ └── test_stick.json5 │ │ ├── lang/ │ │ │ └── en_us.json5 │ │ ├── models/ │ │ │ ├── block/ │ │ │ │ └── braid_display.json5 │ │ │ └── item/ │ │ │ ├── braid.json5 │ │ │ └── counter.json5 │ │ ├── nine_patch_textures/ │ │ │ └── contributors_panel.json5 │ │ ├── owo_ui/ │ │ │ ├── config.xml │ │ │ ├── expand_gap_test.xml │ │ │ ├── focus_cycle_test.xml │ │ │ ├── parse_fail.xml │ │ │ ├── smol_components.xml │ │ │ ├── test_element_one.xml │ │ │ └── test_element_two.xml │ │ └── textures/ │ │ └── gui/ │ │ └── bikeshed.png.mcmeta │ ├── data/ │ │ └── uwu/ │ │ ├── item_group_tabs/ │ │ │ ├── crab_group.json5 │ │ │ ├── food_and_drink_button.json5 │ │ │ ├── ingredients_extension.json5 │ │ │ └── ingredients_extension_2.json5 │ │ ├── recipe/ │ │ │ ├── test_recipe.json5 │ │ │ ├── uwu_shaped_recipe.json5 │ │ │ └── what_the_bucket_doin.json5 │ │ └── tags/ │ │ └── item/ │ │ └── tab_2_content.json5 │ ├── fabric.mod.json │ ├── owo-json5 │ └── uwu.mixins.json └── stylesheet.css ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/build.yml ================================================ # Automatically build the project and run any configured tests for every push # and submitted pull request. This can help catch issues that only occur on # certain platforms or Java versions, and provides a first line of defence # against bad commits. name: build on: [pull_request, push] jobs: build: strategy: matrix: # Use these Java versions java: [ 21 # Minimum supported by Minecraft ] # and run on both Linux and Windows os: [ubuntu-22.04] runs-on: ${{ matrix.os }} steps: - name: checkout repository uses: actions/checkout@v2 - name: validate gradle wrapper uses: gradle/wrapper-validation-action@v1 - name: setup jdk ${{ matrix.java }} uses: actions/setup-java@v1 with: java-version: ${{ matrix.java }} - name: make gradle wrapper executable if: ${{ runner.os != 'Windows' }} run: chmod +x ./gradlew - name: build run: ./gradlew build - name: capture build artifacts if: ${{ runner.os == 'Linux' && matrix.java == '21' }} # Only upload artifacts built from latest java on one OS uses: actions/upload-artifact@v4 with: name: Artifacts path: build/libs/ ================================================ FILE: .gitignore ================================================ # User-specific stuff .idea/ *.iml *.ipr *.iws # IntelliJ out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Compiled class file *.class # Log file *.log # BlueJ files *.ctxt # Package Files # *.jar *.war *.nar *.ear *.zip *.tar.gz *.rar # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* *~ # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk # Windows thumbnail cache files Thumbs.db Thumbs.db:encryptable ehthumbs.db ehthumbs_vista.db # Dump file *.stackdump # Folder config file [Dd]esktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msix *.msm *.msp # Windows shortcuts *.lnk .gradle build/ # Ignore Gradle GUI config gradle-app.setting # Cache of project .gradletasknamecache **/build/ # Common working directory run/ # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) !gradle-wrapper.jar # generated sources /src/*/generated/ ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2021 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================


oωo (owo-lib)

## Overview A general utility, GUI and config library for modding on Fabric. oωo is generally aimed at reducing code verbosity and making development more ergonomic. It covers a wide range of features from networking and serialization over GUI applications and configuration to data handling and registration. **Build Setup:** ```properties # https://maven.wispforest.io/io/wispforest/owo-lib/ owo_version=... ``` ```groovy repositories { maven { url 'https://maven.wispforest.io' } } <...> dependencies { modImplementation "io.wispforest:owo-lib:${project.owo_version}" // only if you plan to use owo-config annotationProcessor "io.wispforest:owo-lib:${project.owo_version}" // include this if you don't want force your users to install owo // sentinel will warn them and give the option to download it automatically include "io.wispforest:owo-sentinel:${project.owo_version}" } ```
Kotlin DSL ```kotlin repositories { maven("https://maven.wispforest.io") } dependencies { modImplementation("io.wispforest:owo-lib:${properties["owo_version"]}") // only if you plan to use owo-config annotationProcessor("io.wispforest:owo-lib:${properties["owo_version"]}") // include this if you don't want force your users to install owo // sentinel will warn them and give the option to download it automatically include("io.wispforest:owo-sentinel:${properties["owo_version"]}") } ```
You can check the latest version on the [Releases](https://github.com/wisp-forest/owo-lib/releases) page owo is documented in two main ways: - There is rich, detailed JavaDoc throughout the entire codebase - There is a wiki with in-depth explanations and tutorials for most of owo's features over at https://docs.wispforest.io/owo/features ## Features This is by no means an exhaustive list, for a more complete overview head to https://docs.wispforest.io/owo/features - [owo-ui](https://docs.wispforest.io/owo/ui), a fully-featured declarative UI library for building dynamic, beautiful screens with blazingly fast development times - [owo-config](https://docs.wispforest.io/owo/config), a built-in, customizable configuration system built on top of owo-ui. It provides many of the same features as [Cloth Config](https://modrinth.com/mod/cloth-config) while many new conveniences, like server-client config synchronization, added on top - A fully automatic [registration system](https://docs.wispforest.io/owo/registration) that is designed to be as generic as possible. It is simple and non-verbose to use for basic registries, yet the underlying API tree is flexible and can also be used for many custom registration solutions - [Item Group extensions](https://docs.wispforest.io/owo/item-groups) which allow for sub-tabs inside your mod's group as well as a host of other features like custom buttons, textures and item variant handling - A fully-featured [networking layer](https://docs.wispforest.io/owo/networking) with fully automatic serialization, handshaking to ensure client compatibility and a built-in solution for triggering parametrized particle events in a side-agnostic manner - Client-sided particle helpers that allow for easily composing multi-particle effects - Rich text translations, allowing you to use Minecraft's text component format in your language files to provide styled text without any code ================================================ FILE: braid-reload-agent/.gitattributes ================================================ # # https://help.github.com/articles/dealing-with-line-endings/ # # Linux start script should use lf /gradlew text eol=lf # These are Windows script files and should use crlf *.bat text eol=crlf ================================================ FILE: braid-reload-agent/.gitignore ================================================ # Ignore Gradle project-specific cache directory .gradle # Ignore Gradle build output directory build ================================================ FILE: braid-reload-agent/build.gradle.kts ================================================ plugins { application `maven-publish` } repositories { mavenCentral() } dependencies {} version = "0.1.0" group = "io.wispforest" java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } tasks.jar { manifest.attributes( "Premain-Class" to "io.wispforest.BraidReloadAgent" ) } publishing { publications { create("maven") { from(components["java"]) } } val env = System.getenv() if (env.contains("MAVEN_URL")) { repositories { maven { url = uri(env["MAVEN_URL"]!!) credentials { username = env["MAVEN_USER"] password = env["MAVEN_PASSWORD"] } } } } } ================================================ FILE: braid-reload-agent/gradle/libs.versions.toml ================================================ # This file was generated by the Gradle 'init' task. # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format [versions] guava = "33.0.0-jre" junit-jupiter = "5.10.2" [libraries] guava = { module = "com.google.guava:guava", version.ref = "guava" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } ================================================ FILE: braid-reload-agent/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: braid-reload-agent/gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: braid-reload-agent/gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: braid-reload-agent/settings.gradle.kts ================================================ plugins { // Apply the foojay-resolver plugin to allow automatic download of JDKs id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } ================================================ FILE: braid-reload-agent/src/main/java/io/wispforest/BraidReloadAgent.java ================================================ package io.wispforest; import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.IllegalClassFormatException; import java.lang.instrument.Instrumentation; import java.security.ProtectionDomain; import java.util.*; public class BraidReloadAgent { public static void premain(String agentArgs, Instrumentation instrumentation) { instrumentation.addTransformer(new RedefinitionListener()); } } class RedefinitionListener implements ClassFileTransformer { private final Map classHashes = new HashMap<>(); private final Set classesToWaitFor = new HashSet<>(Set.of( "io/wispforest/owo/braid/framework/widget/Widget", "io/wispforest/owo/braid/framework/proxy/WidgetState", "io/wispforest/owo/braid/core/BraidHotReloadCallback" )); private ClassLoader braidClassLoader; private boolean logSetupComplete = false; @Override public byte[] transform(Module module, ClassLoader loader, String className, Class classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { if (this.logSetupComplete) { this.logSetupComplete = false; fallible(() -> { var callbackClass = Class.forName("io.wispforest.owo.braid.core.BraidHotReloadCallback", false, this.braidClassLoader); callbackClass.getMethod("setupComplete").invoke(null); }); } if (!this.classesToWaitFor.isEmpty()) { if (this.classesToWaitFor.contains(className)) { this.classesToWaitFor.remove(className); if (this.braidClassLoader == null) { this.braidClassLoader = loader; } if (this.classesToWaitFor.isEmpty()) { this.logSetupComplete = true; } } return ClassFileTransformer.super.transform(module, loader, className, classBeingRedefined, protectionDomain, classfileBuffer); } fallible(() -> { var widgetClass = Class.forName("io.wispforest.owo.braid.framework.widget.Widget", false, this.braidClassLoader); var widgetStateClass = Class.forName("io.wispforest.owo.braid.framework.proxy.WidgetState", false, this.braidClassLoader); var callbackClass = Class.forName("io.wispforest.owo.braid.core.BraidHotReloadCallback", false, this.braidClassLoader); if (classBeingRedefined != null) { if (widgetClass.isAssignableFrom(classBeingRedefined) || widgetStateClass.isAssignableFrom(classBeingRedefined)) { var newHash = Arrays.hashCode(classfileBuffer); if (!this.classHashes.containsKey(className) || this.classHashes.get(className) != newHash) { callbackClass.getMethod("invoke").invoke(null); } this.classHashes.put(className, newHash); } } }); return ClassFileTransformer.super.transform(module, loader, className, classBeingRedefined, protectionDomain, classfileBuffer); } private static void fallible(Fallible fallible) { fallible.run(); } } interface Fallible { void body() throws Throwable; default void run() { try { this.body(); } catch (Throwable error) { System.err.println("(braid reload agent) hotswap error: " + error.getMessage()); //noinspection CallToPrintStackTrace error.printStackTrace(); } } } ================================================ FILE: build.gradle ================================================ //file:noinspection GradlePackageVersionRange plugins { id 'net.fabricmc.fabric-loom-remap' version '1.15-SNAPSHOT' id 'maven-publish' } allprojects { apply plugin: "java" apply plugin: "fabric-loom" apply plugin: "maven-publish" def ENV = System.getenv() version = "${project.mod_version}+${rootProject.minecraft_base_version}" group = rootProject.maven_group base { archivesName = project.archives_base_name } dependencies { minecraft "com.mojang:minecraft:${rootProject.minecraft_version}" mappings loom.officialMojangMappings() modImplementation "net.fabricmc:fabric-loader:${rootProject.loader_version}" modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" } processResources { inputs.property "version", project.version filteringCharset "UTF-8" filesMatching("fabric.mod.json") { expand "version": project.version } } def targetJavaVersion = 21 tasks.withType(JavaCompile).configureEach { // ensure that the encoding is set to UTF-8, no matter what the system default is // this fixes some edge cases with special characters not displaying correctly // see http://yodaconditions.net/blog/fix-for-java-file-encoding-problems-with-gradle.html // If Javadoc is generated, this must be specified in that task too. it.options.encoding = "UTF-8" if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) { it.options.release = targetJavaVersion } options.compilerArgs << "-Xmaxerrs" << "69420" } java { def javaVersion = JavaVersion.toVersion(targetJavaVersion) if (JavaVersion.current() < javaVersion) { toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) } // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task // if it is present. // If you remove this line, sources will not be generated. withSourcesJar() } jar { from("LICENSE") { rename { "${it}_${project.base.archivesName.get()}" } } } publishing { publications { mavenJava(MavenPublication) { from components.java } } repositories { maven { url ENV.MAVEN_URL credentials { username ENV.MAVEN_USER password ENV.MAVEN_PASSWORD } } } } } repositories { maven { url "https://maven.terraformersmc.com/releases/" } maven { url "https://maven.shedaniel.me/" } maven { url "https://api.modrinth.com/maven" content { includeGroup "maven.modrinth" } } maven { url "https://maven.nucleoid.xyz/" } maven { url 'https://maven.wispforest.io' } maven { url 'https://jitpack.io' } } sourceSets { testmod { runtimeClasspath += main.runtimeClasspath compileClasspath += main.compileClasspath } } loom { runs { testmodClient { client() ideConfigGenerated project.rootProject == project name = "Testmod Client" source sourceSets.testmod } testmodServer { server() ideConfigGenerated project.rootProject == project name = "Testmod Server" source sourceSets.testmod } } accessWidenerPath = file("src/main/resources/owo.accesswidener") } dependencies { // modLocalRuntime("me.shedaniel:RoughlyEnoughItems-fabric:${project.rei_version}") modCompileOnly("me.shedaniel:RoughlyEnoughItems-default-plugin-fabric:${project.rei_version}") { exclude "group": "net.fabricmc.fabric-api" } modCompileOnly("me.shedaniel:RoughlyEnoughItems-api-fabric:${project.rei_version}") { exclude "group": "net.fabricmc.fabric-api" } modCompileOnly("dev.emi:emi-fabric:${project.emi_version}") { exclude "group": "net.fabricmc.fabric-api" } // modLocalRuntime("dev.emi:emi-fabric:${project.emi_version}") modCompileOnly("com.terraformersmc:modmenu:${project.modmenu_version}") // modLocalRuntime("com.terraformersmc:modmenu:${project.modmenu_version}") include api("io.wispforest:endec:0.1.12") include api("io.wispforest.endec:netty:0.1.6") include api("io.wispforest.endec:gson:0.1.7") include api("io.wispforest.endec:jankson:0.1.7") include api("blue.endless:jankson:${project.jankson_version}") include api("com.github.kdl-org:kdl4j:${project.kdl_version}") modCompileOnly("xyz.nucleoid:server-translations-api:${project.stapi_version}") testmodImplementation sourceSets.main.output testmodAnnotationProcessor sourceSets.main.output } javadoc { options.stylesheetFile = new File(projectDir, "stylesheet.css") options.tags = ["apiNote", "implNote", "implSpec"] options.addStringOption("Xdoclint:-missing", "-quiet") options.encoding = 'UTF-8' } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # Done to increase the memory available to gradle. org.gradle.jvmargs=-Xmx2G # Fabric Properties # check these on https://fabricmc.net/develop minecraft_base_version=1.21.11 minecraft_version=1.21.11 yarn_mappings=1.21.11+build.2 loader_version=0.18.2 # Mod Properties mod_version=0.13.0 maven_group=io.wispforest archives_base_name=owo-lib # Dependencies fabric_version=0.141.2+1.21.11 # https://maven.shedaniel.me/me/shedaniel/RoughlyEnoughItems-fabric/ rei_version=21.9.812 # https://maven.terraformersmc.com/releases/dev/emi/emi-fabric/ emi_version=1.1.18+1.21.1 # https://search.maven.org/artifact/blue.endless/jankson jankson_version=1.2.2 # https://jitpack.io/#kdl-org/kdl4j kdl_version=1.0.1 # https://maven.terraformersmc.com/releases/com/terraformersmc/modmenu modmenu_version=17.0.0-alpha.1 # https://maven.nucleoid.xyz/xyz/nucleoid/server-translations-api/ stapi_version=2.5.2+1.21.9-pre3 ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh # # Copyright 2015 the original author or authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin or MSYS, switch paths to Windows format before running java if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=`expr $i + 1` done case $i in 0) set -- ;; 1) set -- "$args0" ;; 2) set -- "$args0" "$args1" ;; 3) set -- "$args0" "$args1" "$args2" ;; 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto init echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto init echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :init @rem Get command-line arguments, handling Windows variants if not "%OS%" == "Windows_NT" goto win9xME_args :win9xME_args @rem Slurp the command line arguments. set CMD_LINE_ARGS= set _SKIP=2 :win9xME_args_slurp if "x%~1" == "x" goto execute set CMD_LINE_ARGS=%* :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: jitpack.yml ================================================ jdk: - openjdk21 ================================================ FILE: owo-sentinel/build.gradle ================================================ loom { runConfigs.client.ideConfigGenerated = true mods { "owo-sentinel" { sourceSet sourceSets.main } } } ================================================ FILE: owo-sentinel/gradle.properties ================================================ archives_base_name=owo-sentinel ================================================ FILE: owo-sentinel/src/main/java/io/wispforest/owosentinel/DownloadTask.java ================================================ package io.wispforest.owosentinel; import javax.swing.*; import java.util.function.Consumer; public class DownloadTask extends SwingWorker { private final Runnable whenDone; private final Consumer logger; public DownloadTask(Consumer logger, Runnable whenDone) { this.logger = logger; this.whenDone = whenDone; } @Override protected void done() { whenDone.run(); } @Override protected Void doInBackground() { try { OwoSentinel.downloadAndInstall(logger); } catch (Exception e) { logger.accept("Download failed!"); OwoSentinel.LOGGER.error("Download failed", e); } return null; } } ================================================ FILE: owo-sentinel/src/main/java/io/wispforest/owosentinel/Maldenhagen.java ================================================ package io.wispforest.owosentinel; import net.fabricmc.loader.api.LanguageAdapter; import net.fabricmc.loader.api.ModContainer; public class Maldenhagen implements LanguageAdapter { @Override public T create(ModContainer mod, String value, Class type) { throw new UnsupportedOperationException(); } static { OwoSentinel.launch(); } } ================================================ FILE: owo-sentinel/src/main/java/io/wispforest/owosentinel/OwoSentinel.java ================================================ package io.wispforest.owosentinel; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.awt.*; import java.io.InputStreamReader; import java.net.URL; import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.function.Consumer; public class OwoSentinel { public static final Logger LOGGER = LogManager.getLogger("oωo-sentinel"); private static final Gson GSON = new Gson(); public static final String OWO_EXPLANATION = """ oωo-lib is a library used by most mods under the Wisp Forest domain to ease development. This is simply a convenient installer, as oωo is missing from your installation. Should you not trust it, feel free to head to the repository and download oωo yourself. """; public static final boolean FORCE_HEADLESS = Boolean.getBoolean("owo.sentinel.forceHeadless"); public static void launch() { if (FabricLoader.getInstance().isModLoaded("owo-impl")) return; try { if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("mac") || GraphicsEnvironment.isHeadless() || FORCE_HEADLESS) { SentinelConsole.run(); } else { SentinelWindow.open(); } } catch (Exception e) { LOGGER.error("Error thrown while opening sentinel! Exiting", e); System.exit(1); } System.exit(0); } public static List listOwoDependents() { var list = new ArrayList(); var used = new HashSet(); for (var mod : FabricLoader.getInstance().getAllMods()) { for (var dependency : mod.getMetadata().getDependencies()) { if (!dependency.getModId().equals("owo") && !dependency.getModId().equals("owo-lib")) continue; list.add(mod.getMetadata().getName() + " (explicit dependency)"); used.add(mod.getMetadata().getId()); } } FabricLoader.getInstance() .getModContainer("owo-sentinel") .flatMap(ModContainer::getContainingMod) .ifPresent(mod -> { if (used.contains(mod.getMetadata().getId())) return; list.add(mod.getMetadata().getName() + " (included sentinel)"); }); return list; } @SuppressWarnings("deprecation") public static void downloadAndInstall(Consumer logger) throws Exception { logger.accept("Fetching versions"); final URL url = new URL("https://api.modrinth.com/v2/project/owo-lib/version?game_versions=[%22" + FabricLoader.getInstance().getRawGameVersion() + "%22]&loaders=[%22fabric%22]"); final var response = GSON.fromJson(new InputStreamReader(url.openStream()), JsonArray.class); final var targetVersion = FabricLoader.getInstance().getModContainer("owo-sentinel").orElseThrow().getMetadata().getVersion().getFriendlyString(); JsonObject latestVersion = null; for (var version : response) { final var versionObject = version.getAsJsonObject(); if (versionObject.get("version_number").getAsString().equals(targetVersion)) { latestVersion = versionObject; break; } } if (latestVersion != null) { final var firstFile = latestVersion .get("files").getAsJsonArray().get(0).getAsJsonObject(); final var versionUrl = firstFile .get("url").getAsString(); final var versionFilename = firstFile .get("filename").getAsString(); logger.accept("Found matching version: " + latestVersion.get("version_number").getAsString()); final var filePath = FabricLoader.getInstance().getGameDir().resolve("mods").resolve(versionFilename); logger.accept("Downloading..."); try (final var modStream = new URL(versionUrl).openStream()) { Files.copy(modStream, filePath, StandardCopyOption.REPLACE_EXISTING); } logger.accept("Success!"); } else { logger.accept("No matching version found"); } } } ================================================ FILE: owo-sentinel/src/main/java/io/wispforest/owosentinel/SentinelConsole.java ================================================ package io.wispforest.owosentinel; import java.util.Locale; import java.util.Scanner; public class SentinelConsole { public static void run() throws Exception { System.out.println("oωo-lib is required to run the following mods:"); for (String dependent : OwoSentinel.listOwoDependents()) { System.out.println("- " + dependent); } System.out.println("\n" + OwoSentinel.OWO_EXPLANATION); System.out.print("Download and install (Y/n): "); Scanner in = new Scanner(System.in); boolean install = false; try { String answer = in.next(); install = answer.isBlank() || answer.toLowerCase(Locale.ROOT).startsWith("y"); } catch (Exception e) { System.out.println(""); } if (install) { OwoSentinel.downloadAndInstall(System.out::println); } else { System.out.println("You can install oωo-lib at https://modrinth.com/mod/owo-lib."); } } } ================================================ FILE: owo-sentinel/src/main/java/io/wispforest/owosentinel/SentinelWindow.java ================================================ package io.wispforest.owosentinel; import javax.imageio.ImageIO; import javax.swing.*; import javax.swing.border.EmptyBorder; import java.awt.*; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.io.IOException; import java.net.URI; public class SentinelWindow { public static void open() throws Exception { // Fix AA System.setProperty("awt.useSystemAAFontSettings", "lcd"); System.setProperty("swing.aatext", "true"); // Force GTK if available UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); for (var laf : UIManager.getInstalledLookAndFeels()) { if (!"GTK+".equals(laf.getName())) continue; UIManager.setLookAndFeel(laf.getClassName()); } // ------ // Window // ------ JFrame window = new JFrame("oωo-sentinel"); window.setVisible(false); //noinspection ConstantConditions final var owoIconImage = ImageIO.read(OwoSentinel.class.getClassLoader() .getResourceAsStream("owo_sentinel_icon.png")); window.setIconImage(owoIconImage); window.setMinimumSize(new Dimension(0, 250)); window.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); window.addWindowListener(new WindowAdapter() { @Override public void windowClosed(WindowEvent e) { System.exit(0); } }); window.setLocationByPlatform(true); // ----- // Title // ----- final var titleLabel = new JLabel("oωo-lib is required to run the following mods", new ImageIcon(owoIconImage), SwingConstants.LEFT); titleLabel.setFont(titleLabel.getFont().deriveFont(titleLabel.getFont().getSize() * 1.25f)); titleLabel.setHorizontalAlignment(SwingConstants.CENTER); titleLabel.setBorder(new EmptyBorder(0, 15, 0, 15)); window.getContentPane().add(titleLabel, BorderLayout.NORTH); // ---------- // Dependents // ---------- var dependents = "
" + String.join("
", OwoSentinel.listOwoDependents()) + "

\u200B"; final var dependentsLabel = new JLabel(dependents); final var defaultDepFont = dependentsLabel.getFont(); dependentsLabel.setFont(defaultDepFont.deriveFont(defaultDepFont.getSize() * 1.1f)); dependentsLabel.setHorizontalAlignment(SwingConstants.CENTER); window.getContentPane().add(dependentsLabel, BorderLayout.CENTER); // ------- // Buttons // ------- var buttonsPanel = new JPanel(); // Download final var downloadButton = new JButton("Download and install"); final var progressBar = new JProgressBar(); progressBar.setIndeterminate(true); downloadButton.addActionListener(e -> { downloadButton.setEnabled(false); downloadButton.add(progressBar); downloadButton.updateUI(); titleLabel.setText("Installing oωo-lib"); window.getContentPane().remove(dependentsLabel); final var logBox = new JTextArea(); logBox.setEditable(false); logBox.setMargin(new Insets(15, 15, 15, 15)); final var scrollPane = new JScrollPane(logBox); scrollPane.setBorder(new EmptyBorder(0, 15, 0, 15)); window.getContentPane().add(scrollPane, BorderLayout.CENTER); var task = new DownloadTask(s -> { OwoSentinel.LOGGER.info(s); logBox.setText(logBox.getText() + (logBox.getText().isBlank() ? "" : "\n") + s); scrollPane.getVerticalScrollBar().setValue(scrollPane.getVerticalScrollBar().getMaximum()); }, () -> { progressBar.setVisible(false); titleLabel.setText(""); downloadButton.setText("Installed"); }); task.execute(); }); // What is this final var whatIsThisButton = new JButton("What is this?"); whatIsThisButton.addActionListener(e -> { String[] options = {"Open GitHub", "OK"}; int selection = JOptionPane.showOptionDialog(window, OwoSentinel.OWO_EXPLANATION, "oωo-sentinel", JOptionPane.YES_NO_OPTION, JOptionPane.INFORMATION_MESSAGE, new ImageIcon(owoIconImage), options, options[0]); if (selection == 0 && Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { try { Desktop.getDesktop().browse(URI.create("https://github.com/wisp-forest/owo-lib")); } catch (IOException ignored) {} } }); // Exit final var exitButton = new JButton("Close"); exitButton.addActionListener(e -> window.dispose()); // Panel setup buttonsPanel.add(downloadButton); buttonsPanel.add(whatIsThisButton); buttonsPanel.add(exitButton); // --------------- // Window creation // --------------- window.getContentPane().add(buttonsPanel, BorderLayout.SOUTH); window.pack(); window.setVisible(true); window.requestFocus(); synchronized (SentinelWindow.class) { SentinelWindow.class.wait(); } } } ================================================ FILE: owo-sentinel/src/main/resources/fabric.mod.json ================================================ { "schemaVersion": 1, "id": "owo-sentinel", "version": "${version}", "name": "oωo-sentinel", "description": "makes u download oωo", "authors": [ "glisco" ], "contact": {}, "license": "MIT", "icon": "owo_sentinel_icon.png", "environment": "*", "provides": [ "owo", "owo-lib" ], "languageAdapters": { "maldenhagen": "io.wispforest.owosentinel.Maldenhagen" }, "depends": { "fabricloader": "*", "minecraft": ">=1.18" }, "custom": { "modmenu": { "links": { "modmenu.discord": "https://discord.gg/xrwHKktV2d" }, "badges": [ "library" ] } } } ================================================ FILE: owo-ui.xsd ================================================ Insets describing an offset on each side of a rectangle. Elements which occur after one another override each other, meaning that a `bottom` element after an `all` element will only redefine the bottom offset and leave the rest intact Any of the three positioning types supported by owo-ui, with the content formatted as `{horizontal},{vertical}`, eg `25,50` A container for the horizontal and vertical sizing declaration, each of which may occur once Some literal or translated text, depending on whether the `translate` attribute is `true` A standard integer color in either `#AARRGGBB` or `#RRGGBB` format. Alternatively, the all-lowercase name of any of Minecraft's 16 text colors One or multiple surfaces chained together. If multiple surfaces appear in this declaration, they are chained together in order of appearance via the `and(...)` method A standard Minecraft panel, optionally with a dark texture An inset into a panel, used to create an area enclosed by a standard light panel A panel inset bordered by a standard light panel of the specified width on each border A simple surface repeating the given texture, just like the options background does with the dirt texture A simple, colorless surface that blurs everything underneath itself The standard Minecraft options background, usually a repeating dirt texture The standard dark translucent background most Vanilla UIs use The same renderer used by vanilla item and UI element tooltips A simple rectangular outline of the specified color A flat rectangle of the specified color A standard Minecraft identifier, optionally with the namespace omitted and defaulted to `minecraft` ================================================ FILE: settings.gradle ================================================ pluginManagement { repositories { maven { name = 'Fabric' url = 'https://maven.fabricmc.net/' } gradlePluginPortal() } } include 'owo-sentinel' ================================================ FILE: src/main/java/io/wispforest/owo/Owo.java ================================================ package io.wispforest.owo; import io.wispforest.owo.client.screens.MenuNetworkingInternals; import io.wispforest.owo.command.debug.OwoDebugCommands; import io.wispforest.owo.ops.LootOps; import io.wispforest.owo.text.CustomTextRegistry; import io.wispforest.owo.text.InsertingTextContent; import io.wispforest.owo.util.Wisdom; import net.fabricmc.api.ModInitializer; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import net.minecraft.server.MinecraftServer; import org.jetbrains.annotations.ApiStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static io.wispforest.owo.ops.TextOps.withColor; public class Owo implements ModInitializer { public static final String MOD_ID = "owo"; /** * Whether oωo debug is enabled, this defaults to {@code true} in a development environment. * To override that behavior, add the {@code -Dowo.debug=false} java argument */ public static final boolean DEBUG; public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); private static MinecraftServer SERVER; public static final Component PREFIX = Component.empty().withStyle(ChatFormatting.GRAY) .append(withColor("o", 0x3955e5)) .append(withColor("ω", 0x13a6f0)) .append(withColor("o", 0x3955e5)) .append(Component.literal(" > ").withStyle(ChatFormatting.GRAY)); static { boolean debug = FabricLoader.getInstance().isDevelopmentEnvironment(); if (System.getProperty("owo.debug") != null) debug = Boolean.getBoolean("owo.debug"); if (Boolean.getBoolean("owo.forceDisableDebug")) { LOGGER.warn("Deprecated system property 'owo.forceDisableDebug=true' was used - use 'owo.debug=false' instead"); debug = false; } DEBUG = debug; } @Override @ApiStatus.Internal public void onInitialize() { LootOps.registerListener(); CustomTextRegistry.register("index", InsertingTextContent.CODEC); MenuNetworkingInternals.init(); ServerLifecycleEvents.SERVER_STARTING.register(server -> SERVER = server); ServerLifecycleEvents.SERVER_STOPPED.register(server -> SERVER = null); Wisdom.spread(); if (!DEBUG) return; OwoDebugCommands.register(); } @ApiStatus.Internal public static void debugWarn(Logger logger, String message) { if (!DEBUG) return; logger.warn(message); } @ApiStatus.Internal public static void debugWarn(Logger logger, String message, Object... params) { if (!DEBUG) return; logger.warn(message, params); } /** * @return The currently active minecraft server instance. If running * on a physical client, this will return the integrated server while in * a local singleplayer world and {@code null} otherwise */ public static MinecraftServer currentServer() { return SERVER; } // "eh it's only like 10-15 of them what's the big deal" - glisco, while writing the 52nd hardcoded Identifier.of("owo", ...) @ApiStatus.Internal public static Identifier id(String path) { return Identifier.fromNamespaceAndPath(MOD_ID, path); } } ================================================ FILE: src/main/java/io/wispforest/owo/blockentity/LinearProcess.java ================================================ package io.wispforest.owo.blockentity; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import net.minecraft.world.level.Level; import java.util.function.BiConsumer; import java.util.function.Predicate; /** * Represents a process made of steps than can be executed tick by tick using a respective * {@link LinearProcessExecutor}. This can, for example, be used on BlockEntities that perform * rituals or similar activities that are made of consecutive steps. *

* A process defines the pattern of steps and events that shall be followed, thus there is one (usually static) * instance of it. You then create a new instance of {@link LinearProcessExecutor} using the * {@link #createExecutor(Object)} method for each instance of your BlockEntity of whatever else if supposed to run it *

* To create a new process, call {@link #LinearProcess(int)} with the length it should have. A process always has the same * length. Then, in the constructor of each object that will use an executor, use {@link #createExecutor(Object)} to * obtain an instance. This then has to be told whether it lives on the client or server using * {@link #configureExecutor(LinearProcessExecutor, boolean)}. On a BlockEntity this can be achieved by overriding * {@link net.minecraft.world.level.block.entity.BlockEntity#setLevel(Level)} and configuring after the super call using the provided * world *

* Steps and events should be added to process once, ideally in the {@code static} initializer block of the containing class. * After the process is complete, call {@link #finish()} to prevent further changes * * @param The type of object this process will be executed on, * a {@link net.minecraft.world.level.block.entity.BlockEntity} in most cases */ public class LinearProcess { private final Int2ObjectMap, T>> clientEventTable = new Int2ObjectOpenHashMap<>(); private final Int2ObjectMap> clientProcessStepTable = new Int2ObjectOpenHashMap<>(); private final Int2ObjectMap, T>> serverEventTable = new Int2ObjectOpenHashMap<>(); private final Int2ObjectMap> serverProcessStepTable = new Int2ObjectOpenHashMap<>(); private Predicate> condition = tLinearProcessExecutor -> true; private final int processLength; private boolean finished = false; /** * Creates a new process * * @param processLength The length of the process. This is immutable */ public LinearProcess(int processLength) { this.processLength = processLength; } /** * Creates a new executor for the given target object * * @param target The object the executor should operate on * @return The created executor. This is not ready for use yet * @see #configureExecutor(LinearProcessExecutor, boolean) */ public LinearProcessExecutor createExecutor(T target) { if (!finished) throw new IllegalStateException("Illegal attempt to create executor for unfinished process"); return new LinearProcessExecutor<>(target, processLength, condition, serverProcessStepTable); } /** * Configures an executor to use either the * server or client instructions * * @param executor The executor to configure * @param client {@code true} if the client instructions should be used */ public void configureExecutor(LinearProcessExecutor executor, boolean client) { if (!finished) throw new IllegalStateException("Illegal attempt to configure executor using unfinished process"); if (client) { executor.configure(clientEventTable, clientProcessStepTable); } else { executor.configure(serverEventTable, serverProcessStepTable); } } /** * Adds a new step to this process on both client and server * * @param when When the step should start * @param length How long it should last * @param executor The code to be run each tick while the step is active */ public void addCommonStep(int when, int length, BiConsumer, T> executor) { checkForIllegalModification(); var step = new LinearProcessExecutor.ProcessStep<>(length, executor); clientProcessStepTable.put(when, step); serverProcessStepTable.put(when, step); } /** * @see #addCommonStep(int, int, BiConsumer) */ public void addClientStep(int when, int length, BiConsumer, T> executor) { checkForIllegalModification(); var step = new LinearProcessExecutor.ProcessStep<>(length, executor); clientProcessStepTable.put(when, step); } /** * @see #addCommonStep(int, int, BiConsumer) */ public void addServerStep(int when, int length, BiConsumer, T> executor) { checkForIllegalModification(); var step = new LinearProcessExecutor.ProcessStep<>(length, executor); serverProcessStepTable.put(when, step); } /** * Adds an event that is executed once, on both client and server * * @param when When the event should occur * @param executor The code to be run on the given tick * @see #addClientEvent(int, BiConsumer) * @see #addServerEvent(int, BiConsumer) */ public void addCommonEvent(int when, BiConsumer, T> executor) { eventAtIndex(when, clientEventTable, executor); eventAtIndex(when, serverEventTable, executor); } /** * @see #addCommonEvent(int, BiConsumer) */ public void addClientEvent(int when, BiConsumer, T> executor) { eventAtIndex(when, clientEventTable, executor); } /** * @see #addCommonEvent(int, BiConsumer) */ public void addServerEvent(int when, BiConsumer, T> executor) { eventAtIndex(when, serverEventTable, executor); } /** * Defines code to be run when this process has successfully * finished, on both client and server * * @param executor The code to be run * @see #whenFinishedClient(BiConsumer) * @see #whenFinishedServer(BiConsumer) */ public void whenFinishedCommon(BiConsumer, T> executor) { eventAtIndex(LinearProcessExecutor.FINISH_EVENT_INDEX, clientEventTable, executor); eventAtIndex(LinearProcessExecutor.FINISH_EVENT_INDEX, serverEventTable, executor); } /** * @see #whenFinishedCommon(BiConsumer) */ public void whenFinishedServer(BiConsumer, T> executor) { eventAtIndex(LinearProcessExecutor.FINISH_EVENT_INDEX, serverEventTable, executor); } /** * @see #whenFinishedCommon(BiConsumer) */ public void whenFinishedClient(BiConsumer, T> executor) { eventAtIndex(LinearProcessExecutor.FINISH_EVENT_INDEX, clientEventTable, executor); } /** * Defines code to be run on both client and server when this process * is unexpectedly cancelled mid-execution, use this to clean up after you. * * @param executor The code to be run * @see #onCancelledClient(BiConsumer) * @see #onCancelledServer(BiConsumer) */ public void onCancelledCommon(BiConsumer, T> executor) { eventAtIndex(LinearProcessExecutor.CANCEL_EVENT_INDEX, clientEventTable, executor); eventAtIndex(LinearProcessExecutor.CANCEL_EVENT_INDEX, serverEventTable, executor); } /** * @see #onCancelledCommon(BiConsumer) */ public void onCancelledServer(BiConsumer, T> executor) { eventAtIndex(LinearProcessExecutor.CANCEL_EVENT_INDEX, serverEventTable, executor); } /** * @see #onCancelledCommon(BiConsumer) */ public void onCancelledClient(BiConsumer, T> executor) { eventAtIndex(LinearProcessExecutor.CANCEL_EVENT_INDEX, clientEventTable, executor); } /** * Defines a condition that has to be met every tick this process runs, * otherwise it cancels itself * * @param condition The condition that should be satisfied during the entire * process execution */ public void runConditionally(Predicate> condition) { this.condition = condition; } /** * Marks this process and completely built and ready for execution */ public void finish() { this.finished = true; } private void checkForIllegalModification() { if (finished) throw new IllegalStateException("Illegal attempt to modify finished process"); } private void eventAtIndex(int index, Int2ObjectMap, T>> eventTable, BiConsumer, T> executor) { checkForIllegalModification(); eventTable.put(index, executor); } } ================================================ FILE: src/main/java/io/wispforest/owo/blockentity/LinearProcessExecutor.java ================================================ package io.wispforest.owo.blockentity; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import net.minecraft.nbt.CompoundTag; import org.jetbrains.annotations.ApiStatus; import java.util.HashSet; import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Predicate; /** * A handler that executes the steps defined in a {@link LinearProcess}. Each object that is * supposed to run the process needs an instance of this, and each instance of this refers back * to the object it operates on * * @param The type of object this executor operates on */ public class LinearProcessExecutor { public static final int CANCEL_EVENT_INDEX = -1; public static final int FINISH_EVENT_INDEX = -2; private final T target; private final int processLength; private final Predicate> condition; private Int2ObjectMap, T>> eventTable; private Int2ObjectMap> processStepTable; private final Set> activeSteps = new HashSet<>(); private int processTick = 0; protected LinearProcessExecutor(T target, int processLength, Predicate> condition, Int2ObjectMap> serverStepTable) { this.target = target; this.processLength = processLength; this.condition = condition; this.eventTable = null; this.processStepTable = serverStepTable; } protected void configure(Int2ObjectMap, T>> eventTable, Int2ObjectMap> processStepTable) { this.eventTable = eventTable; this.processStepTable = processStepTable; } public void tick() { if (this.eventTable == null) throw new IllegalStateException("Illegal attempt to tick unconfigured executor"); if (!this.running()) return; if (this.cancelIfAppropriate()) return; if (this.finishIfAppropriate()) return; int tableIndex = processTick - 1; if (this.eventTable.containsKey(tableIndex)) this.eventTable.get(tableIndex).accept(this, this.target); if (this.processStepTable.containsKey(tableIndex)) this.activeSteps.add(this.processStepTable.get(tableIndex).createInfo(tableIndex)); this.activeSteps.removeIf(stepInfo -> !stepInfo.tick(this)); this.processTick++; } /** * Attempts to begin execution * * @return {@code true} if execution will start next tick, * {@code false} if execution is already running */ public boolean begin() { if (this.processTick != 0) return false; this.processTick = 1; return true; } /** * @return {@code true} if this executor is currently running */ @SuppressWarnings("BooleanMethodIsAlwaysInverted") public boolean running() { return this.processTick > 0; } /** * @return The last processing tick this executor completed */ public int getProcessTick() { return processTick; } /** * @return The object this executor is operating on */ public T getTarget() { return target; } /** * Attempts to instantly cancel execution * * @return {@code true} if execution was successfully cancelled, * {@code false} if this executor was not running */ public boolean cancel() { if (!this.running()) return false; this.processTick = 0; this.activeSteps.clear(); if (this.eventTable.containsKey(CANCEL_EVENT_INDEX)) this.eventTable.get(CANCEL_EVENT_INDEX).accept(this, this.target); return true; } private boolean finishIfAppropriate() { if (!this.running()) return false; if (this.processTick < processLength) return false; if (this.eventTable.containsKey(FINISH_EVENT_INDEX)) this.eventTable.get(FINISH_EVENT_INDEX).accept(this, this.target); this.processTick = 0; this.activeSteps.clear(); return true; } private boolean cancelIfAppropriate() { if (this.condition.test(this)) return false; this.cancel(); return true; } /** * Saves the state of this executor * * @param targetTag The nbt to write state into */ public void writeState(CompoundTag targetTag) { targetTag.putInt("ProcessTick", processTick); } /** * Restores the saved state of this executor * * @param targetTag The nbt to read state from */ public void readState(CompoundTag targetTag) { this.processTick = targetTag.getIntOr("ProcessTick", 0); activeSteps.clear(); processStepTable.forEach((index, step) -> { if (processTick >= index && processTick <= index + step.length) { activeSteps.add(step.createInfo(index, processTick - index)); } }); } @ApiStatus.Internal public record ProcessStep(int length, BiConsumer, T> executor) { public Info createInfo(int index) { return new Info<>(index, this); } public Info createInfo(int index, int tick) { return new Info<>(index, tick, this); } public static final class Info { private final ProcessStep step; private final int index; private int tick = 0; public Info(int index, ProcessStep step) { this.index = index; this.step = step; } public Info(int index, int tick, ProcessStep step) { this.index = index; this.tick = tick; this.step = step; } public boolean tick(LinearProcessExecutor target) { this.tick++; if (this.tick == step.length) return false; this.step.executor.accept(target, target.getTarget()); return true; } } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/animation/AlignmentLerp.java ================================================ package io.wispforest.owo.braid.animation; import io.wispforest.owo.braid.core.Alignment; import net.minecraft.util.Mth; public class AlignmentLerp extends Lerp { public AlignmentLerp(Alignment start, Alignment end) { super(start, end); } @Override protected Alignment at(double t) { return Alignment.of( Mth.lerp(t, this.start.horizontal(), this.end.horizontal()), Mth.lerp(t, this.start.vertical(), this.end.vertical()) ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/animation/Animation.java ================================================ package io.wispforest.owo.braid.animation; import io.wispforest.owo.braid.framework.proxy.ProxyHost; import net.minecraft.util.Mth; import org.jetbrains.annotations.Nullable; import java.time.Duration; public class Animation { private final Scheduler scheduler; private final Listener listener; private final @Nullable FinishListener finishListener; public Easing easing; public Duration duration; private double progress; private @Nullable Target target; public Animation(Easing easing, Duration duration, Scheduler scheduler, Listener listener, @Nullable FinishListener finishListener, Target startFrom) { this.easing = easing; this.duration = duration; this.scheduler = scheduler; this.listener = listener; this.finishListener = finishListener; this.progress = startFrom.targetProgress; } public Animation(Easing easing, Duration duration, Scheduler scheduler, Listener listener, Target startFrom) { this(easing, duration, scheduler, listener, null, startFrom); } public @Nullable Target target() { return this.target; } public double progress() { return this.easing.apply((float) this.progress); } public void towards(Target target) { this.towards(target, true); } public void towards(Target target, boolean restart) { if (restart) { this.progress = 1 - target.targetProgress; } if (this.target == null) { this.scheduler.schedule(this::callback); } this.target = target; } public void pause() { this.target = null; } public void stop() { this.stop(null); } public void stop(@Nullable Target at) { if (this.target == null && at == null) return; this.progress = at != null ? at.targetProgress : this.target.targetProgress; this.target = null; } private void callback(Duration delta) { if (this.target == null) return; this.progress = Mth.clamp( this.progress + this.target.direction * delta.toNanos() / (double) this.duration.toNanos(), 0, 1 ); this.listener.onUpdate(this.easing.apply((float) this.progress)); if (Math.abs(this.progress - this.target.targetProgress) > EPSILON) { this.scheduler.schedule(this::callback); } else { if (this.finishListener != null) { this.finishListener.onFinished(this.target); } this.progress = this.target.targetProgress; this.target = null; } } // --- private static final double EPSILON = 1e-3; // --- public enum Target { START(-1, 0), END(1, 1); public final long direction; public final double targetProgress; Target(long direction, double targetProgress) { this.direction = direction; this.targetProgress = targetProgress; } } @FunctionalInterface public interface Listener { void onUpdate(double progress); } @FunctionalInterface public interface FinishListener { void onFinished(Target atTarget); } @FunctionalInterface public interface Scheduler { void schedule(ProxyHost.AnimationCallback callback); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/animation/AutomaticallyAnimatedWidget.java ================================================ package io.wispforest.owo.braid.animation; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import org.apache.commons.lang3.mutable.MutableBoolean; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Duration; import java.util.Objects; public abstract class AutomaticallyAnimatedWidget extends StatefulWidget { private static final Logger log = LoggerFactory.getLogger(AutomaticallyAnimatedWidget.class); public final Duration duration; public final Easing easing; protected AutomaticallyAnimatedWidget(Duration duration, Easing easing) { this.duration = duration; this.easing = easing; } @Override public abstract State createState(); @SuppressWarnings({"unchecked", "rawtypes"}) public static abstract class State extends WidgetState { private Animation animation; private LerpVisitor activeVisitor; private void callback(double progress) { this.setState(() -> {}); } @Override public void init() { this.animation = new Animation( this.widget().easing, this.widget().duration, this::scheduleAnimationCallback, this::callback, Animation.Target.END ); this.visitLerps((previous, targetValue, factory) -> { return factory.make(targetValue, targetValue); }); } @Override public void didUpdateWidget(AutomaticallyAnimatedWidget oldWidget) { var restartAnimation = new MutableBoolean(this.widget().easing != oldWidget.easing); this.animation.duration = this.widget().duration; if (restartAnimation.isFalse()) { this.visitLerps((previous, targetValue, factory) -> { if (!Objects.equals(previous.end, targetValue)) { restartAnimation.setTrue(); } return previous; }); } if (restartAnimation.isTrue()) { this.visitLerps((previous, targetValue, factory) -> factory.make(previous.compute(this.animationValue()), targetValue)); this.animation.easing = this.widget().easing; this.animation.towards(Animation.Target.END); } } private void visitLerps(LerpVisitor visitor) { this.activeVisitor = visitor; this.updateLerps(); } // --- protected double animationValue() { return this.animation.progress(); } protected , V> L visitLerp(@Nullable Lerp previous, V targetValue, Lerp.Factory factory) { return (L) this.activeVisitor.visit(previous, targetValue, factory); } protected , V> L visitNullableLerp(@Nullable Lerp previous, V targetValue, Lerp.Factory factory) { return (L) this.activeVisitor.visit(previous, targetValue, (start, end) -> new NullableLerp(start, end, factory)); } protected abstract void updateLerps(); } @FunctionalInterface private interface LerpVisitor, V> { L visit(@Nullable Lerp previous, V targetValue, Lerp.Factory factory); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/animation/ColorLerp.java ================================================ package io.wispforest.owo.braid.animation; import io.wispforest.owo.braid.core.Color; public class ColorLerp extends Lerp { public ColorLerp(Color start, Color end) { super(start, end); } @Override protected Color at(double t) { return Color.mix(t, this.start, this.end); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/animation/DoubleLerp.java ================================================ package io.wispforest.owo.braid.animation; import net.minecraft.util.Mth; public class DoubleLerp extends Lerp { public DoubleLerp(Double start, Double end) { super(start, end); } @Override protected Double at(double t) { return Mth.lerp(t, this.start, this.end); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/animation/Easing.java ================================================ package io.wispforest.owo.braid.animation; public class Easing { public static final Easing LINEAR = new Easing(x -> x); public static final Easing IN_QUAD = new Easing(x -> x * x); public static final Easing OUT_QUAD = new Easing(x -> 1.0 - (1.0 - x) * (1.0 - x)); public static final Easing IN_OUT_QUAD = new Easing(x -> x < 0.5 ? 2.0 * x * x : 1.0 - Math.pow(-2.0 * x + 2.0, 2.0) / 2.0); public static final Easing IN_CUBIC = new Easing(x -> x * x * x); public static final Easing OUT_CUBIC = new Easing(x -> 1.0 - Math.pow(1.0 - x, 3)); public static final Easing IN_OUT_CUBIC = new Easing(x -> x < 0.5 ? 4.0 * x * x * x : 1.0 - Math.pow(-2.0 * x + 2.0, 3.0) / 2.0); public static final Easing IN_QUART = new Easing(x -> x * x * x * x); public static final Easing OUT_QUART = new Easing(x -> 1.0 - Math.pow(1.0 - x, 4.0)); public static final Easing IN_OUT_QUART = new Easing(x -> x < 0.5 ? 8.0 * x * x * x * x : 1.0 - Math.pow(-2.0 * x + 2.0, 4.0) / 2.0); public static final Easing IN_QUINT = new Easing(x -> x * x * x * x * x); public static final Easing OUT_QUINT = new Easing(x -> 1.0 - Math.pow(1.0 - x, 5.0)); public static final Easing IN_OUT_QUINT = new Easing(x -> x < 0.5 ? 16.0 * x * x * x * x * x : 1.0 - Math.pow(-2.0 * x + 2.0, 5.0) / 2.0); public static final Easing IN_SINE = new Easing(x -> 1.0 - Math.cos((x * Math.PI) / 2.0)); public static final Easing OUT_SINE = new Easing(x -> Math.sin((x * Math.PI) / 2.0)); public static final Easing IN_OUT_SINE = new Easing(x -> -(Math.cos(Math.PI * x) - 1) / 2.0); public static final Easing IN_EXPO = new Easing(x -> x == 0.0 ? 0.0 : Math.pow(2.0, 10.0 * x - 10.0)); public static final Easing OUT_EXPO = new Easing(x -> x == 1.0 ? 1.0 : 1.0 - Math.pow(2.0, -10.0 * x)); public static final Easing IN_OUT_EXPO = new Easing(x -> x == 0.0 ? 0.0 : x == 1.0 ? 1.0 : x < 0.5 ? Math.pow(2.0, 20.0 * x - 10.0) / 2.0 : (2.0 - Math.pow(2.0, -20.0 * x + 10.0)) / 2.0); public static final Easing IN_CIRC = new Easing(x -> 1.0 - Math.sqrt(1.0 - Math.pow(x, 2.0))); public static final Easing OUT_CIRC = new Easing(x -> Math.sqrt(1.0 - Math.pow(x - 1.0, 2.0))); public static final Easing IN_OUT_CIRC = new Easing(x -> x < 0.5 ? (1.0 - Math.sqrt(1.0 - Math.pow(2.0 * x, 2.0))) / 2 : (Math.sqrt(1.0 - Math.pow(-2.0 * x + 2.0, 2.0)) + 1.0) / 2.0); public static final Easing OUT_BOUNCE = new Easing(x -> { var n1 = 7.5625; var d1 = 2.75; if (x < 1 / d1) { return n1 * x * x; } else if (x < 2 / d1) { return n1 * (x -= 1.5 / d1) * x + 0.75; } else if (x < 2.5 / d1) { return n1 * (x -= 2.25 / d1) * x + 0.9375; } else { return n1 * (x -= 2.625 / d1) * x + 0.984375; } }); // --- private final Function function; public Easing(Function function) { this.function = function; } public final double apply(double x) { if (x == 0 || x == 1) return x; return this.compute(x); } protected double compute(double x) { return this.function.compute(x); } @FunctionalInterface public interface Function { double compute(double x); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/animation/InsetsLerp.java ================================================ package io.wispforest.owo.braid.animation; import io.wispforest.owo.braid.core.Insets; import net.minecraft.util.Mth; public class InsetsLerp extends Lerp { public InsetsLerp(Insets start, Insets end) { super(start, end); } @Override protected Insets at(double t) { return Insets.of( Mth.lerp(t, this.start.top(), this.end.top()), Mth.lerp(t, this.start.bottom(), this.end.bottom()), Mth.lerp(t, this.start.left(), this.end.left()), Mth.lerp(t, this.start.right(), this.end.right()) ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/animation/Lerp.java ================================================ package io.wispforest.owo.braid.animation; public abstract class Lerp { public final T start; public final T end; protected Lerp(T start, T end) { this.start = start; this.end = end; } public T compute(double t) { if (t - EPSILON <= 0) return this.start; if (t + EPSILON >= 1) return this.end; return this.at(t); } protected abstract T at(double t); // --- private static final double EPSILON = 1e-4; // --- @FunctionalInterface public interface Factory, V> { T make(V start, V end); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/animation/NullableLerp.java ================================================ package io.wispforest.owo.braid.animation; import org.jetbrains.annotations.Nullable; public class NullableLerp extends Lerp { private final @Nullable Lerp delegate; public NullableLerp(@Nullable T start, @Nullable T end, Lerp.Factory, T> delegateFactory) { super(start, end); if (start != null) { this.delegate = delegateFactory.make(start, end); } else { this.delegate = null; } } @Override protected T at(double t) { return this.delegate != null ? this.delegate.at(t) : this.end; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/Aabb2d.java ================================================ package io.wispforest.owo.braid.core; import org.joml.Matrix3x2f; import org.joml.Vector2f; public class Aabb2d { public double x; public double y; public double width; public double height; public Aabb2d(double x, double y, double width, double height) { this.x = x; this.y = y; this.width = width; this.height = height; } public double minX() { return this.x; } public double maxX() { return this.x + this.width; } public double minY() { return this.y; } public double maxY() { return this.y + this.height; } public Aabb2d transform(Matrix3x2f matrix) { var topLeft = matrix.transformPosition((float) this.x, (float) this.y, new Vector2f()); var topRight = matrix.transformPosition((float) (this.x + this.width), (float) this.y, new Vector2f()); var bottomLeft = matrix.transformPosition((float) this.x, (float) (this.y + this.height), new Vector2f()); var bottomRight = matrix.transformPosition((float) (this.x + this.width), (float) (this.y + this.height), new Vector2f()); this.x = Math.min(Math.min(Math.min(topLeft.x, topRight.x), bottomLeft.x), bottomRight.x); this.width = Math.max(Math.max(Math.max(topLeft.x, topRight.x), bottomLeft.x), bottomRight.x) - this.x; this.y = Math.min(Math.min(Math.min(topLeft.y, topRight.y), bottomLeft.y), bottomRight.y); this.height = Math.max(Math.max(Math.max(topLeft.y, topRight.y), bottomLeft.y), bottomRight.y) - this.y; return this; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/Alignment.java ================================================ package io.wispforest.owo.braid.core; import org.jetbrains.annotations.ApiStatus; public record Alignment(double horizontal, double vertical) { public static final Alignment TOP_LEFT = Alignment.of(0, 0); public static final Alignment TOP = Alignment.of(.5, 0); public static final Alignment TOP_RIGHT = Alignment.of(1, 0); public static final Alignment LEFT = Alignment.of(0, .5); public static final Alignment CENTER = Alignment.of(.5, .5); public static final Alignment RIGHT = Alignment.of(1, .5); public static final Alignment BOTTOM_LEFT = Alignment.of(0, 1); public static final Alignment BOTTOM = Alignment.of(.5, 1); public static final Alignment BOTTOM_RIGHT = Alignment.of(1, 1); // --- @ApiStatus.Internal @Deprecated(forRemoval = true) public Alignment {} public static Alignment of(double horizontal, double vertical) { return new Alignment(horizontal, vertical); } public double alignHorizontal(double space, double object) { return Math.floor((space - object) * horizontal); } public double alignVertical(double space, double object) { return Math.floor((space - object) * vertical); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/AppState.java ================================================ package io.wispforest.owo.braid.core; import com.google.common.collect.Iterables; import com.mojang.blaze3d.opengl.GlStateManager; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.core.events.*; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.instance.*; import io.wispforest.owo.braid.framework.proxy.BuildScope; import io.wispforest.owo.braid.framework.proxy.ProxyHost; import io.wispforest.owo.braid.framework.proxy.SingleChildInstanceWidgetProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Tooltip; import io.wispforest.owo.braid.widgets.basic.VisitorWidget; import io.wispforest.owo.braid.widgets.eventstream.BraidEventStream; import io.wispforest.owo.braid.widgets.focus.FocusClickArea; import io.wispforest.owo.braid.widgets.focus.RootFocusScope; import io.wispforest.owo.braid.widgets.inspector.BraidInspector; import io.wispforest.owo.braid.widgets.inspector.InstancePicker; import io.wispforest.owo.util.EventSource; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent; import net.minecraft.network.chat.Style; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.joml.Vector2d; import org.joml.Vector2dc; import org.joml.Vector2f; import org.lwjgl.glfw.GLFW; import org.slf4j.Logger; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.function.Consumer; public class AppState implements InstanceHost, ProxyHost { public final @Nullable Logger logger; private final Minecraft client; public final Surface surface; public final EventBinding eventBinding; private final BuildScope rootBuildScope = new BuildScope(); private Deque animationCallbacks = new LinkedList<>(); private final PriorityQueue callbacks = new PriorityQueue<>(); private Deque postLayoutCallbacks = new LinkedList<>(); private final String name; private final RootProxy root; private final Vector2d cursorPosition = new Vector2d(); private Set hovered = new HashSet<>(); private final WeakHashMap mousePositions = new WeakHashMap<>(); private @Nullable MouseListener dragging = null; private @Nullable CursorStyle draggingCursorStyle = null; private int draggingButton = -1; private KeyModifiers draggingModifiers = null; private boolean dragStarted = false; private static final Duration MIN_GRACE_PERIOD = Duration.ofMillis(200); private static final Duration MAX_GRACE_PERIOD = Duration.ofMillis(500); private static final int SCROLL_MOVEMENT_THRESHOLD = 5; private @Nullable HitTestState scrollHit = null; private Vector2d scrollPos = new Vector2d(); private Instant lastScrollTime = Instant.EPOCH; private final BraidEventStream keyDownStream = new BraidEventStream<>(); private final BraidEventStream keyUpStream = new BraidEventStream<>(); private final BraidEventStream charStream = new BraidEventStream<>(); private final BraidHotReloadCallback.Listener reloadListener; private final EventSource.Subscription resizeSubscription; private final List onTerminate = new ArrayList<>(); private boolean running = true; private final BraidInspector inspector = new BraidInspector(this); public AppState( @Nullable Logger logger, @Nullable String name, Minecraft client, Surface surface, EventBinding eventBinding, Widget root ) { this.logger = logger; this.client = client; this.surface = surface; this.eventBinding = eventBinding; this.name = name != null ? name : root.getClass().getName(); this.root = new RootWidget( new AppWidget( this, new InstancePicker( this.inspector.onPick(), this.inspector::revealInstance, new RootFocusScope( this.keyDownStream.source(), this.keyUpStream.source(), this.charStream.source(), new UserRoot( widgetProxy -> inspector.rootProxy = widgetProxy, widgetInstance -> inspector.rootInstance = widgetInstance, root ) ) ) ), this.rootBuildScope ).proxy(); this.root.bootstrap(this, this); this.scheduleLayout(this.rootInstance()); this.reloadListener = BraidHotReloadCallback.register(); this.resizeSubscription = this.surface.onResize().subscribe((newWidth, newHeight) -> { this.rootInstance().markNeedsLayout(); }); } public boolean running() { return this.running; } public void onTerminate(Runnable callback) { this.onTerminate.add(callback); } public void scheduleShutdown() { this.running = false; this.onTerminate.forEach(Runnable::run); } public void activateInspector() { this.inspector.activate(); } private @Nullable TooltipState activeTooltip; public void draw(GuiGraphics graphics) { this.surface.beginRendering(); graphics.push(); this.rootInstance().transform.transformToParent(graphics.pose()); var braidContext = BraidGraphics.create(graphics, this.surface); GlStateManager._enableScissorTest(); this.rootInstance().draw(braidContext); GlStateManager._disableScissorTest(); if (this.activeTooltip != null) { if (this.activeTooltip.components() != null) braidContext.drawTooltip(this.client.font, this.activeTooltip.x(), this.activeTooltip.y(), this.activeTooltip.components()); if (this.activeTooltip.style() != null) graphics.renderComponentHoverEffect(this.client.font, this.activeTooltip.style(), this.activeTooltip.x(), this.activeTooltip.y()); } graphics.pop(); this.surface.endRendering(); } public void processEvents(float frameDeltaInTicks) { this.pollAndDispatchEvents(); var state = this.hitTest(); var tooltipSupplier = state.firstWhere(hit -> hit.instance() instanceof TooltipProvider); if (tooltipSupplier != null) { var tooltip = (TooltipProvider) tooltipSupplier.instance(); var components = tooltip.getTooltipComponentsAt(tooltipSupplier.x(), tooltipSupplier.y()); var style = tooltip.getStyleAt(tooltipSupplier.x(), tooltipSupplier.y()); if (components != null || style != null) this.activeTooltip = new TooltipState(components, style, (int) this.cursorPosition.x, (int) this.cursorPosition.y); } else { this.activeTooltip = null; } // --- var nowHovered = new HashSet(); for (var hit : Iterables.filter(state.occludedTrace(), hit -> hit.instance() instanceof MouseListener)) { var listener = (MouseListener) hit.instance(); nowHovered.add(listener); if (this.hovered.contains(listener)) { this.hovered.remove(listener); } else { listener.onMouseEnter(); } var mousePosition = this.mousePositions.getOrDefault(listener, MousePosition.ORIGIN); if (mousePosition.x() != hit.x() || mousePosition.y() != hit.y()) { listener.onMouseMove(hit.x(), hit.y()); this.mousePositions.put(listener, new MousePosition(hit.x(), hit.y())); } } for (var noLongerHovered : this.hovered) { noLongerHovered.onMouseExit(); } this.hovered = nowHovered; // --- @Nullable CursorStyle activeStyle = null; if (this.dragging != null) { activeStyle = this.draggingCursorStyle; } else { var cursorStyleSource = state.firstWhere( (hit) -> hit.instance() instanceof MouseListener && ((MouseListener) hit.instance()).cursorStyleAt(hit.x(), hit.y()) != null ); if (cursorStyleSource != null) { activeStyle = ((MouseListener) cursorStyleSource.instance()).cursorStyleAt( cursorStyleSource.x(), cursorStyleSource.y() ); } } this.surface.setCursorStyle(activeStyle != null ? activeStyle : CursorStyle.NONE); // --- if (this.reloadListener.poll()) { this.rebuildRoot(); } if (!this.animationCallbacks.isEmpty()) { var callbacksForThisFrame = this.animationCallbacks; this.animationCallbacks = new LinkedList<>(); while (!callbacksForThisFrame.isEmpty()) { callbacksForThisFrame.poll().run(Duration.ofMillis((long) (frameDeltaInTicks * 50))); } } var now = Instant.now(); while (!this.callbacks.isEmpty() && this.callbacks.peek().after().isBefore(now)) { this.callbacks.poll().callback().run(); } var anyTreeMutations = false; anyTreeMutations |= this.rootBuildScope.rebuildDirtyProxies(); anyTreeMutations |= this.flushLayoutQueue(); if (anyTreeMutations) { this.inspector.refresh(); } if (!this.postLayoutCallbacks.isEmpty()) { var callbacksForThisFrame = this.postLayoutCallbacks; this.postLayoutCallbacks = new LinkedList<>(); while (!callbacksForThisFrame.isEmpty()) { callbacksForThisFrame.poll().run(); } } } private void pollAndDispatchEvents() { var events = this.eventBinding.poll(); for (var slot : events) { switch (slot.event) { case MouseButtonPressEvent(int button, KeyModifiers modifiers) -> { this.scrollHit = null; var state = this.hitTest(); state.firstWhere(hit -> { if (!(hit.instance() instanceof FocusClickArea.Instance instance)) return false; instance.widget().clickCallback.run(); return true; }); var clicked = state.firstWhere( (hit) -> hit.instance() instanceof MouseListener && ((MouseListener) hit.instance()).onMouseDown(hit.x(), hit.y(), button, modifiers) ); if (clicked != null) { slot.markHandled(); if (this.dragging == null) { this.dragging = (MouseListener) clicked.instance(); this.draggingCursorStyle = ((MouseListener) clicked.instance()).cursorStyleAt( clicked.x(), clicked.y() ); this.dragStarted = false; this.draggingButton = button; this.draggingModifiers = modifiers; } } } case MouseMoveEvent(double x, double y) -> { slot.markHandled(); var deltaX = x - this.cursorPosition.x; var deltaY = y - this.cursorPosition.y; if (deltaX == 0 && deltaY == 0) break; this.cursorPosition.x = x; this.cursorPosition.y = y; if (this.cursorPosition.distance(this.scrollPos) > SCROLL_MOVEMENT_THRESHOLD) this.scrollHit = null; if (!(this.dragging instanceof WidgetInstance)) break; if (!this.dragStarted) { this.dragging.onMouseDragStart(draggingButton, draggingModifiers); this.dragStarted = true; } var globalTransform = ((WidgetInstance) this.dragging).computeGlobalTransform(); var coordinates = new Vector2f((float) x, (float) y); globalTransform.transformPosition(coordinates); // apply *only the rotation* of the instance's transform // to the mouse movement var delta = new Vector2f((float) deltaX, (float) deltaY); globalTransform.transformDirection(delta); this.dragging.onMouseDrag(coordinates.x, coordinates.y, delta.x, delta.y); } case MouseButtonReleaseEvent(int button, KeyModifiers modifiers) -> { this.scrollHit = null; var state = this.hitTest(); var unClicked = state.firstWhere( (hit) -> hit.instance() instanceof MouseListener && ((MouseListener) hit.instance()).onMouseUp(hit.x(), hit.y(), button, modifiers) ); if (unClicked != null) { slot.markHandled(); } if (this.draggingButton == button) { if (this.dragStarted && this.dragging != null) { this.dragging.onMouseDragEnd(); } this.dragging = null; } } case MouseScrollEvent(double xOffset, double yOffset) -> { var now = Instant.now(); var grace = this.cursorPosition.distance(this.scrollPos) > SCROLL_MOVEMENT_THRESHOLD ? MIN_GRACE_PERIOD : MAX_GRACE_PERIOD; if (this.scrollHit == null || now.minus(grace).isAfter(this.lastScrollTime) ) this.scrollHit = this.hitTest(); this.lastScrollTime = now; this.scrollPos = new Vector2d(this.cursorPosition); var scrolled = this.scrollHit.firstWhere( (hit) -> hit.instance() instanceof MouseListener && ((MouseListener) hit.instance()).onMouseScroll( hit.x(), hit.y(), xOffset, yOffset ) ); if (scrolled != null) { slot.markHandled(); } } case KeyPressEvent(int keyCode, int scancode, KeyModifiers modifiers) -> { if (keyCode == GLFW.GLFW_KEY_R && modifiers.shift() && modifiers.alt()) { this.rebuildRoot(); slot.markHandled(); break; } if (keyCode == GLFW.GLFW_KEY_I && modifiers.ctrl() && modifiers.shift()) { this.inspector.activate(); slot.markHandled(); break; } var event = new RootFocusScope.KeyDownEvent(keyCode, modifiers); this.keyDownStream.sink().onEvent(event); if (event.handled()) { slot.markHandled(); } } case KeyReleaseEvent(int keycode, int scancode, KeyModifiers modifiers) -> { var event = new RootFocusScope.KeyUpEvent(keycode, modifiers); this.keyUpStream.sink().onEvent(event); if (event.handled()) { slot.markHandled(); } } case CharInputEvent(char codepoint, KeyModifiers modifiers) -> { var event = new RootFocusScope.CharEvent(codepoint, modifiers); this.charStream.sink().onEvent(event); if (event.handled()) { slot.markHandled(); } } case FilesDroppedEvent filesDroppedEvent -> {} case CloseEvent ignored -> { slot.markHandled(); this.scheduleShutdown(); } } } } public void rebuildRoot() { var before = Instant.now(); this.root.reassemble(); var elapsed = ChronoUnit.MICROS.between(before, Instant.now()); if (this.logger != null) this.logger.debug("completed full app rebuild in {}us", elapsed); } public void dispose() { this.inspector.close(); this.reloadListener.unregister(); this.resizeSubscription.cancel(); this.surface.dispose(); this.root.unmount(); } private HitTestState hitTest() { return this.hitTest(this.cursorPosition.x, this.cursorPosition.y); } public HitTestState hitTest(double x, double y) { var state = new HitTestState(); this.rootInstance().hitTest(x, y, state); return state; } // --- @Override public Minecraft client() { return this.client; } public SingleChildWidgetInstance rootInstance() { return this.root.instance(); } // --- private List> layoutQueue = new ArrayList<>(); private boolean mergeToLayoutQueue = false; private boolean flushLayoutQueue() { if (this.layoutQueue.isEmpty()) return false; while (!this.layoutQueue.isEmpty()) { var queue = this.layoutQueue; this.layoutQueue = new ArrayList<>(); queue.sort(Comparator.naturalOrder()); for (var idx = 0; idx < queue.size(); idx++) { var instance = queue.get(idx); if (this.mergeToLayoutQueue) { this.mergeToLayoutQueue = false; if (!this.layoutQueue.isEmpty()) { this.layoutQueue.addAll(queue.subList(idx, queue.size())); break; } } if (instance.needsLayout()) { instance.layout( instance.hasParent() ? instance.constraints() : Constraints.tight(Size.of(this.surface.width(), this.surface.height())) ); } } this.mergeToLayoutQueue = false; } return true; } @Override public void scheduleLayout(WidgetInstance instance) { this.layoutQueue.add(instance); } @Override public void notifySubtreeRebuild() { this.mergeToLayoutQueue = true; } @Override public void scheduleAnimationCallback(AnimationCallback callback) { this.animationCallbacks.offer(callback); } @Override public long scheduleDelayedCallback(Duration delay, Runnable callback) { var id = ScheduledCallback.nextId++; this.callbacks.add(new ScheduledCallback( Instant.now().plus(delay), callback, id )); return id; } @Override public void cancelDelayedCallback(long id) { this.callbacks.removeIf(scheduledCallback -> scheduledCallback.id() == id); } @Override public void schedulePostLayoutCallback(Runnable callback) { this.postLayoutCallbacks.offer(callback); } @Override public Vector2dc cursorPosition() { return this.cursorPosition; } @Override public String toString() { return String.format("%s (AppState@%s)", this.name, Integer.toHexString(hashCode())); } // --- public static String formatName(String category, Widget userRoot) { var classPath = userRoot.getClass().getName().split("\\."); return String.format("%s[%s]", category, classPath[classPath.length - 1]); } public static String formatName(String category, Widget userRoot, String... attributes) { var classPath = userRoot.getClass().getName().split("\\."); return String.format("%s[%s, %s]", category, String.join(", ", attributes), classPath[classPath.length - 1]); } public static AppState of(BuildContext context) { //noinspection DataFlowIssue return context.getAncestor(AppWidget.class).app; } } record ScheduledCallback(Instant after, Runnable callback, long id) implements Comparable { //"fuck you we starting at 7" -chyz public static long nextId = 7; @Override public int compareTo(@NotNull ScheduledCallback o) { return this.after.compareTo(o.after); } } class RootWidget extends SingleChildInstanceWidget { public final BuildScope rootBuildScope; public RootWidget(Widget child, BuildScope rootBuildScope) { super(child); this.rootBuildScope = rootBuildScope; } @Override public RootProxy proxy() { return new RootProxy(this); } @Override public RootInstance instantiate() { return new RootInstance(this); } } class RootProxy extends SingleChildInstanceWidgetProxy { public RootProxy(RootWidget widget) { super(widget); } @Override public BuildScope buildScope() { return ((RootWidget) this.widget()).rootBuildScope; } @Override public boolean mounted() { return this.bootstrapped; } private boolean bootstrapped = false; void bootstrap(InstanceHost instanceHost, ProxyHost proxyHost) { this.bootstrapped = true; this.lifecycle = Lifecycle.LIVE; this.rootSetHost(proxyHost); rebuild(); this.setDepth(0); this.instance.setDepth(0); this.instance.attachHost(instanceHost); } } class RootInstance extends SingleChildWidgetInstance.ShrinkWrap { public RootInstance(RootWidget widget) { super(widget); } } class UserRoot extends VisitorWidget { public final Consumer proxyCallback; public final Consumer> instanceCallback; public UserRoot(Consumer proxyCallback, Consumer> instanceCallback, Widget child) { super(child); this.proxyCallback = proxyCallback; this.instanceCallback = instanceCallback; } private static final Visitor VISITOR = (widget, instance) -> { widget.instanceCallback.accept(instance); }; @Override public Proxy proxy() { var proxy = new Proxy<>(this, VISITOR); this.proxyCallback.accept(proxy); return proxy; } } class AppWidget extends InheritedWidget { public final AppState app; protected AppWidget(AppState app, Widget child) { super(child); this.app = app; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { if (((AppWidget) newWidget).app != this.app) { throw new UnsupportedOperationException("changing the AppState of a widget tree is not supported"); } return false; } } record TooltipState(@Nullable List components, @Nullable Style style, int x, int y) {} record MousePosition(double x, double y) { public static final MousePosition ORIGIN = new MousePosition(0, 0); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/BraidGraphics.java ================================================ package io.wispforest.owo.braid.core; import com.mojang.blaze3d.pipeline.RenderPipeline; import io.wispforest.owo.braid.core.element.BraidDashedLineElement; import io.wispforest.owo.mixin.braid.Matrix3x2fStackAccessor; import io.wispforest.owo.mixin.ui.access.GuiGraphicsAccessor; import io.wispforest.owo.ui.core.OwoUIGraphics; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.render.state.GuiRenderState; import org.joml.Matrix3x2f; import org.joml.Matrix3x2fStack; import org.joml.Matrix3x2fc; import java.util.function.Consumer; public class BraidGraphics extends OwoUIGraphics { private final Surface surface; protected BraidGraphics(Minecraft client, GuiRenderState renderState, int mouseX, int mouseY, Consumer setTooltipDrawer, Surface surface) { super(client, renderState, mouseX, mouseY, setTooltipDrawer); this.surface = surface; } public static BraidGraphics create(GuiGraphics grpahics, Surface surface) { var braidContext = new BraidGraphics( Minecraft.getInstance(), grpahics.guiRenderState, ((GuiGraphicsAccessor) grpahics).owo$getMouseX(), ((GuiGraphicsAccessor) grpahics).owo$getMouseY(), ((GuiGraphicsAccessor) grpahics)::owo$setDeferredTooltip, surface ); ((GuiGraphicsAccessor) braidContext).owo$setScissorStack(((GuiGraphicsAccessor) grpahics).owo$getScissorStack()); ((GuiGraphicsAccessor) braidContext).owo$setPose(new MatrixStack(((GuiGraphicsAccessor) grpahics).owo$getPose())); return braidContext; } @Override public int guiWidth() { return this.surface.width(); } @Override public int guiHeight() { return this.surface.height(); } public void buildRectOutline(double x, double y, double width, double height, RectEdgeBuilder builder) { builder.edge(x, y, x + width, y); builder.edge(x, y + height, x + width, y + height); builder.edge(x, y, x, y + height); builder.edge(x + width, y, x + width, y + height); } public void drawDashedLine(RenderPipeline pipeline, double x1, double y1, double x2, double y2, double thiccness, double segmentLength, Color color) { this.guiRenderState.submitGuiElement(new BraidDashedLineElement( color, thiccness, segmentLength, pipeline, new Matrix3x2f(this.pose()), new ScreenRectangle((int) x1, (int) y1, (int) (x2 - x1), (int) (y2 - y1)), this.scissorStack.peek() )); } @FunctionalInterface public interface RectEdgeBuilder { void edge(double x1, double y1, double x2, double y2); } @SuppressWarnings("ExternalizableWithoutPublicNoArgConstructor") public static class MatrixStack extends Matrix3x2fStack { public MatrixStack(Matrix3x2fc source) { super(16); this.mul(source); } @Override public Matrix3x2fStack pushMatrix() { var accessor = (Matrix3x2fStackAccessor) this; if (accessor.owo$getCurr() == accessor.owo$getMats().length) { var newMats = new Matrix3x2f[accessor.owo$getMats().length * 2]; System.arraycopy(accessor.owo$getMats(), 0, newMats, 0, accessor.owo$getMats().length); for (int idx = newMats.length / 2; idx < newMats.length; idx++) { newMats[idx] = new Matrix3x2f(); } accessor.owo$setMats(newMats); } return super.pushMatrix(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/BraidHotReloadCallback.java ================================================ package io.wispforest.owo.braid.core; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.HashSet; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; public final class BraidHotReloadCallback { private static final Set LISTENERS = new HashSet<>(); public static final Logger LOGGER = LoggerFactory.getLogger("braid reload agent"); public static Listener register() { var listener = new Listener(); LISTENERS.add(listener); return listener; } @ApiStatus.Internal public static void setupComplete() { LOGGER.info("setup complete, debounce time is {}ms", Listener.DEBOUNCE_TIME); } @ApiStatus.Internal public static void invoke() { for (var listener : LISTENERS) { listener.triggered.set(true); } } public static class Listener { private static final int DEBOUNCE_TIME = Integer.getInteger("owo.braid.hotswapDebounceTime", 250); private final AtomicBoolean triggered = new AtomicBoolean(); private @Nullable Instant lastTriggerTimestamp = null; public boolean poll() { if (this.triggered.getAndSet(false)) { this.lastTriggerTimestamp = Instant.now(); } if (this.lastTriggerTimestamp != null && ChronoUnit.MILLIS.between(this.lastTriggerTimestamp, Instant.now()) > DEBOUNCE_TIME) { this.lastTriggerTimestamp = null; return true; } return false; } public void unregister() { LISTENERS.remove(this); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/BraidRenderPipelines.java ================================================ package io.wispforest.owo.braid.core; import com.mojang.blaze3d.pipeline.RenderPipeline; import io.wispforest.owo.Owo; import net.minecraft.client.renderer.RenderPipelines; import org.jetbrains.annotations.ApiStatus; public class BraidRenderPipelines { public static final RenderPipeline TEXTURED_DEFAULT = RenderPipeline.builder(RenderPipelines.GUI_TEXTURED_SNIPPET) .withLocation(Owo.id("pipeline/braid_textured_default")) .build(); public static final RenderPipeline TEXTURED_NEAREST = RenderPipeline.builder(RenderPipelines.GUI_TEXTURED_SNIPPET) .withLocation(Owo.id("pipeline/braid_textured_nearest")) .build(); public static final RenderPipeline TEXTURED_BILINEAR = RenderPipeline.builder(RenderPipelines.GUI_TEXTURED_SNIPPET) .withLocation(Owo.id("pipeline/braid_textured_bilinear")) .build(); @ApiStatus.Internal public static void register() { RenderPipelines.register(TEXTURED_DEFAULT); RenderPipelines.register(TEXTURED_NEAREST); RenderPipelines.register(TEXTURED_BILINEAR); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/BraidScreen.java ================================================ package io.wispforest.owo.braid.core; import io.wispforest.owo.braid.core.events.*; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.BraidApp; import io.wispforest.owo.ui.util.DisposableScreen; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.input.CharacterEvent; import net.minecraft.client.input.KeyEvent; import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.Nullable; public class BraidScreen extends Screen implements DisposableScreen { protected final EventBinding eventBinding = new EventBinding.Default(); protected final Surface.Default surface = new Surface.Default(); protected final Settings settings; protected final Widget rootWidget; public AppState state; public BraidScreen(Settings settings, Widget rootWidget) { super(Component.empty()); this.settings = settings; this.rootWidget = rootWidget; } public BraidScreen(Widget rootWidget) { this(new Settings(), rootWidget); } @Override protected void init() { super.init(); if (this.state == null) { var widget = this.settings.useBraidAppWidget ? new BraidApp(this.rootWidget) : this.rootWidget; this.state = new AppState( null, AppState.formatName("BraidScreen", this.rootWidget), this.minecraft, this.surface, this.eventBinding, new BraidScreenProvider(this, widget) ); } } @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { super.render(graphics, mouseX, mouseY, delta); this.eventBinding.add(new MouseMoveEvent(mouseX, mouseY)); this.state.processEvents( this.minecraft.getDeltaTracker().getGameTimeDeltaTicks() ); this.state.draw(graphics); } @Override public void dispose() { this.state.dispose(); } @Override public boolean isPauseScreen() { return this.settings.shouldPause; } @Override public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { this.eventBinding.add(new MouseButtonPressEvent(click.button(), click.modifiers())); return true; } @Override public boolean mouseReleased(MouseButtonEvent click) { this.eventBinding.add(new MouseButtonReleaseEvent(click.button(), click.modifiers())); return true; } @Override public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { this.eventBinding.add(new MouseScrollEvent(horizontalAmount, verticalAmount)); return true; } @Override public boolean keyPressed(KeyEvent input) { this.eventBinding.add(new KeyPressEvent(input.key(), input.scancode(), input.modifiers())); return super.keyPressed(input); } @Override public boolean keyReleased(KeyEvent input) { this.eventBinding.add(new KeyReleaseEvent(input.key(), input.scancode(), input.modifiers())); return true; } @Override public boolean charTyped(CharacterEvent input) { this.eventBinding.add(new CharInputEvent((char) input.codepoint(), input.modifiers())); return true; } // --- public static @Nullable BraidScreen maybeOf(BuildContext context) { var provider = context.getAncestor(BraidScreenProvider.class); return provider != null ? provider.screen : null; } public static class Settings { public boolean shouldPause = true; public boolean useBraidAppWidget = true; } } class BraidScreenProvider extends InheritedWidget { public final BraidScreen screen; public BraidScreenProvider(BraidScreen screen, Widget child) { super(child); this.screen = screen; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return false; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/BraidUtils.java ================================================ package io.wispforest.owo.braid.core; import java.util.function.BiFunction; public class BraidUtils { public static T fold(Iterable values, T initial, BiFunction step) { var result = initial; for (var value : values) { result = step.apply(result, value); } return result; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/BraidWindow.java ================================================ package io.wispforest.owo.braid.core; import com.mojang.blaze3d.opengl.GlDebug; import com.mojang.blaze3d.opengl.GlTexture; import com.mojang.blaze3d.pipeline.TextureTarget; import com.mojang.blaze3d.systems.RenderSystem; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.cursor.CursorController; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.core.events.*; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.util.BraidGuiRenderer; import io.wispforest.owo.util.EventSource; import io.wispforest.owo.util.EventStream; import net.minecraft.client.Minecraft; import org.apache.commons.lang3.mutable.MutableLong; import org.lwjgl.glfw.*; import org.lwjgl.opengl.GL32; import org.lwjgl.system.NativeResource; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.HashSet; import java.util.List; // TODO: consider somehow getting notified or polling // for changes in the gui scale option so we can react // instantly when it changes rather than on next resize public class BraidWindow implements Surface { public final EventBinding eventBinding = new WindowEventBinding(this); public final long handle; private final List resources = new ArrayList<>(); private final EventStream onResize = ResizeCallback.newStream(); private TextureTarget remoteTarget; private int localFbo; public final BraidGuiRenderer guiRenderer; private final CursorController cursorController; private int framebufferWidth; private int framebufferHeight; private int scaledWidth; private int scaledHeight; private int scaleFactor; public BraidWindow(long handle) { this.handle = handle; this.cursorController = new CursorController(this.handle); this.guiRenderer = new BraidGuiRenderer(Minecraft.getInstance()); var framebufferWidthOut = new int[1]; var framebufferHeightOut = new int[1]; GLFW.glfwGetFramebufferSize(this.handle, framebufferWidthOut, framebufferHeightOut); this.framebufferWidth = framebufferWidthOut[0]; this.framebufferHeight = framebufferHeightOut[0]; this.remoteTarget = new TextureTarget("braid window", this.framebufferWidth, this.framebufferHeight, true); this.recreateLocalFbo(); GLFW.glfwSetWindowCloseCallback(this.handle, this.storeNativeResource(GLFWWindowCloseCallback.create(window -> { this.eventBinding.add(CloseEvent.INSTANCE); }))); GLFW.glfwSetFramebufferSizeCallback(this.handle, this.storeNativeResource(GLFWFramebufferSizeCallback.create((window, width, height) -> { this.framebufferWidth = width; this.framebufferHeight = height; withContext(Minecraft.getInstance().getWindow().handle(), () -> { this.remoteTarget.destroyBuffers(); this.remoteTarget = new TextureTarget("braid window", this.framebufferWidth, this.framebufferHeight, true); }); this.recreateLocalFbo(); this.onResize.sink().onResize(this.scaledWidth, this.scaledHeight); }))); GLFW.glfwSetMouseButtonCallback(this.handle, this.storeNativeResource(GLFWMouseButtonCallback.create((window, button, action, mods) -> { this.eventBinding.add(switch (action) { case GLFW.GLFW_PRESS -> new MouseButtonPressEvent(button, new KeyModifiers(mods)); case GLFW.GLFW_RELEASE -> new MouseButtonReleaseEvent(button, new KeyModifiers(mods)); default -> throw new UnsupportedOperationException("incompatible glfw event type"); }); }))); GLFW.glfwSetCursorPosCallback(this.handle, this.storeNativeResource(GLFWCursorPosCallback.create((window, mouseX, mouseY) -> { this.eventBinding.add(new MouseMoveEvent( mouseX / this.scaleFactor, mouseY / this.scaleFactor )); }))); GLFW.glfwSetScrollCallback(this.handle, this.storeNativeResource(GLFWScrollCallback.create((window, xOffset, yOffset) -> { this.eventBinding.add(new MouseScrollEvent(xOffset, yOffset)); }))); GLFW.glfwSetKeyCallback(this.handle, this.storeNativeResource(GLFWKeyCallback.create((window, key, scancode, action, mods) -> { this.eventBinding.add(switch (action) { case GLFW.GLFW_PRESS, GLFW.GLFW_REPEAT -> new KeyPressEvent(key, scancode, new KeyModifiers(mods)); case GLFW.GLFW_RELEASE -> new KeyReleaseEvent(key, scancode, new KeyModifiers(mods)); default -> throw new UnsupportedOperationException("incompatible glfw event type"); }); }))); GLFW.glfwSetCharModsCallback(this.handle, this.storeNativeResource(GLFWCharModsCallback.create((window, codepoint, mods) -> { this.eventBinding.add(new CharInputEvent((char) codepoint, new KeyModifiers(mods))); }))); GLFW.glfwSetDropCallback(this.handle, this.storeNativeResource(GLFWDropCallback.create((window, count, names) -> { var paths = new ArrayList(count); for (int pathIdx = 0; pathIdx < count; pathIdx++) { var pathString = GLFWDropCallback.getName(names, pathIdx); try { paths.add(Paths.get(pathString)); } catch (InvalidPathException e) { Owo.LOGGER.error("Failed to parse path '{}'", pathString, e); } } if (!paths.isEmpty()) { this.eventBinding.add(new FilesDroppedEvent(paths)); } }))); } private void recreateLocalFbo() { withContext(this.handle, () -> { if (this.localFbo != 0) { GL32.glDeleteFramebuffers(this.localFbo); } this.localFbo = GL32.glGenFramebuffers(); GL32.glBindFramebuffer(GL32.GL_FRAMEBUFFER, this.localFbo); GL32.glFramebufferTexture2D(GL32.GL_FRAMEBUFFER, GL32.GL_COLOR_ATTACHMENT0, GL32.GL_TEXTURE_2D, ((GlTexture) this.remoteTarget.getColorTexture()).glId(), 0); if (GL32.glCheckFramebufferStatus(GL32.GL_FRAMEBUFFER) != GL32.GL_FRAMEBUFFER_COMPLETE) { throw new UnsupportedOperationException("Failed to initialize local FBO"); } }); this.recalculateScale(); } private void recalculateScale() { var guiScale = Minecraft.getInstance().options.guiScale().get(); var forceUnicodeFont = Minecraft.getInstance().options.forceUnicodeFont().get(); var factor = 1; while ( factor != guiScale && factor < this.framebufferWidth && factor < this.framebufferHeight && this.framebufferWidth / (factor + 1) >= 320 && this.framebufferHeight / (factor + 1) >= 240 ) { ++factor; } if (forceUnicodeFont && factor % 2 != 0) { ++factor; } this.scaleFactor = factor; var scaledWidth = (int) ((double) this.framebufferWidth / this.scaleFactor); this.scaledWidth = (double) this.framebufferWidth / this.scaleFactor > (double) scaledWidth ? scaledWidth + 1 : scaledWidth; var scaledHeight = (int) ((double) this.framebufferHeight / this.scaleFactor); this.scaledHeight = (double) this.framebufferHeight / this.scaleFactor > (double) scaledHeight ? scaledHeight + 1 : scaledHeight; } public static BraidWindow create(String title, int width, int height) { var handleOut = new MutableLong(); withContext(0, () -> { GLFW.glfwWindowHint(GLFW.GLFW_CLIENT_API, GLFW.GLFW_OPENGL_API); GLFW.glfwWindowHint(GLFW.GLFW_CONTEXT_CREATION_API, GLFW.GLFW_NATIVE_CONTEXT_API); GLFW.glfwWindowHint(GLFW.GLFW_CONTEXT_VERSION_MAJOR, 3); GLFW.glfwWindowHint(GLFW.GLFW_CONTEXT_VERSION_MINOR, 2); GLFW.glfwWindowHint(GLFW.GLFW_OPENGL_PROFILE, GLFW.GLFW_OPENGL_CORE_PROFILE); GLFW.glfwWindowHint(GLFW.GLFW_OPENGL_FORWARD_COMPAT, GLFW.GLFW_TRUE); var handle = GLFW.glfwCreateWindow(width, height, title, 0, Minecraft.getInstance().getWindow().handle()); if (handle == 0) { throw new UnsupportedOperationException("Failed to create a GLFW window"); } GLFW.glfwMakeContextCurrent(handle); GLFW.glfwSwapInterval(0); GlDebug.enableDebugCallback(Minecraft.getInstance().options.glDebugVerbosity, true, new HashSet<>()); handleOut.setValue(handle); }); return new BraidWindow(handleOut.longValue()); } public static OpenResult open(String title, int width, int height, Widget widget) { var window = create(title, width, height); var app = new AppState( Owo.LOGGER, AppState.formatName("BraidWindow", widget, title), Minecraft.getInstance(), window, window.eventBinding, widget ); BraidWindowScheduler.add(window, app); return new OpenResult(app, window); } // --- @Override public void dispose() { GLFW.glfwDestroyWindow(this.handle); this.cursorController.dispose(); this.guiRenderer.close(); this.remoteTarget.destroyBuffers(); for (var resource : this.resources) { resource.free(); } } // --- @Override public int width() { return this.scaledWidth; } @Override public int height() { return this.scaledHeight; } @Override public double scaleFactor() { return this.scaleFactor; } @Override public EventSource onResize() { return this.onResize.source(); } @Override public CursorStyle currentCursorStyle() { return this.cursorController.currentStyle(); } @Override public void setCursorStyle(CursorStyle style) { this.cursorController.setStyle(style); } // --- @Override public void beginRendering() { RenderSystem.getDevice().createCommandEncoder().clearColorAndDepthTextures( this.remoteTarget.getColorTexture(), 0xFF000000, this.remoteTarget.getDepthTexture(), 1 ); } @Override public void endRendering() { this.guiRenderer.render(new BraidGuiRenderer.Target( this.remoteTarget, this )); // --- withContext(this.handle, () -> { GL32.glBindFramebuffer(GL32.GL_READ_FRAMEBUFFER, this.localFbo); GL32.glBindFramebuffer(GL32.GL_DRAW_FRAMEBUFFER, 0); GL32.glBlitFramebuffer( 0, 0, this.framebufferWidth, this.framebufferHeight, 0, 0, this.framebufferWidth, this.framebufferHeight, GL32.GL_COLOR_BUFFER_BIT, GL32.GL_NEAREST ); GLFW.glfwSwapBuffers(this.handle); }); } // --- private R storeNativeResource(R resource) { this.resources.add(resource); return resource; } public static void withContext(long contextHandle, Runnable fn) { var activeContext = GLFW.glfwGetCurrentContext(); try { GLFW.glfwMakeContextCurrent(contextHandle); fn.run(); } finally { GLFW.glfwMakeContextCurrent(activeContext); } } // --- public static class WindowEventBinding extends EventBinding { public final BraidWindow window; public WindowEventBinding(BraidWindow window) { this.window = window; } @Override public boolean isKeyPressed(int keyCode) { return GLFW.glfwGetKey(this.window.handle, keyCode) == GLFW.GLFW_PRESS; } } public record OpenResult(AppState state, BraidWindow window) {} } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/BraidWindowScheduler.java ================================================ package io.wispforest.owo.braid.core; import io.wispforest.owo.ui.event.ClientRenderCallback; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.minecraft.client.Minecraft; import java.util.ArrayList; import java.util.List; public class BraidWindowScheduler { private static final List APPS = new ArrayList<>(); public static void add(BraidWindow window, AppState app) { APPS.add(new App(window, app)); } private static void frame() { for (var app : new ArrayList<>(APPS)) { if (!app.state().running()) { app.state().dispose(); APPS.remove(app); continue; } app.state().processEvents( Minecraft.getInstance().getDeltaTracker().getGameTimeDeltaTicks() ); app.state().draw(app.surface().guiRenderer.newGraphics(app.state().cursorPosition().x(), app.state().cursorPosition().y())); } } static { ClientRenderCallback.BEFORE_SWAP.register(client -> frame()); ClientLifecycleEvents.CLIENT_STOPPING.register(client -> { APPS.forEach(app -> app.state().dispose()); APPS.clear(); }); } } record App(BraidWindow surface, AppState state) {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/Color.java ================================================ package io.wispforest.owo.braid.core; import net.minecraft.ChatFormatting; import net.minecraft.util.Mth; public class Color { public static final Color RED = Color.values(1, 0, 0); public static final Color YELLOW = Color.values(1, 1, 0); public static final Color GREEN = Color.values(0, 1, 0); public static final Color AQUA = Color.values(0, 1, 1); public static final Color BLUE = Color.values(0, 0, 1); public static final Color MAGENTA = Color.values(1, 0, 1); public static final Color WHITE = Color.values(1, 1, 1); public static final Color BLACK = Color.values(0, 0, 0); // public final double r, g, b, a; private Color(double r, double g, double b, double a) { this.r = r; this.g = g; this.b = b; this.a = a; } // --- public Color(int argb) { this( ((argb >> 16) & 0xFF) / 255.0, ((argb >> 8) & 0xFF) / 255.0, (argb & 0xFF) / 255.0, (argb >>> 24) / 255.0 ); } public static Color values(double r, double g, double b, double a) { return new Color(r, g, b, a); } public static Color values(double r, double g, double b) { return values(r, g, b, 1); } public static Color rgb(int rgb) { return values( ((rgb >> 16) & 0xFF) / 255.0, ((rgb >> 8) & 0xFF) / 255.0, (rgb & 0xFF) / 255.0 ); } public static Color hsv(double hue, double saturation, double value, double alpha) { // we call .5e-7f the magic "do not turn a hue value of 1f into yellow" constant return new Color((int) (alpha * 255) << 24 | Mth.hsvToRgb((float) (hue - .5e-7f), (float) saturation, (float) value)); } public static Color hsv(double hue, double saturation, double value) { return hsv(hue, saturation, value, 1); } public static Color formatting(ChatFormatting formatting) { var rgb = formatting.getColor(); return rgb(rgb != null ? rgb : 0); } public static Color mix(double t, Color a, Color b) { return Color.values( Mth.lerp(t, a.r, b.r), Mth.lerp(t, a.g, b.g), Mth.lerp(t, a.b, b.b), Mth.lerp(t, a.a, b.a) ); } public static Color randomHue() { return hsv(Math.random(), .75, 1); } // --- public io.wispforest.owo.ui.core.Color toOwoUi() { return new io.wispforest.owo.ui.core.Color( (float) this.r, (float) this.g, (float) this.b, (float) this.a ); } public String toHexString(boolean includeAlpha) { return includeAlpha ? String.format("#%08X", this.argb()) : String.format("#%06X", this.rgb()); } // public Color withR(double r) { return new Color(r, this.g, this.b, this.a); } public Color withG(double g) { return new Color(this.r, g, this.b, this.a); } public Color withB(double b) { return new Color(this.r, this.g, b, this.a); } public Color withA(double a) { return new Color(this.r, this.g, this.b, a); } // --- public int rgb() { return (int) (this.r * 255) << 16 | (int) (this.g * 255) << 8 | (int) (this.b * 255); } public int argb() { return (int) (this.a * 255) << 24 | (int) (this.r * 255) << 16 | (int) (this.g * 255) << 8 | (int) (this.b * 255); } public float[] hsv() { return this.toOwoUi().hsv(); } @Override public boolean equals(Object o) { if (o == null || this.getClass() != o.getClass()) return false; var other = (Color) o; return this.r == other.r && this.g == other.g && this.b == other.b && this.a == other.a; } @Override public int hashCode() { int result = Double.hashCode(r); result = 31 * result + Double.hashCode(g); result = 31 * result + Double.hashCode(b); result = 31 * result + Double.hashCode(a); return result; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/CompoundListenable.java ================================================ package io.wispforest.owo.braid.core; import java.util.ArrayList; import java.util.List; public class CompoundListenable extends Listenable { protected final Runnable listener = this::notifyListeners; protected final List children = new ArrayList<>(); public CompoundListenable(Listenable... initialChildren) { for (var child : initialChildren) { this.addChild(child); } } public void addChild(Listenable child) { this.children.add(child); child.addListener(this.listener); } public void removeChild(Listenable child) { this.children.remove(child); child.removeListener(this.listener); } public void clear() { for (var child : this.children) { child.removeListener(this.listener); } this.children.clear(); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/Constraints.java ================================================ package io.wispforest.owo.braid.core; import net.minecraft.util.Mth; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; public record Constraints(double minWidth, double minHeight, double maxWidth, double maxHeight) { private static final Constraints UNCONSTRAINED = new Constraints(0, 0, Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY); @ApiStatus.Internal @Deprecated(forRemoval = true) public Constraints {} public static Constraints unconstrained() { return UNCONSTRAINED; } public static Constraints of(double minWidth, double minHeight, double maxWidth, double maxHeight) { return new Constraints(minWidth, minHeight, maxWidth, maxHeight); } public static Constraints ofMinWidth(double minWidth) { return new Constraints(minWidth, 0, Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY); } public static Constraints ofMinHeight(double minHeight) { return new Constraints(0, minHeight, Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY); } public static Constraints ofMaxWidth(double maxWidth) { return new Constraints(0, 0, maxWidth, Double.POSITIVE_INFINITY); } public static Constraints ofMaxHeight(double maxHeight) { return new Constraints(0, 0, Double.POSITIVE_INFINITY, maxHeight); } public static Constraints only(@Nullable Double minWidth, @Nullable Double minHeight, @Nullable Double maxWidth, @Nullable Double maxHeight) { return new Constraints( minWidth != null ? minWidth : 0, minHeight != null ? minHeight : 0, maxWidth != null ? maxWidth : Double.POSITIVE_INFINITY, maxHeight != null ? maxHeight : Double.POSITIVE_INFINITY ); } public static Constraints tight(Size exactSize) { return new Constraints(exactSize.width(), exactSize.height(), exactSize.width(), exactSize.height()); } public static Constraints loose(Size maxSize) { return new Constraints(0, 0, maxSize.width(), maxSize.height()); } public static Constraints tightOnAxis(@Nullable Double horizontal, @Nullable Double vertical) { return only(horizontal, vertical, horizontal, vertical); } // --- public Constraints withMinWidth(double minWidth) { return new Constraints(minWidth, this.minHeight, this.maxWidth, this.maxHeight); } public Constraints withMinHeight(double minHeight) { return new Constraints(this.minWidth, minHeight, this.maxWidth, this.maxHeight); } public Constraints withMaxWidth(double maxWidth) { return new Constraints(this.minWidth, this.minHeight, maxWidth, this.maxHeight); } public Constraints withMaxHeight(double maxHeight) { return new Constraints(this.minWidth, this.minHeight, this.maxWidth, maxHeight); } // --- public double minOnAxis(LayoutAxis axis) { return switch (axis) { case HORIZONTAL -> this.minWidth(); case VERTICAL -> this.minHeight(); }; } public double maxOnAxis(LayoutAxis axis) { return switch (axis) { case HORIZONTAL -> this.maxWidth(); case VERTICAL -> this.maxHeight(); }; } public double maxFiniteOrMinOnAxis(LayoutAxis axis) { return switch (axis) { case HORIZONTAL -> this.maxFiniteOrMinWidth(); case VERTICAL -> this.maxFiniteOrMinHeight(); }; } public double maxFiniteOrMinWidth() { return this.hasBoundedWidth() ? this.maxWidth() : this.minWidth(); } public double maxFiniteOrMinHeight() { return this.hasBoundedHeight() ? this.maxHeight() : this.minHeight(); } // --- public Constraints asLoose() { return this.isLoose() ? this : new Constraints(0, 0, this.maxWidth, this.maxHeight); } public Constraints respecting(Constraints other) { if (this.minWidth >= other.minWidth && this.minWidth <= other.maxWidth && this.maxWidth >= other.minWidth && this.maxWidth <= other.maxWidth && this.minHeight >= other.minHeight && this.minHeight <= other.maxHeight && this.maxHeight >= other.minHeight && this.maxHeight <= other.maxHeight) { return this; } return new Constraints( Mth.clamp(this.minWidth, other.minWidth, other.maxWidth), Mth.clamp(this.minHeight, other.minHeight, other.maxHeight), Mth.clamp(this.maxWidth, other.minWidth, other.maxWidth), Mth.clamp(this.maxHeight, other.minHeight, other.maxHeight) ); } public boolean hasLooseWidth() { return this.minWidth == 0; } public boolean hasLooseHeight() { return this.minHeight == 0; } public boolean hasTightWidth() { return this.minWidth == this.maxWidth; } public boolean hasTightHeight() { return this.minHeight == this.maxHeight; } public boolean isLoose() { return this.hasLooseWidth() && this.hasLooseHeight(); } public boolean isTight() { return this.hasTightWidth() && this.hasTightHeight(); } public boolean hasBoundedWidth() { return this.maxWidth < Double.POSITIVE_INFINITY; } public boolean hasBoundedHeight() { return this.maxHeight < Double.POSITIVE_INFINITY; } public Size minSize() { return Size.of(this.minWidth, this.minHeight); } public Size maxSize() { return Size.of(this.maxWidth, this.maxHeight); } public Size maxFiniteOrMinSize() { return Size.of( this.maxFiniteOrMinWidth(), this.maxFiniteOrMinHeight() ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/EventBinding.java ================================================ package io.wispforest.owo.braid.core; import com.mojang.blaze3d.platform.InputConstants; import io.wispforest.owo.braid.core.events.UserEvent; import net.minecraft.client.Minecraft; import org.lwjgl.glfw.GLFW; import java.util.ArrayList; import java.util.List; public abstract class EventBinding { private final List bufferedEvents = new ArrayList<>(); public EventSlot add(UserEvent event) { var slot = new EventSlot(event); this.bufferedEvents.add(slot); return slot; } List poll() { var events = new ArrayList<>(this.bufferedEvents); this.bufferedEvents.clear(); return events; } public abstract boolean isKeyPressed(int keyCode); public KeyModifiers activeModifiers() { return new KeyModifiers( (this.isKeyPressed(GLFW.GLFW_KEY_LEFT_SHIFT) || this.isKeyPressed(GLFW.GLFW_KEY_RIGHT_SHIFT) ? GLFW.GLFW_MOD_SHIFT : 0) | (this.isKeyPressed(GLFW.GLFW_KEY_LEFT_CONTROL) || this.isKeyPressed(GLFW.GLFW_KEY_RIGHT_CONTROL) ? GLFW.GLFW_MOD_CONTROL : 0) | (this.isKeyPressed(GLFW.GLFW_KEY_LEFT_ALT) || this.isKeyPressed(GLFW.GLFW_KEY_RIGHT_ALT) ? GLFW.GLFW_MOD_ALT : 0) | (this.isKeyPressed(GLFW.GLFW_KEY_RIGHT_SUPER) || this.isKeyPressed(GLFW.GLFW_KEY_RIGHT_SUPER) ? GLFW.GLFW_MOD_SUPER : 0) | (this.isKeyPressed(GLFW.GLFW_KEY_NUM_LOCK) ? GLFW.GLFW_MOD_NUM_LOCK : 0) | (this.isKeyPressed(GLFW.GLFW_KEY_CAPS_LOCK) ? GLFW.GLFW_MOD_CAPS_LOCK : 0) ); } public static class EventSlot { final UserEvent event; private boolean handled = false; public EventSlot(UserEvent event) { this.event = event; } public boolean handled() { return this.handled; } void markHandled() { this.handled = true; } } // --- public static class Headless extends EventBinding { @Override public boolean isKeyPressed(int keyCode) { return false; } } public static class Default extends EventBinding { @Override public boolean isKeyPressed(int keyCode) { return InputConstants.isKeyDown(Minecraft.getInstance().getWindow(), keyCode); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/Insets.java ================================================ package io.wispforest.owo.braid.core; import org.jetbrains.annotations.ApiStatus; public record Insets(double top, double bottom, double left, double right) { private static final Insets NONE = new Insets(0, 0, 0, 0); @ApiStatus.Internal @Deprecated(forRemoval = true) public Insets {} // --- public static Insets of(double top, double bottom, double left, double right) { return new Insets(top, bottom, left, right); } public static Insets all(double inset) { return new Insets(inset, inset, inset, inset); } public static Insets both(double horizontal, double vertical) { return new Insets(vertical, vertical, horizontal, horizontal); } public static Insets top(double top) { return new Insets(top, 0, 0, 0); } public static Insets bottom(double bottom) { return new Insets(0, bottom, 0, 0); } public static Insets left(double left) { return new Insets(0, 0, left, 0); } public static Insets right(double right) { return new Insets(0, 0, 0, right); } public static Insets vertical(double inset) { return new Insets(inset, inset, 0, 0); } public static Insets horizontal(double inset) { return new Insets(0, 0, inset, inset); } public static Insets none() { return NONE; } // --- public Insets withTop(double top) { return new Insets(top, this.bottom, this.left, this.right); } public Insets withBottom(double bottom) { return new Insets(this.top, bottom, this.left, this.right); } public Insets withLeft(double left) { return new Insets(this.top, this.bottom, left, this.right); } public Insets withRight(double right) { return new Insets(this.top, this.bottom, this.left, right); } public double horizontal() { return this.left + this.right; } public double vertical() { return this.top + this.bottom; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/KeyModifiers.java ================================================ package io.wispforest.owo.braid.core; import it.unimi.dsi.fastutil.ints.IntList; import static org.lwjgl.glfw.GLFW.*; public record KeyModifiers(int bitMask) { public static final KeyModifiers NONE = new KeyModifiers(0); public boolean shift() { return (this.bitMask & GLFW_MOD_SHIFT) != 0; } public boolean ctrl() { return (this.bitMask & GLFW_MOD_CONTROL) != 0; } public boolean alt() { return (this.bitMask & GLFW_MOD_ALT) != 0; } public boolean meta() { return (this.bitMask & GLFW_MOD_SUPER) != 0; } public boolean capsLock() { return (this.bitMask & GLFW_MOD_CAPS_LOCK) != 0; } public boolean numLock() { return (this.bitMask & GLFW_MOD_NUM_LOCK) != 0; } public static boolean isModifier(int keyCode) { return MODIFIER_KEYS.contains(keyCode); } public static KeyModifiers both(KeyModifiers a, KeyModifiers b) { return new KeyModifiers(a.bitMask | b.bitMask); } public static final IntList MODIFIER_KEYS = IntList.of( GLFW_KEY_LEFT_SHIFT, GLFW_KEY_RIGHT_SHIFT, GLFW_KEY_LEFT_CONTROL, GLFW_KEY_RIGHT_CONTROL, GLFW_KEY_LEFT_ALT, GLFW_KEY_RIGHT_ALT, GLFW_KEY_LEFT_SUPER, GLFW_KEY_RIGHT_SUPER ); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/LayoutAxis.java ================================================ package io.wispforest.owo.braid.core; import java.util.function.Supplier; public enum LayoutAxis { HORIZONTAL, VERTICAL; public T choose(T horizontal, T vertical) { return switch (this) { case HORIZONTAL -> horizontal; case VERTICAL -> vertical; }; } public T chooseCompute(Supplier horizontal, Supplier vertical) { return switch (this) { case HORIZONTAL -> horizontal.get(); case VERTICAL -> vertical.get(); }; } public Size createSize(double extent, double crossExtent) { return switch (this) { case HORIZONTAL -> Size.of(extent, crossExtent); case VERTICAL -> Size.of(crossExtent, extent); }; } public LayoutAxis opposite() { return switch (this) { case HORIZONTAL -> VERTICAL; case VERTICAL -> HORIZONTAL; }; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/Listenable.java ================================================ package io.wispforest.owo.braid.core; import java.util.ArrayList; import java.util.List; public abstract class Listenable { protected final List listeners = new ArrayList<>(); public void addListener(Runnable listener) { this.listeners.add(listener); } public void removeListener(Runnable listener) { this.listeners.remove(listener); } protected void notifyListeners() { this.listeners.forEach(Runnable::run); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/ListenableValue.java ================================================ package io.wispforest.owo.braid.core; public class ListenableValue extends Listenable { private V value; public ListenableValue(V value) { this.value = value; } public V value() { return this.value; } public void setValue(V value) { this.value = value; this.notifyListeners(); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/RelativePosition.java ================================================ package io.wispforest.owo.braid.core; import com.google.common.base.Preconditions; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.framework.BuildContext; import org.joml.Vector2d; import org.joml.Vector2f; public record RelativePosition(BuildContext context, double x, double y) { public Vector2d convertTo(BuildContext ancestor) { var contextInstance = context.instance(); var ancestorInstance = ancestor.instance(); if (Owo.DEBUG) { Preconditions.checkArgument( contextInstance.ancestors().contains(ancestorInstance), "a RelativePosition can only be converted to the coordinate system of an ancestor" ); } var coordinates = new Vector2f((float) this.x, (float) this.y); contextInstance.computeTransformFrom(ancestorInstance).invert().transformPosition(coordinates); return new Vector2d(coordinates.x, coordinates.y); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/Size.java ================================================ package io.wispforest.owo.braid.core; import net.minecraft.util.Mth; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; public record Size(double width, double height) { private static final Size ZERO = new Size(0, 0); @ApiStatus.Internal @Deprecated(forRemoval = true) public Size {} // --- public static Size zero() { return ZERO; } public static Size of(double width, double height) { return new Size(width, height); } public static Size square(double sideLength) { return new Size(sideLength, sideLength); } public static Size max(Size a, Size b) { return new Size(Math.max(a.width, b.width), Math.max(a.height, b.height)); } // --- public Size withInsets(Insets insets) { return new Size(this.width + insets.horizontal(), this.height + insets.vertical()); } public Size with(@Nullable Double width, @Nullable Double height) { return new Size(width != null ? width : this.width, height != null ? height : this.height); } public Size floor() { return new Size(Math.floor(this.width), Math.floor(this.height)); } public Size ceil() { return new Size(Math.ceil(this.width), Math.ceil(this.height)); } public double getExtent(LayoutAxis axis) { return switch (axis) { case HORIZONTAL -> width(); case VERTICAL -> height(); }; } public Size constrained(Constraints constraints) { return new Size( Mth.clamp(this.width, constraints.minWidth(), constraints.maxWidth()), Mth.clamp(this.height, constraints.minHeight(), constraints.maxHeight()) ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/Surface.java ================================================ package io.wispforest.owo.braid.core; import com.mojang.blaze3d.platform.Window; import io.wispforest.owo.braid.core.cursor.CursorController; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.ui.event.WindowResizeCallback; import io.wispforest.owo.util.EventSource; import io.wispforest.owo.util.EventStream; import net.minecraft.client.Minecraft; public interface Surface { int width(); int height(); double scaleFactor(); EventSource onResize(); CursorStyle currentCursorStyle(); void setCursorStyle(CursorStyle style); void beginRendering(); void endRendering(); void dispose(); class Default implements Surface { private static EventStream resizeEvents; private final Window window; private final CursorController cursorController; public Default() { this.window = Minecraft.getInstance().getWindow(); this.cursorController = new CursorController(this.window.handle()); if (resizeEvents == null) { resizeEvents = ResizeCallback.newStream(); WindowResizeCallback.EVENT.register((client, resizedWindow) -> { resizeEvents.sink().onResize(resizedWindow.getGuiScaledWidth(), resizedWindow.getGuiScaledHeight()); }); } } @Override public int width() { return this.window.getGuiScaledWidth(); } @Override public int height() { return this.window.getGuiScaledHeight(); } @Override public double scaleFactor() { return this.window.getGuiScale(); } @Override public EventSource onResize() { return resizeEvents.source(); } @Override public CursorStyle currentCursorStyle() { return this.cursorController.currentStyle(); } @Override public void setCursorStyle(CursorStyle style) { this.cursorController.setStyle(style); } @Override public void beginRendering() {} @Override public void endRendering() {} @Override public void dispose() { this.cursorController.dispose(); } } interface ResizeCallback { void onResize(int newWidth, int newHeight); static EventStream newStream() { return new EventStream<>(callbacks -> (newWidth, newHeight) -> { for (var callback : callbacks) { callback.onResize(newWidth, newHeight); } }); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/TextLayout.java ================================================ package io.wispforest.owo.braid.core; import net.minecraft.client.gui.Font; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import java.util.ArrayList; import java.util.List; public class TextLayout { public static EditMetrics measure(Font font, String text, Style baseStyle, int maxWidth) { var lines = new ArrayList(); font.getSplitter().splitLines( text, maxWidth, baseStyle, false, (style, start, end) -> lines.add(new Line(style, start, end)) ); if (text.endsWith("\n")) { lines.add(new Line(baseStyle, text.length(), text.length())); } if (lines.isEmpty()) { lines.add(new Line(baseStyle, 0, 0)); } // --- var textWidth = 0; var textHeight = 0; var lineMetrics = new ArrayList(); for (var line : lines) { var lineWidth = font.width(line.substring(text)); lineMetrics.add(new LineMetrics(line.beginIdx, line.endIdx, lineWidth)); textWidth = Math.max(textWidth, lineWidth); textHeight += font.lineHeight; } return new EditMetrics(textWidth, textHeight, lineMetrics); } public record LineMetrics(int beginIdx, int endIdx, double width) { public String substring(String fullContent) { return fullContent.substring(this.beginIdx, this.endIdx); } } public record EditMetrics(int width, int height, List lineMetrics) {} private record Line(Style style, int beginIdx, int endIdx) { public Component substring(String fullContent) { return Component.literal(fullContent.substring(this.beginIdx, this.endIdx)).setStyle(this.style); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/TextureSurface.java ================================================ package io.wispforest.owo.braid.core; import com.mojang.blaze3d.pipeline.TextureTarget; import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.textures.FilterMode; import com.mojang.blaze3d.textures.GpuTextureView; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.util.BraidGuiRenderer; import io.wispforest.owo.util.EventSource; import io.wispforest.owo.util.EventStream; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.texture.AbstractTexture; import net.minecraft.resources.Identifier; import java.util.UUID; public class TextureSurface implements Surface { private final TextureTarget target; private final EventStream resizeEvents = ResizeCallback.newStream(); public final TextureSurfaceTexture registeredTexture; public final Identifier registeredTextureId; private CursorStyle currentCursorStyle = CursorStyle.NONE; public final BraidGuiRenderer guiRenderer; public TextureSurface(int width, int height) { this.target = new TextureTarget("texture surface", width, height, true); this.guiRenderer = new BraidGuiRenderer(Minecraft.getInstance()); this.registeredTexture = new TextureSurfaceTexture(); this.registeredTextureId = Owo.id("texture_surface_" + UUID.randomUUID()); Minecraft.getInstance().getTextureManager().register(this.registeredTextureId, this.registeredTexture); } public void resize(int width, int height) { this.target.resize(width, height); this.resizeEvents.sink().onResize(width, height); this.registeredTexture.sync(); } public GpuTextureView texture() { return this.target.getColorTextureView(); } @Override public int width() { return this.target.width; } @Override public int height() { return this.target.height; } @Override public double scaleFactor() { return 1; } @Override public EventSource onResize() { return this.resizeEvents.source(); } @Override public CursorStyle currentCursorStyle() { return this.currentCursorStyle; } @Override public void setCursorStyle(CursorStyle style) { this.currentCursorStyle = style; } // --- @Override public void beginRendering() { RenderSystem.getDevice().createCommandEncoder().clearColorAndDepthTextures( this.target.getColorTexture(), 0x00000000, this.target.getDepthTexture(), 1 ); } @Override public void endRendering() { this.guiRenderer.render(new BraidGuiRenderer.Target( this.target, this )); } @Override public void dispose() { this.target.destroyBuffers(); Minecraft.getInstance().getTextureManager().release(this.registeredTextureId); } // --- public class TextureSurfaceTexture extends AbstractTexture { public TextureSurfaceTexture() { this.sync(); this.sampler = RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST); } private void sync() { this.texture = TextureSurface.this.target.getColorTexture(); this.textureView = TextureSurface.this.target.getColorTextureView(); } @Override public void close() {} } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/cursor/CursorController.java ================================================ package io.wispforest.owo.braid.core.cursor; import org.lwjgl.glfw.GLFW; import java.util.HashMap; import java.util.Map; public class CursorController { private final Map cursors = new HashMap<>(); private final long windowHandle; private CursorStyle lastCursorStyle = CursorStyle.NONE; private boolean disposed = false; public CursorController(long windowHandle) { this.windowHandle = windowHandle; } public CursorStyle currentStyle() { return this.lastCursorStyle; } public void setStyle(CursorStyle style) { if (this.disposed || this.lastCursorStyle == style) return; if (style == CursorStyle.NONE) { GLFW.glfwSetCursor(this.windowHandle, 0); } else { if (!this.cursors.containsKey(style)) { this.cursors.put(style, style.allocate()); } GLFW.glfwSetCursor(this.windowHandle, this.cursors.get(style)); } this.lastCursorStyle = style; } public void dispose() { if (this.disposed) return; for (var ptr : this.cursors.values()) { if (ptr == 0) return; GLFW.glfwDestroyCursor(ptr); } this.disposed = true; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/cursor/CursorStyle.java ================================================ package io.wispforest.owo.braid.core.cursor; import io.wispforest.owo.braid.core.LayoutAxis; import net.minecraft.util.Mth; import org.joml.Matrix3x2f; import org.lwjgl.glfw.GLFW; public sealed interface CursorStyle permits SystemCursorStyle { CursorStyle NONE = new SystemCursorStyle(0); CursorStyle POINTER = new SystemCursorStyle(GLFW.GLFW_ARROW_CURSOR); CursorStyle TEXT = new SystemCursorStyle(GLFW.GLFW_IBEAM_CURSOR); CursorStyle HAND = new SystemCursorStyle(GLFW.GLFW_HAND_CURSOR); CursorStyle MOVE = new SystemCursorStyle(GLFW.GLFW_RESIZE_ALL_CURSOR); CursorStyle CROSSHAIR = new SystemCursorStyle(GLFW.GLFW_CROSSHAIR_CURSOR); CursorStyle HORIZONTAL_RESIZE = new SystemCursorStyle(GLFW.GLFW_HRESIZE_CURSOR); CursorStyle VERTICAL_RESIZE = new SystemCursorStyle(GLFW.GLFW_VRESIZE_CURSOR); CursorStyle NWSE_RESIZE = new SystemCursorStyle(GLFW.GLFW_RESIZE_NWSE_CURSOR); CursorStyle NESW_RESIZE = new SystemCursorStyle(GLFW.GLFW_RESIZE_NESW_CURSOR); CursorStyle NOT_ALLOWED = new SystemCursorStyle(GLFW.GLFW_NOT_ALLOWED_CURSOR); long allocate(); static CursorStyle forDraggingAlong(LayoutAxis axis, Matrix3x2f transform3x2) { // Extract the Z rotation from the transform var rotation = Math.atan2(transform3x2.m01, transform3x2.m11); // Convert to degrees rotation = Math.toDegrees(rotation); // apply axis adjustment if (axis == LayoutAxis.VERTICAL) rotation += 90; // Normalize to [0, 180) (because the cursors are symmetric) rotation = Mth.positiveModulo(rotation, 180); // Map to [0, 8) rotation /= 22.5; if (rotation < 1 || rotation >= 7) return HORIZONTAL_RESIZE; else if (rotation >= 3 && rotation < 5) return VERTICAL_RESIZE; else if (rotation >= 1 && rotation < 3) return NESW_RESIZE; else return NWSE_RESIZE; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/cursor/SystemCursorStyle.java ================================================ package io.wispforest.owo.braid.core.cursor; import org.lwjgl.glfw.GLFW; public final class SystemCursorStyle implements CursorStyle { public final int glfwId; SystemCursorStyle(int glfwId) { this.glfwId = glfwId; } @Override public long allocate() { return GLFW.glfwCreateStandardCursor(this.glfwId); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/element/BraidBlockElement.java ================================================ package io.wispforest.owo.braid.core.element; import com.mojang.blaze3d.platform.Lighting; import com.mojang.blaze3d.vertex.PoseStack; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.render.pip.PictureInPictureRenderer; import net.minecraft.client.gui.render.state.pip.PictureInPictureRenderState; import net.minecraft.client.renderer.LightTexture; import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; import net.minecraft.client.renderer.state.CameraRenderState; import net.minecraft.client.renderer.texture.OverlayTexture; import net.minecraft.world.level.block.RenderShape; import net.minecraft.world.level.block.state.BlockState; import org.jetbrains.annotations.Nullable; import org.joml.Matrix3x2f; import org.joml.Matrix4f; public record BraidBlockElement( BlockState block, @Nullable BlockEntityRenderState entity, Matrix4f transform, Matrix3x2f pose, double width, double height, ScreenRectangle scissorArea ) implements PictureInPictureRenderState { @Override public int x0() { return 0; } @Override public int x1() { return (int) this.width; } @Override public int y0() { return 0; } @Override public int y1() { return (int) this.height; } @Override public float scale() { return 1; } @Override public Matrix3x2f pose() { return this.pose; } @Override public @Nullable ScreenRectangle scissorArea() { return this.scissorArea; } @Override public @Nullable ScreenRectangle bounds() { var bounds = new ScreenRectangle(0, 0, (int) this.width, (int) this.height).transformMaxBounds(this.pose); return this.scissorArea != null ? this.scissorArea.intersection(bounds) : bounds; } public static class Renderer extends PictureInPictureRenderer { public Renderer(MultiBufferSource.BufferSource vertexConsumers) { super(vertexConsumers); } @Override public Class getRenderStateClass() { return BraidBlockElement.class; } @Override @SuppressWarnings("NonAsciiCharacters") protected void renderToTexture(BraidBlockElement state, PoseStack matrices) { Minecraft.getInstance().gameRenderer.getLighting().setupFor(Lighting.Entry.ENTITY_IN_UI); matrices.mulPose(state.transform); if (state.block.getRenderShape() != RenderShape.INVISIBLE) { Minecraft.getInstance().getBlockRenderer().renderSingleBlock( state.block, matrices, bufferSource, LightTexture.FULL_BRIGHT, OverlayTexture.NO_OVERLAY ); } if (state.entity != null) { var медведь = Minecraft.getInstance().getBlockEntityRenderDispatcher().getRenderer(state.entity); if (медведь != null) { var dispatcher = Minecraft.getInstance().gameRenderer.getFeatureRenderDispatcher(); медведь.submit(state.entity, matrices, dispatcher.getSubmitNodeStorage(), new CameraRenderState()); dispatcher.renderAllFeatures(); } } } @Override protected float getTranslateY(int height, int windowScaleFactor) { return height / 2f; } @Override protected String getTextureLabel() { return "owo-ui_block"; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/element/BraidDashedLineElement.java ================================================ package io.wispforest.owo.braid.core.element; import com.mojang.blaze3d.pipeline.RenderPipeline; import com.mojang.blaze3d.vertex.VertexConsumer; import io.wispforest.owo.braid.core.Color; import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.render.TextureSetup; import net.minecraft.client.gui.render.state.GuiElementRenderState; import org.joml.Matrix3x2f; import org.joml.Vector2d; public record BraidDashedLineElement( Color color, double thiccness, double segmentLength, RenderPipeline pipeline, Matrix3x2f pose, ScreenRectangle bounds, ScreenRectangle scissorArea ) implements GuiElementRenderState { @Override public void buildVertices(VertexConsumer buffer) { var colorArgb = this.color.argb(); var begin = new Vector2d(this.bounds.left(), this.bounds.top()); var end = new Vector2d(this.bounds.right(), this.bounds.bottom()); var step = end.sub(begin, new Vector2d()).normalize().mul(this.segmentLength); var segmentCount = (int) ((end.distance(begin) + this.segmentLength) / (this.segmentLength * 2)); var offset = end.sub(begin, new Vector2d()).perpendicular().normalize().mul(this.thiccness * .5d); end.set(begin).add(step); step.mul(2); for (var i = 0; i < segmentCount; i++) { buffer.addVertexWith2DPose(this.pose, (float) (begin.x + offset.x), (float) (begin.y + offset.y)).setColor(colorArgb); buffer.addVertexWith2DPose(this.pose, (float) (begin.x - offset.x), (float) (begin.y - offset.y)).setColor(colorArgb); buffer.addVertexWith2DPose(this.pose, (float) (end.x - offset.x), (float) (end.y - offset.y)).setColor(colorArgb); buffer.addVertexWith2DPose(this.pose, (float) (end.x + offset.x), (float) (end.y + offset.y)).setColor(colorArgb); begin.add(step); end.add(step); } } @Override public RenderPipeline pipeline() { return this.pipeline; } @Override public TextureSetup textureSetup() { return TextureSetup.noTexture(); } @Override public ScreenRectangle scissorArea() { return this.scissorArea; } @Override public ScreenRectangle bounds() { var bounds = this.bounds.transformMaxBounds(this.pose); return this.scissorArea != null ? this.scissorArea.intersection(bounds) : bounds; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/element/BraidEntityElement.java ================================================ package io.wispforest.owo.braid.core.element; import com.mojang.blaze3d.platform.Lighting; import com.mojang.blaze3d.vertex.PoseStack; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.render.pip.PictureInPictureRenderer; import net.minecraft.client.gui.render.state.pip.PictureInPictureRenderState; import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.entity.EntityRenderDispatcher; import net.minecraft.client.renderer.entity.state.EntityRenderState; import net.minecraft.client.renderer.state.CameraRenderState; import org.jetbrains.annotations.Nullable; import org.joml.Matrix3x2f; import org.joml.Matrix4f; import org.joml.Quaternionf; public record BraidEntityElement( EntityRenderState entityState, Matrix4f transform, Matrix3x2f pose, double width, double height, ScreenRectangle scissorArea ) implements PictureInPictureRenderState { @Override public int x0() { return 0; } @Override public int x1() { return (int) this.width; } @Override public int y0() { return 0; } @Override public int y1() { return (int) this.height; } @Override public float scale() { return 1; } @Override public Matrix3x2f pose() { return this.pose; } @Override public @Nullable ScreenRectangle scissorArea() { return this.scissorArea; } @Override public @Nullable ScreenRectangle bounds() { var bounds = new ScreenRectangle(0, 0, (int) this.width, (int) this.height).transformMaxBounds(this.pose); return this.scissorArea != null ? this.scissorArea.intersection(bounds) : bounds; } public static class Renderer extends PictureInPictureRenderer { private final EntityRenderDispatcher renderManager = Minecraft.getInstance().getEntityRenderDispatcher(); public Renderer(MultiBufferSource.BufferSource vertexConsumers) { super(vertexConsumers); } @Override public Class getRenderStateClass() { return BraidEntityElement.class; } @Override protected void renderToTexture(BraidEntityElement state, PoseStack matrices) { Minecraft.getInstance().gameRenderer.getLighting().setupFor(Lighting.Entry.ENTITY_IN_UI); matrices.mulPose(state.transform); var camera = new CameraRenderState(); camera.orientation = state.transform.invert().getUnnormalizedRotation(new Quaternionf()); var dispatcher = Minecraft.getInstance().gameRenderer.getFeatureRenderDispatcher(); this.renderManager.submit(state.entityState, camera, 0, 0, 0, matrices, dispatcher.getSubmitNodeStorage()); dispatcher.renderAllFeatures(); } @Override protected float getTranslateY(int height, int windowScaleFactor) { return 0; } @Override protected String getTextureLabel() { return "owo-entity"; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/element/BraidItemElement.java ================================================ package io.wispforest.owo.braid.core.element; import com.mojang.blaze3d.platform.Lighting; import com.mojang.blaze3d.vertex.PoseStack; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.render.pip.PictureInPictureRenderer; import net.minecraft.client.gui.render.state.pip.PictureInPictureRenderState; import net.minecraft.client.renderer.LightTexture; import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.item.ItemStackRenderState; import net.minecraft.client.renderer.texture.OverlayTexture; import org.jetbrains.annotations.Nullable; import org.joml.Matrix3x2f; import org.joml.Matrix4fc; public record BraidItemElement( ItemStackRenderState item, double width, double height, ScreenRectangle scissorArea, Matrix4fc transform, Matrix3x2f pose ) implements PictureInPictureRenderState { @Override public int x0() { return 0; } @Override public int x1() { return (int) this.width; } @Override public int y0() { return 0; } @Override public int y1() { return (int) this.height; } @Override public float scale() { return 1; } @Override public Matrix3x2f pose() { return this.pose; } @Override public @Nullable ScreenRectangle scissorArea() { return this.scissorArea; } @Override public @Nullable ScreenRectangle bounds() { var bounds = new ScreenRectangle(0, 0, (int) this.width, (int) this.height).transformMaxBounds(this.pose); return this.scissorArea != null ? this.scissorArea.intersection(bounds) : bounds; } public static class Renderer extends PictureInPictureRenderer { public Renderer(MultiBufferSource.BufferSource vertexConsumers) { super(vertexConsumers); } @Override public Class getRenderStateClass() { return BraidItemElement.class; } @Override protected void renderToTexture(BraidItemElement state, PoseStack matrices) { matrices.scale((float) state.width, (float) -state.height, (float) -Math.min(state.width, state.height)); matrices.mulPose(state.transform); var notSideLit = !state.item.usesBlockLight(); if (notSideLit) { Minecraft.getInstance().gameRenderer.getLighting().setupFor(Lighting.Entry.ITEMS_FLAT); } else { Minecraft.getInstance().gameRenderer.getLighting().setupFor(Lighting.Entry.ITEMS_3D); } var dispatcher = Minecraft.getInstance().gameRenderer.getFeatureRenderDispatcher(); state.item.submit(matrices, dispatcher.getSubmitNodeStorage(), LightTexture.FULL_BRIGHT, OverlayTexture.NO_OVERLAY, 0); dispatcher.renderAllFeatures(); } @Override protected float getTranslateY(int height, int windowScaleFactor) { return height / 2f; } @Override protected String getTextureLabel() { return "owo-item"; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/events/CharInputEvent.java ================================================ package io.wispforest.owo.braid.core.events; import io.wispforest.owo.braid.core.KeyModifiers; public record CharInputEvent(char codepoint, KeyModifiers modifiers) implements UserEvent { public CharInputEvent(char codepoint, int modifiers) { this(codepoint, new KeyModifiers(modifiers)); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/events/CloseEvent.java ================================================ package io.wispforest.owo.braid.core.events; public enum CloseEvent implements UserEvent { INSTANCE; } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/events/FilesDroppedEvent.java ================================================ package io.wispforest.owo.braid.core.events; import java.nio.file.Path; import java.util.List; public record FilesDroppedEvent(List paths) implements UserEvent {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/events/KeyPressEvent.java ================================================ package io.wispforest.owo.braid.core.events; import io.wispforest.owo.braid.core.KeyModifiers; public record KeyPressEvent(int keyCode, int scancode, KeyModifiers modifiers) implements UserEvent { public KeyPressEvent(int keyCode, int scancode, int modifiers) { this(keyCode, scancode, new KeyModifiers(modifiers)); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/events/KeyReleaseEvent.java ================================================ package io.wispforest.owo.braid.core.events; import io.wispforest.owo.braid.core.KeyModifiers; public record KeyReleaseEvent(int keycode, int scancode, KeyModifiers modifiers) implements UserEvent { public KeyReleaseEvent(int keycode, int scancode, int modifiers) { this(keycode, scancode, new KeyModifiers(modifiers)); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/events/MouseButtonPressEvent.java ================================================ package io.wispforest.owo.braid.core.events; import io.wispforest.owo.braid.core.KeyModifiers; public record MouseButtonPressEvent(int button, KeyModifiers modifiers) implements UserEvent { public MouseButtonPressEvent(int button, int modifiers) { this(button, new KeyModifiers(modifiers)); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/events/MouseButtonReleaseEvent.java ================================================ package io.wispforest.owo.braid.core.events; import io.wispforest.owo.braid.core.KeyModifiers; public record MouseButtonReleaseEvent(int button, KeyModifiers modifiers) implements UserEvent { public MouseButtonReleaseEvent(int button, int modifiers) { this(button, new KeyModifiers(modifiers)); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/events/MouseMoveEvent.java ================================================ package io.wispforest.owo.braid.core.events; public record MouseMoveEvent(double x, double y) implements UserEvent {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/events/MouseScrollEvent.java ================================================ package io.wispforest.owo.braid.core.events; public record MouseScrollEvent(double xOffset, double yOffset) implements UserEvent {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/core/events/UserEvent.java ================================================ package io.wispforest.owo.braid.core.events; public sealed interface UserEvent permits CloseEvent, CharInputEvent, FilesDroppedEvent, KeyPressEvent, KeyReleaseEvent, MouseButtonPressEvent, MouseButtonReleaseEvent, MouseMoveEvent, MouseScrollEvent {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/display/BraidDisplay.java ================================================ package io.wispforest.owo.braid.display; import com.mojang.blaze3d.pipeline.BlendFunction; import com.mojang.blaze3d.pipeline.RenderPipeline; import com.mojang.blaze3d.vertex.PoseStack; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.AppState; import io.wispforest.owo.braid.core.EventBinding; import io.wispforest.owo.braid.core.TextureSurface; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.mixin.braid.RenderTypeInvoker; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.client.renderer.SubmitNodeCollector; import net.minecraft.client.renderer.rendertype.RenderSetup; import net.minecraft.client.renderer.rendertype.RenderType; import org.jetbrains.annotations.ApiStatus; import java.util.function.Function; public class BraidDisplay { public DisplayQuad quad; public final AppState app; public final TextureSurface surface; @ApiStatus.Internal public boolean primaryPressed = false; @ApiStatus.Internal public boolean secondaryPressed = false; boolean renderAutomatically = false; public BraidDisplay(DisplayQuad quad, int surfaceWidth, int surfaceHeight, Widget widget) { this.quad = quad; this.surface = new TextureSurface(surfaceWidth, surfaceHeight); this.app = new AppState( null, AppState.formatName("BraidDisplay", widget), Minecraft.getInstance(), this.surface, new EventBinding.Headless(), widget ); } public BraidDisplay renderAutomatically() { this.renderAutomatically = true; return this; } public void updateAndDrawApp() { var client = this.app.client(); this.app.processEvents( client.getDeltaTracker().getGameTimeDeltaTicks() ); this.app.draw(this.surface.guiRenderer.newGraphics(this.app.cursorPosition().x(), this.app.cursorPosition().y())); } public void render(PoseStack matrices, SubmitNodeCollector queue, int light) { var layer = RENDER_TYPE.apply(this.surface); queue.submitCustomGeometry(matrices, layer, (matricesEntry, buffer) -> { var normal = this.quad.normal.toVector3f(); buffer.addVertex(matricesEntry, 0, 0, 0).setColor(1f, 1f, 1f, 1f).setUv(0, 1).setLight(light).setNormal(matricesEntry, normal); buffer.addVertex(matricesEntry, this.quad.left.toVector3f()).setColor(1f, 1f, 1f, 1f).setUv(0, 0).setLight(light).setNormal(matricesEntry, normal); buffer.addVertex(matricesEntry, this.quad.top.add(this.quad.left).toVector3f()).setColor(1f, 1f, 1f, 1f).setUv(1, 0).setLight(light).setNormal(matricesEntry, normal); buffer.addVertex(matricesEntry, this.quad.top.toVector3f()).setColor(1f, 1f, 1f, 1f).setUv(1, 1).setLight(light).setNormal(matricesEntry, normal); }); } // --- public static final RenderPipeline PIPELINE = RenderPipeline.builder(RenderPipelines.BLOCK_SNIPPET) .withLocation(Owo.id("pipeline/braid_display")) .withShaderDefine("ALPHA_CUTOUT", 0.1F) .withCull(false) .withBlend(BlendFunction.TRANSLUCENT) .build(); private static final Function RENDER_TYPE = surface -> RenderTypeInvoker.owo$of( Owo.id("braid_display").toString(), RenderSetup.builder(PIPELINE) .withTexture("Sampler0", surface.registeredTextureId) .useLightmap() .createRenderSetup() ); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/display/BraidDisplayBinding.java ================================================ package io.wispforest.owo.braid.display; import com.mojang.blaze3d.vertex.PoseStack; import io.wispforest.owo.braid.core.events.MouseMoveEvent; import net.minecraft.client.renderer.LightTexture; import net.minecraft.client.renderer.SubmitNodeCollector; import net.minecraft.client.renderer.state.CameraRenderState; import net.minecraft.world.phys.Vec3; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; import org.joml.Vector2dc; import java.util.ArrayList; import java.util.List; public class BraidDisplayBinding { private static final List ACTIVE_DISPLAYS = new ArrayList<>(); // --- public static void activate(BraidDisplay display) { ACTIVE_DISPLAYS.add(display); } public static void deactivate(BraidDisplay display) { ACTIVE_DISPLAYS.remove(display); } // --- public static @Nullable DisplayHitResult targetDisplay; @ApiStatus.Internal public static @Nullable DisplayHitResult queryTargetDisplay(Vec3 rayOrigin, Vec3 rayDirection) { DisplayHitResult closestResult = null; double closestRayOffset = Double.POSITIVE_INFINITY; for (var display : ACTIVE_DISPLAYS) { var result = display.quad.hitTest(rayOrigin, rayDirection); if (result == null || result.t() >= closestRayOffset) continue; closestResult = new DisplayHitResult(display, result.point()); closestRayOffset = result.t(); } return closestResult; } @ApiStatus.Internal public static void onDisplayHit(DisplayHitResult targetDisplay) { var app = targetDisplay.display.app; var cursorX = targetDisplay.point.x() * app.surface.width(); var cursorY = targetDisplay.point.y() * app.surface.height(); app.eventBinding.add(new MouseMoveEvent(cursorX, cursorY)); } @ApiStatus.Internal public static void updateAndDrawDisplays() { for (var display : ACTIVE_DISPLAYS) { display.updateAndDrawApp(); } } @ApiStatus.Internal public static void renderAutomaticDisplays(PoseStack matrices, CameraRenderState camera, SubmitNodeCollector nodeCollector) { for (var display : ACTIVE_DISPLAYS) { if (!display.renderAutomatically) continue; matrices.pushPose(); matrices.translate(display.quad.pos.subtract(camera.pos)); display.render(matrices, nodeCollector, LightTexture.FULL_BRIGHT); matrices.popPose(); } } // --- public record DisplayHitResult(BraidDisplay display, Vector2dc point) {} } ================================================ FILE: src/main/java/io/wispforest/owo/braid/display/DisplayQuad.java ================================================ package io.wispforest.owo.braid.display; import net.minecraft.world.phys.Vec3; import org.jetbrains.annotations.Nullable; import org.joml.Vector2d; import org.joml.Vector2dc; public final class DisplayQuad { public final Vec3 pos; public final Vec3 top; public final Vec3 left; public final Vec3 normal; public DisplayQuad(Vec3 pos, Vec3 top, Vec3 left) { this.pos = pos; this.top = top; this.left = left; this.normal = this.left.cross(this.top); } public Vec3 unproject(Vector2dc point) { return this.pos.add(this.top.scale(point.x())).add(this.left.scale(point.y())); } public @Nullable HitTestResult hitTest(Vec3 origin, Vec3 direction) { var t = this.pos.subtract(origin).dot(this.normal) / direction.dot(this.normal); if (t < 0) return null; var candidatePoint = origin.add(direction.scale(t)).subtract(this.pos); var widthSquared = this.top.lengthSqr(); var heightSquared = this.left.lengthSqr(); var point = new Vector2d( candidatePoint.dot(this.top) / widthSquared, candidatePoint.dot(this.left) / heightSquared ); return point.x > 0 && point.x < 1 && point.y > 0 && point.y < 1 ? new HitTestResult(point, t) : null; } public record HitTestResult(Vector2dc point, double t) {} } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/BuildContext.java ================================================ package io.wispforest.owo.braid.framework; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import org.jetbrains.annotations.Nullable; public interface BuildContext { @Nullable T getAncestor(Class ancestorClass, Object inheritedKey); default @Nullable T getAncestor(Class ancestorClass) { return this.getAncestor(ancestorClass, ancestorClass); } @Nullable T dependOnAncestor(Class ancestorClass, Object inheritedKey, @Nullable Object dependency); default @Nullable T dependOnAncestor(Class ancestorClass, Object inheritedKey) { return this.dependOnAncestor(ancestorClass, inheritedKey, null); } default @Nullable T dependOnAncestor(Class ancestorClass) { return this.dependOnAncestor(ancestorClass, ancestorClass); } /// To prevent excessive IDE warnings, the return type of this /// getter is not annotated `@Nullable` even though if it is called /// before this context has been laid out, it will (correctly) /// return null WidgetInstance instance(); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/CustomWidgetTransform.java ================================================ package io.wispforest.owo.braid.framework.instance; import org.jetbrains.annotations.Nullable; import org.joml.*; public class CustomWidgetTransform extends WidgetTransform { protected @Nullable Matrix3x2f toParent; protected @Nullable Matrix3x2f toWidget; private boolean applyAtCenter = true; private Matrix3x2f matrix = new Matrix3x2f(); public void setMatrix(Matrix3x2f matrix) { this.setState(() -> this.matrix = matrix); } public Matrix3x2f matrix() { return this.matrix; } public void setApplyAtCenter(boolean applyToCenter) { this.setState(() -> this.applyAtCenter = applyToCenter); } public boolean applyAtCenter() { return this.applyAtCenter; } protected Matrix3x2fc toParent() { if (this.toParent == null) { if (this.applyAtCenter) { this.toParent = new Matrix3x2f() .translate((float) (this.x + this.width / 2), (float) (this.y + this.height / 2)) .mul(this.matrix) .translate((float) (-this.width / 2), (float) (-this.height / 2)); } else { this.toParent = new Matrix3x2f() .translate((float) this.x, (float) this.y) .mul(this.matrix); } } return this.toParent; } protected Matrix3x2fc toWidget() { if (this.toWidget == null) { this.toWidget = new Matrix3x2f(this.toParent()).invert(); } return this.toWidget; } @Override public void transformToParent(Matrix3x2f mat) { mat.mul(this.toParent()); } @Override public void transformToParent(Matrix3x2fStack matrices) { matrices.mul(this.toParent()); } @Override public void transformToWidget(Matrix3x2f mat) { mat.mul(this.toWidget()); } @Override public void transformToWidget(Matrix3x2fStack matrices) { matrices.mul(this.toWidget()); } @Override public void toParentCoordinates(Vector2d vec) { var vec2f = new Vector2f(vec); this.toParent().transformPosition(vec2f); vec.set(vec2f.x, vec2f.y); } @Override public void toWidgetCoordinates(Vector2d vec) { var vec2f = new Vector2f(vec); this.toWidget().transformPosition(vec2f); vec.set(vec2f.x, vec2f.y); } @Override public void recompute() { super.recompute(); this.toParent = null; this.toWidget = null; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/Hit.java ================================================ package io.wispforest.owo.braid.framework.instance; public record Hit(WidgetInstance instance, double x, double y) {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/HitTestState.java ================================================ package io.wispforest.owo.braid.framework.instance; import com.google.common.collect.FluentIterable; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayDeque; import java.util.Deque; import java.util.Iterator; import java.util.function.Predicate; public class HitTestState { private final Deque hits = new ArrayDeque<>(); public boolean anyHit() { return !this.hits.isEmpty(); } public Hit firstHit() { return this.hits.getFirst(); } public Iterable trace() { return this.hits; } public Iterable occludedTrace() { return new Iterable<>() { @Override public @NotNull Iterator iterator() { var inner = HitTestState.this.hits.iterator(); return new Iterator<>() { private boolean encounteredBoundary = false; @Override public boolean hasNext() { return inner.hasNext() && !this.encounteredBoundary; } @Override public Hit next() { var next = inner.next(); if ((next.instance().flags & WidgetInstance.FLAG_HIT_TEST_BOUNDARY) != 0) { this.encounteredBoundary = true; } return next; } }; } }; } public @Nullable Hit firstWhere(Predicate predicate) { return FluentIterable.from(this.occludedTrace()).firstMatch(predicate::test).orNull(); } public void addHit(WidgetInstance instance, double x, double y) { this.hits.addFirst(new Hit(instance, x, y)); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/InspectorProperty.java ================================================ package io.wispforest.owo.braid.framework.instance; import net.minecraft.network.chat.Component; public record InspectorProperty(Component name, Component value) {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/InstanceHost.java ================================================ package io.wispforest.owo.braid.framework.instance; import io.wispforest.owo.braid.widgets.basic.LayoutBuilder; import net.minecraft.client.Minecraft; import org.joml.Vector2dc; public interface InstanceHost { Minecraft client(); /// Schedule a [WidgetInstance#layout] invocation for `instance`, /// to be executed during the next layout pass. /// /// This function must generally not be called during a layout pass /// unless [#notifySubtreeRebuild] has been invoked first since /// otherwise we run the risk of laying out some instances twice void scheduleLayout(WidgetInstance instance); /// Notify the layout scheduler that a widget or proxy subtree /// of the current element is (likely) about to rebuild and /// subsequently [#scheduleLayout] may be invoked during the /// current layout pass /// /// This is used to implement the [LayoutBuilder] mechanism void notifySubtreeRebuild(); void schedulePostLayoutCallback(Runnable callback); Vector2dc cursorPosition(); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/LeafWidgetInstance.java ================================================ package io.wispforest.owo.braid.framework.instance; import io.wispforest.owo.braid.framework.widget.InstanceWidget; public abstract class LeafWidgetInstance extends WidgetInstance { public LeafWidgetInstance(T widget) { super(widget); } @Override public void visitChildren(Visitor visitor) {} } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/MouseListener.java ================================================ package io.wispforest.owo.braid.framework.instance; import io.wispforest.owo.braid.core.KeyModifiers; import io.wispforest.owo.braid.core.cursor.CursorStyle; import org.jetbrains.annotations.Nullable; public interface MouseListener { default @Nullable CursorStyle cursorStyleAt(double x, double y) { return null; } default boolean onMouseDown(double x, double y, int button, KeyModifiers modifiers) { return false; } default boolean onMouseUp(double x, double y, int button, KeyModifiers modifiers) { return false; } default void onMouseEnter() {} default void onMouseMove(double toX, double toY) {} default void onMouseExit() {} default void onMouseDragStart(int button, KeyModifiers modifiers) {} default void onMouseDrag(double x, double y, double dx, double dy) {} default void onMouseDragEnd() {} default boolean onMouseScroll(double x, double y, double horizontal, double vertical) { return false; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/MultiChildWidgetInstance.java ================================================ package io.wispforest.owo.braid.framework.instance; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.BraidUtils; import io.wispforest.owo.braid.framework.widget.MultiChildInstanceWidget; import java.util.ArrayList; import java.util.List; import java.util.OptionalDouble; public abstract class MultiChildWidgetInstance extends WidgetInstance { public List> children = new ArrayList<>(); public MultiChildWidgetInstance(T widget) { super(widget); } @Override public void draw(BraidGraphics graphics) { for (var child : this.children) { this.drawChild(graphics, child); } } @Override public void visitChildren(Visitor visitor) { for (var child : this.children) { visitor.visit(child); } } public void insertChild(int index, WidgetInstance child) { this.children.set(index, this.adopt(child)); this.markNeedsLayout(); } // --- protected OptionalDouble computeFirstBaselineOffset() { for (var child : this.children) { var childBaseline = child.getBaselineOffset(); if (childBaseline.isEmpty()) continue; return OptionalDouble.of(childBaseline.getAsDouble() + child.transform.y); } return OptionalDouble.empty(); } protected OptionalDouble computeHighestBaselineOffset() { return BraidUtils.fold(this.children, null, (acc, child) -> { var childBaseline = child.getBaselineOffset(); if (childBaseline.isEmpty()) return acc; return baselineMin(acc, OptionalDouble.of(childBaseline.getAsDouble() + child.transform.y)); }); } private static OptionalDouble baselineMin(OptionalDouble a, OptionalDouble b) { if (a.isEmpty()) return b; if (b.isEmpty()) return a; return a.getAsDouble() <= b.getAsDouble() ? a : b; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/OptionalChildWidgetInstance.java ================================================ package io.wispforest.owo.braid.framework.instance; import com.google.common.base.Preconditions; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.widget.InstanceWidget; import org.jetbrains.annotations.Nullable; import java.util.OptionalDouble; public abstract class OptionalChildWidgetInstance extends WidgetInstance { protected @Nullable WidgetInstance child; public OptionalChildWidgetInstance(T widget) { super(widget); } @Override public void draw(BraidGraphics graphics) { if (this.child != null) { this.drawChild(graphics, this.child); } } @Override public void visitChildren(Visitor visitor) { if (this.child != null) { visitor.visit(this.child); } } public WidgetInstance child() { Preconditions.checkNotNull(this.child, "tried to retrieve child of SingleChildWidgetInstance before it was set"); return this.child; } public void setChild(@Nullable WidgetInstance value) { if (value == this.child) return; this.child = this.adopt(value); this.markNeedsLayout(); } public static abstract class ShrinkWrap extends OptionalChildWidgetInstance { public ShrinkWrap(T widget) { super(widget); } @Override protected void doLayout(Constraints constraints) { this.sizeToChild(constraints, this.child); } @Override protected double measureIntrinsicWidth(double height) { return this.child != null ? this.child.getIntrinsicWidth(height) : 0; } @Override protected double measureIntrinsicHeight(double width) { return this.child != null ? this.child.getIntrinsicHeight(width) : 0; } @Override protected OptionalDouble measureBaselineOffset() { return this.child != null ? this.child.getBaselineOffset() : OptionalDouble.empty(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/SingleChildWidgetInstance.java ================================================ package io.wispforest.owo.braid.framework.instance; import com.google.common.base.Preconditions; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.widget.InstanceWidget; import java.util.OptionalDouble; public abstract class SingleChildWidgetInstance extends WidgetInstance { protected WidgetInstance child; public SingleChildWidgetInstance(T widget) { super(widget); } @Override public void draw(BraidGraphics graphics) { this.drawChild(graphics, this.child); } @Override public void visitChildren(Visitor visitor) { visitor.visit(this.child); } public WidgetInstance child() { Preconditions.checkNotNull(this.child, "tried to retrieve child of SingleChildWidgetInstance before it was set"); return this.child; } public void setChild(WidgetInstance value) { if (value == this.child) return; this.child = this.adopt(value); this.markNeedsLayout(); } public static abstract class ShrinkWrap extends SingleChildWidgetInstance { public ShrinkWrap(T widget) { super(widget); } @Override protected void doLayout(Constraints constraints) { this.sizeToChild(constraints, this.child); } @Override protected double measureIntrinsicWidth(double height) { return this.child.getIntrinsicWidth(height); } @Override protected double measureIntrinsicHeight(double width) { return this.child.getIntrinsicHeight(width); } @Override protected OptionalDouble measureBaselineOffset() { return this.child.getBaselineOffset(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/TooltipProvider.java ================================================ package io.wispforest.owo.braid.framework.instance; import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent; import net.minecraft.network.chat.Style; import org.jetbrains.annotations.Nullable; import java.util.List; public interface TooltipProvider { @Nullable List getTooltipComponentsAt(double x, double y); @Nullable default Style getStyleAt(double x, double y) { return null; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/WidgetInstance.java ================================================ package io.wispforest.owo.braid.framework.instance; import com.google.common.base.Preconditions; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.widget.InstanceWidget; import io.wispforest.owo.ui.core.Color; import io.wispforest.owo.ui.util.NinePatchTexture; import it.unimi.dsi.fastutil.objects.Object2DoubleMap; import it.unimi.dsi.fastutil.objects.Object2DoubleOpenHashMap; import net.minecraft.world.phys.AABB; import org.jetbrains.annotations.MustBeInvokedByOverriders; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.joml.Matrix3x2f; import org.joml.Vector2d; import org.joml.Vector2f; import java.util.*; public abstract class WidgetInstance implements Comparable> { public static final int FLAG_HIT_TEST_BOUNDARY = 0b1; public final WidgetTransform transform = this.createTransform(); public @Nullable Object parentData; public int flags = 0; private int depth = 0; private InstanceHost host; private WidgetInstance parent; protected T widget; // --- public boolean debugHighlighted = false; public boolean debugDrawVisualizers = false; public boolean debugParentHasDependency() { //noinspection OptionalAssignedToNull return !this.intrinsicSizeCache.isEmpty() || this.baselineOffsetCache != null; } // --- private @Nullable Constraints constraints; private boolean needsLayout = false; private @Nullable WidgetInstance relayoutBoundary; public WidgetInstance(T widget) { this.widget = widget; } protected WidgetTransform createTransform() { return new WidgetTransform(); } // --- public final Size layout(Constraints constraints) { if (!this.needsLayout && Objects.equals(constraints, this.constraints)) { return this.transform.toSize(); } this.constraints = constraints; this.relayoutBoundary = constraints.isTight() || this.parent == null ? this : this.parent.relayoutBoundary; this.doLayout(constraints); this.needsLayout = false; return this.transform.toSize(); } protected abstract void doLayout(Constraints constraints); protected abstract double measureIntrinsicWidth(double height); protected abstract double measureIntrinsicHeight(double width); private final Object2DoubleMap intrinsicSizeCache = new Object2DoubleOpenHashMap<>(); public double getIntrinsicWidth(double height) { return this.intrinsicSizeCache.computeIfAbsent(new IntrinsicCacheKey(LayoutAxis.HORIZONTAL, height), ($) -> this.measureIntrinsicWidth(height)); } public double getIntrinsicHeight(double width) { return this.intrinsicSizeCache.computeIfAbsent(new IntrinsicCacheKey(LayoutAxis.VERTICAL, width), ($) -> this.measureIntrinsicHeight(width)); } protected abstract OptionalDouble measureBaselineOffset(); private @Nullable OptionalDouble baselineOffsetCache; public OptionalDouble getBaselineOffset() { //noinspection OptionalAssignedToNull if (this.baselineOffsetCache != null) return this.baselineOffsetCache; return this.baselineOffsetCache = this.measureBaselineOffset(); } // --- public abstract void draw(BraidGraphics graphics); public abstract void visitChildren(Visitor visitor); // --- public void attachHost(InstanceHost host) { this.host = host; var callback = POST_ATTACH_CALLBACKS.remove(this); if (callback != null) callback.run(); this.visitChildren(child -> child.attachHost(host)); } protected > W adopt(W child) { if (child == null || ((WidgetInstance) child).parent == this) return child; child.setDepth(this.depth + 1); ((WidgetInstance) child).parent = this; if (this.host != null) { child.attachHost(this.host); } return child; } // --- public List debugListInspectorProperties() { return List.of(); } public boolean debugHasVisualizers() { return false; } protected void debugDrawVisualizers(BraidGraphics graphics) {} // --- protected void drawChild(BraidGraphics ctx, WidgetInstance child) { ctx.push(); child.transform.transformToParent(ctx.pose()); child.draw(ctx); if (child.debugHasVisualizers() && child.debugDrawVisualizers) { child.debugDrawVisualizers(ctx); } if (child.debugHighlighted) { NinePatchTexture.draw( Owo.id("braid_debug_highlighted"), ctx, 0, 0, (int) child.transform.width(), (int) child.transform.height(), Color.ofRgb(0x00FFD1) ); } ctx.pop(); } protected void sizeToChild(Constraints constraints, @Nullable WidgetInstance child) { if (child == null) { this.transform.setSize(constraints.minSize()); } else { var childSize = child.layout(constraints); this.transform.setSize(childSize); } } public void clearLayoutCache(boolean recursive) { this.needsLayout = true; if (recursive) { this.visitChildren(child -> child.clearLayoutCache(true)); } } @SuppressWarnings("OptionalAssignedToNull") public void markNeedsLayout() { this.needsLayout = true; var parentHasDependency = !this.intrinsicSizeCache.isEmpty() || this.baselineOffsetCache != null; this.intrinsicSizeCache.clear(); this.baselineOffsetCache = null; if (!parentHasDependency && this.isRelayoutBoundary()) { if (this.host != null) this.host.scheduleLayout(this); } else { if (this.parent != null) this.parent.markNeedsLayout(); } } private boolean debugDisposed = false; @MustBeInvokedByOverriders public void dispose() { Preconditions.checkState(!this.debugDisposed, "tried to dispose a widget instance twice"); this.debugDisposed = true; this.parent = null; } // --- public List> ancestors() { var result = new ArrayList>(); var ancestor = this.parent; while (ancestor != null) { result.add(ancestor); ancestor = ancestor.parent; } return result; } public void hitTest(double x, double y, HitTestState state) { if (this.hitTestSelf(x, y)) { state.addHit(this, x, y); } var coordinates = new Vector2d(); this.visitChildren(child -> { coordinates.set(x, y); child.transform.toWidgetCoordinates(coordinates); child.hitTest(coordinates.x, coordinates.y, state); }); } protected boolean hitTestSelf(double x, double y) { return x >= 0 && x < this.transform.width && y >= 0 && y < this.transform.height; } public Matrix3x2f computeGlobalTransform() { return this.computeTransformFrom(null); } public Matrix3x2f computeTransformFrom(@Nullable WidgetInstance ancestor) { var result = new Matrix3x2f(); this.transform.transformToWidget(result); for (var step : this.ancestors()) { if (step == ancestor) break; step.transform.transformToWidget(result); } return result; } public AABB computeGlobalBounds() { var global = this.parent != null ? this.parent.computeGlobalTransform().invert() : new Matrix3x2f(); var min = global.transformPosition(new Vector2f((float) this.transform.x, (float) this.transform.y)); var max = global.transformPosition(new Vector2f((float) (this.transform.x + this.transform.width), (float) (this.transform.y + this.transform.height))); return new AABB(min.x, min.y, 0, max.x, max.y, 0); } public Vector2d computeGlobalPosition() { var global = this.parent != null ? this.parent.computeGlobalTransform().invert() : new Matrix3x2f(); var pos = global.transformPosition(new Vector2f((float) this.transform.x, (float) this.transform.y)); return new Vector2d(pos.x, pos.y); } // --- public @Nullable Constraints constraints() { return this.constraints; } public int depth() { return this.depth; } public void setDepth(int depth) { if (this.depth == depth) return; this.depth = depth; this.visitChildren(child -> child.setDepth(this.depth + 1)); } /// To prevent excessive IDE warnings, the return type of this /// getter is not annotated `@Nullable` even though if it is called /// before this instance is adopted it will (correctly) return null public InstanceHost host() { return this.host; } public boolean needsLayout() { return this.needsLayout; } public boolean isRelayoutBoundary() { return this.relayoutBoundary == this; } public boolean hasParent() { return this.parent != null; } public void setWidget(T widget) { this.widget = widget; } public T widget() { return this.widget; } public WidgetInstance parent() { return this.parent; } // --- private static final WeakHashMap, Runnable> POST_ATTACH_CALLBACKS = new WeakHashMap<>(); public static void addPostAttachCallback(WidgetInstance instance, Runnable callback) { POST_ATTACH_CALLBACKS.put(instance, callback); } // --- @Override public int compareTo(@NotNull WidgetInstance o) { return Integer.compare(this.depth, o.depth); } // --- @FunctionalInterface public interface Visitor { void visit(WidgetInstance child); } } record IntrinsicCacheKey(LayoutAxis axis, double crossExtent) {} //enum Visitors implements WidgetInstance.Visitor { // MARK_NEEDS_LAYOUT(WidgetInstance::markNeedsLayout); // // private final WidgetInstance.Visitor delegate; // // Visitors(WidgetInstance.Visitor delegate) { // this.delegate = delegate; // } // // @Override // public void visit(WidgetInstance child) { // this.delegate.visit(child); // } //} ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/instance/WidgetTransform.java ================================================ package io.wispforest.owo.braid.framework.instance; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.core.Size; import org.joml.Matrix3x2f; import org.joml.Matrix3x2fStack; import org.joml.Vector2d; public class WidgetTransform { protected double x = 0, y = 0; protected double width = 0, height = 0; public void setX(double x) { setState(() -> this.x = x); } public double x() { return this.x; } public void setY(double y) { setState(() -> this.y = y); } public double y() { return this.y; } public void setWidth(double width) { setState(() -> { if (Double.isInfinite(width)) { this.width = 69420; Owo.LOGGER.error("A widget transform received infinite width, clamping to 69420. This should never happen"); } else { this.width = width; } }); } public double width() { return this.width; } public void setHeight(double height) { setState(() -> { if (Double.isInfinite(height)) { this.height = 69420; Owo.LOGGER.error("A widget transform received infinite height, clamping to 69420. This should never happen"); } else { this.height = height; } }); } public double height() { return this.height; } public void setSize(Size size) { setState(() -> { this.width = size.width(); this.height = size.height(); }); } public Size toSize() { return Size.of(this.width, this.height); } public void transformToParent(Matrix3x2f mat) { mat.translate((float) this.x, (float) this.y); } public void transformToParent(Matrix3x2fStack matrices) { matrices.translate((float) this.x, (float) this.y); } public void transformToWidget(Matrix3x2f mat) { mat.translate((float) -this.x, (float) -this.y); } public void transformToWidget(Matrix3x2fStack matrices) { matrices.translate((float) -this.x, (float) -this.y); } public void toParentCoordinates(Vector2d vec) { vec.add(this.x, this.y); } public void toWidgetCoordinates(Vector2d vec) { vec.sub(this.x, this.y); } public void setExtent(LayoutAxis axis, double value) { switch (axis) { case HORIZONTAL -> setWidth(value); case VERTICAL -> setHeight(value); } } public double getExtent(LayoutAxis axis) { return switch (axis) { case HORIZONTAL -> width(); case VERTICAL -> height(); }; } public void setCoordinate(LayoutAxis axis, double value) { switch (axis) { case HORIZONTAL -> setX(value); case VERTICAL -> setY(value); } } public double getCoordinate(LayoutAxis axis) { return switch (axis) { case HORIZONTAL -> x(); case VERTICAL -> y(); }; } protected void setState(Runnable action) { action.run(); this.recompute(); } public void recompute() {} } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/BuildScope.java ================================================ package io.wispforest.owo.braid.framework.proxy; import io.wispforest.owo.Owo; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; public class BuildScope { private final List dirtyProxies = new ArrayList<>(); private boolean resortProxies = true; private final @Nullable Runnable scheduleRebuild; public BuildScope(@Nullable Runnable scheduleRebuild) { this.scheduleRebuild = scheduleRebuild; } public BuildScope() { this(null); } // --- public void scheduleRebuild(WidgetProxy proxy) { this.dirtyProxies.add(proxy); this.resortProxies = true; if (this.scheduleRebuild != null) { this.scheduleRebuild.run(); } } public boolean rebuildDirtyProxies() { if (this.dirtyProxies.isEmpty()) return false; this.dirtyProxies.sort(Comparator.naturalOrder()); for (var idx = 0; idx < this.dirtyProxies.size(); idx = this.nextDirtyIndex(idx)) { this.dirtyProxies.get(idx).rebuild(); } if (Owo.DEBUG && this.dirtyProxies.stream().anyMatch(BuildScope::isMissed)) { throw new IllegalStateException( "missed the following dirty proxies: [" + this.dirtyProxies.stream().filter(BuildScope::isMissed).map(Objects::toString).collect(Collectors.joining(", ")) + "]" ); } this.dirtyProxies.clear(); return true; } private int nextDirtyIndex(int idx) { if (!this.resortProxies) return idx + 1; this.dirtyProxies.sort(Comparator.naturalOrder()); this.resortProxies = false; idx++; while (idx > 0 && this.dirtyProxies.get(idx - 1).needsRebuild()) { idx--; } return idx; } // --- private static boolean isMissed(WidgetProxy proxy) { return proxy.needsRebuild && proxy.lifecycle == WidgetProxy.Lifecycle.LIVE; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/ComposedProxy.java ================================================ package io.wispforest.owo.braid.framework.proxy; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public abstract non-sealed class ComposedProxy extends WidgetProxy { protected @Nullable WidgetProxy child; public ComposedProxy(Widget widget) { super(widget); } public WidgetProxy child() { return this.child; } @Override public void visitChildren(Visitor visitor) { if (this.child != null) visitor.visit(this.child); } // --- private WidgetInstance descendantInstance; @Override public @Nullable WidgetInstance instance() { return this.descendantInstance; } @Override public void notifyDescendantInstance(@Nullable WidgetInstance instance, @Nullable Object slot) { this.descendantInstance = instance; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/InheritedProxy.java ================================================ package io.wispforest.owo.braid.framework.proxy; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.HashMap; import java.util.List; public class InheritedProxy extends ComposedProxy { private final List dependents = new ArrayList<>(); public InheritedProxy(InheritedWidget widget) { super(widget); } public void addDependency(WidgetProxy dependent, @Nullable Object dependency) { this.dependents.add(dependent); } public void removeDependent(WidgetProxy dependent) { this.dependents.remove(dependent); } protected boolean mustRebuildDependent(WidgetProxy dependent) { return true; } public void notifyDependent(WidgetProxy dependent) { dependent.notifyDependenciesChanged(); } @Override public void mount(WidgetProxy parent, @Nullable Object slot) { super.mount(parent, slot); this.inheritedProxies = this.inheritedProxies != null ? new HashMap<>(this.inheritedProxies) : new HashMap<>(); this.inheritedProxies.put(((InheritedWidget) this.widget()).inheritedKey(), this); this.rebuild(); } @Override public void updateWidget(Widget newWidget) { var shouldUpdate = ((InheritedWidget) this.widget()).mustRebuildDependents((InheritedWidget) newWidget); super.updateWidget(newWidget); this.rebuild(true); if (shouldUpdate) { for (var dependent : this.dependents) { if (!this.mustRebuildDependent(dependent)) continue; this.notifyDependent(dependent); } } } @Override protected void doRebuild() { super.doRebuild(); this.child = this.refreshChild(this.child, ((InheritedWidget) this.widget()).child, this.slot()); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/InstanceWidgetProxy.java ================================================ package io.wispforest.owo.braid.framework.proxy; import com.google.common.base.Preconditions; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.widget.InstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; public abstract non-sealed class InstanceWidgetProxy extends WidgetProxy { protected final WidgetInstance instance; private final List ancestorsUntilNextInstanceProxy = new ArrayList<>(); protected InstanceWidgetProxy(InstanceWidget widget) { super(widget); //noinspection unchecked this.instance = (WidgetInstance) widget.instantiate(); Preconditions.checkNotNull(this.instance, "Widget#instantiate must return a non-null instance"); } @Override public WidgetInstance instance() { return this.instance; } @Override public void mount(WidgetProxy parent, @Nullable Object slot) { super.mount(parent, slot); var ancestor = parent; while (!(ancestor instanceof InstanceWidgetProxy)) { this.ancestorsUntilNextInstanceProxy.add(ancestor); ancestor = ancestor.parent(); } this.ancestorsUntilNextInstanceProxy.add(ancestor); this.rebuild(); this.notifyAncestors(); } @Override public void updateSlot(@Nullable Object newSlot) { super.updateSlot(newSlot); this.notifyAncestors(); } @Override public void unmount() { super.unmount(); this.instance.dispose(); this.ancestorsUntilNextInstanceProxy.clear(); } @Override public void updateWidget(Widget newWidget) { super.updateWidget(newWidget); this.instance.setWidget((InstanceWidget) newWidget); } private void notifyAncestors() { for (var listener : this.ancestorsUntilNextInstanceProxy) { listener.notifyDescendantInstance(this.instance, this.slot()); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/LeafInstanceWidgetProxy.java ================================================ package io.wispforest.owo.braid.framework.proxy; import com.google.common.base.Preconditions; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.widget.LeafInstanceWidget; import org.jetbrains.annotations.Nullable; public class LeafInstanceWidgetProxy extends InstanceWidgetProxy { public LeafInstanceWidgetProxy(LeafInstanceWidget widget) { super(widget); } @Override public void visitChildren(Visitor visitor) {} @Override public void notifyDescendantInstance(@Nullable WidgetInstance instance, @Nullable Object slot) { Preconditions.checkState(false, "a leaf proxy cannot have descendant instances"); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/MultiChildInstanceWidgetProxy.java ================================================ package io.wispforest.owo.braid.framework.proxy; import com.google.common.base.Preconditions; import io.wispforest.owo.braid.framework.instance.MultiChildWidgetInstance; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.widget.InstanceWidget; import io.wispforest.owo.braid.framework.widget.Key; import io.wispforest.owo.braid.framework.widget.MultiChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; public class MultiChildInstanceWidgetProxy extends InstanceWidgetProxy { public List children = new ArrayList<>(); public List> childInstances = new ArrayList<>(); public MultiChildInstanceWidgetProxy(MultiChildInstanceWidget widget) { super(widget); } @Override public MultiChildWidgetInstance instance() { //noinspection unchecked return (MultiChildWidgetInstance) super.instance(); } @Override public void visitChildren(Visitor visitor) { for (var child : children) { visitor.visit(child); } } @Override public void updateWidget(Widget newWidget) { super.updateWidget(newWidget); rebuild(true); } @Override public void doRebuild() { super.doRebuild(); var newWidgets = ((MultiChildInstanceWidget) this.widget()).children; var newChildrenTop = 0; var oldChildrenTop = 0; var newChildrenBottom = newWidgets.size() - 1; var oldChildrenBottom = this.children.size() - 1; var newChildren = Stream.generate(() -> null).limit(newWidgets.size()).collect(Collectors.toList()); // we already set up the new child instance list, so that any // notifyDescendantInstance invocations caused by the below // refreshChild calls always index into the correct list this.childInstances = Stream.>generate(() -> null).limit(newChildren.size()).collect(Collectors.toList()); copyInto(this.childInstances, 0, this.instance().children, 0, Math.min(this.childInstances.size(), this.instance().children.size())); if (this.instance().children.size() > this.childInstances.size()) { this.instance().markNeedsLayout(); } this.instance().children = this.childInstances; // sync from the top while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) { var oldChild = this.children.get(oldChildrenTop); var newWidget = newWidgets.get(newChildrenTop); if (!Widget.canUpdate(oldChild.widget(), newWidget)) { break; } newChildren.set(newChildrenTop, this.refreshChild(oldChild, newWidget, newChildrenTop)); Preconditions.checkNotNull(this.childInstances.get(newChildrenTop)); oldChildrenTop++; newChildrenTop++; } // scan from the bottom while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) { var oldChild = this.children.get(oldChildrenTop); var newWidget = newWidgets.get(newChildrenTop); if (!Widget.canUpdate(oldChild.widget(), newWidget)) { break; } oldChildrenTop++; newChildrenTop++; } // scan middle, store keyed and disposed un-keyed var hasOldChildren = oldChildrenTop <= oldChildrenBottom; Map keyedOldChildren = null; if (hasOldChildren) { keyedOldChildren = new HashMap<>(); while (oldChildrenTop <= oldChildrenBottom) { var oldChild = this.children.get(oldChildrenTop); var key = oldChild.widget().key(); if (key != null) { keyedOldChildren.put(key, oldChild); } else { oldChild.unmount(); } oldChildrenTop++; } } // sync middle, updating keyed while (newChildrenTop <= newChildrenBottom) { WidgetProxy oldChild = null; var newWidget = newWidgets.get(newChildrenTop); if (hasOldChildren) { var key = newWidget.key(); if (key != null) { oldChild = keyedOldChildren.get(key); if (oldChild != null) { if (Widget.canUpdate(oldChild.widget(), newWidget)) { keyedOldChildren.remove(key); } else { oldChild = null; } } } } newChildren.set(newChildrenTop, this.refreshChild(oldChild, newWidget, newChildrenTop)); Preconditions.checkNotNull(this.childInstances.get(newChildrenTop)); newChildrenTop++; } newChildrenBottom = newWidgets.size() - 1; oldChildrenBottom = this.children.size() - 1; while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) { var oldChild = this.children.get(oldChildrenTop); var newWidget = newWidgets.get(newChildrenTop); newChildren.set(newChildrenTop, this.refreshChild(oldChild, newWidget, newChildrenTop)); Preconditions.checkNotNull(this.childInstances.get(newChildrenTop)); oldChildrenTop++; newChildrenTop++; } // dispose keyed proxies that were not reused if (hasOldChildren && !keyedOldChildren.isEmpty()) { for (var proxy : keyedOldChildren.values()) { proxy.unmount(); } } // finally, install new children this.children = newChildren; } @Override public void notifyDescendantInstance(@Nullable WidgetInstance instance, @Nullable Object slot) { this.instance().insertChild(((Integer) slot).intValue(), instance); } @SuppressWarnings("SameParameterValue") private static void copyInto(List target, int at, List source, int from, int to) { var copyCount = to - from; for (var i = 0; i < copyCount; i++) { target.set(at + i, source.get(from + i)); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/OptionalChildInstanceWidgetProxy.java ================================================ package io.wispforest.owo.braid.framework.proxy; import io.wispforest.owo.braid.framework.instance.OptionalChildWidgetInstance; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.widget.InstanceWidget; import io.wispforest.owo.braid.framework.widget.OptionalChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public class OptionalChildInstanceWidgetProxy extends InstanceWidgetProxy { protected @Nullable WidgetProxy child; public OptionalChildInstanceWidgetProxy(OptionalChildInstanceWidget widget) { super(widget); } @Override public OptionalChildWidgetInstance instance() { return (OptionalChildWidgetInstance) super.instance(); } @Override public void updateWidget(Widget newWidget) { super.updateWidget(newWidget); this.rebuild(true); } @Override protected void doRebuild() { super.doRebuild(); this.child = this.refreshChild(this.child, ((OptionalChildInstanceWidget) this.widget()).child, null); if (((OptionalChildInstanceWidget) this.widget()).child == null) { this.instance().setChild(null); } } @Override public void notifyDescendantInstance(@Nullable WidgetInstance instance, @Nullable Object slot) { this.instance().setChild(instance); } @Override public void visitChildren(Visitor visitor) { if (this.child != null) { visitor.visit(this.child); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/ProxyHost.java ================================================ package io.wispforest.owo.braid.framework.proxy; import net.minecraft.client.Minecraft; import java.time.Duration; public interface ProxyHost { Minecraft client(); void scheduleAnimationCallback(AnimationCallback callback); long scheduleDelayedCallback(Duration delay, Runnable callback); void cancelDelayedCallback(long id); void schedulePostLayoutCallback(Runnable callback); interface AnimationCallback { void run(Duration delta); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/SingleChildInstanceWidgetProxy.java ================================================ package io.wispforest.owo.braid.framework.proxy; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.widget.InstanceWidget; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public class SingleChildInstanceWidgetProxy extends InstanceWidgetProxy { protected WidgetProxy child; public SingleChildInstanceWidgetProxy(SingleChildInstanceWidget widget) { super(widget); } @Override public SingleChildWidgetInstance instance() { return (SingleChildWidgetInstance) super.instance(); } @Override public void updateWidget(Widget newWidget) { super.updateWidget(newWidget); this.rebuild(true); } @Override protected void doRebuild() { super.doRebuild(); this.child = this.refreshChild(this.child, ((SingleChildInstanceWidget) this.widget()).child, null); } @Override public void notifyDescendantInstance(@Nullable WidgetInstance instance, @Nullable Object slot) { this.instance().setChild(instance); } @Override public void visitChildren(Visitor visitor) { if (this.child != null) { visitor.visit(this.child); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/StatefulProxy.java ================================================ package io.wispforest.owo.braid.framework.proxy; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public class StatefulProxy extends ComposedProxy { private final WidgetState state; private boolean dependenciesChanged = false; public StatefulProxy(StatefulWidget widget) { super(widget); //noinspection unchecked this.state = (WidgetState) widget.createState(); this.state.widget = (StatefulWidget) this.widget(); this.state.owner = this; } public WidgetState state() { return this.state; } @Override public void mount(WidgetProxy parent, @Nullable Object slot) { super.mount(parent, slot); this.state.init(); this.rebuild(); } @Override public void notifyDependenciesChanged() { super.notifyDependenciesChanged(); this.dependenciesChanged = true; } @Override public void unmount() { super.unmount(); this.state.dispose(); } @Override public void updateWidget(Widget newWidget) { super.updateWidget(newWidget); var oldWidget = this.state.widget; this.state.widget = (StatefulWidget) newWidget; this.state.didUpdateWidget(oldWidget); this.rebuild(true); } @Override protected void doRebuild() { if (this.dependenciesChanged) { this.state.notifyDependenciesChanged(); this.dependenciesChanged = false; } var newWidget = this.state.build(this); super.doRebuild(); this.child = this.refreshChild(this.child, newWidget, this.slot()); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/StatelessProxy.java ================================================ package io.wispforest.owo.braid.framework.proxy; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public class StatelessProxy extends ComposedProxy { public StatelessProxy(StatelessWidget widget) { super(widget); } @Override public void mount(WidgetProxy parent, @Nullable Object slot) { super.mount(parent, slot); this.rebuild(); } @Override public void updateWidget(Widget newWidget) { super.updateWidget(newWidget); this.rebuild(true); } @Override protected void doRebuild() { var newWidget = ((StatelessWidget) this.widget()).build(this); super.doRebuild(); this.child = this.refreshChild(this.child, newWidget, this.slot()); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/WidgetProxy.java ================================================ package io.wispforest.owo.braid.framework.proxy; import com.google.common.base.Preconditions; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.MustBeInvokedByOverriders; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.HashSet; import java.util.Map; import java.util.Objects; import java.util.Set; public abstract sealed class WidgetProxy implements BuildContext, Comparable permits ComposedProxy, InstanceWidgetProxy { private Widget widget; private @Nullable WidgetProxy parent; private BuildScope parentBuildScope; private int depth = -1; private @Nullable ProxyHost host; private @Nullable Object slot; public Lifecycle lifecycle = Lifecycle.INITIAL; protected boolean needsRebuild = true; protected Map inheritedProxies = null; protected Set dependencies = null; public WidgetProxy(Widget widget) { this.widget = widget; this.widget.freeze(); } public void mount(WidgetProxy parent, @Nullable Object slot) { Preconditions.checkArgument(parent.mounted(), "parent proxy must be mounted before its children"); Preconditions.checkState(this.lifecycle == Lifecycle.INITIAL, "proxy must be in INITIAL lifecycle state when mount() is called"); this.lifecycle = Lifecycle.LIVE; this.inheritedProxies = parent.inheritedProxies; this.parent = parent; this.parentBuildScope = parent.buildScope(); this.setDepth(parent.depth + 1); this.slot = slot; this.host = parent.host; } @MustBeInvokedByOverriders public void updateSlot(@Nullable Object newSlot) { this.slot = newSlot; } public void unmount() { Preconditions.checkState(this.lifecycle == Lifecycle.LIVE, "proxy must be in LIVE lifecycle state when unmount() is called"); this.lifecycle = Lifecycle.DEAD; if (this.dependencies != null) { for (var dependency : this.dependencies) { if (dependency != null) dependency.removeDependent(this); } } visitChildren(Visitors.UNMOUNT); } public void markNeedsRebuild() { if (this.needsRebuild) return; this.needsRebuild = true; this.buildScope().scheduleRebuild(this); } public void reassemble() { this.markNeedsRebuild(); this.visitChildren(Visitors.REASSEMBLE); } // --- protected @Nullable WidgetProxy refreshChild(@Nullable WidgetProxy child, @Nullable Widget newWidget, @Nullable Object newSlot) { if (newWidget == null) { if (child != null) child.unmount(); return null; } if (child != null && Widget.canUpdate(child.widget, newWidget)) { if (!Objects.equals(child.slot, newSlot)) { child.updateSlot(newSlot); } if (child.widget != newWidget) { child.updateWidget(newWidget); } return child; } else { if (child != null) { child.unmount(); } var newProxy = newWidget.proxy(); newProxy.mount(this, newSlot); return newProxy; } } @MustBeInvokedByOverriders public void updateWidget(Widget newWidget) { this.widget = newWidget; this.widget.freeze(); } public final void rebuild() { this.rebuild(false); } public final void rebuild(boolean force) { if (!(force || (this.needsRebuild && this.lifecycle == Lifecycle.LIVE))) return; this.doRebuild(); } @MustBeInvokedByOverriders protected void doRebuild() { this.needsRebuild = false; } // --- @Override public @Nullable T getAncestor(Class ancestorClass, Object inheritedKey) { var ancestor = this.inheritedProxies != null ? this.inheritedProxies.get(inheritedKey) : null; if (ancestor != null) { Preconditions.checkArgument(ancestorClass == ancestor.widget().getClass(), "attempted to look up an ancestor using an inheritedKey pointing to one of a different type"); //noinspection unchecked return (T) ancestor.widget(); } return null; } @Override public @Nullable T dependOnAncestor(Class ancestorClass, Object inheritedKey, @Nullable Object dependency) { var ancestor = this.inheritedProxies != null ? this.inheritedProxies.get(inheritedKey) : null; if (ancestor != null) { Preconditions.checkArgument(ancestorClass == ancestor.widget().getClass(), "attempted to look up an ancestor using an inheritedKey pointing to one of a different type"); if (this.dependencies == null) { this.dependencies = new HashSet<>(); } ancestor.addDependency(this, dependency); this.dependencies.add(ancestor); //noinspection unchecked return (T) ancestor.widget(); } return null; } public void notifyDependenciesChanged() { this.markNeedsRebuild(); } // --- public abstract void visitChildren(Visitor visitor); @Override public abstract @Nullable WidgetInstance instance(); public abstract void notifyDescendantInstance(@Nullable WidgetInstance instance, @Nullable Object slot); // --- public Widget widget() { return this.widget; } public @Nullable WidgetProxy parent() { return this.parent; } public boolean mounted() { return this.parent != null; } public BuildScope buildScope() { Preconditions.checkNotNull(this.parentBuildScope, "parent build scope not set"); return this.parentBuildScope; } public @Nullable Object slot() { return this.slot; } public ProxyHost host() { return this.host; } public boolean needsRebuild() { return this.needsRebuild; } public int depth() { return this.depth; } public void setDepth(int depth) { if (this.depth == depth) return; this.depth = depth; this.visitChildren(child -> child.setDepth(this.depth + 1)); } // --- /// Set the host of this proxy, reserved for use by /// root proxy implementations. In all other scenarios, /// the host is to be taken from the parent in [#mount] protected void rootSetHost(ProxyHost host) { this.host = host; } // --- @Override public int compareTo(@NotNull WidgetProxy o) { return Integer.compare(this.depth, o.depth); } // --- @FunctionalInterface public interface Visitor { void visit(WidgetProxy child); } public enum Lifecycle { INITIAL, LIVE, DEAD } } enum Visitors implements WidgetProxy.Visitor { UNMOUNT(WidgetProxy::unmount), REASSEMBLE(WidgetProxy::reassemble); private final WidgetProxy.Visitor delegate; Visitors(WidgetProxy.Visitor delegate) { this.delegate = delegate; } @Override public void visit(WidgetProxy child) { this.delegate.visit(child); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/proxy/WidgetState.java ================================================ package io.wispforest.owo.braid.framework.proxy; import com.google.common.base.Preconditions; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.time.Duration; public abstract class WidgetState { StatefulProxy owner; @Nullable T widget; public abstract Widget build(BuildContext context); public BuildContext context() { if (Owo.DEBUG) { Preconditions.checkNotNull(this.owner, "cannot access this.context() on a WidgetState before init() is called"); } return this.owner; } public void init() {} public void dispose() {} public void didUpdateWidget(T oldWidget) {} public void notifyDependenciesChanged() {} public final void setState(Runnable fn) { Preconditions.checkState(this.owner != null, "setState invoked on WidgetState before it was mounted"); fn.run(); this.owner.markNeedsRebuild(); } public final long scheduleDelayedCallback(Duration after, Runnable callback) { return this.owner.host().scheduleDelayedCallback(after, callback); } public final void cancelDelayedCallback(long id) { this.owner.host().cancelDelayedCallback(id); } public final void scheduleAnimationCallback(ProxyHost.AnimationCallback callback) { this.owner.host().scheduleAnimationCallback(callback); } public final void schedulePostLayoutCallback(Runnable callback) { this.owner.host().schedulePostLayoutCallback(callback); } public T widget() { Preconditions.checkNotNull(this.widget, "widget() accessor on a WidgetState was used before init()"); return this.widget; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/widget/InheritedWidget.java ================================================ package io.wispforest.owo.braid.framework.widget; import io.wispforest.owo.braid.framework.proxy.InheritedProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; public abstract class InheritedWidget extends Widget { public final Widget child; protected InheritedWidget(Widget child) { this.child = child; } @Override public WidgetProxy proxy() { return new InheritedProxy(this); } // --- public Object inheritedKey() { return this.getClass(); } public abstract boolean mustRebuildDependents(InheritedWidget newWidget); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/widget/InstanceWidget.java ================================================ package io.wispforest.owo.braid.framework.widget; import io.wispforest.owo.braid.framework.instance.WidgetInstance; public abstract class InstanceWidget extends Widget { public abstract WidgetInstance instantiate(); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/widget/Key.java ================================================ package io.wispforest.owo.braid.framework.widget; import com.google.common.base.Preconditions; import org.jetbrains.annotations.NotNull; public class Key { final String value; private Key(String value) { this.value = value; } public static Key of(@NotNull String value) { Preconditions.checkNotNull(value ,"the value of a key must never be null"); return new Key(value); } // --- @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; Key key = (Key) o; return this.value.equals(key.value); } @Override public int hashCode() { return this.value.hashCode(); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/widget/LeafInstanceWidget.java ================================================ package io.wispforest.owo.braid.framework.widget; import io.wispforest.owo.braid.framework.instance.LeafWidgetInstance; import io.wispforest.owo.braid.framework.proxy.LeafInstanceWidgetProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; public abstract class LeafInstanceWidget extends InstanceWidget { @Override public abstract LeafWidgetInstance instantiate(); @Override public WidgetProxy proxy() { return new LeafInstanceWidgetProxy(this); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/widget/MultiChildInstanceWidget.java ================================================ package io.wispforest.owo.braid.framework.widget; import io.wispforest.owo.braid.framework.instance.MultiChildWidgetInstance; import io.wispforest.owo.braid.framework.proxy.MultiChildInstanceWidgetProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; import java.util.List; public abstract class MultiChildInstanceWidget extends InstanceWidget { public final List children; protected MultiChildInstanceWidget(List children) { this.children = children; } @Override public abstract MultiChildWidgetInstance instantiate(); @Override public WidgetProxy proxy() { return new MultiChildInstanceWidgetProxy(this); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/widget/OptionalChildInstanceWidget.java ================================================ package io.wispforest.owo.braid.framework.widget; import io.wispforest.owo.braid.framework.instance.OptionalChildWidgetInstance; import io.wispforest.owo.braid.framework.proxy.OptionalChildInstanceWidgetProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; import org.jetbrains.annotations.Nullable; public abstract class OptionalChildInstanceWidget extends InstanceWidget { public final @Nullable Widget child; public OptionalChildInstanceWidget(@Nullable Widget child) { this.child = child; } @Override public abstract OptionalChildWidgetInstance instantiate(); @Override public WidgetProxy proxy() { return new OptionalChildInstanceWidgetProxy(this); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/widget/SingleChildInstanceWidget.java ================================================ package io.wispforest.owo.braid.framework.widget; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.proxy.SingleChildInstanceWidgetProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; public abstract class SingleChildInstanceWidget extends InstanceWidget { public final Widget child; protected SingleChildInstanceWidget(Widget child) { this.child = child; } @Override public abstract SingleChildWidgetInstance instantiate(); @Override public WidgetProxy proxy() { return new SingleChildInstanceWidgetProxy(this); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/widget/StatefulWidget.java ================================================ package io.wispforest.owo.braid.framework.widget; import io.wispforest.owo.braid.framework.proxy.StatefulProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; import io.wispforest.owo.braid.framework.proxy.WidgetState; public abstract class StatefulWidget extends Widget { public abstract WidgetState createState(); @Override public WidgetProxy proxy() { return new StatefulProxy(this); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/widget/StatelessWidget.java ================================================ package io.wispforest.owo.braid.framework.widget; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.StatelessProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; public abstract class StatelessWidget extends Widget { public abstract Widget build(BuildContext context); @Override public WidgetProxy proxy() { return new StatelessProxy(this); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/widget/Widget.java ================================================ package io.wispforest.owo.braid.framework.widget; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; import java.util.Objects; public abstract class Widget { private boolean mutable = true; @ApiStatus.Internal public final void freeze() { mutable = false; } protected final void assertMutable() { if (!this.mutable) throw new ImmutableWidgetError(); } // --- private @Nullable Key key; public Widget key(Key key) { this.assertMutable(); this.key = key; return this; } public @Nullable Key key() { return this.key; } // --- public abstract WidgetProxy proxy(); public static boolean canUpdate(Widget oldWidget, Widget newWidget) { return oldWidget.getClass() == newWidget.getClass() && Objects.equals(oldWidget.key, newWidget.key); } public static WidgetSetupCallback noSetup() { //noinspection unchecked return NO_SETUP; } @SuppressWarnings("rawtypes") private static final WidgetSetupCallback NO_SETUP = widget -> {}; } class ImmutableWidgetError extends Error { public ImmutableWidgetError() { // TODO: more detailed explanation of why this is bad super("A mutation on a widget was attempted after the widget was frozen"); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/framework/widget/WidgetSetupCallback.java ================================================ package io.wispforest.owo.braid.framework.widget; public interface WidgetSetupCallback { void setup(T widget); default WidgetSetupCallback compose(WidgetSetupCallback before) { return widget -> { before.setup(widget); this.setup(widget); }; } default WidgetSetupCallback andThen(WidgetSetupCallback after) { return widget -> { this.setup(widget); after.setup(widget); }; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/BraidGuiRenderer.java ================================================ package io.wispforest.owo.braid.util; import com.mojang.blaze3d.buffers.GpuBufferSlice; import com.mojang.blaze3d.pipeline.RenderTarget; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.Surface; import io.wispforest.owo.mixin.braid.GameRendererAccessor; import io.wispforest.owo.mixin.braid.GuiRendererAccessor; import io.wispforest.owo.util.pond.BraidGuiRendererExtension; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.render.GuiRenderer; import net.minecraft.client.gui.render.state.GuiRenderState; import net.minecraft.client.renderer.fog.FogRenderer; import java.util.ArrayList; public class BraidGuiRenderer extends GuiRenderer { private final Minecraft client; public BraidGuiRenderer(Minecraft client) { super( new GuiRenderState(), client.renderBuffers().bufferSource(), client.gameRenderer.getSubmitNodeStorage(), client.gameRenderer.getFeatureRenderDispatcher(), new ArrayList<>(((GuiRendererAccessor) ((GameRendererAccessor) client.gameRenderer).owo$getGuiRenderer()).owo$getPictureInPictureRenderers().values()) ); this.client = client; } public GuiGraphics newGraphics(double mouseX, double mouseY) { this.trySetFabricState(); return new GuiGraphics( this.client, ((GuiRendererAccessor) this).owo$getRenderState(), (int) mouseX, (int) mouseY ); } private boolean fabricStateSet = false; private void trySetFabricState() { if (this.fabricStateSet) { return; } try { var initField = GuiRenderer.class.getDeclaredField("hasFabricInitialized"); initField.setAccessible(true); initField.set(this, true); var commandQueueField = GuiRenderer.class.getDeclaredField("orderedRenderCommandQueue"); commandQueueField.setAccessible(true); commandQueueField.set(this, this.client.gameRenderer.getSubmitNodeStorage()); } catch (IllegalAccessException | NoSuchFieldException e) { Owo.LOGGER.warn("Failed to apply braid's Fabric API GuiRendererMixin workaround, there might be crashes with texture and window surfaces"); } finally { this.fabricStateSet = true; } } public void render(Target target) { ((BraidGuiRendererExtension) this).owo$setTarget(target); this.render(((GameRendererAccessor) this.client.gameRenderer).owo$getFogRenderer().getBuffer(FogRenderer.FogMode.NONE)); } @Override @Deprecated public void render(GpuBufferSlice fogBuffer) { super.render(fogBuffer); } public record Target(RenderTarget framebuffer, Surface surface) {} } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/BraidHudElement.java ================================================ package io.wispforest.owo.braid.util; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.AppState; import io.wispforest.owo.braid.core.EventBinding; import io.wispforest.owo.braid.core.Surface; import io.wispforest.owo.braid.framework.widget.Widget; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElement; import net.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import org.jetbrains.annotations.Nullable; public class BraidHudElement implements HudElement { public final Widget widget; private AppState app; public BraidHudElement(Widget widget) { this.widget = widget; ClientPlayConnectionEvents.JOIN.register((clientPlayNetworkHandler, packetSender, minecraftClient) -> { this.setupAppState(); }); ClientPlayConnectionEvents.DISCONNECT.register((clientPlayNetworkHandler, minecraftClient) -> { this.resetAppState(); }); } public @Nullable AppState app() { return this.app; } @Override public void render(GuiGraphics graphics, DeltaTracker deltaTracker) { if (this.app == null) { if (!Owo.DEBUG) { return; } throw new IllegalStateException("tried to render a BraidHudElement before it was initialized"); } this.app.processEvents(deltaTracker.getGameTimeDeltaTicks()); this.app.draw(graphics); } protected void setupAppState() { this.app = new AppState( null, AppState.formatName("BraidHudElement", widget), Minecraft.getInstance(), new Surface.Default(), new EventBinding.Headless(), widget ); } protected void resetAppState() { this.app.dispose(); this.app = null; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/BraidToast.java ================================================ package io.wispforest.owo.braid.util; import com.google.common.base.Preconditions; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.AppState; import io.wispforest.owo.braid.core.EventBinding; import io.wispforest.owo.braid.core.Surface; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Align; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.toasts.Toast; import net.minecraft.client.gui.components.toasts.ToastManager; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; import java.time.Duration; public class BraidToast implements Toast { private final @Nullable Duration timeout; private final Object token; private final AppState app; private EmbedderRoot.Instance rootInstance; private BraidToast(@Nullable Duration timeout, @Nullable Object token, Widget widget) { this.timeout = timeout; this.token = token != null ? token : new Object(); this.app = new AppState( Owo.LOGGER, AppState.formatName("BraidToast", widget), Minecraft.getInstance(), new Surface.Default(), new EventBinding.Headless(), new Align( Alignment.TOP_LEFT, new EmbedderRoot( instance -> this.rootInstance = instance, new BraidToastProvider( this, widget ) ) ) ); this.app.processEvents(0); } public static void show(@Nullable Duration timeout, @Nullable Object token, Widget widget) { Minecraft.getInstance().getToastManager().addToast(new BraidToast(timeout, token, widget)); } public static void hideWithToken(Object token) { var toast = Minecraft.getInstance().getToastManager().getToast(BraidToast.class, token); if (toast != null) { toast.visibility = Visibility.HIDE; } } public static void hide(BuildContext context) { var provider = context.getAncestor(BraidToastProvider.class); Preconditions.checkNotNull(provider, "BraidToast.hide can only be used from inside a BraidToast's widget tree"); provider.toast.visibility = Visibility.HIDE; } // --- @ApiStatus.Internal public void dispose() { this.app.dispose(); } @Override public void render(GuiGraphics graphics, Font font, long startTime) { this.app.draw(graphics); } @Override public int width() { return (int) this.rootInstance.transform.width(); } @Override public int height() { return (int) this.rootInstance.transform.height(); } // --- private Visibility visibility = Visibility.SHOW; @Override public void update(ToastManager manager, long time) { if (this.timeout != null && time > this.timeout.toMillis()) { this.visibility = Visibility.HIDE; } var tickCounter = Minecraft.getInstance().getDeltaTracker(); this.app.processEvents( tickCounter.getGameTimeDeltaTicks() ); } @Override public Visibility getWantedVisibility() { return this.visibility; } @Override public Object getToken() { return this.token; } } class BraidToastProvider extends InheritedWidget { public final BraidToast toast; public BraidToastProvider(BraidToast toast, Widget child) { super(child); this.toast = toast; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return false; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/BraidTooltipComponent.java ================================================ package io.wispforest.owo.braid.util; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.AppState; import io.wispforest.owo.braid.core.EventBinding; import io.wispforest.owo.braid.core.Surface; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Align; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent; import org.apache.commons.lang3.mutable.MutableObject; import java.lang.ref.Cleaner; public class BraidTooltipComponent implements ClientTooltipComponent { private final AppState app; private final EmbedderRoot.Instance instance; public BraidTooltipComponent(Widget widget) { var embedderInstance = new MutableObject(); this.app = new AppState( Owo.LOGGER, AppState.formatName("BraidTooltipComponent", widget), Minecraft.getInstance(), new Surface.Default(), new EventBinding.Headless(), new Align( Alignment.TOP_LEFT, new EmbedderRoot( embedderInstance::setValue, widget ) ) ); this.app.processEvents(0); this.instance = embedderInstance.getValue(); APP_CLEANER.register(this, new CleanCallback(this.app)); } @Override public void renderImage(Font font, int x, int y, int width, int height, GuiGraphics context) { context.push().translate(x, y); this.app.draw(context); context.pop(); } @Override public int getWidth(Font font) { return (int) this.instance.transform.width(); } @Override public int getHeight(Font font) { return (int) this.instance.transform.height(); } // --- private static final Cleaner APP_CLEANER = Cleaner.create(); private record CleanCallback(AppState app) implements Runnable { @Override public void run() { Minecraft.getInstance().schedule(this.app::dispose); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/EmbedderRoot.java ================================================ package io.wispforest.owo.braid.util; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import java.util.function.Consumer; public class EmbedderRoot extends SingleChildInstanceWidget { public final Consumer instanceListener; public EmbedderRoot(Consumer instanceListener, Widget child) { super(child); this.instanceListener = instanceListener; } @Override public SingleChildWidgetInstance instantiate() { var instance = new Instance(this); this.instanceListener.accept(instance); return instance; } public static class Instance extends SingleChildWidgetInstance.ShrinkWrap { public Instance(EmbedderRoot widget) { super(widget); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/kdl/BraidKdlEndecs.java ================================================ package io.wispforest.owo.braid.util.kdl; import com.mojang.brigadier.StringReader; import com.mojang.brigadier.exceptions.CommandSyntaxException; import io.wispforest.endec.Endec; import io.wispforest.endec.SerializationAttribute; import io.wispforest.endec.StructEndec; import io.wispforest.endec.impl.StructEndecBuilder; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.widgets.flex.CrossAxisAlignment; import io.wispforest.owo.braid.widgets.flex.MainAxisAlignment; import net.minecraft.client.Minecraft; import net.minecraft.commands.arguments.blocks.BlockStateParser; import net.minecraft.commands.arguments.item.ItemParser; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.world.item.ItemStack; import org.jetbrains.annotations.Nullable; import org.joml.Matrix3x2f; import org.joml.Vector2f; import java.util.List; import java.util.Map; import java.util.function.Consumer; public final class BraidKdlEndecs { private BraidKdlEndecs() {} public static final SerializationAttribute.WithValue>> HANDLERS = SerializationAttribute.withValue("braid_handlers"); public static final Endec ALIGNMENT = Endec.STRING.xmap( s -> switch (s) { case "top_left" -> Alignment.TOP_LEFT; case "top" -> Alignment.TOP; case "top_right" -> Alignment.TOP_RIGHT; case "left" -> Alignment.LEFT; case "center" -> Alignment.CENTER; case "right" -> Alignment.RIGHT; case "bottom_left" -> Alignment.BOTTOM_LEFT; case "bottom" -> Alignment.BOTTOM; case "bottom_right" -> Alignment.BOTTOM_RIGHT; default -> throw new IllegalStateException("invalid alignment type: " + s); }, alignment -> { throw new UnsupportedOperationException("cannot serialize arbitrary alignment into a string"); } ); public static final Endec COLOR = Endec.INT.xmap(Color::new, Color::argb); public static final Endec LAYOUT_AXIS = Endec.STRING.xmap( s -> switch (s) { case "column" -> LayoutAxis.VERTICAL; case "row" -> LayoutAxis.HORIZONTAL; default -> throw new IllegalStateException("invalid layout axis: " + s); }, layoutAxis -> switch (layoutAxis) { case VERTICAL -> "column"; case HORIZONTAL -> "row"; } ); public static final Endec CROSS_AXIS_ALIGNMENT = Endec.forEnum(CrossAxisAlignment.class, false); public static final Endec MAIN_AXIS_ALIGNMENT = Endec.forEnum(MainAxisAlignment.class, false); public static final Endec VECTOR2F = Endec.FLOAT.listOf().validate(floats -> { if (floats.size() != 2) { throw new IllegalStateException("Vector2f array must have two elements"); } }).xmap( components -> new Vector2f(components.get(0), components.get(1)), vector -> List.of(vector.x, vector.y) ); private sealed interface TransformStep { record Translate(Vector2f translation) implements TransformStep { public static final StructEndec ENDEC = StructEndecBuilder.of( VECTOR2F.fieldOf("@arguments", Translate::translation), Translate::new ); } record Scale(Vector2f scaling) implements TransformStep { public static final StructEndec ENDEC = StructEndecBuilder.of( VECTOR2F.fieldOf("@arguments", Scale::scaling), Scale::new ); } record Rotate(float angle) implements TransformStep { public static final StructEndec ENDEC = StructEndecBuilder.of( Endec.FLOAT.fieldOf("@argument", Rotate::angle), Rotate::new ); } StructEndec ENDEC = Endec.dispatchedStruct( variant -> switch (variant) { case "translate" -> Translate.ENDEC; case "scale" -> Scale.ENDEC; case "rotate" -> Rotate.ENDEC; default -> throw new IllegalStateException("invalid transform step: " + variant); }, step -> switch (step) { case Translate ignored -> "translate"; case Scale ignored -> "scale"; case Rotate ignored -> "rotate"; }, Endec.STRING, "@name" ); } public static final StructEndec TRANSFORM_MATRIX_2D = StructEndecBuilder.of( TransformStep.ENDEC.listOf().fieldOf("@children", s -> {throw new UnsupportedOperationException("cannot serialize a matrix into transform steps");}), transformSteps -> { var result = new Matrix3x2f(); for (var step : transformSteps) { switch (step) { case TransformStep.Translate(var translation) -> result.translate(translation); case TransformStep.Scale(var scaling) -> result.scale(scaling); case TransformStep.Rotate(var angle) -> result.rotate((float) Math.toRadians(angle)); } } return result; } ); public static final Endec ITEM_STACK_STRING = Endec.STRING.xmap( s -> { try { var result = new ItemParser(Minecraft.getInstance().level.registryAccess()).parse(new StringReader(s)); var stack = result.item().value().getDefaultInstance(); stack.applyComponents(result.components()); return stack; } catch (CommandSyntaxException e) { throw new IllegalStateException("invalid item stack: " + s, e); } }, stack -> { throw new UnsupportedOperationException("cannot serialize an item stack to a string"); } ); public static final Endec BLOCK_STRING = Endec.STRING.xmap( s -> { try { return BlockStateParser.parseForBlock(BuiltInRegistries.BLOCK, s, true); } catch (CommandSyntaxException e) { throw new IllegalStateException("invalid block state: " + s, e); } }, blockState -> { throw new UnsupportedOperationException("cannot serialize a block state to a string"); } ); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/kdl/KdlDeserializer.java ================================================ package io.wispforest.owo.braid.util.kdl; import dev.kdl.KdlNode; import dev.kdl.KdlValue; import io.wispforest.endec.*; import io.wispforest.endec.util.RecursiveDeserializer; import org.jspecify.annotations.Nullable; import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.function.Supplier; public class KdlDeserializer extends RecursiveDeserializer implements SelfDescribedDeserializer { public final List mappers; public KdlDeserializer(KdlNode rootNode, List mappers) { super(new KdlElement.KdlNodeElement(rootNode)); this.mappers = mappers; } @Override public void readAny(SerializationContext ctx, Serializer visitor) { this.decodeElement(ctx, visitor, this.getValue()); } private final Endec elementEndec = Endec.of( this::decodeElement, (ctx, deserializer) -> { throw new AssertionError("unreachable"); } ); private void decodeElement(SerializationContext ctx, Serializer visitor, KdlElement element) { switch (element) { case KdlElement.KdlValueElement(var value) -> { if (value.isBoolean()) { visitor.writeBoolean(ctx, (Boolean) value.value()); } else if (value.isNumber()) { visitor.writeLong(ctx, ((Number) value.value()).longValue()); } else if (value.isString()) { visitor.writeString(ctx, (String) value.value()); } else if (value.isNull()) { visitor.writeOptional(ctx, this.elementEndec, Optional.empty()); } else { throw new UnsupportedOperationException("unknown KDL value type"); } } case KdlElement.KdlNodeElement(var node) -> { try (var state = visitor.struct()) { node.properties().forEach(entry -> { state.field(entry.getKey(), ctx, this.elementEndec, new KdlElement.KdlValueElement(entry.getValue().getFirst())); }); for (var mapper : this.mappers) { if (!mapper.export().apply(node)) { continue; } state.field(mapper.key(), ctx, this.elementEndec, mapper.get().apply(node)); } } } case KdlElement.KdlElementList(var elements) -> { try (var state = visitor.sequence(ctx, this.elementEndec, elements.size())) { elements.forEach(state::element); } } } } @Override public byte readByte(SerializationContext ctx) { return this.expectPrimitive(ctx, Number.class).byteValue(); } @Override public short readShort(SerializationContext ctx) { return this.expectPrimitive(ctx, Number.class).shortValue(); } @Override public int readInt(SerializationContext ctx) { return this.expectPrimitive(ctx, Number.class).intValue(); } @Override public long readLong(SerializationContext ctx) { return this.expectPrimitive(ctx, Number.class).longValue(); } @Override public float readFloat(SerializationContext ctx) { return this.expectPrimitive(ctx, Number.class).floatValue(); } @Override public double readDouble(SerializationContext ctx) { return this.expectPrimitive(ctx, Number.class).doubleValue(); } @Override public int readVarInt(SerializationContext ctx) { return this.expectPrimitive(ctx, Number.class).intValue(); } @Override public long readVarLong(SerializationContext ctx) { return this.expectPrimitive(ctx, Number.class).longValue(); } @Override public boolean readBoolean(SerializationContext ctx) { return this.expectPrimitive(ctx, Boolean.class); } @Override public String readString(SerializationContext ctx) { return this.expectPrimitive(ctx, String.class); } @Override public byte[] readBytes(SerializationContext ctx) { throw new UnsupportedOperationException(); } @Override public Optional readOptional(SerializationContext ctx, Endec endec) { var value = this.getValue(); return !(value instanceof KdlElement.KdlValueElement(var kdlValue) && kdlValue.isNull()) ? Optional.of(endec.decode(ctx, this)) : Optional.empty(); } private K expectElement(SerializationContext ctx, Class clazz) { var value = this.getValue(); if (!(clazz.isAssignableFrom(value.getClass()))) { ctx.throwMalformedInput("Expected a " + KdlElement.KdlValueElement.class.getSimpleName() + ", found a " + value.getClass().getSimpleName()); } return (K) value; } private V expectPrimitive(SerializationContext ctx, Class clazz) { var kdlValue = expectElement(ctx, KdlElement.KdlValueElement.class).value(); if (!clazz.isAssignableFrom(kdlValue.value().getClass())) { ctx.throwMalformedInput("Expected a " + clazz.getSimpleName() + ", found a " + kdlValue.value().getClass().getSimpleName()); } //noinspection unchecked return (V) kdlValue.value(); } @Override public Deserializer.Sequence sequence(SerializationContext ctx, Endec elementEndec) { return new Sequence<>(ctx, elementEndec, expectElement(ctx, KdlElement.KdlElementList.class).elements()); } @Override public Map map(SerializationContext ctx, Endec valueEndec) { throw new UnsupportedOperationException(); } @Override public Deserializer.Struct struct(SerializationContext ctx) { return new Struct(expectElement(ctx, KdlElement.KdlNodeElement.class).node()); } private class Struct implements Deserializer.Struct { public final KdlNode node; private Struct(KdlNode node) { this.node = node; } @Override public @Nullable F field(String name, SerializationContext ctx, Endec endec, @org.jetbrains.annotations.Nullable Supplier defaultValueFactory) { var element = this.tryMap(name); if (element == null && this.node.properties().hasProperty(name)) { element = new KdlElement.KdlValueElement(node.properties().getValue(name).get()); } if (element == null) { if (defaultValueFactory != null) { return defaultValueFactory.get(); } throw new IllegalStateException("Required property " + name + " is missing from serialized data"); } var javaMoment = element; return KdlDeserializer.this.frame( () -> javaMoment, () -> endec.decode(ctx, KdlDeserializer.this) ); } private @Nullable KdlElement tryMap(String key) { if (key.startsWith(".")) { var maybeChild = this.node.children().stream().filter(node -> node.name().equals(key)).findFirst(); return maybeChild.map(KdlElement.KdlNodeElement::new).orElse(null); } var mapper = KdlDeserializer.this.mappers.stream().filter(element -> Objects.equals(element.key(), key)).findFirst().orElse(null); if (mapper == null) { return null; } return mapper.get().apply(this.node); } } private class Sequence implements Deserializer.Sequence { public final SerializationContext ctx; public final Endec elementEndec; public final Iterator iterator; public final int size; private Sequence(SerializationContext ctx, Endec elementEndec, List elements) { this.ctx = ctx; this.elementEndec = elementEndec; this.iterator = elements.iterator(); this.size = elements.size(); } @Override public int estimatedSize() { return this.size; } @Override public boolean hasNext() { return this.iterator.hasNext(); } @Override public V next() { var value = this.iterator.next(); return KdlDeserializer.this.frame( () -> value, () -> this.elementEndec.decode(this.ctx, KdlDeserializer.this) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/kdl/KdlElement.java ================================================ package io.wispforest.owo.braid.util.kdl; import dev.kdl.KdlNode; import dev.kdl.KdlValue; import java.util.List; public sealed interface KdlElement { record KdlElementList(List elements) implements KdlElement {} record KdlNodeElement(KdlNode node) implements KdlElement {} record KdlValueElement(KdlValue value) implements KdlElement {} } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/kdl/KdlEntityWidget.java ================================================ package io.wispforest.owo.braid.util.kdl; import com.mojang.brigadier.StringReader; import com.mojang.brigadier.exceptions.CommandSyntaxException; import io.wispforest.endec.Endec; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.AppState; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.object.EntityWidget; import net.minecraft.IdentifierException; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.TagParser; import net.minecraft.resources.Identifier; import net.minecraft.util.ProblemReporter; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntitySpawnReason; import net.minecraft.world.entity.EntityType; import net.minecraft.world.level.storage.TagValueInput; import org.jetbrains.annotations.Nullable; import java.util.NoSuchElementException; public class KdlEntityWidget extends StatefulWidget { public final double scale; public final EntitySpec spec; public final EntityWidget.DisplayMode mode; public final boolean scaleToFit; public final boolean showNametag; public KdlEntityWidget(double scale, EntitySpec spec, EntityWidget.DisplayMode mode, boolean scaleToFit, boolean showNametag) { this.scale = scale; this.spec = spec; this.mode = mode; this.scaleToFit = scaleToFit; this.showNametag = showNametag; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private Entity entity; @Override public void init() { this.recreateEntity(); } @Override public void didUpdateWidget(KdlEntityWidget oldWidget) { if (!this.widget().spec.equals(oldWidget.spec)) { this.recreateEntity(); } } private void recreateEntity() { var level = AppState.of(this.context()).client().level; var entity = this.widget().spec.type.create(level, EntitySpawnReason.LOAD); if (this.widget().spec.nbt != null) { entity.load(TagValueInput.create(new ProblemReporter.ScopedCollector(Owo.LOGGER), level.registryAccess(), this.widget().spec.nbt)); } this.setState(() -> { this.entity = entity; }); } @Override public Widget build(BuildContext context) { return new EntityWidget( this.widget().scale, this.entity, widget -> widget .displayMode(this.widget().mode) .scaleToFit(this.widget().scaleToFit) .showNametag(this.widget().showNametag) ); } } public record EntitySpec(EntityType type, @Nullable CompoundTag nbt) { public static final Endec STRING_ENDEC = Endec.STRING.xmap( s -> { try { CompoundTag nbt = null; int nbtIndex = s.indexOf('{'); if (nbtIndex != -1) { nbt = TagParser.parseCompoundAsArgument(new StringReader(s.substring(nbtIndex))); s = s.substring(0, nbtIndex); } var entityType = BuiltInRegistries.ENTITY_TYPE.getOptional(Identifier.parse(s)).orElseThrow(); return new EntitySpec(entityType, nbt); } catch (CommandSyntaxException | NoSuchElementException | IdentifierException e) { throw new IllegalStateException("invalid entity: " + s, e); } }, spec -> { throw new UnsupportedOperationException("cannot serialize an entity spec to a string"); } ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/kdl/KdlMapper.java ================================================ package io.wispforest.owo.braid.util.kdl; import dev.kdl.KdlNode; import dev.kdl.KdlString; import java.util.List; import java.util.function.BiFunction; import java.util.function.Function; public record KdlMapper(Function export, String key, Function get, BiFunction set) { public static final List DEFAULT_MAPPERS = List.of( new KdlMapper( kdlNode -> true, "@name", node -> new KdlElement.KdlValueElement(new KdlString(node.name())), (node, element) -> node.mutate().name((String) ((KdlElement.KdlValueElement) element).value().value()).build() ), new KdlMapper( kdlNode -> kdlNode.arguments().size() == 1, "@argument", node -> !node.arguments().isEmpty() ? new KdlElement.KdlValueElement(node.arguments().getFirst()) : null, (node, element) -> node.mutate().argument(((KdlElement.KdlValueElement) element).value()).build() ), new KdlMapper( kdlNode -> kdlNode.arguments().size() > 1, "@arguments", node -> new KdlElement.KdlElementList(node.arguments().stream().map(KdlElement.KdlValueElement::new).toList()), (node, element) -> { var builder = node.mutate(); ((KdlElement.KdlElementList) element).elements() .stream() .map(kdlElement -> ((KdlElement.KdlValueElement) kdlElement).value()) .forEach(builder::argument); return builder.build(); } ), new KdlMapper( kdlNode -> kdlNode.children().size() == 1, "@child", node -> { var candidates = node.children().stream().filter(kdlNode -> !kdlNode.name().startsWith(".")).toList(); return !candidates.isEmpty() ? new KdlElement.KdlNodeElement(candidates.getFirst()) : null; }, (node, element) -> node.mutate().child(((KdlElement.KdlNodeElement) element).node()).build() ), new KdlMapper( kdlNode -> kdlNode.children().size() > 1, "@children", node -> new KdlElement.KdlElementList( node.children().stream() .filter(kdlNode -> !kdlNode.name().startsWith(".")) .map(KdlElement.KdlNodeElement::new) .toList() ), (node, element) -> { var builder = node.mutate(); ((KdlElement.KdlElementList) element).elements().stream() .map(kdlElement -> ((KdlElement.KdlNodeElement) kdlElement).node()) .forEach(builder::child); return builder.build(); } ) ); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/kdl/WidgetEndec.java ================================================ package io.wispforest.owo.braid.util.kdl; import io.wispforest.endec.Endec; import io.wispforest.endec.SelfDescribedDeserializer; import io.wispforest.endec.StructEndec; import io.wispforest.endec.format.java.JavaSerializer; import io.wispforest.endec.impl.StructEndecBuilder; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.SpriteWidget; import io.wispforest.owo.braid.widgets.basic.*; import io.wispforest.owo.braid.widgets.button.Button; import io.wispforest.owo.braid.widgets.button.MessageButton; import io.wispforest.owo.braid.widgets.flex.CrossAxisAlignment; import io.wispforest.owo.braid.widgets.flex.Flex; import io.wispforest.owo.braid.widgets.flex.Flexible; import io.wispforest.owo.braid.widgets.flex.MainAxisAlignment; import io.wispforest.owo.braid.widgets.grid.Grid; import io.wispforest.owo.braid.widgets.label.Label; import io.wispforest.owo.braid.widgets.label.LabelStyle; import io.wispforest.owo.braid.widgets.object.BlockWidget; import io.wispforest.owo.braid.widgets.object.EntityWidget; import io.wispforest.owo.braid.widgets.object.ItemStackWidget; import io.wispforest.owo.braid.widgets.stack.Stack; import io.wispforest.owo.braid.widgets.stack.StackBase; import io.wispforest.owo.serialization.endec.MinecraftEndecs; import net.minecraft.client.resources.model.Material; import net.minecraft.commands.arguments.blocks.BlockStateParser; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.contents.TranslatableContents; import net.minecraft.resources.Identifier; import net.minecraft.world.item.ItemDisplayContext; import org.jetbrains.annotations.ApiStatus; import java.util.HashMap; import java.util.Map; import java.util.Optional; public class WidgetEndec { private static final Map> REGISTRY = new HashMap<>(); private static final Map, String> WIDGET_TYPE_NAMES = new HashMap<>(); public static final Endec ROOT = Endec.dispatchedStruct( variant -> { var endec = REGISTRY.get(variant); if (endec == null) { throw new IllegalStateException("Unknown widget type: " + variant); } return endec; }, widget -> WIDGET_TYPE_NAMES.get(widget.getClass()), Endec.STRING, "@name" ); public static void register(Identifier key, Class widgetClass, StructEndec endec) { register(key.toLanguageKey(), widgetClass, endec); } @ApiStatus.Internal public static void register(String key, Class widgetClass, StructEndec endec) { if (REGISTRY.containsKey(key)) { throw new IllegalArgumentException("Duplicate widget endec key: " + key); } REGISTRY.put(key, endec); WIDGET_TYPE_NAMES.put(widgetClass, key); } // --- static { register( "align", Align.class, StructEndecBuilder.of( BraidKdlEndecs.ALIGNMENT.fieldOf("@argument", s -> s.alignment), Endec.DOUBLE.nullableOf().optionalFieldOf("width_factor", s -> s.widthFactor.isPresent() ? s.widthFactor.getAsDouble() : null, (Double) null), Endec.DOUBLE.nullableOf().optionalFieldOf("height_factor", s -> s.heightFactor.isPresent() ? s.heightFactor.getAsDouble() : null, (Double) null), ROOT.fieldOf("@child", s -> s.child), Align::new ) ); register( "center", Center.class, StructEndecBuilder.of( Endec.DOUBLE.nullableOf().optionalFieldOf("width_factor", s -> s.widthFactor.isPresent() ? s.widthFactor.getAsDouble() : null, (Double) null), Endec.DOUBLE.nullableOf().optionalFieldOf("height_factor", s -> s.heightFactor.isPresent() ? s.heightFactor.getAsDouble() : null, (Double) null), ROOT.fieldOf("@child", s -> s.child), Center::new ) ); register( "padding", Padding.class, StructEndecBuilder.of( Endec.DOUBLE.optionalOf().optionalFieldOf("@argument", s -> Optional.empty(), Optional::empty), Endec.DOUBLE.optionalOf().optionalFieldOf("horizontal", s -> Optional.empty(), Optional::empty), Endec.DOUBLE.optionalOf().optionalFieldOf("vertical", s -> Optional.empty(), Optional::empty), Endec.DOUBLE.optionalOf().optionalFieldOf("top", s -> Optional.of(s.insets.top()), Optional::empty), Endec.DOUBLE.optionalOf().optionalFieldOf("bottom", s -> Optional.of(s.insets.bottom()), Optional::empty), Endec.DOUBLE.optionalOf().optionalFieldOf("left", s -> Optional.of(s.insets.left()), Optional::empty), Endec.DOUBLE.optionalOf().optionalFieldOf("right", s -> Optional.of(s.insets.right()), Optional::empty), ROOT.optionalOf().optionalFieldOf("@child", s -> Optional.ofNullable(s.child), Optional.empty()), (all, horizontal, vertical, top, bottom, left, right, child) -> { var dTop = top.orElse(vertical.orElse(all.orElse(0.0))); var dBottom = bottom.orElse(vertical.orElse(all.orElse(0.0))); var dLeft = left.orElse(horizontal.orElse(all.orElse(0.0))); var dRight = right.orElse(horizontal.orElse(all.orElse(0.0))); return new Padding( Insets.of(dTop, dBottom, dLeft, dRight), child.orElse(null) ); } ) ); register( "sized", Sized.class, StructEndecBuilder.of( Endec.DOUBLE.nullableOf().optionalFieldOf("width", s -> s.width, (Double) null), Endec.DOUBLE.nullableOf().optionalFieldOf("height", s -> s.height, (Double) null), ROOT.fieldOf("@child", s -> s.child), Sized::new ) ); //noinspection unchecked register( "flex", Flex.class, StructEndecBuilder.of( BraidKdlEndecs.LAYOUT_AXIS.fieldOf("@argument", s -> s.mainAxis), BraidKdlEndecs.MAIN_AXIS_ALIGNMENT.optionalFieldOf("main_axis_alignment", s -> s.mainAxisAlignment, MainAxisAlignment.START), BraidKdlEndecs.CROSS_AXIS_ALIGNMENT.optionalFieldOf("cross_axis_alignment", s -> s.crossAxisAlignment, CrossAxisAlignment.START), ROOT.listOf().fieldOf("@children", s -> (java.util.List) s.children), (mainAxis, mainAxisAlignment, crossAxisAlignment, children) -> new Flex(mainAxis, mainAxisAlignment, crossAxisAlignment, null, children) ) ); register( "flexible", Flexible.class, StructEndecBuilder.of( Endec.DOUBLE.optionalFieldOf("flex_factor", s -> s.flexFactor, 1.0), ROOT.fieldOf("@child", s -> s.child), Flexible::new ) ); //noinspection unchecked register( "stack", Stack.class, StructEndecBuilder.of( BraidKdlEndecs.ALIGNMENT.optionalFieldOf("alignment", s -> s.alignment, Alignment.TOP_LEFT), ROOT.listOf().fieldOf("@children", s -> (java.util.List) s.children), Stack::new ) ); register( "stack_base", StackBase.class, StructEndecBuilder.of( ROOT.fieldOf("@child", s -> s.child), StackBase::new ) ); var tightCellFitEndec = Endec.unit(Grid.CellFit.tight()); var looseCellFitEndec = StructEndecBuilder.of( BraidKdlEndecs.ALIGNMENT.fieldOf("@argument", s -> ((Grid.CellFit.Loose)s).alignment), Grid.CellFit::loose ); var cellFitEndec = Endec.dispatchedStruct( s -> switch (s) { case "tight" -> tightCellFitEndec; case "loose" -> looseCellFitEndec; default -> throw new IllegalStateException("invalid cell fit: " + s); }, cellFit -> switch (cellFit) { case Grid.CellFit.Tight ignored -> "tight"; case Grid.CellFit.Loose ignored -> "loose"; }, Endec.STRING, "@name" ); //noinspection unchecked register( "grid", Grid.class, StructEndecBuilder.of( BraidKdlEndecs.LAYOUT_AXIS.fieldOf("@argument", s -> s.mainAxis), Endec.INT.fieldOf("cross_axis_cells", s -> s.crossAxisCells), StructEndecBuilder.of(cellFitEndec.fieldOf("@child", s -> s), cellFit -> cellFit).fieldOf(".fit", s -> s.cellFit), ROOT.listOf().fieldOf("@children", s -> (java.util.List) s.children), Grid::new ) ); var labelStyleEndec = StructEndecBuilder.of( BraidKdlEndecs.ALIGNMENT.nullableOf().optionalFieldOf("text_alignment", LabelStyle::textAlignment, (Alignment) null), BraidKdlEndecs.COLOR.nullableOf().optionalFieldOf("base_color", LabelStyle::baseColor, (Color) null), Endec.BOOLEAN.nullableOf().optionalFieldOf("shadow", LabelStyle::shadow, (Boolean) null), (alignment, color, shadow) -> new LabelStyle(alignment, color, null, shadow) ); register( "label", Label.class, StructEndecBuilder.of( labelStyleEndec.nullableOf().optionalFieldOf(".style", s -> s.style, (LabelStyle) null), Endec.BOOLEAN.optionalFieldOf("soft_wrap", s -> s.softWrap, true), Endec.forEnum(Label.Overflow.class, false).optionalFieldOf("overflow", s -> s.overflow, Label.Overflow.CLIP), Endec.BOOLEAN.optionalFieldOf("translate", s -> s.text.getContents() instanceof TranslatableContents, false), Endec.STRING.fieldOf("@argument", s -> s.text.getString()), (style, softWrap, overflow, translate, text) -> new Label( style, softWrap, overflow, translate ? Component.translatable(text) : Component.literal(text) ) ) ); register( "texture", TextureWidget.class, StructEndecBuilder.of( MinecraftEndecs.IDENTIFIER.fieldOf("@argument", s -> s.texture), Endec.forEnum(TextureWidget.Wrap.class, false).optionalFieldOf("wrap", s -> s.wrap, TextureWidget.Wrap.STRETCH), Endec.forEnum(TextureWidget.Filter.class, false).optionalFieldOf("filter", s -> s.filter, TextureWidget.Filter.TEXTURE_DEFAULT), BraidKdlEndecs.COLOR.optionalFieldOf("color", s -> s.color, Color.WHITE), TextureWidget::new ) ); register( "sprite", SpriteWidget.class, StructEndecBuilder.of( MinecraftEndecs.IDENTIFIER.fieldOf("@argument", s -> s.spriteIdentifier.texture()), MinecraftEndecs.IDENTIFIER.optionalFieldOf("atlas", s -> s.spriteIdentifier.atlasLocation(), SpriteWidget.GUI_ATLAS_ID), (id, atlas) -> new SpriteWidget(new Material(atlas, id)) ) ); register( "box", Box.class, StructEndecBuilder.of( BraidKdlEndecs.COLOR.fieldOf("@argument", s -> s.color), Endec.BOOLEAN.optionalFieldOf("outline", s -> s.outline, false), ROOT.nullableOf().optionalFieldOf("@child", s -> s.child, (Widget) null), Box::new ) ); register( "transform", Transform.class, StructEndecBuilder.of( BraidKdlEndecs.TRANSFORM_MATRIX_2D.fieldOf(".steps", s -> s.matrix), ROOT.fieldOf("@child", s -> s.child), Transform::new ) ); register( "item", ItemStackWidget.class, StructEndecBuilder.of( Endec.forEnum(ItemDisplayContext.class, false).optionalFieldOf("display_context", ItemStackWidget::displayContext, ItemDisplayContext.GUI), Endec.BOOLEAN.optionalFieldOf("show_overlay", ItemStackWidget::showOverlay, true), Endec.forEnum(ItemStackWidget.LightOverride.class, false).nullableOf().optionalFieldOf("light_override", ItemStackWidget::lightOverride, (ItemStackWidget.LightOverride) null), BraidKdlEndecs.ITEM_STACK_STRING.fieldOf("@argument", s -> s.stack), (displayContext, showOverlay, lightOverride, stack) -> new ItemStackWidget( stack, widget -> widget .displayContext(displayContext) .showOverlay(showOverlay) .lightOverride(lightOverride) ) ) ); register( "block", BlockWidget.class, StructEndecBuilder.of( BraidKdlEndecs.BLOCK_STRING.fieldOf("@argument", s -> new BlockStateParser.BlockResult(s.blockState, s.blockState.getValues(), s.blockEntityNbt)), blockResult -> new BlockWidget(blockResult.blockState(), blockResult.nbt()) ) ); register( "entity", KdlEntityWidget.class, StructEndecBuilder.of( Endec.DOUBLE.optionalFieldOf("scale", s -> s.scale, 1.0), KdlEntityWidget.EntitySpec.STRING_ENDEC.fieldOf("@argument", s -> s.spec), Endec.forEnum(EntityWidget.DisplayMode.class, false).optionalFieldOf("mode", s -> s.mode, EntityWidget.DisplayMode.FIXED), Endec.BOOLEAN.optionalFieldOf("scale_to_fit", s -> s.scaleToFit, true), Endec.BOOLEAN.optionalFieldOf("show_nametag", s -> s.showNametag, false), KdlEntityWidget::new ) ); var handlerEndec = Endec.STRING .optionalOf() .xmapWithContext( (ctx, maybeHandlerId) -> maybeHandlerId.flatMap(handlerId -> { var handler = ctx.getAttributeValue(BraidKdlEndecs.HANDLERS).get(handlerId); if (handler == null) { throw new UnsupportedOperationException("missing handler with id: " + handlerId); } return Optional.of(handler); }), (context, o) -> { throw new UnsupportedOperationException("cannot serialize a braid kdl handler"); } ); var handlerArgEndec = Endec.of( (ctx, serializer, o) -> { throw new UnsupportedOperationException("cannot serialize a braid kdl handler argument"); }, (ctx, deserializer) -> { if (!(deserializer instanceof SelfDescribedDeserializer selfDescribedDeserializer)) { throw new UnsupportedOperationException("can only deserialize braid kdl handler arguments from self-described input"); } var visitor = JavaSerializer.of(); selfDescribedDeserializer.readAny(ctx, visitor); return visitor.result(); } ); register( "message_button", MessageButton.class, StructEndecBuilder.of( Endec.STRING.fieldOf("message", s -> s.text.getString()), Endec.BOOLEAN.optionalFieldOf("translate_message", s -> s.text.getContents() instanceof TranslatableContents, () -> false), handlerEndec.fieldOf("handler", s -> { throw new UnsupportedOperationException("cannot serialize a button callback"); }), handlerArgEndec.nullableOf().optionalFieldOf("handler_arg", s -> { throw new UnsupportedOperationException("cannot serialize a button's callback argument"); }, (Object) null), (message, translateMessage, handler, handlerArg) -> new MessageButton( translateMessage ? Component.translatable(message) : Component.literal(message), handler.flatMap(o -> { return Optional.of(() -> o.accept(handlerArg)); }).orElse(null) ) ) ); register( "button", Button.class, StructEndecBuilder.of( handlerEndec.fieldOf("handler", s -> { throw new UnsupportedOperationException("cannot serialize a button callback"); }), handlerArgEndec.nullableOf().optionalFieldOf("handler_arg", s -> { throw new UnsupportedOperationException("cannot serialize a button's callback argument"); }, (Object) null), ROOT.fieldOf("@child", s -> s.child), (handler, handlerArg, child) -> { return new Button( handler.flatMap(o -> { return Optional.of(() -> o.accept(handlerArg)); }).orElse(null), child ); } ) ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/layers/AnchorJustification.java ================================================ package io.wispforest.owo.braid.util.layers; public record AnchorJustification(double anchorX, double anchorY, double widgetX, double widgetY) { public static final AnchorJustification TOP_LEFT_TO_TOP_LEFT = new AnchorJustification(0, 0, 0, 0); public static final AnchorJustification TOP_LEFT_TO_BOTTOM_LEFT = new AnchorJustification(0, 1, 0, 0); public static final AnchorJustification TOP_LEFT_TO_TOP_RIGHT = new AnchorJustification(1, 0, 0, 0); public static final AnchorJustification TOP_LEFT_TO_BOTTOM_RIGHT = new AnchorJustification(1, 1, 0, 0); public static final AnchorJustification BOTTOM_LEFT_TOP_LEFT = new AnchorJustification(0, 0, 0, 1); public static final AnchorJustification BOTTOM_LEFT_BOTTOM_LEFT = new AnchorJustification(0, 1, 0, 1); public static final AnchorJustification BOTTOM_LEFT_TOP_RIGHT = new AnchorJustification(1, 0, 0, 1); public static final AnchorJustification BOTTOM_LEFT_BOTTOM_RIGHT = new AnchorJustification(1, 1, 0, 1); public static final AnchorJustification TOP_RIGHT_TO_TOP_LEFT = new AnchorJustification(0, 0, 1, 0); public static final AnchorJustification TOP_RIGHT_TO_BOTTOM_LEFT = new AnchorJustification(0, 1, 1, 0); public static final AnchorJustification TOP_RIGHT_TO_TOP_RIGHT = new AnchorJustification(1, 0, 1, 0); public static final AnchorJustification TOP_RIGHT_TO_BOTTOM_RIGHT = new AnchorJustification(1, 1, 1, 0); public static final AnchorJustification BOTTOM_RIGHT_TO_TOP_LEFT = new AnchorJustification(0, 0, 1, 1); public static final AnchorJustification BOTTOM_RIGHT_TO_BOTTOM_LEFT = new AnchorJustification(0, 1, 1, 1); public static final AnchorJustification BOTTOM_RIGHT_TO_TOP_RIGHT = new AnchorJustification(1, 0, 1, 1); public static final AnchorJustification BOTTOM_RIGHT_TO_BOTTOM_RIGHT = new AnchorJustification(1, 1, 1, 1); public static final AnchorJustification CENTER_TO_TOP_LEFT = new AnchorJustification(0, 0, .5, .5); public static final AnchorJustification CENTER_TO_BOTTOM_LEFT = new AnchorJustification(0, 1, .5, .5); public static final AnchorJustification CENTER_TO_TOP_RIGHT = new AnchorJustification(1, 0, .5, .5); public static final AnchorJustification CENTER_TO_BOTTOM_RIGHT = new AnchorJustification(1, 1, .5, .5); public static final AnchorJustification TOP_LEFT_TO_CENTER = new AnchorJustification(.5, .5, 0, 0); public static final AnchorJustification BOTTOM_LEFT_TO_CENTER = new AnchorJustification(.5, .5, 0, 1); public static final AnchorJustification TOP_RIGHT_TO_CENTER = new AnchorJustification(.5, .5, 1, 0); public static final AnchorJustification BOTTOM_RIGHT_TO_CENTER = new AnchorJustification(.5, .5, 1, 1); public static final AnchorJustification CENTER_TO_CENTER = new AnchorJustification(.5, .5, .5, .5); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/layers/BraidLayersBinding.java ================================================ package io.wispforest.owo.braid.util.layers; import com.google.common.base.Suppliers; import com.mojang.blaze3d.platform.cursor.CursorType; import com.mojang.blaze3d.platform.cursor.CursorTypes; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.AppState; import io.wispforest.owo.braid.core.EventBinding; import io.wispforest.owo.braid.core.Surface; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.core.events.*; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.eventstream.BraidEventStream; import io.wispforest.owo.braid.widgets.overlay.Overlay; import io.wispforest.owo.braid.widgets.stack.Stack; import io.wispforest.owo.util.pond.OwoScreenExtension; import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; import net.fabricmc.fabric.api.client.screen.v1.ScreenKeyboardEvents; import net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents; import net.fabricmc.fabric.api.event.Event; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.Screen; import net.minecraft.resources.Identifier; import net.minecraft.util.Unit; import org.jetbrains.annotations.ApiStatus; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Predicate; import java.util.function.Supplier; public class BraidLayersBinding { public static void add(Predicate screenPredicate, Widget widget) { LAYERS.add(new Layer(screenPredicate, widget)); } // --- @ApiStatus.Internal public static boolean tryHandleEvent(Screen screen, UserEvent event) { var app = ((OwoScreenExtension) screen).owo$getBraidLayersApp(); if (app == null) { return false; } var slot = app.eventBinding.add(event); app.processEvents(0); return slot.handled(); } @ApiStatus.Internal public static void renderLayers(Screen screen, GuiGraphics graphics, double mouseX, double mouseY) { var state = ((OwoScreenExtension) screen).owo$getBraidLayersState(); if (state == null) { return; } state.refreshEvents.sink().onEvent(Unit.INSTANCE); state.app.eventBinding.add(new MouseMoveEvent(mouseX, mouseY)); state.app.processEvents(Minecraft.getInstance().getDeltaTracker().getGameTimeDeltaTicks()); state.app.draw(graphics); var cursorStyle = ((LayerSurface) state.app.surface).currentCursorStyle; if (cursorStyle != CursorStyle.NONE && CURSOR_MAPPINGS.get().containsKey(cursorStyle)) { graphics.requestCursor(CURSOR_MAPPINGS.get().get(cursorStyle)); } } private static void setupLayers(Screen screen) { var widgets = LAYERS.stream().filter(layer -> layer.screenPredicate.test(screen)).map(Layer::widget).toList(); if (widgets.isEmpty()) { return; } var refreshEvents = new BraidEventStream(); var app = new AppState( null, "BraidLayersBinding", Minecraft.getInstance(), new LayerSurface(), new EventBinding.Default(), new LayerContext( refreshEvents.source(), screen, new Overlay( new Stack(widgets) ) ) ); ((OwoScreenExtension) screen).owo$setBraidLayersState(new LayersState(app, refreshEvents)); } // --- public static final Identifier INIT_PHASE = Owo.id("init-braid-layers"); private static final List LAYERS = new ArrayList<>(); private record Layer(Predicate screenPredicate, Widget widget) {} @ApiStatus.Internal public record LayersState(AppState app, BraidEventStream refreshEvents) {} private static class LayerSurface extends Surface.Default { public CursorStyle currentCursorStyle = CursorStyle.NONE; @Override public void setCursorStyle(CursorStyle style) { this.currentCursorStyle = style; } @Override public CursorStyle currentCursorStyle() { return this.currentCursorStyle; } } private static final Supplier> CURSOR_MAPPINGS = Suppliers.memoize(() -> Map.of( CursorStyle.POINTER, CursorTypes.ARROW, CursorStyle.TEXT, CursorTypes.IBEAM, CursorStyle.CROSSHAIR, CursorTypes.CROSSHAIR, CursorStyle.HAND, CursorTypes.POINTING_HAND, CursorStyle.VERTICAL_RESIZE, CursorTypes.RESIZE_NS, CursorStyle.HORIZONTAL_RESIZE, CursorTypes.RESIZE_EW, CursorStyle.MOVE, CursorTypes.RESIZE_ALL, CursorStyle.NOT_ALLOWED, CursorTypes.NOT_ALLOWED )); // --- static { ScreenEvents.AFTER_INIT.addPhaseOrdering(Event.DEFAULT_PHASE, INIT_PHASE); ScreenEvents.AFTER_INIT.register(INIT_PHASE, (client, screeen, scaledWidth, scaledHeight) -> { if (((OwoScreenExtension)screeen).owo$getBraidLayersState() == null) { setupLayers(screeen); } ScreenEvents.remove(screeen).register(screen -> { var app = ((OwoScreenExtension) screen).owo$getBraidLayersApp(); if (app != null) { app.dispose(); } }); ScreenMouseEvents.allowMouseClick(screeen).register((screen, click) -> { return !tryHandleEvent(screen, new MouseButtonPressEvent(click.button(), click.modifiers())); }); ScreenMouseEvents.allowMouseRelease(screeen).register((screen, click) -> { return !tryHandleEvent(screen, new MouseButtonReleaseEvent(click.button(), click.modifiers())); }); ScreenMouseEvents.allowMouseScroll(screeen).register((screen, mouseX, mouseY, horizontalAmount, verticalAmount) -> { return !tryHandleEvent(screen, new MouseScrollEvent(horizontalAmount, verticalAmount)); }); ScreenKeyboardEvents.allowKeyPress(screeen).register((screen, keyInput) -> { return !tryHandleEvent(screen, new KeyPressEvent(keyInput.key(), keyInput.scancode(), keyInput.modifiers())); }); ScreenKeyboardEvents.allowKeyRelease(screeen).register((screen, keyInput) -> { return !tryHandleEvent(screen, new KeyReleaseEvent(keyInput.key(), keyInput.scancode(), keyInput.modifiers())); }); }); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/layers/Justify.java ================================================ package io.wispforest.owo.braid.util.layers; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; public class Justify extends SingleChildInstanceWidget { public final double x; public final double y; public Justify(double x, double y, Widget child) { super(child); this.x = x; this.y = y; } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance.ShrinkWrap { public Instance(Justify widget) { super(widget); } @Override public void setWidget(Justify widget) { super.setWidget(widget); this.justify(); } @Override protected void doLayout(Constraints constraints) { super.doLayout(constraints); this.justify(); } private void justify() { this.child.transform.setX(-this.child.transform.width() * this.widget.x); this.child.transform.setY(-this.child.transform.height() * this.widget.y); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/layers/LayerAlignment.java ================================================ package io.wispforest.owo.braid.util.layers; import io.wispforest.owo.braid.core.RelativePosition; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.EmptyWidget; import io.wispforest.owo.braid.widgets.overlay.Overlay; import io.wispforest.owo.braid.widgets.overlay.OverlayEntry; import io.wispforest.owo.braid.widgets.overlay.OverlayEntryBuilder; import net.minecraft.client.gui.components.AbstractWidget; import org.jetbrains.annotations.Nullable; import org.joml.Vector2d; import java.util.Objects; import java.util.function.Function; import java.util.function.Predicate; // TODO: // - consider whether we should provide means to render the same // widget multiple times if the predicate finds more than one match // - perhaps we should also make the miss behavior customizable - hide // the widget when no match is found, show it when one is? keep it around // once we've found a match at least once? public class LayerAlignment extends StatefulWidget { public final Function offsetGetter; public final @Nullable AnchorJustification justification; public final Widget widget; private LayerAlignment(Function offsetGetter, @Nullable AnchorJustification justification, Widget widget) { this.offsetGetter = offsetGetter; this.justification = justification; this.widget = widget; } public static LayerAlignment atVanillaWidget(Predicate anchorPredicate, Widget widget) { return atVanillaWidget(anchorPredicate, AnchorJustification.TOP_LEFT_TO_TOP_LEFT, widget); } public static LayerAlignment atVanillaWidget(Predicate anchorPredicate, AnchorJustification justification, Widget widget) { return new LayerAlignment( context -> { var anchor = LayerContext.findWidget(context, anchorPredicate); if (anchor == null) return null; return new Vector2d( anchor.getX() + justification.anchorX() * anchor.getWidth(), anchor.getY() + justification.anchorY() * anchor.getHeight() ); }, justification, widget ); } public static LayerAlignment atContainerScreenCoordinates(double xOffset, double yOffset, Widget widget) { return new LayerAlignment( context -> { var root = LayerContext.containerScreenRootOf(context); if (root == null) return null; return root.add(xOffset, yOffset); }, null, widget ); } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private OverlayEntry entry; private boolean widgetChanged = false; @Override public void init() { this.scheduleOverlayUpdate(); } @Override public void didUpdateWidget(LayerAlignment oldWidget) { this.widgetChanged = oldWidget.widget != this.widget().widget || !Objects.equals(oldWidget.justification, this.widget().justification); } @Override public void notifyDependenciesChanged() { this.scheduleOverlayUpdate(); } private void scheduleOverlayUpdate() { this.schedulePostLayoutCallback(() -> { var offset = this.widget().offsetGetter.apply(this.context()); if (offset == null) return; if (this.entry == null) { this.entry = Overlay.of(this.context()).add( new OverlayEntryBuilder( this.prepareWidget(), new RelativePosition(this.context(), offset.x, offset.y) ) ); } else if (this.widgetChanged || this.entry.x != offset.x || this.entry.y != offset.y) { this.widgetChanged = false; this.entry.setState(() -> { this.entry.widget = this.prepareWidget(); this.entry.x = offset.x; this.entry.y = offset.y; }); } }); } private Widget prepareWidget() { var justification = this.widget().justification; return justification != null ? new Justify(justification.widgetX(), justification.widgetY(), this.widget().widget) : this.widget().widget; } @Override public Widget build(BuildContext context) { return EmptyWidget.INSTANCE; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/util/layers/LayerContext.java ================================================ package io.wispforest.owo.braid.util.layers; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.eventstream.BraidEventSource; import io.wispforest.owo.braid.widgets.eventstream.StreamListenerState; import io.wispforest.owo.mixin.ui.layers.AbstractContainerScreenAccessor; import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.gui.layouts.Layout; import net.minecraft.client.gui.screens.Screen; import net.minecraft.util.Unit; import org.jetbrains.annotations.Nullable; import org.joml.Vector2d; import java.util.ArrayList; import java.util.List; import java.util.function.Predicate; public class LayerContext extends StatefulWidget { public final BraidEventSource refreshEvents; public final Screen contextScreen; public final Widget child; public LayerContext(BraidEventSource refreshEvents, Screen contextScreen, Widget child) { this.refreshEvents = refreshEvents; this.contextScreen = contextScreen; this.child = child; } @Override public WidgetState createState() { return new State(); } public static class State extends StreamListenerState { @Override public void init() { this.streamListen(widget -> widget.refreshEvents, unit -> this.setState(() -> {})); } @Override public Widget build(BuildContext context) { return new LayerContextScope( this.widget().child, this.widget().contextScreen ); } } // --- private static LayerContextScope of(BuildContext context) { var layerContext = context.dependOnAncestor(LayerContextScope.class); if (layerContext == null) { throw new IllegalStateException("attempted to look up the ambient LayerContext without one present"); } return layerContext; } public static AbstractWidget findWidget(BuildContext context, Predicate predicate) { var layerContext = of(context); var widgets = new ArrayList(); for (var element : layerContext.contextScreen.children()) { collectChildren(element, widgets); } AbstractWidget widget = null; for (var candidate : widgets) { if (!predicate.test(candidate)) continue; widget = candidate; break; } return widget; } public static Screen screenOf(BuildContext context) { return of(context).contextScreen; } public static @Nullable Vector2d containerScreenRootOf(BuildContext context) { var screen = screenOf(context); if (!(screen instanceof AbstractContainerScreenAccessor containerScreen)) return null; return new Vector2d( containerScreen.owo$getRootX(), containerScreen.owo$getRootY() ); } private static void collectChildren(GuiEventListener element, List children) { if (element instanceof AbstractWidget widget) children.add(widget); if (element instanceof Layout layout) { layout.visitWidgets(child -> collectChildren(child, children)); } } } class LayerContextScope extends InheritedWidget { public final Screen contextScreen; public LayerContextScope(Widget child, Screen contextScreen) { super(child); this.contextScreen = contextScreen; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return true; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/BraidApp.java ================================================ package io.wispforest.owo.braid.widgets; import com.google.common.collect.ImmutableMap; import io.wispforest.owo.braid.core.KeyModifiers; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.focus.FocusTraversalDirection; import io.wispforest.owo.braid.widgets.intents.*; import io.wispforest.owo.braid.widgets.textinput.*; import net.minecraft.util.Util; import java.util.List; import java.util.Map; import static org.lwjgl.glfw.GLFW.*; public class BraidApp extends StatelessWidget { public final Widget child; public BraidApp(Widget child) { this.child = child; } @Override public Widget build(BuildContext context) { return new Interactable( DEFAULT_SHORTCUTS, widget -> widget .actions(DEFAULT_ACTIONS) .skipTraversal(true), new Shortcuts( DEFAULT_TEXT_SHORTCUTS, widget -> widget .autoFocus(true) .skipTraversal(true), new Navigator( this.child ) ) ); } // --- private static final KeyModifiers SHIFT = new KeyModifiers(GLFW_MOD_SHIFT); private static final KeyModifiers CTRL = new KeyModifiers(GLFW_MOD_CONTROL); private static final KeyModifiers SHIFT_AND_CTRL = KeyModifiers.both(SHIFT, CTRL); public static final Map, Action> DEFAULT_ACTIONS = Map.of( TraverseFocusIntent.class, new TraverseFocusAction() ); public static final Map, Intent> DEFAULT_SHORTCUTS = Map.of( List.of(new ShortcutTrigger( Trigger.ofKey(GLFW_KEY_ENTER), Trigger.ofKey(GLFW_KEY_KP_ENTER), Trigger.ofKey(GLFW_KEY_SPACE) )), PrimaryActionIntent.INSTANCE, List.of(new ShortcutTrigger( Trigger.ofKey(GLFW_KEY_ENTER, SHIFT), Trigger.ofKey(GLFW_KEY_KP_ENTER, SHIFT), Trigger.ofKey(GLFW_KEY_SPACE, SHIFT) )), SecondaryActionIntent.INSTANCE, List.of(ShortcutTrigger.UP.withModifiers(null)), new TraverseFocusIntent(FocusTraversalDirection.UP), List.of(ShortcutTrigger.DOWN.withModifiers(null)), new TraverseFocusIntent(FocusTraversalDirection.DOWN), List.of(ShortcutTrigger.LEFT.withModifiers(null)), new TraverseFocusIntent(FocusTraversalDirection.LEFT), List.of(ShortcutTrigger.RIGHT.withModifiers(null)), new TraverseFocusIntent(FocusTraversalDirection.RIGHT), List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_TAB))), new TraverseFocusIntent(FocusTraversalDirection.NEXT), List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_TAB, SHIFT))), new TraverseFocusIntent(FocusTraversalDirection.PREVIOUS) ); public static final Map, Intent> DEFAULT_TEXT_SHORTCUTS = Util.make(() -> { var builder = new ImmutableMap.Builder, Intent>(); builder.put(List.of(new ShortcutTrigger( Trigger.ofKey(GLFW_KEY_ENTER), Trigger.ofKey(GLFW_KEY_KP_ENTER) ).withModifiers(null)), InsertNewlineIntent.INSTANCE); builder.put(List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_TAB))), InsertTabIntent.INSTANCE); builder.put(List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_BACKSPACE))), new DeleteTextIntent(false, false)); builder.put(List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_BACKSPACE)).withModifiers(CTRL)), new DeleteTextIntent(false, true)); builder.put(List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_DELETE))), new DeleteTextIntent(true, false)); builder.put(List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_DELETE)).withModifiers(CTRL)), new DeleteTextIntent(true, true)); builder.put(List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_DELETE)).withModifiers(SHIFT)), DeleteLineIntent.INSTANCE); builder.put(List.of(ShortcutTrigger.UP), new MoveCursorIntent(MoveCursorIntent.Direction.UP, false, false)); builder.put(List.of(ShortcutTrigger.DOWN), new MoveCursorIntent(MoveCursorIntent.Direction.DOWN, false, false)); builder.put(List.of(ShortcutTrigger.LEFT), new MoveCursorIntent(MoveCursorIntent.Direction.LEFT, false, false)); builder.put(List.of(ShortcutTrigger.RIGHT), new MoveCursorIntent(MoveCursorIntent.Direction.RIGHT, false, false)); builder.put(List.of(ShortcutTrigger.UP.withModifiers(SHIFT)), new MoveCursorIntent(MoveCursorIntent.Direction.UP, false, true)); builder.put(List.of(ShortcutTrigger.DOWN.withModifiers(SHIFT)), new MoveCursorIntent(MoveCursorIntent.Direction.DOWN, false, true)); builder.put(List.of(ShortcutTrigger.LEFT.withModifiers(SHIFT)), new MoveCursorIntent(MoveCursorIntent.Direction.LEFT, false, true)); builder.put(List.of(ShortcutTrigger.RIGHT.withModifiers(SHIFT)), new MoveCursorIntent(MoveCursorIntent.Direction.RIGHT, false, true)); builder.put(List.of(ShortcutTrigger.UP.withModifiers(CTRL)), new MoveCursorIntent(MoveCursorIntent.Direction.UP, true, false)); builder.put(List.of(ShortcutTrigger.DOWN.withModifiers(CTRL)), new MoveCursorIntent(MoveCursorIntent.Direction.DOWN, true, false)); builder.put(List.of(ShortcutTrigger.LEFT.withModifiers(CTRL)), new MoveCursorIntent(MoveCursorIntent.Direction.LEFT, true, false)); builder.put(List.of(ShortcutTrigger.RIGHT.withModifiers(CTRL)), new MoveCursorIntent(MoveCursorIntent.Direction.RIGHT, true, false)); builder.put(List.of(ShortcutTrigger.UP.withModifiers(SHIFT_AND_CTRL)), new MoveCursorIntent(MoveCursorIntent.Direction.UP, true, true)); builder.put(List.of(ShortcutTrigger.DOWN.withModifiers(SHIFT_AND_CTRL)), new MoveCursorIntent(MoveCursorIntent.Direction.DOWN, true, true)); builder.put(List.of(ShortcutTrigger.LEFT.withModifiers(SHIFT_AND_CTRL)), new MoveCursorIntent(MoveCursorIntent.Direction.LEFT, true, true)); builder.put(List.of(ShortcutTrigger.RIGHT.withModifiers(SHIFT_AND_CTRL)), new MoveCursorIntent(MoveCursorIntent.Direction.RIGHT, true, true)); builder.put(List.of(ShortcutTrigger.HOME), new TeleportCursorIntent(true, false)); builder.put(List.of(ShortcutTrigger.HOME.withModifiers(SHIFT)), new TeleportCursorIntent(true, true)); builder.put(List.of(ShortcutTrigger.END), new TeleportCursorIntent(false, false)); builder.put(List.of(ShortcutTrigger.END.withModifiers(SHIFT)), new TeleportCursorIntent(false, true)); builder.put(List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_A)).withModifiers(CTRL)), SelectAllIntent.INSTANCE); builder.put(List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_C)).withModifiers(CTRL)), new CopyTextIntent(false)); builder.put(List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_X)).withModifiers(CTRL)), new CopyTextIntent(true)); builder.put(List.of(new ShortcutTrigger(Trigger.ofKey(GLFW_KEY_V)).withModifiers(CTRL)), PasteTextIntent.INSTANCE); return builder.build(); }); // --- public static class BaseRoute extends StatelessWidget { public final Widget route; public BaseRoute(Widget route) { this.route = route; } @Override public Widget build(BuildContext context) { return this.route; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/BraidLogo.java ================================================ package io.wispforest.owo.braid.widgets; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Sized; import io.wispforest.owo.braid.widgets.basic.TextureWidget; import net.minecraft.resources.Identifier; public class BraidLogo extends StatelessWidget { @Override public Widget build(BuildContext context) { return new Sized( 64, 64, new TextureWidget( TEXTURE_ID, TextureWidget.Wrap.STRETCH, Color.WHITE ) ); } private static final Identifier TEXTURE_ID = Owo.id("textures/gui/braid_logo.png"); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/Dialog.java ================================================ package io.wispforest.owo.braid.widgets; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Box; import io.wispforest.owo.braid.widgets.basic.Center; import io.wispforest.owo.braid.widgets.basic.HitTestTrap; import io.wispforest.owo.braid.widgets.basic.MouseArea; import org.lwjgl.glfw.GLFW; public class Dialog extends StatelessWidget { public final Color barrierColor; public final boolean barrierCanDismiss; public final Widget child; public Dialog(Color barrierColor, boolean barrierCanDismiss, Widget child) { this.barrierColor = barrierColor; this.barrierCanDismiss = barrierCanDismiss; this.child = child; } public Dialog(Color barrierColor, Widget child) { this(barrierColor, true, child); } public Dialog(boolean barrierCanDismiss, Widget child) { this(DEFAULT_BARRIER_COLOR, barrierCanDismiss, child); } public Dialog(Widget child) { this(DEFAULT_BARRIER_COLOR, true, child); } @Override public Widget build(BuildContext context) { return new HitTestTrap( new MouseArea( widget -> widget .clickCallback((x, y, button, modifiers) -> { if (!this.barrierCanDismiss || button != GLFW.GLFW_MOUSE_BUTTON_LEFT) return false; Navigator.pop(context); return true; }), new Box( this.barrierColor, new Center( new HitTestTrap( this.child ) ) ) ) ); } // --- private static final Color DEFAULT_BARRIER_COLOR = Color.BLACK.withA(.25); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/HoverStyledLabel.java ================================================ package io.wispforest.owo.braid.widgets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.MouseArea; import io.wispforest.owo.braid.widgets.label.Label; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; public class HoverStyledLabel extends StatefulWidget { public final Component defaultText; public final Style hoverStyle; public HoverStyledLabel(Component defaultText, Style hoverStyle) { this.defaultText = defaultText; this.hoverStyle = hoverStyle; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private boolean hovered = false; @Override public Widget build(BuildContext context) { return new MouseArea( widget -> widget .enterCallback(() -> setState(() -> this.hovered = true)) .exitCallback(() -> setState(() -> this.hovered = false)), new Label(this.hovered ? this.widget().defaultText.copy().withStyle(this.widget().hoverStyle) : this.widget().defaultText) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/Marquee.java ================================================ package io.wispforest.owo.braid.widgets; import io.wispforest.owo.braid.animation.Animation; import io.wispforest.owo.braid.animation.Easing; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.basic.Clip; import io.wispforest.owo.braid.widgets.basic.ListenableBuilder; import io.wispforest.owo.braid.widgets.basic.MouseArea; import io.wispforest.owo.braid.widgets.scroll.RawScrollView; import io.wispforest.owo.braid.widgets.scroll.ScrollController; import org.jetbrains.annotations.Nullable; import java.time.Duration; public class Marquee extends StatefulWidget { protected Easing easing = Easing.IN_OUT_SINE; protected Duration minDuration = Duration.ofSeconds(1); protected Duration durationPerPixel = Duration.ofMillis(100); protected Duration pauseTime = Duration.ofSeconds(2); protected boolean pauseWhileHovered = true; protected LayoutAxis axis = LayoutAxis.HORIZONTAL; public final Widget child; public Marquee(@Nullable WidgetSetupCallback setup, Widget child) { this.child = child; if (setup != null) setup.setup(this); } public Marquee(Widget child) { this.child = child; } public Marquee easing(Easing easing) { this.assertMutable(); this.easing = easing; return this; } public Easing easing() { return this.easing; } public Marquee minDuration(Duration minDuration) { this.assertMutable(); this.minDuration = minDuration; return this; } public Marquee minDuration(long millis) { return this.minDuration(Duration.ofMillis(millis)); } public Duration minDuration() { return this.minDuration; } public Marquee durationPerPixel(Duration durationPerPixel) { this.assertMutable(); this.durationPerPixel = durationPerPixel; return this; } public Marquee durationPerPixel(long millisPerPixel) { return this.durationPerPixel(Duration.ofMillis(millisPerPixel)); } public Duration durationPerPixel() { return this.durationPerPixel; } public Marquee pauseTime(Duration pauseTime) { this.assertMutable(); this.pauseTime = pauseTime; return this; } public Marquee pauseTime(long millis) { return this.pauseTime(Duration.ofMillis(millis)); } public Duration pauseTime() { return this.pauseTime; } public Marquee pauseWhileHovered(boolean pauseWhileHovered) { this.pauseWhileHovered = pauseWhileHovered; return this; } public boolean pauseWhileHovered() { return this.pauseWhileHovered; } public Marquee axis(LayoutAxis axis) { this.assertMutable(); this.axis = axis; return this; } public LayoutAxis axis() { return this.axis; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private final ScrollController controller = new ScrollController(this); private Animation animation; private Animation.Target pausedAnimationTarget = null; private long callbackId = -1; @Override public void init() { this.animation = new Animation( this.widget().easing, this.widget().durationPerPixel, this::scheduleAnimationCallback, this::onAnimationStep, this::onAnimationFinished, Animation.Target.START ); this.controller.addListener(() -> { this.updateAnimationDuration(); if (this.animation.target() == null) { this.cancelDelayedCallback(this.callbackId); this.animation.towards(this.animation.progress() == 0 ? Animation.Target.END : Animation.Target.START); } }); } @Override public void didUpdateWidget(Marquee oldWidget) { super.didUpdateWidget(oldWidget); this.updateAnimationDuration(); this.animation.easing = this.widget().easing; } private void updateAnimationDuration() { this.animation.duration = Duration.ofNanos((long) Math.max( this.widget().minDuration.toNanos(), this.widget().durationPerPixel.toNanos() * this.controller.maxOffset() )); } private void onAnimationStep(double progress) { this.controller.jumpTo(progress * this.controller.maxOffset()); } private void onAnimationFinished(Animation.Target atTarget) { if (this.controller.maxOffset() == 0) return; this.cancelDelayedCallback(this.callbackId); this.callbackId = this.scheduleDelayedCallback( this.widget().pauseTime, () -> this.animation.towards(atTarget == Animation.Target.END ? Animation.Target.START : Animation.Target.END) ); } @Override public Widget build(BuildContext context) { return new MouseArea( widget -> { if (!this.widget().pauseWhileHovered) return; widget .enterCallback(() -> { this.pausedAnimationTarget = this.animation.target(); this.animation.pause(); }) .exitCallback(() -> { if (this.pausedAnimationTarget == null) return; this.animation.towards(this.pausedAnimationTarget, false); }); }, new Clip( new ListenableBuilder( this.controller, buildContext -> new RawScrollView( this.widget().axis == LayoutAxis.HORIZONTAL ? this.controller : null, this.widget().axis == LayoutAxis.VERTICAL ? this.controller : null, this.widget().child ) ) ) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/Navigator.java ================================================ package io.wispforest.owo.braid.widgets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Builder; import io.wispforest.owo.braid.widgets.sharedstate.ShareableState; import io.wispforest.owo.braid.widgets.sharedstate.SharedState; import io.wispforest.owo.braid.widgets.stack.Stack; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; public class Navigator extends StatelessWidget { public final @Nullable Widget initialRoute; public Navigator(@Nullable Widget initialRoute) { this.initialRoute = initialRoute; } @Override public Widget build(BuildContext context) { return new SharedState<>( () -> new NavigationState(this.initialRoute), new Builder(innerContext -> { var state = SharedState.get(innerContext, NavigationState.class); return new Stack(state.displayedRoutes()); }) ); } // --- public static void pushOverlay(BuildContext context, Widget route) { SharedState.set(context, NavigationState.class, state -> state.push(route, true)); } public static void push(BuildContext context, Widget route) { SharedState.set(context, NavigationState.class, state -> state.push(route, false)); } public static void pop(BuildContext context) { SharedState.set(context, NavigationState.class, NavigationState::pop); } } record Route(Widget widget, boolean overlay) {} class NavigationState extends ShareableState { private final List routes; private List displayedRoutes = List.of(); public NavigationState(@Nullable Widget initialRoute) { this.routes = initialRoute != null ? new ArrayList<>(List.of(new Route(initialRoute, false))) : new ArrayList<>(); this.updateDisplayedRoutes(); } public List displayedRoutes() { return this.displayedRoutes; } public void push(Widget route, boolean overlay) { this.routes.add(new Route(route, overlay)); this.updateDisplayedRoutes(); } public void pop() { this.routes.removeLast(); this.updateDisplayedRoutes(); } private void updateDisplayedRoutes() { int idx; for (idx = this.routes.size() - 1; idx >= 0; idx--) { if (!this.routes.get(idx).overlay()) { break; } } this.displayedRoutes = this.routes.subList(idx, this.routes.size()).stream().map(Route::widget).toList(); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/SpriteWidget.java ================================================ package io.wispforest.owo.braid.widgets; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.instance.LeafWidgetInstance; import io.wispforest.owo.braid.framework.widget.LeafInstanceWidget; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.client.renderer.texture.TextureAtlasSprite; import net.minecraft.client.renderer.texture.TextureManager; import net.minecraft.client.resources.model.Material; import net.minecraft.resources.Identifier; import java.util.OptionalDouble; public class SpriteWidget extends LeafInstanceWidget { public static final Identifier GUI_ATLAS_ID = Identifier.withDefaultNamespace("textures/atlas/gui.png"); public final Material spriteIdentifier; public SpriteWidget(Material spriteIdentifier) { this.spriteIdentifier = spriteIdentifier; } public SpriteWidget(Identifier spriteIdentifier) { this.spriteIdentifier = new Material(GUI_ATLAS_ID, spriteIdentifier); } @Override public LeafWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends LeafWidgetInstance { protected TextureAtlasSprite sprite; public Instance(SpriteWidget widget) { super(widget); } @Override public void setWidget(SpriteWidget widget) { if (this.widget.spriteIdentifier.equals(widget.spriteIdentifier)) return; super.setWidget(widget); this.markNeedsLayout(); } protected TextureAtlasSprite findSprite() { try { this.sprite = Minecraft.getInstance().getAtlasManager().get(this.widget.spriteIdentifier); } catch (IllegalArgumentException ignored) { this.sprite = Minecraft.getInstance().getAtlasManager().get(new Material(GUI_ATLAS_ID, TextureManager.INTENTIONAL_MISSING_TEXTURE)); } return this.sprite; } @Override protected void doLayout(Constraints constraints) { this.sprite = this.findSprite(); var size = Size.of( this.sprite.contents().width(), this.sprite.contents().height() ).constrained(constraints); this.transform.setSize(size); } @Override protected double measureIntrinsicWidth(double height) { return this.findSprite().contents().width(); } @Override protected double measureIntrinsicHeight(double width) { return this.findSprite().contents().height(); } @Override protected OptionalDouble measureBaselineOffset() { return OptionalDouble.empty(); } @Override public void draw(BraidGraphics graphics) { graphics.blitSprite( RenderPipelines.GUI_TEXTURED, this.sprite, 0, 0, (int) this.transform.width(), (int) this.transform.height() ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/animated/AnimatedAlign.java ================================================ package io.wispforest.owo.braid.widgets.animated; import io.wispforest.owo.braid.animation.AlignmentLerp; import io.wispforest.owo.braid.animation.AutomaticallyAnimatedWidget; import io.wispforest.owo.braid.animation.Easing; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Align; import java.time.Duration; public class AnimatedAlign extends AutomaticallyAnimatedWidget { public final Alignment alignment; public final Widget child; public AnimatedAlign(Duration duration, Easing easing, Alignment alignment, Widget child) { super(duration, easing); this.alignment = alignment; this.child = child; } @Override public State createState() { return new State(); } public static class State extends AutomaticallyAnimatedWidget.State { private AlignmentLerp alignment; @Override protected void updateLerps() { this.alignment = this.visitLerp(this.alignment, this.widget().alignment, AlignmentLerp::new); } @Override public Widget build(BuildContext context) { return new Align( this.alignment.compute(this.animationValue()), this.widget().child ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/animated/AnimatedBox.java ================================================ package io.wispforest.owo.braid.widgets.animated; import io.wispforest.owo.braid.animation.AutomaticallyAnimatedWidget; import io.wispforest.owo.braid.animation.ColorLerp; import io.wispforest.owo.braid.animation.Easing; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Box; import org.jetbrains.annotations.Nullable; import java.time.Duration; public class AnimatedBox extends AutomaticallyAnimatedWidget { public final Color color; public final boolean outline; public final @Nullable Widget child; public AnimatedBox(Duration duration, Easing easing, Color color, boolean outline, @Nullable Widget child) { super(duration, easing); this.color = color; this.outline = outline; this.child = child; } public AnimatedBox(Duration duration, Easing easing, Color color, boolean outline) { this(duration, easing, color, outline, null); } public AnimatedBox(Duration duration, Easing easing, Color color) { this(duration, easing, color, false); } @Override public State createState() { return new State(); } public static class State extends AutomaticallyAnimatedWidget.State { private ColorLerp color; @Override protected void updateLerps() { this.color = this.visitLerp(this.color, this.widget().color, ColorLerp::new); } @Override public Widget build(BuildContext context) { return new Box( this.color.compute(this.animationValue()), this.widget().outline, this.widget().child ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/animated/AnimatedPadding.java ================================================ package io.wispforest.owo.braid.widgets.animated; import io.wispforest.owo.braid.animation.AutomaticallyAnimatedWidget; import io.wispforest.owo.braid.animation.Easing; import io.wispforest.owo.braid.animation.InsetsLerp; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Padding; import org.jetbrains.annotations.Nullable; import java.time.Duration; public class AnimatedPadding extends AutomaticallyAnimatedWidget { public final Insets insets; public final @Nullable Widget child; public AnimatedPadding(Duration duration, Easing easing, Insets insets, @Nullable Widget child) { super(duration, easing); this.insets = insets; this.child = child; } @Override public State createState() { return new State(); } public static class State extends AutomaticallyAnimatedWidget.State { private InsetsLerp insets; @Override protected void updateLerps() { this.insets = this.visitLerp(this.insets, this.widget().insets, InsetsLerp::new); } @Override public Widget build(BuildContext context) { return new Padding( this.insets.compute(this.animationValue()), this.widget().child ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/animated/AnimatedSized.java ================================================ package io.wispforest.owo.braid.widgets.animated; import io.wispforest.owo.braid.animation.AutomaticallyAnimatedWidget; import io.wispforest.owo.braid.animation.DoubleLerp; import io.wispforest.owo.braid.animation.Easing; import io.wispforest.owo.braid.animation.Lerp; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Sized; import org.jetbrains.annotations.Nullable; import java.time.Duration; public class AnimatedSized extends AutomaticallyAnimatedWidget { public final @Nullable Double width; public final @Nullable Double height; public final Widget child; public AnimatedSized(Duration duration, Easing easing, @Nullable Double width, @Nullable Double height, Widget child) { super(duration, easing); this.width = width; this.height = height; this.child = child; } @Override public State createState() { return new State(); } public static class State extends AutomaticallyAnimatedWidget.State { private Lerp<@Nullable Double> width; private Lerp<@Nullable Double> height; @Override protected void updateLerps() { this.width = this.visitNullableLerp(this.width, this.widget().width, DoubleLerp::new); this.height = this.visitNullableLerp(this.height, this.widget().height, DoubleLerp::new); } @Override public Widget build(BuildContext context) { return new Sized( this.width.compute(this.animationValue()), this.height.compute(this.animationValue()), this.widget().child ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Align.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.util.Objects; import java.util.OptionalDouble; public class Align extends SingleChildInstanceWidget { public final Alignment alignment; public final OptionalDouble widthFactor; public final OptionalDouble heightFactor; public Align(Alignment alignment, @Nullable Double widthFactor, @Nullable Double heightFactor, Widget child) { super(child); this.alignment = alignment; this.widthFactor = widthFactor != null ? OptionalDouble.of(widthFactor) : OptionalDouble.empty(); this.heightFactor = heightFactor != null ? OptionalDouble.of(heightFactor) : OptionalDouble.empty(); } public Align(Alignment alignment, Widget child) { this(alignment, null, null, child); } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance { public Instance(Align widget) { super(widget); } @Override public void setWidget(Align widget) { if (Objects.equals(this.widget.widthFactor, widget.widthFactor) && Objects.equals(this.widget.heightFactor, widget.heightFactor) && Objects.equals(this.widget.alignment, widget.alignment)) { return; } super.setWidget(widget); this.markNeedsLayout(); } @Override protected void doLayout(Constraints constraints) { var widthFactor = this.widget.widthFactor; var heightFactor = this.widget.heightFactor; var alignment = this.widget.alignment; var childSize = this.child.layout(constraints.asLoose()); var selfSize = Size.of( widthFactor.isPresent() || !constraints.hasBoundedWidth() ? childSize.width() * widthFactor.orElse(1) : constraints.maxWidth(), heightFactor.isPresent() || !constraints.hasBoundedHeight() ? childSize.height() * heightFactor.orElse(1) : constraints.maxHeight() ).constrained(constraints); var childX = alignment.alignHorizontal(selfSize.width(), childSize.width()); var childY = alignment.alignVertical(selfSize.height(), childSize.height()); this.child.transform.setX(childX); this.child.transform.setY(childY); this.transform.setSize(selfSize); } @Override protected double measureIntrinsicWidth(double height) { return this.child.getIntrinsicWidth(height) * (this.widget.widthFactor.orElse(1)); } @Override protected double measureIntrinsicHeight(double width) { return this.child.getIntrinsicHeight(width) * (this.widget.heightFactor.orElse(1)); } @Override protected OptionalDouble measureBaselineOffset() { return this.child.getBaselineOffset().stream().map(operand -> operand + this.child.transform.y()).findAny(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/AspectRatio.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import java.util.OptionalDouble; public class AspectRatio extends SingleChildInstanceWidget { public final double ratio; public AspectRatio(double ratio, Widget child) { super(child); this.ratio = ratio; } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } // --- public static Size applyAspectRatioToMaxSize(Constraints constraints, double ratio) { double width = constraints.maxWidth(); double height; if (Double.isFinite(width)) { height = width / ratio; } else { height = constraints.maxHeight(); width = height * ratio; } return applyAspectRatio(constraints, Size.of(width, height)); } public static Size applyAspectRatio(Constraints constraints, Size size) { if (constraints.isTight()) { return constraints.minSize(); } var width = size.width(); var height = size.height(); var ratio = width / height; if (width > constraints.maxWidth()) { width = constraints.maxWidth(); height = width / ratio; } if (height > constraints.maxHeight()) { height = constraints.maxHeight(); width = height * ratio; } if (width < constraints.minWidth()) { width = constraints.minWidth(); height = width / ratio; } if (height < constraints.minHeight()) { height = constraints.minHeight(); width = height * ratio; } return Size.of(width, height).constrained(constraints); } // --- public static class Instance extends SingleChildWidgetInstance { public Instance(AspectRatio widget) { super(widget); } @Override public void setWidget(AspectRatio widget) { if (widget.ratio == this.widget.ratio) return; super.setWidget(widget); this.markNeedsLayout(); } @Override protected void doLayout(Constraints constraints) { var size = AspectRatio.applyAspectRatioToMaxSize(constraints, this.widget.ratio); this.transform.setSize(size); this.child.layout(Constraints.tight(size)); } @Override protected double measureIntrinsicWidth(double height) { return Double.isFinite(height) ? height * this.widget.ratio : this.child.getIntrinsicWidth(height); } @Override protected double measureIntrinsicHeight(double width) { return Double.isFinite(width) ? width / this.widget.ratio : this.child.getIntrinsicHeight(width); } @Override protected OptionalDouble measureBaselineOffset() { return this.child.getBaselineOffset(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Blur.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.ui.renderstate.BlurQuadElementRenderState; import net.minecraft.client.gui.navigation.ScreenRectangle; import org.joml.Matrix3x2f; public class Blur extends SingleChildInstanceWidget { public final float quality; public final float size; public final boolean blurChild; public Blur(float quality, float size, boolean blurChild, Widget child) { super(child); this.quality = quality; this.size = size; this.blurChild = blurChild; } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance.ShrinkWrap { public Instance(Blur widget) { super(widget); } @Override public void draw(BraidGraphics graphics) { if (!this.widget.blurChild) { this.drawBlur(graphics); } super.draw(graphics); if (this.widget.blurChild) { this.drawBlur(graphics); } } private void drawBlur(BraidGraphics ctx) { ctx.guiRenderState.submitGuiElement(new BlurQuadElementRenderState( new Matrix3x2f(ctx.pose()), new ScreenRectangle(0, 0, (int) this.transform.width(), (int) this.transform.height()), ctx.scissorStack.peek(), 16, this.widget.quality, this.widget.size )); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Box.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.framework.instance.OptionalChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.OptionalChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public class Box extends OptionalChildInstanceWidget { public final Color color; public final boolean outline; public Box(Color color, boolean outline, @Nullable Widget child) { super(child); this.color = color; this.outline = outline; } public Box(Color color, @Nullable Widget child) { this(color, false, child); } public Box(Color color, boolean outline) { this(color, outline, null); } public Box(Color color) { this(color, false); } @Override public OptionalChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends OptionalChildWidgetInstance.ShrinkWrap { public Instance(Box widget) { super(widget); } @Override public void draw(BraidGraphics graphics) { if (this.widget.outline) { graphics.drawRectOutline(0, 0, (int) this.transform.width(), (int) this.transform.height(), this.widget.color.argb()); } else { graphics.fill(0, 0, (int) this.transform.width(), (int) this.transform.height(), this.widget.color.argb()); } super.draw(graphics); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Builder.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; public class Builder extends StatelessWidget { public final WidgetBuilder builder; public Builder(WidgetBuilder builder) { this.builder = builder; } @Override public Widget build(BuildContext context) { return this.builder.build(context); } @FunctionalInterface public interface WidgetBuilder { Widget build(BuildContext context); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Center.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public class Center extends Align { public Center(@Nullable Double widthFactor, @Nullable Double heightFactor, Widget child) { super(Alignment.CENTER, widthFactor, heightFactor, child); } public Center(Widget child) { this(null, null, child); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Clip.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.framework.instance.HitTestState; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import net.minecraft.client.gui.navigation.ScreenRectangle; // TODO: stencil clip // also warn in docs about transforms which aren't pure translations public class Clip extends SingleChildInstanceWidget { public final boolean clipHitTest; public final boolean clipDrawing; public Clip(boolean clipHitTest, boolean clipDrawing, Widget child) { super(child); this.clipHitTest = clipHitTest; this.clipDrawing = clipDrawing; } public Clip(Widget child) { this(true, true, child); } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance.ShrinkWrap { public Instance(Clip widget) { super(widget); } @Override public void draw(BraidGraphics graphics) { if (!this.widget.clipDrawing) { super.draw(graphics); return; } graphics.scissorStack.push(new ScreenRectangle(0, 0, (int) this.transform.width(), (int) this.transform.height()).transformMaxBounds(graphics.pose())); super.draw(graphics); graphics.disableScissor(); } @Override public void hitTest(double x, double y, HitTestState state) { if (this.widget.clipHitTest && (x < 0 || x > this.transform.width() || y < 0 || y > this.transform.height())) { return; } super.hitTest(x, y, state); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Constrain.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.widget.Widget; public class Constrain extends ConstraintWidget { public final Constraints constraints; public Constrain(Constraints constraints, Widget child) { super(child); this.constraints = constraints; } @Override protected Constraints constraints() { return this.constraints; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/ConstraintWidget.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import net.minecraft.util.Mth; import java.util.Objects; import java.util.OptionalDouble; public abstract class ConstraintWidget extends SingleChildInstanceWidget { protected ConstraintWidget(Widget child) { super(child); } protected abstract Constraints constraints(); @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance { public Instance(ConstraintWidget widget) { super(widget); } @Override public void setWidget(ConstraintWidget widget) { if (Objects.equals(this.widget.constraints(), widget.constraints())) { return; } super.setWidget(widget); this.markNeedsLayout(); } @Override protected void doLayout(Constraints constraints) { this.sizeToChild(this.widget.constraints().respecting(constraints), this.child); } @Override protected double measureIntrinsicWidth(double height) { return Mth.clamp(this.child.getIntrinsicWidth(height), this.widget.constraints().minWidth(), this.widget.constraints().maxWidth()); } @Override protected double measureIntrinsicHeight(double width) { return Mth.clamp(this.child.getIntrinsicHeight(width), this.widget.constraints().minHeight(), this.widget.constraints().maxHeight()); } @Override protected OptionalDouble measureBaselineOffset() { return this.child.getBaselineOffset(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/ControlsOverride.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; /// A widget that descendants can check to disable interactive controls, /// such as buttons or text fields. /// /// This is useful for deactivating larger sections of a UI /// without having to manually disable each individual widget. public class ControlsOverride extends InheritedWidget { public final boolean disableControls; public ControlsOverride(boolean disableControls, Widget child) { super(child); this.disableControls = disableControls; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return this.disableControls != ((ControlsOverride) newWidget).disableControls; } public static boolean controlsDisabled(BuildContext context) { var widget = context.dependOnAncestor(ControlsOverride.class); return widget != null && widget.disableControls; } } //TODO: make sure this is applied to all relevant widgets ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/CustomDraw.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.instance.LeafWidgetInstance; import io.wispforest.owo.braid.framework.instance.WidgetTransform; import io.wispforest.owo.braid.framework.widget.LeafInstanceWidget; import java.util.OptionalDouble; public class CustomDraw extends LeafInstanceWidget { public final CustomDrawFunction function; public CustomDraw(CustomDrawFunction function) { this.function = function; } @Override public LeafWidgetInstance instantiate() { return new Instance(this); } @FunctionalInterface public interface CustomDrawFunction { void draw(BraidGraphics graphics, WidgetTransform transform); } public static class Instance extends LeafWidgetInstance { public Instance(CustomDraw widget) { super(widget); } @Override protected void doLayout(Constraints constraints) { var size = constraints.minSize(); this.transform.setSize(size); } @Override public void draw(BraidGraphics graphics) { this.widget.function.draw(graphics, this.transform); } @Override protected double measureIntrinsicWidth(double height) { return 0; } @Override protected double measureIntrinsicHeight(double width) { return 0; } @Override protected OptionalDouble measureBaselineOffset() { return OptionalDouble.empty(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/EmptyWidget.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; public class EmptyWidget extends StatelessWidget { public static final EmptyWidget INSTANCE = new EmptyWidget(); private EmptyWidget() {} @Override public Widget build(BuildContext context) { return new Padding(Insets.none()); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/HitTestTrap.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.widget.Widget; public class HitTestTrap extends VisitorWidget { public final boolean occludeHitTest; public HitTestTrap(boolean occludeHitTest, Widget child) { super(child); this.occludeHitTest = occludeHitTest; } public HitTestTrap(Widget child) { this(true, child); } public static final Visitor VISITOR = (widget, instance) -> { if (widget.occludeHitTest) { instance.flags |= WidgetInstance.FLAG_HIT_TEST_BOUNDARY; } else { instance.flags &= ~WidgetInstance.FLAG_HIT_TEST_BOUNDARY; } }; @Override public Proxy proxy() { return new Proxy<>(this, VISITOR); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/HoverableBuilder.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public class HoverableBuilder extends StatefulWidget { public final HoverableBuilderCallback builder; public final @Nullable Widget child; public HoverableBuilder(HoverableBuilderCallback builder, @NotNull Widget child) { this.builder = builder; this.child = child; } public HoverableBuilder(HoverableBuilderCallbackWithoutChild builder) { this.builder = (context, hovered, $) -> builder.build(context, hovered); this.child = null; } public HoverableBuilder(Widget notHovered, Widget hovered) { this((context, isHovered) -> isHovered ? hovered : notHovered); } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private boolean hovered = false; @Override public Widget build(BuildContext context) { return new MouseArea( widget -> widget .enterCallback(() -> this.setState(() -> this.hovered = true)) .exitCallback(() -> this.setState(() -> this.hovered = false)), this.widget().builder.build(context, this.hovered, this.widget().child) ); } } @FunctionalInterface public interface HoverableBuilderCallback { Widget build(BuildContext hoverableContext, boolean hovered, Widget child); } @FunctionalInterface public interface HoverableBuilderCallbackWithoutChild { Widget build(BuildContext hoverableContext, boolean hovered); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/IntrinsicHeight.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import java.util.OptionalDouble; public class IntrinsicHeight extends SingleChildInstanceWidget { public IntrinsicHeight(Widget child) { super(child); } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance { public Instance(IntrinsicHeight widget) { super(widget); } @Override protected void doLayout(Constraints constraints) { var childSize = this.child.getIntrinsicHeight(constraints.maxWidth()); var childConstraints = Constraints.of( constraints.minWidth(), childSize, constraints.maxWidth(), childSize ).respecting(constraints); this.transform.setSize(this.child.layout(childConstraints)); } @Override protected double measureIntrinsicWidth(double height) { return this.child.getIntrinsicWidth(height); } @Override protected double measureIntrinsicHeight(double width) { return this.child.getIntrinsicHeight(width); } @Override protected OptionalDouble measureBaselineOffset() { return this.child.getBaselineOffset(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/IntrinsicWidth.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import java.util.OptionalDouble; public class IntrinsicWidth extends SingleChildInstanceWidget { public IntrinsicWidth(Widget child) { super(child); } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance { public Instance(IntrinsicWidth widget) { super(widget); } @Override protected void doLayout(Constraints constraints) { var childSize = this.child.getIntrinsicWidth(constraints.maxHeight()); var childConstraints = Constraints.of( childSize, constraints.minHeight(), childSize, constraints.maxHeight() ).respecting(constraints); this.transform.setSize(this.child.layout(childConstraints)); } @Override protected double measureIntrinsicWidth(double height) { return this.child.getIntrinsicWidth(height); } @Override protected double measureIntrinsicHeight(double width) { return this.child.getIntrinsicHeight(width); } @Override protected OptionalDouble measureBaselineOffset() { return this.child.getBaselineOffset(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/LayoutBuilder.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.instance.OptionalChildWidgetInstance; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.proxy.BuildScope; import io.wispforest.owo.braid.framework.proxy.InstanceWidgetProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; import io.wispforest.owo.braid.framework.widget.InstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.util.function.Consumer; public class LayoutBuilder extends InstanceWidget { public final Callback builder; public LayoutBuilder(Callback builder) { this.builder = builder; } @Override public WidgetInstance instantiate() { return new Instance(this); } @Override public WidgetProxy proxy() { return new Proxy(this); } public static class Proxy extends InstanceWidgetProxy { protected final BuildScope scope = new BuildScope(() -> { this.instance.markNeedsLayout(); }); protected WidgetProxy child; protected Proxy(InstanceWidget widget) { super(widget); this.instance().callback = this::rebuild; } @Override public Instance instance() { return (Instance) super.instance(); } @Override public BuildScope buildScope() { return this.scope; } @Override public void updateWidget(Widget newWidget) { super.updateWidget(newWidget); this.instance.markNeedsLayout(); } protected void rebuild(Constraints constraints) { var newWidget = ((LayoutBuilder) this.widget()).builder.build(this, constraints); this.child = this.refreshChild(this.child, newWidget, null); this.buildScope().rebuildDirtyProxies(); } @Override public void notifyDescendantInstance(@Nullable WidgetInstance instance, @Nullable Object slot) { this.instance().setChild(instance); } @Override public void visitChildren(Visitor visitor) { if (this.child != null) { visitor.visit(this.child); } } } public static class Instance extends OptionalChildWidgetInstance.ShrinkWrap { private Consumer callback; public Instance(LayoutBuilder widget) { super(widget); } @Override protected void doLayout(Constraints constraints) { this.host().notifySubtreeRebuild(); this.callback.accept(constraints); super.doLayout(constraints); } } @FunctionalInterface public interface Callback { Widget build(BuildContext context, Constraints constraints); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/ListenableBuilder.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Listenable; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public class ListenableBuilder extends StatefulWidget { public final Listenable listenable; public final ListenableBuilderWithChildFunction builder; public final @Nullable Widget child; public ListenableBuilder(Listenable listenable, ListenableBuilderFunction builder) { this.listenable = listenable; this.builder = (context, $) -> builder.build(context); this.child = null; } public ListenableBuilder(Listenable listenable, ListenableBuilderWithChildFunction builder, @NotNull Widget child) { this.listenable = listenable; this.builder = builder; this.child = child; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private final Runnable listener = () -> this.setState(() -> {}); @Override public void init() { this.widget().listenable.addListener(this.listener); } @Override public void didUpdateWidget(ListenableBuilder oldWidget) { if (this.widget().listenable != oldWidget.listenable) { oldWidget.listenable.removeListener(this.listener); this.widget().listenable.addListener(this.listener); } } @Override public Widget build(BuildContext context) { return this.widget().builder.build(context, this.widget().child); } @Override public void dispose() { this.widget().listenable.removeListener(this.listener); } } // --- @FunctionalInterface public interface ListenableBuilderFunction { Widget build(BuildContext listenableContext); } @FunctionalInterface public interface ListenableBuilderWithChildFunction { Widget build(BuildContext listenableContext, Widget child); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/MouseArea.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.KeyModifiers; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.instance.MouseListener; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import org.jetbrains.annotations.Nullable; public class MouseArea extends SingleChildInstanceWidget { private @Nullable ClickCallback clickCallback; private @Nullable ReleaseCallback releaseCallback; private @Nullable EnterCallback enterCallback; private @Nullable MoveCallback moveCallback; private @Nullable ExitCallback exitCallback; private @Nullable DragStartCallback dragStartCallback; private @Nullable DragCallback dragCallback; private @Nullable DragEndCallback dragEndCallback; private @Nullable ScrollCallback scrollCallback; private @Nullable CursorStyleSupplier cursorStyleSupplier; public MouseArea( WidgetSetupCallback setupCallback, Widget child ) { super(child); setupCallback.setup(this); } public MouseArea clickCallback(@Nullable ClickCallback clickCallback) { this.assertMutable(); this.clickCallback = clickCallback; return this; } public @Nullable ClickCallback clickCallback() { return this.clickCallback; } public MouseArea releaseCallback(@Nullable ReleaseCallback releaseCallback) { this.assertMutable(); this.releaseCallback = releaseCallback; return this; } public @Nullable ReleaseCallback releaseCallback() { return this.releaseCallback; } public MouseArea enterCallback(@Nullable EnterCallback enterCallback) { this.assertMutable(); this.enterCallback = enterCallback; return this; } public @Nullable EnterCallback enterCallback() { return this.enterCallback; } public MouseArea moveCallback(@Nullable MoveCallback moveCallback) { this.assertMutable(); this.moveCallback = moveCallback; return this; } public @Nullable MoveCallback moveCallback() { return this.moveCallback; } public MouseArea exitCallback(@Nullable ExitCallback exitCallback) { this.assertMutable(); this.exitCallback = exitCallback; return this; } public @Nullable ExitCallback exitCallback() { return this.exitCallback; } public MouseArea dragStartCallback(@Nullable DragStartCallback dragStartCallback) { this.assertMutable(); this.dragStartCallback = dragStartCallback; return this; } public @Nullable DragStartCallback dragStartCallback() { return this.dragStartCallback; } public MouseArea dragCallback(@Nullable DragCallback dragCallback) { this.assertMutable(); this.dragCallback = dragCallback; return this; } public @Nullable DragCallback dragCallback() { return this.dragCallback; } public MouseArea dragEndCallback(@Nullable DragEndCallback dragEndCallback) { this.assertMutable(); this.dragEndCallback = dragEndCallback; return this; } public @Nullable DragEndCallback dragEndCallback() { return this.dragEndCallback; } public MouseArea scrollCallback(@Nullable ScrollCallback scrollCallback) { this.assertMutable(); this.scrollCallback = scrollCallback; return this; } public @Nullable ScrollCallback scrollCallback() { return this.scrollCallback; } public MouseArea cursorStyleSupplier(@Nullable CursorStyleSupplier cursorStyleSupplier) { this.assertMutable(); this.cursorStyleSupplier = cursorStyleSupplier; return this; } public MouseArea cursorStyle(@Nullable CursorStyle style) { return this.cursorStyleSupplier((x, y) -> style); } public @Nullable CursorStyleSupplier cursorStyleSupplier() { return this.cursorStyleSupplier; } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } @FunctionalInterface public interface ClickCallback { boolean onClick(double x, double y, int button, KeyModifiers modifiers); } @FunctionalInterface public interface ReleaseCallback { boolean onRelease(double x, double y, int button, KeyModifiers modifiers); } @FunctionalInterface public interface EnterCallback { void onMouseEnter(); } @FunctionalInterface public interface MoveCallback { void onMouseMove(double toX, double toY); } @FunctionalInterface public interface ExitCallback { void onMouseExit(); } @FunctionalInterface public interface DragStartCallback { void onDragStart(int button, KeyModifiers modifiers); } @FunctionalInterface public interface DragCallback { void onDrag(double x, double y, double dx, double dy); } @FunctionalInterface public interface DragEndCallback { void onDragEnd(); } @FunctionalInterface public interface ScrollCallback { boolean onScroll(double horizontal, double vertical); } @FunctionalInterface public interface CursorStyleSupplier { @Nullable CursorStyle getCursorStyle(double x, double y); } public static class Instance extends SingleChildWidgetInstance.ShrinkWrap implements MouseListener { public Instance(MouseArea widget) { super(widget); } @Override public @Nullable CursorStyle cursorStyleAt(double x, double y) { if (this.widget.cursorStyleSupplier == null) return null; return this.widget.cursorStyleSupplier.getCursorStyle(x, y); } @Override public boolean onMouseDown(double x, double y, int button, KeyModifiers modifiers) { if (this.widget.clickCallback != null) { return this.widget.clickCallback.onClick(x, y, button, modifiers); } return this.widget.dragCallback != null; } @Override public boolean onMouseUp(double x, double y, int button, KeyModifiers modifiers) { if (this.widget.releaseCallback != null) { return this.widget.releaseCallback.onRelease(x, y, button, modifiers); } return this.widget.dragEndCallback != null; } @Override public void onMouseEnter() { if (this.widget.enterCallback != null) this.widget.enterCallback.onMouseEnter(); } @Override public void onMouseMove(double toX, double toY) { if (this.widget.moveCallback != null) this.widget.moveCallback.onMouseMove(toX, toY); } @Override public void onMouseExit() { if (this.widget.exitCallback != null) this.widget.exitCallback.onMouseExit(); } @Override public void onMouseDragStart(int button, KeyModifiers modifiers) { if (this.widget.dragStartCallback != null) this.widget.dragStartCallback.onDragStart(button, modifiers); } @Override public void onMouseDrag(double x, double y, double dx, double dy) { if (this.widget.dragCallback != null) this.widget.dragCallback.onDrag(x, y, dx, dy); } @Override public void onMouseDragEnd() { if (this.widget.dragEndCallback != null) this.widget.dragEndCallback.onDragEnd(); } @Override public boolean onMouseScroll(double x, double y, double horizontal, double vertical) { if (this.widget.scrollCallback != null) { return this.widget.scrollCallback.onScroll(horizontal, vertical); } return false; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Padding.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.instance.OptionalChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.OptionalChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.util.Objects; import java.util.OptionalDouble; public class Padding extends OptionalChildInstanceWidget { public final Insets insets; public Padding(Insets insets, @Nullable Widget child) { super(child); this.insets = insets; } public Padding(Insets insets) { this(insets, null); } public Padding(Size size) { this(Insets.right(size.width()).withTop(size.height()), null); } @Override public OptionalChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends OptionalChildWidgetInstance { public Instance(Padding widget) { super(widget); } @Override public void setWidget(Padding widget) { if (Objects.equals(this.widget.insets, widget.insets)) return; super.setWidget(widget); this.markNeedsLayout(); } @Override protected void doLayout(Constraints constraints) { var insets = this.widget.insets; var childConstraints = Constraints.of( Math.max(0, constraints.minWidth() - insets.horizontal()), Math.max(0, constraints.minHeight() - insets.vertical()), Math.max(0, constraints.maxWidth() - insets.horizontal()), Math.max(0, constraints.maxHeight() - insets.vertical()) ); var size = (this.child != null ? this.child.layout(childConstraints) : Size.zero()).withInsets(insets).constrained(constraints); this.transform.setSize(size); if (this.child != null) { this.child.transform.setX(insets.left()); this.child.transform.setY(insets.top()); } } @Override protected double measureIntrinsicWidth(double height) { var childWidth = this.child != null ? this.child.getIntrinsicWidth(height) : 0; return childWidth + this.widget.insets.horizontal(); } @Override protected double measureIntrinsicHeight(double width) { var childHeight = this.child != null ? this.child.getIntrinsicHeight(width) : 0; return childHeight + this.widget.insets.vertical(); } @Override protected OptionalDouble measureBaselineOffset() { var childBaseline = this.child != null ? this.child.getBaselineOffset() : OptionalDouble.empty(); if (childBaseline.isEmpty()) return OptionalDouble.empty(); return OptionalDouble.of(childBaseline.getAsDouble() + this.widget.insets.top()); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Panel.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.framework.instance.OptionalChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.OptionalChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.ui.core.OwoUIGraphics; import io.wispforest.owo.ui.util.NinePatchTexture; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.Nullable; public class Panel extends OptionalChildInstanceWidget { public static final Identifier VANILLA_LIGHT = Owo.id("panel/default"); public static final Identifier VANILLA_DARK = Owo.id("panel/dark"); public static final Identifier VANILLA_INSET = Owo.id("panel/inset"); // --- public final @Nullable Identifier texture; public Panel(@Nullable Identifier texture, @Nullable Widget child) { super(child); this.texture = texture; } public Panel(Identifier texture) { this(texture, null); } @Override public OptionalChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends OptionalChildWidgetInstance.ShrinkWrap { public Instance(Panel widget) { super(widget); } @Override public void draw(BraidGraphics graphics) { if (this.widget.texture != null) { NinePatchTexture.draw(this.widget.texture, OwoUIGraphics.of(graphics), 0, 0, (int) this.transform.width(), (int) this.transform.height()); } super.draw(graphics); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/RotatedLayout.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.instance.CustomWidgetTransform; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.instance.WidgetTransform; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.joml.Matrix3x2f; import java.util.OptionalDouble; public class RotatedLayout extends SingleChildInstanceWidget { public final int increments; public RotatedLayout(int increments, Widget child) { super(child); this.increments = increments; } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance { public Instance(RotatedLayout widget) { super(widget); this.visualIncrements = Math.floorMod(widget.increments, 4); } @Override protected WidgetTransform createTransform() { var transform = new CustomWidgetTransform(); transform.setApplyAtCenter(false); return transform; } private int visualIncrements; private boolean isVertical() { return this.visualIncrements % 2 == 1; } @Override public void setWidget(RotatedLayout widget) { if (this.visualIncrements == Math.floorMod(widget.increments, 4)) { return; } super.setWidget(widget); this.visualIncrements = Math.floorMod(widget.increments, 4); this.markNeedsLayout(); } @Override protected void doLayout(Constraints constraints) { var isVertical = this.isVertical(); var childConstraints = isVertical ? Constraints.of(constraints.minHeight(), constraints.minWidth(), constraints.maxHeight(), constraints.maxWidth()) : constraints; var childSize = this.child.layout(childConstraints); var selfSize = isVertical ? Size.of(childSize.height(), childSize.width()) : childSize; this.transform.setSize(selfSize); var childTransform = new Matrix3x2f() .translate((float) (selfSize.width() / 2), (float) (selfSize.height() / 2)) .rotate((float) (this.visualIncrements * Math.PI / 2)) .translate((float) (-childSize.width() / 2), (float) (-childSize.height() / 2)); ((CustomWidgetTransform) this.transform).setMatrix(childTransform); } @Override protected double measureIntrinsicWidth(double height) { return this.isVertical() ? this.child.getIntrinsicHeight(height) : this.child.getIntrinsicWidth(height); } @Override protected double measureIntrinsicHeight(double width) { return this.isVertical() ? this.child.getIntrinsicWidth(width) : this.child.getIntrinsicHeight(width); } @Override protected OptionalDouble measureBaselineOffset() { return OptionalDouble.empty(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Sized.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public class Sized extends ConstraintWidget { public final @Nullable Double width; public final @Nullable Double height; public Sized(@Nullable Double width, @Nullable Double height, Widget child) { super(child); this.width = width; this.height = height; } public Sized(@Nullable Integer width, @Nullable Integer height, Widget child) { this(width == null ? null : width.doubleValue(), height == null ? null : height.doubleValue(), child); } public Sized(double width, double height, Widget child) { this((Double) width, (Double) height, child); } public Sized(Size size, Widget child) { this(size.width(), size.height(), child); } @Override protected Constraints constraints() { return Constraints.tightOnAxis(this.width, this.height); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/TextureWidget.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.*; import io.wispforest.owo.braid.framework.instance.InstanceHost; import io.wispforest.owo.braid.framework.instance.OptionalChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.OptionalChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.Nullable; import java.util.OptionalDouble; public class TextureWidget extends OptionalChildInstanceWidget { public final Identifier texture; public final Wrap wrap; public final Filter filter; public final Color color; public TextureWidget(Identifier texture, Wrap wrap, Filter filter, Color color, @Nullable Widget child) { super(child); this.texture = texture; this.wrap = wrap; this.filter = filter; this.color = color; } public TextureWidget(Identifier texture, Wrap wrap, Color color, @Nullable Widget child) { this(texture, wrap, Filter.TEXTURE_DEFAULT, color, child); } public TextureWidget(Identifier texture, Wrap wrap, Filter filter, Color color) { this(texture, wrap, filter, color, null); } public TextureWidget(Identifier texture, Wrap wrap, Color color) { this(texture, wrap, Filter.TEXTURE_DEFAULT, color); } @Override public OptionalChildWidgetInstance instantiate() { return new Instance(this); } // --- public enum Wrap { NONE, STRETCH, REPEAT } public enum Filter { TEXTURE_DEFAULT, NEAREST, LINEAR; } // --- public static class Instance extends OptionalChildWidgetInstance { private @Nullable Size textureSize; public Instance(TextureWidget widget) { super(widget); } @Override public void attachHost(InstanceHost host) { super.attachHost(host); this.refreshTextureSize(); } @Override public void setWidget(TextureWidget widget) { super.setWidget(widget); this.refreshTextureSize(); } private void refreshTextureSize() { var texture = this.host().client().getTextureManager().getTexture(widget.texture).getTexture(); var newTextureSize = Size.of( texture.getWidth(0), texture.getHeight(0) ); if (!newTextureSize.equals(this.textureSize)) { this.markNeedsLayout(); } this.textureSize = newTextureSize; } private double imageAspectRatio() { //noinspection DataFlowIssue return this.textureSize.width() / this.textureSize.height(); } @Override protected void doLayout(Constraints constraints) { if (this.child == null) { var size = this.textureSize != null ? AspectRatio.applyAspectRatio(constraints, this.textureSize) : constraints.maxFiniteOrMinSize(); this.transform.setSize(size); } else { this.sizeToChild(constraints, this.child); } } @Override protected double measureIntrinsicWidth(double height) { return this.child != null ? this.child.getIntrinsicWidth(height) : this.textureSize != null ? Double.isFinite(height) ? height * this.imageAspectRatio() : this.textureSize.width() : 0; } @Override protected double measureIntrinsicHeight(double width) { return this.child != null ? this.child.getIntrinsicHeight(width) : this.textureSize != null ? Double.isFinite(width) ? width / this.imageAspectRatio() : this.textureSize.height() : 0; } @Override protected OptionalDouble measureBaselineOffset() { return this.child != null ? this.child.getBaselineOffset() : OptionalDouble.empty(); } @Override public void draw(BraidGraphics graphics) { var matrices = graphics.pose(); var stretch = this.widget.wrap == Wrap.STRETCH; var textureWidth = (int) (this.textureSize != null ? this.textureSize.width() : this.transform.width()); var textureHeight = (int) (this.textureSize != null ? this.textureSize.height() : this.transform.height()); var quadWidth = (int) (this.widget.wrap != Wrap.REPEAT ? textureWidth : this.transform.width()); var quadHeight = (int) (this.widget.wrap != Wrap.REPEAT ? textureHeight : this.transform.height()); if (stretch) { matrices.pushMatrix(); matrices.scale((int) this.transform.width() / (float) textureWidth, (int) this.transform.height() / (float) textureHeight); } var pipeline = switch (this.widget.filter) { case TEXTURE_DEFAULT -> BraidRenderPipelines.TEXTURED_DEFAULT; case NEAREST -> BraidRenderPipelines.TEXTURED_NEAREST; case LINEAR -> BraidRenderPipelines.TEXTURED_BILINEAR; }; graphics.blit( pipeline, this.widget.texture, 0, 0, 0, 0, quadWidth, quadHeight, textureWidth, textureHeight, this.widget.color.argb() ); if (stretch) { matrices.popMatrix(); } super.draw(graphics); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Tooltip.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.framework.instance.InstanceHost; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.instance.TooltipProvider; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import net.minecraft.client.gui.screens.inventory.tooltip.ClientTextTooltip; import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.Collection; import java.util.List; public class Tooltip extends SingleChildInstanceWidget { public final @Nullable List tooltip; public final Component tooltipText; public Tooltip(@NotNull List tooltip, Widget child) { super(child); this.tooltip = tooltip; this.tooltipText = null; } public Tooltip(Collection tooltip, Widget child) { this( tooltip.stream().map(Component::getVisualOrderText).map(ClientTextTooltip::new).toList(), child ); } public Tooltip(Component tooltip, Widget child) { super(child); this.tooltip = null; this.tooltipText = tooltip; } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance.ShrinkWrap implements TooltipProvider { private @Nullable List tooltip; public Instance(Tooltip widget) { super(widget); } @Override public void attachHost(InstanceHost host) { super.attachHost(host); this.setup(); } @Override public void setWidget(Tooltip widget) { super.setWidget(widget); this.setup(); } private void setup() { this.tooltip = widget.tooltipText != null ? this.host().client().font .split(widget.tooltipText, Integer.MAX_VALUE) .stream() .map(ClientTextTooltip::new) .toList() : widget.tooltip; } @Override public @Nullable List getTooltipComponentsAt(double x, double y) { return tooltip; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Transform.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.framework.instance.CustomWidgetTransform; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.instance.WidgetTransform; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.joml.Matrix3x2f; import java.util.Objects; public class Transform extends SingleChildInstanceWidget { public final Matrix3x2f matrix; public Transform(Matrix3x2f matrix, Widget child) { super(child); this.matrix = matrix; } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance.ShrinkWrap { public Instance(Transform widget) { super(widget); ((CustomWidgetTransform) this.transform).setMatrix(this.widget.matrix); } @Override public void setWidget(Transform widget) { if (Objects.equals(this.widget.matrix, widget.matrix)) { this.transform.recompute(); return; } super.setWidget(widget); ((CustomWidgetTransform) this.transform).setMatrix(this.widget.matrix); this.markNeedsLayout(); } @Override protected WidgetTransform createTransform() { return new CustomWidgetTransform(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/Visibility.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.instance.HitTestState; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import java.util.OptionalDouble; public class Visibility extends SingleChildInstanceWidget { public final boolean visible; public final boolean reportSize; public Visibility(boolean visible, boolean reportSize, Widget child) { super(child); this.visible = visible; this.reportSize = reportSize; } public Visibility(boolean visible, Widget child) { this(visible, false, child); } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance { public Instance(Visibility widget) { super(widget); } @Override public void setWidget(Visibility widget) { if (this.widget.visible == widget.visible && this.widget.reportSize == widget.reportSize) { return; } super.setWidget(widget); this.markNeedsLayout(); } @Override protected void doLayout(Constraints constraints) { var childSize = this.child.layout(constraints); if (this.widget.visible || this.widget.reportSize) { this.transform.setSize(childSize); } else { this.transform.setSize(Size.zero()); } } @Override protected double measureIntrinsicWidth(double height) { return this.widget.visible || this.widget.reportSize ? this.child.getIntrinsicWidth(height) : 0; } @Override protected double measureIntrinsicHeight(double width) { return this.widget.visible || this.widget.reportSize ? this.child.getIntrinsicHeight(width) : 0; } @Override protected OptionalDouble measureBaselineOffset() { return this.widget.visible || this.widget.reportSize ? this.child.getBaselineOffset() : OptionalDouble.empty(); } @Override public void draw(BraidGraphics graphics) { if (!this.widget.visible) return; super.draw(graphics); } @Override public void hitTest(double x, double y, HitTestState state) { if (!this.widget.visible) return; super.hitTest(x, y, state); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/basic/VisitorWidget.java ================================================ package io.wispforest.owo.braid.widgets.basic; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.proxy.ComposedProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public abstract class VisitorWidget extends Widget { public final Widget child; protected VisitorWidget(Widget child) { this.child = child; } @Override public abstract Proxy proxy(); public static class Proxy extends ComposedProxy { public final VisitorWidget.Visitor visitor; public WidgetInstance descendantInstance; public Proxy(Widget widget, VisitorWidget.Visitor visitor) { super(widget); this.visitor = visitor; } @Override public void mount(WidgetProxy parent, @Nullable Object slot) { super.mount(parent, slot); this.rebuild(); } @Override public void updateWidget(Widget newWidget) { super.updateWidget(newWidget); this.rebuild(true); } @Override protected void doRebuild() { super.doRebuild(); this.child = this.refreshChild(this.child, ((VisitorWidget)this.widget()).child, this.slot()); if (this.descendantInstance != null) { this.visitor.visit((T) this.widget(), this.descendantInstance); } } @Override public void notifyDescendantInstance(@Nullable WidgetInstance instance, @Nullable Object slot) { this.visitor.visit((T) this.widget(), instance); this.descendantInstance = instance; } } @FunctionalInterface public interface Visitor { void visit(T widget, WidgetInstance instance); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/button/Button.java ================================================ package io.wispforest.owo.braid.widgets.button; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.ControlsOverride; import io.wispforest.owo.braid.widgets.basic.Padding; import org.jetbrains.annotations.Nullable; import java.util.function.BooleanSupplier; public class Button extends StatelessWidget { public final @Nullable ButtonStyle style; public final @Nullable BooleanSupplier onClick; public final Widget child; public Button(@Nullable ButtonStyle style, @Nullable BooleanSupplier onClick, Widget child) { this.onClick = onClick; this.style = style; this.child = child; } public Button(@Nullable ButtonStyle style, @Nullable Runnable onClick, Widget child) { this(style, Clickable.alwaysClick(onClick), child); } public Button(@Nullable BooleanSupplier onClick, Widget child) { this(null, onClick, child); } public Button(@Nullable Runnable onClick, Widget child) { this(Clickable.alwaysClick(onClick), child); } public Button(@Nullable ButtonStyle style, boolean active, BooleanSupplier onClick, Widget child) { this(style, active ? onClick : null, child); } public Button(@Nullable ButtonStyle style, boolean active, Runnable onClick, Widget child) { this(style, active, Clickable.alwaysClick(onClick), child); } public Button(boolean active, BooleanSupplier onClick, Widget child) { this(null, active, onClick, child); } public Button(boolean active, Runnable onClick, Widget child) { this(active, Clickable.alwaysClick(onClick), child); } @Override public Widget build(BuildContext context) { var effectiveStyle = this.style != null ? this.style : ButtonStyle.DEFAULT; if (DefaultButtonStyle.maybeOf(context) instanceof ButtonStyle contextStyle) { effectiveStyle = effectiveStyle.overriding(contextStyle); } Widget content = new Padding( effectiveStyle.padding() != null ? effectiveStyle.padding() : Insets.all(5), this.child ); var disabled = this.onClick == null || ControlsOverride.controlsDisabled(context); content = effectiveStyle.builder() != null ? effectiveStyle.builder().build(!disabled, content) : new ButtonPanel(!disabled, content); return new Clickable( this.onClick, effectiveStyle.clickSound(), content ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/button/ButtonPanel.java ================================================ package io.wispforest.owo.braid.widgets.button; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.HoverableBuilder; import io.wispforest.owo.braid.widgets.basic.Panel; import io.wispforest.owo.braid.widgets.focus.Focusable; import io.wispforest.owo.ui.component.ButtonComponent; public class ButtonPanel extends StatelessWidget { public final boolean active; public final Widget child; public ButtonPanel(boolean active, Widget child) { this.active = active; this.child = child; } @Override public Widget build(BuildContext context) { return new HoverableBuilder( (innerContext, hovered, child) -> { return new Panel( this.active ? (hovered || Focusable.shouldShowHighlight(context)) ? ButtonComponent.HOVERED_TEXTURE : ButtonComponent.ACTIVE_TEXTURE : ButtonComponent.DISABLED_TEXTURE, child ); }, this.child ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/button/ButtonStyle.java ================================================ package io.wispforest.owo.braid.widgets.button; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.widget.Widget; import net.minecraft.sounds.SoundEvent; import org.jetbrains.annotations.Nullable; public record ButtonStyle( @Nullable ContentBuilder builder, @Nullable Insets padding, @Nullable SoundEvent clickSound ) { public ButtonStyle overriding(ButtonStyle other) { return new ButtonStyle( this.builder != null ? this.builder : other.builder, this.padding != null ? this.padding : other.padding, this.clickSound != null ? this.clickSound : other.clickSound ); } public static final ButtonStyle DEFAULT = new ButtonStyle(null, null, null); @FunctionalInterface public interface ContentBuilder { Widget build(boolean active, Widget child); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/button/Clickable.java ================================================ package io.wispforest.owo.braid.widgets.button; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.ControlsOverride; import io.wispforest.owo.braid.widgets.intents.Interactable; import io.wispforest.owo.ui.util.UISounds; import net.minecraft.sounds.SoundEvent; import net.minecraft.sounds.SoundEvents; import org.jetbrains.annotations.Nullable; import java.util.function.BooleanSupplier; public class Clickable extends StatelessWidget { public final @Nullable BooleanSupplier onClick; public final @Nullable SoundEvent clickSound; public final Widget child; public Clickable(@Nullable BooleanSupplier onClick, @Nullable SoundEvent clickSound, Widget child) { this.onClick = onClick; this.clickSound = clickSound; this.child = child; } public Clickable(@Nullable BooleanSupplier onClick, Widget child) { this(onClick, null, child); } public Clickable(boolean active, BooleanSupplier onClick, @Nullable SoundEvent clickSound, Widget child) { this(active ? onClick : null, clickSound, child); } public Clickable(boolean active, BooleanSupplier onClick, Widget child) { this(active, onClick, null, child); } @Override public Widget build(BuildContext context) { if (this.onClick == null || ControlsOverride.controlsDisabled(context)) { return this.child; } var effectiveSound = this.clickSound != null ? this.clickSound : SoundEvents.UI_BUTTON_CLICK.value(); return Interactable.primary( () -> { if (this.onClick.getAsBoolean()) { UISounds.play(effectiveSound); } }, this.child ); } // --- public static @Nullable BooleanSupplier alwaysClick(@Nullable Runnable onClick) { if (onClick == null) { return null; } return () -> { onClick.run(); return true; }; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/button/DefaultButtonStyle.java ================================================ package io.wispforest.owo.braid.widgets.button; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Builder; import org.jetbrains.annotations.Nullable; public class DefaultButtonStyle extends InheritedWidget { public final ButtonStyle style; public DefaultButtonStyle(ButtonStyle style, Widget child) { super(child); this.style = style; } public static Widget merge(ButtonStyle style, Widget child) { return new Builder(context -> { var contextStyle = DefaultButtonStyle.maybeOf(context); return new DefaultButtonStyle(contextStyle != null ? style.overriding(contextStyle) : style, child); }); } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return !this.style.equals(((DefaultButtonStyle) newWidget).style); } public static @Nullable ButtonStyle maybeOf(BuildContext context) { var widget = context.dependOnAncestor(DefaultButtonStyle.class); if (widget != null) { return widget.style; } else { return null; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/button/MessageButton.java ================================================ package io.wispforest.owo.braid.widgets.button; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.label.Label; import io.wispforest.owo.braid.widgets.label.LabelStyle; import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.Nullable; public class MessageButton extends StatelessWidget { public final Component text; public final @Nullable Runnable onClick; public MessageButton(Component text, @Nullable Runnable onClick) { this.text = text; this.onClick = onClick; } public MessageButton(Component text, boolean active, Runnable onClick) { this(text, active ? onClick : null); } @Override public Widget build(BuildContext context) { return new Button( this.onClick, //TODO: abstract away the million places where a ternary operator is used to determine the label style for a possibly disabled button new Label( this.onClick != null ? LabelStyle.SHADOW : new LabelStyle(null, Color.formatting(ChatFormatting.GRAY), null, false), true, this.text ) ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/checkbox/Checkbox.java ================================================ package io.wispforest.owo.braid.widgets.checkbox; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.SpriteWidget; import io.wispforest.owo.braid.widgets.basic.Builder; import io.wispforest.owo.braid.widgets.basic.ControlsOverride; import io.wispforest.owo.braid.widgets.checkbox.TogglingClickable.CheckboxCallback; import io.wispforest.owo.braid.widgets.focus.Focusable; import io.wispforest.owo.braid.widgets.stack.Stack; import io.wispforest.owo.braid.widgets.stack.StackBase; import net.minecraft.client.resources.model.Material; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.Nullable; public class Checkbox extends StatelessWidget { public final @Nullable CheckboxStyle style; public final boolean checked; public final @Nullable CheckboxCallback onUpdate; public Checkbox(@Nullable CheckboxStyle style, boolean checked, @Nullable CheckboxCallback onUpdate) { this.checked = checked; this.style = style; this.onUpdate = onUpdate; } public Checkbox(boolean checked, @Nullable CheckboxCallback onUpdate) { this(null, checked, onUpdate); } public Checkbox(@Nullable CheckboxStyle style, boolean checked, boolean active, CheckboxCallback onUpdate) { this(style, checked, active ? onUpdate : null); } public Checkbox(boolean checked, boolean active, CheckboxCallback onUpdate) { this(null, checked, active, onUpdate); } @Override public Widget build(BuildContext context) { var effectiveStyle = this.style != null ? this.style : CheckboxStyle.DEFAULT; if (DefaultCheckboxStyle.maybeOf(context) instanceof CheckboxStyle contextStyle) { effectiveStyle = effectiveStyle.overriding(contextStyle); } var disabled = this.onUpdate == null || ControlsOverride.controlsDisabled(context); var background = effectiveStyle.backgroundBuilder() != null ? effectiveStyle.backgroundBuilder().build(disabled) : DEFAULT_BACKGROUND; var checkmark = effectiveStyle.checkmark() != null ? effectiveStyle.checkmark() : DEFAULT_CHECKMARK; return new TogglingClickable( this.checked, this.onUpdate, effectiveStyle.clickSound(), this.checked ? new Stack(new StackBase(background), checkmark) : background ); } // --- public static final Material SELECTED_HIGHLIGHTED_TEXTURE = new Material( SpriteWidget.GUI_ATLAS_ID, Identifier.withDefaultNamespace("widget/checkbox_selected_highlighted") ); public static final Material SELECTED_TEXTURE = new Material( SpriteWidget.GUI_ATLAS_ID, Identifier.withDefaultNamespace("widget/checkbox_selected") ); public static final Material HIGHLIGHTED_TEXTURE = new Material( SpriteWidget.GUI_ATLAS_ID, Identifier.withDefaultNamespace("widget/checkbox_highlighted") ); public static final Material TEXTURE = new Material( SpriteWidget.GUI_ATLAS_ID, Identifier.withDefaultNamespace("widget/checkbox") ); // --- private static final Widget DEFAULT_BACKGROUND = new Builder(context -> { return new SpriteWidget(Focusable.shouldShowHighlight(context) ? HIGHLIGHTED_TEXTURE : TEXTURE); }); private static final Widget DEFAULT_CHECKMARK = new Builder(context -> { return new SpriteWidget(Focusable.shouldShowHighlight(context) ? SELECTED_HIGHLIGHTED_TEXTURE : SELECTED_TEXTURE); }); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/checkbox/CheckboxStyle.java ================================================ package io.wispforest.owo.braid.widgets.checkbox; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.SpriteWidget; import io.wispforest.owo.braid.widgets.basic.Builder; import io.wispforest.owo.braid.widgets.basic.Center; import io.wispforest.owo.braid.widgets.focus.Focusable; import net.minecraft.client.resources.model.Material; import net.minecraft.sounds.SoundEvent; import org.jetbrains.annotations.Nullable; public record CheckboxStyle( @Nullable BackgroundBuilder backgroundBuilder, @Nullable Widget checkmark, @Nullable SoundEvent clickSound ) { public CheckboxStyle overriding(CheckboxStyle other) { return new CheckboxStyle( this.backgroundBuilder != null ? this.backgroundBuilder : other.backgroundBuilder, this.checkmark != null ? this.checkmark : other.checkmark, this.clickSound != null ? this.clickSound : other.clickSound ); } public static final CheckboxStyle DEFAULT = new CheckboxStyle(null, null, null); @FunctionalInterface public interface BackgroundBuilder { Widget build(boolean active); } // --- public static final Material BRAID_BACKGROUND_TEXTURE = new Material( SpriteWidget.GUI_ATLAS_ID, Owo.id("braid_checkbox") ); public static final Material BRAID_BACKGROUND_FOCUSED_TEXTURE = new Material( SpriteWidget.GUI_ATLAS_ID, Owo.id("braid_checkbox_focused") ); public static final Material BRAID_CHECKMARK_TEXTURE = new Material( SpriteWidget.GUI_ATLAS_ID, Owo.id("braid_checkmark") ); private static final Widget BRAID_BACKGROUND = new Builder(context -> { return new SpriteWidget(Focusable.shouldShowHighlight(context) ? BRAID_BACKGROUND_FOCUSED_TEXTURE : BRAID_BACKGROUND_TEXTURE); }); private static final Widget BRAID_CHECKMARK = new Center(new SpriteWidget(BRAID_CHECKMARK_TEXTURE)); public static final CheckboxStyle BRAID = new CheckboxStyle( active -> BRAID_BACKGROUND, BRAID_CHECKMARK, null ); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/checkbox/DefaultCheckboxStyle.java ================================================ package io.wispforest.owo.braid.widgets.checkbox; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Builder; import org.jetbrains.annotations.Nullable; public class DefaultCheckboxStyle extends InheritedWidget { public final CheckboxStyle style; public DefaultCheckboxStyle(CheckboxStyle style, Widget child) { super(child); this.style = style; } public static Widget merge(CheckboxStyle style, Widget child) { return new Builder(context -> { var contextStyle = DefaultCheckboxStyle.maybeOf(context); return new DefaultCheckboxStyle(contextStyle != null ? style.overriding(contextStyle) : style, child); }); } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return !this.style.equals(((DefaultCheckboxStyle) newWidget).style); } public static @Nullable CheckboxStyle maybeOf(BuildContext context) { var widget = context.dependOnAncestor(DefaultCheckboxStyle.class); if (widget != null) { return widget.style; } else { return null; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/checkbox/TogglingClickable.java ================================================ package io.wispforest.owo.braid.widgets.checkbox; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.button.Clickable; import net.minecraft.sounds.SoundEvent; import org.jetbrains.annotations.Nullable; public class TogglingClickable extends StatelessWidget { public final boolean checked; public final @Nullable CheckboxCallback onUpdate; public final @Nullable SoundEvent clickSound; public final Widget child; public TogglingClickable(boolean checked, @Nullable CheckboxCallback onUpdate, @Nullable SoundEvent clickSound, Widget child) { this.checked = checked; this.onUpdate = onUpdate; this.clickSound = clickSound; this.child = child; } public TogglingClickable(boolean checked, @Nullable CheckboxCallback onUpdate, Widget child) { this(checked, onUpdate, null, child); } public TogglingClickable(boolean checked, boolean active, @Nullable SoundEvent clickSound, CheckboxCallback onUpdate, Widget child) { this(checked, active ? onUpdate : null, clickSound, child); } public TogglingClickable(boolean checked, boolean active, CheckboxCallback onUpdate, Widget child) { this(checked, active, null, onUpdate, child); } @Override public Widget build(BuildContext context) { return new Clickable( this.onUpdate != null ? Clickable.alwaysClick(() -> this.onUpdate.accept(!this.checked)) : null, this.clickSound, this.child ); } @FunctionalInterface public interface CheckboxCallback { void accept(boolean nowChecked); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/collapsible/Collapsible.java ================================================ package io.wispforest.owo.braid.widgets.collapsible; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.SpriteWidget; import io.wispforest.owo.braid.widgets.basic.*; import io.wispforest.owo.braid.widgets.flex.Column; import io.wispforest.owo.braid.widgets.flex.CrossAxisAlignment; import io.wispforest.owo.braid.widgets.flex.MainAxisAlignment; import io.wispforest.owo.braid.widgets.flex.Row; import io.wispforest.owo.braid.widgets.intents.Interactable; import io.wispforest.owo.braid.widgets.stack.Stack; import io.wispforest.owo.braid.widgets.stack.StackBase; import java.util.ArrayList; public class Collapsible extends StatefulWidget { public final boolean showVerticalRule; public final boolean collapsed; public final CollapsibleCallback onToggled; public final Widget title; public final Widget content; public Collapsible(boolean showVerticalRule, boolean collapsed, CollapsibleCallback onToggled, Widget title, Widget content) { this.showVerticalRule = showVerticalRule; this.collapsed = collapsed; this.onToggled = onToggled; this.title = title; this.content = content; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { public boolean hovered = false; @Override public Widget build(BuildContext context) { var body = new ArrayList(); if (this.widget().showVerticalRule) { body.add(new Align( Alignment.LEFT, new Padding( Insets.left(6), new Sized( 1, Double.POSITIVE_INFINITY, new Box( this.hovered ? Color.WHITE : Color.mix(.5f, Color.WHITE, Color.BLACK) ) ) ) )); } body.add(new StackBase( new Padding(Insets.left(10), this.widget().content) )); return new Column( new MouseArea( widget -> widget .enterCallback(this.widget().showVerticalRule ? () -> this.setState(() -> this.hovered = true) : null) .exitCallback(this.widget().showVerticalRule ? () -> this.setState(() -> this.hovered = false) : null), new Row( MainAxisAlignment.START, CrossAxisAlignment.CENTER, new Sized( 12, 12, Interactable.primary( () -> this.widget().onToggled.onToggled(!this.widget().collapsed), new Center( new SpriteWidget(Owo.id(this.widget().collapsed ? "braid_collapsible_closed" : "braid_collapsible_open")) ) ) ), this.widget().title ) ), new Visibility( !this.widget().collapsed, new Stack( body ) ) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/collapsible/CollapsibleCallback.java ================================================ package io.wispforest.owo.braid.widgets.collapsible; @FunctionalInterface public interface CollapsibleCallback { void onToggled(boolean nowCollapsed); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/collapsible/LazyCollapsible.java ================================================ package io.wispforest.owo.braid.widgets.collapsible; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Padding; public class LazyCollapsible extends StatefulWidget { public final boolean showVerticalRule; public final boolean collapsed; public final CollapsibleCallback onToggled; public final Widget title; public final Widget content; public LazyCollapsible(boolean showVerticalRule, boolean collapsed, CollapsibleCallback onToggled, Widget title, Widget content) { this.showVerticalRule = showVerticalRule; this.collapsed = collapsed; this.onToggled = onToggled; this.title = title; this.content = content; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { public boolean expandedOnce = false; @Override public Widget build(BuildContext context) { var widget = this.widget(); if (!widget.collapsed && !this.expandedOnce) { this.expandedOnce = true; } return new Collapsible( widget.showVerticalRule, widget.collapsed, widget.onToggled, widget.title, this.expandedOnce ? widget.content : new Padding(Insets.none()) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/combobox/ComboBox.java ================================================ package io.wispforest.owo.braid.widgets.combobox; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.core.ListenableValue; import io.wispforest.owo.braid.core.RelativePosition; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.SpriteWidget; import io.wispforest.owo.braid.widgets.basic.HoverableBuilder; import io.wispforest.owo.braid.widgets.basic.Padding; import io.wispforest.owo.braid.widgets.basic.Panel; import io.wispforest.owo.braid.widgets.flex.CrossAxisAlignment; import io.wispforest.owo.braid.widgets.flex.Flexible; import io.wispforest.owo.braid.widgets.flex.MainAxisAlignment; import io.wispforest.owo.braid.widgets.flex.Row; import io.wispforest.owo.braid.widgets.intents.*; import io.wispforest.owo.braid.widgets.overlay.Overlay; import io.wispforest.owo.braid.widgets.overlay.OverlayEntry; import io.wispforest.owo.braid.widgets.overlay.OverlayEntryBuilder; import io.wispforest.owo.braid.widgets.textinput.EditableText; import io.wispforest.owo.braid.widgets.textinput.TextEditingController; import io.wispforest.owo.braid.widgets.textinput.TextEditingValue; import io.wispforest.owo.braid.widgets.textinput.TextSelection; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.OptionalInt; import java.util.function.Function; public class ComboBox extends StatefulWidget { public static final Identifier ACTIVE_TEXTURE = Owo.id("braid_combobox/active"); public static final Identifier HOVERED_TEXTURE = Owo.id("braid_combobox/hovered"); public static final Identifier DISABLED_TEXTURE = Owo.id("braid_combobox/disabled"); // --- public final Function optionToName; public final List options; public final @Nullable T selectedOption; public final SelectCallback onSelect; public ComboBox(Function optionToName, List options, @Nullable T selectedOption, SelectCallback onSelect) { this.optionToName = optionToName; this.options = options; this.selectedOption = selectedOption; this.onSelect = onSelect; } public ComboBox(List options, @Nullable T selectedOption, SelectCallback onSelect) { this(option -> Component.literal(Objects.toString(option)), options, selectedOption, onSelect); } @Override public WidgetState> createState() { return new State<>(); } public List optionNames() { return this.options.stream().map(this::nameOption).toList(); } public Component nameOption(@Nullable T option) { return option != null ? this.optionToName.apply(option) : Component.empty(); } public interface SelectCallback { void onSelect(T option); } private static class State extends WidgetState> { private final Runnable listener = this::textListener; private TextEditingController controller; private String lastText; private @Nullable OverlayEntry currentOverlay; private @Nullable ListenableValue> buttonsState; private boolean isOpen() { return this.currentOverlay != null; } @Override public void init() { this.controller = new TextEditingController(this.widget().nameOption(widget().selectedOption).getString()); this.controller.addListener(this.listener); } @Override public void didUpdateWidget(ComboBox oldWidget) { if (!Objects.equals(this.widget().selectedOption, oldWidget.selectedOption)) { this.resetTextInput(); } } @Override public void dispose() { this.controller.removeListener(this.listener); if (this.currentOverlay != null) { this.currentOverlay.remove(); } } private void textListener() { if (Objects.equals(this.controller.value().text(), this.lastText)) return; this.lastText = controller.value().text(); if (this.widget().optionNames().stream().map(Component::getString).anyMatch(s -> s.equals(this.controller.value().text()))) { return; } if (!this.isOpen()) { this.open(); } this.buttonsState.setValue(new ComboBoxButtonsState<>( this.widget().options.stream() .filter(option -> this.widget().nameOption(option).getString().startsWith(this.controller.value().text())) .toList(), OptionalInt.empty() )); } private void resetTextInput() { var text = this.widget().nameOption(this.widget().selectedOption).getString(); this.controller.setValue(new TextEditingValue( text, TextSelection.collapsed(text.length()) )); } private void select(T option) { this.widget().onSelect.onSelect(option); this.resetTextInput(); if (this.currentOverlay != null) { this.currentOverlay.remove(); } } private void trySelectHighlightedValue() { if (this.buttonsState == null) return; var state = this.buttonsState.value(); if (state.highlightedOptionIdx().isEmpty() && state.options().isEmpty()) { return; } this.select( state.highlightedOptionIdx().isPresent() ? state.options().get(state.highlightedOptionIdx().getAsInt()) : state.options().getFirst() ); } private void cycle(int offset) { if (this.isOpen()) { var state = this.buttonsState.value(); var currentOptionIdx = state.highlightedOptionIdx().orElse(offset > 0 ? -1 : 0); var nextOptionIdx = Math.floorMod(currentOptionIdx + offset, state.options().size()); this.buttonsState.setValue(new ComboBoxButtonsState<>( state.options(), OptionalInt.of(nextOptionIdx) )); } else { var currentOptionIdx = this.widget().selectedOption != null ? this.widget().options.indexOf(this.widget().selectedOption) : -Integer.signum(offset); var nextOptionIdx = Math.floorMod(currentOptionIdx + offset, this.widget().options.size()); this.select(this.widget().options.get(nextOptionIdx)); } } private void open() { this.setState(() -> { this.buttonsState = new ListenableValue<>(new ComboBoxButtonsState<>(this.widget().options, OptionalInt.empty())); this.currentOverlay = Overlay.of(this.context()).add( new OverlayEntryBuilder( new ComboBoxButtons<>( this.buttonsState, this.context().instance().transform.width(), this.widget()::nameOption, this::select ), new RelativePosition(this.context(), 0, this.context().instance().transform.height() - 1) ) .dismissOverlayOnClick() .onRemove(() -> this.setState(() -> { this.currentOverlay = null; this.buttonsState = null; })) ); }); } private void close() { if (this.currentOverlay != null) { this.currentOverlay.remove(); } } @Override public Widget build(BuildContext context) { var expanded = this.isOpen(); return new Interactable( SHORTCUTS, widget -> widget .focusLostCallback(this::resetTextInput) .cursorStyle(CursorStyle.HAND) .addCallbackAction(CycleIntent.class, (actionCtx, intent) -> this.cycle(intent.previous() ? -1 : 1)) .addCallbackAction(SelectIntent.class, (actionCtx, intent) -> { if (expanded) { this.trySelectHighlightedValue(); } else { this.open(); } }) .addCallbackAction(PrimaryActionIntent.class, (actionCtx, intent) -> { if (expanded) { this.close(); } else { this.open(); } }), new HoverableBuilder( (hoverableContext, hovered, child) -> new Panel( (expanded || hovered) ? HOVERED_TEXTURE : ACTIVE_TEXTURE, child ), new Padding( Insets.of(4, 4, 6, 0), new Row( MainAxisAlignment.START, CrossAxisAlignment.CENTER, new Flexible( new EditableText( this.controller, widget -> widget .textShadow(true) .singleLine() ) ), new Padding( Insets.horizontal(3), new SpriteWidget(Owo.id("braid_combo_box_arrow")) ) ) ) ) ); } } // --- private static final Map, Intent> SHORTCUTS = Map.of( List.of(ShortcutTrigger.UP), new CycleIntent(true), List.of(ShortcutTrigger.DOWN), new CycleIntent(false), List.of(new ShortcutTrigger(Trigger.ofKey(GLFW.GLFW_KEY_ENTER), Trigger.ofKey(GLFW.GLFW_KEY_KP_ENTER))), new SelectIntent(), List.of(ShortcutTrigger.LEFT_CLICK), PrimaryActionIntent.INSTANCE ); } record CycleIntent(boolean previous) implements Intent {} record SelectIntent() implements Intent {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/combobox/ComboBoxButtons.java ================================================ package io.wispforest.owo.braid.widgets.combobox; import io.wispforest.owo.braid.core.*; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.*; import io.wispforest.owo.braid.widgets.button.Clickable; import io.wispforest.owo.braid.widgets.flex.Column; import io.wispforest.owo.braid.widgets.label.Label; import io.wispforest.owo.braid.widgets.label.LabelStyle; import io.wispforest.owo.braid.widgets.scroll.FlatScrollbar; import io.wispforest.owo.braid.widgets.scroll.Scrollable; import io.wispforest.owo.braid.widgets.scroll.ScrollableWithBars; import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.function.Function; class ComboBoxButtons extends StatelessWidget { public final ListenableValue> state; public final double width; public final Function<@Nullable T, Component> optionToName; public final ComboBox.SelectCallback onSelect; public ComboBoxButtons(ListenableValue> state, double width, Function<@Nullable T, Component> optionToName, ComboBox.SelectCallback onSelect) { this.state = state; this.width = width; this.optionToName = optionToName; this.onSelect = onSelect; } @Override public Widget build(BuildContext context) { return new Sized( this.width, null, new Box( Color.WHITE, true, new Padding( Insets.all(1), new Blur( 5, 10, false, new Box( Color.BLACK.withA(.6), new ListenableBuilder( this.state, (listenableContext) -> { var buttons = new ArrayList(); var state = this.state.value(); for (var idx = 0; idx < state.options().size(); idx++) { var option = state.options().get(idx); buttons.add(new HighlightableButton<>( this.onSelect, option, idx == state.highlightedOptionIdx().orElse(-1), this.optionToName )); } return new Constrain( Constraints.ofMaxHeight(13 * 8), new ScrollableWithBars( null, null, null, 4, (layoutAxis, scrollController) -> new FlatScrollbar(layoutAxis, scrollController, Color.WHITE, Color.WHITE), new Column(buttons) ) ); } ) ) ) ) ) ); } private static class HighlightableButton extends StatefulWidget { public final ComboBox.SelectCallback onSelect; public final T option; public final boolean highlighted; public final Function<@Nullable T, Component> optionToName; public HighlightableButton(ComboBox.SelectCallback onSelect, T option, boolean highlighted, Function<@Nullable T, Component> optionToName) { this.onSelect = onSelect; this.option = option; this.highlighted = highlighted; this.optionToName = optionToName; } @Override public WidgetState> createState() { return new State<>(); } public static class State extends WidgetState> { @Override public void didUpdateWidget(HighlightableButton oldWidget) { if (!oldWidget.highlighted && this.widget().highlighted) { this.schedulePostLayoutCallback(() -> Scrollable.reveal(this.context())); } } @Override public Widget build(BuildContext context) { return new Clickable( Clickable.alwaysClick(() -> this.widget().onSelect.onSelect(this.widget().option)), new HoverableBuilder( (hoverableContext, hovered) -> { var highlighted = hovered || this.widget().highlighted; return new Box( highlighted ? Color.WHITE.withA(.1f): new Color(0), new Padding( Insets.all(2).withLeft(3), new Label( new LabelStyle(Alignment.LEFT, highlighted ? Color.rgb(ChatFormatting.YELLOW.getColor()) : null, null, highlighted), true, this.widget().optionToName.apply(this.widget().option) ) ) ); } ) ); } } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/combobox/ComboBoxButtonsState.java ================================================ package io.wispforest.owo.braid.widgets.combobox; import java.util.List; import java.util.OptionalInt; record ComboBoxButtonsState(List options, OptionalInt highlightedOptionIdx) {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/cycle/Cycler.java ================================================ package io.wispforest.owo.braid.widgets.cycle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import net.minecraft.util.Mth; import java.util.Arrays; import java.util.List; public class Cycler extends StatelessWidget { //Psyckler public final List values; public final int currentIndex; public final boolean wrap; public final CyclerCallback onChanged; public final CyclingWidgetBuilder builder; public Cycler(List values, T currentValue, boolean wrap, CyclerCallback onChanged, CyclingWidgetBuilder builder) { this.values = values; this.currentIndex = this.values.indexOf(currentValue); this.wrap = wrap; this.onChanged = onChanged; this.builder = builder; } public Cycler(List values, T currentValue, CyclerCallback onChanged, CyclingWidgetBuilder builder) { this(values, currentValue, true, onChanged, builder); } public static Cycler forBoolean(boolean value, boolean wrap, CyclerCallback onChanged, CyclingWidgetBuilder builder) { return new Cycler<>(List.of(false, true), value, wrap, onChanged, builder); } public static Cycler forBoolean(boolean value, CyclerCallback onChanged, CyclingWidgetBuilder builder) { return Cycler.forBoolean(value, true, onChanged, builder); } @SuppressWarnings("unchecked") public static > Cycler forEnum(T value, boolean wrap, CyclerCallback onChanged, CyclingWidgetBuilder builder) { return new Cycler<>((List) Arrays.stream(value.getClass().getEnumConstants()).toList(), value, wrap, onChanged, builder); } public static > Cycler forEnum(T value, CyclerCallback onChanged, CyclingWidgetBuilder builder) { return Cycler.forEnum(value, true, onChanged, builder); } @Override public Widget build(BuildContext context) { return this.builder.build( this.values.get(this.currentIndex), this.currentIndex, amount -> { var newIndex = this.wrap ? Mth.positiveModulo(this.currentIndex + amount, this.values.size()) : Mth.clamp(this.currentIndex + amount, 0, this.values.size() - 1); if (newIndex == this.currentIndex) return false; this.onChanged.cycle(this.values.get(newIndex), newIndex); return true; } ); } @FunctionalInterface public interface CyclerCallback { void cycle(T newValue, int newIndex); } @FunctionalInterface public interface CyclingWidgetBuilder { Widget build(T currentValue, int currentIndex, CycleFunction cycle); } @FunctionalInterface public interface CycleFunction { boolean cycle(int amount); default boolean forScroll(double amount) { if (amount == 0) return false; return this.cycle(amount > 0 ? 1 : -1); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/cycle/CyclingButton.java ================================================ package io.wispforest.owo.braid.widgets.cycle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.ControlsOverride; import io.wispforest.owo.braid.widgets.button.Button; import io.wispforest.owo.braid.widgets.button.ButtonStyle; import io.wispforest.owo.braid.widgets.button.DefaultButtonStyle; import org.jetbrains.annotations.Nullable; import java.util.List; public class CyclingButton extends StatelessWidget { public final List values; public final T currentValue; public final boolean wrap; public final @Nullable Cycler.CyclerCallback onChanged; public final Widget child; public CyclingButton(List values, T currentValue, boolean wrap, @Nullable Cycler.CyclerCallback onChanged, Widget child) { this.values = values; this.currentValue = currentValue; this.wrap = wrap; this.onChanged = onChanged; this.child = child; } public CyclingButton(List values, T currentValue, boolean wrap, Cycler.CyclerCallback onChanged, boolean active, Widget child) { this(values, currentValue, wrap, active ? onChanged : null, child); } public CyclingButton(List values, T currentValue, @Nullable Cycler.CyclerCallback onChanged, Widget child) { this(values, currentValue, true, onChanged, child); } public CyclingButton(List values, T currentValue, Cycler.CyclerCallback onChanged, boolean active, Widget child) { this(values, currentValue, true, active ? onChanged : null, child); } public static CyclingButton forBoolean(boolean value, @Nullable Cycler.CyclerCallback onChanged, Widget child) { return new CyclingButton<>(List.of(false, true), value, true, onChanged, child); } public static CyclingButton forBoolean(boolean value, Cycler.CyclerCallback onChanged, boolean active, Widget child) { return CyclingButton.forBoolean(value, active ? onChanged : null, child); } public static > CyclingButton forEnum(T value, boolean wrap, @Nullable Cycler.CyclerCallback onChanged, Widget child) { return new CyclingButton<>(List.of(value.getDeclaringClass().getEnumConstants()), value, wrap, onChanged, child); } public static > CyclingButton forEnum(T value, boolean wrap, Cycler.CyclerCallback onChanged, boolean active, Widget child) { return CyclingButton.forEnum(value, wrap, active ? onChanged : null, child); } public static > CyclingButton forEnum(T value, @Nullable Cycler.CyclerCallback onChanged, Widget child) { return CyclingButton.forEnum(value, true, onChanged, child); } public static > CyclingButton forEnum(T value, Cycler.CyclerCallback onChanged, boolean active, Widget child) { return CyclingButton.forEnum(value, true, onChanged, active, child); } @Override public Widget build(BuildContext context) { Widget content = this.child; if (this.onChanged != null && !ControlsOverride.controlsDisabled(context)) { // TODO: properly override the style once this is setupcallbackified var clickSound = DefaultButtonStyle.maybeOf(context) instanceof ButtonStyle style ? style.clickSound() : null; content = new Cycler<>( this.values, this.currentValue, this.wrap, this.onChanged, (currentValue, currentIndex, cycle) -> { return new CyclingClickable( cycle, clickSound, true, new Button( () -> cycle.cycle(1), this.child ) ); } ); } return content; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/cycle/CyclingClickable.java ================================================ package io.wispforest.owo.braid.widgets.cycle; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.MouseArea; import io.wispforest.owo.braid.widgets.intents.*; import io.wispforest.owo.ui.util.UISounds; import net.minecraft.sounds.SoundEvent; import net.minecraft.sounds.SoundEvents; import org.jetbrains.annotations.Nullable; import java.util.List; import java.util.Map; import java.util.function.ToIntFunction; public class CyclingClickable extends StatelessWidget { public final @Nullable Cycler.CycleFunction cycle; public final @Nullable SoundEvent clickSound; public final boolean skipFocusTraversal; public final Widget child; public CyclingClickable(@Nullable Cycler.CycleFunction cycle, @Nullable SoundEvent clickSound, boolean skipFocusTraversal, Widget child) { this.cycle = cycle; this.clickSound = clickSound; this.skipFocusTraversal = skipFocusTraversal; this.child = child; } @Override public Widget build(BuildContext context) { if (this.cycle == null) { return this.child; } return new MouseArea( widget -> widget.scrollCallback((horizontal, vertical) -> this.cycle.forScroll(vertical)), new Interactable( SHORTCUTS, widget -> widget .cursorStyle(CursorStyle.HAND) .skipTraversal(this.skipFocusTraversal) .addCallbackAction( AdjustIntent.class, this.cycleCallback(this.cycle, intent -> intent.direction().offset()) ).addCallbackAction( PrimaryActionIntent.class, this.cycleCallback(this.cycle, intent -> 1) ).addCallbackAction( SecondaryActionIntent.class, this.cycleCallback(this.cycle, intent -> -1) ), child ) ); } private Action.Callback cycleCallback(Cycler.CycleFunction cycle, ToIntFunction offset) { var sound = this.clickSound != null ? this.clickSound : SoundEvents.UI_BUTTON_CLICK.value(); return (actionCtx, intent) -> { if (cycle.cycle(offset.applyAsInt(intent))) { UISounds.play(sound); } }; } // --- private static final Map, Intent> SHORTCUTS = Map.of( List.of(ShortcutTrigger.of(ShortcutTrigger.UP, ShortcutTrigger.RIGHT)), new AdjustIntent(AdjustIntent.Direction.INCREMENT), List.of(ShortcutTrigger.of(ShortcutTrigger.RIGHT_CLICK, ShortcutTrigger.DOWN, ShortcutTrigger.LEFT)), new AdjustIntent(AdjustIntent.Direction.DECREMENT) ); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/cycle/MessageCyclingButton.java ================================================ package io.wispforest.owo.braid.widgets.cycle; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.label.Label; import io.wispforest.owo.braid.widgets.label.LabelStyle; import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.Nullable; import java.util.List; public class MessageCyclingButton extends StatelessWidget { public final List values; public final T currentValue; public final boolean wrap; public final Component text; public final @Nullable Cycler.CyclerCallback onChanged; public MessageCyclingButton(List values, T currentValue, boolean wrap, Component text, @Nullable Cycler.CyclerCallback onChanged) { this.values = values; this.currentValue = currentValue; this.wrap = wrap; this.text = text; this.onChanged = onChanged; } public MessageCyclingButton(List values, T currentValue, boolean wrap, Component text, Cycler.CyclerCallback onChanged, boolean active) { this(values, currentValue, wrap, text, active ? onChanged : null); } public MessageCyclingButton(List values, T currentValue, Component text, @Nullable Cycler.CyclerCallback onChanged) { this(values, currentValue, true, text, onChanged); } public MessageCyclingButton(List values, T currentValue, Component text, Cycler.CyclerCallback onChanged, boolean active) { this(values, currentValue, true, text, onChanged, active); } public static MessageCyclingButton forBoolean(boolean value, Component text, @Nullable Cycler.CyclerCallback onChanged) { return new MessageCyclingButton<>(List.of(false, true), value, true, text, onChanged); } public static MessageCyclingButton forBoolean(boolean value, Component text, Cycler.CyclerCallback onChanged, boolean active) { return MessageCyclingButton.forBoolean(value, text, active ? onChanged : null); } public static > MessageCyclingButton forEnum(T value, boolean wrap, Component text, @Nullable Cycler.CyclerCallback onChanged) { return new MessageCyclingButton<>(List.of(value.getDeclaringClass().getEnumConstants()), value, wrap, text, onChanged); } public static > MessageCyclingButton forEnum(T value, boolean wrap, Component text, Cycler.CyclerCallback onChanged, boolean active) { return MessageCyclingButton.forEnum(value, wrap, text, active ? onChanged : null); } public static > MessageCyclingButton forEnum(T value, Component text, @Nullable Cycler.CyclerCallback onChanged) { return MessageCyclingButton.forEnum(value, true, text, onChanged); } public static > MessageCyclingButton forEnum(T value, Component text, Cycler.CyclerCallback onChanged, boolean active) { return MessageCyclingButton.forEnum(value, true, text, active ? onChanged : null); } @Override public Widget build(BuildContext context) { return new CyclingButton<>( this.values, this.currentValue, this.wrap, this.onChanged, //TODO: abstract away the million places where a ternary operator is used to determine the label style for a possibly disabled button new Label( this.onChanged != null ? LabelStyle.SHADOW : new LabelStyle(null, Color.formatting(ChatFormatting.GRAY), null, false), true, this.text ) ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/drag/DragArena.java ================================================ package io.wispforest.owo.braid.widgets.drag; import io.wispforest.owo.braid.framework.instance.MultiChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.MultiChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import java.util.Arrays; import java.util.List; public class DragArena extends MultiChildInstanceWidget { public DragArena(List children) { super(children); } public DragArena(Widget... children) { this(Arrays.asList(children)); } @Override public MultiChildWidgetInstance instantiate() { return new DragArenaInstance(this); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/drag/DragArenaElement.java ================================================ package io.wispforest.owo.braid.widgets.drag; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.VisitorWidget; public class DragArenaElement extends VisitorWidget { public final double x, y; public DragArenaElement(double x, double y, Widget child) { super(child); this.x = x; this.y = y; } public static final Visitor VISITOR = (widget, instance) -> { if (instance.parentData instanceof DragParentData data) { data.x = widget.x; data.y = widget.y; } else { instance.parentData = new DragParentData(widget.x, widget.y); } instance.markNeedsLayout(); }; @Override public Proxy proxy() { return new Proxy<>(this, VISITOR); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/drag/DragArenaInstance.java ================================================ package io.wispforest.owo.braid.widgets.drag; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.instance.MultiChildWidgetInstance; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import java.util.OptionalDouble; public class DragArenaInstance extends MultiChildWidgetInstance { public DragArenaInstance(DragArena widget) { super(widget); } @Override public > W adopt(W child) { if (child != null && !(child.parentData instanceof DragParentData)) { child.parentData = new DragParentData(0, 0); } return super.adopt(child); } @Override public void doLayout(Constraints constraints) { for (var child : this.children) { child.layout(Constraints.unconstrained()); var parentData = (DragParentData) child.parentData; child.transform.setX(parentData.x); child.transform.setY(parentData.y); } this.transform.setSize(constraints.maxSize()); } @Override protected double measureIntrinsicWidth(double height) { return 0; } @Override protected double measureIntrinsicHeight(double width) { return 0; } @Override protected OptionalDouble measureBaselineOffset() { return this.computeHighestBaselineOffset(); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/drag/DragParentData.java ================================================ package io.wispforest.owo.braid.widgets.drag; public class DragParentData { public double x, y; public DragParentData(double x, double y) { this.x = x; this.y = y; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/eventstream/BraidEventSource.java ================================================ package io.wispforest.owo.braid.widgets.eventstream; import io.wispforest.owo.util.EventSource; import io.wispforest.owo.util.EventStream; public class BraidEventSource extends EventSource> { BraidEventSource(EventStream> stream) { super(stream); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/eventstream/BraidEventStream.java ================================================ package io.wispforest.owo.braid.widgets.eventstream; import io.wispforest.owo.util.EventStream; public class BraidEventStream extends EventStream> { public BraidEventStream() { super(listeners -> event -> { for (var listener : listeners) listener.onEvent(event); }); } @Override public BraidEventSource source() { return new BraidEventSource<>(this); } public interface Listener { void onEvent(T event); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/eventstream/StreamListenerState.java ================================================ package io.wispforest.owo.braid.widgets.eventstream; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; import java.util.function.Function; public abstract class StreamListenerState extends WidgetState { private final List> streamSubscriptions = new ArrayList<>(); protected void streamListen(Function> streamGetter, Consumer onData) { this.streamSubscriptions.add( new SubscriptionData<>(this.widget(), streamGetter, stream -> stream.subscribe(onData::accept)) ); } @Override public void didUpdateWidget(T oldWidget) { super.didUpdateWidget(oldWidget); for (var subscription : this.streamSubscriptions) { subscription.update(this.widget()); } } @Override public void dispose() { super.dispose(); for (var subscription : this.streamSubscriptions) { if (subscription.currentSubscription != null) subscription.currentSubscription.cancel(); } } private static class SubscriptionData { private final Function> getter; private final Function, BraidEventSource.Subscription> listenerFactory; private @Nullable BraidEventSource currentStream; private @Nullable BraidEventSource.Subscription currentSubscription; private SubscriptionData(W widget, Function> getter, Function, BraidEventSource.Subscription> listenerFactory) { this.getter = getter; this.listenerFactory = listenerFactory; this.listenOn(widget); } private void listenOn(W widget) { this.currentStream = this.getter.apply(widget); if (this.currentStream == null) return; this.currentSubscription = this.listenerFactory.apply(this.currentStream); } public void update(W newWidget) { var newStream = this.getter.apply(newWidget); if (newStream == this.currentStream) return; if (this.currentSubscription != null) this.currentSubscription.cancel(); listenOn(newWidget); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/flex/Column.java ================================================ package io.wispforest.owo.braid.widgets.flex; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.util.Arrays; import java.util.List; /// A [Flex], restricted to the vertical axis. /// /// See the [Flex] documentation for details public class Column extends Flex { public Column( MainAxisAlignment mainAxisAlignment, CrossAxisAlignment crossAxisAlignment, @Nullable Widget separator, List children ) { super(LayoutAxis.VERTICAL, mainAxisAlignment, crossAxisAlignment, separator, children); } /// Create a column without a separator public Column( MainAxisAlignment mainAxisAlignment, CrossAxisAlignment crossAxisAlignment, List children ) { this(mainAxisAlignment, crossAxisAlignment, null, children); } /// Create a column without a separator public Column( MainAxisAlignment mainAxisAlignment, CrossAxisAlignment crossAxisAlignment, Widget... children ) { this(mainAxisAlignment, crossAxisAlignment, null, Arrays.asList(children)); } /// Create a column with default (start) alignment /// on both axes public Column( @Nullable Widget separator, List children ) { this(MainAxisAlignment.START, CrossAxisAlignment.START, separator, children); } /// Create a column with default (start) alignment /// on both axes and no separator public Column( List children ) { this(MainAxisAlignment.START, CrossAxisAlignment.START, null, children); } /// Create a column with default (start) alignment /// on both axes and no separator public Column( Widget... children ) { this(Arrays.asList(children)); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/flex/CrossAxisAlignment.java ================================================ package io.wispforest.owo.braid.widgets.flex; public enum CrossAxisAlignment { /// The start of the cross axis (left for a column, top for a row) START, /// The end of the cross axis (right for a column, bottom for a row) END, /// Center across the cross axis CENTER, /// Force all children to fill the flex's cross axis constraints STRETCH; @SuppressWarnings("DuplicateBranchesInSwitch") double computeChildOffset(double freeSpace) { return Math.floor(switch (this) { case STRETCH -> 0; case START -> 0; case CENTER -> freeSpace / 2; case END -> freeSpace; }); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/flex/Flex.java ================================================ package io.wispforest.owo.braid.widgets.flex; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.framework.instance.MultiChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.MultiChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import net.minecraft.util.Util; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.Arrays; import java.util.List; /// A Flex places its children one after another in a horizontal /// or vertical list, as dictated by its [#mainAxis]. /// /// Child alignment on both axes is given by [#mainAxisAlignment] /// and [#crossAxisAlignment] respectively. /// /// Children receive loose and unbounded constraints on the main axis /// and the Flex's cross axis constraints are passed down unchanged. If /// cross axis alignment is set to [CrossAxisAlignment#STRETCH], cross /// axis constraints are tightened to their maximum. /// /// Children can be made to receive tight main axis constraints by wrapping /// them in a [Flexible]. After all non-flexible children are laid out, /// the remaining main axis space is divided up amongst all [Flexible] children, /// weighted by their respective [Flexible#flexFactor]. /// /// A Flex can have an optional `separator`, which is inserted between each child public class Flex extends MultiChildInstanceWidget { /// The axis along which the children of this widget /// are placed public final LayoutAxis mainAxis; /// How remaining space on the main axis, if any, /// should be distributed between and around the children public final MainAxisAlignment mainAxisAlignment; /// How remaining space on the cross axis, if any, /// should be distributed around the children public final CrossAxisAlignment crossAxisAlignment; public Flex( LayoutAxis mainAxis, MainAxisAlignment mainAxisAlignment, CrossAxisAlignment crossAxisAlignment, @Nullable Widget separator, List children ) { super(Util.make(() -> { if (separator == null || children.size() < 2) return children; var result = new ArrayList(); for (var i = 0; i < children.size() - 1; i++) { result.add(children.get(i)); result.add(separator); } result.add(children.getLast()); return result; })); this.mainAxis = mainAxis; this.mainAxisAlignment = mainAxisAlignment; this.crossAxisAlignment = crossAxisAlignment; } /// Create a flex without a separator public Flex( LayoutAxis mainAxis, MainAxisAlignment mainAxisAlignment, CrossAxisAlignment crossAxisAlignment, Widget... children ) { this(mainAxis, mainAxisAlignment, crossAxisAlignment, null, Arrays.asList(children)); } @Override public MultiChildWidgetInstance instantiate() { return new FlexInstance(this); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/flex/FlexInstance.java ================================================ package io.wispforest.owo.braid.widgets.flex; import com.google.common.collect.Iterables; import com.google.common.collect.Streams; import io.wispforest.owo.braid.core.BraidUtils; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.instance.MultiChildWidgetInstance; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import java.util.OptionalDouble; import java.util.stream.Collectors; public class FlexInstance extends MultiChildWidgetInstance { public FlexInstance(Flex widget) { super(widget); } @Override public void setWidget(Flex widget) { if (this.widget.mainAxis == widget.mainAxis && this.widget.mainAxisAlignment == widget.mainAxisAlignment && this.widget.crossAxisAlignment == widget.crossAxisAlignment) { return; } super.setWidget(widget); this.markNeedsLayout(); } @Override protected void doLayout(Constraints constraints) { var mainAxis = widget.mainAxis; var crossAxis = mainAxis.opposite(); var crossAxisMinimum = widget.crossAxisAlignment == CrossAxisAlignment.STRETCH ? constraints.maxOnAxis(crossAxis) : constraints.minOnAxis(crossAxis); var childConstraints = Constraints.of( mainAxis == LayoutAxis.VERTICAL ? crossAxisMinimum : 0, mainAxis == LayoutAxis.HORIZONTAL ? crossAxisMinimum : 0, mainAxis == LayoutAxis.VERTICAL ? constraints.maxOnAxis(crossAxis) : Double.POSITIVE_INFINITY, mainAxis == LayoutAxis.HORIZONTAL ? constraints.maxOnAxis(crossAxis) : Double.POSITIVE_INFINITY ); // first, lay out all non-flex children and store their sizes var childSizes = this.children.stream() .filter(element -> !(element.parentData instanceof FlexParentData)) .map((e) -> e.layout(childConstraints)) .collect(Collectors.toList()); // now, compute the remaining space on the main axis var remainingSpace = Math.max( constraints.maxOnAxis(mainAxis) - BraidUtils.fold(childSizes, 0.0, (acc, size) -> acc + size.getExtent(mainAxis)), 0 ); // get the flex children and compute the total flex factor in order // to divvy up the remaining space properly later var flexChildren = Iterables.filter(children, (element) -> element.parentData instanceof FlexParentData); var totalFlexFactor = BraidUtils.fold( flexChildren, 0.0, (previousValue, element) -> previousValue + ((FlexParentData) element.parentData).flexFactor ); // lay out all flex children with (for now) tight constraints // on the main axis according to their allotted space for (var child : flexChildren) { var space = remainingSpace * (((FlexParentData) child.parentData).flexFactor / totalFlexFactor); childSizes.add( child.layout( childConstraints.respecting( Constraints.tightOnAxis( mainAxis == LayoutAxis.HORIZONTAL ? space : null, mainAxis == LayoutAxis.VERTICAL ? space : null ) ) ) ); } // compute and apply the final size of ourselves var size = BraidUtils.fold( childSizes, Size.zero(), (acc, elem) -> mainAxis.createSize( acc.getExtent(mainAxis) + elem.getExtent(mainAxis), Math.max(acc.getExtent(crossAxis), elem.getExtent(crossAxis)) ) ).constrained(constraints); this.transform.setSize(size); // distribute remaining space on the main axis var freeSpace = size.getExtent(mainAxis) - BraidUtils.fold(childSizes, 0.0, (acc, elem) -> acc + elem.getExtent(mainAxis)); var leadingSpace = this.widget.mainAxisAlignment.leadingSpace(freeSpace, childSizes.size()); var betweenSpace = this.widget.mainAxisAlignment.between(freeSpace, childSizes.size()); // move children into position and apply cross-axis alignment var mainAxisOffset = leadingSpace; for (var child : children) { child.transform.setCoordinate(mainAxis, mainAxisOffset); child.transform.setCoordinate( crossAxis, this.widget.crossAxisAlignment.computeChildOffset( size.getExtent(crossAxis) - child.transform.getExtent(crossAxis) ) ); mainAxisOffset += child.transform.getExtent(mainAxis) + betweenSpace; } } @Override protected double measureIntrinsicWidth(double height) { return this.widget.mainAxis == LayoutAxis.HORIZONTAL ? this.measureMainAxis(height) : this.measureCrossAxis(height); } @Override protected double measureIntrinsicHeight(double width) { return this.widget.mainAxis == LayoutAxis.VERTICAL ? this.measureMainAxis(width) : this.measureCrossAxis(width); } @Override protected OptionalDouble measureBaselineOffset() { return switch (this.widget.mainAxis) { case VERTICAL -> this.computeFirstBaselineOffset(); case HORIZONTAL -> this.computeHighestBaselineOffset(); }; } @SuppressWarnings("DataFlowIssue") private double measureMainAxis(double crossExtent) { var horizontal = this.widget.mainAxis == LayoutAxis.HORIZONTAL; var nonFlexSize = this.children.stream() .filter(element -> !(element.parentData instanceof FlexParentData)) .mapToDouble(e -> horizontal ? e.getIntrinsicWidth(crossExtent) : e.getIntrinsicHeight(crossExtent)) .sum(); var totalFlexFactor = 0.0; WidgetInstance largestFlexChild = null; double largestFlexChildSize = 0; double largestFlexChildFlexFactor = 0; for (var flexChild : Iterables.filter(this.children, element -> element.parentData instanceof FlexParentData)) { totalFlexFactor += ((FlexParentData) flexChild.parentData).flexFactor; var size = horizontal ? flexChild.getIntrinsicWidth(crossExtent) : flexChild.getIntrinsicHeight(crossExtent); if (size > largestFlexChildSize) { largestFlexChild = flexChild; largestFlexChildSize = size; largestFlexChildFlexFactor = ((FlexParentData) flexChild.parentData).flexFactor; } } var flexSize = largestFlexChild != null ? (totalFlexFactor / largestFlexChildFlexFactor) * largestFlexChildSize : 0; return nonFlexSize + flexSize; } @SuppressWarnings("DataFlowIssue") private double measureCrossAxis(double mainExtent) { var horizontal = this.widget.mainAxis == LayoutAxis.HORIZONTAL; var crossSize = 0.0; var nonFlexSize = 0.0; for (var child : Iterables.filter(this.children, element -> !(element.parentData instanceof FlexParentData))) { var childSize = horizontal ? child.getIntrinsicHeight(mainExtent) : child.getIntrinsicWidth(mainExtent); nonFlexSize += childSize; crossSize = Math.max(crossSize, childSize); } var flexChildren = Iterables.filter(children, (element) -> element.parentData instanceof FlexParentData); var totalFlexFactor = Streams.stream(flexChildren).mapToDouble(e -> ((FlexParentData) e.parentData).flexFactor).sum(); for (var child : flexChildren) { var childSpace = (mainExtent - nonFlexSize) * (totalFlexFactor / ((FlexParentData) child.parentData).flexFactor); crossSize = Math.max( crossSize, horizontal ? child.getIntrinsicHeight(childSpace) : child.getIntrinsicWidth(childSpace) ); } return crossSize; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/flex/FlexParentData.java ================================================ package io.wispforest.owo.braid.widgets.flex; public class FlexParentData { public double flexFactor; public FlexParentData(double flexFactor) { this.flexFactor = flexFactor; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/flex/Flexible.java ================================================ package io.wispforest.owo.braid.widgets.flex; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.VisitorWidget; /// A widget which is forced to fill the remaining space in its /// [Flex] parent. The space is weighted between all Flexible /// children according to their [#flexFactor] /// /// Check the [Flex] documentation for details /// /// **Note:** For Flexible to work, it is essential that the path from it to /// its enclosing [Flex] contains only stateful and stateless widgets. public class Flexible extends VisitorWidget { /// The relative proportion of the parent's /// remaining space to assign to this widget public final double flexFactor; public Flexible(double flexFactor, Widget child) { super(child); this.flexFactor = flexFactor; } /// Create a Flexible with the default flex factor 1 public Flexible(Widget child) { this(1, child); } private static final Visitor VISITOR = (widget, instance) -> { if (instance.parentData instanceof FlexParentData data) { data.flexFactor = widget.flexFactor; } else { instance.parentData = new FlexParentData(widget.flexFactor); } instance.markNeedsLayout(); }; @Override public Proxy proxy() { return new VisitorWidget.Proxy<>(this, VISITOR); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/flex/MainAxisAlignment.java ================================================ package io.wispforest.owo.braid.widgets.flex; public enum MainAxisAlignment { /// The start of the main axis (top for a column, left for a row) START, /// The end of the main axis (bottom for a column, right for a row) END, /// Center in the main axis CENTER, /// Distribute any remaining space evenly between all children SPACE_BETWEEN, /// Distribute half of any remaining space equally before the first and /// after the last child, and the other half evenly between all children SPACE_AROUND, /// Distribute any remaining space evenly between all children /// as well as before the first and after the last child SPACE_EVENLY; @SuppressWarnings("DuplicateBranchesInSwitch") double leadingSpace(double freeSpace, int childCount) { return Math.floor(switch (this) { case START -> 0; case END -> freeSpace; case CENTER -> freeSpace / 2; case SPACE_BETWEEN -> 0; case SPACE_AROUND -> freeSpace / childCount / 2; case SPACE_EVENLY -> freeSpace / (childCount + 1); }); } @SuppressWarnings("DuplicateBranchesInSwitch") double between(double freeSpace, int childCount) { return Math.floor(switch (this) { case START -> 0; case END -> 0; case CENTER -> 0; case SPACE_BETWEEN -> freeSpace / (childCount - 1); case SPACE_AROUND -> freeSpace / childCount; case SPACE_EVENLY -> freeSpace / (childCount + 1); }); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/flex/Row.java ================================================ package io.wispforest.owo.braid.widgets.flex; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.util.Arrays; import java.util.List; /// A [Flex], restricted to the horizontal axis. /// /// See the [Flex] documentation for details public class Row extends Flex { public Row( MainAxisAlignment mainAxisAlignment, CrossAxisAlignment crossAxisAlignment, @Nullable Widget separator, List children ) { super(LayoutAxis.HORIZONTAL, mainAxisAlignment, crossAxisAlignment, separator, children); } /// Create a row without a separator public Row( MainAxisAlignment mainAxisAlignment, CrossAxisAlignment crossAxisAlignment, List children ) { this(mainAxisAlignment, crossAxisAlignment, null, children); } /// Create a row without a separator public Row( MainAxisAlignment mainAxisAlignment, CrossAxisAlignment crossAxisAlignment, Widget... children ) { this(mainAxisAlignment, crossAxisAlignment, null, Arrays.asList(children)); } /// Create a row with default (start) alignment /// on both axes public Row( @Nullable Widget separator, List children ) { this(MainAxisAlignment.START, CrossAxisAlignment.START, separator, children); } /// Create a column with default (start) alignment /// on both axes and no separator public Row( List children ) { this(MainAxisAlignment.START, CrossAxisAlignment.START, null, children); } /// Create a column with default (start) alignment /// on both axes and no separator public Row( Widget... children ) { this(Arrays.asList(children)); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/focus/FocusClickArea.java ================================================ package io.wispforest.owo.braid.widgets.focus; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; public class FocusClickArea extends SingleChildInstanceWidget { public final Runnable clickCallback; public FocusClickArea(Runnable clickCallback, Widget child) { super(child); this.clickCallback = clickCallback; } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance.ShrinkWrap { public Instance(FocusClickArea widget) { super(widget); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/focus/FocusLevel.java ================================================ package io.wispforest.owo.braid.widgets.focus; public enum FocusLevel { BASE, HIGHLIGHT } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/focus/FocusPolicy.java ================================================ package io.wispforest.owo.braid.widgets.focus; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; public class FocusPolicy extends InheritedWidget { public final boolean clickFocus; public FocusPolicy(boolean clickFocus, Widget child) { super(child); this.clickFocus = clickFocus; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return ((FocusPolicy) newWidget).clickFocus != this.clickFocus; } // --- public static FocusPolicy of(BuildContext context) { return context.dependOnAncestor(FocusPolicy.class); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/focus/FocusScope.java ================================================ package io.wispforest.owo.braid.widgets.focus; import io.wispforest.owo.braid.core.KeyModifiers; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.StatefulProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.scroll.Scrollable; import io.wispforest.owo.braid.widgets.stack.Stack; import io.wispforest.owo.braid.widgets.stack.StackBase; import net.minecraft.util.Mth; import net.minecraft.world.phys.AABB; import org.jetbrains.annotations.Nullable; import org.joml.Vector2d; import java.util.*; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; public class FocusScope extends Focusable { public FocusScope(WidgetSetupCallback setupCallback, Widget child) { super(widget -> setupCallback.setup((FocusScope) widget), child); } @Override public WidgetProxy proxy() { return new FocusScopeProxy(this); } @Override public WidgetState createState() { return new State(); } public static class State extends Focusable.State { Supplier>> descendants; private List> focusedDescendants = new ArrayList<>(); private @Nullable FocusEntry previousPrimaryFocus; private final Deque previouslyFocusedScopes = new ArrayDeque<>(); private final Deque> traversalHistory = new LinkedList<>(); private FocusTraversalDirection historyDirection = null; public void updateFocus(@Nullable Focusable.State primary, @Nullable FocusLevel level) { this.updateFocus(primary, level, false); } public void updateFocus(@Nullable Focusable.State primary, @Nullable FocusLevel level, boolean keepTraversalHistory) { var currentPrimaryFocus = !this.focusedDescendants.isEmpty() ? this.focusedDescendants.getFirst() : null; if (primary == currentPrimaryFocus && (primary != null ? primary.level : null) == level) { return; } if (!keepTraversalHistory) { this.traversalHistory.clear(); } if (level != null && primary != null) { this.requestFocus(level); } var nowFocused = primary != null ? Stream.concat(Stream.of(primary), primary.ancestors()).takeWhile(state -> state != this).collect(Collectors.toList()) : new ArrayList>(); for (var state : nowFocused) { if (this.focusedDescendants.contains(state)) { this.focusedDescendants.remove(state); if (state.level != level) { state.onFocusChange(level); } } else { state.onFocusChange(level); } } if (!this.focusedDescendants.isEmpty() && this.focusedDescendants.getFirst() instanceof State scope && !nowFocused.contains(scope)) { this.previouslyFocusedScopes.add(new FocusEntry(scope, scope.level)); } else if (nowFocused.isEmpty() || !(nowFocused.getFirst() instanceof State)) { previouslyFocusedScopes.clear(); } for (var noLongerFocused : this.focusedDescendants) { noLongerFocused.onFocusChange(null); } if (primary != null) { var scrollable = Scrollable.maybeOf(primary.context()); if (scrollable != null) { Scrollable.reveal(primary.context()); } } this.focusedDescendants = nowFocused; } void onFocusableDisposed(Focusable.State descendant) { if (!this.focusedDescendants.isEmpty() && descendant == this.focusedDescendants.getFirst() && !this.previouslyFocusedScopes.isEmpty()) { var entry = this.previouslyFocusedScopes.removeLast(); updateFocus(entry.state(), entry.level()); } this.focusedDescendants.remove(descendant); this.traversalHistory.remove(descendant); this.previouslyFocusedScopes.removeIf(entry -> entry.state() == descendant); } @Override public Focusable.State primaryFocus() { if (this.level != null) { var candidate = !this.focusedDescendants.isEmpty() ? this.focusedDescendants.getFirst() : null; if (candidate instanceof State) candidate = candidate.primaryFocus(); return candidate != null ? candidate : this; } else { return super.primaryFocus(); } } @Override public void traverseFocus(FocusTraversalDirection direction) { switch (direction) { case PREVIOUS, NEXT -> this.traverseFocusLogical(direction == FocusTraversalDirection.NEXT); case LEFT, RIGHT, UP, DOWN -> this.traverseFocusDirectional(direction); } } private void traverseFocusLogical(boolean forwards) { var descendants = this.descendants.get(); var searchStartIdx = !this.focusedDescendants.isEmpty() ? descendants.indexOf(this.focusedDescendants.getFirst()) : (forwards ? -1 : 0); var offset = forwards ? 1 : -1; var nextFocusIdx = searchStartIdx; do { nextFocusIdx = Mth.positiveModulo(nextFocusIdx + offset, descendants.size()); } while (descendants.get(nextFocusIdx).widget().skipTraversal()); this.updateFocus(descendants.get(nextFocusIdx), FocusLevel.HIGHLIGHT); } private boolean tryTraverseFocusHistory(FocusTraversalDirection direction) { var poppedHistory = false; if (!this.traversalHistory.isEmpty()) { if (this.historyDirection == direction.opposite()) { poppedHistory = true; this.updateFocus(this.traversalHistory.pop(), FocusLevel.HIGHLIGHT, true); } else if (this.historyDirection != direction) { this.traversalHistory.clear(); } } if (!poppedHistory && !this.focusedDescendants.isEmpty()) { this.historyDirection = direction; } return poppedHistory; } private void traverseFocusDirectional(FocusTraversalDirection direction) { if (this.focusedDescendants.isEmpty() || this.tryTraverseFocusHistory(direction)) return; var descendants = this.descendants.get(); var focusedBounds = this.focusedDescendants.getFirst().context().instance().computeGlobalBounds(); var focusedCenter = FocusTraversalCandidate.of(this.focusedDescendants.getFirst()).center(); var candidates = descendants.stream() .filter(state -> !state.widget().skipTraversal()) .map(FocusTraversalCandidate::of) .filter(candidate -> { return this.filterCandidate(candidate, focusedBounds, direction); }) .collect(Collectors.toList()); var candidatesInBand = candidates.stream() .filter(candidate -> { return this.filterInBand(candidate, focusedBounds, direction); }) .collect(Collectors.toList()); if (!candidatesInBand.isEmpty()) { candidatesInBand.sort(this.sortInBand(focusedCenter, direction)); this.traversalHistory.push(this.focusedDescendants.getFirst()); this.updateFocus(candidatesInBand.getFirst().state(), FocusLevel.HIGHLIGHT, true); return; } if (!candidates.isEmpty()) { candidates.sort(this.sortOutOfBand(focusedCenter, direction)); this.traversalHistory.push(this.focusedDescendants.getFirst()); this.updateFocus(candidates.getFirst().state(), FocusLevel.HIGHLIGHT, true); } } private boolean filterCandidate(FocusTraversalCandidate candidate, AABB focusedBounds, FocusTraversalDirection direction) { return switch (direction) { case LEFT -> candidate.center().x <= focusedBounds.minX; case RIGHT -> candidate.center().x >= focusedBounds.maxX; case UP -> candidate.center().y <= focusedBounds.minY; case DOWN -> candidate.center().y >= focusedBounds.maxY; default -> throw new IllegalStateException(); }; } private boolean filterInBand(FocusTraversalCandidate candidate, AABB focusedBounds, FocusTraversalDirection direction) { return switch (direction) { case LEFT, RIGHT -> candidate.aabb().minY < focusedBounds.maxY && candidate.aabb().maxY > focusedBounds.minY; case UP, DOWN -> candidate.aabb().minX < focusedBounds.maxX && candidate.aabb().maxX > focusedBounds.minX; default -> throw new IllegalStateException(); }; } private Comparator sortInBand(Vector2d focusedCenter, FocusTraversalDirection direction) { return switch (direction) { case LEFT -> Comparator.comparingDouble(candidate -> -candidate.center().x) .thenComparingDouble(candidate -> Math.abs(candidate.center().y - focusedCenter.y)); case RIGHT -> Comparator.comparingDouble(candidate -> candidate.center().x) .thenComparingDouble(candidate -> Math.abs(candidate.center().y - focusedCenter.y)); case UP -> Comparator.comparingDouble(candidate -> -candidate.center().y) .thenComparingDouble(candidate -> Math.abs(candidate.center().x - focusedCenter.x)); case DOWN -> Comparator.comparingDouble(candidate -> candidate.center().y) .thenComparingDouble(candidate -> Math.abs(candidate.center().x - focusedCenter.x)); default -> throw new IllegalStateException(); }; } private Comparator sortOutOfBand(Vector2d focusedCenter, FocusTraversalDirection direction) { return switch (direction) { case LEFT, RIGHT -> Comparator.comparingDouble(candidate -> Math.abs(candidate.center().y - focusedCenter.y)) .thenComparingDouble(candidate -> Math.abs(candidate.center().x - focusedCenter.x)); case UP, DOWN -> Comparator.comparingDouble(candidate -> Math.abs(candidate.center().x - focusedCenter.x)) .thenComparingDouble(candidate -> Math.abs(candidate.center().y - focusedCenter.y)); default -> throw new IllegalStateException(); }; } @Override void onFocusChange(@Nullable FocusLevel newLevel) { var previousLevel = this.level; super.onFocusChange(newLevel); if (previousLevel != null && newLevel == null) { var primaryFocus = !this.focusedDescendants.isEmpty() ? this.focusedDescendants.getFirst() : null; this.previousPrimaryFocus = primaryFocus != null ? new FocusEntry(primaryFocus, primaryFocus.level) : null; this.updateFocus(null, null); } else if (previousLevel == null && newLevel != null && this.previousPrimaryFocus != null) { this.updateFocus(this.previousPrimaryFocus.state(), this.previousPrimaryFocus.level()); } } @Override boolean onKeyDown(int keyCode, KeyModifiers modifiers) { for (var descendant : this.focusedDescendants) { if (descendant.onKeyDown(keyCode, modifiers)) { return true; } } return super.onKeyDown(keyCode, modifiers); } @Override boolean onKeyUp(int keyCode, KeyModifiers modifiers) { for (var descendant : this.focusedDescendants) { if (descendant.onKeyUp(keyCode, modifiers)) { return true; } } return super.onKeyUp(keyCode, modifiers); } @Override boolean onChar(int charCode, KeyModifiers modifiers) { for (var descendant : this.focusedDescendants) { if (descendant.onChar(charCode, modifiers)) { return true; } } return super.onChar(charCode, modifiers); } @Override void onClick() { super.onClick(); this.updateFocus(null, null); } @Override public Widget build(BuildContext context) { return new Stack( new StackBase( new FocusStateProvider<>( this, State.class, this.level, super.build(context) ) ) // new CustomDraw((ctx, transform) -> { // if (this.focusedDescendants.isEmpty()) return; // // var instance = this.focusedDescendants.getFirst().context().instance(); // var drawTransform = instance.parent().computeTransformFrom(this.context().instance()).invert(); // // var boxMin = drawTransform.transformPosition(instance.transform.aabb().getMinPos().toVector3f()); // var boxMax = drawTransform.transformPosition(instance.transform.aabb().getMaxPos().toVector3f()); // // var box = new Box(new Vec3d(boxMin), new Vec3d(boxMax)); // // ctx.push(); // ctx.translate(box.minX, box.minY, box.minZ); // // NinePatchTexture.draw( // Identifier.of("owo", "braid_debug_focused"), // ctx, // 0, 0, (int) (box.maxX - box.minX), (int) (box.maxY - box.minY), // Color.ofHsv(this.focusedDescendants.getFirst().debugDepth() / 8f % 1f, .75f, 1) // ); // // ctx.pop(); // }) ); } // --- static @Nullable FocusScope.State maybeOf(BuildContext context) { var provider = context.getAncestor(FocusStateProvider.class, FocusStateProvider.keyOf(State.class)); if (provider == null) return null; return (State) provider.state; } } } class FocusScopeProxy extends StatefulProxy { public FocusScopeProxy(FocusScope widget) { super(widget); } @Override public void mount(WidgetProxy parent, @Nullable Object slot) { super.mount(parent, slot); ((FocusScope.State) this.state()).descendants = () -> { var descendants = new ArrayList>(); this.visitChildren(child -> collectFocusDescendants(child, descendants)); return descendants; }; } private static void collectFocusDescendants(WidgetProxy proxy, List> into) { if (proxy instanceof StatefulProxy stateful && stateful.state() instanceof Focusable.State state) { into.add(state); if (state instanceof FocusScope.State) { return; } } proxy.visitChildren(child -> { collectFocusDescendants(child, into); }); } } record FocusEntry(Focusable.State state, FocusLevel level) {} record FocusTraversalCandidate(Focusable.State state, AABB aabb, Vector2d center) { public static FocusTraversalCandidate of(Focusable.State state) { var aabb = state.context().instance().computeGlobalBounds(); var center = new Vector2d( aabb.minX + (aabb.maxX - aabb.minX) / 2, aabb.minY + (aabb.maxY - aabb.minY) / 2 ); return new FocusTraversalCandidate(state, aabb, center); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/focus/FocusStateProvider.java ================================================ package io.wispforest.owo.braid.widgets.focus; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; class FocusStateProvider> extends InheritedWidget { public final F state; public final @Nullable FocusLevel level; private final InheritedKey inheritedKey; public FocusStateProvider(F state, Class stateClass, @Nullable FocusLevel level, Widget child) { super(child); this.state = state; this.level = level; this.inheritedKey = new InheritedKey(stateClass); } @Override public Object inheritedKey() { return this.inheritedKey; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { //noinspection unchecked return ((FocusStateProvider) newWidget).level != this.level; } // --- public static > Object keyOf(Class stateClass) { return new InheritedKey(stateClass); } } record InheritedKey(Class stateClass) {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/focus/FocusTraversalDirection.java ================================================ package io.wispforest.owo.braid.widgets.focus; public enum FocusTraversalDirection { NEXT, PREVIOUS, UP, DOWN, LEFT, RIGHT; public FocusTraversalDirection opposite() { return switch (this) { case NEXT -> PREVIOUS; case PREVIOUS -> NEXT; case UP -> DOWN; case DOWN -> UP; case LEFT -> RIGHT; case RIGHT -> LEFT; }; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/focus/Focusable.java ================================================ package io.wispforest.owo.braid.widgets.focus; import com.google.common.base.Preconditions; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.KeyModifiers; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import org.jetbrains.annotations.Nullable; import java.util.Objects; import java.util.stream.Stream; public class Focusable extends StatefulWidget { @Nullable private KeyDownCallback keyDownCallback; @Nullable private KeyUpCallback keyUpCallback; @Nullable private CharCallback charCallback; @Nullable private FocusGainedCallback focusGainedCallback; @Nullable private FocusLostCallback focusLostCallback; @Nullable private FocusLevelChangedCallback focusLevelChangedCallback; private boolean skipTraversal; private boolean autoFocus; @Nullable private Boolean clickFocus; public final Widget child; public Focusable(WidgetSetupCallback setupCallback, Widget child) { setupCallback.setup(this); this.child = child; } // --- public static @Nullable State maybeOf(BuildContext context) { var provider = context.getAncestor(FocusStateProvider.class, FocusStateProvider.keyOf(State.class)); if (provider == null) return null; return provider.state; } public static State of(BuildContext context) { var state = maybeOf(context); Preconditions.checkNotNull(state, "attempted to look up the closest Focusable without one present"); return state; } public static @Nullable FocusLevel levelOf(BuildContext context) { var provider = context.dependOnAncestor(FocusStateProvider.class, FocusStateProvider.keyOf(State.class)); return provider != null ? provider.level : null; } public static boolean isFocused(BuildContext context) { return levelOf(context) != null; } public static boolean shouldShowHighlight(BuildContext context) { return levelOf(context) == FocusLevel.HIGHLIGHT; } // --- public Focusable keyDownCallback(@Nullable KeyDownCallback keyDownCallback) { this.assertMutable(); this.keyDownCallback = keyDownCallback; return this; } public @Nullable KeyDownCallback keyDownCallback() { return this.keyDownCallback; } public Focusable keyUpCallback(@Nullable KeyUpCallback keyUpCallback) { this.assertMutable(); this.keyUpCallback = keyUpCallback; return this; } public @Nullable KeyUpCallback keyUpCallback() { return this.keyUpCallback; } public Focusable charCallback(@Nullable CharCallback charCallback) { this.assertMutable(); this.charCallback = charCallback; return this; } public @Nullable CharCallback charCallback() { return this.charCallback; } public Focusable focusGainedCallback(@Nullable FocusGainedCallback focusGainedCallback) { this.assertMutable(); this.focusGainedCallback = focusGainedCallback; return this; } public @Nullable FocusGainedCallback focusGainedCallback() { return this.focusGainedCallback; } public Focusable focusLostCallback(@Nullable FocusLostCallback focusLostCallback) { this.assertMutable(); this.focusLostCallback = focusLostCallback; return this; } public @Nullable FocusLostCallback focusLostCallback() { return this.focusLostCallback; } public Focusable focusLevelChangedCallback(@Nullable FocusLevelChangedCallback focusLevelChangedCallback) { this.assertMutable(); this.focusLevelChangedCallback = focusLevelChangedCallback; return this; } public @Nullable FocusLevelChangedCallback focusLevelChangedCallback() { return this.focusLevelChangedCallback; } public Focusable skipTraversal(boolean skipTraversal) { this.assertMutable(); this.skipTraversal = skipTraversal; return this; } public boolean skipTraversal() { return this.skipTraversal; } public Focusable autoFocus(boolean autoFocus) { this.assertMutable(); this.autoFocus = autoFocus; return this; } public boolean autoFocus() { return this.autoFocus; } public Focusable clickFocus(@Nullable Boolean clickFocus) { this.assertMutable(); this.clickFocus = clickFocus; return this; } public @Nullable Boolean clickFocus() { return this.clickFocus; } @Override public WidgetState createState() { return new State<>(); } @FunctionalInterface public interface KeyDownCallback { boolean onKeyDown(int keyCode, KeyModifiers modifiers); } @FunctionalInterface public interface KeyUpCallback { boolean onKeyUp(int keyCode, KeyModifiers modifiers); } @FunctionalInterface public interface CharCallback { boolean onChar(int charCode, KeyModifiers modifiers); } @FunctionalInterface public interface FocusGainedCallback { void onFocusGained(); } @FunctionalInterface public interface FocusLostCallback { void onFocusLost(); } @FunctionalInterface public interface FocusLevelChangedCallback { void onFocusLevelChanged(@Nullable FocusLevel level); } public static class State extends WidgetState { private @Nullable State parent; private @Nullable FocusScope.State scope; @Nullable FocusLevel level; private int debugDepth; public int debugDepth() { return this.debugDepth; } public State primaryFocus() { return this.scope != null ? this.scope.primaryFocus() : this; } public Stream> ancestors() { return Stream.iterate( this.parent, Objects::nonNull, state -> state.parent ); } public void requestFocus() { this.requestFocus(FocusLevel.HIGHLIGHT); } public void requestFocus(FocusLevel level) { if (this.scope != null) { this.scope.updateFocus(this, level); } } public void unfocus() { if (this.scope != null) { this.scope.updateFocus(null, null); } } public void traverseFocus(FocusTraversalDirection direction) { if (this.scope != null) { this.scope.traverseFocus(direction); } } void onFocusChange(@Nullable FocusLevel newLevel) { if (Owo.DEBUG) { Preconditions.checkState( this.level != newLevel, String.format("_onFocusChange(%s) invoked on a state which is already at %s", newLevel, newLevel) ); } if (this.widget().focusLevelChangedCallback() instanceof FocusLevelChangedCallback callback) { callback.onFocusLevelChanged(newLevel); } if (this.level == null && newLevel != null) { if (this.widget().focusGainedCallback() instanceof FocusGainedCallback callback) { callback.onFocusGained(); } } else if (this.level != null && newLevel == null) { if (this.widget().focusLostCallback() instanceof FocusLostCallback callback) { callback.onFocusLost(); } } this.setState(() -> { this.level = newLevel; }); } void onClick() { var shouldClickFocus = this.widget().clickFocus(); if (shouldClickFocus == Boolean.TRUE || this.context().getAncestor(FocusPolicy.class).clickFocus) { this.requestFocus(FocusLevel.BASE); } } boolean onKeyDown(int keyCode, KeyModifiers modifiers) { if (Owo.DEBUG) { Preconditions.checkState( this.level != null, "onKeyDown invoked on a state which is not focused" ); } return this.widget().keyDownCallback() instanceof KeyDownCallback callback && callback.onKeyDown(keyCode, modifiers); } boolean onKeyUp(int keyCode, KeyModifiers modifiers) { if (Owo.DEBUG) { Preconditions.checkState( this.level != null, "onKeyUp invoked on a state which is not focused" ); } return this.widget().keyUpCallback() instanceof KeyUpCallback callback && callback.onKeyUp(keyCode, modifiers); } boolean onChar(int charCode, KeyModifiers modifiers) { if (Owo.DEBUG) { Preconditions.checkState( this.level != null, "onChar invoked on a state which is not focused" ); } return this.widget().charCallback() instanceof CharCallback callback && callback.onChar(charCode, modifiers); } @Override public void init() { this.parent = Focusable.maybeOf(this.context()); this.scope = FocusScope.State.maybeOf(this.context()); this.debugDepth = this.parent != null ? this.parent.debugDepth + 1 : 0; if (this.widget().autoFocus()) { this.requestFocus(); } } @Override public void dispose() { if (this.scope != null) { this.scope.onFocusableDisposed(this); } } @Override public Widget build(BuildContext context) { return new FocusClickArea( this::onClick, new FocusStateProvider<>( this, Focusable.State.class, this.level, this.widget().child ) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/focus/RootFocusScope.java ================================================ package io.wispforest.owo.braid.widgets.focus; import io.wispforest.owo.braid.core.KeyModifiers; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Builder; import io.wispforest.owo.braid.widgets.eventstream.BraidEventSource; import io.wispforest.owo.braid.widgets.eventstream.StreamListenerState; public class RootFocusScope extends StatefulWidget { public final BraidEventSource onKeyDown; public final BraidEventSource onKeyUp; public final BraidEventSource onChar; public final Widget child; public RootFocusScope( BraidEventSource onKeyDown, BraidEventSource onKeyUp, BraidEventSource onChar, Widget child ) { this.onKeyDown = onKeyDown; this.onKeyUp = onKeyUp; this.onChar = onChar; this.child = child; } @Override public WidgetState createState() { return new State(); } public static class State extends StreamListenerState { private FocusScope.State scope; @Override public void init() { this.streamListen(widget -> widget.onKeyDown, event -> event.handled = this.scope.onKeyDown(event.keyCode, event.modifiers)); this.streamListen(widget -> widget.onKeyUp, event -> event.handled = this.scope.onKeyUp(event.keyCode, event.modifiers)); this.streamListen(widget -> widget.onChar, event -> event.handled = this.scope.onChar(event.charCode, event.modifiers)); } @Override public Widget build(BuildContext context) { return new FocusPolicy( true, new FocusScope( Widget.noSetup(), new Builder(scopeContext -> { if (this.scope == null) { this.scope = FocusScope.State.maybeOf(scopeContext); //noinspection DataFlowIssue this.scope.onFocusChange(FocusLevel.BASE); } return this.widget().child; }) ) ); } } // --- private static class FocusEvent { boolean handled = false; public boolean handled() { return this.handled; } } public static final class KeyDownEvent extends FocusEvent { private final int keyCode; private final KeyModifiers modifiers; public KeyDownEvent(int keyCode, KeyModifiers modifiers) { this.keyCode = keyCode; this.modifiers = modifiers; } } public static final class KeyUpEvent extends FocusEvent { private final int keyCode; private final KeyModifiers modifiers; public KeyUpEvent(int keyCode, KeyModifiers modifiers) { this.keyCode = keyCode; this.modifiers = modifiers; } } public static final class CharEvent extends FocusEvent { private final int charCode; private final KeyModifiers modifiers; public CharEvent(int charCode, KeyModifiers modifiers) { this.charCode = charCode; this.modifiers = modifiers; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/grid/Grid.java ================================================ package io.wispforest.owo.braid.widgets.grid; import io.wispforest.owo.braid.core.*; import io.wispforest.owo.braid.framework.instance.InspectorProperty; import io.wispforest.owo.braid.framework.instance.MultiChildWidgetInstance; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.widget.MultiChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Padding; import io.wispforest.owo.ui.core.OwoUIGraphics; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.FontDescription; import net.minecraft.util.Mth; import org.jetbrains.annotations.Nullable; import java.util.Arrays; import java.util.List; import java.util.OptionalDouble; import java.util.function.Function; import java.util.function.ToDoubleFunction; import java.util.stream.DoubleStream; public class Grid extends MultiChildInstanceWidget { public final LayoutAxis mainAxis; public final int crossAxisCells; public final CellFit cellFit; public Grid(LayoutAxis mainAxis, int crossAxisCells, CellFit cellFit, List children) { super(children.stream().map(widget -> widget == null ? new Padding(Insets.none()) : widget).toList()); this.mainAxis = mainAxis; this.cellFit = cellFit; this.crossAxisCells = crossAxisCells; } public Grid(LayoutAxis mainAxis, int crossAxisCells, CellFit cellFit, @Nullable Widget... children) { this(mainAxis, crossAxisCells, cellFit, Arrays.asList(children)); } public Grid(LayoutAxis mainAxis, int crossAxisCells, CellFit cellFit, Function cellWrapper, List children) { this(mainAxis, crossAxisCells, cellFit, children.stream().map(cellWrapper).toList()); } public Grid(LayoutAxis mainAxis, int crossAxisCells, CellFit cellFit, Function cellWrapper, @Nullable Widget... children) { this(mainAxis, crossAxisCells, cellFit, cellWrapper, Arrays.asList(children)); } @Override public MultiChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends MultiChildWidgetInstance { private double[] debugCrossAxisSizes; private double[] debugMainAxisSizes; public Instance(Grid widget) { super(widget); } @Override public void setWidget(Grid widget) { if (this.widget.mainAxis == widget.mainAxis && this.widget.crossAxisCells == widget.crossAxisCells && this.widget.cellFit.equals(widget.cellFit)) { return; } super.setWidget(widget); this.markNeedsLayout(); } @Override protected void doLayout(Constraints constraints) { var mainAxis = this.widget.mainAxis; var crossAxis = mainAxis.opposite(); var crossAxisCells = this.widget.crossAxisCells; var mainAxisCells = Mth.ceil(this.children.size() / (double) this.widget.crossAxisCells); var mustMeasureCrossAxis = this.widget.cellFit.isTight() && !crossAxis.choose(constraints.hasTightWidth(), constraints.hasTightHeight()); var mustMeasureMainAxis = this.widget.cellFit.isTight() && !mainAxis.choose(constraints.hasTightWidth(), constraints.hasTightHeight()); var fixedCrossAxisCellSize = constraints.maxOnAxis(crossAxis) / crossAxisCells; var fixedMainAxisCellSize = constraints.maxOnAxis(mainAxis) / mainAxisCells; double @Nullable [] dynamicCrossAxisCellSizes = null; if (mustMeasureCrossAxis) { dynamicCrossAxisCellSizes = this.measureCrossAxis(fixedMainAxisCellSize); } double @Nullable [] dynamicMainAxisCellSizes = null; if (mustMeasureMainAxis) { dynamicMainAxisCellSizes = this.measureMainAxis(fixedCrossAxisCellSize); } for (int mainAxisIdx = 0; mainAxisIdx < mainAxisCells; mainAxisIdx++) { var maxMainAxisChildSize = mustMeasureMainAxis ? dynamicMainAxisCellSizes[mainAxisIdx] : fixedMainAxisCellSize; var firstChildIdx = mainAxisIdx * this.widget.crossAxisCells; var lastChildIdx = Math.min(this.children.size(), firstChildIdx + this.widget.crossAxisCells) - 1; for (var childIdx = firstChildIdx; childIdx <= lastChildIdx; childIdx++) { var child = this.children.get(childIdx); var maxCrossAxisChildSize = mustMeasureCrossAxis ? dynamicCrossAxisCellSizes[childIdx % this.widget.crossAxisCells] : fixedCrossAxisCellSize; var maxWidth = mainAxis == LayoutAxis.VERTICAL ? maxCrossAxisChildSize : maxMainAxisChildSize; var maxHeight = mainAxis == LayoutAxis.VERTICAL ? maxMainAxisChildSize : maxCrossAxisChildSize; var childConstraints = this.widget.cellFit.isTight() ? Constraints.tightOnAxis(maxWidth, maxHeight) : Constraints.loose(Size.of(maxWidth, maxHeight)); child.layout(childConstraints); } } var actualCrossAxisSizes = new double[crossAxisCells]; var actualMainAxisSizes = new double[mainAxisCells]; var minCrossAxisCellSize = constraints.minOnAxis(crossAxis) / crossAxisCells; var minMainAxisCellSize = constraints.minOnAxis(mainAxis) / mainAxisCells; for (var childIdx = 0; childIdx < this.children.size(); childIdx++) { var child = this.children.get(childIdx); var mainAxisCell = childIdx / crossAxisCells; var crossAxisCell = childIdx % crossAxisCells; actualCrossAxisSizes[crossAxisCell] = Math.max(minCrossAxisCellSize, Math.max(actualCrossAxisSizes[crossAxisCell], child.transform.getExtent(crossAxis))); actualMainAxisSizes[mainAxisCell] = Math.max(minMainAxisCellSize, Math.max(actualMainAxisSizes[mainAxisCell], child.transform.getExtent(mainAxis))); } var alignment = this.widget.cellFit instanceof CellFit.Loose loose ? loose.alignment : Alignment.TOP_LEFT; var mainAxisPos = 0d; for (var mainAxisCell = 0; mainAxisCell < mainAxisCells; mainAxisCell++) { var crossAxisPos = 0d; for (var crossAxisCell = 0; crossAxisCell < crossAxisCells; crossAxisCell++) { var childIdx = mainAxisCell * crossAxisCells + crossAxisCell; if (childIdx >= this.children.size()) break; var child = this.children.get(childIdx); child.transform.setCoordinate(crossAxis, crossAxisPos + alignment.alignHorizontal(actualCrossAxisSizes[crossAxisCell], child.transform.getExtent(crossAxis))); child.transform.setCoordinate(mainAxis, mainAxisPos + alignment.alignVertical(actualMainAxisSizes[mainAxisCell], child.transform.getExtent(mainAxis))); crossAxisPos += actualCrossAxisSizes[crossAxisCell]; } mainAxisPos += actualMainAxisSizes[mainAxisCell]; } this.debugCrossAxisSizes = actualCrossAxisSizes; this.debugMainAxisSizes = actualMainAxisSizes; this.transform.setSize( mainAxis == LayoutAxis.VERTICAL ? Size.of(DoubleStream.of(actualCrossAxisSizes).sum(), DoubleStream.of(actualMainAxisSizes).sum()) : Size.of(DoubleStream.of(actualMainAxisSizes).sum(), DoubleStream.of(actualCrossAxisSizes).sum()) ); } @Override public List debugListInspectorProperties() { return List.of( new InspectorProperty( Component.literal("Main Axis"), Component.literal(this.widget.mainAxis.toString()) ) ); } @Override public boolean debugHasVisualizers() { return true; } @Override protected void debugDrawVisualizers(BraidGraphics graphics) { var frameColor = Color.rgb(0xFFD65A); graphics.drawRectOutline( 0, 0, (int) this.transform.width(), (int) this.transform.height(), frameColor.argb() ); var verticalSizes = this.widget.mainAxis == LayoutAxis.VERTICAL ? this.debugMainAxisSizes : this.debugCrossAxisSizes; var horizontalSizes = this.widget.mainAxis == LayoutAxis.VERTICAL ? this.debugCrossAxisSizes : this.debugMainAxisSizes; var verticalPos = 0.0; for (int i = 0; i < verticalSizes.length; i++) { if (i > 0) { graphics.drawDashedLine( RenderPipelines.GUI, 0, verticalPos, this.transform.width(), verticalPos, 1, 2, frameColor ); } graphics.drawText( Component.literal(verticalSizes[i] + "px").withStyle(style -> style.withFont(new FontDescription.Resource(Minecraft.UNIFORM_FONT))), 0, (float) verticalPos, 1f, Color.WHITE.argb(), OwoUIGraphics.TextAnchor.TOP_RIGHT ); verticalPos += verticalSizes[i]; } var horizontalPos = 0.0; for (int i = 0; i < horizontalSizes.length; i++) { if (i > 0) { graphics.drawDashedLine( RenderPipelines.GUI, horizontalPos, 0, horizontalPos, this.transform.height(), 1, 2, frameColor ); } graphics.drawText( Component.literal(horizontalSizes[i] + "px").withStyle(style -> style.withFont(new FontDescription.Resource(Minecraft.UNIFORM_FONT))), (float) horizontalPos, 0, 1f, Color.WHITE.argb(), OwoUIGraphics.TextAnchor.BOTTOM_LEFT ); horizontalPos += horizontalSizes[i]; } } @Override protected double measureIntrinsicWidth(double height) { return this.widget.mainAxis == LayoutAxis.VERTICAL ? DoubleStream.of(this.measureCrossAxis(height)).sum() : DoubleStream.of(this.measureMainAxis(height)).sum(); } @Override protected double measureIntrinsicHeight(double width) { return this.widget.mainAxis == LayoutAxis.VERTICAL ? DoubleStream.of(this.measureMainAxis(width)).sum() : DoubleStream.of(this.measureCrossAxis(width)).sum(); } protected double[] measureCrossAxis(double mainAxisCellSize) { var crossAxisCells = this.widget.crossAxisCells; var measureFunction = this.widget.mainAxis.opposite().>>chooseCompute( () -> child -> child.getIntrinsicWidth(mainAxisCellSize), () -> child -> child.getIntrinsicHeight(mainAxisCellSize) ); var intrinsics = this.children.stream().mapToDouble(measureFunction).toArray(); var sizes = new double[crossAxisCells]; for (var cell = 0; cell < crossAxisCells; cell++) { var cellSize = 0d; for (var childIdx = cell; childIdx < this.children.size(); childIdx += crossAxisCells) { cellSize = Math.max(cellSize, intrinsics[childIdx]); } sizes[cell] = cellSize; } return sizes; } protected double[] measureMainAxis(double crossAxisCellSize) { var crossAxisCells = this.widget.crossAxisCells; var mainAxisCells = Mth.ceil(this.children.size() / (double) this.widget.crossAxisCells); var measureFunction = this.widget.mainAxis.>>chooseCompute( () -> child -> child.getIntrinsicWidth(crossAxisCellSize), () -> child -> child.getIntrinsicHeight(crossAxisCellSize) ); var intrinsics = this.children.stream().mapToDouble(measureFunction).toArray(); var sizes = new double[mainAxisCells]; for (var cell = 0; cell < mainAxisCells; cell++) { var cellSize = 0d; var firstChild = cell * crossAxisCells; var lastChild = firstChild + (crossAxisCells - 1); for (var childIdx = firstChild; childIdx <= lastChild; childIdx++) { if (childIdx >= this.children.size()) break; cellSize = Math.max(cellSize, intrinsics[childIdx]); } sizes[cell] = cellSize; } return sizes; } @Override protected OptionalDouble measureBaselineOffset() { return this.computeHighestBaselineOffset(); } } public static sealed abstract class CellFit { public abstract boolean isTight(); public static CellFit loose() { return loose(Alignment.CENTER); } public static CellFit loose(Alignment alignment) { return new Loose(alignment); } public static CellFit tight() { return Tight.INSTANCE; } public static final class Tight extends CellFit { public static final Tight INSTANCE = new Tight(); @Override public boolean isTight() { return true; } } public static final class Loose extends CellFit { public final Alignment alignment; public Loose(Alignment alignment) {this.alignment = alignment;} @Override public boolean isTight() { return false; } } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/inspector/BraidInspector.java ================================================ package io.wispforest.owo.braid.widgets.inspector; import io.wispforest.owo.braid.core.AppState; import io.wispforest.owo.braid.core.BraidWindow; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; import io.wispforest.owo.braid.widgets.eventstream.BraidEventSource; import io.wispforest.owo.braid.widgets.eventstream.BraidEventStream; import net.minecraft.util.Unit; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; public class BraidInspector { public final AppState subject; public WidgetProxy rootProxy; public WidgetInstance rootInstance; private final BraidEventStream refreshEvents = new BraidEventStream<>(); private final BraidEventStream pickEvents = new BraidEventStream<>(); private final BraidEventStream revealEvents = new BraidEventStream<>(); private boolean active = false; @Nullable AppState currentApp; @Nullable BraidWindow currentWindow; public BraidInspector(AppState subject) { this.subject = subject; } public BraidEventSource onPick() { return this.pickEvents.source(); } public void pick() { this.pickEvents.sink().onEvent(Unit.INSTANCE); } public BraidEventSource onRefresh() { return this.refreshEvents.source(); } public BraidEventSource onReveal() { return this.revealEvents.source(); } public void activate() { if (this.rootProxy == null || this.rootInstance == null) { throw new IllegalStateException("cannot activate the braid inspector before the root proxy and instance have been set"); } if (this.currentApp != null) { GLFW.glfwShowWindow(this.currentWindow.handle); return; } if (this.active) return; this.active = true; var result = BraidWindow.open( "braid inspector", 900, 550, new InspectorWidget(this.rootProxy, this.rootInstance, this) ); GLFW.glfwSetWindowAttrib(result.window().handle, GLFW.GLFW_FLOATING, GLFW.GLFW_TRUE); this.currentApp = result.state(); this.currentWindow = result.window(); this.currentApp.onTerminate(() -> { this.currentApp = null; this.currentWindow = null; this.active = false; }); } public void revealInstance(WidgetInstance instance) { if (!this.active) return; this.revealEvents.sink().onEvent(new RevealInstanceEvent(instance)); } public void refresh() { this.refreshEvents.sink().onEvent(Unit.INSTANCE); } public void close() { if (this.currentApp == null) return; this.currentApp.scheduleShutdown(); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/inspector/CollapsibleEntry.java ================================================ package io.wispforest.owo.braid.widgets.inspector; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.collapsible.LazyCollapsible; import io.wispforest.owo.braid.widgets.eventstream.BraidEventSource; import io.wispforest.owo.braid.widgets.eventstream.StreamListenerState; import io.wispforest.owo.braid.widgets.intents.Intent; import io.wispforest.owo.braid.widgets.intents.Interactable; import io.wispforest.owo.braid.widgets.intents.ShortcutTrigger; import net.minecraft.util.Unit; import java.util.List; import java.util.Map; public class CollapsibleEntry extends StatefulWidget { public final BraidEventSource onExpand; public final boolean startCollapsed; public final Widget title; public final Widget content; public CollapsibleEntry(BraidEventSource onExpand, boolean startCollapsed, Widget title, Widget content) { this.onExpand = onExpand; this.startCollapsed = startCollapsed; this.title = title; this.content = content; } @Override public WidgetState createState() { return new State(); } public static class State extends StreamListenerState { private boolean collapsed; private void expand(Unit unit) { this.setState(() -> { this.collapsed = false; }); } @Override public void init() { this.streamListen(widget -> widget.onExpand, this::expand); this.collapsed = this.widget().startCollapsed; } @Override public Widget build(BuildContext context) { return new Interactable( SHORTCUTS, widget -> widget .addCallbackAction(SetCollapsedIntent.class, (actionCtx, intent) -> { this.setState(() -> this.collapsed = intent.collapsed()); }), new LazyCollapsible( true, this.collapsed, nowCollapsed -> this.setState(() -> this.collapsed = nowCollapsed), this.widget().title, this.widget().content ) ); } } // --- public static final Map, Intent> SHORTCUTS = Map.of( List.of(ShortcutTrigger.LEFT), new SetCollapsedIntent(true), List.of(ShortcutTrigger.RIGHT), new SetCollapsedIntent(false) ); } record SetCollapsedIntent(boolean collapsed) implements Intent {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/inspector/InspectorState.java ================================================ package io.wispforest.owo.braid.widgets.inspector; import io.wispforest.owo.braid.widgets.sharedstate.ShareableState; import org.jetbrains.annotations.Nullable; public class InspectorState extends ShareableState { public @Nullable Object selectedElement; public @Nullable RevealInstanceEvent lastRevealEvent; } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/inspector/InspectorWidget.java ================================================ package io.wispforest.owo.braid.widgets.inspector; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.SpriteWidget; import io.wispforest.owo.braid.widgets.basic.*; import io.wispforest.owo.braid.widgets.button.Button; import io.wispforest.owo.braid.widgets.eventstream.StreamListenerState; import io.wispforest.owo.braid.widgets.flex.Flexible; import io.wispforest.owo.braid.widgets.flex.Row; import io.wispforest.owo.braid.widgets.label.DefaultLabelStyle; import io.wispforest.owo.braid.widgets.label.LabelStyle; import io.wispforest.owo.braid.widgets.scroll.DefaultScrollAnimationSettings; import io.wispforest.owo.braid.widgets.scroll.FlatScrollbar; import io.wispforest.owo.braid.widgets.scroll.ScrollAnimationSettings; import io.wispforest.owo.braid.widgets.scroll.ScrollableWithBars; import io.wispforest.owo.braid.widgets.sharedstate.SharedState; import io.wispforest.owo.braid.widgets.stack.Stack; import net.minecraft.client.Minecraft; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.FontDescription; import net.minecraft.network.chat.Style; import org.lwjgl.glfw.GLFW; import java.util.List; public class InspectorWidget extends StatefulWidget { private final WidgetProxy rootProxy; private final WidgetInstance rootInstance; private final BraidInspector inspector; public InspectorWidget(WidgetProxy rootProxy, WidgetInstance rootInstance, BraidInspector inspector) { this.rootProxy = rootProxy; this.rootInstance = rootInstance; this.inspector = inspector; } @Override public WidgetState createState() { return new State(); } public static class State extends StreamListenerState { public InspectorState inspectorState; public boolean alwaysOnTop = true; @Override public void init() { this.streamListen( widget -> widget.inspector.onRefresh(), unit -> setState(() -> {}) ); this.streamListen( widget -> widget.inspector.onReveal(), event -> this.inspectorState.setState(() -> { this.inspectorState.selectedElement = event.instance; this.inspectorState.lastRevealEvent = event; }) ); } @Override public Widget build(BuildContext context) { return new DefaultScrollAnimationSettings( ScrollAnimationSettings.DEFAULT, new SharedState<>( InspectorState::new, new Builder(stateContext -> { this.inspectorState = SharedState.getWithoutDependency(stateContext, InspectorState.class); return new Box( Color.rgb(0x1d2026), new DefaultLabelStyle( new LabelStyle(null, null, Style.EMPTY.withFont(new FontDescription.Resource(Minecraft.UNIFORM_FONT)), null), new Row( new Flexible( new Stack( new ScrollableWithBars( null, null, null, 3, (axis, controller) -> new FlatScrollbar(axis, controller, Color.rgb(0xabb0bf), Color.rgb(0xabb0bf)), new Align( Alignment.TOP_LEFT, new InstanceTreeView(this.widget().inspector.onReveal(), this.widget().rootInstance) ) ), new Align( Alignment.BOTTOM_RIGHT, new Padding( Insets.all(5), new Row( new Padding(Insets.horizontal(1)), List.of( new Sized( 20, 20, new Tooltip( Component.literal(this.alwaysOnTop ? "window behavior:\nalways on top" : "window behavior:\nnormal"), new Button( () -> this.setState(() -> { this.alwaysOnTop = !this.alwaysOnTop; GLFW.glfwSetWindowAttrib(this.widget().inspector.currentWindow.handle, GLFW.GLFW_FLOATING, this.alwaysOnTop ? GLFW.GLFW_TRUE : GLFW.GLFW_FALSE); }), new SpriteWidget( this.alwaysOnTop ? Owo.id("braid_inspector_always_on_top") : Owo.id("braid_inspector_not_always_on_top") ) ) ) ), new Sized( 20, 20, new Tooltip( Component.literal("reassemble app"), new Button( () -> this.widget().inspector.subject.rebuildRoot(), new SpriteWidget(Owo.id("braid_inspector_reassemble")) ) ) ), new Sized( 20, 20, new Tooltip( Component.literal("pick widget"), new Button( () -> this.widget().inspector.pick(), new SpriteWidget(Owo.id("braid_inspector_pick")) ) ) ) ) ) ) ) ) ), new InstanceDetails() ) ) ); }) ) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/inspector/InstanceDetails.java ================================================ package io.wispforest.owo.braid.widgets.inspector; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Box; import io.wispforest.owo.braid.widgets.basic.Center; import io.wispforest.owo.braid.widgets.basic.Padding; import io.wispforest.owo.braid.widgets.basic.Sized; import io.wispforest.owo.braid.widgets.checkbox.Checkbox; import io.wispforest.owo.braid.widgets.checkbox.CheckboxStyle; import io.wispforest.owo.braid.widgets.flex.*; import io.wispforest.owo.braid.widgets.grid.Grid; import io.wispforest.owo.braid.widgets.label.Label; import io.wispforest.owo.braid.widgets.sharedstate.SharedState; import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import org.joml.Matrix3x2f; import org.joml.Vector2f; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; import java.util.stream.Stream; public class InstanceDetails extends StatefulWidget { @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { @Override public Widget build(BuildContext context) { var selected = SharedState.select(context, InspectorState.class, state -> state.selectedElement); List children; if (selected instanceof WidgetInstance instance) { var instanceClassName = instance.getClass().getName(); var matcher = INSTANCE_NAME_PATTERN.matcher(instanceClassName); instanceClassName = matcher.matches() ? matcher.group(1) : instanceClassName; children = new ArrayList<>(); children.add(new Grid( LayoutAxis.VERTICAL, 2, Grid.CellFit.tight(), colorRows( Color.rgb(0x111319), 2, gatherProperties(instance).stream().map(Label::new).toList() ) )); if (instance.debugHasVisualizers()) { children.add(new Padding( Insets.of(5, 0, 5, 0), new Row( MainAxisAlignment.START, CrossAxisAlignment.CENTER, new Checkbox( CheckboxStyle.BRAID, instance.debugDrawVisualizers, nowChecked -> setState(() -> { instance.debugDrawVisualizers = nowChecked; }) ), new Padding( Insets.left(5), Label.literal("draw visualizers") ) ) )); } children.addAll(List.of( new Flexible(new Padding(Insets.none())), new Label(Component.literal(instanceClassName)) )); } else { children = List.of(new Flexible( new Center( new Label(Component.literal("no instance selected")) ) )); } return new Row( new Sized(1, null, new Box(Color.WHITE)), new Sized( 150, null, new Column( Stream.concat( Stream.of(new Padding(Insets.bottom(3), new Label(Component.literal("Instance Details")))), children.stream() ).toList() ) ) ); } private static List gatherProperties(WidgetInstance instance) { var instanceTransform = instance.hasParent() ? instance.parent().computeGlobalTransform().invert() : new Matrix3x2f(); var absPos = instanceTransform.transformPosition((float) instance.transform.x(), (float) instance.transform.y(), new Vector2f()); var properties = new ArrayList<>(List.of( Component.literal("Rel. Position").withStyle(ChatFormatting.BOLD), Component.literal(rounded(instance.transform.x()) + ", " + rounded(instance.transform.y())), Component.literal("Abs. Position").withStyle(ChatFormatting.BOLD), Component.literal(rounded(absPos.x()) + ", " + rounded(absPos.y())), Component.literal("Width").withStyle(ChatFormatting.BOLD), Component.literal(instance.transform.width() + "px"), Component.literal("Height").withStyle(ChatFormatting.BOLD), Component.literal(instance.transform.height() + "px"), Component.literal("Widget").withStyle(ChatFormatting.BOLD), Component.literal(instance.widget().getClass().getSimpleName()) )); for (var property : instance.debugListInspectorProperties()) { properties.add(property.name().copy().withStyle(ChatFormatting.BOLD)); properties.add(property.value()); } return properties; } private static List colorRows(Color alternateColor, int crossAxisCells, List cells) { var result = new ArrayList(); var mainAxisIdx = 0; var crossAxisIdx = 0; for (var widget : cells) { widget = new Padding(Insets.vertical(2), widget); if (mainAxisIdx % 2 == 0) { result.add(widget); } else { result.add(new Box(alternateColor, false, widget)); } if (++crossAxisIdx == crossAxisCells) { crossAxisIdx = 0; mainAxisIdx++; } } return result; } private static String rounded(double value) { return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).toPlainString(); } // --- private static final Pattern INSTANCE_NAME_PATTERN = Pattern.compile("^.*?([A-Za-z]\\w+\\$?Instance)$"); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/inspector/InstancePicker.java ================================================ package io.wispforest.owo.braid.widgets.inspector; import com.google.common.collect.Streams; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.instance.HitTestState; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Builder; import io.wispforest.owo.braid.widgets.basic.MouseArea; import io.wispforest.owo.braid.widgets.basic.Padding; import io.wispforest.owo.braid.widgets.eventstream.BraidEventSource; import io.wispforest.owo.braid.widgets.eventstream.StreamListenerState; import io.wispforest.owo.braid.widgets.stack.Stack; import io.wispforest.owo.braid.widgets.stack.StackBase; import net.minecraft.util.Unit; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; import java.util.ArrayList; import java.util.Comparator; public class InstancePicker extends StatefulWidget { public final BraidEventSource activateEvents; public final PickCallback pickCallback; public final Widget child; public InstancePicker(BraidEventSource activateEvents, PickCallback pickCallback, Widget child) { this.activateEvents = activateEvents; this.pickCallback = pickCallback; this.child = child; } @Override public WidgetState createState() { return new State(); } public static class State extends StreamListenerState { private BuildContext childContext; private @Nullable WidgetInstance pickedInstance; private boolean picking = false; @Override public void init() { this.streamListen(widget -> widget.activateEvents, unit -> { this.setState(() -> this.picking = true); }); } @Override public Widget build(BuildContext context) { var children = new ArrayList(); children.add(new StackBase( new Builder(childContext -> { this.childContext = childContext; return this.widget().child; }) )); if (this.picking) { children.add(new MouseArea( widget -> widget .moveCallback((toX, toY) -> { var hitTest = new HitTestState(); this.childContext.instance().hitTest(toX, toY, hitTest); if (this.pickedInstance != null) this.pickedInstance.debugHighlighted = false; var pickHit = Streams.stream(hitTest.occludedTrace()) .min(Comparator.comparingDouble(value -> value.instance().transform.width() * value.instance().transform.height())) .orElse(null); this.pickedInstance = pickHit != null ? pickHit.instance() : null; if (this.pickedInstance != null) this.pickedInstance.debugHighlighted = true; }) .clickCallback((x, y, button, modifiers) -> { if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { if (this.pickedInstance != null) { this.pickedInstance.debugHighlighted = false; this.widget().pickCallback.onPick(this.pickedInstance); } this.setState(() -> this.picking = false); } return true; }), new Padding(Insets.none()) )); } return new Stack(children); } } @FunctionalInterface public interface PickCallback { void onPick(WidgetInstance pickedInstance); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/inspector/InstanceTitle.java ================================================ package io.wispforest.owo.braid.widgets.inspector; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.SpriteWidget; import io.wispforest.owo.braid.widgets.basic.*; import io.wispforest.owo.braid.widgets.flex.CrossAxisAlignment; import io.wispforest.owo.braid.widgets.flex.MainAxisAlignment; import io.wispforest.owo.braid.widgets.flex.Row; import io.wispforest.owo.braid.widgets.focus.Focusable; import io.wispforest.owo.braid.widgets.label.Label; import io.wispforest.owo.braid.widgets.sharedstate.SharedState; import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import java.util.regex.Pattern; public class InstanceTitle extends StatefulWidget { public final WidgetInstance instance; public InstanceTitle(WidgetInstance instance) { this.instance = instance; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { public boolean hovered = false; @Override public Widget build(BuildContext context) { var selected = SharedState.select(context, InspectorState.class, state -> state.selectedElement) == this.widget().instance; var instanceName = this.widget().instance.getClass().getName(); var matcher = INSTANCE_NAME_PATTERN.matcher(instanceName); if (matcher.matches()) { instanceName = matcher.group(1).replaceAll("\\$", "."); } var title = new Panel( selected ? Owo.id("braid_inspector_selected") : null, new Padding( Insets.all(2), new Row( MainAxisAlignment.START, CrossAxisAlignment.CENTER, new Label(Component.literal(instanceName).withStyle(style -> style.withBold(this.hovered))), new Visibility( this.widget().instance.isRelayoutBoundary() && this.widget().instance.debugParentHasDependency(), new Padding( Insets.left(2), new Tooltip( Component.literal("Relayout Boundary\n").append(Component.literal("with parent dependency").withStyle(ChatFormatting.GRAY)), new SpriteWidget(Owo.id("braid_inspector_relayout_boundary_with_dependency")) ) ) ), new Visibility( this.widget().instance.isRelayoutBoundary() && !this.widget().instance.debugParentHasDependency(), new Padding( Insets.left(2), new Tooltip( Component.literal("Relayout Boundary"), new SpriteWidget(Owo.id("braid_inspector_relayout_boundary")) ) ) ), new Visibility( (this.widget().instance.flags & WidgetInstance.FLAG_HIT_TEST_BOUNDARY) != 0, new Padding( Insets.left(2), new Tooltip( Component.literal("Hit Test Boundary"), new SpriteWidget(Owo.id("braid_inspector_hit_test_boundary")) ) ) ) ) ) ); return new Focusable( widget -> widget .focusGainedCallback(() -> SharedState.set(context, InspectorState.class, state -> state.selectedElement = this.widget().instance)), new MouseArea( widget -> widget .enterCallback(() -> this.setState(() -> { this.widget().instance.debugHighlighted = true; this.hovered = true; })) .exitCallback(() -> this.setState(() -> { this.widget().instance.debugHighlighted = false; this.hovered = false; })) .cursorStyle(CursorStyle.CROSSHAIR), title ) ); } // --- private static final Pattern INSTANCE_NAME_PATTERN = Pattern.compile("^.*?([A-Za-z]\\w+)\\$?Instance$"); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/inspector/InstanceTreeView.java ================================================ package io.wispforest.owo.braid.widgets.inspector; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.animation.Easing; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.SpriteWidget; import io.wispforest.owo.braid.widgets.animated.AnimatedBox; import io.wispforest.owo.braid.widgets.basic.Sized; import io.wispforest.owo.braid.widgets.eventstream.BraidEventSource; import io.wispforest.owo.braid.widgets.eventstream.BraidEventStream; import io.wispforest.owo.braid.widgets.eventstream.StreamListenerState; import io.wispforest.owo.braid.widgets.flex.Column; import io.wispforest.owo.braid.widgets.flex.CrossAxisAlignment; import io.wispforest.owo.braid.widgets.flex.MainAxisAlignment; import io.wispforest.owo.braid.widgets.flex.Row; import io.wispforest.owo.braid.widgets.scroll.Scrollable; import io.wispforest.owo.braid.widgets.sharedstate.SharedState; import net.minecraft.util.Unit; import java.time.Duration; import java.util.ArrayList; public class InstanceTreeView extends StatefulWidget { public final BraidEventSource revealEvents; public final WidgetInstance viewInstance; public InstanceTreeView(BraidEventSource revealEvents, WidgetInstance viewInstance) { this.revealEvents = revealEvents; this.viewInstance = viewInstance; } @Override public WidgetState createState() { return new State(); } public static class State extends StreamListenerState { public final BraidEventStream expandEvents = new BraidEventStream<>(); public boolean builtOnce = false; public boolean highlight = false; private void reveal() { this.schedulePostLayoutCallback(() -> { Scrollable.reveal(this.context(), Insets.all(20)); }); } @Override public void init() { this.streamListen( widget -> widget.revealEvents, event -> { if (event.instance == this.widget().viewInstance) { this.reveal(); } if (event.fullPath.contains(this.widget().viewInstance)) { this.expandEvents.sink().onEvent(Unit.INSTANCE); } } ); } @Override public void didUpdateWidget(InstanceTreeView oldWidget) { if (oldWidget.viewInstance != this.widget().viewInstance) { this.setState(() -> { this.highlight = true; }); } } @Override public Widget build(BuildContext context) { var title = new InstanceTitle(this.widget().viewInstance); var children = new ArrayList>(); this.widget().viewInstance.visitChildren(children::add); if (this.highlight) { this.schedulePostLayoutCallback(() -> this.setState(() -> this.highlight = false)); } var startCollapsed = true; if (!this.builtOnce) { this.builtOnce = true; var lastRevealEvent = SharedState.getWithoutDependency(context, InspectorState.class).lastRevealEvent; if (lastRevealEvent != null && lastRevealEvent.instance == this.widget().viewInstance) { this.reveal(); } startCollapsed = lastRevealEvent == null || !lastRevealEvent.fullPath.contains(this.widget().viewInstance); } return new AnimatedBox( this.highlight ? Duration.ZERO : Duration.ofMillis(1250), Easing.IN_OUT_SINE, this.highlight ? Color.hsv((this.widget().viewInstance.depth() % 15) / 15d, .75, 1, .5) : new Color(0), true, !children.isEmpty() ? new CollapsibleEntry( this.expandEvents.source(), startCollapsed, title, new Column( children.stream() .map(child -> new InstanceTreeView(this.widget().revealEvents, child)) .toList() ) ) : new Row( MainAxisAlignment.START, CrossAxisAlignment.CENTER, new Sized( 12, 12, new SpriteWidget(Owo.id("braid_inspector_leaf")) ), title ) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/inspector/RevealInstanceEvent.java ================================================ package io.wispforest.owo.braid.widgets.inspector; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; public class RevealInstanceEvent { public final WidgetInstance instance; public final Set> fullPath; public RevealInstanceEvent(WidgetInstance instance) { this.instance = instance; this.fullPath = Stream.concat( this.instance.ancestors().stream(), Stream.of(this.instance) ).collect(Collectors.toSet()); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/Action.java ================================================ package io.wispforest.owo.braid.widgets.intents; import io.wispforest.owo.braid.framework.BuildContext; public abstract class Action { public boolean isActive(BuildContext context, I intent) { return true; } public abstract void invoke(BuildContext context, I intent); public static Action callback(Callback callback) { return new CallbackAction<>(callback); } public static class CallbackAction extends Action { public Callback callback; public CallbackAction(Callback callback) { this.callback = callback; } @Override public void invoke(BuildContext context, I intent) { this.callback.invoke(context, intent); } } @FunctionalInterface public interface Callback { void invoke(BuildContext actionCtx, I intent); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/Actions.java ================================================ package io.wispforest.owo.braid.widgets.intents; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.focus.Focusable; import org.jetbrains.annotations.Nullable; import java.util.HashMap; import java.util.Map; public class Actions extends StatefulWidget { private boolean focusable = true; private boolean autoFocus = false; private boolean skipTraversal = false; private final Map, Action> actions; public final Widget child; public Actions(@Nullable WidgetSetupCallback setup, Widget child) { this.actions = new HashMap<>(); this.child = child; if (setup != null) setup.setup(this); } public Actions focusable(boolean focusable) { this.assertMutable(); this.focusable = focusable; return this; } public boolean focusable() { return this.focusable; } public Actions autoFocus(boolean autoFocus) { this.assertMutable(); this.autoFocus = autoFocus; return this; } public boolean autoFocus() { return this.autoFocus; } public Actions skipTraversal(boolean skipTraversal) { this.assertMutable(); this.skipTraversal = skipTraversal; return this; } public boolean skipTraversal() { return this.skipTraversal; } public Actions actions(Map, Action> actions) { this.assertMutable(); this.actions.putAll(actions); return this; } public Actions addAction(Class intentType, Action action) { if (this.actions.containsKey(intentType)) { throw new IllegalArgumentException("Duplicate intent type: " + intentType); } this.actions.put(intentType, action); return this; } public Actions addCallbackAction(Class intentType, Action.Callback callback) { this.addAction(intentType, Action.callback(callback)); return this; } public Map, Action> actions() { return this.actions; } @Override public WidgetState createState() { return new State(); } // --- public static boolean invoke(BuildContext context, Intent intent) { var action = actionForIntent(context, intent); if (action != null) { action.invoke(context, intent); return true; } return false; } @SuppressWarnings({"unchecked"}) public static @Nullable Action actionForIntent(BuildContext context, I intent) { var intents = context.getAncestor(ActionsProvider.class); while (intents != null) { var action = intents.state.widget().actions.get(intent.getClass()); if (action != null && ((Action) action).isActive(context, intent)) { break; } intents = intents.state.context().getAncestor(ActionsProvider.class); } return intents != null ? (Action) intents.state.widget().actions.get(intent.getClass()) : null; } // --- public static class State extends WidgetState { @Override public Widget build(BuildContext context) { var widget = this.widget(); return new ActionsProvider( this, widget.focusable ? new Focusable(focusable -> focusable.autoFocus(widget.autoFocus).skipTraversal(this.widget().skipTraversal), widget.child) : widget.child ); } } } class ActionsProvider extends InheritedWidget { public final Actions.State state; public ActionsProvider(Actions.State state, Widget child) { super(child); this.state = state; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return false; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/AdjustIntent.java ================================================ package io.wispforest.owo.braid.widgets.intents; public record AdjustIntent(Direction direction) implements Intent { public enum Direction { INCREMENT, DECREMENT; public int offset() { return switch (this) { case INCREMENT -> 1; case DECREMENT -> -1; }; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/Intent.java ================================================ package io.wispforest.owo.braid.widgets.intents; public interface Intent {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/Interactable.java ================================================ package io.wispforest.owo.braid.widgets.intents; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.basic.MouseArea; import io.wispforest.owo.braid.widgets.focus.Focusable; import org.jetbrains.annotations.Nullable; import java.util.HashMap; import java.util.List; import java.util.Map; public class Interactable extends StatefulWidget { private @Nullable MouseArea.EnterCallback enterCallback; private @Nullable MouseArea.ExitCallback exitCallback; private @Nullable MouseArea.CursorStyleSupplier cursorStyleSupplier; private @Nullable Focusable.FocusGainedCallback focusGainedCallback; private @Nullable Focusable.FocusLostCallback focusLostCallback; private boolean skipTraversal = false; private boolean autoFocus = false; public final Map, Intent> shortcuts; private final Map, Action> actions = new HashMap<>(); public final Widget child; public Interactable( @Nullable Map, Intent> shortcuts, WidgetSetupCallback setup, Widget child ) { this.shortcuts = shortcuts != null ? shortcuts : Map.of(); this.child = child; setup.setup(this); } public static Widget primary(@Nullable Runnable onClick, @Nullable WidgetSetupCallback setup, Widget child) { return new Interactable( CLICK_SHORTCUT, widget -> { if (onClick != null) { widget .addAction(PrimaryActionIntent.class, Action.callback((context, intent) -> onClick.run())) .cursorStyle(CursorStyle.HAND); } if (setup != null) { setup.setup(widget); } }, child ); } public static Widget primary(Runnable onClick, Widget child) { return primary(onClick, null, child); } // --- public Interactable enterCallback(@Nullable MouseArea.EnterCallback enterCallback) { this.assertMutable(); this.enterCallback = enterCallback; return this; } public @Nullable MouseArea.EnterCallback enterCallback() { return this.enterCallback; } public Interactable exitCallback(@Nullable MouseArea.ExitCallback exitCallback) { this.assertMutable(); this.exitCallback = exitCallback; return this; } public @Nullable MouseArea.ExitCallback exitCallback() { return this.exitCallback; } public Interactable cursorStyleSupplier(@Nullable MouseArea.CursorStyleSupplier cursorStyleSupplier) { this.assertMutable(); this.cursorStyleSupplier = cursorStyleSupplier; return this; } public Interactable cursorStyle(@Nullable CursorStyle style) { return this.cursorStyleSupplier((x, y) -> style); } public @Nullable MouseArea.CursorStyleSupplier cursorStyleSupplier() { return this.cursorStyleSupplier; } public Interactable focusGainedCallback(@Nullable Focusable.FocusGainedCallback focusGainedCallback) { this.assertMutable(); this.focusGainedCallback = focusGainedCallback; return this; } public @Nullable Focusable.FocusGainedCallback focusGainedCallback() { return this.focusGainedCallback; } public Interactable focusLostCallback(@Nullable Focusable.FocusLostCallback focusLostCallback) { this.assertMutable(); this.focusLostCallback = focusLostCallback; return this; } public @Nullable Focusable.FocusLostCallback focusLostCallback() { return this.focusLostCallback; } public Interactable skipTraversal(boolean skipTraversal) { this.assertMutable(); this.skipTraversal = skipTraversal; return this; } public boolean skipTraversal() { return this.skipTraversal; } public Interactable autoFocus(boolean autoFocus) { this.assertMutable(); this.autoFocus = autoFocus; return this; } public boolean autoFocus() { return this.autoFocus; } public Interactable actions(Map, Action> actions) { this.assertMutable(); this.actions.putAll(actions); return this; } public Interactable addAction(Class intentType, Action action) { if (this.actions.containsKey(intentType)) { throw new IllegalArgumentException("Duplicate intent type: " + intentType); } this.actions.put(intentType, action); return this; } public Interactable addCallbackAction(Class intentType, Action.Callback callback) { this.addAction(intentType, Action.callback(callback)); return this; } public Map, Action> actions() { return this.actions; } @Override public WidgetState createState() { return new State(); } // --- private static final Map, Intent> CLICK_SHORTCUT = Map.of( List.of(ShortcutTrigger.LEFT_CLICK), PrimaryActionIntent.INSTANCE ); // --- public static class State extends WidgetState { @Override public Widget build(BuildContext context) { var widget = this.widget(); return new Actions( actions -> actions .focusable(false) .actions(this.widget().actions), new Shortcuts( widget.shortcuts, shortcuts -> shortcuts .enterCallback(this.widget().enterCallback) .exitCallback(this.widget().exitCallback) .cursorStyleSupplier(this.widget().cursorStyleSupplier) .focusGainedCallback(this.widget().focusGainedCallback) .focusLostCallback(this.widget().focusLostCallback) .skipTraversal(this.widget().skipTraversal) .autoFocus(this.widget().autoFocus), widget.child ) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/PrimaryActionIntent.java ================================================ package io.wispforest.owo.braid.widgets.intents; public class PrimaryActionIntent implements Intent { private PrimaryActionIntent() {} public static final PrimaryActionIntent INSTANCE = new PrimaryActionIntent(); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/SecondaryActionIntent.java ================================================ package io.wispforest.owo.braid.widgets.intents; public class SecondaryActionIntent implements Intent { private SecondaryActionIntent() {} public static final SecondaryActionIntent INSTANCE = new SecondaryActionIntent(); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/ShortcutDecoder.java ================================================ package io.wispforest.owo.braid.widgets.intents; import com.google.common.collect.Iterables; import io.wispforest.owo.braid.core.BraidUtils; import io.wispforest.owo.braid.core.KeyModifiers; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.basic.MouseArea; import io.wispforest.owo.braid.widgets.focus.Focusable; import net.minecraft.util.Tuple; import org.jetbrains.annotations.Nullable; import java.time.Duration; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Function; public class ShortcutDecoder extends StatefulWidget { private @Nullable MouseArea.EnterCallback enterCallback; private @Nullable MouseArea.ExitCallback exitCallback; private @Nullable MouseArea.CursorStyleSupplier cursorStyleSupplier; private @Nullable Focusable.FocusGainedCallback focusGainedCallback; private @Nullable Focusable.FocusLostCallback focusLostCallback; private boolean skipTraversal = false; private boolean autoFocus = false; private final Map, Listener> shortcuts = new LinkedHashMap<>(); private final Widget child; public ShortcutDecoder( WidgetSetupCallback setupCallback, Widget child ) { this.child = child; setupCallback.setup(this); } public ShortcutDecoder enterCallback(@Nullable MouseArea.EnterCallback enterCallback) { this.assertMutable(); this.enterCallback = enterCallback; return this; } public @Nullable MouseArea.EnterCallback enterCallback() { return this.enterCallback; } public ShortcutDecoder exitCallback(@Nullable MouseArea.ExitCallback exitCallback) { this.assertMutable(); this.exitCallback = exitCallback; return this; } public @Nullable MouseArea.ExitCallback exitCallback() { return this.exitCallback; } public ShortcutDecoder cursorStyleSupplier(@Nullable MouseArea.CursorStyleSupplier cursorStyleSupplier) { this.assertMutable(); this.cursorStyleSupplier = cursorStyleSupplier; return this; } public ShortcutDecoder cursorStyle(@Nullable CursorStyle style) { return this.cursorStyleSupplier((x, y) -> style); } public @Nullable MouseArea.CursorStyleSupplier cursorStyleSupplier() { return this.cursorStyleSupplier; } public ShortcutDecoder focusGainedCallback(@Nullable Focusable.FocusGainedCallback focusGainedCallback) { this.assertMutable(); this.focusGainedCallback = focusGainedCallback; return this; } public @Nullable Focusable.FocusGainedCallback focusGainedCallback() { return this.focusGainedCallback; } public ShortcutDecoder focusLostCallback(@Nullable Focusable.FocusLostCallback focusLostCallback) { this.assertMutable(); this.focusLostCallback = focusLostCallback; return this; } public @Nullable Focusable.FocusLostCallback focusLostCallback() { return this.focusLostCallback; } public ShortcutDecoder skipTraversal(boolean skipTraversal) { this.assertMutable(); this.skipTraversal = skipTraversal; return this; } public boolean skipTraversal() { return this.skipTraversal; } public ShortcutDecoder autoFocus(boolean autoFocus) { this.assertMutable(); this.autoFocus = autoFocus; return this; } public boolean autoFocus() { return this.autoFocus; } public ShortcutDecoder shortcuts(Map, Listener> shortcuts) { this.assertMutable(); this.shortcuts.putAll(shortcuts); return this; } public ShortcutDecoder addShortcut(List triggers, Listener action) { this.assertMutable(); this.shortcuts.put(triggers, action); return this; } public ShortcutDecoder addShortcut(ShortcutTrigger trigger, Listener action) { return this.addShortcut(List.of(trigger), action); } public Map, Listener> shortcuts() { return this.shortcuts; } @Override public WidgetState createState() { return new State(); } @FunctionalInterface public interface Listener { boolean trigger(TriggerType type); } public static class State extends WidgetState { private List sequences = new ArrayList<>(); private final List queuedSequences = new ArrayList<>(); @Nullable private Long callbackId; @Override public void init() { this.buildSequences(); } @Override public void didUpdateWidget(ShortcutDecoder oldWidget) { this.buildSequences(); } private void buildSequences() { this.sequences = widget().shortcuts.entrySet().stream().map(emongus -> new ShortcutSequence(emongus.getKey(), emongus.getValue())).toList(); } @Override public Widget build(BuildContext context) { return new MouseArea( widget -> widget .enterCallback(this.widget().enterCallback()) .exitCallback(this.widget().exitCallback()) .cursorStyleSupplier(this.widget().cursorStyleSupplier()) .clickCallback((x, y, button, modifiers) -> stepShortcuts(trigger -> trigger.isTriggeredByMouseButton(button, modifiers) ? ShortcutTriggerResult.ACTIVATED : ShortcutTriggerResult.NOT_ACTIVATED, TriggerType.MOUSE)), new Focusable( widget -> widget .focusGainedCallback(this.widget().focusGainedCallback()) .focusLostCallback(this.widget().focusLostCallback()) .skipTraversal(this.widget().skipTraversal()) .autoFocus(this.widget().autoFocus()) .keyDownCallback((keyCode, modifiers) -> stepShortcuts(trigger -> { if (trigger.isTriggeredByKeyCode(keyCode, modifiers)) return ShortcutTriggerResult.ACTIVATED; return KeyModifiers.isModifier(keyCode) ? ShortcutTriggerResult.IGNORED : ShortcutTriggerResult.NOT_ACTIVATED; }, TriggerType.KEY)), this.widget().child ) ); } private boolean stepShortcuts(Function test, TriggerType trigger) { // in case we currently have a dispatch queued, we // must cancel it *now* to avoid prematurely triggering // a dispatch before the user is done entering triggers if (this.callbackId != null) { this.cancelDelayedCallback(this.callbackId); this.callbackId = null; } // now, begin by stepping all sequences with current input and keeping // only the ones which didn't ignore it. this can lead to a few outcomes // for each sequence. to break it down: // - singular sequences: // these can always step and, if so, will immediately complete // - non-singular sequences: // whether these can step depends on their current state: // - non-negative trigger index: // if triggered, will step and potentially complete // if not triggered, will not step and poison the trigger index // - negative (poisoned) trigger index: // will not step var steppedSequences = sequences.stream() .map(sequence -> new Tuple<>(sequence, sequence.step(test))) .filter(pair -> pair.getB() != ShortcutSequenceStep.IGNORE) .toList(); // next, get the sequence to treat as completed on this iteration - if any // - if multiple sequences completed, pick the first one // - always prioritize non-singular sequences over singular sequences. // this is important, since the current trigger could both finish // a non-singular sequence (user intent) and immediately complete a // singular one (this would be an artifact) var completed = BraidUtils.fold( Iterables.filter(steppedSequences, pair -> pair.getB() == ShortcutSequenceStep.COMPLETE), (Tuple) null, (acc, element) -> { if (acc == null) return element; if (!element.getA().isSingular && acc.getA().isSingular) return element; return acc; } ); //(I personally think this should've used stream.reduce but glisco said it was "not ideal" so here we are) -chyz // if we have successfully resolved all ambiguity, that is, // every remaining (non-poisoned) sequence stepped to completion, // dispatch immediately if (steppedSequences.stream().allMatch(pair -> pair.getB() == ShortcutSequenceStep.COMPLETE) && completed != null) { return this.dispatch(completed.getA(), completed.getA().isSingular, trigger); } else { // otherwise, queue up the completed sequence (if any) // and queue dispatch after the maximum possible input delay if (completed != null) { // if the sequence we just complete is non-singular, clear // the queue - this is important, since otherwise we could duplicate // the respective events if (!completed.getA().isSingular) { this.queuedSequences.clear(); } this.queuedSequences.add(completed.getA()); completed.getA().nextTriggerIndex = 0; } this.callbackId = this.scheduleDelayedCallback(MAX_INPUT_DELAY, () -> this.dispatch(null, true, trigger)); return !steppedSequences.isEmpty(); } } private boolean dispatch(@Nullable ShortcutDecoder.State.ShortcutSequence completedSequence, boolean runQueued, TriggerType trigger) { if (runQueued) { for (var sequence : this.queuedSequences) { sequence.callback.trigger(trigger); } } var success = false; if (completedSequence != null) { success = completedSequence.callback.trigger(trigger); } this.queuedSequences.clear(); for (var sequence : sequences) { sequence.nextTriggerIndex = 0; } return success; } public static final Duration MAX_INPUT_DELAY = Duration.ofMillis(250); private enum ShortcutTriggerResult { /// the trigger was not activated by this input. /// non-singular sequences should poison NOT_ACTIVATED, /// the trigger was activated by this input. /// sequences should step ACTIVATED, /// the trigger entirely ignored this input. /// non-singular sequences should not poison /// and sequences should not step IGNORED } private enum ShortcutSequenceStep { IGNORE, ADVANCE, COMPLETE } private static class ShortcutSequence { public final List triggers; public final Listener callback; /// whether this sequence is singular, i.e. it only has /// a single trigger and can be completed at any time public final boolean isSingular; public int nextTriggerIndex = 0; public ShortcutSequence(List triggers, Listener callback) { this.triggers = triggers; this.callback = callback; this.isSingular = triggers.size() == 1; } /// step this sequence /// - if the sequence ignored the input, is poisoned or is completed, return [ShortcutSequenceStep#IGNORE] /// - if the sequence activated its final trigger, return [ShortcutSequenceStep#COMPLETE] /// - if the sequence activated an intermediate trigger, return [ShortcutSequenceStep#ADVANCE] public ShortcutSequenceStep step(Function test) { if (this.nextTriggerIndex < 0 || this.nextTriggerIndex >= this.triggers.size()) return ShortcutSequenceStep.IGNORE; var result = test.apply(this.triggers.get(this.nextTriggerIndex)); if (result == ShortcutTriggerResult.ACTIVATED) { this.nextTriggerIndex++; return this.nextTriggerIndex == this.triggers.size() ? ShortcutSequenceStep.COMPLETE : ShortcutSequenceStep.ADVANCE; } else if (!this.isSingular && result == ShortcutTriggerResult.NOT_ACTIVATED) { // only poison non-singular sequences. this is important, because // otherwise we could incorrectly swallow a singular sequence completed // just after the first trigger of a non-singular sequence this.nextTriggerIndex = -1; } return ShortcutSequenceStep.IGNORE; } } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/ShortcutTrigger.java ================================================ package io.wispforest.owo.braid.widgets.intents; import io.wispforest.owo.braid.core.KeyModifiers; import org.jetbrains.annotations.Nullable; import java.util.Arrays; import java.util.Collection; import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; import static org.lwjgl.glfw.GLFW.*; public record ShortcutTrigger(Set triggers) { public static final ShortcutTrigger LEFT_CLICK = new ShortcutTrigger(Trigger.ofMouse(GLFW_MOUSE_BUTTON_LEFT)); public static final ShortcutTrigger RIGHT_CLICK = new ShortcutTrigger(Trigger.ofMouse(GLFW_MOUSE_BUTTON_RIGHT)); public static final ShortcutTrigger UP = new ShortcutTrigger( Trigger.ofKey(GLFW_KEY_UP) ); public static final ShortcutTrigger DOWN = new ShortcutTrigger( Trigger.ofKey(GLFW_KEY_DOWN) ); public static final ShortcutTrigger RIGHT = new ShortcutTrigger( Trigger.ofKey(GLFW_KEY_RIGHT) ); public static final ShortcutTrigger LEFT = new ShortcutTrigger( Trigger.ofKey(GLFW_KEY_LEFT) ); public static final ShortcutTrigger PAGE_UP = new ShortcutTrigger( Trigger.ofKey(GLFW_KEY_PAGE_UP) ); public static final ShortcutTrigger PAGE_DOWN = new ShortcutTrigger( Trigger.ofKey(GLFW_KEY_PAGE_DOWN) ); public static final ShortcutTrigger HOME = new ShortcutTrigger( Trigger.ofKey(GLFW_KEY_HOME) ); public static final ShortcutTrigger END = new ShortcutTrigger( Trigger.ofKey(GLFW_KEY_END) ); public static ShortcutTrigger of(ShortcutTrigger... triggers) { return new ShortcutTrigger(Arrays.stream(triggers).flatMap(actionTrigger -> actionTrigger.triggers.stream()).collect(Collectors.toSet())); } public static ShortcutTrigger of(ShortcutTrigger actionTrigger, Trigger... triggers) { var combinedTriggers = new HashSet<>(actionTrigger.triggers); combinedTriggers.addAll(Arrays.asList(triggers)); return new ShortcutTrigger(combinedTriggers); } public ShortcutTrigger(Collection triggers) { this(Set.copyOf(triggers)); } public ShortcutTrigger(Trigger... triggers) { this(Set.of(triggers)); } public ShortcutTrigger withModifiers(@Nullable KeyModifiers modifiers) { var triggers = new HashSet(); for (var trigger : this.triggers) { triggers.add(trigger.withModifiers(modifiers)); } return new ShortcutTrigger(triggers); } public boolean isTriggeredByMouseButton(int button, KeyModifiers modifiers) { return this.triggers.stream().anyMatch(trigger -> trigger instanceof Trigger.Mouse mouseTrigger && mouseTrigger.isTriggered(button, modifiers)); } public boolean isTriggeredByKeyCode(int keyCode, KeyModifiers modifiers) { return this.triggers.stream().anyMatch(trigger -> trigger instanceof Trigger.Key keyTrigger && keyTrigger.isTriggered(keyCode, modifiers)); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/Shortcuts.java ================================================ package io.wispforest.owo.braid.widgets.intents; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.basic.MouseArea; import io.wispforest.owo.braid.widgets.focus.Focusable; import org.jetbrains.annotations.Nullable; import java.util.HashMap; import java.util.List; import java.util.Map; public class Shortcuts extends StatefulWidget { private @Nullable MouseArea.EnterCallback enterCallback; private @Nullable MouseArea.ExitCallback exitCallback; private @Nullable MouseArea.CursorStyleSupplier cursorStyleSupplier; private @Nullable Focusable.FocusGainedCallback focusGainedCallback; private @Nullable Focusable.FocusLostCallback focusLostCallback; private boolean skipTraversal = false; private boolean autoFocus = false; public final Map, Intent> shortcuts; public final Widget child; public Shortcuts(Map, Intent> shortcuts, @Nullable WidgetSetupCallback setup, Widget child) { this.shortcuts = shortcuts; this.child = child; if (setup != null) setup.setup(this); } public Shortcuts enterCallback(@Nullable MouseArea.EnterCallback enterCallback) { this.assertMutable(); this.enterCallback = enterCallback; return this; } public @Nullable MouseArea.EnterCallback enterCallback() { return this.enterCallback; } public Shortcuts exitCallback(@Nullable MouseArea.ExitCallback exitCallback) { this.assertMutable(); this.exitCallback = exitCallback; return this; } public @Nullable MouseArea.ExitCallback exitCallback() { return this.exitCallback; } public Shortcuts cursorStyleSupplier(@Nullable MouseArea.CursorStyleSupplier cursorStyleSupplier) { this.assertMutable(); this.cursorStyleSupplier = cursorStyleSupplier; return this; } public Shortcuts cursorStyle(@Nullable CursorStyle style) { return this.cursorStyleSupplier((x, y) -> style); } public @Nullable MouseArea.CursorStyleSupplier cursorStyleSupplier() { return this.cursorStyleSupplier; } public Shortcuts focusGainedCallback(@Nullable Focusable.FocusGainedCallback focusGainedCallback) { this.assertMutable(); this.focusGainedCallback = focusGainedCallback; return this; } public @Nullable Focusable.FocusGainedCallback focusGainedCallback() { return this.focusGainedCallback; } public Shortcuts focusLostCallback(@Nullable Focusable.FocusLostCallback focusLostCallback) { this.assertMutable(); this.focusLostCallback = focusLostCallback; return this; } public @Nullable Focusable.FocusLostCallback focusLostCallback() { return this.focusLostCallback; } public Shortcuts skipTraversal(boolean skipTraversal) { this.assertMutable(); this.skipTraversal = skipTraversal; return this; } public boolean skipTraversal() { return this.skipTraversal; } public Shortcuts autoFocus(boolean autoFocus) { this.assertMutable(); this.autoFocus = autoFocus; return this; } public boolean autoFocus() { return this.autoFocus; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private Map, ShortcutDecoder.Listener> listeners; @Override public void init() { this.buildListeners(); } @Override public void didUpdateWidget(Shortcuts oldWidget) { this.buildListeners(); } private void buildListeners() { this.listeners = new HashMap<>(); this.widget().shortcuts.forEach((triggers, intent) -> { this.listeners.put(triggers, type -> { var sourceContext = switch (type) { case KEY -> Focusable.of(this.context()).primaryFocus().context(); case MOUSE -> this.context(); }; return Actions.invoke(sourceContext, intent); }); }); } @Override public Widget build(BuildContext context) { return new ShortcutDecoder( widget -> widget .shortcuts(this.listeners) .enterCallback(this.widget().enterCallback) .exitCallback(this.widget().exitCallback) .cursorStyleSupplier(this.widget().cursorStyleSupplier) .focusGainedCallback(this.widget().focusGainedCallback) .focusLostCallback(this.widget().focusLostCallback) .skipTraversal(this.widget().skipTraversal) .autoFocus(this.widget().autoFocus), this.widget().child ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/TraverseFocusAction.java ================================================ package io.wispforest.owo.braid.widgets.intents; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.widgets.focus.Focusable; public class TraverseFocusAction extends Action { @Override public void invoke(BuildContext context, TraverseFocusIntent intent) { Focusable.of(context).traverseFocus(intent.direction()); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/TraverseFocusIntent.java ================================================ package io.wispforest.owo.braid.widgets.intents; import io.wispforest.owo.braid.widgets.focus.FocusTraversalDirection; public record TraverseFocusIntent(FocusTraversalDirection direction) implements Intent {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/Trigger.java ================================================ package io.wispforest.owo.braid.widgets.intents; import io.wispforest.owo.braid.core.KeyModifiers; import org.jetbrains.annotations.Nullable; import java.util.Objects; public sealed interface Trigger { boolean isTriggered(int button, @Nullable KeyModifiers modifiers); Trigger withModifiers(@Nullable KeyModifiers modifiers); static Trigger.Key ofKey(int keyCode, @Nullable KeyModifiers modifiers) { return new Key(keyCode, modifiers); } static Trigger.Key ofKey(int keyCode) { return new Key(keyCode); } static Trigger.Mouse ofMouse(int button, @Nullable KeyModifiers modifiers) { return new Mouse(button, modifiers); } static Trigger.Mouse ofMouse(int button) { return new Mouse(button); } record Key(int keyCode, @Nullable KeyModifiers modifiers) implements Trigger { public Key(int keyCode) { this(keyCode, KeyModifiers.NONE); } @Override public boolean isTriggered(int button, KeyModifiers modifiers) { return this.keyCode == button && (this.modifiers == null || this.modifiers.equals(modifiers)); } @Override public Trigger withModifiers(@Nullable KeyModifiers modifiers) { return !Objects.equals(this.modifiers, modifiers) ? new Key(this.keyCode, modifiers) : this; } } record Mouse(int button, @Nullable KeyModifiers modifiers) implements Trigger { public Mouse(int button) { this(button, KeyModifiers.NONE); } @Override public boolean isTriggered(int button, KeyModifiers modifiers) { return this.button == button && (this.modifiers == null || this.modifiers.equals(modifiers)); } @Override public Trigger withModifiers(@Nullable KeyModifiers modifiers) { return !Objects.equals(this.modifiers, modifiers) ? new Mouse(this.button, modifiers) : this; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/intents/TriggerType.java ================================================ package io.wispforest.owo.braid.widgets.intents; public enum TriggerType { KEY, MOUSE } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/label/DefaultLabelStyle.java ================================================ package io.wispforest.owo.braid.widgets.label; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Builder; import org.jetbrains.annotations.Nullable; public class DefaultLabelStyle extends InheritedWidget { public final LabelStyle style; public DefaultLabelStyle(LabelStyle style, Widget child) { super(child); this.style = style; } public static Widget merge(LabelStyle style, Widget child) { return new Builder(context -> { var contextStyle = DefaultLabelStyle.maybeOf(context); return new DefaultLabelStyle(contextStyle != null ? style.overriding(contextStyle) : style, child); }); } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return !this.style.equals(((DefaultLabelStyle) newWidget).style); } public static @Nullable LabelStyle maybeOf(BuildContext context) { var widget = context.dependOnAncestor(DefaultLabelStyle.class); if (widget != null) { return widget.style; } else { return null; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/label/Label.java ================================================ package io.wispforest.owo.braid.widgets.label; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Clip; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.Nullable; public class Label extends StatelessWidget { public final @Nullable LabelStyle style; public final boolean softWrap; public final Overflow overflow; public final Component text; public Label(@Nullable LabelStyle style, boolean softWrap, Overflow overflow, Component text) { this.style = style; this.softWrap = softWrap; this.overflow = overflow; this.text = text; } public Label(@Nullable LabelStyle style, boolean softWrap, Component text) { this(style, softWrap, Overflow.CLIP, text); } public Label(boolean softWrap, Component text) { this(null, softWrap, text); } public Label(Overflow overflow, Component text) { this(null, true, overflow, text); } public Label(Component text) { this(true, text); } public static Label literal(String text) { return new Label(Component.literal(text)); } @Override public Widget build(BuildContext context) { var effectiveStyle = this.style != null ? this.style : LabelStyle.EMPTY; if (DefaultLabelStyle.maybeOf(context) instanceof LabelStyle contextStyle) { effectiveStyle = effectiveStyle.overriding(contextStyle); } Widget result = new RawLabel( effectiveStyle.fillDefaults(), this.softWrap, this.overflow == Overflow.ELLIPSIS, this.text ); if (this.overflow == Overflow.CLIP) { result = new Clip(result); } return result; } public enum Overflow { SHOW, CLIP, ELLIPSIS } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/label/LabelStyle.java ================================================ package io.wispforest.owo.braid.widgets.label; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.Color; import net.minecraft.network.chat.Style; import org.jetbrains.annotations.Nullable; public record LabelStyle(@Nullable Alignment textAlignment, @Nullable Color baseColor, @Nullable Style textStyle, @Nullable Boolean shadow) { public static final LabelStyle EMPTY = new LabelStyle(null, null, null, null); public static final LabelStyle SHADOW = new LabelStyle(null, null, null, true); public LabelStyle overriding(LabelStyle other) { return new LabelStyle( this.textAlignment != null ? this.textAlignment : other.textAlignment, this.baseColor != null ? this.baseColor : other.baseColor, this.textStyle != null ? this.textStyle : other.textStyle, this.shadow != null ? this.shadow : other.shadow ); } public LabelStyle fillDefaults() { return new LabelStyle( this.textAlignment != null ? this.textAlignment : Alignment.CENTER, this.baseColor != null ? this.baseColor : Color.WHITE, this.textStyle != null ? this.textStyle : Style.EMPTY, this.shadow != null ? this.shadow : false ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/label/RawLabel.java ================================================ package io.wispforest.owo.braid.widgets.label; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.KeyModifiers; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.instance.LeafWidgetInstance; import io.wispforest.owo.braid.framework.instance.MouseListener; import io.wispforest.owo.braid.framework.instance.TooltipProvider; import io.wispforest.owo.braid.framework.widget.LeafInstanceWidget; import io.wispforest.owo.mixin.braid.ClickableStyleFinderAccessor; import io.wispforest.owo.ui.core.OwoUIGraphics; import it.unimi.dsi.fastutil.doubles.DoubleArrayList; import it.unimi.dsi.fastutil.doubles.DoubleList; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.ActiveTextCollector; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent; import net.minecraft.locale.Language; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.FormattedText; import net.minecraft.network.chat.Style; import net.minecraft.util.FormattedCharSequence; import org.jetbrains.annotations.Nullable; import org.joml.Vector2f; import java.util.List; import java.util.Objects; import java.util.OptionalDouble; import java.util.function.Function; public class RawLabel extends LeafInstanceWidget { public final LabelStyle style; public final boolean softWrap; public final boolean ellipsize; public final Component text; public RawLabel(LabelStyle style, boolean softWrap, boolean ellipsize, Component text) { this.style = style; this.softWrap = softWrap; this.ellipsize = ellipsize; this.text = text; } @Override public LeafWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends LeafWidgetInstance implements TooltipProvider, MouseListener { private List renderText = List.of(); private DoubleList renderTextWidths = new DoubleArrayList(); private int renderTextHeight = 0; protected Function textClickHandler = style -> { return style != null && OwoUIGraphics.utilityScreen().handleTextClick(style, Minecraft.getInstance().screen); }; public Instance(RawLabel widget) { super(widget); } @Override public void setWidget(RawLabel widget) { if (Objects.equals(this.widget.style, widget.style) && this.widget.softWrap == widget.softWrap && this.widget.ellipsize == widget.ellipsize && Objects.equals(this.widget.text, widget.text)) { return; } super.setWidget(widget); this.markNeedsLayout(); } protected List wrapText(Font font, int maxWidth, double maxHeight) { var styledText = this.widget.text.copy().withStyle(textStyle -> textStyle.applyTo(this.widget.style.textStyle())); var wrappedLines = font.getSplitter().splitLines(styledText, this.widget.softWrap ? maxWidth : Integer.MAX_VALUE, Style.EMPTY); var maxLines = (int) Math.floor(maxHeight / font.lineHeight); if (this.widget.ellipsize && !wrappedLines.isEmpty() && maxLines > 0 && (wrappedLines.size() > maxLines || font.width(wrappedLines.getLast()) > maxWidth)) { wrappedLines = wrappedLines.subList(0, maxLines); var ellipsis = FormattedText.of("…"); var ellipsisLength = font.width(ellipsis); var trimmedLastLine = font.substrByWidth(wrappedLines.getLast(), maxWidth - ellipsisLength); wrappedLines.set( wrappedLines.size() - 1, FormattedText.composite(trimmedLastLine, ellipsis) ); } return Language.getInstance().getVisualOrder(wrappedLines); } protected TextMetrics measureText(Font font, List lines) { var textWidth = 0; var textHeight = 0; var lineWidths = new DoubleArrayList(); for (var line : lines) { var lineWidth = font.width(line); lineWidths.add(lineWidth); textWidth = Math.max(textWidth, lineWidth); textHeight += font.lineHeight; } return new TextMetrics(textWidth, textHeight, lineWidths); } @Override protected void doLayout(Constraints constraints) { var font = this.host().client().font; this.renderText = this.wrapText(font, (int) constraints.maxWidth(), (int) constraints.maxHeight()); var metrics = this.measureText(font, this.renderText); this.renderTextWidths = metrics.lineWidths(); this.renderTextHeight = metrics.height(); var size = Size.of(metrics.width, metrics.height).constrained(constraints); this.transform.setSize(size); } @Override protected double measureIntrinsicWidth(double height) { var renderer = this.host().client().font; return this.measureText(renderer, this.wrapText(renderer, Integer.MAX_VALUE, (int) height)).width; } @Override protected double measureIntrinsicHeight(double width) { var renderer = this.host().client().font; return this.measureText(renderer, this.wrapText(renderer, this.widget.softWrap ? (int) width : Integer.MAX_VALUE, Integer.MAX_VALUE)).height; } @Override protected OptionalDouble measureBaselineOffset() { return OptionalDouble.of(this.host().client().font.lineHeight - 2); } @Override public void draw(BraidGraphics graphics) { var font = this.host().client().font; var yOffset = this.widget.style.textAlignment().alignVertical(this.transform.height(), this.renderTextHeight); for (int lineIdx = 0; lineIdx < this.renderText.size(); lineIdx++) { graphics.drawString( font, this.renderText.get(lineIdx), (int) this.widget.style.textAlignment().alignHorizontal(this.transform.width(), this.renderTextWidths.getDouble(lineIdx)), (int) yOffset + lineIdx * font.lineHeight, this.widget.style.baseColor().argb(), this.widget.style.shadow() ); } } // this reimplementation of RawLabel.draw is pretty cringe, however // mojang has left our hands tied since the text collector interface // does not give us control over text color and shadow public void collectText(ActiveTextCollector collector) { var font = this.host().client().font; var yOffset = this.widget.style.textAlignment().alignVertical(this.transform.height(), this.renderTextHeight); for (int lineIdx = 0; lineIdx < this.renderText.size(); lineIdx++) { collector.accept( (int) this.widget.style.textAlignment().alignHorizontal(this.transform.width(), this.renderTextWidths.getDouble(lineIdx)), (int) yOffset + lineIdx * font.lineHeight, this.renderText.get(lineIdx) ); } } @Override @Nullable public List getTooltipComponentsAt(double x, double y) { return null; } @Override @Nullable public Style getStyleAt(double x, double y) { if (this.renderText.isEmpty()) return null; var collector = new StyleCollector(this.host().client().font, (int) x, (int) y); this.collectText(collector); return collector.result(); } @Override public boolean onMouseDown(double x, double y, int button, KeyModifiers modifiers) { if (button != 0) return MouseListener.super.onMouseDown(x, y, button, modifiers); return this.textClickHandler.apply(this.getStyleAt(x, y)); } @Override public @Nullable CursorStyle cursorStyleAt(double x, double y) { var style = this.getStyleAt(x, y); if (style == null) return null; if (style.getClickEvent() != null) return CursorStyle.HAND; return null; } public static class StyleCollector extends ActiveTextCollector.ClickableStyleFinder { public StyleCollector(Font font, int clickX, int clickY) { super(font, clickX, clickY); ((ClickableStyleFinderAccessor) this).owo$setStyleScanner(((ClickableStyleFinderAccessor) this)::owo$setResult); } } } public record TextMetrics(int width, int height, DoubleList lineWidths) {} } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/object/BlockWidget.java ================================================ package io.wispforest.owo.braid.widgets.object; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.mixin.ui.access.BlockEntityAccessor; import net.minecraft.client.Minecraft; import net.minecraft.nbt.CompoundTag; import net.minecraft.util.ProblemReporter; import net.minecraft.world.level.block.EntityBlock; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.storage.TagValueInput; import org.jetbrains.annotations.Nullable; import org.joml.Matrix4f; import java.util.Objects; import java.util.function.Consumer; public class BlockWidget extends StatefulWidget { public final BlockState blockState; public final @Nullable BlockEntity blockEntity; public final @Nullable CompoundTag blockEntityNbt; public final @Nullable Consumer transform; private BlockWidget(BlockState blockState, @Nullable BlockEntity blockEntity, @Nullable CompoundTag blockEntityNbt, @Nullable Consumer transform) { this.blockState = blockState; this.blockEntity = blockEntity; this.blockEntityNbt = blockEntityNbt; this.transform = transform; } public BlockWidget(BlockState blockState, @Nullable BlockEntity blockEntity) { this(blockState, blockEntity, null, null); } public BlockWidget(BlockState blockState, @Nullable BlockEntity blockEntity, Consumer transform) { this(blockState, blockEntity, null, transform); } public BlockWidget(BlockState blockState, @Nullable CompoundTag blockEntityNbt) { this(blockState, null, blockEntityNbt, null); } public BlockWidget(BlockState blockState, @Nullable CompoundTag blockEntityNbt, Consumer transform) { this(blockState, null, blockEntityNbt, transform); } public BlockWidget(BlockState blockState, Consumer transform) { this(blockState, null, null, transform); } public BlockWidget(BlockState blockState) { this(blockState, null, null, null); } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private @Nullable BlockEntity internalBlockEntity; @Override public void init() { this.resetBlockEntity(); } @Override public void didUpdateWidget(BlockWidget oldWidget) { if (this.widget().blockState == oldWidget.blockState && this.widget().blockEntity == oldWidget.blockEntity && Objects.equals(this.widget().blockEntityNbt, oldWidget.blockEntityNbt)) { return; } this.resetBlockEntity(); } private void resetBlockEntity() { this.internalBlockEntity = this.widget().blockEntity == null ? prepareBlockEntity(this.widget().blockState, this.widget().blockEntityNbt) : null; } @Override public Widget build(BuildContext context) { return new RawBlockWidget( this.widget().blockState, this.internalBlockEntity != null ? this.internalBlockEntity : this.widget().blockEntity, this.widget().transform ); } // --- private static @Nullable BlockEntity prepareBlockEntity(BlockState state, @Nullable CompoundTag nbt) { var client = Minecraft.getInstance(); if (!state.hasBlockEntity()) { return null; } var blockEntity = ((EntityBlock) state.getBlock()).newBlockEntity(client.player.blockPosition(), state); if (blockEntity == null) { return null; } ((BlockEntityAccessor) blockEntity).owo$setBlockState(state); blockEntity.setLevel(client.level); if (nbt != null) { blockEntity.loadWithComponents(TagValueInput.create(new ProblemReporter.ScopedCollector(Owo.LOGGER), client.level.registryAccess(), nbt)); } return blockEntity; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/object/EntityWidget.java ================================================ package io.wispforest.owo.braid.widgets.object; import com.mojang.math.Axis; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.element.BraidEntityElement; import io.wispforest.owo.braid.framework.instance.LeafWidgetInstance; import io.wispforest.owo.braid.framework.widget.LeafInstanceWidget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.LivingEntity; import org.jetbrains.annotations.Nullable; import org.joml.Matrix3x2f; import org.joml.Matrix4f; import org.joml.Vector4f; import java.util.OptionalDouble; import java.util.function.Consumer; public class EntityWidget extends LeafInstanceWidget { public final double scale; public final Entity entity; protected DisplayMode displayMode = DisplayMode.FIXED; protected boolean scaleToFit = true; protected boolean showNametag = false; protected @Nullable Consumer transform = null; public EntityWidget(double scale, Entity entity, @Nullable WidgetSetupCallback setupCallback) { this.scale = scale; this.entity = entity; if (setupCallback != null) setupCallback.setup(this); } public EntityWidget displayMode(DisplayMode displayMode) { this.displayMode = displayMode; return this; } public DisplayMode displayMode() { return this.displayMode; } public EntityWidget scaleToFit(boolean scaleToFit) { this.scaleToFit = scaleToFit; return this; } public boolean scaleToFit() { return this.scaleToFit; } public EntityWidget showNametag(boolean showNametag) { this.showNametag = showNametag; return this; } public boolean showNametag() { return this.showNametag; } public EntityWidget transform(Consumer transform) { this.transform = transform; return this; } public @Nullable Consumer transform() { return this.transform; } @Override public LeafWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends LeafWidgetInstance { protected double baseScale = 1.0; public Instance(EntityWidget widget) { super(widget); } @Override public void setWidget(EntityWidget widget) { if (this.widget.scaleToFit != widget.scaleToFit) { this.markNeedsLayout(); } super.setWidget(widget); } @Override protected void doLayout(Constraints constraints) { this.transform.setSize(constraints.minSize()); if (this.widget.scaleToFit) { this.baseScale = Math.min( this.transform.width() / this.widget.entity.getBbWidth(), this.transform.height() / this.widget.entity.getBbHeight() ) * .6; } } @Override protected double measureIntrinsicWidth(double height) { return 32; } @Override protected double measureIntrinsicHeight(double width) { return 32; } @Override protected OptionalDouble measureBaselineOffset() { return OptionalDouble.empty(); } @Override public void draw(BraidGraphics graphics) { var entity = this.widget.entity; var entitySpaceToWidgetSpace = new Matrix4f(); entitySpaceToWidgetSpace.translate(0, (float) (this.transform.height() / 2), 100); entitySpaceToWidgetSpace.scale((float) (this.widget.scale * this.baseScale)); entitySpaceToWidgetSpace.scale(1, -1, -1); var entityTransform = new Matrix4f(); if (this.widget.transform != null) { this.widget.transform.accept(entityTransform); } entityTransform.translate(0, -entity.getBbHeight() / 2, 0); var xRotation = 0f; var yRotation = 0f; var lastHeadYaw = entity instanceof LivingEntity living ? living.yHeadRotO : 0; var lastYaw = entity.yRotO; var lastPitch = entity.xRotO; if (this.widget.displayMode == DisplayMode.FIXED) { xRotation = 35; yRotation = -45; } else if (this.widget.displayMode != DisplayMode.NONE) { var globalCursorPos = this.host().cursorPosition(); var cursor4x4Buffer = graphics.pose().get4x4(new float[16]); var cursorTransform = new Matrix4f() .set(cursor4x4Buffer) // we do this ugly cursor-specific offset here to account for the // centering being indiscriminately applied inside the PIP renderer .translate((float) (this.transform.width() / 2), 0, 0) .mul(entitySpaceToWidgetSpace) .mul(entityTransform) .invert(); var localCursorPos = cursorTransform.transform(new Vector4f((float) globalCursorPos.x(), (float) globalCursorPos.y(), 0, 1)); switch (widget.displayMode) { case CURSOR -> { var center = new Vector4f(0, entity.getEyeHeight(entity.getPose()), 0, 1); xRotation = (float) Math.toDegrees(Math.atan(localCursorPos.y - center.y)) * -.15f; yRotation = (float) Math.toDegrees(Math.atan(localCursorPos.x - center.x)) * .15f; if (entity instanceof LivingEntity living) living.yHeadRotO = -yRotation * 3; entity.yRotO = -yRotation * .65f; entity.xRotO = xRotation * 2.5f; } case VANILLA -> { var center = new Vector4f(0, entity.getBbHeight() / 2, 0, 1); xRotation = (float) Math.atan(localCursorPos.y - center.y) * -20f; yRotation = (float) Math.atan(localCursorPos.x - center.x) * 20f; if (entity instanceof LivingEntity living) living.yHeadRotO = -yRotation; entity.yRotO = -yRotation; entity.xRotO = xRotation; } } } // We make sure the yRotation never becomes 0, as the lighting otherwise becomes very unhappy if (yRotation == 0) yRotation = .1f; entityTransform.rotate(Axis.XP.rotationDegrees(xRotation)); entityTransform.rotate(Axis.YP.rotationDegrees(yRotation)); var entityState = this.host().client().getEntityRenderDispatcher().extractEntity(this.widget.entity, 0); if (!this.widget.showNametag) { entityState.nameTag = null; } graphics.guiRenderState.submitPicturesInPictureState(new BraidEntityElement( entityState, new Matrix4f().mul(entitySpaceToWidgetSpace).mul(entityTransform), new Matrix3x2f(graphics.pose()), this.transform.width(), this.transform.height(), graphics.scissorStack.peek() )); if (entity instanceof LivingEntity living) living.yHeadRotO = lastHeadYaw; entity.xRotO = lastPitch; entity.yRotO = lastYaw; } } public enum DisplayMode { FIXED, VANILLA, CURSOR, NONE } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/object/ItemStackWidget.java ================================================ package io.wispforest.owo.braid.widgets.object; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.core.element.BraidItemElement; import io.wispforest.owo.braid.framework.instance.LeafWidgetInstance; import io.wispforest.owo.braid.framework.widget.LeafInstanceWidget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import net.minecraft.client.renderer.item.ItemStackRenderState; import net.minecraft.world.item.ItemDisplayContext; import net.minecraft.world.item.ItemStack; import org.jetbrains.annotations.Nullable; import org.joml.Matrix3x2f; import org.joml.Matrix4f; import java.util.OptionalDouble; import java.util.function.Consumer; /// A widget that renders an [ItemStack] /// /// The stack is rendered using the specified [ItemDisplayContext] /// and can show overlay information (item bar, count, cooldown progress, etc.) public class ItemStackWidget extends LeafInstanceWidget { public final ItemStack stack; protected boolean showOverlay = true; protected ItemDisplayContext displayContext = ItemDisplayContext.GUI; protected @Nullable LightOverride lightOverride = null; protected @Nullable Consumer transform; public ItemStackWidget(ItemStack stack, @Nullable WidgetSetupCallback setupCallback) { this.stack = stack; if (setupCallback != null) setupCallback.setup(this); } public ItemStackWidget(ItemStack stack) { this(stack, null); } public ItemStackWidget showOverlay(boolean showOverlay) { this.assertMutable(); this.showOverlay = showOverlay; return this; } public boolean showOverlay() { return this.showOverlay; } public ItemStackWidget displayContext(ItemDisplayContext displayContext) { this.assertMutable(); this.displayContext = displayContext; this.showOverlay = false; return this; } public ItemDisplayContext displayContext() { return this.displayContext; } public ItemStackWidget lightOverride(@Nullable LightOverride lightOverride) { this.assertMutable(); this.lightOverride = lightOverride; return this; } public @Nullable LightOverride lightOverride() { return this.lightOverride; } public ItemStackWidget transform(@Nullable Consumer transform) { this.transform = transform; return this; } public @Nullable Consumer transform() { return this.transform; } @Override public LeafWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends LeafWidgetInstance { public static final Size DEFAULT_SIZE = Size.square(16); public Instance(ItemStackWidget widget) { super(widget); } @Override protected void doLayout(Constraints constraints) { var size = DEFAULT_SIZE.constrained(constraints); this.transform.setSize(size); } @Override protected double measureIntrinsicWidth(double height) { return 16; } @Override protected double measureIntrinsicHeight(double width) { return 16; } @Override protected OptionalDouble measureBaselineOffset() { return OptionalDouble.empty(); } @Override public void draw(BraidGraphics graphics) { if (this.transform.width() <= 16 && this.transform.height() <= 16 && this.widget.displayContext == ItemDisplayContext.GUI && this.widget.transform == null) { // scale according to widget size, since items assume a 16x16 window graphics.push().scale((float) (this.transform.width() / 16f), (float) (this.transform.height() / 16f)); graphics.renderItem(this.widget.stack, 0, 0); graphics.pop(); } else { var state = new ItemStackRenderState(); this.host().client().getItemModelResolver().appendItemLayers(state, this.widget.stack, this.widget.displayContext, this.host().client().level, this.host().client().player, 0); var transformThisFrame = new Matrix4f(); if (this.widget.transform != null) { this.widget.transform.accept(transformThisFrame); } graphics.guiRenderState.submitPicturesInPictureState(new BraidItemElement( state, this.transform.width(), this.transform.height(), graphics.scissorStack.peek(), transformThisFrame, new Matrix3x2f(graphics.pose()) )); } if (this.widget.showOverlay) { var popTransform = false; if (this.transform.width() != 16 || this.transform.height() != 16) { popTransform = true; graphics.push(); graphics.scale((float) (this.transform.width() / 16), (float) (this.transform.height() / 16)); } graphics.renderItemDecorations(this.host().client().font, this.widget.stack, 0, 0); if (popTransform) { graphics.pop(); } } } } public enum LightOverride { FRONT, SIDE } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/object/RawBlockWidget.java ================================================ package io.wispforest.owo.braid.widgets.object; import com.mojang.math.Axis; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.core.element.BraidBlockElement; import io.wispforest.owo.braid.framework.instance.LeafWidgetInstance; import io.wispforest.owo.braid.framework.widget.LeafInstanceWidget; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.phys.Vec3; import org.jetbrains.annotations.Nullable; import org.joml.Matrix3x2f; import org.joml.Matrix4f; import java.util.OptionalDouble; import java.util.function.Consumer; /// A widget that renders a [BlockState] and optionally a [BlockEntity] public class RawBlockWidget extends LeafInstanceWidget { public final BlockState blockState; public final @Nullable BlockEntity blockEntity; public final @Nullable Consumer transform; public RawBlockWidget(BlockState blockState, @Nullable BlockEntity blockEntity, @Nullable Consumer transform) { this.blockState = blockState; this.blockEntity = blockEntity; this.transform = transform; } @Override public LeafWidgetInstance instantiate() { return new Instance(this); } // --- public static class Instance extends LeafWidgetInstance { public static final Size DEFAULT_SIZE = Size.square(16); public Instance(RawBlockWidget widget) { super(widget); } @Override protected void doLayout(Constraints constraints) { var size = DEFAULT_SIZE.constrained(constraints); this.transform.setSize(size); } @Override protected double measureIntrinsicWidth(double height) { return 16; } @Override protected double measureIntrinsicHeight(double width) { return 16; } @Override protected OptionalDouble measureBaselineOffset() { return OptionalDouble.empty(); } @Override public void draw(BraidGraphics graphics) { var drawTransform = new Matrix4f(); drawTransform.scale(40 * (float) (this.transform.width() / 64f), -40 * (float) (this.transform.height() / 64f), -40); if (this.widget.transform != null) { this.widget.transform.accept(drawTransform); } else { drawTransform.rotate(Axis.XP.rotationDegrees(30)); drawTransform.rotate(Axis.YP.rotationDegrees(45 + 180)); } drawTransform.translate(-.5f, -.5f, -.5f); BlockEntityRenderState entity = null; if (this.widget.blockEntity != null) { var renderer = Minecraft.getInstance().getBlockEntityRenderDispatcher().getRenderer(this.widget.blockEntity); if (renderer != null) { entity = renderer.createRenderState(); renderer.extractRenderState( this.widget.blockEntity, entity, 0, Vec3.ZERO, null ); } } graphics.guiRenderState.submitPicturesInPictureState(new BraidBlockElement( this.widget.blockState, entity, drawTransform, new Matrix3x2f(graphics.pose()), this.transform.width(), this.transform.height(), graphics.scissorStack.peek() )); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/overlay/Overlay.java ================================================ package io.wispforest.owo.braid.widgets.overlay; import com.google.common.base.Preconditions; import com.google.common.collect.Iterables; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.Key; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.EmptyWidget; import io.wispforest.owo.braid.widgets.basic.HitTestTrap; import io.wispforest.owo.braid.widgets.basic.MouseArea; import io.wispforest.owo.braid.widgets.stack.Stack; import io.wispforest.owo.braid.widgets.stack.StackBase; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; public class Overlay extends StatefulWidget { public final Widget child; public Overlay(Widget child) { this.child = child; } @Override public WidgetState createState() { return new State(); } // --- public static @Nullable State maybeOf(BuildContext context) { var provider = context.getAncestor(OverlayProvider.class); return provider != null ? provider.state : null; } public static State of(BuildContext context) { var state = maybeOf(context); Preconditions.checkNotNull(state, "attempted to look up the enclosing overlay state without one present"); return state; } // --- public static class State extends WidgetState { public OverlayEntry add(OverlayEntryBuilder builder) { var entryPosition = builder.position.convertTo(this.context()); var entry = new OverlayEntry( this, builder.onRemove, builder.widget, builder.dismissOverlayOnClick, builder.occludeHitTest, entryPosition.x, entryPosition.y ); this.setState(() -> { this.entries.add(entry); }); return entry; } // --- final List entries = new ArrayList<>(); @SuppressWarnings("DataFlowIssue") @Override public Widget build(BuildContext context) { return new OverlayProvider( this, new Stack( this.widget().child, new HitTestTrap( Iterables.any(this.entries, entry -> entry.occludeHitTest), new MouseArea( widget -> widget .clickCallback((x, y, button, modifiers) -> { if (!Iterables.any(this.entries, entry -> entry.dismissOnOverlayClick)) return false; for (var entry : Iterables.filter(this.entries, entry -> entry.dismissOnOverlayClick)) { if (entry.onRemove != null) entry.onRemove.run(); } this.setState(() -> { this.entries.removeIf(entry -> entry.dismissOnOverlayClick); }); return false; }), EmptyWidget.INSTANCE ) ), new StackBase( new RawOverlay( this.entries.stream() .map(entry -> (RawOverlayElement) new RawOverlayElement(entry.x, entry.y, entry.widget).key(Key.of(entry.uuid.toString()))) .toList() ) ) ) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/overlay/OverlayEntry.java ================================================ package io.wispforest.owo.braid.widgets.overlay; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.util.UUID; public class OverlayEntry { private final Overlay.State owner; final @Nullable Runnable onRemove; final UUID uuid = UUID.randomUUID(); public Widget widget; public boolean dismissOnOverlayClick; public boolean occludeHitTest; public double x; public double y; OverlayEntry(Overlay.State owner, @Nullable Runnable onRemove, Widget widget, boolean dismissOnOverlayClick, boolean occludeHitTest, double x, double y) { this.owner = owner; this.onRemove = onRemove; this.widget = widget; this.dismissOnOverlayClick = dismissOnOverlayClick; this.occludeHitTest = occludeHitTest; this.x = x; this.y = y; } // --- public void setState(Runnable fn) { this.owner.setState(fn); } public void remove() { this.owner.setState(() -> { if (this.onRemove != null) this.onRemove.run(); this.owner.entries.remove(this); }); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/overlay/OverlayEntryBuilder.java ================================================ package io.wispforest.owo.braid.widgets.overlay; import io.wispforest.owo.braid.core.RelativePosition; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public class OverlayEntryBuilder { final Widget widget; final RelativePosition position; @Nullable Runnable onRemove = null; boolean dismissOverlayOnClick = false; boolean occludeHitTest = false; public OverlayEntryBuilder(Widget widget, RelativePosition position) { this.widget = widget; this.position = position; } public OverlayEntryBuilder onRemove(Runnable onRemove) { this.onRemove = onRemove; return this; } public OverlayEntryBuilder dismissOverlayOnClick() { this.dismissOverlayOnClick = true; return this; } public OverlayEntryBuilder occludeHitTest() { this.occludeHitTest = true; return this; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/overlay/OverlayParentData.java ================================================ package io.wispforest.owo.braid.widgets.overlay; public class OverlayParentData { public double x, y; public OverlayParentData(double x, double y) { this.x = x; this.y = y; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/overlay/OverlayProvider.java ================================================ package io.wispforest.owo.braid.widgets.overlay; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; class OverlayProvider extends InheritedWidget { public final Overlay.State state; protected OverlayProvider(Overlay.State state, Widget child) { super(child); this.state = state; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return false; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/overlay/RawOverlay.java ================================================ package io.wispforest.owo.braid.widgets.overlay; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.instance.MultiChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.MultiChildInstanceWidget; import java.util.Arrays; import java.util.List; import java.util.OptionalDouble; public class RawOverlay extends MultiChildInstanceWidget { public RawOverlay(List children) { super(children); } public RawOverlay(RawOverlayElement... children) { this(Arrays.asList(children)); } @Override public MultiChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends MultiChildWidgetInstance { public Instance(RawOverlay widget) { super(widget); } @Override protected void doLayout(Constraints constraints) { for (var child : this.children) { child.layout(Constraints.unconstrained()); var parentData = (OverlayParentData) child.parentData; child.transform.setX(parentData.x); child.transform.setY(parentData.y); } this.transform.setSize(constraints.maxFiniteOrMinSize()); } @Override protected double measureIntrinsicWidth(double height) { return 0; } @Override protected double measureIntrinsicHeight(double width) { return 0; } @Override protected OptionalDouble measureBaselineOffset() { return OptionalDouble.empty(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/overlay/RawOverlayElement.java ================================================ package io.wispforest.owo.braid.widgets.overlay; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.VisitorWidget; public class RawOverlayElement extends VisitorWidget { public final double x, y; public RawOverlayElement(double x, double y, Widget child) { super(child); this.x = x; this.y = y; } public static final Visitor VISITOR = (widget, instance) -> { if (instance.parentData instanceof OverlayParentData data) { data.x = widget.x; data.y = widget.y; } else { instance.parentData = new OverlayParentData(widget.x, widget.y); } instance.markNeedsLayout(); }; @Override public Proxy proxy() { return new Proxy<>(this, VISITOR); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/owoui/OwoUIWidget.java ================================================ package io.wispforest.owo.braid.widgets.owoui; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Align; import io.wispforest.owo.braid.widgets.basic.Builder; import io.wispforest.owo.braid.widgets.focus.Focusable; import io.wispforest.owo.ui.core.ParentUIComponent; import java.util.function.Supplier; public class OwoUIWidget extends StatefulWidget { private final Supplier componentSupplier; public OwoUIWidget(Supplier componentSupplier) { this.componentSupplier = componentSupplier; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private ParentUIComponent component; private BuildContext owoUiContext; @Override public void init() { component = this.widget().componentSupplier.get(); } @Override public Widget build(BuildContext context) { return new Align( Alignment.TOP_LEFT, new Focusable( widget -> widget .focusLostCallback(() -> ((OwoUIWidgetWrapper.Instance) this.owoUiContext.instance()).onFocusLost()) .keyDownCallback((keyCode, modifiers) -> ((OwoUIWidgetWrapper.Instance) this.owoUiContext.instance()).onKeyDown(keyCode, modifiers)) .charCallback((charCode, modifiers) -> ((OwoUIWidgetWrapper.Instance) this.owoUiContext.instance()).onChar(charCode, modifiers)), new Builder(owoUiContext -> { this.owoUiContext = owoUiContext; return new OwoUIWidgetWrapper(component); }) ) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/owoui/OwoUIWidgetWrapper.java ================================================ package io.wispforest.owo.braid.widgets.owoui; import com.mojang.blaze3d.opengl.GlStateManager; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.KeyModifiers; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.instance.LeafWidgetInstance; import io.wispforest.owo.braid.framework.instance.MouseListener; import io.wispforest.owo.braid.framework.widget.LeafInstanceWidget; import io.wispforest.owo.ui.core.ParentUIComponent; import io.wispforest.owo.ui.core.UIComponent; import net.minecraft.client.input.CharacterEvent; import net.minecraft.client.input.KeyEvent; import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.client.input.MouseButtonInfo; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; import java.util.OptionalDouble; public class OwoUIWidgetWrapper extends LeafInstanceWidget { private final ParentUIComponent rootComponent; public OwoUIWidgetWrapper(ParentUIComponent rootComponent) { this.rootComponent = rootComponent; } @Override public LeafWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends LeafWidgetInstance implements MouseListener { private int mouseX = -100; private int mouseY = -100; private int dragButton = -1; public Instance(OwoUIWidgetWrapper widget) { super(widget); } @Override protected void doLayout(Constraints constraints) { Size space = constraints.maxFiniteOrMinSize(); widget.rootComponent.inflate(io.wispforest.owo.ui.core.Size.of((int) space.width(), (int) space.height())); widget.rootComponent.mount(null, 0, 0); this.transform.setSize(Size.of(widget.rootComponent.width(), widget.rootComponent.height()) .constrained(constraints)); } @Override protected double measureIntrinsicWidth(double height) { // the focus handler is created on mount, therefore it's null only if the // component wasn't mounted. if (widget.rootComponent.focusHandler() != null) { throw new IllegalStateException("Tried to measure intrinsic width of mounted owo-ui component"); } widget.rootComponent.inflate(io.wispforest.owo.ui.core.Size.of(Integer.MAX_VALUE, (int) height)); return widget.rootComponent.width(); } @Override protected double measureIntrinsicHeight(double width) { // the focus handler is created on mount, therefore it's null only if the // component wasn't mounted. if (widget.rootComponent.focusHandler() != null) { throw new IllegalStateException("Tried to measure intrinsic height of mounted owo-ui component"); } widget.rootComponent.inflate(io.wispforest.owo.ui.core.Size.of((int) width, Integer.MAX_VALUE)); return widget.rootComponent.height(); } @Override protected OptionalDouble measureBaselineOffset() { return OptionalDouble.empty(); } @Override public void onMouseMove(double toX, double toY) { this.mouseX = (int) toX; this.mouseY = (int) toY; } @Override public void onMouseExit() { this.mouseX = -100; this.mouseY = -100; } public void onFocusLost() { this.widget.rootComponent.focusHandler().focus(null, UIComponent.FocusSource.MOUSE_CLICK); } @Override public @Nullable CursorStyle cursorStyleAt(double x, double y) { var hovered = this.widget.rootComponent.childAt((int) x, (int) y); if (hovered == null) return null; return switch (hovered.cursorStyle()) { case NONE -> CursorStyle.NONE; case POINTER -> CursorStyle.POINTER; case TEXT -> CursorStyle.TEXT; case HAND -> CursorStyle.HAND; case CROSSHAIR -> CursorStyle.CROSSHAIR; case MOVE -> CursorStyle.MOVE; case HORIZONTAL_RESIZE -> CursorStyle.HORIZONTAL_RESIZE; case VERTICAL_RESIZE -> CursorStyle.VERTICAL_RESIZE; case NWSE_RESIZE -> CursorStyle.NWSE_RESIZE; case NESW_RESIZE -> CursorStyle.NESW_RESIZE; case NOT_ALLOWED -> CursorStyle.NOT_ALLOWED; }; } @Override public boolean onMouseDown(double x, double y, int button, KeyModifiers modifiers) { return this.widget.rootComponent.onMouseDown(new MouseButtonEvent(x, y, new MouseButtonInfo(button, modifiers.bitMask())), false); } @Override public boolean onMouseUp(double x, double y, int button, KeyModifiers modifiers) { return this.widget.rootComponent.onMouseUp(new MouseButtonEvent(x, y, new MouseButtonInfo(button, modifiers.bitMask()))); } @Override public boolean onMouseScroll(double x, double y, double horizontal, double vertical) { return this.widget.rootComponent.onMouseScroll(x, y, vertical); } @Override public void onMouseDragStart(int button, KeyModifiers modifiers) { this.dragButton = button; } @Override public void onMouseDrag(double x, double y, double dx, double dy) { this.widget.rootComponent.onMouseDrag(new MouseButtonEvent(x, y, new MouseButtonInfo(this.dragButton, 0)), dx, dy); } @Override public void onMouseDragEnd() { this.dragButton = -1; } public boolean onKeyDown(int keyCode, KeyModifiers modifiers) { return this.widget.rootComponent.onKeyPress(new KeyEvent(keyCode, GLFW.glfwGetKeyScancode(keyCode), modifiers.bitMask())); } public boolean onChar(int charCode, KeyModifiers modifiers) { return this.widget.rootComponent.onCharTyped(new CharacterEvent(charCode, modifiers.bitMask())); } @Override public void draw(BraidGraphics graphics) { var client = host().client(); this.widget.rootComponent.update( client.getDeltaTracker().getGameTimeDeltaTicks(), mouseX, mouseY ); this.widget.rootComponent.draw( graphics, mouseX, mouseY, client.getDeltaTracker().getGameTimeDeltaPartialTick(false), client.getDeltaTracker().getGameTimeDeltaTicks() ); // TODO: tooltips. // this mitigates the vanilla scissor stack disabling the scissor stack if it's empty GlStateManager._enableScissorTest(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/recipeviewer/RecipeViewerExclusionZone.java ================================================ package io.wispforest.owo.braid.widgets.recipeviewer; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; public class RecipeViewerExclusionZone extends SingleChildInstanceWidget { public RecipeViewerExclusionZone(Widget child) { super(child); } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance.ShrinkWrap { public Instance(RecipeViewerExclusionZone widget) { super(widget); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/recipeviewer/RecipeViewerStack.java ================================================ package io.wispforest.owo.braid.widgets.recipeviewer; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.util.ViewerStack; import java.util.function.Supplier; public class RecipeViewerStack extends SingleChildInstanceWidget { public final Supplier stackProvider; public RecipeViewerStack(Supplier stackProvider, Widget child) { super(child); this.stackProvider = stackProvider; } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance.ShrinkWrap { public Instance(RecipeViewerStack widget) { super(widget); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/recipeviewer/StackDropArea.java ================================================ package io.wispforest.owo.braid.widgets.recipeviewer; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.util.ViewerStack; import java.util.function.Consumer; import java.util.function.Predicate; public class StackDropArea extends SingleChildInstanceWidget { public final Predicate stackPredicate; public final Consumer stackAcceptor; public StackDropArea(Predicate stackPredicate, Consumer stackAcceptor, Widget child) { super(child); this.stackPredicate = stackPredicate; this.stackAcceptor = stackAcceptor; } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance.ShrinkWrap { public Instance(StackDropArea widget) { super(widget); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/scroll/ButtonScrollbar.java ================================================ package io.wispforest.owo.braid.widgets.scroll; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.widgets.basic.Panel; import io.wispforest.owo.ui.component.ButtonComponent; public class ButtonScrollbar extends Scrollbar { public ButtonScrollbar(LayoutAxis axis, ScrollController controller) { super( axis, controller, new Panel(ButtonComponent.DISABLED_TEXTURE), new Panel(ButtonComponent.ACTIVE_TEXTURE) ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/scroll/DefaultScrollAnimationSettings.java ================================================ package io.wispforest.owo.braid.widgets.scroll; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public class DefaultScrollAnimationSettings extends InheritedWidget { public final ScrollAnimationSettings settings; public DefaultScrollAnimationSettings(ScrollAnimationSettings settings, Widget child) { super(child); this.settings = settings; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return this.settings != ((DefaultScrollAnimationSettings) newWidget).settings; } // --- public static @Nullable ScrollAnimationSettings maybeOf(BuildContext context) { var widget = context.dependOnAncestor(DefaultScrollAnimationSettings.class); if (widget != null) { return widget.settings; } else { return null; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/scroll/FlatScrollbar.java ================================================ package io.wispforest.owo.braid.widgets.scroll; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.widgets.basic.Box; import io.wispforest.owo.braid.widgets.basic.HoverableBuilder; import io.wispforest.owo.braid.widgets.basic.Padding; public class FlatScrollbar extends Scrollbar { public FlatScrollbar(LayoutAxis axis, ScrollController controller, Color color, Color hoveredColor) { super( axis, controller, new Padding(Insets.none()), new HoverableBuilder( new Box(color), new Box(hoveredColor) ) ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/scroll/HorizontallyScrollable.java ================================================ package io.wispforest.owo.braid.widgets.scroll; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public class HorizontallyScrollable extends Scrollable { public HorizontallyScrollable(@Nullable ScrollController controller, @Nullable ScrollAnimationSettings animationSettings, Widget child) { super(true, false, controller, null, animationSettings, child); } public HorizontallyScrollable(Widget child) { this(null, null, child); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/scroll/RawScrollView.java ================================================ package io.wispforest.owo.braid.widgets.scroll; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.framework.instance.SingleChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.SingleChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.util.OptionalDouble; public class RawScrollView extends SingleChildInstanceWidget { public final ScrollController horizontalController; public final ScrollController verticalController; public RawScrollView( @Nullable ScrollController horizontalController, @Nullable ScrollController verticalController, Widget child ) { super(child); this.horizontalController = horizontalController; this.verticalController = verticalController; } @Override public SingleChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends SingleChildWidgetInstance { protected double horizontalOffset, maxHorizontalOffset; protected double verticalOffset, maxVerticalOffset; public Instance(RawScrollView widget) { super(widget); this.horizontalOffset = this.widget.horizontalController != null ? this.widget.horizontalController.offset() : 0; this.maxHorizontalOffset = this.widget.horizontalController != null ? this.widget.horizontalController.maxOffset() : 0; this.verticalOffset = this.widget.verticalController != null ? this.widget.verticalController.offset() : 0; this.maxVerticalOffset = this.widget.verticalController != null ? this.widget.verticalController.maxOffset() : 0; } @Override public void setWidget(RawScrollView widget) { var horizontalOffset = this.widget.horizontalController != null ? this.widget.horizontalController.offset() : 0; var maxHorizontalOffset = this.widget.horizontalController != null ? this.widget.horizontalController.maxOffset() : 0; var verticalOffset = this.widget.verticalController != null ? this.widget.verticalController.offset() : 0; var maxVerticalOffset = this.widget.verticalController != null ? this.widget.verticalController.maxOffset() : 0; if (!(this.horizontalOffset == horizontalOffset && this.maxHorizontalOffset == maxHorizontalOffset && this.verticalOffset == verticalOffset && this.maxVerticalOffset == maxVerticalOffset)) { this.horizontalOffset = horizontalOffset; this.maxHorizontalOffset = maxHorizontalOffset; this.verticalOffset = verticalOffset; this.maxVerticalOffset = maxVerticalOffset; this.markNeedsLayout(); } super.setWidget(widget); } @Override protected void doLayout(Constraints constraints) { var childSize = this.child.layout( Constraints.of( constraints.minWidth(), constraints.minHeight(), this.widget.horizontalController != null ? Double.POSITIVE_INFINITY : constraints.maxWidth(), this.widget.verticalController != null ? Double.POSITIVE_INFINITY : constraints.maxHeight() ) ); var selfSize = childSize.constrained(constraints); this.updateMaxOffset(this.widget.horizontalController, Math.max(0, childSize.width() - selfSize.width())); this.updateMaxOffset(this.widget.verticalController, Math.max(0, childSize.height() - selfSize.height())); this.child.transform.setX(-this.horizontalOffset); this.child.transform.setY(-this.verticalOffset); this.transform.setSize(selfSize); } /// Delay the actual invocation of scroll controller listeners until /// after the current layout cycle. /// /// This is important, because for one nobody could react to it anyways /// (since we are in the layout phase, the build phase for this frame /// is over) but *also* it actually breaks instances which descend from /// a layout builder. This happens because such a descendant would now /// mark itself dirty during the layout phase, but before the layout builder /// instance is marked clean. Thus, the `markNeedsLayout()` invocation on /// that layout builder instance gets swallowed and the widget is now stuck /// in improperly-rebuilt limbo until the layout builder happens to re-layout /// for other reasons. That is especially problematic because there is /// potential for this effect to mask legitimate rebuilds said descendant /// requires - it won't mark itself as needing a rebuild again because it /// is still dutifully waiting for such a rebuild to occur. private void updateMaxOffset(@Nullable ScrollController controller, double offset) { if (controller == null) return; if (controller.setMaxOffset(offset) && !controller.maxOffsetNotificationScheduled) { controller.maxOffsetNotificationScheduled = true; this.host().schedulePostLayoutCallback(controller::sendMaxOffsetNotification); } } @Override protected double measureIntrinsicWidth(double height) { return this.widget.horizontalController == null ? this.child.getIntrinsicWidth(height) : 0; } @Override protected double measureIntrinsicHeight(double width) { return this.widget.verticalController == null ? this.child.getIntrinsicHeight(width) : 0; } @Override protected OptionalDouble measureBaselineOffset() { var childBaseline = this.child != null ? this.child.getBaselineOffset() : OptionalDouble.empty(); if (childBaseline.isEmpty()) return OptionalDouble.empty(); return OptionalDouble.of(childBaseline.getAsDouble() + this.child.transform.y()); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/scroll/ScrollAnimationSettings.java ================================================ package io.wispforest.owo.braid.widgets.scroll; import io.wispforest.owo.braid.animation.Easing; import java.time.Duration; public record ScrollAnimationSettings(Duration duration, Easing easing) { public static final ScrollAnimationSettings DEFAULT = new ScrollAnimationSettings(Duration.ofMillis(250), Easing.OUT_QUART); public static final ScrollAnimationSettings NO_ANIMATION = new ScrollAnimationSettings(null, null); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/scroll/ScrollController.java ================================================ package io.wispforest.owo.braid.widgets.scroll; import io.wispforest.owo.braid.animation.Animation; import io.wispforest.owo.braid.animation.DoubleLerp; import io.wispforest.owo.braid.animation.Easing; import io.wispforest.owo.braid.core.Listenable; import io.wispforest.owo.braid.framework.proxy.WidgetState; import net.minecraft.util.Mth; import java.time.Duration; public class ScrollController extends Listenable { private final Animation animation; private DoubleLerp lerp; public ScrollController(WidgetState contextState) { this(contextState::scheduleAnimationCallback); } public ScrollController(Animation.Scheduler callbackScheduler) { this.lerp = new DoubleLerp(0.0, 0.0); this.animation = new Animation( Easing.LINEAR, Duration.ofNanos(1), callbackScheduler, progress -> this.setOffset(this.lerp.compute(progress)), Animation.Target.END ); } protected double offset = 0; protected double maxOffset = 0; public void animateTo(double offset, Duration duration, Easing easing) { this.animation.duration = duration; this.animation.easing = easing; this.lerp = new DoubleLerp(this.offset, this.clampOffset(offset)); this.animation.towards(Animation.Target.END); } public void animateBy(double by, Duration duration, Easing easing) { this.animateTo(this.lerp.end + by, duration, easing); } public void jumpTo(double offset) { offset = this.clampOffset(offset); this.animation.stop(); this.lerp = new DoubleLerp(offset, offset); this.setOffset(offset); } public void jumpBy(double by) { this.jumpTo(this.offset + by); } private void setOffset(double offset) { if (this.offset == offset) { return; } this.offset = this.clampOffset(offset); this.notifyListeners(); } private double clampOffset(double offset) { return Mth.clamp(offset, 0, this.maxOffset); } public double offset() { return this.offset; } boolean setMaxOffset(double maxOffset) { if (this.maxOffset == maxOffset) { return false; } this.maxOffset = maxOffset; this.offset = this.clampOffset(this.offset); return true; } boolean maxOffsetNotificationScheduled = false; void sendMaxOffsetNotification() { this.notifyListeners(); this.maxOffsetNotificationScheduled = false; } public double maxOffset() { return this.maxOffset; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/scroll/Scrollable.java ================================================ package io.wispforest.owo.braid.widgets.scroll; import com.google.common.base.Preconditions; import io.wispforest.owo.braid.core.Aabb2d; import io.wispforest.owo.braid.core.AppState; import io.wispforest.owo.braid.core.CompoundListenable; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Clip; import io.wispforest.owo.braid.widgets.basic.ListenableBuilder; import io.wispforest.owo.braid.widgets.basic.MouseArea; import org.jetbrains.annotations.Nullable; import org.joml.Matrix3x2f; import java.util.Objects; public class Scrollable extends StatefulWidget { public final boolean horizontal; public final boolean vertical; public final @Nullable ScrollController horizontalController; public final @Nullable ScrollController verticalController; public final @Nullable ScrollAnimationSettings animationSettings; public final Widget child; public Scrollable( boolean horizontal, boolean vertical, @Nullable ScrollController horizontalController, @Nullable ScrollController verticalController, @Nullable ScrollAnimationSettings animationSettings, Widget child ) { this.horizontal = horizontal; this.vertical = vertical; this.horizontalController = horizontalController; this.verticalController = verticalController; this.animationSettings = animationSettings; this.child = child; } @Override public WidgetState createState() { return new State(); } // --- public static void reveal(BuildContext context) { reveal(context, Insets.none()); } public static void reveal(BuildContext context, Insets padding) { of(context).reveal(context, padding); } public static void revealAabb(BuildContext context, Aabb2d box) { of(context).revealAabb(context, box); } public static @Nullable State maybeOf(BuildContext context) { var provider = context.getAncestor(ScrollableProvider.class); return provider != null ? provider.state : null; } public static State of(BuildContext context) { var state = maybeOf(context); Preconditions.checkNotNull(state, "attempted to look up the enclosing scrollable state without one being present"); return state; } // --- public static class State extends WidgetState { protected final CompoundListenable listenable = new CompoundListenable(); protected ScrollController horizontalController; protected ScrollController verticalController; private void reveal(BuildContext context, Insets padding) { var transform = context.instance().transform; var matrix = new Matrix3x2f(); transform.transformToWidget(matrix); var box = new Aabb2d( transform.x() - padding.left(), transform.y() - padding.top(), transform.width() + padding.horizontal(), transform.height() + padding.vertical() ).transform(matrix); revealAabb(context, box); } // TODO: support animations private void revealAabb(BuildContext context, Aabb2d box) { var scrollInstance = this.context().instance(); var revealInstance = context.instance(); var transform = revealInstance.computeTransformFrom(scrollInstance).invert().translate( this.horizontalController != null ? (float) this.horizontalController.offset : 0, this.verticalController != null ? (float) this.verticalController.offset : 0 ); box.transform(transform); if (this.horizontalController != null) { if (box.minX() < this.horizontalController.offset) { this.horizontalController.jumpTo(box.minX()); } if (box.maxX() > scrollInstance.transform.width() + this.horizontalController.offset) { this.horizontalController.jumpTo(box.maxX() - scrollInstance.transform.width()); } } if (this.verticalController != null) { if (box.minY() < this.verticalController.offset) { this.verticalController.jumpTo(box.minY()); } if (box.maxY() > scrollInstance.transform.height() + this.verticalController.offset) { this.verticalController.jumpTo(box.maxY() - scrollInstance.transform.height()); } } } @Override public void init() { this.horizontalController = this.widget().horizontal ? Objects.requireNonNullElse(this.widget().horizontalController, new ScrollController(this)) : null; this.verticalController = this.widget().vertical ? Objects.requireNonNullElse(this.widget().verticalController, new ScrollController(this)) : null; if (this.horizontalController != null) this.listenable.addChild(this.horizontalController); if (this.verticalController != null) this.listenable.addChild(this.verticalController); } @Override public void didUpdateWidget(Scrollable oldWidget) { this.listenable.clear(); if (this.widget().horizontal) { if (this.widget().horizontalController != null) { this.horizontalController = this.widget().horizontalController; } else if (this.horizontalController == null || this.horizontalController == oldWidget.horizontalController) { this.horizontalController = new ScrollController(this); } this.listenable.addChild(this.horizontalController); } else { this.horizontalController = null; } if (this.widget().vertical) { if (this.widget().verticalController != null) { this.verticalController = this.widget().verticalController; } else if (this.verticalController == null || this.verticalController == oldWidget.verticalController) { this.verticalController = new ScrollController(this); } this.listenable.addChild(this.verticalController); } else { this.verticalController = null; } } @Override public Widget build(BuildContext context) { var widgetSettings = this.widget().animationSettings; var animationSettings = widgetSettings != null ? (widgetSettings != ScrollAnimationSettings.NO_ANIMATION ? widgetSettings : null) : DefaultScrollAnimationSettings.maybeOf(context); return new Clip( new MouseArea( widget -> widget .scrollCallback((horizontal, vertical) -> { var verticalDelta = vertical * -15; var horizontalDelta = horizontal * -15; if (AppState.of(context).eventBinding.activeModifiers().shift()) { if (this.widget().horizontal) { if (animationSettings != null) { this.horizontalController.animateBy(verticalDelta, animationSettings.duration(), animationSettings.easing()); } else { this.horizontalController.jumpBy(verticalDelta); } } } else { if (this.widget().vertical) { if (animationSettings != null) { this.verticalController.animateBy(verticalDelta, animationSettings.duration(), animationSettings.easing()); } else { this.verticalController.jumpBy(verticalDelta); } } } if (this.widget().horizontal) { if (animationSettings != null) { this.horizontalController.animateBy(horizontalDelta, animationSettings.duration(), animationSettings.easing()); } else { this.horizontalController.jumpBy(horizontalDelta); } } return true; }), new ListenableBuilder( this.listenable, (innerContext, child) -> new RawScrollView( this.horizontalController, this.verticalController, new ScrollableProvider(this, child) ), this.widget().child ) ) ); } } } class ScrollableProvider extends InheritedWidget { public final Scrollable.State state; public ScrollableProvider(Scrollable.State state, Widget child) { super(child); this.state = state; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return false; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/scroll/ScrollableWithBars.java ================================================ package io.wispforest.owo.braid.widgets.scroll; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Align; import io.wispforest.owo.braid.widgets.basic.ListenableBuilder; import io.wispforest.owo.braid.widgets.basic.Padding; import io.wispforest.owo.braid.widgets.basic.Sized; import io.wispforest.owo.braid.widgets.stack.Stack; import io.wispforest.owo.braid.widgets.stack.StackBase; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.function.BiFunction; public class ScrollableWithBars extends StatefulWidget { public final @Nullable ScrollController horizontalController; public final @Nullable ScrollController verticalController; public final @Nullable ScrollAnimationSettings animationSettings; public final int scrollbarSize; public final BiFunction scrollbarFactory; public final Widget child; public ScrollableWithBars(@Nullable ScrollController horizontalController, @Nullable ScrollController verticalController, @Nullable ScrollAnimationSettings animationSettings, int scrollbarSize, BiFunction scrollbarFactory, Widget child) { this.horizontalController = horizontalController; this.verticalController = verticalController; this.animationSettings = animationSettings; this.scrollbarSize = scrollbarSize; this.scrollbarFactory = scrollbarFactory; this.child = child; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private ScrollController horizontalController; private ScrollController verticalController; private void updateControllers() { var newHorizontalController = this.widget().horizontalController != null ? this.widget().horizontalController : this.horizontalController; this.horizontalController = newHorizontalController != null ? newHorizontalController : new ScrollController(this); var newVerticalController = this.widget().verticalController != null ? this.widget().verticalController : this.verticalController; this.verticalController = newVerticalController != null ? newVerticalController : new ScrollController(this); } @Override public void init() { this.updateControllers(); } @Override public void didUpdateWidget(ScrollableWithBars oldWidget) { this.updateControllers(); } @Override public Widget build(BuildContext context) { return new ListenableBuilder( this.horizontalController, horizontalContext -> { var showHorizontalScrollbar = this.horizontalController.maxOffset() > 0; return new ListenableBuilder( this.verticalController, verticalContext -> { var showVerticalScrollbar = this.verticalController.maxOffset() > 0; var widgets = new ArrayList(); widgets.add(new StackBase( new Padding( Insets.of( 0, showHorizontalScrollbar ? this.widget().scrollbarSize : 0, 0, showVerticalScrollbar ? this.widget().scrollbarSize : 0 ), new Scrollable( true, true, this.horizontalController, this.verticalController, this.widget().animationSettings, this.widget().child ) ) )); if (showVerticalScrollbar) { widgets.add(new Align( Alignment.RIGHT, new Padding( showHorizontalScrollbar ? Insets.bottom(this.widget().scrollbarSize) : Insets.none(), new Sized( this.widget().scrollbarSize, null, this.widget().scrollbarFactory.apply(LayoutAxis.VERTICAL, this.verticalController) ) ) )); } if (showHorizontalScrollbar) { widgets.add(new Align( Alignment.BOTTOM, new Sized( null, this.widget().scrollbarSize, this.widget().scrollbarFactory.apply(LayoutAxis.HORIZONTAL, this.horizontalController) ) )); } return new Stack(widgets); } ); } ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/scroll/Scrollbar.java ================================================ package io.wispforest.owo.braid.widgets.scroll; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.LayoutBuilder; import io.wispforest.owo.braid.widgets.basic.ListenableBuilder; import io.wispforest.owo.braid.widgets.basic.Padding; import io.wispforest.owo.braid.widgets.slider.SliderStyle; import io.wispforest.owo.braid.widgets.slider.slider.Slider; import org.jetbrains.annotations.Nullable; public class Scrollbar extends StatelessWidget { public final LayoutAxis axis; public final ScrollController controller; public final @Nullable Widget track; public final Widget handle; public Scrollbar(LayoutAxis axis, ScrollController controller, @Nullable Widget track, Widget handle) { this.axis = axis; this.controller = controller; this.track = track; this.handle = handle; } @Override public Widget build(BuildContext context) { return new ListenableBuilder( this.controller, buildContext -> { return new LayoutBuilder( (ctx, constraints) -> { var currentOffset = this.controller.offset; var maxOffset = this.controller.maxOffset; var containerSize = constraints.maxOnAxis(this.axis); var childSize = containerSize + maxOffset; var scrollbarLength = Math.floor(Math.min((containerSize / childSize) * containerSize, containerSize)); return maxOffset != 0 ? new Slider( currentOffset, widget -> widget .style(new SliderStyle<>( this.track, active -> this.handle, Math.max(5, scrollbarLength), null )) .min(this.axis.choose(0d, maxOffset)) .max(this.axis.choose(maxOffset, 0d)) .axis(this.axis), this.controller::jumpTo ) : new Padding(Insets.none()); } ); } ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/scroll/VerticallyScrollable.java ================================================ package io.wispforest.owo.braid.widgets.scroll; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; public class VerticallyScrollable extends Scrollable { public VerticallyScrollable(@Nullable ScrollController controller, @Nullable ScrollAnimationSettings animationSettings, Widget child) { super(false, true, null, controller, animationSettings, child); } public VerticallyScrollable(Widget child) { this(null, null, child); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/sharedstate/ShareableState.java ================================================ package io.wispforest.owo.braid.widgets.sharedstate; public abstract class ShareableState { SharedState.State backingState; public final void setState(Runnable fn) { this.backingState.setState(() -> { fn.run(); this.backingState.generation++; }); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/sharedstate/SharedState.java ================================================ package io.wispforest.owo.braid.widgets.sharedstate; import com.google.common.base.Preconditions; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; public class SharedState extends StatefulWidget { public final Supplier initState; public final Widget child; public SharedState(Supplier initState, Widget child) { this.initState = initState; this.child = child; } @Override public WidgetState> createState() { return new State<>(); } public static T get(BuildContext context, Class clazz) { var provider = context.dependOnAncestor(SharedStateProvider.class, SharedStateProvider.keyOf(clazz)); Preconditions.checkArgument(provider != null, "attempted to read shared state which is not provided by the current context"); return (T) provider.state.state; } public static T getWithoutDependency(BuildContext context, Class clazz) { var provider = context.getAncestor(SharedStateProvider.class, SharedStateProvider.keyOf(clazz)); Preconditions.checkArgument(provider != null, "attempted to read shared state which is not provided by the current context"); return (T) provider.state.state; } public static S select(BuildContext context, Class clazz, Function selector) { var provider = context.getAncestor(SharedStateProvider.class, SharedStateProvider.keyOf(clazz)); Preconditions.checkArgument(provider != null, "attempted to select from shared state which is not provided by the current context"); var capturedValue = selector.apply(((SharedStateProvider) provider).state.state); context.dependOnAncestor(SharedStateProvider.class, SharedStateProvider.keyOf(clazz), SharedStateProvider.dependencyOf(clazz, capturedValue, selector)); return capturedValue; } public static void set(BuildContext context, Class clazz, Consumer consumer) { var provider = context.dependOnAncestor(SharedStateProvider.class, SharedStateProvider.keyOf(clazz)); Preconditions.checkArgument(provider != null, "attempted to set shared state which is not provided by the current context"); provider.state.state.setState(() -> consumer.accept((T) provider.state.state)); } public static class State extends WidgetState> { public T state; public int generation = 0; @Override public void init() { super.init(); this.state = widget().initState.get(); this.state.backingState = this; } @Override public Widget build(BuildContext context) { return new SharedStateProvider<>(this, this.generation, this.widget().child); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/sharedstate/SharedStateProvider.java ================================================ package io.wispforest.owo.braid.widgets.sharedstate; import com.google.common.collect.Iterables; import io.wispforest.owo.braid.framework.proxy.InheritedProxy; import io.wispforest.owo.braid.framework.proxy.WidgetProxy; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import org.jetbrains.annotations.Nullable; import java.util.*; import java.util.function.Function; public final class SharedStateProvider extends InheritedWidget { public final SharedState.State state; public final int generation; private final InheritedKey inheritedKey; public SharedStateProvider(SharedState.State state, int generation, Widget child) { super(child); this.state = state; this.generation = generation; this.inheritedKey = new InheritedKey(state.state.getClass()); } @Override public WidgetProxy proxy() { return new Proxy<>(this); } @Override public Object inheritedKey() { return this.inheritedKey; } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return generation != ((SharedStateProvider) newWidget).generation; } public static Object keyOf(Class stateClass) { return new InheritedKey(stateClass); } public static Object dependencyOf(Class stateClass, @Nullable Object capturedValue, Function selector) { return new StateAspect<>(stateClass, capturedValue, selector); } public static class Proxy extends InheritedProxy { private static final Object COMPLETE_DEPENDENCY_SENTINEL = new Object(); private final Map dependenciesByDependent = new HashMap<>(); public Proxy(SharedStateProvider widget) { super(widget); } @SuppressWarnings("unchecked") @Override public void addDependency(WidgetProxy dependent, @Nullable Object dependency) { super.addDependency(dependent, dependency); var existingDependency = this.dependenciesByDependent.get(dependent); if (existingDependency != null && !(existingDependency instanceof List)) { return; } if (!(dependency instanceof StateAspect aspect) || aspect.stateClass() != ((SharedStateProvider) this.widget()).state.state.getClass()) { this.dependenciesByDependent.put(dependent, COMPLETE_DEPENDENCY_SENTINEL); return; } List> aspects; if (existingDependency != null) { aspects = (List>) existingDependency; } else { aspects = new ArrayList<>(); this.dependenciesByDependent.put(dependent, aspects); } aspects.add((StateAspect) dependency); } @SuppressWarnings("unchecked") @Override protected boolean mustRebuildDependent(WidgetProxy dependent) { var dependency = this.dependenciesByDependent.get(dependent); if (dependency instanceof List) { return Iterables.any( (List>) dependency, element -> !Objects.equals(element.capturedValue(), element.selector().apply(((SharedStateProvider) this.widget()).state.state)) ); } else { return true; } } @Override public void notifyDependent(WidgetProxy dependent) { super.notifyDependent(dependent); this.dependenciesByDependent.remove(dependent); } } } record InheritedKey(Class stateClass) {} record StateAspect(Class stateClass, @Nullable Object capturedValue, Function selector) {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/DefaultSliderHandle.java ================================================ package io.wispforest.owo.braid.widgets.slider; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.EmptyWidget; import io.wispforest.owo.braid.widgets.button.ButtonPanel; public class DefaultSliderHandle extends StatelessWidget { public final boolean active; public DefaultSliderHandle(boolean active) { this.active = active; } @Override public Widget build(BuildContext context) { return new ButtonPanel( this.active, EmptyWidget.INSTANCE ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/Incrementor.java ================================================ package io.wispforest.owo.braid.widgets.slider; import io.wispforest.owo.braid.core.AppState; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.MouseArea; import io.wispforest.owo.braid.widgets.intents.Intent; import io.wispforest.owo.braid.widgets.intents.Interactable; import io.wispforest.owo.braid.widgets.intents.ShortcutTrigger; import net.minecraft.util.Util; import org.jetbrains.annotations.Nullable; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.DoubleConsumer; public class Incrementor extends StatelessWidget { public final @Nullable DoubleConsumer xCallback, yCallback; public final Widget child; public Incrementor( @Nullable DoubleConsumer xCallback, @Nullable DoubleConsumer yCallback, Widget child ) { this.xCallback = xCallback; this.yCallback = yCallback; this.child = child; } public Incrementor(@Nullable DoubleConsumer callback, Widget child) { this(callback, callback, child); } public Incrementor(LayoutAxis axis, @Nullable DoubleConsumer callback, Widget child) { this( axis.choose(callback, null), axis.choose(null, callback), child ); } @Override public Widget build(BuildContext context) { return new Interactable( this.xCallback != null && this.yCallback != null ? BOTH_AXIS_SHORTCUTS : this.xCallback != null ? HORIZONTAL_SHORTCUTS : this.yCallback != null ? VERTICAL_SHORTCUTS : Map.of(), interactable -> interactable.addCallbackAction( IncrementIntent.class, (actionCtx, intent) -> { switch (intent.axis) { case HORIZONTAL -> { if (this.xCallback != null) this.xCallback.accept(intent.amount); } case VERTICAL -> { if (this.yCallback != null) this.yCallback.accept(intent.amount); } } } ), new MouseArea( mouseArea -> mouseArea .scrollCallback((baseHorizontal, baseVertical) -> { var handled = false; var modifiers = AppState.of(context).eventBinding.activeModifiers(); var horizontal = modifiers.shift() ? baseVertical : baseHorizontal; var vertical = modifiers.shift() ? baseHorizontal : baseVertical; if (horizontal != 0 && this.xCallback != null) { this.xCallback.accept(horizontal); handled = true; } if (vertical != 0 && this.yCallback != null) { this.yCallback.accept(vertical); handled = true; } return handled; }), this.child ) ); } // --- public static final Map, Intent> HORIZONTAL_SHORTCUTS = Map.of( List.of(ShortcutTrigger.RIGHT), new IncrementIntent(LayoutAxis.HORIZONTAL, 1), List.of(ShortcutTrigger.LEFT), new IncrementIntent(LayoutAxis.HORIZONTAL, -1), List.of(ShortcutTrigger.HOME), new IncrementIntent(LayoutAxis.HORIZONTAL, Double.NEGATIVE_INFINITY), List.of(ShortcutTrigger.END), new IncrementIntent(LayoutAxis.HORIZONTAL, Double.POSITIVE_INFINITY) ); public static final Map, Intent> VERTICAL_SHORTCUTS = Map.of( List.of(ShortcutTrigger.UP), new IncrementIntent(LayoutAxis.VERTICAL, 1), List.of(ShortcutTrigger.DOWN), new IncrementIntent(LayoutAxis.VERTICAL, -1), List.of(ShortcutTrigger.PAGE_UP), new IncrementIntent(LayoutAxis.VERTICAL, Double.POSITIVE_INFINITY), List.of(ShortcutTrigger.PAGE_DOWN), new IncrementIntent(LayoutAxis.VERTICAL, Double.NEGATIVE_INFINITY) ); public static final Map, Intent> BOTH_AXIS_SHORTCUTS = Util.make(() -> { var map = new HashMap, Intent>(); map.putAll(HORIZONTAL_SHORTCUTS); map.putAll(VERTICAL_SHORTCUTS); return Collections.unmodifiableMap(map); }); public record IncrementIntent(LayoutAxis axis, double amount) implements Intent {} } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/SliderStyle.java ================================================ package io.wispforest.owo.braid.widgets.slider; import io.wispforest.owo.braid.framework.widget.Widget; import net.minecraft.sounds.SoundEvent; import org.jetbrains.annotations.Nullable; import java.util.Optional; public record SliderStyle( @Nullable Widget track, @Nullable HandleBuilder handleBuilder, @Nullable HandleSize handleSize, @Nullable Optional confirmSound ) { public SliderStyle overriding(SliderStyle other) { //noinspection OptionalAssignedToNull return new SliderStyle<>( this.track != null ? this.track : other.track, this.handleBuilder != null ? this.handleBuilder : other.handleBuilder, this.handleSize != null ? this.handleSize : other.handleSize, this.confirmSound != null ? this.confirmSound : other.confirmSound ); } private static final SliderStyle DEFAULT = new SliderStyle<>(null, null, null, null); public static SliderStyle getDefault() { //noinspection unchecked return (SliderStyle) DEFAULT; } @FunctionalInterface public interface HandleBuilder { Widget build(boolean active); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/drag/Drag.java ================================================ package io.wispforest.owo.braid.widgets.slider.drag; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.basic.Panel; import io.wispforest.owo.braid.widgets.slider.slider.SliderCallback; import io.wispforest.owo.ui.component.ButtonComponent; import org.jetbrains.annotations.Nullable; public class Drag extends RawDrag { public Drag( double value, @Nullable WidgetSetupCallback setupCallback, @Nullable SliderCallback onChanged ) { super( value, null, onChanged, new Panel(ButtonComponent.DISABLED_TEXTURE) ); if (setupCallback != null) setupCallback.setup(this); } public Drag( double value, @Nullable WidgetSetupCallback setupCallback, SliderCallback onChanged, boolean active ) { this(value, setupCallback, active ? onChanged : null); } @Override public Drag min(@Nullable Double min) { return (Drag) super.min(min); } @Override public Drag min(double min) { return (Drag) super.min(min); } @Override public Drag max(@Nullable Double max) { return (Drag) super.max(max); } @Override public Drag max(double max) { return (Drag) super.max(max); } @Override public Drag range(@Nullable Double min, @Nullable Double max) { return (Drag) super.range(min, max); } @Override public Drag range(double min, double max) { return (Drag) super.range(min, max); } @Override public Drag step(@Nullable Double step) { return (Drag) super.step(step); } @Override public Drag step(double step) { return (Drag) super.step(step); } @Override public Drag dragFunction(DragFunction dragFunction) { return (Drag) super.dragFunction(dragFunction); } @Override public Drag axis(LayoutAxis axis) { return (Drag) super.axis(axis); } @Override public Drag vertical() { return (Drag) super.vertical(); } @Override public Drag wrap(boolean wrap) { return (Drag) super.wrap(wrap); } @Override public Drag dragMultiplier(double dragMultiplier) { return (Drag) super.dragMultiplier(dragMultiplier); } @Override public Drag incrementStep(double incrementStep) { return (Drag) super.incrementStep(incrementStep); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/drag/DragFunction.java ================================================ package io.wispforest.owo.braid.widgets.slider.drag; import org.jetbrains.annotations.Nullable; public interface DragFunction { double deltaValue(double currentValue, @Nullable Double min, @Nullable Double max, double cursorNormalizedDelta); DragFunction LINEAR = (currentValue, min, max, cursorDelta) -> { if (min != null && max != null) return cursorDelta * (max - min); return cursorDelta; }; DragFunction LOGARITHMIC = (currentValue, min, max, cursorDelta) -> { double base; if (min != null && max != null) { base = cursorDelta * (max - min); double denom = Math.max(Math.abs(min), Math.abs(max)); double rel = denom > 0 ? Math.abs(currentValue) / denom : 0; double scale = 1.0 + rel; return base * scale; } else { double scale = 1.0 + Math.min(2.0, Math.log1p(Math.abs(currentValue))); return cursorDelta * scale; } }; } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/drag/MessageDrag.java ================================================ package io.wispforest.owo.braid.widgets.slider.drag; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.label.Label; import io.wispforest.owo.braid.widgets.label.LabelStyle; import io.wispforest.owo.braid.widgets.slider.slider.SliderCallback; import io.wispforest.owo.braid.widgets.stack.Stack; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.Nullable; public class MessageDrag extends StatelessWidget { public final double value; public final @Nullable WidgetSetupCallback setupCallback; public final @Nullable SliderCallback onChanged; public final Component message; public MessageDrag( double value, @Nullable WidgetSetupCallback setupCallback, @Nullable SliderCallback onChanged, Component message ) { this.value = value; this.setupCallback = setupCallback; this.onChanged = onChanged; this.message = message; } public MessageDrag( double value, @Nullable WidgetSetupCallback setupCallback, boolean active, SliderCallback onChanged, Component message ) { this(value, setupCallback, active ? onChanged : null, message); } @Override public Widget build(BuildContext context) { return new Stack( new Drag( this.value, this.setupCallback, this.onChanged ), new Label( LabelStyle.SHADOW, false, this.message ) ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/drag/RawDrag.java ================================================ package io.wispforest.owo.braid.widgets.slider.drag; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.basic.*; import io.wispforest.owo.braid.widgets.slider.Incrementor; import io.wispforest.owo.braid.widgets.slider.slider.SliderCallback; import net.minecraft.util.Mth; import org.jetbrains.annotations.Nullable; public class RawDrag extends StatefulWidget { public final double value; protected @Nullable Double min = 0d; protected @Nullable Double max = 1d; protected @Nullable Double step; protected DragFunction dragFunction = DragFunction.LINEAR; protected double dragMultiplier = 1; protected LayoutAxis axis = LayoutAxis.HORIZONTAL; protected boolean wrap = false; public final @Nullable SliderCallback onChanged; public final @Nullable Widget child; protected @Nullable Double incrementStep = null; public RawDrag( double value, @Nullable WidgetSetupCallback setupCallback, @Nullable SliderCallback onChanged, @Nullable Widget child ) { this.value = value; this.onChanged = onChanged; this.child = child; if (setupCallback != null) setupCallback.setup(this); } public RawDrag min(@Nullable Double min) { this.assertMutable(); this.min = min; return this; } public RawDrag min(double min) { this.assertMutable(); this.min = min; return this; } public @Nullable Double min() { return this.min; } public RawDrag max(@Nullable Double max) { this.assertMutable(); this.max = max; return this; } public RawDrag max(double max) { this.assertMutable(); this.max = max; return this; } public @Nullable Double max() { return this.max; } public RawDrag range(@Nullable Double min, @Nullable Double max) { this.assertMutable(); this.min = min; this.max = max; return this; } public RawDrag range(double min, double max) { this.assertMutable(); this.min = min; this.max = max; return this; } public RawDrag step(@Nullable Double step) { this.assertMutable(); this.step = step; return this; } public RawDrag step(double step) { this.assertMutable(); this.step = step; return this; } public @Nullable Double step() { return this.step; } public RawDrag dragFunction(DragFunction dragFunction) { this.assertMutable(); this.dragFunction = dragFunction; return this; } public DragFunction dragFunction() { return this.dragFunction; } public RawDrag axis(LayoutAxis axis) { this.assertMutable(); this.axis = axis; return this; } public RawDrag vertical() { return this.axis(LayoutAxis.VERTICAL); } public LayoutAxis axis() { return this.axis; } public RawDrag wrap(boolean wrap) { this.assertMutable(); this.wrap = wrap; return this; } public boolean wrap() { return this.wrap; } public RawDrag dragMultiplier(double dragMultiplier) { this.assertMutable(); this.dragMultiplier = dragMultiplier; return this; } public double dragMultiplier() { return this.dragMultiplier; } public RawDrag incrementStep(double incrementStep) { this.assertMutable(); this.incrementStep = incrementStep; return this; } public @Nullable Double incrementStep() { return this.incrementStep; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { protected double dragValue = 0; protected boolean dragging = false; protected double normalizedValue; protected double incrementStep; protected CursorStyle draggingCursorStyle = null; @Override public void init() { var widget = this.widget(); // incrementStep in drag: when bounded, treat increment as fraction of range; when unbounded, as raw value units if (widget.min != null && widget.max != null) { var range = widget.max - widget.min; var inc = widget.incrementStep != null ? widget.incrementStep : (widget.step != null ? widget.step : range * 0.01); this.incrementStep = inc / (range == 0 ? 1 : range); } else { this.incrementStep = widget.incrementStep != null ? widget.incrementStep : (widget.step != null ? widget.step : 1.0); } } @Override public Widget build(BuildContext context) { var widget = this.widget(); this.normalizedValue = (widget.min != null && widget.max != null)? (widget.value - widget.min) / (widget.max - widget.min) : 0; this.draggingCursorStyle = null; return new LayoutBuilder((innerContext, constraints) -> { var size = constraints.maxFiniteOrMinSize(); var content = new Sized(size, widget.child); return new Center( widget.onChanged == null || ControlsOverride.controlsDisabled(context) ? content : new Incrementor( widget.axis, increment -> this.increment(constraints, increment), new MouseArea( mouseArea -> mouseArea .clickCallback((x, y, button, modifiers) -> { if (button != 0) return false; this.dragValue = this.normalizedValue; this.dragging = true; return true; }) .dragCallback((x, y, dx, dy) -> { var delta = widget.axis.choose(dx, widget.axis == LayoutAxis.VERTICAL ? -dy : dy); this.move(constraints, delta); }) .dragEndCallback(() -> dragging = false) .cursorStyleSupplier((x, y) -> { if (dragging) { if (draggingCursorStyle == null) this.draggingCursorStyle = CursorStyle.forDraggingAlong(widget.axis, context.instance().computeGlobalTransform()); return this.draggingCursorStyle; } return CursorStyle.HAND; }), content ) ) ); }); } protected void move(Constraints constraints, double deltaAlongAxis) { var widget = this.widget(); if (widget.min != null && widget.max != null) { var track = Math.max(1, constraints.maxFiniteOrMinOnAxis(widget.axis)); var cursorNorm = (deltaAlongAxis / track) * widget.dragMultiplier; var valueDelta = widget.dragFunction.deltaValue(widget.value, widget.min, widget.max, cursorNorm); var newValue = widget.value + valueDelta; this.applyValueBounded(newValue); } else { var track = Math.max(1, constraints.maxFiniteOrMinOnAxis(widget.axis)); var cursorNorm = (deltaAlongAxis / track) * widget.dragMultiplier; var valueDelta = widget.dragFunction.deltaValue(widget.value, null, null, cursorNorm); this.applyValueUnbounded(widget.value + valueDelta); } } protected void increment(Constraints constraints, double increment) { var widget = this.widget(); if (widget.min != null && widget.max != null) { var range = widget.max - widget.min; var valueDelta = incrementStep * increment * range; this.applyValueBounded(widget.value + valueDelta); } else { var unit = widget.incrementStep != null ? widget.incrementStep : (widget.step != null ? widget.step : 1.0); this.applyValueUnbounded(widget.value + unit * increment); } } protected void applyValueBounded(double newValue) { var widget = this.widget(); var min = widget.min == null ? Double.NEGATIVE_INFINITY : widget.min; var max = widget.max == null ? Double.POSITIVE_INFINITY : widget.max; if (widget.wrap && widget.min != null && widget.max != null) { var range = max - min; if (range != 0) { var offset = (newValue - min) % range; if (offset < 0) offset += range; newValue = min + offset; } } else { newValue = Mth.clamp(newValue, min, max); } var step = widget.step; newValue = step != null ? Math.round(newValue / step) * step : newValue; if (widget.wrap && widget.min != null && widget.max != null) { var range = max - min; if (range != 0) { var offset = (newValue - min) % range; if (offset < 0) offset += range; newValue = min + offset; } } else { newValue = Mth.clamp(newValue, min, max); } widget.onChanged.accept(newValue); } protected void applyValueUnbounded(double newValue) { var widget = this.widget(); var step = widget.step; widget.onChanged.accept(step != null ? Math.round(newValue / step) * step : newValue); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/range/DefaultRangeSliderStyle.java ================================================ package io.wispforest.owo.braid.widgets.slider.range; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Builder; import org.jetbrains.annotations.Nullable; public class DefaultRangeSliderStyle extends InheritedWidget { public final RangeSliderStyle style; public DefaultRangeSliderStyle(RangeSliderStyle style, Widget child) { super(child); this.style = style; } public static Widget merge(RangeSliderStyle style, Widget child) { return new Builder(context -> { var contextStyle = DefaultRangeSliderStyle.maybeOf(context); return new DefaultRangeSliderStyle(contextStyle != null ? style.overriding(contextStyle) : style, child); }); } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return !this.style.equals(((DefaultRangeSliderStyle) newWidget).style); } public static @Nullable RangeSliderStyle maybeOf(BuildContext context) { var widget = context.dependOnAncestor(DefaultRangeSliderStyle.class); if (widget != null) { return widget.style; } else { return null; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/range/MessageRangeSlider.java ================================================ package io.wispforest.owo.braid.widgets.slider.range; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.label.Label; import io.wispforest.owo.braid.widgets.label.LabelStyle; import io.wispforest.owo.braid.widgets.stack.Stack; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.Nullable; public class MessageRangeSlider extends StatelessWidget { public final double minValue, maxValue; public final @Nullable WidgetSetupCallback setupCallback; public final @Nullable RangeSliderCallback onChanged; public final Component message; public MessageRangeSlider( double minValue, double maxValue, Component message, @Nullable WidgetSetupCallback setupCallback, @Nullable RangeSliderCallback onChanged ) { this.minValue = minValue; this.maxValue = maxValue; this.setupCallback = setupCallback; this.onChanged = onChanged; this.message = message; } public MessageRangeSlider( double minValue, double maxValue, Component message, @Nullable WidgetSetupCallback setupCallback, boolean active, RangeSliderCallback onChanged ) { this(minValue, maxValue, message, setupCallback, active ? onChanged : null); } @Override public Widget build(BuildContext context) { return new Stack( new RangeSlider( this.minValue, this.maxValue, this.setupCallback, this.onChanged ), new Label( LabelStyle.SHADOW, false, this.message ) ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/range/RangeSlider.java ================================================ package io.wispforest.owo.braid.widgets.slider.range; import io.wispforest.owo.braid.core.*; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.basic.*; import io.wispforest.owo.braid.widgets.slider.DefaultSliderHandle; import io.wispforest.owo.braid.widgets.slider.Incrementor; import io.wispforest.owo.braid.widgets.slider.slider.SliderFunction; import io.wispforest.owo.braid.widgets.stack.Stack; import io.wispforest.owo.ui.component.ButtonComponent; import io.wispforest.owo.ui.util.UISounds; import net.minecraft.sounds.SoundEvents; import net.minecraft.util.Mth; import org.jetbrains.annotations.Nullable; import java.util.Objects; public class RangeSlider extends StatefulWidget { protected double min = 0; protected double max = 1; protected double minRange = 0; protected double maxRange = -1; protected @Nullable Double step; protected SliderFunction sliderFunction = SliderFunction.LINEAR; protected LayoutAxis axis = LayoutAxis.HORIZONTAL; protected @Nullable Double incrementStep = null; protected @Nullable RangeSliderStyle style = null; public final double minValue, maxValue; public final @Nullable RangeSliderCallback onChanged; public RangeSlider( double minValue, double maxValue, @Nullable WidgetSetupCallback setupCallback, @Nullable RangeSliderCallback onChanged ) { this.minValue = minValue; this.maxValue = maxValue; this.onChanged = onChanged; if (setupCallback != null) setupCallback.setup(this); } public RangeSlider( double minValue, double maxValue, @Nullable WidgetSetupCallback setupCallback, boolean active, RangeSliderCallback onChanged ) { this( minValue, maxValue, setupCallback, active ? onChanged : null ); } public RangeSlider min(double min) { this.assertMutable(); this.min = min; return this; } public double min() { return this.min; } public RangeSlider max(double max) { this.assertMutable(); this.max = max; return this; } public double max() { return this.max; } public RangeSlider range(double min, double max) { this.assertMutable(); this.min = min; this.max = max; return this; } public RangeSlider minRange(double minRange) { this.assertMutable(); this.minRange = minRange; return this; } public double minRange() { return this.minRange; } public RangeSlider maxRange(double maxRange) { this.assertMutable(); this.maxRange = maxRange; return this; } public double maxRange() { return this.maxRange; } public RangeSlider clampRange(double minRange, double maxRange) { this.assertMutable(); this.minRange = minRange; this.maxRange = maxRange; return this; } public RangeSlider step(@Nullable Double step) { this.assertMutable(); this.step = step; return this; } public RangeSlider step(double step) { this.assertMutable(); this.step = step; return this; } public @Nullable Double step() { return this.step; } public RangeSlider sliderFunction(SliderFunction function) { this.assertMutable(); this.sliderFunction = function; return this; } public SliderFunction sliderFunction() { return this.sliderFunction; } public RangeSlider axis(LayoutAxis axis) { this.assertMutable(); this.axis = axis; return this; } public RangeSlider vertical() { return this.axis(LayoutAxis.VERTICAL); } public LayoutAxis axis() { return this.axis; } public RangeSlider incrementStep(double incrementStep) { this.assertMutable(); this.incrementStep = incrementStep; return this; } public @Nullable Double incrementStep() { return this.incrementStep; } public RangeSlider style(RangeSliderStyle style) { this.assertMutable(); this.style = style; return this; } public @Nullable RangeSliderStyle style() { return this.style; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { protected double dragValue = 0; protected @Nullable Handle grabbedHandle = null; protected boolean dragging = false; protected double normalizedMin; protected double normalizedMax; protected double incrementStep; protected CursorStyle draggingCursorStyle = null; protected double dragWidth; protected double minHandleSize; protected double maxHandleSize; @Override public void init() { var widget = this.widget(); var trueMin = Math.min(widget.min, widget.max); var trueMax = Math.max(widget.min, widget.max); this.incrementStep = widget.incrementStep != null ? widget.sliderFunction.normalize(widget.incrementStep, trueMin, trueMax) : widget.step != null ? widget.sliderFunction.normalize(widget.step, trueMin, trueMax) : 0.01; } @Override public Widget build(BuildContext context) { var widget = this.widget(); var effectiveStyle = widget.style != null ? widget.style : RangeSliderStyle.DEFAULT; if (DefaultRangeSliderStyle.maybeOf(context) instanceof RangeSliderStyle contextStyle) { effectiveStyle = effectiveStyle.overriding(contextStyle); } var disabled = widget.onChanged == null || ControlsOverride.controlsDisabled(context); var track = Objects.requireNonNullElse(effectiveStyle.track(), DEFAULT_TRACK); var rangeIndicator = Objects.requireNonNullElse(effectiveStyle.rangeIndicator(), DEFAULT_RANGE_INDICATOR); var minHandle = Objects.requireNonNullElse(effectiveStyle.minHandleBuilder(), DEFAULT_HANDLE_BUILDER).build(!disabled); var maxHandle = Objects.requireNonNullElse(effectiveStyle.maxHandleBuilder(), DEFAULT_HANDLE_BUILDER).build(!disabled); this.minHandleSize = Objects.requireNonNullElse(effectiveStyle.minHandleSize(), DEFAULT_HANDLE_SIZE); this.maxHandleSize = Objects.requireNonNullElse(effectiveStyle.maxHandleSize(), DEFAULT_HANDLE_SIZE); //noinspection OptionalAssignedToNull var confirmSound = effectiveStyle.confirmSound() != null ? effectiveStyle.confirmSound().orElse(null) : SoundEvents.UI_BUTTON_CLICK.value(); this.normalizedMin = widget.sliderFunction.normalize(widget.minValue, widget.min, widget.max); this.normalizedMax = widget.sliderFunction.normalize(widget.maxValue, widget.min, widget.max); this.draggingCursorStyle = null; return new LayoutBuilder((innerContext, constraints) -> { var combinedHandleSize = this.minHandleSize + this.maxHandleSize; var rangeExtent = Math.ceil((constraints.maxOnAxis(widget.axis) - combinedHandleSize) * (this.normalizedMax - this.normalizedMin)); var content = new Stack( widget.axis.choose(Alignment.LEFT, Alignment.TOP), new Sized(constraints.maxWidth(), constraints.maxHeight(), track), new Padding( widget.axis.chooseCompute( () -> Insets.left(this.minHandleSize + Math.floor((constraints.maxWidth() - this.minHandleSize * 2) * this.normalizedMin) - 1), () -> Insets.top(this.minHandleSize + Math.floor((constraints.maxHeight() - this.minHandleSize * 2) * this.normalizedMin) - 1) ), new Center( 1.0, null, widget.axis.chooseCompute( () -> new Sized(rangeExtent + 2, constraints.maxHeight(), rangeIndicator), () -> new Sized(constraints.maxWidth(), rangeExtent + 2, rangeIndicator) ) ) ), new Padding( widget.axis.chooseCompute( () -> Insets.left(Math.floor((constraints.maxWidth() - this.minHandleSize * 2) * this.normalizedMin)), () -> Insets.top(Math.floor((constraints.maxHeight() - this.minHandleSize * 2) * this.normalizedMin)) ), widget.axis.chooseCompute( () -> new Sized(this.minHandleSize, constraints.maxHeight(), minHandle), () -> new Sized(constraints.maxWidth(), this.minHandleSize, minHandle) ) ), new Padding( widget.axis.chooseCompute( () -> Insets.left(this.maxHandleSize + Math.floor((constraints.maxWidth() - this.maxHandleSize * 2) * this.normalizedMax)), () -> Insets.top(this.maxHandleSize + Math.floor((constraints.maxHeight() - this.maxHandleSize * 2) * this.normalizedMax)) ), widget.axis.chooseCompute( () -> new Sized(this.maxHandleSize, constraints.maxHeight(), maxHandle), () -> new Sized(constraints.maxWidth(), this.maxHandleSize, maxHandle) ) ) ); return new Center( widget.onChanged == null || ControlsOverride.controlsDisabled(context) ? content : new Incrementor( widget.axis, this::increment, new MouseArea( mousearea -> mousearea .clickCallback((x, y, button, modifiers) -> { if (button != 0) return false; if (widget.axis == LayoutAxis.VERTICAL) y = constraints.maxFiniteOrMinOnAxis(widget.axis) - y; this.grabbedHandle = this.handleAt(constraints, x, y); if (this.grabbedHandle == Handle.BOTH && !this.isInRange(constraints, x, y)) { this.grabbedHandle = this.nearestHandle(constraints, x, y); } var initialDragValue = this.grabbedHandle == Handle.MAX ? this.normalizedMax : this.normalizedMin; if (!this.isInHandle(constraints, x, y) && this.grabbedHandle != Handle.BOTH) { initialDragValue = this.setAbsolute(constraints, x, y); } this.dragWidth = this.normalizedMax - this.normalizedMin; this.dragValue = initialDragValue; this.dragging = true; return true; }) .dragCallback((x, y, dx, dy) -> this.move(constraints, dx, widget.axis == LayoutAxis.VERTICAL ? -dy : dy)) .dragEndCallback(() -> { this.dragging = false; if (confirmSound != null) { UISounds.play(confirmSound); } }) .cursorStyleSupplier((x, y) -> { if (!isInHandle(constraints, x, constraints.maxHeight() - y) && !isInRange(constraints, x, constraints.maxHeight() - y) && !dragging) return CursorStyle.HAND; if (this.isInRange(constraints, x, y)) return CursorStyle.MOVE; if (this.draggingCursorStyle == null) this.draggingCursorStyle = CursorStyle.forDraggingAlong(widget.axis, context.instance().computeGlobalTransform()); return this.draggingCursorStyle; }), content ) ) ); }); } protected Handle handleAt(Constraints constraints, double x, double y) { var widget = this.widget(); if (widget.axis == LayoutAxis.VERTICAL) y = constraints.maxFiniteOrMinOnAxis(widget.axis) - y; var coordinate = widget.axis.choose(x, y); var minStart = Math.floor((constraints.maxFiniteOrMinOnAxis(widget.axis) - this.minHandleSize * 2) * this.normalizedMin); var maxStart = this.maxHandleSize + Math.floor((constraints.maxFiniteOrMinOnAxis(widget.axis) - this.maxHandleSize * 2) * this.normalizedMax); var inMin = coordinate >= minStart && coordinate <= minStart + this.minHandleSize; var inMax = coordinate >= maxStart && coordinate <= maxStart + this.maxHandleSize; if (inMin && inMax) return Handle.BOTH; if (inMin) return Handle.MIN; if (inMax) return Handle.MAX; if (coordinate > minStart + this.minHandleSize && coordinate < maxStart) return Handle.BOTH; var distToMin = Math.abs(coordinate - (minStart + this.minHandleSize / 2)); var distToMax = Math.abs(coordinate - (maxStart + this.maxHandleSize / 2)); return distToMin <= distToMax ? Handle.MIN : Handle.MAX; } protected boolean isInHandle(Constraints constraints, double x, double y) { var widget = this.widget(); if (widget.axis == LayoutAxis.VERTICAL) y = constraints.maxFiniteOrMinOnAxis(widget.axis) - y; var coordinate = widget.axis.choose(x, y); var minStart = Math.floor((constraints.maxFiniteOrMinOnAxis(widget.axis) - this.minHandleSize * 2) * this.normalizedMin); var maxStart = this.maxHandleSize + Math.floor((constraints.maxFiniteOrMinOnAxis(widget.axis) - this.maxHandleSize * 2) * this.normalizedMax); return (coordinate >= minStart && coordinate <= minStart + this.minHandleSize) || (coordinate >= maxStart && coordinate <= maxStart + this.maxHandleSize); } protected boolean isInRange(Constraints constraints, double x, double y) { var widget = this.widget(); if (widget.axis == LayoutAxis.VERTICAL) y = constraints.maxFiniteOrMinOnAxis(widget.axis) - y; var coordinate = widget.axis.choose(x, y); var minEnd = Math.floor((constraints.maxFiniteOrMinOnAxis(widget.axis) - this.minHandleSize * 2) * this.normalizedMin) + this.minHandleSize; var maxStart = this.maxHandleSize + Math.floor((constraints.maxFiniteOrMinOnAxis(widget.axis) - this.maxHandleSize * 2) * this.normalizedMax); return coordinate >= minEnd && coordinate <= maxStart; } protected Handle nearestHandle(Constraints constraints, double x, double y) { var widget = this.widget(); var trackLength = constraints.maxOnAxis(widget.axis) - (this.minHandleSize + this.maxHandleSize); var minCenter = this.normalizedMin * trackLength + this.minHandleSize / 2; var maxCenter = this.normalizedMax * trackLength + this.maxHandleSize / 2; var coordinate = widget.axis.choose(x, y); return Math.abs(coordinate - minCenter) <= Math.abs(coordinate - maxCenter) ? Handle.MIN : Handle.MAX; } protected double normalizedValueAt(Constraints constraints, double x, double y, @Nullable Handle grabbedHandle) { var widget = this.widget(); if (widget.axis == LayoutAxis.VERTICAL) y = constraints.maxFiniteOrMinOnAxis(widget.axis) - y; double coordinate = widget.axis.choose(x, y); if (grabbedHandle == Handle.MAX) { var denom = Math.max(1, constraints.maxFiniteOrMinOnAxis(widget.axis) - this.maxHandleSize * 2); return Mth.clamp((coordinate - this.maxHandleSize * 1.5) / denom, 0, 1); } else { var denom = Math.max(1, constraints.maxFiniteOrMinOnAxis(widget.axis) - this.minHandleSize * 2); return Mth.clamp((coordinate - this.minHandleSize / 2) / denom, 0, 1); } } protected double setAbsolute(Constraints constraints, double x, double y) { if (this.widget().onChanged == null) return this.grabbedHandle == Handle.MAX ? this.normalizedMax : this.normalizedMin; var normalizedValue = this.normalizedValueAt(constraints, x, y, this.grabbedHandle); var newNormalizedMin = this.normalizedMin; var newNormalizedMax = this.normalizedMax; var minRangeNorm = this.minRangeNorm(); var maxRangeNorm = this.maxRangeNorm(); if (this.grabbedHandle == Handle.MIN) { var upper = newNormalizedMax - minRangeNorm; var lower = maxRangeNorm >= 0 ? newNormalizedMax - maxRangeNorm : 0; newNormalizedMin = Mth.clamp(normalizedValue, Math.max(0, lower), Math.max(0, upper)); } else if (this.grabbedHandle == Handle.MAX) { var lower = newNormalizedMin + minRangeNorm; var upper = maxRangeNorm >= 0 ? newNormalizedMin + maxRangeNorm : 1; newNormalizedMax = Mth.clamp(normalizedValue, Math.min(1, lower), Math.min(1, upper)); } this.applyValue(newNormalizedMin, newNormalizedMax); return this.grabbedHandle == Handle.MAX ? newNormalizedMax : newNormalizedMin; } protected void move(Constraints constraints, double dx, double dy) { if (this.widget().onChanged == null || this.grabbedHandle == null) return; var axis = this.widget().axis; var combinedHandleSize = this.minHandleSize + this.maxHandleSize; var track = constraints.maxFiniteOrMinOnAxis(axis) - combinedHandleSize; var deltaNorm = (axis.choose(dx, dy)) / track; this.dragValue += deltaNorm; var minRangeNorm = this.minRangeNorm(); var maxRangeNorm = this.maxRangeNorm(); switch (this.grabbedHandle) { case MIN -> { var maxCap = this.normalizedMax - minRangeNorm; var minCap = maxRangeNorm >= 0 ? this.normalizedMax - maxRangeNorm : 0; var newMin = Mth.clamp(this.dragValue, Math.max(0, minCap), Math.max(0, maxCap)); this.applyValue(newMin, this.normalizedMax); } case MAX -> { var minCap = this.normalizedMin + minRangeNorm; var maxCap = maxRangeNorm >= 0 ? this.normalizedMin + maxRangeNorm : 1; var newMax = Mth.clamp(this.dragValue, Math.min(1, minCap), Math.min(1, maxCap)); this.applyValue(this.normalizedMin, newMax); } case BOTH -> { var width = this.dragWidth; this.dragValue = Mth.clamp(this.dragValue, 0, 1 - width); var newMin = this.dragValue; var newMax = newMin + width; this.applyValue(newMin, newMax); } } } protected void increment(double increment) { if (this.widget().onChanged == null) return; var target = this.grabbedHandle != null ? this.grabbedHandle : Handle.BOTH; var delta = this.incrementStep * increment; var newMin = this.normalizedMin; var newMax = this.normalizedMax; var minRangeNorm = this.minRangeNorm(); var maxRangeNorm = this.maxRangeNorm(); if (target == Handle.MIN) { var upper = newMax - minRangeNorm; var lower = maxRangeNorm >= 0 ? newMax - maxRangeNorm : 0; newMin = Mth.clamp(newMin + delta, Math.max(0, lower), Math.max(0, upper)); } else if (target == Handle.MAX) { var lower = newMin + minRangeNorm; var upper = maxRangeNorm >= 0 ? newMin + maxRangeNorm : 1; newMax = Mth.clamp(newMax + delta, Math.min(1, lower), Math.min(1, upper)); } else { newMin = Mth.clamp(newMin + delta, 0, 1); newMax = Mth.clamp(newMax + delta, 0, 1); } this.applyValue(newMin, newMax); } protected void applyValue(double newNormalizedMin, double newNormalizedMax) { var widget = this.widget(); var newMinValue = widget.sliderFunction.deNormalize(newNormalizedMin, widget.min, widget.max); var newMaxValue = widget.sliderFunction.deNormalize(newNormalizedMax, widget.min, widget.max); if (widget.step != null) { var step = widget.step; newMinValue = Math.round(newMinValue / step) * step; newMaxValue = Math.round(newMaxValue / step) * step; } newMinValue = Mth.clamp(newMinValue, widget.min, widget.max); newMaxValue = Mth.clamp(newMaxValue, widget.min, widget.max); widget.onChanged.accept(newMinValue, newMaxValue); } protected double minRangeNorm() { var widget = this.widget(); if (widget.minRange <= 0) return 0; var a = widget.sliderFunction.normalize(widget.min, widget.min, widget.max); var b = widget.sliderFunction.normalize(widget.min + widget.minRange, widget.min, widget.max); return Math.max(0, b - a); } protected double maxRangeNorm() { var widget = this.widget(); if (widget.maxRange < 0) return -1; var a = widget.sliderFunction.normalize(widget.min, widget.min, widget.max); var b = widget.sliderFunction.normalize(widget.min + widget.maxRange, widget.min, widget.max); return Math.max(0, b - a); } protected enum Handle { MIN, MAX, BOTH } } // --- private static final Widget DEFAULT_TRACK = new Panel(ButtonComponent.DISABLED_TEXTURE); private static final Widget DEFAULT_RANGE_INDICATOR = new Box(new Color(0x7f000000)); private static final RangeSliderStyle.HandleBuilder DEFAULT_HANDLE_BUILDER = DefaultSliderHandle::new; private static final double DEFAULT_HANDLE_SIZE = 8.0; } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/range/RangeSliderCallback.java ================================================ package io.wispforest.owo.braid.widgets.slider.range; @FunctionalInterface public interface RangeSliderCallback { void accept(double newMin, double newMax); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/range/RangeSliderStyle.java ================================================ package io.wispforest.owo.braid.widgets.slider.range; import io.wispforest.owo.braid.framework.widget.Widget; import net.minecraft.sounds.SoundEvent; import org.jetbrains.annotations.Nullable; import java.util.Optional; public record RangeSliderStyle( @Nullable Widget track, @Nullable Widget rangeIndicator, @Nullable HandleBuilder minHandleBuilder, @Nullable Double minHandleSize, @Nullable HandleBuilder maxHandleBuilder, @Nullable Double maxHandleSize, @Nullable Optional confirmSound ) { public RangeSliderStyle overriding(RangeSliderStyle other) { //noinspection OptionalAssignedToNull return new RangeSliderStyle( this.track != null ? this.track : other.track, this.rangeIndicator != null ? this.rangeIndicator : other.rangeIndicator, this.minHandleBuilder != null ? this.minHandleBuilder : other.minHandleBuilder, this.minHandleSize != null ? this.minHandleSize : other.minHandleSize, this.maxHandleBuilder != null ? this.maxHandleBuilder : other.maxHandleBuilder, this.maxHandleSize != null ? this.maxHandleSize : other.maxHandleSize, this.confirmSound != null ? this.confirmSound : other.confirmSound ); } public static final RangeSliderStyle DEFAULT = new RangeSliderStyle(null, null, null, null, null, null, null); @FunctionalInterface public interface HandleBuilder { Widget build(boolean active); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/slider/DefaultSliderStyle.java ================================================ package io.wispforest.owo.braid.widgets.slider.slider; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Builder; import io.wispforest.owo.braid.widgets.slider.SliderStyle; import org.jetbrains.annotations.Nullable; public class DefaultSliderStyle extends InheritedWidget { public final SliderStyle style; public DefaultSliderStyle(SliderStyle style, Widget child) { super(child); this.style = style; } public static Widget merge(SliderStyle style, Widget child) { return new Builder(context -> { var contextStyle = DefaultSliderStyle.maybeOf(context); return new DefaultSliderStyle(contextStyle != null ? style.overriding(contextStyle) : style, child); }); } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return !this.style.equals(((DefaultSliderStyle) newWidget).style); } public static @Nullable SliderStyle maybeOf(BuildContext context) { var widget = context.dependOnAncestor(DefaultSliderStyle.class); if (widget != null) { return widget.style; } else { return null; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/slider/MessageSlider.java ================================================ package io.wispforest.owo.braid.widgets.slider.slider; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.label.Label; import io.wispforest.owo.braid.widgets.label.LabelStyle; import io.wispforest.owo.braid.widgets.stack.Stack; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.Nullable; public class MessageSlider extends StatelessWidget { public final double value; public final @Nullable WidgetSetupCallback setupCallback; public final @Nullable SliderCallback onChanged; public final Component message; public MessageSlider( double value, Component message, @Nullable WidgetSetupCallback setupCallback, @Nullable SliderCallback onChanged ) { this.value = value; this.setupCallback = setupCallback; this.onChanged = onChanged; this.message = message; } public MessageSlider( double value, Component message, @Nullable WidgetSetupCallback setupCallback, boolean active, SliderCallback onChanged ) { this(value, message, setupCallback, active ? onChanged : null); } @Override public Widget build(BuildContext context) { return new Stack( new Slider( this.value, this.setupCallback, this.onChanged ), //TODO: abstract this styling? new Label( LabelStyle.SHADOW, false, this.message ) ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/slider/Slider.java ================================================ package io.wispforest.owo.braid.widgets.slider.slider; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.basic.*; import io.wispforest.owo.braid.widgets.slider.DefaultSliderHandle; import io.wispforest.owo.braid.widgets.slider.Incrementor; import io.wispforest.owo.braid.widgets.slider.SliderStyle; import io.wispforest.owo.braid.widgets.stack.Stack; import io.wispforest.owo.ui.component.ButtonComponent; import io.wispforest.owo.ui.util.UISounds; import net.minecraft.sounds.SoundEvents; import net.minecraft.util.Mth; import org.jetbrains.annotations.Nullable; import java.util.Objects; public class Slider extends StatefulWidget { public final double value; public final @Nullable SliderCallback onChanged; protected double min = 0; protected double max = 1; protected @Nullable Double step; protected SliderFunction function = SliderFunction.LINEAR; protected LayoutAxis axis = LayoutAxis.HORIZONTAL; protected @Nullable Double incrementStep = null; protected @Nullable SliderStyle style; public Slider( double value, @Nullable WidgetSetupCallback setupCallback, @Nullable SliderCallback onChanged ) { this.value = value; this.onChanged = onChanged; if (setupCallback != null) setupCallback.setup(this); } public Slider( double value, @Nullable WidgetSetupCallback setupCallback, boolean active, SliderCallback onChanged ) { this(value, setupCallback, active ? onChanged : null); } public Slider min(double min) { this.assertMutable(); this.min = min; return this; } public double min() { return this.min; } public Slider max(double max) { this.assertMutable(); this.max = max; return this; } public double max() { return this.max; } public Slider range(double min, double max) { this.assertMutable(); this.min = min; this.max = max; return this; } public Slider step(@Nullable Double step) { this.assertMutable(); this.step = step; return this; } public Slider step(double step) { this.assertMutable(); this.step = step; return this; } public @Nullable Double step() { return this.step; } public Slider function(SliderFunction sliderFunction) { this.assertMutable(); this.function = sliderFunction; return this; } public SliderFunction function() { return this.function; } public Slider axis(LayoutAxis axis) { this.assertMutable(); this.axis = axis; return this; } public Slider vertical() { return this.axis(LayoutAxis.VERTICAL); } public LayoutAxis axis() { return this.axis; } public Slider incrementStep(double incrementStep) { this.assertMutable(); this.incrementStep = incrementStep; return this; } public @Nullable Double incrementStep() { return this.incrementStep; } public Slider style(SliderStyle style) { this.assertMutable(); this.style = style; return this; } public @Nullable SliderStyle style() { return this.style; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { protected double dragValue = 0; protected boolean dragging = false; protected double normalizedValue; protected double incrementStep; protected CursorStyle draggingCursorStyle = null; protected double handleSize; @Override public Widget build(BuildContext context) { var widget = this.widget(); var effectiveStyle = widget.style != null ? widget.style : SliderStyle.getDefault(); if (DefaultSliderStyle.maybeOf(context) instanceof SliderStyle contextStyle) { effectiveStyle = effectiveStyle.overriding(contextStyle); } var disabled = widget.onChanged == null || ControlsOverride.controlsDisabled(context); var track = Objects.requireNonNullElse(effectiveStyle.track(), DEFAULT_TRACK); var handle = Objects.requireNonNullElse(effectiveStyle.handleBuilder(), DEFAULT_HANDLE_BUILDER).build(!disabled); this.handleSize = Objects.requireNonNullElse(effectiveStyle.handleSize(), DEFAULT_HANDLE_SIZE); //noinspection OptionalAssignedToNull var confirmSound = effectiveStyle.confirmSound() != null ? effectiveStyle.confirmSound().orElse(null) : SoundEvents.UI_BUTTON_CLICK.value(); this.normalizedValue = widget.function.normalize(widget.value, widget.min, widget.max); var trueMin = Math.min(widget.max, widget.min); var trueMax = Math.max(widget.max, widget.min); this.incrementStep = widget.incrementStep != null ? widget.function.normalize(widget.incrementStep, trueMin, trueMax) : widget.step != null ? widget.function.normalize(widget.step, trueMin, trueMax) : 0.01; this.draggingCursorStyle = null; return new LayoutBuilder((innerContext, constraints) -> { var size = constraints.maxFiniteOrMinSize(); var content = new Stack( widget.axis.choose(Alignment.LEFT, Alignment.TOP), new Sized(size, track), new Padding( widget.axis.chooseCompute( () -> Insets.left(Math.floor((size.width() - this.handleSize) * this.normalizedValue)), () -> Insets.top(Math.floor((size.height() - this.handleSize) * (1 - this.normalizedValue))) ), widget.axis.chooseCompute( () -> new Sized(this.handleSize, size.height(), handle), () -> new Sized(size.width(), this.handleSize, handle) ) ) ); return new Center( widget.onChanged == null || ControlsOverride.controlsDisabled(context) ? content : new Incrementor( widget.axis, increment -> this.applyValue(Mth.clamp(this.normalizedValue + this.incrementStep * increment, 0, 1)), new MouseArea( mouseArea -> mouseArea //TODO: decide what to do with buttons here .clickCallback((x, y, button, modifiers) -> { if (button != 0) return false; if (widget.axis == LayoutAxis.VERTICAL) y = constraints.maxFiniteOrMinOnAxis(widget.axis) - y; var initialDragValue = this.normalizedValue; if (!this.isInHandle(constraints, x, y)) initialDragValue = this.setAbsolute(constraints, x, y); this.dragValue = initialDragValue; this.dragging = true; return true; }) .dragCallback((x, y, dx, dy) -> this.move(constraints, dx, widget.axis == LayoutAxis.VERTICAL ? -dy : dy)) .dragEndCallback(() -> { this.dragging = false; if (confirmSound != null) { UISounds.play(confirmSound); } }) .cursorStyleSupplier((x, y) -> { //TODO: invert the y passed in here cuz its cringe atm if (!this.isInHandle(constraints, x, constraints.maxHeight() - y) && !this.dragging) return CursorStyle.HAND; if (this.draggingCursorStyle == null) this.draggingCursorStyle = CursorStyle.forDraggingAlong(widget.axis, context.instance().computeGlobalTransform()); return this.draggingCursorStyle; }), content ) ) ); }); } protected boolean isInHandle(Constraints constraints, double x, double y) { var axis = this.widget().axis; var trackLength = constraints.maxFiniteOrMinOnAxis(axis) - this.handleSize; var handleMin = this.normalizedValue * trackLength; var handleMax = handleMin + this.handleSize; var coordinate = axis.choose(x, y); return coordinate >= handleMin && coordinate <= handleMax; } protected void move(Constraints constraints, double dx, double dy) { this.dragValue += this.widget().axis.choose(dx, dy) / (constraints.maxFiniteOrMinOnAxis(this.widget().axis) - this.handleSize); this.applyValue(Mth.clamp(this.dragValue, 0, 1)); } protected double setAbsolute(Constraints constraints, double x, double y) { if (this.widget().onChanged == null) return this.normalizedValue; var axis = this.widget().axis; var newNormalizedValue = Mth.clamp((axis.choose(x, y) - this.handleSize / 2) / (constraints.maxFiniteOrMinOnAxis(axis) - this.handleSize), 0, 1); this.applyValue(newNormalizedValue); return newNormalizedValue; } protected void applyValue(double newNormalizedValue) { var widget = this.widget(); var step = widget.step; var newValue = widget.function.deNormalize(newNormalizedValue, widget.min, widget.max); this.widget().onChanged.accept(step != null ? Math.round(newValue / step) * step : newValue); } } // --- private static final Widget DEFAULT_TRACK = new Panel(ButtonComponent.DISABLED_TEXTURE); private static final SliderStyle.HandleBuilder DEFAULT_HANDLE_BUILDER = DefaultSliderHandle::new; private static final double DEFAULT_HANDLE_SIZE = 8.0; } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/slider/SliderCallback.java ================================================ package io.wispforest.owo.braid.widgets.slider.slider; @FunctionalInterface public interface SliderCallback { void accept(double newValue); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/slider/SliderFunction.java ================================================ package io.wispforest.owo.braid.widgets.slider.slider; import net.minecraft.util.Mth; import static net.minecraft.util.Mth.EPSILON; public interface SliderFunction { double normalize(double value, double min, double max); double deNormalize(double normalizedValue, double min, double max); SliderFunction LINEAR = new SliderFunction() { @Override public double normalize(double value, double min, double max) { return (value - min) / (max - min); } @Override public double deNormalize(double normalizedValue, double min, double max) { return min + normalizedValue * (max - min); } }; SliderFunction LOGARITHMIC = new SliderFunction() { @Override public double normalize(double value, double min, double max) { if (min <= 0) { var offset = EPSILON - min; min += offset; max += offset; value += offset; } value = Mth.clamp(value, min, max); var logMin = Math.log(min); var logMax = Math.log(max); if (logMin >= logMax) return (value - min) / (max - min); return (Math.log(value) - logMin) / (logMax - logMin); } @Override public double deNormalize(double normalizedValue, double min, double max) { if (min <= 0) { var offset = EPSILON - min; min += offset; max += offset; } var logMin = Math.log(min); var logMax = Math.log(max); var expValue = Math.exp(logMin + normalizedValue * (logMax - logMin)); if (min <= 0 && max > min) expValue -= (EPSILON - min); return expValue; } }; } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/xlyder/DefaultXlyderStyle.java ================================================ package io.wispforest.owo.braid.widgets.slider.xlyder; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.InheritedWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Builder; import io.wispforest.owo.braid.widgets.slider.SliderStyle; import org.jetbrains.annotations.Nullable; public class DefaultXlyderStyle extends InheritedWidget { public final SliderStyle style; public DefaultXlyderStyle(SliderStyle style, Widget child) { super(child); this.style = style; } public static Widget merge(SliderStyle style, Widget child) { return new Builder(context -> { var contextStyle = DefaultXlyderStyle.maybeOf(context); return new DefaultXlyderStyle(contextStyle != null ? style.overriding(contextStyle) : style, child); }); } @Override public boolean mustRebuildDependents(InheritedWidget newWidget) { return !this.style.equals(((DefaultXlyderStyle) newWidget).style); } public static @Nullable SliderStyle maybeOf(BuildContext context) { var widget = context.dependOnAncestor(DefaultXlyderStyle.class); if (widget != null) { return widget.style; } else { return null; } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/xlyder/MessageXlyder.java ================================================ package io.wispforest.owo.braid.widgets.slider.xlyder; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.widget.StatelessWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.label.Label; import io.wispforest.owo.braid.widgets.label.LabelStyle; import io.wispforest.owo.braid.widgets.stack.Stack; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.Nullable; import org.joml.Vector2d; import org.joml.Vector2dc; public class MessageXlyder extends StatelessWidget { public final Vector2dc value; public final @Nullable WidgetSetupCallback setupCallback; public final @Nullable XlyderCallback onChanged; public final Component message; public MessageXlyder( Vector2dc value, Component message, @Nullable WidgetSetupCallback setupCallback, @Nullable XlyderCallback onChanged ) { this.value = value; this.setupCallback = setupCallback; this.onChanged = onChanged; this.message = message; } public MessageXlyder( Vector2dc value, Component message, @Nullable WidgetSetupCallback setupCallback, boolean active, XlyderCallback onChanged ) { this(value, message, setupCallback, active ? onChanged : null); } public MessageXlyder( double x, double y, Component message, @Nullable WidgetSetupCallback setupCallback, @Nullable XlyderCallback onChanged ) { this(new Vector2d(x, y), message, setupCallback, onChanged); } public MessageXlyder( double x, double y, Component message, @Nullable WidgetSetupCallback setupCallback, boolean active, XlyderCallback onChanged ) { this(new Vector2d(x, y), message, setupCallback, active ? onChanged : null); } @Override public Widget build(BuildContext context) { return new Stack( new Xlyder( this.value, this.setupCallback, this.onChanged ), //TODO: abstract this styling? new Label( LabelStyle.SHADOW, false, this.message ) ); } @FunctionalInterface public interface XlyderMessageProvider { Component getMessage(double x, double y); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/xlyder/Xlyder.java ================================================ package io.wispforest.owo.braid.widgets.slider.xlyder; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.basic.*; import io.wispforest.owo.braid.widgets.slider.DefaultSliderHandle; import io.wispforest.owo.braid.widgets.slider.Incrementor; import io.wispforest.owo.braid.widgets.slider.SliderStyle; import io.wispforest.owo.braid.widgets.slider.slider.SliderFunction; import io.wispforest.owo.braid.widgets.stack.Stack; import io.wispforest.owo.ui.component.ButtonComponent; import io.wispforest.owo.ui.util.UISounds; import net.minecraft.sounds.SoundEvents; import net.minecraft.util.Mth; import org.jetbrains.annotations.Nullable; import org.joml.Vector2d; import org.joml.Vector2dc; import java.util.Objects; public class Xlyder extends StatefulWidget { protected final Vector2d min = new Vector2d(); protected final Vector2d max = new Vector2d(1); protected @Nullable Double xStep; protected @Nullable Double yStep; protected SliderFunction xSliderFunction = SliderFunction.LINEAR; protected SliderFunction ySliderFunction = SliderFunction.LINEAR; protected @Nullable Double xIncrementStep = null; protected @Nullable Double yIncrementStep = null; protected SliderStyle style; public final Vector2dc value; public final @Nullable XlyderCallback onChanged; public Xlyder( Vector2dc value, @Nullable WidgetSetupCallback setupCallback, @Nullable XlyderCallback onChanged ) { this.value = value; this.onChanged = onChanged; if (setupCallback != null) setupCallback.setup(this); } public Xlyder( Vector2dc value, @Nullable WidgetSetupCallback setupCallback, boolean active, XlyderCallback onChanged ) { this(value, setupCallback, active ? onChanged : null); } public Xlyder( double x, double y, @Nullable WidgetSetupCallback setupCallback, @Nullable XlyderCallback onChanged ) { this(new Vector2d(x, y), setupCallback, onChanged); } public Xlyder( double x, double y, @Nullable WidgetSetupCallback setupCallback, boolean active, XlyderCallback onChanged ) { this(new Vector2d(x, y), setupCallback, active ? onChanged : null); } public Xlyder min(Vector2d min) { this.assertMutable(); this.min.set(min); return this; } public Xlyder min(double minX, double minY) { this.assertMutable(); this.min.set(minX, minY); return this; } public Xlyder min(double min) { this.assertMutable(); this.min.set(min, min); return this; } public Vector2dc min() { return this.min; } public Xlyder minX(double minX) { this.assertMutable(); this.min.x = minX; return this; } public double minX() { return this.min.x; } public Xlyder minY(double minY) { this.assertMutable(); this.min.y = minY; return this; } public double minY() { return this.min.y; } public Xlyder max(Vector2d max) { this.assertMutable(); this.max.set(max); return this; } public Xlyder max(double maxX, double maxY) { this.assertMutable(); this.max.set(maxX, maxY); return this; } public Xlyder max(double max) { this.assertMutable(); this.max.set(max, max); return this; } public Vector2dc max() { return this.max; } public Xlyder maxX(double maxX) { this.assertMutable(); this.max.x = maxX; return this; } public double maxX() { return this.max.x; } public Xlyder maxY(double maxY) { this.assertMutable(); this.max.y = maxY; return this; } public double maxY() { return this.max.y; } public Xlyder range(Vector2d min, Vector2d max) { this.assertMutable(); this.min.set(min); this.max.set(max); return this; } public Xlyder range(double minX, double minY, double maxX, double maxY) { this.assertMutable(); this.min.set(minX, minY); this.max.set(maxX, maxY); return this; } public Xlyder range(double min, double max) { this.assertMutable(); this.min.set(min, min); this.max.set(max, max); return this; } public Xlyder rangeX(double minX, double maxX) { this.assertMutable(); this.min.x = minX; this.max.x = maxX; return this; } public Xlyder rangeY(double minY, double maxY) { this.assertMutable(); this.min.y = minY; this.max.y = maxY; return this; } public Xlyder step(@Nullable Double step) { this.assertMutable(); this.xStep = step; this.yStep = step; return this; } public Xlyder step(double step) { this.assertMutable(); this.xStep = step; this.yStep = step; return this; } public Xlyder stepX(@Nullable Double xStep) { this.assertMutable(); this.xStep = xStep; return this; } public Xlyder stepX(double xStep) { this.assertMutable(); this.xStep = xStep; return this; } public @Nullable Double stepX() { return this.xStep; } public Xlyder stepY(@Nullable Double yStep) { this.assertMutable(); this.yStep = yStep; return this; } public Xlyder stepY(double yStep) { this.assertMutable(); this.yStep = yStep; return this; } public @Nullable Double stepY() { return this.yStep; } public Xlyder sliderFunction(SliderFunction sliderFunction) { this.assertMutable(); this.xSliderFunction = sliderFunction; this.ySliderFunction = sliderFunction; return this; } public Xlyder sliderFunctionX(SliderFunction xSliderFunction) { this.assertMutable(); this.xSliderFunction = xSliderFunction; return this; } public SliderFunction sliderFunctionX() { return this.xSliderFunction; } public Xlyder sliderFunctionY(SliderFunction ySliderFunction) { this.assertMutable(); this.ySliderFunction = ySliderFunction; return this; } public SliderFunction sliderFunctionY() { return this.ySliderFunction; } public Xlyder incrementStep(@Nullable Double incrementStep) { this.assertMutable(); this.xIncrementStep = incrementStep; this.yIncrementStep = incrementStep; return this; } public Xlyder incrementStep(double incrementStep) { this.assertMutable(); this.xIncrementStep = incrementStep; this.yIncrementStep = incrementStep; return this; } public Xlyder incrementStepX(@Nullable Double xIncrementStep) { this.assertMutable(); this.xIncrementStep = xIncrementStep; return this; } public Xlyder incrementStepX(double xIncrementStep) { this.assertMutable(); this.xIncrementStep = xIncrementStep; return this; } public @Nullable Double incrementStepX() { return this.xIncrementStep; } public Xlyder incrementStepY(@Nullable Double yIncrementStep) { this.assertMutable(); this.yIncrementStep = yIncrementStep; return this; } public Xlyder incrementStepY(double yIncrementStep) { this.assertMutable(); this.yIncrementStep = yIncrementStep; return this; } public @Nullable Double incrementStepY() { return this.yIncrementStep; } public Xlyder style(SliderStyle style) { this.assertMutable(); this.style = style; return this; } public @Nullable SliderStyle style() { return this.style; } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { protected final Vector2d dragValue = new Vector2d(); protected boolean dragging = false; protected Vector2dc normalizedValue; protected Vector2dc incrementStep; protected Size handleSize; @Override public void init() { var widget = this.widget(); var trueMinX = Math.min(widget.min.x, widget.max.x); var trueMaxX = Math.max(widget.min.x, widget.max.x); var trueMinY = Math.min(widget.min.y, widget.max.y); var trueMaxY = Math.max(widget.min.y, widget.max.y); this.incrementStep = new Vector2d( widget.xIncrementStep != null ? widget.xSliderFunction.normalize(widget.xIncrementStep, trueMinX, trueMaxX) : widget.xStep != null ? widget.xSliderFunction.normalize(widget.xStep, trueMinX, trueMaxX) : 0.01, widget.yIncrementStep != null ? widget.ySliderFunction.normalize(widget.yIncrementStep, trueMinY, trueMaxY) : widget.yStep != null ? widget.ySliderFunction.normalize(widget.yStep, trueMinY, trueMaxY) : 0.01 ); } @Override public Widget build(BuildContext context) { var widget = this.widget(); var effectiveStyle = widget.style != null ? widget.style : SliderStyle.getDefault(); if (DefaultXlyderStyle.maybeOf(context) instanceof SliderStyle contextStyle) { effectiveStyle = effectiveStyle.overriding(contextStyle); } var disabled = widget.onChanged == null || ControlsOverride.controlsDisabled(context); var track = Objects.requireNonNullElse(effectiveStyle.track(), DEFAULT_TRACK); var handle = Objects.requireNonNullElse(effectiveStyle.handleBuilder(), DEFAULT_HANDLE_BUILDER).build(!disabled); this.handleSize = Objects.requireNonNullElse(effectiveStyle.handleSize(), DEFAULT_HANDLE_SIZE); //noinspection OptionalAssignedToNull var confirmSound = effectiveStyle.confirmSound() != null ? effectiveStyle.confirmSound().orElse(null) : SoundEvents.UI_BUTTON_CLICK.value(); this.normalizedValue = new Vector2d( widget.xSliderFunction.normalize(widget.value.x(), widget.min.x, widget.max.x), widget.ySliderFunction.normalize(widget.value.y(), widget.min.y, widget.max.y) ); return new LayoutBuilder((innerContext, constraints) -> { var content = new Stack( Alignment.TOP_LEFT, new Sized(constraints.maxWidth(), constraints.maxHeight(), track), new Padding( Insets.left(Math.floor((constraints.maxWidth() - this.handleSize.width()) * this.normalizedValue.x())) .withTop(Math.floor((constraints.maxHeight() - this.handleSize.height()) * (1 - this.normalizedValue.y()))), new Sized(this.handleSize, handle) ) ); return new Center( widget.onChanged == null || ControlsOverride.controlsDisabled(context) ? content : new Incrementor( xIncrement -> this.applyValue(Mth.clamp(this.normalizedValue.x() + this.incrementStep.x() * xIncrement, 0, 1), null), yIncrement -> this.applyValue(null, Mth.clamp(this.normalizedValue.y() + this.incrementStep.y() * yIncrement, 0, 1)), new MouseArea( mouseArea -> mouseArea //TODO: decide what to do with buttons here .clickCallback((x, y, button, modifiers) -> { if (button != 0) return false; y = constraints.maxHeight() - y; Vector2dc initialDragValue = new Vector2d(this.normalizedValue); if (!this.isInHandle(constraints, x, y)) initialDragValue = this.setAbsolute(constraints, x, y); this.dragValue.set(initialDragValue); this.dragging = true; return true; }) .dragCallback((x, y, dx, dy) -> this.move(constraints, dx, -dy)) .dragEndCallback(() -> { this.dragging = false; if (confirmSound != null) { UISounds.play(confirmSound); } }) //TODO: invert the y passed here cuz it cringe atm .cursorStyleSupplier((x, y) -> (!this.isInHandle(constraints, x, constraints.maxHeight() - y) && !this.dragging) ? CursorStyle.HAND : CursorStyle.MOVE), content ) ) ); }); } protected boolean isInHandle(Constraints constraints, double x, double y) { var trackWidth = constraints.maxWidth() - this.handleSize.width(); var trackHeight = constraints.maxHeight() - this.handleSize.height(); var handleMinX = this.normalizedValue.x() * trackWidth; var handleMinY = this.normalizedValue.y() * trackHeight; var handleMaxX = handleMinX + this.handleSize.width(); var handleMaxY = handleMinY + this.handleSize.height(); return x >= handleMinX && x <= handleMaxX && y >= handleMinY && y <= handleMaxY; } protected void move(Constraints constraints, double dx, double dy) { this.dragValue.add( dx / (constraints.maxWidth() - this.handleSize.width()), dy / (constraints.maxHeight() - this.handleSize.height()) ); this.applyValue( Mth.clamp(this.dragValue.x, 0, 1), Mth.clamp(this.dragValue.y, 0, 1) ); } protected Vector2dc setAbsolute(Constraints constraints, double x, double y) { if (this.widget().onChanged == null) return this.normalizedValue; var handleSize = this.handleSize; var newNormalizedX = Mth.clamp((x - (handleSize.width() / 2)) / (constraints.maxWidth() - handleSize.width()), 0, 1); var newNormalizedY = Mth.clamp((y - (handleSize.height() / 2)) / (constraints.maxHeight() - handleSize.height()), 0, 1); this.applyValue(newNormalizedX, newNormalizedY); return new Vector2d(newNormalizedX, newNormalizedY); } protected void applyValue(@Nullable Double newNormalizedX, @Nullable Double newNormalizedY) { if (newNormalizedX == null && newNormalizedY == null) return; var widget = this.widget(); double newX = widget.value.x(); double newY = widget.value.y(); if (newNormalizedX != null) newX = widget.xSliderFunction.deNormalize(newNormalizedX, widget.min.x, widget.max.x); if (newNormalizedY != null) newY = widget.ySliderFunction.deNormalize(newNormalizedY, widget.min.y, widget.max.y); widget.onChanged.accept( widget.xStep != null ? Math.round(newX / widget.xStep) * widget.xStep : newX, widget.yStep != null ? Math.round(newY / widget.yStep) * widget.yStep : newY ); } } // --- private static final Widget DEFAULT_TRACK = new Panel(ButtonComponent.DISABLED_TEXTURE); private static final SliderStyle.HandleBuilder DEFAULT_HANDLE_BUILDER = DefaultSliderHandle::new; private static final Size DEFAULT_HANDLE_SIZE = Size.square(8.0); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/slider/xlyder/XlyderCallback.java ================================================ package io.wispforest.owo.braid.widgets.slider.xlyder; @FunctionalInterface public interface XlyderCallback { void accept(double newX, double newY); } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/splitpane/MultiSplitPane.java ================================================ package io.wispforest.owo.braid.widgets.splitpane; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.Key; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Box; import io.wispforest.owo.braid.widgets.basic.Constrain; import io.wispforest.owo.braid.widgets.basic.LayoutBuilder; import io.wispforest.owo.braid.widgets.basic.MouseArea; import io.wispforest.owo.braid.widgets.flex.CrossAxisAlignment; import io.wispforest.owo.braid.widgets.flex.Flex; import io.wispforest.owo.braid.widgets.flex.Flexible; import io.wispforest.owo.braid.widgets.flex.MainAxisAlignment; import net.minecraft.util.Mth; import java.util.ArrayList; import java.util.List; public class MultiSplitPane extends StatefulWidget { public final LayoutAxis mainAxis; public final MainAxisAlignment mainAxisAlignment; public final CrossAxisAlignment crossAxisAlignment; public final List children; public MultiSplitPane( LayoutAxis mainAxis, MainAxisAlignment mainAxisAlignment, CrossAxisAlignment crossAxisAlignment, List children ) { this.mainAxis = mainAxis; this.mainAxisAlignment = mainAxisAlignment; this.crossAxisAlignment = crossAxisAlignment; this.children = children; } @Override public WidgetState createState() { return new MultiSplitPaneState(); } } class MultiSplitPaneState extends WidgetState { private List splits = null; @Override public Widget build(BuildContext context) { return new LayoutBuilder((innerContext, constraints) -> { var axis = this.widget().mainAxis; var children = this.widget().children; var maxSize = constraints.maxOnAxis(axis) - ((children.size() - 2) * 2); var splitSize = maxSize / (children.size()); if (this.splits == null) { this.splits = new ArrayList<>(); for (int i = 0; i < children.size() - 1; i++) this.splits.add(splitSize * (i + 1)); } var widgets = new ArrayList(); for (int i = 0; i < children.size(); i++) { var child = children.get(i); // var split = MathHelper.clamp(this.splits.get(i), .1, .9) * maxSize; var min = i == 0 ? 0 : this.splits.get(i - 1); var max = i == children.size() - 1 ? maxSize : this.splits.get(i); var childConstraints = Constraints.tight(axis.createSize(max - min, constraints.maxOnAxis(axis.opposite()))); widgets.add(new Constrain(childConstraints, child).key(child.key())); if (i < children.size() - 1) { int finalI = i; widgets.add(new Flexible( new MouseArea( widget -> widget .dragCallback((x, y, dx, dy) -> setState(() -> { this.splits.set(finalI, this.splits.get(finalI) + axis.choose(dx, dy)); })) .dragEndCallback(() -> { this.splits.set(finalI, Mth.clamp(this.splits.get(finalI), .1 * maxSize, .9 * maxSize)); }) .cursorStyleSupplier((x, y) -> axis.choose(CursorStyle.HORIZONTAL_RESIZE, CursorStyle.VERTICAL_RESIZE)), new Box(Color.WHITE) ) ).key(Key.of("splitter-" + i))); } } return new Flex( axis, this.widget().mainAxisAlignment, this.widget().crossAxisAlignment, null, widgets ); // var split = Math.floor(MathHelper.clamp(this.splitCoordinate, .1 * maxSize, .9 * maxSize)); // // var firstConstraints = Constraints.tight(axis.createSize(split, constraints.maxOnAxis(axis.opposite()))); // var secondConstraints = Constraints.tight(axis.createSize(maxSize - split, constraints.maxOnAxis(axis.opposite()))); // // return new Flex( // axis, // MainAxisAlignment.START, // CrossAxisAlignment.START, // new Constrain(firstConstraints, this.widget().firstChild).key(this.widget().firstChild.key()), // new Flexible( // new MouseArea( // widget -> widget // .dragCallback((x, y, dx, dy) -> setState(() -> { // this.splitCoordinate = this.splitCoordinate + axis.choose(dx, dy); // })) // .dragEndCallback(() -> { // this.splitCoordinate = MathHelper.clamp(this.splitCoordinate, .1 * maxSize, .9 * maxSize); // }) // .cursorStyleSupplier((x, y) -> axis.choose(CursorStyle.HORIZONTAL_RESIZE, CursorStyle.VERTICAL_RESIZE)), // new Box(Color.WHITE) // ) // ).key(Key.of("splitter")), // new Constrain(secondConstraints, this.widget().secondChild).key(this.widget().secondChild.key()) // ); }); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/splitpane/SplitPane.java ================================================ package io.wispforest.owo.braid.widgets.splitpane; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.LayoutAxis; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.Key; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Box; import io.wispforest.owo.braid.widgets.basic.Constrain; import io.wispforest.owo.braid.widgets.basic.LayoutBuilder; import io.wispforest.owo.braid.widgets.basic.MouseArea; import io.wispforest.owo.braid.widgets.flex.CrossAxisAlignment; import io.wispforest.owo.braid.widgets.flex.Flex; import io.wispforest.owo.braid.widgets.flex.Flexible; import io.wispforest.owo.braid.widgets.flex.MainAxisAlignment; import net.minecraft.util.Mth; public class SplitPane extends StatefulWidget { public final Widget firstChild; public final Widget secondChild; public final LayoutAxis axis; public SplitPane(Widget firstChild, Widget secondChild, LayoutAxis axis) { this.firstChild = firstChild; this.secondChild = secondChild; this.axis = axis; } @Override public WidgetState createState() { return new SplitPaneState(); } } class SplitPaneState extends WidgetState { private double splitCoordinate = -1; @Override public Widget build(BuildContext context) { return new LayoutBuilder((innerContext, constraints) -> { var axis = this.widget().axis; var maxSize = constraints.maxOnAxis(axis) - 2; if (this.splitCoordinate == -1) this.splitCoordinate = .5 * maxSize; var split = Math.floor(Mth.clamp(this.splitCoordinate, .1 * maxSize, .9 * maxSize)); var firstConstraints = Constraints.tight(axis.createSize(split, constraints.maxOnAxis(axis.opposite()))); var secondConstraints = Constraints.tight(axis.createSize(maxSize - split, constraints.maxOnAxis(axis.opposite()))); return new Flex( axis, MainAxisAlignment.START, CrossAxisAlignment.START, new Constrain(firstConstraints, this.widget().firstChild).key(this.widget().firstChild.key()), new Flexible( new MouseArea( widget -> widget .dragCallback((x, y, dx, dy) -> setState(() -> { this.splitCoordinate = this.splitCoordinate + axis.choose(dx, dy); System.out.println("Split coordinate: " + this.splitCoordinate); })) .dragEndCallback(() -> { this.splitCoordinate = Mth.clamp(this.splitCoordinate, .1 * maxSize, .9 * maxSize); }) .cursorStyleSupplier((x, y) -> axis.choose(CursorStyle.HORIZONTAL_RESIZE, CursorStyle.VERTICAL_RESIZE)), new Box(Color.WHITE) ) ).key(Key.of("splitter")), new Constrain(secondConstraints, this.widget().secondChild).key(this.widget().secondChild.key()) ); }); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/stack/Stack.java ================================================ package io.wispforest.owo.braid.widgets.stack; import com.google.common.collect.Iterables; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.BraidUtils; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.instance.MultiChildWidgetInstance; import io.wispforest.owo.braid.framework.widget.MultiChildInstanceWidget; import io.wispforest.owo.braid.framework.widget.Widget; import java.util.Arrays; import java.util.List; import java.util.OptionalDouble; public class Stack extends MultiChildInstanceWidget { public final Alignment alignment; public Stack(Alignment alignment, List children) { super(children); this.alignment = alignment; } public Stack(List children) { this(Alignment.CENTER, children); } public Stack(Alignment alignment, Widget... children) { this(alignment, Arrays.asList(children)); } public Stack(Widget... children) { this(Alignment.CENTER, children); } @Override public MultiChildWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends MultiChildWidgetInstance { public Instance(Stack widget) { super(widget); } @Override public void setWidget(Stack widget) { if (this.widget.alignment == widget.alignment) return; super.setWidget(widget); this.markNeedsLayout(); } @Override protected void doLayout(Constraints constraints) { var sizingBase = this.children.stream().filter(child -> child.parentData == StackParentData.INSTANCE).findFirst().orElse(null); Size selfSize; if (sizingBase != null) { selfSize = sizingBase.layout(constraints); var childConstraints = Constraints.tight(selfSize); for (var child : Iterables.filter(this.children, child -> child != sizingBase)) { child.layout(childConstraints); } } else { selfSize = BraidUtils.fold(this.children, Size.zero(), (size, child) -> Size.max(size, child.layout(constraints))); } for (var child : this.children) { child.transform.setX( this.widget.alignment.alignHorizontal(selfSize.width(), child.transform.width()) ); child.transform.setY( this.widget.alignment.alignVertical(selfSize.height(), child.transform.height()) ); } this.transform.setSize(selfSize); } @Override protected double measureIntrinsicWidth(double height) { return BraidUtils.fold( this.children, 0.0, (width, child) -> Math.max(child.getIntrinsicWidth(height), width) ); } @Override protected double measureIntrinsicHeight(double width) { return BraidUtils.fold( this.children, 0.0, (height, child) -> Math.max(child.getIntrinsicHeight(width), height) ); } @Override protected OptionalDouble measureBaselineOffset() { return this.computeHighestBaselineOffset(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/stack/StackBase.java ================================================ package io.wispforest.owo.braid.widgets.stack; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.VisitorWidget; public class StackBase extends VisitorWidget { public StackBase(Widget child) { super(child); } private static final Visitor VISITOR = (widget, instance) -> { if (instance.parentData != StackParentData.INSTANCE) { instance.parentData = StackParentData.INSTANCE; instance.markNeedsLayout(); } }; @Override public Proxy proxy() { return new VisitorWidget.Proxy<>(this, VISITOR); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/stack/StackParentData.java ================================================ package io.wispforest.owo.braid.widgets.stack; public enum StackParentData { INSTANCE; } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/CopyTextIntent.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.widgets.intents.Intent; public record CopyTextIntent(boolean delete) implements Intent {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/DeleteLineIntent.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.widgets.intents.Intent; public enum DeleteLineIntent implements Intent { INSTANCE } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/DeleteTextIntent.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.widgets.intents.Intent; public record DeleteTextIntent(boolean forwards, boolean entireWord) implements Intent {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/EditableText.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.core.Aabb2d; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.basic.Builder; import io.wispforest.owo.braid.widgets.focus.Focusable; import io.wispforest.owo.braid.widgets.intents.Action; import io.wispforest.owo.braid.widgets.intents.Actions; import io.wispforest.owo.braid.widgets.intents.Intent; import io.wispforest.owo.braid.widgets.scroll.ScrollAnimationSettings; import io.wispforest.owo.braid.widgets.scroll.ScrollController; import io.wispforest.owo.braid.widgets.scroll.Scrollable; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class EditableText extends StatefulWidget { public final TextEditingController controller; protected boolean softWrap = true; protected boolean autoFocus = false; protected List formatters = new ArrayList<>(); protected Style baseStyle = Style.EMPTY; protected Component suggestion = Component.empty(); protected boolean textShadow = false; protected boolean suggestionIsPlaceholder = false; public EditableText( TextEditingController controller, WidgetSetupCallback setupCallback ) { this.controller = controller; setupCallback.setup(this); } public EditableText softWrap(boolean softWrap) { this.assertMutable(); this.softWrap = softWrap; return this; } public boolean softWrap() { return this.softWrap; } public EditableText autoFocus(boolean autoFocus) { this.assertMutable(); this.autoFocus = autoFocus; return this; } public boolean autoFocus() { return this.autoFocus; } public EditableText formatter(TextInput.Formatter formatter) { this.assertMutable(); this.formatters.add(formatter); return this; } public EditableText formatters(List formatters) { this.assertMutable(); this.formatters = formatters; return this; } public List formatters() { return this.formatters; } public EditableText baseStyle(Style baseStyle) { this.assertMutable(); this.baseStyle = baseStyle; return this; } public Style baseStyle() { return this.baseStyle; } public EditableText suggestion(Component suggestion) { this.assertMutable(); this.suggestion = suggestion; return this; } public Component suggestion() { return this.suggestion; } public EditableText placeholder(Component placeholder) { this.assertMutable(); this.suggestionIsPlaceholder = true; return this.suggestion(placeholder); } public EditableText textShadow(boolean shadow) { this.assertMutable(); this.textShadow = shadow; return this; } public boolean textShadow() { return this.textShadow; } public EditableText singleLine() { return this .softWrap(false) .formatter(PatternFormatter.NO_NEWLINES); } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private final Runnable listener = this::listenerCallback; private static final Duration CURSOR_BLINK_INTERVAL = Duration.ofMillis(300); private boolean showCursor = false; private boolean focused = false; private long blinkCallbackId = -1; private final ScrollController horizontalController = new ScrollController(this); private final ScrollController verticalController = new ScrollController(this); private BuildContext inputContext; private final Map, Action> actions = new HashMap<>(); @Override public void init() { this.widget().controller.addListener(this.listener); this.actions.put( InsertNewlineIntent.class, Action.callback((actionCtx, intent) -> this.instance().insert("\n")) ); this.actions.put( InsertTabIntent.class, Action.callback((actionCtx, intent) -> this.instance().insert(" ")) ); this.actions.put( DeleteTextIntent.class, Action.callback((actionCtx, intent) -> this.instance().deleteText(intent)) ); this.actions.put( DeleteLineIntent.class, Action.callback((actionCtx, intent) -> this.instance().deleteLine()) ); this.actions.put( MoveCursorIntent.class, Action.callback((actionCtx, intent) -> this.instance().moveCursor(intent)) ); this.actions.put( TeleportCursorIntent.class, Action.callback((actionCtx, intent) -> this.instance().teleportCursor(intent)) ); this.actions.put( SelectAllIntent.class, Action.callback((actionCtx, intent) -> this.instance().selectAllText()) ); this.actions.put( CopyTextIntent.class, Action.callback((actionCtx, intent) -> this.instance().copyToClipboard(intent)) ); this.actions.put( PasteTextIntent.class, Action.callback((actionCtx, intent) -> this.instance().pasteFromClipboard()) ); } @Override public void didUpdateWidget(EditableText oldWidget) { if (this.widget().controller != oldWidget.controller) { oldWidget.controller.removeListener(this.listener); this.widget().controller.addListener(this.listener); } } @Override public void dispose() { this.widget().controller.removeListener(this.listener); } private void listenerCallback() { this.schedulePostLayoutCallback(() -> { var inputInstance = (TextInput.Instance) this.inputContext.instance(); var cursorPos = inputInstance.cursorPosition(); var lineHeight = inputInstance.host().client().font.lineHeight; Scrollable.revealAabb( this.inputContext, new Aabb2d( cursorPos.x, cursorPos.y - lineHeight, 2, lineHeight ) ); }); if (this.focused) { this.restartBlinking(); } } private void restartBlinking() { if (this.blinkCallbackId != -1) { this.cancelDelayedCallback(this.blinkCallbackId); this.blinkCallbackId = -1; } this.setState(() -> this.showCursor = true); this.blinkCallbackId = this.scheduleDelayedCallback(CURSOR_BLINK_INTERVAL, this::blink); } private void stopBlinking() { if (this.blinkCallbackId != -1) { this.cancelDelayedCallback(this.blinkCallbackId); this.blinkCallbackId = -1; } this.setState(() -> this.showCursor = false); } private void blink() { this.setState(() -> this.showCursor = !this.showCursor); this.blinkCallbackId = this.scheduleDelayedCallback(CURSOR_BLINK_INTERVAL, this::blink); } private TextInput.Instance instance() { return (TextInput.Instance) this.inputContext.instance(); } @Override public Widget build(BuildContext context) { var widget = this.widget(); return new Focusable( focusable -> focusable .focusGainedCallback(() -> { this.focused = true; this.restartBlinking(); }) .focusLostCallback(() -> { this.focused = false; this.stopBlinking(); }) .charCallback((charCode, modifiers) -> this.instance().onChar(charCode)) .skipTraversal(true), new Actions( actions -> actions .autoFocus(widget.autoFocus) .actions(this.actions), new Scrollable( true, true, this.horizontalController, this.verticalController, ScrollAnimationSettings.NO_ANIMATION, new Builder(inputContext -> { this.inputContext = inputContext; return new TextInput( widget.controller, this.showCursor, widget.softWrap, widget.formatters, widget.baseStyle, widget.textShadow, !widget.suggestionIsPlaceholder || widget.controller.value().text().isEmpty() ? widget.suggestion : Component.empty() ); }) ) ) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/InsertNewlineIntent.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.widgets.intents.Intent; public enum InsertNewlineIntent implements Intent { INSTANCE } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/InsertTabIntent.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.widgets.intents.Intent; public enum InsertTabIntent implements Intent { INSTANCE; } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/MaxLengthFormatter.java ================================================ package io.wispforest.owo.braid.widgets.textinput; public record MaxLengthFormatter(int maxChars) implements TextInput.Formatter { @Override public TextEditingValue format(TextEditingValue previousState, TextEditingValue newState) { if (newState.text().length() <= this.maxChars) { return newState; } else if (previousState.text().length() >= this.maxChars) { return previousState; } return new TextEditingValue( newState.text().substring(0, this.maxChars), newState.selection().upper() > this.maxChars ? TextSelection.collapsed(this.maxChars) : newState.selection() ); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/MoveCursorIntent.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.widgets.intents.Intent; public record MoveCursorIntent(Direction direction, boolean skipWord, boolean selecting) implements Intent { public enum Direction { UP, DOWN, LEFT, RIGHT } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/PasteTextIntent.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.widgets.intents.Intent; public enum PasteTextIntent implements Intent { INSTANCE } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/PatternFormatter.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import net.minecraft.util.Mth; import java.util.regex.Pattern; public record PatternFormatter(Pattern pattern, String replacement, boolean allow) implements TextInput.Formatter { public static PatternFormatter allow(Pattern pattern) { return allow(pattern, ""); } public static PatternFormatter allow(Pattern pattern, String replacement) { return new PatternFormatter(pattern, replacement, true); } public static PatternFormatter deny(Pattern pattern) { return deny(pattern, ""); } public static PatternFormatter deny(Pattern pattern, String replacement) { return new PatternFormatter(pattern, replacement, false); } @Override public TextEditingValue format(TextEditingValue previousState, TextEditingValue newState) { var state = new FormatState(newState); var lastRegionEnd = 0; for (var match : this.pattern.matcher(newState.text()).results().toList()) { this.replaceRegion(lastRegionEnd, match.start(), this.allow, newState.text(), state); this.replaceRegion(match.start(), match.end(), !this.allow, newState.text(), state); lastRegionEnd = match.end(); } this.replaceRegion(lastRegionEnd, newState.text().length(), this.allow, newState.text(), state); return new TextEditingValue( state.builder.toString(), new TextSelection( state.selectionStart, state.selectionEnd ) ); } private void replaceRegion(int start, int end, boolean regionIsDenied, String input, FormatState state) { var replacement = regionIsDenied ? (start != end ? this.replacement : "") : input.substring(start, end); state.builder.append(replacement); if (replacement.length() == end - start) { return; } if (state.newValue.selection().start() > start) { var startInRegion = Mth.clamp(state.newValue.selection().start(), start, end) - start; state.selectionStart += replacement.length() - startInRegion; } if (state.newValue.selection().end() > start) { var endInRegion = Mth.clamp(state.newValue.selection().end(), start, end) - start; state.selectionEnd += replacement.length() - endInRegion; } } private static final Pattern NEWLINE_PATTERN = Pattern.compile("\n|\r\n"); public static final PatternFormatter NO_NEWLINES = PatternFormatter.deny(NEWLINE_PATTERN); private static class FormatState { public final TextEditingValue newValue; public final StringBuilder builder = new StringBuilder(); public int selectionStart; public int selectionEnd; public FormatState(TextEditingValue newValue) { this.newValue = newValue; this.selectionStart = newValue.selection().start(); this.selectionEnd = newValue.selection().end(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/SelectAllIntent.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.widgets.intents.Intent; public enum SelectAllIntent implements Intent { INSTANCE } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/TeleportCursorIntent.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.widgets.intents.Intent; public record TeleportCursorIntent(boolean toStart, boolean selecting) implements Intent {} ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/TextBox.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.framework.widget.WidgetSetupCallback; import io.wispforest.owo.braid.widgets.basic.Box; import io.wispforest.owo.braid.widgets.basic.Padding; import io.wispforest.owo.braid.widgets.focus.Focusable; import net.minecraft.network.chat.Style; import net.minecraft.util.CommonColors; public class TextBox extends StatefulWidget { public final TextEditingController controller; private final EditableText editableText; public TextBox( TextEditingController controller, WidgetSetupCallback setupCallback ) { this.controller = controller; this.editableText = new EditableText( controller, widget -> { setupCallback.setup(widget); widget.suggestion(widget.suggestion().copy().withStyle(style -> style.applyTo(Style.EMPTY.withColor(CommonColors.GRAY)))); } ); } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private boolean focused = false; @Override public Widget build(BuildContext context) { return new Box( //TODO: use panel instead of box here this.focused ? Color.WHITE : new Color(CommonColors.LIGHT_GRAY), new Focusable( widget -> widget .focusGainedCallback(() -> this.setState(() -> this.focused = true)) .focusLostCallback(() -> this.setState(() -> this.focused = false)) .skipTraversal(true), new Padding( Insets.all(1), new Box( Color.BLACK, new Padding( Insets.all(2), this.widget().editableText ) ) ) ) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/TextEditingController.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import io.wispforest.owo.braid.core.ListenableValue; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; public class TextEditingController extends ListenableValue { public TextEditingController(String text, TextSelection selection) { super(new TextEditingValue(text, selection)); } public TextEditingController(String text) { this(text, TextSelection.collapsed(text.length())); } public TextEditingController() { this(""); } public Component createTextForRendering(Style baseStyle) { return Component.literal(this.value().text()).withStyle(style -> baseStyle.applyTo(baseStyle)); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/TextEditingValue.java ================================================ package io.wispforest.owo.braid.widgets.textinput; public record TextEditingValue(String text, TextSelection selection) { public TextEditingValue withText(String text) { return new TextEditingValue(text, this.selection); } public TextEditingValue withSelection(TextSelection selection) { return new TextEditingValue(this.text, selection); } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/TextInput.java ================================================ package io.wispforest.owo.braid.widgets.textinput; import com.google.common.base.Preconditions; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.core.*; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.instance.LeafWidgetInstance; import io.wispforest.owo.braid.framework.instance.MouseListener; import io.wispforest.owo.braid.framework.widget.LeafInstanceWidget; import io.wispforest.owo.ui.core.Color; import io.wispforest.owo.ui.core.OwoUIGraphics; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.util.CommonColors; import net.minecraft.util.FormattedCharSequence; import net.minecraft.util.Mth; import net.minecraft.util.StringUtil; import org.jetbrains.annotations.Nullable; import org.joml.Vector2d; import java.time.Duration; import java.time.Instant; import java.util.*; public class TextInput extends LeafInstanceWidget { public final TextEditingController controller; public final boolean showCursor; public final boolean softWrap; public final List formatters; public final Style baseStyle; public final boolean textShadow; public final Component suggestion; public TextInput(TextEditingController controller, boolean showCursor, boolean softWrap, List formatters, Style baseStyle, boolean textShadow, @Nullable Component suggestion) { this.controller = controller; this.showCursor = showCursor; this.softWrap = softWrap; this.formatters = formatters; this.baseStyle = baseStyle; this.textShadow = textShadow; this.suggestion = suggestion == null ? Component.empty() : suggestion; } @Override public LeafWidgetInstance instantiate() { return new Instance(this); } @FunctionalInterface public interface Formatter { TextEditingValue format(TextEditingValue previousState, TextEditingValue newState); } public static class Instance extends LeafWidgetInstance implements MouseListener { protected TextEditingValue lastValue; protected TextEditingValue value; protected CursorLocation cursorLocation; protected TextLayout.EditMetrics metrics = null; protected List renderLines = List.of(); public Instance(TextInput widget) { super(widget); this.lastValue = this.value = widget.controller.value(); } public Vector2d cursorPosition() { return this.coordinatesAtCharIdx(this.value.selection().end()); } public TextLayout.LineMetrics currentLine() { return this.metrics.lineMetrics().get(this.cursorLocation.line); } @Override public void setWidget(TextInput widget) { if (!(this.lastValue.equals(widget.controller.value()) && this.widget.softWrap == widget.softWrap && this.widget.baseStyle.equals(widget.baseStyle) && this.widget.suggestion.equals(widget.suggestion))) { this.lastValue = this.value = widget.controller.value(); this.markNeedsLayout(); } super.setWidget(widget); } @Override protected void doLayout(Constraints constraints) { var maxWidth = (int) (constraints.hasBoundedWidth() ? constraints.maxWidth() : constraints.minWidth()); var wrapWidth = this.widget.softWrap ? maxWidth - 2 : Integer.MAX_VALUE; this.metrics = TextLayout.measure( this.host().client().font, this.value.text() + this.widget.suggestion.getString(), this.widget.baseStyle, wrapWidth ); this.renderLines = new ArrayList<>(this.host().client().font.split( this.widget.controller.createTextForRendering(this.widget.baseStyle).copy().append(this.widget.suggestion), wrapWidth )); var size = Size.of( this.metrics.width() + 1, this.metrics.height() ).constrained(constraints); this.transform.setSize(size); var newLineIdx = this.lineIdxAtCharIdx(this.value.selection().end()); this.cursorLocation = new CursorLocation(newLineIdx, this.value.selection().end() - this.metrics.lineMetrics().get(newLineIdx).beginIdx()); } @Override protected double measureIntrinsicWidth(double height) { return TextLayout.measure( this.host().client().font, this.value.text(), this.widget.baseStyle, Integer.MAX_VALUE ).width(); } @Override protected double measureIntrinsicHeight(double width) { return TextLayout.measure( this.host().client().font, this.value.text(), this.widget.baseStyle, this.widget.softWrap ? (int) width : Integer.MAX_VALUE ).height(); } @Override protected OptionalDouble measureBaselineOffset() { return OptionalDouble.of(this.host().client().font.lineHeight - 2); } private void drawSelection(OwoUIGraphics ctx, double startX, double endX, double lineBaseY) { var height = this.host().client().font.lineHeight; ctx.push(); ctx.translate(startX, lineBaseY - height); var width = endX - startX; ctx.fill(RenderPipelines.GUI_TEXT_HIGHLIGHT, 0, 0, (int) width, height, CommonColors.BLUE); ctx.pop(); } @Override public void draw(BraidGraphics graphics) { var font = this.host().client().font; for (int lineIdx = 0; lineIdx < this.renderLines.size(); lineIdx++) { graphics.drawString( font, this.renderLines.get(lineIdx), 0, lineIdx * font.lineHeight, Color.WHITE.argb(), this.widget.textShadow ); } // --- var selection = this.value.selection(); if (!selection.collapsed()) { var startLine = this.lineIdxAtCharIdx(selection.lower()); var endLine = this.lineIdxAtCharIdx(selection.upper()); if (startLine == endLine) { var startPos = this.coordinatesAtCharIdx(selection.lower()); var endPos = this.coordinatesAtCharIdx(selection.upper()); this.drawSelection(graphics, startPos.x, endPos.x, endPos.y); } else { var startPos = this.coordinatesAtCharIdx(selection.lower()); this.drawSelection(graphics, startPos.x, this.metrics.lineMetrics().get(startLine).width(), startPos.y); for (var lineIdx = startLine + 1; lineIdx < endLine; lineIdx++) { var line = this.metrics.lineMetrics().get(lineIdx); var width = line.beginIdx() != line.endIdx() ? line.width() : 2; this.drawSelection(graphics, 0, width, (lineIdx + 1) * font.lineHeight); } var endPos = this.coordinatesAtCharIdx(selection.upper()); drawSelection(graphics, 0, endPos.x, endPos.y); } } // --- if (this.widget.showCursor) { var cursorPos = this.coordinatesAtCharIdx(this.value.selection().end()); graphics.vLine( (int) cursorPos.x, (int) (cursorPos.y - font.lineHeight - 2), (int) (cursorPos.y), 0xaad0d0d0 ); } } private int lineIdxAtCharIdx(int charIdx) { var matchedLineIdx = -1; var lines = this.metrics.lineMetrics(); for (var lineIdx = 0; lineIdx < lines.size(); lineIdx++) { var line = lines.get(lineIdx); if (charIdx >= line.beginIdx() && charIdx <= line.endIdx()) { matchedLineIdx = lineIdx; break; } } return matchedLineIdx != -1 ? matchedLineIdx : lines.size() - 1; } private Vector2d coordinatesAtCharIdx(int charIdx) { var lineIdx = this.lineIdxAtCharIdx(charIdx); var line = this.metrics.lineMetrics().get(lineIdx); var font = this.host().client().font; var text = this.value.text(); var x = font.width(text.substring(line.beginIdx(), Math.min(text.length(), charIdx))); var y = (lineIdx + 1) * font.lineHeight; return new Vector2d(x, y); } public void insert(String insertion) { insertion = StringUtil.filterText(insertion, true); var chars = new StringBuilder(this.value.text()); var selection = this.value.selection(); chars.replace(selection.lower(), selection.upper(), insertion); var newText = chars.toString(); this.formatAndSetValue(new TextEditingValue( newText, TextSelection.collapsed(selection.lower() + insertion.length()) )); } private void deleteSelection() { if (Owo.DEBUG) { Preconditions.checkState(!this.value.selection().collapsed(), "deleteSelection invoked with collapsed selection"); } this.insert(""); } private int lastTextLineIdx() { var lastTextLineIdx = 0; while (lastTextLineIdx < this.metrics.lineMetrics().size() && this.metrics.lineMetrics().get(lastTextLineIdx).endIdx() < this.value.text().length()) { lastTextLineIdx++; } return lastTextLineIdx; } private void moveCursorVertically(int byLines, boolean selecting) { var newLineIdx = Mth.clamp(this.cursorLocation.line + byLines, 0, this.lastTextLineIdx()); var currentX = this.cursorPosition().x; var newLine = this.metrics.lineMetrics().get(newLineIdx); var newLocalRune = 0; var text = this.value.text(); var actualEndIdx = Math.min(newLine.endIdx(), text.length()); while (newLocalRune < (actualEndIdx - newLine.beginIdx())) { var glyphX = this.host().client().font.width(text.substring(newLine.beginIdx(), newLine.beginIdx() + newLocalRune)); if (glyphX >= currentX) { var previousGlyphX = this.host().client().font.width(text.substring(newLine.beginIdx(), newLine.beginIdx() + Math.max(0, newLocalRune - 1))); if (Math.abs(currentX - previousGlyphX) < Math.abs(currentX - glyphX)) { newLocalRune--; } break; } newLocalRune++; } this.setCursorPosition(newLine.beginIdx() + newLocalRune, selecting); } private int charIdxAt(double x, double y) { var font = this.host().client().font; var clickedLine = this.metrics.lineMetrics().get(Mth.clamp((int) (y / font.lineHeight), 0, this.lastTextLineIdx())); var lineText = this.value.text().substring(clickedLine.beginIdx(), Math.min(clickedLine.endIdx(), this.value.text().length())); return clickedLine.beginIdx() + font.plainSubstrByWidth(lineText, (int) x + 1).length(); } private void setCursorPosition(int toRune, boolean selecting) { this.formatAndSetValue(this.value.withSelection( selecting ? new TextSelection(this.value.selection().start(), toRune) : TextSelection.collapsed(toRune) )); } private int nextWordBoundary(boolean forwards, OptionalInt fromChar) { var fromCharIdx = fromChar.orElse(this.value.selection().end()); var direction = forwards ? 1 : -1; var lookAhead = forwards ? 0 : -1; var bound = forwards ? this.value.text().length() + 1 : -1; var startingClass = SkipClass.of(this.safeCharAt(fromCharIdx + lookAhead)); var idx = fromCharIdx + direction; while (idx != bound && startingClass.shouldSkip(this.safeCharAt(idx + lookAhead))) { idx += direction; } return idx; } private char safeCharAt(int charIdx) { var text = this.value.text(); return !text.isEmpty() ? text.charAt(Mth.clamp(charIdx, 0, text.length() - 1)) : ' '; } private void formatAndSetValue(TextEditingValue newValue) { var actual = newValue; if (!Objects.equals(this.value.text(), newValue.text())) { actual = BraidUtils.fold( this.widget.formatters, newValue, (value, formatter) -> formatter.format(this.value, value) ); } this.widget.controller.setValue(this.value = actual); } // --- public boolean onChar(int charCode) { this.insert(Character.toString(charCode)); return true; } public void deleteText(DeleteTextIntent intent) { var selection = this.value.selection(); if (!selection.collapsed()) { this.deleteSelection(); return; } var text = this.value.text(); var cursorPosition = selection.end(); if (intent.forwards()) { var chars = new StringBuilder(text); var end = Math.min( text.length(), intent.entireWord() ? this.nextWordBoundary(true, OptionalInt.empty()) : cursorPosition + 1 ); chars.delete(cursorPosition, end); this.formatAndSetValue(new TextEditingValue( chars.toString(), TextSelection.collapsed(cursorPosition) )); } else { var chars = new StringBuilder(text); var start = Math.max( 0, intent.entireWord() ? this.nextWordBoundary(false, OptionalInt.empty()) : cursorPosition - 1 ); chars.delete(start, cursorPosition); this.formatAndSetValue(new TextEditingValue( chars.toString(), TextSelection.collapsed(start) )); } } public void moveCursor(MoveCursorIntent intent) { var selection = this.value.selection(); var text = this.value.text(); var cursorPosition = selection.end(); var endingSelection = !selection.collapsed() && !intent.selecting(); switch (intent.direction()) { case UP -> this.moveCursorVertically(-1, intent.selecting()); case DOWN -> this.moveCursorVertically(1, intent.selecting()); case RIGHT -> this.setCursorPosition( Math.min( text.length(), endingSelection ? selection.upper() : intent.skipWord() ? this.nextWordBoundary(true, OptionalInt.empty()) : cursorPosition + 1 ), intent.selecting() ); case LEFT -> this.setCursorPosition( Math.max( 0, endingSelection ? selection.lower() : intent.skipWord() ? this.nextWordBoundary(false, OptionalInt.empty()) : cursorPosition - 1 ), intent.selecting() ); } } public void pasteFromClipboard() { this.insert(Minecraft.getInstance().keyboardHandler.getClipboard()); } public void copyToClipboard(CopyTextIntent intent) { Minecraft.getInstance().keyboardHandler.setClipboard(this.value.text().substring( this.value.selection().lower(), this.value.selection().upper() )); if (intent.delete()) { this.deleteSelection(); } } public void selectAllText() { this.formatAndSetValue(this.value.withSelection(new TextSelection(0, this.value.text().length()))); } public void teleportCursor(TeleportCursorIntent intent) { if (intent.toStart()) { this.setCursorPosition(this.currentLine().beginIdx(), intent.selecting()); } else { this.setCursorPosition(Math.min(this.value.text().length(), this.currentLine().endIdx()), intent.selecting()); } } public void deleteLine() { var chars = new StringBuilder(this.value.text()); var line = this.currentLine(); chars.delete(line.beginIdx(), line.endIdx()); this.formatAndSetValue(new TextEditingValue( chars.toString(), TextSelection.collapsed(line.beginIdx()) )); } @Override public @Nullable CursorStyle cursorStyleAt(double x, double y) { return CursorStyle.TEXT; } private static final Duration MAX_DOUBLE_CLICK_DELAY = Duration.ofMillis(250); private Instant lastClickTime = Instant.EPOCH; @Override public boolean onMouseDown(double x, double y, int button, KeyModifiers modifiers) { var clickedIdx = this.charIdxAt(x, y); if (Duration.between(this.lastClickTime, Instant.now()).compareTo(MAX_DOUBLE_CLICK_DELAY) < 0) { var start = this.nextWordBoundary(false, OptionalInt.of(clickedIdx)); var end = this.nextWordBoundary(true, OptionalInt.of(clickedIdx)); this.formatAndSetValue(this.value.withSelection( new TextSelection(Math.max(0, start), end) )); } else { this.lastClickTime = Instant.now(); this.setCursorPosition(clickedIdx, modifiers.shift()); } return true; } @Override public void onMouseDrag(double x, double y, double dx, double dy) { this.setCursorPosition(this.charIdxAt(x, y), true); } protected interface SkipClass { boolean shouldSkip(char c); static SkipClass of(char c) { if (c == '\n') { return LineBreakClass.INSTANCE; } if (WordClass.isWordChar(c)) { return WordClass.INSTANCE; } return new NonWordClass(c); } enum WordClass implements SkipClass { INSTANCE; @Override public boolean shouldSkip(char c) { return isWordChar(c); } public static boolean isWordChar(char c) { return c == '_' || Character.isAlphabetic(c) || Character.isDigit(c); } } enum LineBreakClass implements SkipClass { INSTANCE; @Override public boolean shouldSkip(char c) { return false; } } record NonWordClass(char specimen) implements SkipClass { @Override public boolean shouldSkip(char c) { return c == this.specimen; } } } protected record CursorLocation(int line, int charIdx) {} } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/textinput/TextSelection.java ================================================ package io.wispforest.owo.braid.widgets.textinput; public record TextSelection(int start, int end) { public static TextSelection collapsed(int cursorPosition) { return new TextSelection(cursorPosition, cursorPosition); } public int lower() { return Math.min(this.start, this.end); } public int upper() { return Math.max(this.start, this.end); } public boolean collapsed() { return this.start == this.end; } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/vanilla/VanillaWidget.java ================================================ package io.wispforest.owo.braid.widgets.vanilla; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.basic.Builder; import io.wispforest.owo.braid.widgets.basic.Center; import io.wispforest.owo.braid.widgets.basic.Sized; import io.wispforest.owo.braid.widgets.focus.Focusable; import net.minecraft.client.gui.components.Renderable; import net.minecraft.client.gui.components.events.GuiEventListener; import java.util.function.Supplier; public class VanillaWidget extends StatefulWidget { //"Yeah, I think this is a good name" - glisco 2025 public final Size size; public final Supplier widgetSupplier; public VanillaWidget(Size size, Supplier widgetSupplier) { this.widgetSupplier = widgetSupplier; this.size = size; } @Override public WidgetState> createState() { return new State<>(); } //TODO: tell people they need to use a key in the case where they modify the supplier without modifying the tree, i dont like 100% understand this but glisco will know what this means public static class State extends WidgetState> { private T widget; private BuildContext vanillaContext; private VanillaWidgetWrapper.Instance instance() { return (VanillaWidgetWrapper.Instance) this.vanillaContext.instance(); } @Override public void init() { widget = this.widget().widgetSupplier.get(); } @Override public Widget build(BuildContext context) { return new Center( new Focusable( focusable -> focusable .keyDownCallback((keyCode, modifiers) -> this.instance().onKeyDown(keyCode, modifiers)) .keyUpCallback((keyCode, modifiers) -> this.instance().onKeyUp(keyCode, modifiers)) .charCallback((charCode, modifiers) -> this.instance().onChar(charCode, modifiers)) .focusGainedCallback(() -> this.instance().onFocusGained()) .focusLostCallback(() -> this.instance().onFocusLost()), new Sized( this.widget().size, new Builder(vanillaContext -> { this.vanillaContext = vanillaContext; return new VanillaWidgetWrapper<>(widget); }) ) ) ); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/vanilla/VanillaWidgetWrapper.java ================================================ package io.wispforest.owo.braid.widgets.vanilla; import com.mojang.blaze3d.opengl.GlStateManager; import io.wispforest.owo.braid.core.BraidGraphics; import io.wispforest.owo.braid.core.Constraints; import io.wispforest.owo.braid.core.KeyModifiers; import io.wispforest.owo.braid.framework.instance.LeafWidgetInstance; import io.wispforest.owo.braid.framework.instance.MouseListener; import io.wispforest.owo.braid.framework.widget.LeafInstanceWidget; import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.components.Renderable; import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.gui.layouts.LayoutElement; import net.minecraft.client.input.CharacterEvent; import net.minecraft.client.input.KeyEvent; import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.client.input.MouseButtonInfo; import java.util.OptionalDouble; public class VanillaWidgetWrapper extends LeafInstanceWidget { public final T wrapped; public VanillaWidgetWrapper(T wrapped) { this.wrapped = wrapped; } @Override public LeafWidgetInstance instantiate() { return new Instance(this); } public static class Instance extends LeafWidgetInstance> implements MouseListener { private int draggingMouseButton = 0; private double x, y; public Instance(VanillaWidgetWrapper widget) { super(widget); } @Override protected void doLayout(Constraints constraints) { if (widget.wrapped instanceof LayoutElement layoutElement) { layoutElement.setPosition(0, 0); } var size = constraints.hasBoundedWidth() && constraints.hasBoundedHeight() ? constraints.maxSize() : constraints.minSize(); if (widget.wrapped instanceof AbstractWidget abstractWidget) { abstractWidget.setWidth((int) size.width()); abstractWidget.setHeight((int) size.height()); } this.transform.setSize(size); } @Override protected double measureIntrinsicWidth(double height) { return 0; } @Override protected double measureIntrinsicHeight(double width) { return 0; } @Override protected OptionalDouble measureBaselineOffset() { return OptionalDouble.empty(); } @Override public void draw(BraidGraphics graphics) { widget.wrapped.render(graphics, (int) x, (int) y, host().client().getDeltaTracker().getGameTimeDeltaPartialTick(false)); GlStateManager._enableScissorTest(); } public boolean onKeyDown(int keyCode, KeyModifiers modifiers) { return widget.wrapped.keyPressed(new KeyEvent(keyCode, 0, modifiers.bitMask())); } public boolean onKeyUp(int keyCode, KeyModifiers modifiers) { return widget.wrapped.keyReleased(new KeyEvent(keyCode, 0, modifiers.bitMask())); } public boolean onChar(int charCode, KeyModifiers modifiers) { return widget.wrapped.charTyped(new CharacterEvent(charCode, modifiers.bitMask())); } public void onFocusGained() { this.widget.wrapped.setFocused(true); } public void onFocusLost() { this.widget.wrapped.setFocused(false); } @Override public boolean onMouseDown(double x, double y, int button, KeyModifiers modifiers) { return widget.wrapped.mouseClicked(new MouseButtonEvent(x, y, new MouseButtonInfo(button, modifiers.bitMask())), false); } @Override public boolean onMouseUp(double x, double y, int button, KeyModifiers modifiers) { return widget.wrapped.mouseReleased(new MouseButtonEvent(x, y, new MouseButtonInfo(button, modifiers.bitMask()))); } @Override public void onMouseMove(double toX, double toY) { this.x = toX; this.y = toY; } @Override public void onMouseDragStart(int button, KeyModifiers modifiers) { draggingMouseButton = button; } @Override public void onMouseDrag(double x, double y, double dx, double dy) { this.widget.wrapped.mouseDragged(new MouseButtonEvent(x, y, new MouseButtonInfo(draggingMouseButton, 0)), (int) dx, (int) dy); } @Override public boolean onMouseScroll(double x, double y, double horizontal, double vertical) { return widget.wrapped.mouseScrolled(x, y, horizontal, vertical); } } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/window/Window.java ================================================ package io.wispforest.owo.braid.widgets.window; import io.wispforest.owo.braid.core.Alignment; import io.wispforest.owo.braid.core.Color; import io.wispforest.owo.braid.core.Insets; import io.wispforest.owo.braid.core.Size; import io.wispforest.owo.braid.core.cursor.CursorStyle; import io.wispforest.owo.braid.framework.BuildContext; import io.wispforest.owo.braid.framework.proxy.WidgetState; import io.wispforest.owo.braid.framework.widget.StatefulWidget; import io.wispforest.owo.braid.framework.widget.Widget; import io.wispforest.owo.braid.widgets.HoverStyledLabel; import io.wispforest.owo.braid.widgets.basic.*; import io.wispforest.owo.braid.widgets.drag.DragArenaElement; import io.wispforest.owo.braid.widgets.flex.Column; import io.wispforest.owo.braid.widgets.flex.Flexible; import io.wispforest.owo.braid.widgets.flex.Row; import io.wispforest.owo.braid.widgets.intents.Interactable; import io.wispforest.owo.braid.widgets.label.Label; import io.wispforest.owo.braid.widgets.label.LabelStyle; import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.util.Mth; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.HashSet; import java.util.Set; public class Window extends StatefulWidget { public final boolean collapsible; public final Component title; public final @Nullable Runnable onClose; public final @Nullable WindowController controller; public final Size initialSize; public final Size minSize; public final Size maxSize; public final Widget content; public Window(boolean collapsible, Component title, @Nullable Runnable onClose, @Nullable WindowController controller, Size initialSize, Size minSize, Size maxSize, Widget content) { this.collapsible = collapsible; this.title = title; this.onClose = onClose; this.controller = controller; this.initialSize = initialSize; this.minSize = minSize; this.maxSize = maxSize; this.content = content; } public Window(boolean collapsible, Component title, @Nullable Runnable onClose, @Nullable WindowController controller, Size initialSize, Widget content) { this(collapsible, title, onClose, controller, initialSize, Size.square(40), Size.square(Double.POSITIVE_INFINITY), content); } @Override public WidgetState createState() { return new State(); } public static class State extends WidgetState { private WindowController controller; private WindowController internalController; private Set draggingEdges; private Size draggingSize; @Override public void init() { this.internalController = new WindowController(); this.updateController(); this.controller.setSize(this.widget().initialSize); this.applySize(this.widget().initialSize); } @Override public void didUpdateWidget(Window oldWidget) { this.updateController(); this.applySize(this.controller.size()); } private void updateController() { this.controller = this.widget().controller != null ? this.widget().controller : this.internalController; } @Override public Widget build(BuildContext context) { return new ListenableBuilder( this.controller, (buildContext, child) -> { var titleBar = new ArrayList(); if (this.widget().collapsible) { titleBar.add(Interactable.primary( () -> this.controller.toggleCollapsed(), new Padding( Insets.of(2, 0, 0, 4), new Label(Component.literal(this.controller.collapsed() ? "⏶" : "⏷")) ) )); } titleBar.add(new Flexible(new Label(new LabelStyle(Alignment.LEFT, null, null, null), false, Label.Overflow.ELLIPSIS, this.widget().title))); if (this.widget().onClose != null) { titleBar.add(Interactable.primary( () -> this.widget().onClose.run(), new HoverStyledLabel(Component.literal("x"), Style.EMPTY.applyFormat(ChatFormatting.RED)) )); } return new DragArenaElement( Math.ceil(this.controller.x()), Math.ceil(this.controller.y()), new MouseArea( widget -> widget //TODO: decide what to do with buttons here .clickCallback((x, y, button, modifiers) -> { if (button != 0) return false; this.draggingEdges = this.edgesAt(x, y); this.draggingSize = this.controller.size(); return true; }) .dragCallback((x, y, dx, dy) -> this.resize(dx, dy)) .dragEndCallback(() -> { this.draggingEdges = null; this.draggingSize = null; }) .cursorStyleSupplier((x, y) -> this.cursorStyleFor(this.edgesAt(x, y))), new Padding( Insets.all(4), new HitTestTrap( new MouseArea( widget -> widget .dragCallback((x, y, dx, dy) -> { this.controller.setX(this.controller.x() + dx); this.controller.setY(this.controller.y() + dy); }), new Sized( Size.of( this.controller.size().width(), this.controller.size().height() + 15 ).floor(), new Column( new Sized( null, 15.0, new Box( Color.BLACK.withA(.75), new Padding( Insets.horizontal(4), new Row(titleBar) ) ) ), new Flexible( new Visibility( !this.controller.collapsed(), false, child ) ) ) ) ) ) ) ) ); }, new Box( Color.BLACK.withA(.65), new Padding( Insets.all(4), new Clip( this.widget().content ) ) ) ); } protected Set edgesAt(double x, double y) { var result = new HashSet(); if (y < 4) result.add(Edge.TOP); if (y > this.controller.size().height() + 4 + 15) result.add(Edge.BOTTOM); if (x < 4) result.add(Edge.LEFT); if (x > this.controller.size().width() + 4) result.add(Edge.RIGHT); return result; } protected void resize(double dx, double dy) { var size = this.draggingSize; if (this.draggingEdges.contains(Edge.TOP)) { size = size.with(null, size.height() - dy); this.controller.setY(this.controller.y() + dy); } else if (this.draggingEdges.contains(Edge.BOTTOM)) { size = size.with(null, size.height() + dy); } if (this.draggingEdges.contains(Edge.LEFT)) { size = size.with(size.width() - dx, null); this.controller.setX(this.controller.x() + dx); } else if (this.draggingEdges.contains(Edge.RIGHT)) { size = size.with(size.width() + dx, null); } this.draggingSize = size; this.applySize(this.draggingSize); } private void applySize(Size size) { this.controller.setSize(Size.of( Mth.clamp(size.width(), this.widget().minSize.width(), this.widget().maxSize.width()), Mth.clamp(size.height(), this.widget().minSize.height(), this.widget().maxSize.height()) )); } protected @Nullable CursorStyle cursorStyleFor(Set edges) { if (edges.size() == 1) { if (edges.contains(Edge.TOP) || edges.contains(Edge.BOTTOM)) return CursorStyle.VERTICAL_RESIZE; if (edges.contains(Edge.LEFT) || edges.contains(Edge.RIGHT)) return CursorStyle.HORIZONTAL_RESIZE; } else if (edges.size() == 2) { if ((edges.contains(Edge.TOP) && edges.contains(Edge.LEFT)) || (edges.contains(Edge.BOTTOM) && edges.contains(Edge.RIGHT))) { return CursorStyle.NWSE_RESIZE; } if ((edges.contains(Edge.BOTTOM) && edges.contains(Edge.LEFT)) || (edges.contains(Edge.TOP) && edges.contains(Edge.RIGHT))) { return CursorStyle.NESW_RESIZE; } } return null; } } protected enum Edge { TOP, LEFT, RIGHT, BOTTOM } } ================================================ FILE: src/main/java/io/wispforest/owo/braid/widgets/window/WindowController.java ================================================ package io.wispforest.owo.braid.widgets.window; import io.wispforest.owo.braid.core.Listenable; import io.wispforest.owo.braid.core.Size; public class WindowController extends Listenable { private double x = 0; private double y = 0; private Size size = Size.zero(); private boolean collapsed = false; public void setX(double x) { this.x = x; this.notifyListeners(); } public double x() { return this.x; } public void setY(double y) { this.y = y; this.notifyListeners(); } public double y() { return this.y; } public void setSize(Size size) { this.size = size; this.notifyListeners(); } public Size size() { return this.size; } public boolean toggleCollapsed() { this.setCollapsed(!this.collapsed); return this.collapsed; } public void setCollapsed(boolean collapsed) { this.collapsed = collapsed; this.notifyListeners(); } public boolean collapsed() { return this.collapsed; } } ================================================ FILE: src/main/java/io/wispforest/owo/client/OwoClient.java ================================================ package io.wispforest.owo.client; import io.wispforest.owo.Owo; import io.wispforest.owo.braid.display.BraidDisplay; import io.wispforest.owo.client.screens.MenuNetworkingInternals; import io.wispforest.owo.command.debug.OwoDebugCommands; import io.wispforest.owo.config.OwoConfigCommand; import io.wispforest.owo.itemgroup.json.OwoItemGroupLoader; import io.wispforest.owo.moddata.ModDataLoader; import io.wispforest.owo.ui.core.OwoUIPipelines; import io.wispforest.owo.ui.parsing.UIModelLoader; import io.wispforest.owo.ui.renderstate.OwoSpecialGuiElementRenderers; import io.wispforest.owo.ui.util.NinePatchTexture; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; import net.fabricmc.fabric.api.resource.ResourceManagerHelper; import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.server.packs.PackType; import net.minecraft.util.Util; import org.jetbrains.annotations.ApiStatus; @ApiStatus.Internal @Environment(EnvType.CLIENT) public class OwoClient implements ClientModInitializer { private static final String LINUX_RENDERDOC_WARNING = """ ======================================== Ignored 'owo.renderdocPath' property as this Minecraft instance is not running on Windows. Please populate the LD_PRELOAD environment variable instead ========================================"""; private static final String MAC_RENDERDOC_WARNING = """ ======================================== Ignored 'owo.renderdocPath' property as this Minecraft instance is not running on Windows. RenderDoc is not supported on macOS ========================================"""; private static final String GENERIC_RENDERDOC_WARNING = """ ======================================== Ignored 'owo.renderdocPath' property as this Minecraft instance is not running on Windows. ========================================"""; @Override public void onInitializeClient() { ModDataLoader.load(OwoItemGroupLoader.INSTANCE); ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new UIModelLoader()); ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new NinePatchTexture.MetadataLoader()); OwoUIPipelines.register(); RenderPipelines.register(BraidDisplay.PIPELINE); final var renderdocPath = System.getProperty("owo.renderdocPath"); if (renderdocPath != null) { if (Util.getPlatform() == Util.OS.WINDOWS) { System.load(renderdocPath); } else { Owo.LOGGER.warn(switch (Util.getPlatform()) { case LINUX -> LINUX_RENDERDOC_WARNING; case OSX -> MAC_RENDERDOC_WARNING; default -> GENERIC_RENDERDOC_WARNING; }); } } MenuNetworkingInternals.Client.init(); ClientCommandRegistrationCallback.EVENT.register(OwoConfigCommand::register); if (Owo.DEBUG) { OwoDebugCommands.Client.register(); } OwoSpecialGuiElementRenderers.init(); } } ================================================ FILE: src/main/java/io/wispforest/owo/client/screens/MenuNetworkingInternals.java ================================================ package io.wispforest.owo.client.screens; import io.wispforest.endec.Endec; import io.wispforest.endec.impl.StructEndecBuilder; import io.wispforest.owo.Owo; import io.wispforest.owo.serialization.CodecUtils; import io.wispforest.owo.serialization.endec.MinecraftEndecs; import io.wispforest.owo.util.pond.OwoAbstractContainerMenuExtension; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.minecraft.client.gui.screens.inventory.MenuAccess; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.ApiStatus; @ApiStatus.Internal public class MenuNetworkingInternals { public static final Identifier SYNC_PROPERTIES = Owo.id("sync_menu_properties"); public static void init() { var localPacketCodec = CodecUtils.toPacketCodec(LocalPacket.ENDEC); PayloadTypeRegistry.playS2C().register(LocalPacket.ID, localPacketCodec); PayloadTypeRegistry.playC2S().register(LocalPacket.ID, localPacketCodec); PayloadTypeRegistry.playS2C().register(SyncPropertiesPacket.ID, CodecUtils.toPacketCodec(SyncPropertiesPacket.ENDEC)); ServerPlayNetworking.registerGlobalReceiver(LocalPacket.ID, (payload, context) -> { var menu = context.player().containerMenu; if (menu == null) { Owo.LOGGER.error("Received local packet for null ContainerMenu"); return; } ((OwoAbstractContainerMenuExtension) menu).owo$handlePacket(payload, false); }); } public record LocalPacket(int packetId, FriendlyByteBuf payload) implements CustomPacketPayload { public static final Type ID = new Type<>(Owo.id("local_packet")); public static final Endec ENDEC = StructEndecBuilder.of( Endec.VAR_INT.fieldOf("packetId", LocalPacket::packetId), MinecraftEndecs.FRIENDLY_BYTE_BUF.fieldOf("payload", LocalPacket::payload), LocalPacket::new ); @Override public Type type() { return ID; } } public record SyncPropertiesPacket(FriendlyByteBuf payload) implements CustomPacketPayload { public static final Type ID = new Type<>(SYNC_PROPERTIES); public static final Endec ENDEC = StructEndecBuilder.of( MinecraftEndecs.FRIENDLY_BYTE_BUF.fieldOf("payload", SyncPropertiesPacket::payload), SyncPropertiesPacket::new ); @Override public Type type() { return ID; } } @Environment(EnvType.CLIENT) public static class Client { public static void init() { ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> { if (screen instanceof MenuAccess handled) ((OwoAbstractContainerMenuExtension) handled.getMenu()).owo$attachToPlayer(client.player); }); ClientPlayNetworking.registerGlobalReceiver(LocalPacket.ID, (payload, context) -> { var menu = context.player().containerMenu; if (menu == null) { Owo.LOGGER.error("Received local packet for null ContainerMenu"); return; } ((OwoAbstractContainerMenuExtension) menu).owo$handlePacket(payload, true); }); ClientPlayNetworking.registerGlobalReceiver(SyncPropertiesPacket.ID, (payload, context) -> { var menu = context.player().containerMenu; if (menu == null) { Owo.LOGGER.error("Received sync properties packet for null ContainerMenu"); return; } ((OwoAbstractContainerMenuExtension) menu).owo$readPropertySync(payload); }); } } } ================================================ FILE: src/main/java/io/wispforest/owo/client/screens/MenuUtils.java ================================================ package io.wispforest.owo.client.screens; import io.wispforest.owo.mixin.AbstractContainerMenuInvoker; import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.item.ItemStack; /** * A collection of utilities to ease implementing a simple {@link net.minecraft.client.gui.screens.inventory.AbstractContainerScreen} */ public class MenuUtils { /** * Can be used as an implementation of {@link net.minecraft.world.inventory.AbstractContainerMenu#quickMoveStack(Player, int)} * for simple screens with a lower (player) and upper (main) inventory * *
     * {@code
     * @Override
     * public ItemStack quickMove(PlayerEntity player, int invSlot) {
     *     return MenuUtils.handleSlotTransfer(this, invSlot, this.inventory.size());
     * }
     * }
     * 
* * @param menu The target AbstractContainerMenu * @param clickedSlotIndex The slot index that was clicked * @param upperInventorySize The size of the upper (main) inventory * @return The return value for {{@link net.minecraft.world.inventory.AbstractContainerMenu#quickMoveStack(Player, int)}} */ public static ItemStack handleSlotTransfer(AbstractContainerMenu menu, int clickedSlotIndex, int upperInventorySize) { final var slots = menu.slots; final var clickedSlot = slots.get(clickedSlotIndex); if (!clickedSlot.hasItem()) return ItemStack.EMPTY; final var clickedStack = clickedSlot.getItem(); if (clickedSlotIndex < upperInventorySize) { if (!insertIntoSlotRange(menu, clickedStack, upperInventorySize, slots.size(), true)) { return ItemStack.EMPTY; } } else { if (!insertIntoSlotRange(menu, clickedStack, 0, upperInventorySize)) { return ItemStack.EMPTY; } } if (clickedStack.isEmpty()) { clickedSlot.setByPlayer(ItemStack.EMPTY); } else { clickedSlot.setChanged(); } return clickedStack; } /** * Shorthand of {@link #insertIntoSlotRange(AbstractContainerMenu, ItemStack, int, int, boolean)} with * {@code false} for {@code fromLast} */ @SuppressWarnings("BooleanMethodIsAlwaysInverted") public static boolean insertIntoSlotRange(AbstractContainerMenu menu, ItemStack addition, int beginIndex, int endIndex) { return insertIntoSlotRange(menu, addition, beginIndex, endIndex, false); } /** * Tries to insert the {@code addition} stack into all slots in the given range * * @param menu The AbstractContainerMenu to operate on * @param beginIndex The index of the first slot to check * @param endIndex The index of the last slot to check * @param addition The ItemStack to try and insert, this gets mutated * if insertion (partly) succeeds * @param fromLast If {@code true}, iterate the range of slots in * opposite order * @return {@code true} if state was modified */ @SuppressWarnings("BooleanMethodIsAlwaysInverted") public static boolean insertIntoSlotRange(AbstractContainerMenu menu, ItemStack addition, int beginIndex, int endIndex, boolean fromLast) { return ((AbstractContainerMenuInvoker) menu).owo$insertItem(addition, beginIndex, endIndex, fromLast); } } ================================================ FILE: src/main/java/io/wispforest/owo/client/screens/OwoAbstractContainerMenu.java ================================================ package io.wispforest.owo.client.screens; import io.wispforest.endec.Endec; import io.wispforest.endec.impl.ReflectiveEndecBuilder; import net.minecraft.world.entity.player.Player; import org.jetbrains.annotations.NotNull; import java.util.function.Consumer; public interface OwoAbstractContainerMenu { default ReflectiveEndecBuilder endecBuilder() { throw new UnsupportedOperationException("Implemented in AbstractContainerMenuMixin"); } /** * Create a new property on this menu. This property can be updated serverside * and will automatically synchronize to the client - think {@link net.minecraft.world.inventory.ContainerData} * but without being restricted to integers * * @param clazz The class of the property's value * @param endec The endec to use for (de-)serializing the value of this property over the network * @param initial The value with which to initialize the property * @return The created property */ default SyncedProperty createProperty(Class clazz, Endec endec, T initial) { throw new UnsupportedOperationException("Implemented in AbstractContainerMenuMixin"); } /** * Shorthand for {@link #createProperty(Class, Endec, Object)} which creates the endec * through {@link ReflectiveEndecBuilder#get(Class)} */ default SyncedProperty createProperty(Class clazz, T initial) { return this.createProperty(clazz, this.endecBuilder().get(clazz), initial); } /** * Register a serverbound message, or local packet if you will, onto this * menu. This needs to be called during initialization of the menu, * after which you can send messages to the server by invoking {@link #sendMessage(Record)} * with the message you want to send * * @param messageClass The class of message to send, must be a record - much like * packets in an {@link io.wispforest.owo.network.OwoNetChannel} * @param endec The endec to use for (de-)serializing messages sent over the network * @param handler The handler to execute when a message of the given class is * received on the server */ default void addServerboundMessage(Class messageClass, Endec endec, Consumer handler) { throw new UnsupportedOperationException("Implemented in AbstractContainerMenuMixin"); } /** * Shorthand for {@link #addServerboundMessage(Class, Endec, Consumer)} which creates the endec * through {@link ReflectiveEndecBuilder#get(Class)} */ default void addServerboundMessage(Class messageClass, Consumer handler) { this.addServerboundMessage(messageClass, this.endecBuilder().get(messageClass), handler); } /** * Register a clientbound message, or local packet if you will, onto this * menu. This needs to be called during initialization of the menu, * after which you can send messages to the client by invoking {@link #sendMessage(Record)} * with the message you want to send * * @param messageClass The class of message to send, must be a record - much like * packets in an {@link io.wispforest.owo.network.OwoNetChannel} * @param endec The endec to use for (de-)serializing messages sent over the network * @param handler The handler to execute when a message of the given class is * received on the client */ default void addClientboundMessage(Class messageClass, Endec endec, Consumer handler) { throw new UnsupportedOperationException("Implemented in AbstractContainerMenuMixin"); } /** * Shorthand for {@link #addClientboundMessage(Class, Endec, Consumer)} which creates the endec * through {@link ReflectiveEndecBuilder#get(Class)} */ default void addClientboundMessage(Class messageClass, Consumer handler) { this.addClientboundMessage(messageClass, this.endecBuilder().get(messageClass), handler); } /** * Send the given message. This message must have been previously * registered through a call to {@link #addServerboundMessage(Class, Endec, Consumer)} * or {@link #addClientboundMessage(Class, Endec, Consumer)} - this also dictates where * the message will be sent to */ default void sendMessage(@NotNull R message) { throw new UnsupportedOperationException("Implemented in AbstractContainerMenuMixin"); } /** * @return The player this menu is attached to */ default Player player() { throw new UnsupportedOperationException("Implemented in AbstractContainerMenuMixin"); } } ================================================ FILE: src/main/java/io/wispforest/owo/client/screens/ScreenhandlerMessageData.java ================================================ package io.wispforest.owo.client.screens; import io.wispforest.endec.Endec; import org.jetbrains.annotations.ApiStatus; import java.util.function.Consumer; @ApiStatus.Internal public record ScreenhandlerMessageData(int id, boolean clientbound, Endec endec, Consumer handler) {} ================================================ FILE: src/main/java/io/wispforest/owo/client/screens/SlotGenerator.java ================================================ package io.wispforest.owo.client.screens; import net.minecraft.world.Container; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.inventory.Slot; import java.util.function.Consumer; /** * Stateful slot generation utility for easily * arranging the slot grid used in a {@link net.minecraft.world.inventory.AbstractContainerMenu} */ public final class SlotGenerator { private int anchorX, anchorY; private int horizontalSpacing = 0; private int verticalSpacing = 0; private SlotFactory slotFactory = Slot::new; private Consumer slotConsumer; private SlotGenerator(Consumer slotConsumer, int anchorX, int anchorY) { this.anchorX = anchorX; this.anchorY = anchorY; this.slotConsumer = slotConsumer; } /** * Begin generating slots into {@code slotConsumer}, starting at * ({@code anchorX}, {@code anchorY}). Usually, the {@code slotConsumer} * will be the {@code addSlot} method of the screen handler for which * slots are being generated *

*

     * {@code
     * SlotGenerator.begin(this::addSlot, 50, 10)
     *     .grid(someInventory, 0, 3, 3) // add a 3x3 grid of slots 0-8 of 'someInventory'
     *     .moveTo(10, 100)
     *     .playerInventory(playerInventory); // add the player inventory and hotbar slots
     * }
     * 
*/ public static SlotGenerator begin(Consumer slotConsumer, int anchorX, int anchorY) { return new SlotGenerator(slotConsumer, anchorX, anchorY); } /** * Move the top-left anchor point of generated grids to ({@code anchorX}, {@code anchorY}) */ public SlotGenerator moveTo(int anchorX, int anchorY) { this.anchorX = anchorX; this.anchorY = anchorY; return this; } /** * Shorthand for calling both {@link #horizontalSpacing} and * {@link #verticalSpacing} with {@code spacing} */ public SlotGenerator spacing(int spacing) { this.horizontalSpacing = spacing; this.verticalSpacing = spacing; return this; } public SlotGenerator horizontalSpacing(int horizontalSpacing) { this.horizontalSpacing = horizontalSpacing; return this; } public SlotGenerator verticalSpacing(int verticalSpacing) { this.verticalSpacing = verticalSpacing; return this; } public SlotGenerator slotConsumer(Consumer slotConsumer) { this.slotConsumer = slotConsumer; return this; } /** * Reset the slot factory of this generator * to the default {@link Slot#Slot(Container, int, int, int)} constructor */ public SlotGenerator defaultSlotFactory() { this.slotFactory = Slot::new; return this; } /** * Set the slot factory of this generator, used for instantiating * each generated slot, to {@code slotFactory} */ public SlotGenerator slotFactory(SlotFactory slotFactory) { this.slotFactory = slotFactory; return this; } public SlotGenerator grid(Container container, int startIndex, int width, int height) { for (int row = 0; row < height; row++) { for (int column = 0; column < width; column++) { slotConsumer.accept(this.slotFactory.create( container, startIndex + row * width + column, anchorX + column * (18 + this.horizontalSpacing), anchorY + row * (18 + this.verticalSpacing) )); } } return this; } public SlotGenerator playerInventory(Inventory playerInventory) { this.grid(playerInventory, 9, 9, 3); this.anchorY += 58; this.grid(playerInventory, 0, 9, 1); this.anchorY -= 58; return this; } @FunctionalInterface public interface SlotFactory { Slot create(Container container, int index, int x, int y); } } ================================================ FILE: src/main/java/io/wispforest/owo/client/screens/SyncedProperty.java ================================================ package io.wispforest.owo.client.screens; import io.wispforest.endec.Endec; import io.wispforest.endec.SerializationContext; import io.wispforest.owo.serialization.RegistriesAttribute; import io.wispforest.owo.util.Observable; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.world.inventory.AbstractContainerMenu; import org.jetbrains.annotations.ApiStatus; public class SyncedProperty extends Observable { private final int index; private final Endec endec; private final AbstractContainerMenu owner; private boolean needsSync; @ApiStatus.Internal public SyncedProperty(int index, Endec endec, T initial, AbstractContainerMenu owner) { super(initial); this.index = index; this.endec = endec; this.owner = owner; } public int index() { return index; } @ApiStatus.Internal public boolean needsSync() { return needsSync; } @ApiStatus.Internal public void write(FriendlyByteBuf buf) { needsSync = false; buf.write(serializationContext(), this.endec, value); } @ApiStatus.Internal public void read(FriendlyByteBuf buf) { this.set(buf.read(serializationContext(), this.endec)); } @Override protected void notifyObservers(T value) { super.notifyObservers(value); this.needsSync = true; } public void markDirty() { notifyObservers(value); } private SerializationContext serializationContext() { var player = this.owner.player(); if (player == null) return SerializationContext.empty(); return SerializationContext.attributes(RegistriesAttribute.of(player.registryAccess())); } } ================================================ FILE: src/main/java/io/wispforest/owo/client/screens/ValidatingSlot.java ================================================ package io.wispforest.owo.client.screens; import net.minecraft.world.Container; import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.ItemStack; import java.util.function.Predicate; /** * A slot that uses the provided {@code insertCondition} * to decide which items can be inserted */ public class ValidatingSlot extends Slot { private final Predicate insertCondition; public ValidatingSlot(Container container, int index, int x, int y, Predicate insertCondition) { super(container, index, x, y); this.insertCondition = insertCondition; } @Override public boolean mayPlace(ItemStack stack) { return insertCondition.test(stack); } } ================================================ FILE: src/main/java/io/wispforest/owo/client/texture/AnimatedTextureDrawable.java ================================================ package io.wispforest.owo.client.texture; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Renderable; import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.resources.Identifier; import net.minecraft.util.Util; /** * A drawable that can draw an animated texture, very similar to how * .mcmeta works on stitched textures in ticked atlases * *

Originally from Animawid, adapted for oωo

* * @author Tempora * @author glisco */ public class AnimatedTextureDrawable implements Renderable { private final SpriteSheetMetadata metadata; private final Identifier texture; private final int validFrames; private final int delay; private final boolean loop; private final int rows; private long startTime = -1L; private final int width, height; private int x, y; /** * Creates a new animated texture widget using the width and height of the spritesheet as dimensions * * @see #AnimatedTextureDrawable(int, int, int, int, Identifier, SpriteSheetMetadata, int, boolean) */ public AnimatedTextureDrawable(int x, int y, Identifier texture, SpriteSheetMetadata metadata, int delay, boolean loop) { this(x, y, metadata.width(), metadata.height(), texture, metadata, delay, loop); } /** * Creates a new animated texture widget that can be placed on your Screen or overlay etc. * * @param x The x position of the widget. * @param y The y position of the widget. * @param width The width of the widget. * @param height The height of the widget. * @param texture The identifier of the texture, eg: {@code mymod:texture/animation_spritesheet.png} * @param metadata Metadata on the spritesheet. * @param delay The delay, in milliseconds, between each frame. */ public AnimatedTextureDrawable(int x, int y, int width, int height, Identifier texture, SpriteSheetMetadata metadata, int delay, boolean loop) { this.x = x; this.y = y; this.texture = texture; this.delay = delay; this.metadata = metadata; this.width = width; this.height = height; this.loop = loop; int columns = metadata.width() / metadata.frameWidth(); this.rows = metadata.height() / metadata.frameHeight(); this.validFrames = columns * this.rows; } /** * Renders this drawable at the given position. The position * of this drawable is mutated non-temporarily */ public void render(int x, int y, GuiGraphics context, int mouseX, int mouseY, float delta) { this.x = x; this.y = y; this.render(context, mouseX, mouseY, delta); } @SuppressWarnings("IntegerDivisionInFloatingPointContext") @Override public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { if (startTime == -1L) startTime = Util.getMillis(); long currentTime = Util.getMillis(); long frame = Math.min(validFrames - 1, (currentTime - startTime) / delay); if (loop && frame == validFrames - 1) { startTime = Util.getMillis(); frame = 0; } context.blit(RenderPipelines.GUI_TEXTURED, this.texture, x, y, (frame / rows) * metadata.frameWidth(), (frame % rows) * metadata.frameHeight(), width, height, metadata.width(), metadata.height()); } } ================================================ FILE: src/main/java/io/wispforest/owo/client/texture/SpriteSheetMetadata.java ================================================ package io.wispforest.owo.client.texture; /** * A simple container to define the sprite sheet an {@link AnimatedTextureDrawable} uses * *

Originally from Animawid, adapted for oωo

* * @author Tempora * @author glisco */ public record SpriteSheetMetadata(int width, int height, int frameWidth, int frameHeight, int offset) { /** * Creates a new SpriteSheetMetadata object. * * @param width The width of the Sprite Sheet. * @param height The height of the Sprite Sheet. * @param frameWidth The width of each individual frame * @param frameHeight The width of each individual frame */ public SpriteSheetMetadata(int width, int height, int frameWidth, int frameHeight) { this(width, height, frameWidth, frameHeight, 0); } /** * Convenience constructor that assumes both the spritesheet and frames are square */ public SpriteSheetMetadata(int size, int frameSize) { this(size, size, frameSize, frameSize, 0); } } ================================================ FILE: src/main/java/io/wispforest/owo/command/EnumArgumentType.java ================================================ package io.wispforest.owo.command; import com.mojang.brigadier.StringReader; import com.mojang.brigadier.arguments.ArgumentType; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; import io.wispforest.owo.Owo; import net.fabricmc.fabric.api.command.v2.ArgumentTypeRegistry; import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.commands.synchronization.SingletonArgumentInfo; import net.minecraft.network.chat.Component; import java.util.Arrays; import java.util.Locale; import java.util.concurrent.CompletableFuture; /** * A simple implementation of {@link ArgumentType} that works with any {@code enum}. * It is recommended to create one instance of this and use it both in the call * to {@link net.minecraft.commands.Commands#argument(String, ArgumentType)} * as well as for getting the supplied argument via {@link #get(CommandContext, String)} * * @param The {@code enum} this instance can parse */ public class EnumArgumentType> implements ArgumentType> { private final DynamicCommandExceptionType noValueException; private final String noElementMessage; private final Class enumClass; private EnumArgumentType(Class enumClass, String noElementMessage) { this.enumClass = enumClass; this.noElementMessage = noElementMessage; this.noValueException = new DynamicCommandExceptionType(o -> Component.literal(this.noElementMessage.replace("{}", o.toString()))); } /** * Creates a new instance that uses {@code Invalid enum value '{}'} as the * error message if an invalid value is supplied. This must be called * on both server and client so the serializer can be registered correctly. * Since the instance is added to the type registry, this must happen during mod * initialization when the registries are mutable * * @param enumClass The {@code enum} type to parse for * @param The {@code enum} type to parse for * @return A new argument type that can parse instances of {@code T} */ public static > EnumArgumentType create(Class enumClass) { final var type = new EnumArgumentType<>(enumClass, "Invalid enum value '{}'"); ArgumentTypeRegistry.registerArgumentType(Owo.id("enum_" + enumClass.getName().toLowerCase(Locale.ROOT)), type.getClass(), SingletonArgumentInfo.contextFree(() -> type)); return type; } /** * Creates a new instance that uses {@code noElementMessage} as the * error message if an invalid value is supplied. This must be called * on both server and client so the serializer can be registered correctly * Since the instance is added to the type registry, this must happen during mod * initialization when the registries are mutable * * @param enumClass The {@code enum} type to parse for * @param noElementMessage The error message to send if an invalid value is * supplied, with an optional {@code {}} placeholder * for the supplied value * @param The {@code enum} type to parse for * @return A new argument type that can parse instances of {@code T} */ public static > EnumArgumentType create(Class enumClass, String noElementMessage) { final var type = new EnumArgumentType<>(enumClass, noElementMessage); ArgumentTypeRegistry.registerArgumentType(Owo.id("enum_" + enumClass.getName().toLowerCase(Locale.ROOT)), type.getClass(), SingletonArgumentInfo.contextFree(() -> type)); return type; } public T get(CommandContext context, String name) { return context.getArgument(name, enumClass); } @Override public CompletableFuture listSuggestions(CommandContext context, SuggestionsBuilder builder) { return SharedSuggestionProvider.suggest(Arrays.stream(enumClass.getEnumConstants()).map(Enum::toString), builder); } @Override public T parse(StringReader reader) throws CommandSyntaxException { final var name = reader.readString(); try { return Enum.valueOf(enumClass, name); } catch (IllegalArgumentException e) { throw noValueException.create(name); } } } ================================================ FILE: src/main/java/io/wispforest/owo/command/debug/CcaDataCommand.java ================================================ package io.wispforest.owo.command.debug; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import io.wispforest.owo.Owo; import io.wispforest.owo.ops.TextOps; import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.arguments.NbtPathArgument; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.NbtUtils; import net.minecraft.util.ProblemReporter; import net.minecraft.world.level.storage.TagValueOutput; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; public class CcaDataCommand { public static void register(CommandDispatcher dispatcher) { dispatcher.register(literal("cca-data").executes(CcaDataCommand::executeDumpAll) .then(argument("path", NbtPathArgument.nbtPath()).executes(CcaDataCommand::executeDumpPath))); } private static int executeDumpAll(CommandContext context) throws CommandSyntaxException { final var player = context.getSource().getPlayer(); final var writeView = TagValueOutput.createWithoutContext(new ProblemReporter.ScopedCollector(Owo.LOGGER)); player.save(writeView); final var nbt = writeView.buildResult().getCompound("cardinal_components").orElseGet(CompoundTag::new); context.getSource().sendSuccess(() -> TextOps.concat(Owo.PREFIX, TextOps.withFormatting("CCA Data:", ChatFormatting.GRAY)), false); context.getSource().sendSuccess(() -> NbtUtils.toPrettyComponent(nbt), false); return 0; } private static int executeDumpPath(CommandContext context) throws CommandSyntaxException { final var player = context.getSource().getPlayer(); final var path = NbtPathArgument.getPath(context, "path"); final var writeView = TagValueOutput.createWithoutContext(new ProblemReporter.ScopedCollector(Owo.LOGGER)); player.save(writeView); final var nbt = path.get(writeView.buildResult().getCompound("cardinal_components").orElseGet(CompoundTag::new)).iterator().next(); context.getSource().sendSuccess(() -> TextOps.concat(Owo.PREFIX, TextOps.withFormatting("CCA Data:", ChatFormatting.GRAY)), false); context.getSource().sendSuccess(() -> NbtUtils.toPrettyComponent(nbt), false); return 0; } } ================================================ FILE: src/main/java/io/wispforest/owo/command/debug/DumpdataCommand.java ================================================ package io.wispforest.owo.command.debug; import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.StringReader; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import io.wispforest.owo.Owo; import io.wispforest.owo.ops.TextOps; import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.arguments.NbtPathArgument; import net.minecraft.core.Registry; import net.minecraft.core.component.DataComponentPatch; import net.minecraft.core.component.DataComponents; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.NbtOps; import net.minecraft.nbt.NbtUtils; import net.minecraft.nbt.Tag; import net.minecraft.network.chat.Component; import net.minecraft.util.ProblemReporter; import net.minecraft.world.entity.projectile.ProjectileUtil; import net.minecraft.world.level.storage.TagValueOutput; import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.HitResult; import java.util.regex.Pattern; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; public class DumpdataCommand { private static final int GENERAL_PURPLE = 0xB983FF; private static final int KEY_BLUE = 0x94B3FD; private static final int VALUE_BLUE = 0x94DAFF; public static void register(CommandDispatcher dispatcher) { dispatcher.register(literal("dumpdata") .then(literal("item").executes(withRootPath(DumpdataCommand::executeItem)) .then(argument("nbt_path", NbtPathArgument.nbtPath()).executes(withPathArg(DumpdataCommand::executeItem)))) .then(literal("block").executes(withRootPath(DumpdataCommand::executeBlock)) .then(argument("nbt_path", NbtPathArgument.nbtPath()).executes(withPathArg(DumpdataCommand::executeBlock)))) .then(literal("entity").executes(withRootPath(DumpdataCommand::executeEntity)) .then(argument("nbt_path", NbtPathArgument.nbtPath()).executes(withPathArg(DumpdataCommand::executeEntity))))); } private static Command withRootPath(DataDumper dumper) { return context -> dumper.dump(context, NbtPathArgument.nbtPath().parse(new StringReader(""))); } private static Command withPathArg(DataDumper dumper) { return context -> { final var path = NbtPathArgument.getPath(context, "nbt_path"); return dumper.dump(context, path); }; } private static int executeItem(CommandContext context, NbtPathArgument.NbtPath path) throws CommandSyntaxException { final var source = context.getSource(); final var stack = source.getPlayer().getMainHandItem(); informationHeader(source, "Item"); sendIdentifier(source, stack.getItem(), BuiltInRegistries.ITEM); if (stack.get(DataComponents.MAX_DAMAGE) != null) { feedback(source, TextOps.withColor("Durability: §" + stack.get(DataComponents.MAX_DAMAGE), TextOps.color(ChatFormatting.GRAY), KEY_BLUE)); } else { feedback(source, TextOps.withFormatting("Not damageable", ChatFormatting.GRAY)); } if (!stack.getComponentsPatch().isEmpty()) { feedback(source, TextOps.withFormatting("Component changes" + formatPath(path) + ": ", ChatFormatting.GRAY) .append(NbtUtils.toPrettyComponent(getPath(DataComponentPatch.CODEC.encodeStart(NbtOps.INSTANCE, stack.getComponentsPatch()).getOrThrow(), path)))); } else { feedback(source, TextOps.withFormatting("No component changes", ChatFormatting.GRAY)); } feedback(source, TextOps.withFormatting("-----------------------", ChatFormatting.GRAY)); return 0; } private static int executeEntity(CommandContext context, NbtPathArgument.NbtPath path) throws CommandSyntaxException { final var source = context.getSource(); final var player = source.getPlayer(); final var target = ProjectileUtil.getEntityHitResult( player, player.getEyePosition(0), player.getEyePosition(0).add(player.getViewVector(0).scale(5)), player.getBoundingBox().expandTowards(player.getViewVector(0).scale(5)).inflate(1), entity -> true, 5 * 5); if (target == null || target.getType() != HitResult.Type.ENTITY) { source.sendFailure(TextOps.concat(Owo.PREFIX, Component.literal("You're not looking at an entity"))); return 1; } final var entity = target.getEntity(); informationHeader(source, "Entity"); sendIdentifier(source, entity.getType(), BuiltInRegistries.ENTITY_TYPE); var writeView = TagValueOutput.createWithoutContext(new ProblemReporter.ScopedCollector(Owo.LOGGER)); entity.save(writeView); feedback(source, TextOps.withFormatting("NBT" + formatPath(path) + ": ", ChatFormatting.GRAY) .append(NbtUtils.toPrettyComponent(getPath(writeView.buildResult(), path)))); feedback(source, TextOps.withFormatting("-----------------------", ChatFormatting.GRAY)); return 0; } private static int executeBlock(CommandContext context, NbtPathArgument.NbtPath path) throws CommandSyntaxException { final var source = context.getSource(); final var player = source.getPlayer(); final var target = player.pick(5, 0, false); if (target.getType() != HitResult.Type.BLOCK) { source.sendFailure(TextOps.concat(Owo.PREFIX, Component.literal("You're not looking at a block"))); return 1; } final var pos = ((BlockHitResult) target).getBlockPos(); final var blockState = player.level().getBlockState(pos); final var blockStateString = blockState.toString(); informationHeader(source, "Block"); sendIdentifier(source, blockState.getBlock(), BuiltInRegistries.BLOCK); if (blockStateString.contains("[")) { feedback(source, TextOps.withFormatting("State properties: ", ChatFormatting.GRAY)); var stateString = blockStateString.split(Pattern.quote("["))[1]; stateString = stateString.substring(0, stateString.length() - 1); var stateInfo = stateString.replaceAll("=", ": §").split(","); for (var property : stateInfo) { feedback(source, TextOps.withColor(" " + property, KEY_BLUE, VALUE_BLUE)); } } else { feedback(source, TextOps.withFormatting("No state properties", ChatFormatting.GRAY)); } final var blockEntity = player.level().getBlockEntity(pos); if (blockEntity != null) { feedback(source, TextOps.withFormatting("Block Entity NBT" + formatPath(path) + ": ", ChatFormatting.GRAY) .append(NbtUtils.toPrettyComponent(getPath(blockEntity.saveWithoutMetadata(player.registryAccess()), path)))); } else { feedback(source, TextOps.withFormatting("No block entity", ChatFormatting.GRAY)); } feedback(source, TextOps.withFormatting("-----------------------", ChatFormatting.GRAY)); return 0; } private static void sendIdentifier(CommandSourceStack source, T object, Registry registry) { final var id = registry.getKey(object).toString().split(":"); feedback(source, TextOps.withColor("Identifier: §" + id[0] + ":§" + id[1], TextOps.color(ChatFormatting.GRAY), KEY_BLUE, VALUE_BLUE)); } private static void informationHeader(CommandSourceStack source, String name) { feedback(source, TextOps.withColor("---[§ " + name + " Information §]---", TextOps.color(ChatFormatting.GRAY), GENERAL_PURPLE, TextOps.color(ChatFormatting.GRAY))); } private static void feedback(CommandSourceStack source, Component message) { source.sendSuccess(() -> message, false); } private static String formatPath(NbtPathArgument.NbtPath path) { return path.toString().isBlank() ? "" : "(" + path + ")"; } private static Tag getPath(Tag nbt, NbtPathArgument.NbtPath path) throws CommandSyntaxException { return path.get(nbt).iterator().next(); } @FunctionalInterface private interface DataDumper { int dump(CommandContext context, NbtPathArgument.NbtPath path) throws CommandSyntaxException; } } ================================================ FILE: src/main/java/io/wispforest/owo/command/debug/HealCommand.java ================================================ package io.wispforest.owo.command.debug; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.FloatArgumentType; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import io.wispforest.owo.Owo; import io.wispforest.owo.ops.TextOps; import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.arguments.EntityArgument; import net.minecraft.network.chat.Component; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.LivingEntity; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; public class HealCommand { public static void register(CommandDispatcher dispatcher) { dispatcher.register(literal("heal") .executes(HealCommand::executeFullHeal) .then(argument("amount", FloatArgumentType.floatArg(0)) .executes(HealCommand::executeSelfHeal)) .then(argument("entity", EntityArgument.entity()) .executes(HealCommand::executeTargetedFullHeal) .then(argument("amount", FloatArgumentType.floatArg(0)) .executes(HealCommand::executeTargetedHeal)))); } private static int executeFullHeal(CommandContext context) throws CommandSyntaxException { var target = context.getSource().getEntityOrException(); return executeHeal( context, target, target instanceof LivingEntity living ? living.getMaxHealth() : Float.MAX_VALUE ); } private static int executeSelfHeal(CommandContext context) throws CommandSyntaxException { return executeHeal( context, context.getSource().getEntityOrException(), FloatArgumentType.getFloat(context, "amount") ); } private static int executeTargetedFullHeal(CommandContext context) throws CommandSyntaxException { var target = EntityArgument.getEntity(context, "entity"); return executeHeal( context, target, target instanceof LivingEntity living ? living.getMaxHealth() : Float.MAX_VALUE ); } private static int executeTargetedHeal(CommandContext context) throws CommandSyntaxException { return executeHeal( context, EntityArgument.getEntity(context, "entity"), FloatArgumentType.getFloat(context, "amount") ); } private static int executeHeal(CommandContext context, Entity entity, float amount) throws CommandSyntaxException { if (entity instanceof LivingEntity living) { float healed = living.getHealth(); living.heal(amount); healed = living.getHealth() - healed; float thankYouMojang = healed; context.getSource().sendSuccess(() -> TextOps.concat(Owo.PREFIX, TextOps.withColor("healed §" + thankYouMojang + " §hp", TextOps.color(ChatFormatting.GRAY), OwoDebugCommands.GENERAL_PURPLE, TextOps.color(ChatFormatting.GRAY))), false); } else { context.getSource().sendFailure(TextOps.concat(Owo.PREFIX, Component.nullToEmpty("Cannot heal non living entity"))); } return (int) Math.floor(amount); } } //chyz was here ================================================ FILE: src/main/java/io/wispforest/owo/command/debug/MakeLootContainerCommand.java ================================================ package io.wispforest.owo.command.debug; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import net.minecraft.commands.CommandBuildContext; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.arguments.ResourceOrIdArgument; import net.minecraft.commands.arguments.item.ItemArgument; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; public class MakeLootContainerCommand { public static void register(CommandDispatcher dispatcher, CommandBuildContext registryAccess) { dispatcher.register(literal("make-loot-container") .then(argument("item", ItemArgument.item(registryAccess)) .then(argument("loot_table", ResourceOrIdArgument.lootTable(registryAccess)) .executes(MakeLootContainerCommand::execute)))); } // TODO: reimplement private static int execute(CommandContext context) throws CommandSyntaxException { // var targetStack = ItemStackArgumentType.getItemStackArgument(context, "item").createStack(1, false); // var tableId = RegistryEntryArgumentType.getLootTable(context, "loot_table"); // // var blockEntityTag = targetStack.get(DataComponentTypes.BLOCK_ENTITY_DATA); // if (blockEntityTag == null) { // blockEntityTag = TypedEntityData.create() // } // // blockEntityTag = blockEntityTag.apply(x -> { // x.putString("LootTable", tableId.getIdAsString()); // }); // targetStack.set(DataComponentTypes.BLOCK_ENTITY_DATA, blockEntityTag); // // context.getSource().getPlayer().getInventory().offerOrDrop(targetStack); return 0; } } ================================================ FILE: src/main/java/io/wispforest/owo/command/debug/OwoDebugCommands.java ================================================ package io.wispforest.owo.command.debug; import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; import com.mojang.brigadier.suggestion.SuggestionProvider; import com.mojang.logging.LogUtils; import io.wispforest.owo.Owo; import io.wispforest.owo.command.EnumArgumentType; import io.wispforest.owo.ops.TextOps; import io.wispforest.owo.renderdoc.RenderDoc; import io.wispforest.owo.renderdoc.RenderdocScreen; import io.wispforest.owo.ui.hud.HudInspectorScreen; import io.wispforest.owo.ui.parsing.ConfigureHotReloadScreen; import io.wispforest.owo.ui.parsing.UIModelLoader; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.ChatFormatting; import net.minecraft.client.Minecraft; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.commands.arguments.IdentifierArgument; import net.minecraft.core.BlockPos; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.ClickEvent; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.HoverEvent; import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.ai.village.poi.PoiManager; import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.HitResult; import org.jetbrains.annotations.ApiStatus; import org.slf4j.event.Level; import static net.minecraft.commands.Commands.argument; import static net.minecraft.commands.Commands.literal; @ApiStatus.Internal public class OwoDebugCommands { private static final EnumArgumentType LEVEL_ARGUMENT_TYPE = EnumArgumentType.create(Level.class, "'{}' is not a valid logging level"); private static final SuggestionProvider POI_TYPES = (context, builder) -> SharedSuggestionProvider.suggestResource(BuiltInRegistries.POINT_OF_INTEREST_TYPE.keySet(), builder); private static final SimpleCommandExceptionType NO_POI_TYPE = new SimpleCommandExceptionType(Component.nullToEmpty("Invalid POI type")); public static final int GENERAL_PURPLE = 0xB983FF; public static final int KEY_BLUE = 0x94B3FD; public static final int VALUE_BLUE = 0x94DAFF; public static void register() { CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { dispatcher.register(literal("logger").then(argument("level", LEVEL_ARGUMENT_TYPE).executes(context -> { final var level = LEVEL_ARGUMENT_TYPE.get(context, "level"); LogUtils.configureRootLoggingLevel(level); context.getSource().sendSuccess(() -> TextOps.concat(Owo.PREFIX, Component.nullToEmpty("global logging level set to: §9" + level)), false); return 0; }))); dispatcher.register(literal("query-poi").then(argument("poi_type", IdentifierArgument.id()).suggests(POI_TYPES) .then(argument("radius", IntegerArgumentType.integer()).executes(context -> { var player = context.getSource().getPlayer(); var poiType = BuiltInRegistries.POINT_OF_INTEREST_TYPE.getOptional(IdentifierArgument.getId(context, "poi_type")) .orElseThrow(NO_POI_TYPE::create); var entries = ((ServerLevel) player.level()).getPoiManager().getInRange(type -> type.value() == poiType, player.blockPosition(), IntegerArgumentType.getInteger(context, "radius"), PoiManager.Occupancy.ANY).toList(); player.displayClientMessage(TextOps.concat(Owo.PREFIX, TextOps.withColor("Found §" + entries.size() + " §entr" + (entries.size() == 1 ? "y" : "ies"), TextOps.color(ChatFormatting.GRAY), GENERAL_PURPLE, TextOps.color(ChatFormatting.GRAY))), false); for (var entry : entries) { final var entryPos = entry.getPos(); final var blockId = BuiltInRegistries.BLOCK.getKey(player.level().getBlockState(entryPos).getBlock()).toString(); final var posString = "(" + entryPos.getX() + " " + entryPos.getY() + " " + entryPos.getZ() + ")"; final var message = TextOps.withColor("-> §" + blockId + " §" + posString, TextOps.color(ChatFormatting.GRAY), KEY_BLUE, VALUE_BLUE); message.withStyle(style -> style.withClickEvent(new ClickEvent.SuggestCommand( "/tp " + entryPos.getX() + " " + entryPos.getY() + " " + entryPos.getZ())) .withHoverEvent(new HoverEvent.ShowText(Component.nullToEmpty("Click to teleport")))); player.displayClientMessage(message, false); } return entries.size(); })))); dispatcher.register(literal("dumpfield").then(argument("field_name", StringArgumentType.string()).executes(context -> { final var targetField = StringArgumentType.getString(context, "field_name"); final CommandSourceStack source = context.getSource(); final ServerPlayer player = source.getPlayer(); HitResult target = player.pick(5, 0, false); if (target.getType() != HitResult.Type.BLOCK) { source.sendFailure(TextOps.concat(Owo.PREFIX, Component.literal("You're not looking at a block"))); return 1; } BlockPos pos = ((BlockHitResult) target).getBlockPos(); final var blockEntity = player.level().getBlockEntity(pos); if (blockEntity == null) { source.sendFailure(TextOps.concat(Owo.PREFIX, Component.literal(("No block entity")))); return 1; } var blockEntityClass = blockEntity.getClass(); try { final var field = blockEntityClass.getDeclaredField(targetField); if (!field.canAccess(blockEntity)) field.setAccessible(true); final var value = field.get(blockEntity); source.sendSuccess(() -> TextOps.concat(Owo.PREFIX, TextOps.withColor("Field value: §" + value, TextOps.color(ChatFormatting.GRAY), KEY_BLUE)), false); } catch (Exception e) { source.sendFailure(TextOps.concat(Owo.PREFIX, Component.literal("Could not access field - " + e.getClass().getSimpleName() + ": " + e.getMessage()))); } return 0; }))); MakeLootContainerCommand.register(dispatcher, registryAccess); DumpdataCommand.register(dispatcher); HealCommand.register(dispatcher); if (FabricLoader.getInstance().isModLoaded("cardinal-components-base")) { CcaDataCommand.register(dispatcher); } }); } @Environment(EnvType.CLIENT) public static class Client { private static final SuggestionProvider LOADED_UI_MODELS = (context, builder) -> SharedSuggestionProvider.suggestResource(UIModelLoader.allLoadedModels(), builder); private static final SimpleCommandExceptionType NO_SUCH_UI_MODEL = new SimpleCommandExceptionType(Component.literal("No such UI model is loaded")); public static void register() { ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { dispatcher.register(ClientCommandManager.literal("owo-hud-inspect") .executes(context -> { Minecraft.getInstance().setScreen(new HudInspectorScreen()); return 0; })); dispatcher.register(ClientCommandManager.literal("owo-ui-set-reload-path") .then(ClientCommandManager.argument("model-id", IdentifierArgument.id()).suggests(LOADED_UI_MODELS).executes(context -> { var modelId = context.getArgument("model-id", Identifier.class); if (UIModelLoader.getPreloaded(modelId) == null) throw NO_SUCH_UI_MODEL.create(); Minecraft.getInstance().setScreen(new ConfigureHotReloadScreen(modelId, null)); return 0; }))); if (RenderDoc.isAvailable()) { dispatcher.register(ClientCommandManager.literal("renderdoc").executes(context -> { Minecraft.getInstance().setScreen(new RenderdocScreen()); return 1; }).then(ClientCommandManager.literal("comment") .then(ClientCommandManager.argument("capture_index", IntegerArgumentType.integer(0)) .then(ClientCommandManager.argument("comment", StringArgumentType.greedyString()) .executes(context -> { var capture = RenderDoc.getCapture(IntegerArgumentType.getInteger(context, "capture_index")); if (capture == null) { context.getSource().sendError(TextOps.concat(Owo.PREFIX, Component.nullToEmpty("no such capture"))); return 0; } RenderDoc.setCaptureComments(capture, StringArgumentType.getString(context, "comment")); context.getSource().sendFeedback(TextOps.concat(Owo.PREFIX, Component.nullToEmpty("comment updated"))); return 1; }))))); } }); } } } ================================================ FILE: src/main/java/io/wispforest/owo/compat/emi/EmiStackUtil.java ================================================ package io.wispforest.owo.compat.emi; import dev.emi.emi.api.FabricEmiStack; import dev.emi.emi.api.stack.EmiStack; import io.wispforest.owo.util.ViewerStack; import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant; import net.minecraft.world.item.Item; import net.minecraft.world.level.material.Fluid; public class EmiStackUtil { public static ViewerStack fromEmi(EmiStack stack) { if (stack.getKey() instanceof Item item) { return ViewerStack.OfItem.of(stack.getItemStack()); } else if (stack.getKey() instanceof Fluid fluid) { return new ViewerStack.OfFluid(FluidVariant.of(fluid, stack.getComponentChanges()), stack.getAmount()); } else { // TODO: custom EMI stack. return ViewerStack.OfItem.EMPTY; } } public static EmiStack toEmi(ViewerStack stack) { if (stack instanceof ViewerStack.OfItem ofItem) { return EmiStack.of(ofItem.asStack()); } else if (stack instanceof ViewerStack.OfFluid ofFluid) { return FabricEmiStack.of(ofFluid.fluid(), ofFluid.count()); } else { throw new IllegalStateException("Invalid ViewerStack"); } } } ================================================ FILE: src/main/java/io/wispforest/owo/compat/emi/OwoEmiPlugin.java ================================================ package io.wispforest.owo.compat.emi; import dev.emi.emi.api.EmiDragDropHandler; import dev.emi.emi.api.EmiPlugin; import dev.emi.emi.api.EmiRegistry; import dev.emi.emi.api.stack.EmiIngredient; import dev.emi.emi.api.stack.EmiStackInteraction; import dev.emi.emi.api.widget.Bounds; import io.wispforest.owo.braid.core.BraidScreen; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.widgets.recipeviewer.RecipeViewerExclusionZone; import io.wispforest.owo.braid.widgets.recipeviewer.RecipeViewerStack; import io.wispforest.owo.braid.widgets.recipeviewer.StackDropArea; import io.wispforest.owo.itemgroup.OwoItemGroup; import io.wispforest.owo.mixin.itemgroup.CreativeModeInventoryScreenAccessor; import io.wispforest.owo.ui.base.BaseOwoContainerScreen; import io.wispforest.owo.util.pond.OwoCreativeInventoryScreenExtensions; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen; import net.minecraft.world.phys.AABB; import java.util.ArrayList; import java.util.List; public class OwoEmiPlugin implements EmiPlugin { @Override public void register(EmiRegistry registry) { registry.addExclusionArea(CreativeModeInventoryScreen.class, (screen, consumer) -> { var group = CreativeModeInventoryScreenAccessor.owo$getSelectedTab(); if (!(group instanceof OwoItemGroup owoGroup)) return; if (owoGroup.getButtons().isEmpty()) return; int x = ((OwoCreativeInventoryScreenExtensions) screen).owo$getRootX(); int y = ((OwoCreativeInventoryScreenExtensions) screen).owo$getRootY(); int stackHeight = owoGroup.getButtonStackHeight(); y -= 13 * (stackHeight - 4); for (int i = 0; i < owoGroup.getButtons().size(); i++) { int xOffset = x + 198 + (i / stackHeight) * 26; int yOffset = y + 10 + (i % stackHeight) * 30; consumer.accept(new Bounds(xOffset, yOffset, 24, 24)); } }); registry.addGenericExclusionArea((screen, consumer) -> { if (!(screen instanceof BaseOwoContainerScreen owoHandledScreen)) return; owoHandledScreen.componentsForExclusionAreas() .map(component -> new Bounds(component.x(), component.y(), component.width(), component.height())) .forEach(consumer); }); registry.addGenericExclusionArea((screen, consumer) -> { if (!(screen instanceof BraidScreen braid)) return; var visitor = new WidgetInstance.Visitor() { @Override public void visit(WidgetInstance child) { if (child instanceof RecipeViewerExclusionZone.Instance area) { var bounds = area.computeGlobalBounds(); consumer.accept(new Bounds((int) bounds.minX, (int) bounds.minY, (int) (bounds.maxX - bounds.minX), (int) (bounds.maxY - bounds.minY))); } child.visitChildren(this); } }; braid.state.rootInstance().visitChildren(visitor); }); registry.addGenericStackProvider((screen, x, y) -> { if (!(screen instanceof BraidScreen braid)) return EmiStackInteraction.EMPTY; var hit = braid.state.hitTest(x, y) .firstWhere(i -> i.instance() instanceof RecipeViewerStack.Instance); if (hit == null) return EmiStackInteraction.EMPTY; var instance = (RecipeViewerStack.Instance) hit.instance(); return new EmiStackInteraction(EmiStackUtil.toEmi(instance.widget().stackProvider.get())); }); registry.addGenericDragDropHandler(new EmiDragDropHandler<>() { @Override public boolean dropStack(Screen screen, EmiIngredient stack, int x, int y) { if (!(screen instanceof BraidScreen braid)) return false; var hit = braid.state.hitTest(x, y) .firstWhere(i -> i.instance() instanceof StackDropArea.Instance); if (hit == null) return false; var instance = (StackDropArea.Instance) hit.instance(); var converted = EmiStackUtil.fromEmi(stack.getEmiStacks().get(0)); if (!instance.widget().stackPredicate.test(converted)) return false; instance.widget().stackAcceptor.accept(converted); return true; } @Override public void render(Screen screen, EmiIngredient dragged, GuiGraphics draw, int mouseX, int mouseY, float delta) { if (!(screen instanceof BraidScreen braid)) return; List allBounds = new ArrayList<>(); var converted = EmiStackUtil.fromEmi(dragged.getEmiStacks().get(0)); var visitor = new WidgetInstance.Visitor() { @Override public void visit(WidgetInstance child) { if (child instanceof StackDropArea.Instance area && area.widget().stackPredicate.test(converted)) { allBounds.add(area.computeGlobalBounds()); } child.visitChildren(this); } }; braid.state.rootInstance().visitChildren(visitor); for (AABB b : allBounds) { draw.fill((int) b.minX, (int) b.minY, (int) b.maxX, (int) b.maxY, 0x8822BB33); } } }); } } ================================================ FILE: src/main/java/io/wispforest/owo/compat/modmenu/OwoModMenuPlugin.java ================================================ package io.wispforest.owo.compat.modmenu; import com.google.common.collect.ForwardingMap; import com.terraformersmc.modmenu.api.ConfigScreenFactory; import com.terraformersmc.modmenu.api.ModMenuApi; import io.wispforest.owo.config.ui.ConfigScreenProviders; import net.minecraft.util.Util; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import java.util.HashMap; import java.util.Map; @ApiStatus.Internal public class OwoModMenuPlugin implements ModMenuApi { private static final Map> OWO_FACTORIES = new ForwardingMap<>() { @Override protected @NotNull Map> delegate() { return Util.make( new HashMap<>(), map -> ConfigScreenProviders.forEach((s, provider) -> map.put(s, provider::apply)) ); } }; @Override public Map> getProvidedConfigScreenFactories() { return OWO_FACTORIES; } } ================================================ FILE: src/main/java/io/wispforest/owo/compat/rei/OwoReiPlugin.java ================================================ package io.wispforest.owo.compat.rei; import dev.architectury.event.CompoundEventResult; import io.wispforest.owo.braid.core.BraidScreen; import io.wispforest.owo.braid.framework.instance.WidgetInstance; import io.wispforest.owo.braid.widgets.recipeviewer.RecipeViewerExclusionZone; import io.wispforest.owo.braid.widgets.recipeviewer.RecipeViewerStack; import io.wispforest.owo.braid.widgets.recipeviewer.StackDropArea; import io.wispforest.owo.itemgroup.OwoItemGroup; import io.wispforest.owo.mixin.itemgroup.CreativeModeInventoryScreenAccessor; import io.wispforest.owo.ui.base.BaseOwoContainerScreen; import io.wispforest.owo.util.pond.OwoCreativeInventoryScreenExtensions; import me.shedaniel.math.Rectangle; import me.shedaniel.rei.api.client.gui.drag.DraggableStack; import me.shedaniel.rei.api.client.gui.drag.DraggableStackVisitor; import me.shedaniel.rei.api.client.gui.drag.DraggedAcceptorResult; import me.shedaniel.rei.api.client.gui.drag.DraggingContext; import me.shedaniel.rei.api.client.plugins.REIClientPlugin; import me.shedaniel.rei.api.client.registry.screen.ExclusionZones; import me.shedaniel.rei.api.client.registry.screen.OverlayDecider; import me.shedaniel.rei.api.client.registry.screen.OverlayRendererProvider; import me.shedaniel.rei.api.client.registry.screen.ScreenRegistry; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Stream; public class OwoReiPlugin implements REIClientPlugin { @SuppressWarnings("UnstableApiUsage") private static @Nullable OverlayRendererProvider.Sink renderSink = null; @Override public void registerExclusionZones(ExclusionZones zones) { zones.register(CreativeModeInventoryScreen.class, screen -> { var group = CreativeModeInventoryScreenAccessor.owo$getSelectedTab(); if (!(group instanceof OwoItemGroup owoGroup)) return Collections.emptySet(); if (owoGroup.getButtons().isEmpty()) return Collections.emptySet(); int x = ((OwoCreativeInventoryScreenExtensions) screen).owo$getRootX(); int y = ((OwoCreativeInventoryScreenExtensions) screen).owo$getRootY(); int stackHeight = owoGroup.getButtonStackHeight(); y -= 13 * (stackHeight - 4); final var rectangles = new ArrayList(); for (int i = 0; i < owoGroup.getButtons().size(); i++) { int xOffset = x + 198 + (i / stackHeight) * 26; int yOffset = y + 10 + (i % stackHeight) * 30; rectangles.add(new Rectangle(xOffset, yOffset, 24, 24)); } return rectangles; }); zones.register(BaseOwoContainerScreen.class, screen -> { return ((BaseOwoContainerScreen) screen).componentsForExclusionAreas() .map(rect -> new Rectangle(rect.x(), rect.y(), rect.width(), rect.height())) .toList(); }); zones.register(BraidScreen.class, screen -> { List rectangles = new ArrayList<>(); var visitor = new WidgetInstance.Visitor() { @Override public void visit(WidgetInstance child) { if (child instanceof RecipeViewerExclusionZone.Instance area) { var bounds = area.computeGlobalBounds(); rectangles.add(new Rectangle(bounds.minX, bounds.minY, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY)); } child.visitChildren(this); } }; screen.state.rootInstance().visitChildren(visitor); return rectangles; }); } @Override public void registerScreens(ScreenRegistry registry) { registry.registerDecider(new OverlayDecider() { @Override public boolean isHandingScreen(Class screen) { return BaseOwoContainerScreen.class.isAssignableFrom(screen); } @Override @SuppressWarnings("UnstableApiUsage") public OverlayRendererProvider getRendererProvider() { return new OverlayRendererProvider() { @Override public void onApplied(Sink sink) { renderSink = sink; } @Override public void onRemoved() { renderSink = null; } }; } }); registry.registerFocusedStack((screen, mouse) -> { if (!(screen instanceof BraidScreen braid)) return CompoundEventResult.pass(); var hit = braid.state.hitTest(mouse.x, mouse.y) .firstWhere(x -> x.instance() instanceof RecipeViewerStack.Instance); if (hit == null) return CompoundEventResult.pass(); var instance = (RecipeViewerStack.Instance) hit.instance(); return CompoundEventResult.interruptTrue(ReiStackUtil.toRei(instance.widget().stackProvider.get())); }); registry.registerDraggableStackVisitor(new DraggableStackVisitor() { @Override public boolean isHandingScreen(R screen) { return screen instanceof BraidScreen; } @Override public Stream getDraggableAcceptingBounds(DraggingContext context, DraggableStack stack) { if (!(context.getScreen() instanceof BraidScreen braid)) return Stream.empty(); List allBounds = new ArrayList<>(); var converted = ReiStackUtil.fromRei(stack.getStack()); var visitor = new WidgetInstance.Visitor() { @Override public void visit(WidgetInstance child) { if (child instanceof StackDropArea.Instance area && area.widget().stackPredicate.test(converted)) { var bounds = area.computeGlobalBounds(); allBounds.add(BoundsProvider.ofRectangle(new Rectangle(bounds.minX, bounds.minY, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY))); } child.visitChildren(this); } }; braid.state.rootInstance().visitChildren(visitor); return allBounds.stream(); } @Override public DraggedAcceptorResult acceptDraggedStack(DraggingContext context, DraggableStack stack) { if (!(context.getScreen() instanceof BraidScreen braid)) return DraggedAcceptorResult.PASS; var hit = braid.state.hitTest(context.getCurrentPosition().x, context.getCurrentPosition().y) .firstWhere(x -> x.instance() instanceof StackDropArea.Instance); if (hit == null) return DraggedAcceptorResult.PASS; var instance = (StackDropArea.Instance) hit.instance(); var converted = ReiStackUtil.fromRei(stack.getStack()); if (!instance.widget().stackPredicate.test(converted)) return DraggedAcceptorResult.PASS; instance.widget().stackAcceptor.accept(converted); return DraggedAcceptorResult.ACCEPTED; } }); } // static { // ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> { // if (!(screen instanceof BaseOwoHandledScreenAccessor accessor)) return; // // ScreenEvents.beforeRender(screen).register(($, context, mouseX, mouseY, tickDelta) -> { // var root = accessor.owo$getUIAdapter().rootComponent; // // CallbackSurface surface; // if (root.surface() instanceof CallbackSurface wrapped) { // surface = wrapped; // } else { // surface = new CallbackSurface(root.surface()); // root.surface(surface); // } // // surface.callback = () -> { // if (renderSink == null) return; // renderOverlay($, () -> renderSink.render(context, mouseX, mouseY, tickDelta)); // }; // }); // // ScreenEvents.afterRender(screen).register(($, matrices, mouseX, mouseY, tickDelta) -> { // if (renderSink == null) return; // renderOverlay($, () -> renderSink.lateRender(matrices, mouseX, mouseY, tickDelta)); // }); // }); // } // // private static void renderOverlay(Screen screen, Runnable renderFunction) { // if (REIRuntime.getInstance().getSearchTextField().getText().equals("froge")) { // var modelView = RenderSystem.getModelViewStack(); // // final var time = System.currentTimeMillis(); // float scale = .75f + (float) (Math.sin(time / 500d) * .5f); // modelView.pushMatrix(); // modelView.translate(screen.width / 2f - scale / 2f * screen.width, screen.height / 2f - scale / 2f * screen.height, 0); // modelView.scale(scale, scale, 1f); // modelView.translate((float) (Math.sin(time / 1000d) * .75f) * screen.width, (float) (Math.sin(time / 500d) * .75f) * screen.height, 0); // // modelView.translate(screen.width / 2f, screen.height / 2f, 0); // modelView.rotate(RotationAxis.POSITIVE_Z.rotationDegrees((float) (time / 25d % 360d))); // modelView.translate(screen.width / -2f, screen.height / -2f, 0); // // for (int i = 0; i < 20; i++) { // modelView.pushMatrix(); // modelView.translate(screen.width / 2f, screen.height / 2f, 0); // modelView.rotate(RotationAxis.POSITIVE_Z.rotationDegrees(i * 18)); // modelView.translate(screen.width / -2f, screen.height / -2f, 0); // // ScissorStack.pushDirect(0, 0, Integer.MAX_VALUE, Integer.MAX_VALUE); // renderFunction.run(); // GlStateManager._enableScissorTest(); // ScissorStack.pop(); // modelView.popMatrix(); // } // // modelView.popMatrix(); // } else { // ScissorStack.pushDirect(0, 0, Integer.MAX_VALUE, Integer.MAX_VALUE); // renderFunction.run(); // GlStateManager._enableScissorTest(); // ScissorStack.pop(); // } // } // // private static class CallbackSurface implements Surface { // public final Surface inner; // public @NotNull Runnable callback = () -> {}; // // private CallbackSurface(Surface inner) { // this.inner = inner; // } // // @Override // public void draw(OwoUIDrawContext context, ParentComponent component) { // this.inner.draw(context, component); // this.callback.run(); // } // } } ================================================ FILE: src/main/java/io/wispforest/owo/compat/rei/ReiStackUtil.java ================================================ package io.wispforest.owo.compat.rei; import dev.architectury.fluid.FluidStack; import dev.architectury.hooks.fluid.fabric.FluidStackHooksFabric; import io.wispforest.owo.util.ViewerStack; import me.shedaniel.rei.api.common.entry.EntryStack; import me.shedaniel.rei.api.common.util.EntryStacks; import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant; import net.minecraft.world.item.ItemStack; public class ReiStackUtil { public static ViewerStack fromRei(EntryStack stack) { if (stack.getValue() instanceof ItemStack item) { return ViewerStack.OfItem.of(item); } else if (stack.getValue() instanceof FluidStack fluid) { return new ViewerStack.OfFluid(FluidVariant.of(fluid.getFluid(), fluid.getPatch()), fluid.getAmount()); } else { // TODO: custom REI stack. return ViewerStack.OfItem.EMPTY; } } public static EntryStack toRei(ViewerStack stack) { if (stack instanceof ViewerStack.OfItem ofItem) { return EntryStacks.of(ofItem.asStack()); } else if (stack instanceof ViewerStack.OfFluid ofFluid) { return EntryStacks.of(FluidStackHooksFabric.fromFabric(ofFluid.fluid(), ofFluid.count())); } else { throw new IllegalStateException("Invalid ViewerStack"); } } } ================================================ FILE: src/main/java/io/wispforest/owo/compat/rei/ReiUIAdapter.java ================================================ package io.wispforest.owo.compat.rei; import io.wispforest.owo.ui.core.OwoUIAdapter; import io.wispforest.owo.ui.core.ParentUIComponent; import io.wispforest.owo.ui.core.Sizing; import me.shedaniel.math.Point; import me.shedaniel.math.Rectangle; import me.shedaniel.rei.api.client.gui.widgets.Widget; import me.shedaniel.rei.api.client.gui.widgets.WidgetWithBounds; import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.input.CharacterEvent; import net.minecraft.client.input.KeyEvent; import net.minecraft.client.input.MouseButtonEvent; import java.util.List; import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; public class ReiUIAdapter extends Widget { public static final Point LAYOUT = new Point(-69, -69); public final OwoUIAdapter adapter; public ReiUIAdapter(Rectangle bounds, BiFunction rootComponentMaker) { this.adapter = OwoUIAdapter.createWithoutScreen(bounds.x, bounds.y, bounds.width, bounds.height, rootComponentMaker); this.adapter.inspectorZOffset = 900; var screenWithREI = Minecraft.getInstance().screen; if (screenWithREI != null) { ScreenEvents.remove(screenWithREI).register(screen -> this.adapter.dispose()); ScreenEvents.afterRender(screenWithREI).register((screen, drawContext, mouseX, mouseY, tickDelta) -> { this.adapter.drawTooltip(drawContext, mouseX, mouseY, tickDelta); }); } } public void prepare() { this.adapter.inflateAndMount(); } public T rootComponent() { return this.adapter.rootComponent; } public ReiWidgetComponent wrap(W widget) { return new ReiWidgetComponent(widget); } public ReiWidgetComponent wrap(Function widgetFactory, Consumer widgetConfigurator) { var widget = widgetFactory.apply(LAYOUT); widgetConfigurator.accept(widget); return new ReiWidgetComponent(widget); } @Override public boolean containsMouse(double mouseX, double mouseY) { return this.adapter.isMouseOver(mouseX, mouseY); } @Override public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { return this.adapter.mouseClicked(new MouseButtonEvent(click.x() - this.adapter.x(), click.y() - this.adapter.y(), click.buttonInfo()), doubled); } @Override public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { return this.adapter.mouseScrolled(mouseX - this.adapter.x(), mouseY - this.adapter.y(), horizontalAmount, verticalAmount); } @Override public boolean mouseReleased(MouseButtonEvent click) { return this.adapter.mouseReleased(new MouseButtonEvent(click.x() - this.adapter.x(), click.y() - this.adapter.y(), click.buttonInfo())); } @Override public boolean mouseDragged(MouseButtonEvent click, double deltaX, double deltaY) { return this.adapter.mouseDragged(new MouseButtonEvent(click.x() - this.adapter.x(), click.y() - this.adapter.y(), click.buttonInfo()), deltaX, deltaY); } @Override public boolean keyPressed(KeyEvent input) { return this.adapter.keyPressed(input); } @Override public boolean keyReleased(KeyEvent input) { return this.adapter.keyReleased(input); } @Override public boolean charTyped(CharacterEvent input) { return this.adapter.charTyped(input); } @Override public void render(GuiGraphics context, int mouseX, int mouseY, float partialTicks) { context.enableScissor(this.adapter.x(), this.adapter.y(), this.adapter.width(), this.adapter.height()); this.adapter.render(context, mouseX, mouseY, partialTicks); context.disableScissor(); } @Override public List children() { return List.of(); } } ================================================ FILE: src/main/java/io/wispforest/owo/compat/rei/ReiWidgetComponent.java ================================================ package io.wispforest.owo.compat.rei; import io.wispforest.owo.ui.base.BaseUIComponent; import io.wispforest.owo.ui.core.OwoUIGraphics; import io.wispforest.owo.ui.core.ParentUIComponent; import io.wispforest.owo.ui.core.Sizing; import me.shedaniel.rei.api.client.gui.widgets.WidgetWithBounds; import net.minecraft.client.input.CharacterEvent; import net.minecraft.client.input.KeyEvent; import net.minecraft.client.input.MouseButtonEvent; public class ReiWidgetComponent extends BaseUIComponent { private final WidgetWithBounds widget; protected ReiWidgetComponent(WidgetWithBounds widget) { this.widget = widget; var bounds = widget.getBounds(); this.horizontalSizing.set(Sizing.fixed(bounds.getWidth())); this.verticalSizing.set(Sizing.fixed(bounds.getHeight())); this.mouseEnter().subscribe(() -> { this.focusHandler().focus(this, FocusSource.KEYBOARD_CYCLE); }); this.mouseLeave().subscribe(() -> { this.focusHandler().focus(null, null); }); } @Override public void mount(ParentUIComponent parent, int x, int y) { super.mount(parent, x, y); this.applyToWidget(); } @Override public void draw(OwoUIGraphics graphics, int mouseX, int mouseY, float partialTicks, float delta) { this.widget.render(graphics, mouseX, mouseY, partialTicks); } @Override public void drawFocusHighlight(OwoUIGraphics context, int mouseX, int mouseY, float partialTicks, float delta) {} @Override protected int determineHorizontalContentSize(Sizing sizing) { return this.widget.getBounds().getWidth(); } @Override protected int determineVerticalContentSize(Sizing sizing) { return this.widget.getBounds().getHeight(); } @Override public void updateX(int x) { super.updateX(x); this.applyToWidget(); } @Override public void updateY(int y) { super.updateY(y); this.applyToWidget(); } private void applyToWidget() { var bounds = this.widget.getBounds(); bounds.x = this.x; bounds.y = this.y; bounds.width = this.width; bounds.height = this.height; } @Override public boolean onMouseDown(MouseButtonEvent click, boolean doubled) { return this.widget.mouseClicked(new MouseButtonEvent(this.x + click.x(), this.y + click.y(), click.buttonInfo()), doubled) | super.onMouseDown(click, doubled); } @Override public boolean onMouseUp(MouseButtonEvent click) { return this.widget.mouseReleased(new MouseButtonEvent(this.x + click.x(), this.y + click.y(), click.buttonInfo())) | super.onMouseUp(click); } @Override public boolean onMouseScroll(double mouseX, double mouseY, double amount) { return this.widget.mouseScrolled(this.x + mouseX, this.y + mouseY, 0, amount) | super.onMouseScroll(mouseX, mouseY, amount); } @Override public boolean onMouseDrag(MouseButtonEvent click, double deltaX, double deltaY) { return this.widget.mouseDragged(new MouseButtonEvent(this.x + click.x(), this.y + click.y(), click.buttonInfo()), deltaX, deltaY) | super.onMouseDrag(click, deltaX, deltaY); } @Override public boolean onCharTyped(CharacterEvent input) { return this.widget.charTyped(input) | super.onCharTyped(input); } @Override public boolean onKeyPress(KeyEvent input) { return this.widget.keyPressed(input) | super.onKeyPress(input); } @Override public boolean canFocus(FocusSource source) { return true; } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ConfigAP.java ================================================ package io.wispforest.owo.config; import io.wispforest.owo.config.annotation.Config; import io.wispforest.owo.config.annotation.Hook; import io.wispforest.owo.config.annotation.Nest; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import javax.annotation.processing.*; import javax.lang.model.SourceVersion; import javax.lang.model.element.ElementKind; import javax.lang.model.element.TypeElement; import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.tools.Diagnostic; import java.io.IOException; import java.io.PrintWriter; import java.util.*; @ApiStatus.Internal @SupportedAnnotationTypes("io.wispforest.owo.config.annotation.Config") @SupportedSourceVersion(SourceVersion.RELEASE_17) public class ConfigAP extends AbstractProcessor { private static final String WRAPPER_TEMPLATE = """ package {package}; import blue.endless.jankson.Jankson; import io.wispforest.owo.config.ConfigWrapper; import io.wispforest.owo.config.ConfigWrapper.BuilderConsumer; import io.wispforest.owo.config.Option; import io.wispforest.owo.util.Observable; import java.util.HashMap; import java.util.Map; import java.util.function.Consumer; public class {wrapper_class_name} extends ConfigWrapper<{config_class_name}> { public final Keys keys = new Keys(); {option_instances} private {wrapper_class_name}() { super({config_class_name}.class); } private {wrapper_class_name}(BuilderConsumer consumer) { super({config_class_name}.class, consumer); } public static {wrapper_class_name} createAndLoad() { var wrapper = new {wrapper_class_name}(); wrapper.load(); return wrapper; } public static {wrapper_class_name} createAndLoad(BuilderConsumer consumer) { var wrapper = new {wrapper_class_name}(consumer); wrapper.load(); return wrapper; } {accessors} {type_interfaces} public static class Keys { {key_constants} } } """; private static final String GET_ACCESSOR_TEMPLATE = """ public {field_type} {field_name}() { return {option_instance}.value(); } """; private static final String SET_ACCESSOR_TEMPLATE = """ public void {field_name}({field_type} value) { {option_instance}.set(value); } """; private static final String SUBSCRIBE_TEMPLATE = """ public void subscribeTo{field_name}(Consumer<{field_type}> subscriber) { {option_instance}.observe(subscriber); } """; private final Set nestTypes = new LinkedHashSet<>(); private Map primitivesToWrappers; @Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); final var typeUtils = processingEnv.getTypeUtils(); final var elementUtils = processingEnv.getElementUtils(); this.primitivesToWrappers = Map.of( typeUtils.getPrimitiveType(TypeKind.BYTE), elementUtils.getTypeElement("java.lang.Byte").asType(), typeUtils.getPrimitiveType(TypeKind.CHAR), elementUtils.getTypeElement("java.lang.Character").asType(), typeUtils.getPrimitiveType(TypeKind.SHORT), elementUtils.getTypeElement("java.lang.Short").asType(), typeUtils.getPrimitiveType(TypeKind.INT), elementUtils.getTypeElement("java.lang.Integer").asType(), typeUtils.getPrimitiveType(TypeKind.LONG), elementUtils.getTypeElement("java.lang.Long").asType(), typeUtils.getPrimitiveType(TypeKind.FLOAT), elementUtils.getTypeElement("java.lang.Float").asType(), typeUtils.getPrimitiveType(TypeKind.DOUBLE), elementUtils.getTypeElement("java.lang.Double").asType(), typeUtils.getPrimitiveType(TypeKind.BOOLEAN), elementUtils.getTypeElement("java.lang.Boolean").asType() ); } @Override public boolean process(Set annotations, RoundEnvironment roundEnv) { for (var annotation : annotations) { var annotatedElements = roundEnv.getElementsAnnotatedWith(annotation); for (var annotated : annotatedElements) { if (annotated.getKind() != ElementKind.CLASS) continue; var clazz = (TypeElement) annotated; var className = clazz.getQualifiedName().toString(); var wrapperName = annotated.getAnnotation(Config.class).wrapperName(); try { var file = this.processingEnv.getFiler().createSourceFile(wrapperName); try (var writer = new PrintWriter(file.openWriter())) { writer.println(makeWrapper(wrapperName, className, this.collectFields(Option.Key.ROOT, clazz, clazz.getAnnotation(Config.class).defaultHook()))); } } catch (IOException e) { throw new RuntimeException("Failed to generate config wrapper", e); } } } return true; } private List collectFields(Option.Key parent, TypeElement clazz, boolean defaultHook) { var messager = this.processingEnv.getMessager(); var list = new ArrayList(); for (var field : clazz.getEnclosedElements()) { if (field.getKind() != ElementKind.FIELD) continue; var fieldType = field.asType(); var fieldName = field.getSimpleName().toString(); if (fieldType.getKind() == TypeKind.TYPEVAR) { messager.printMessage(Diagnostic.Kind.ERROR, "Generic field types are not allowed in config classes"); } TypeElement typeElement = null; if (fieldType.getKind() == TypeKind.DECLARED) { typeElement = (TypeElement) ((DeclaredType) fieldType).asElement(); if (typeElement == clazz) { messager.printMessage(Diagnostic.Kind.ERROR, "Illegal self-reference in nested config object"); } } if (typeElement != null && field.getAnnotation(Nest.class) != null) { this.nestTypes.add(typeElement); list.add(new NestField(fieldName, collectFields(parent.child(fieldName), typeElement, defaultHook), typeElement.getSimpleName().toString())); } else { list.add(new ValueField(fieldName, parent.child(fieldName), field.asType(), defaultHook || field.getAnnotation(Hook.class) != null)); } } return list; } private String makeWrapper(String wrapperClassName, String configClassName, List fields) { var baseWrapper = WRAPPER_TEMPLATE .replace("{wrapper_class_name}", wrapperClassName) .replace("{package}", configClassName.substring(0, configClassName.lastIndexOf("."))) .replace("{config_class_name}", configClassName); var accessorMethods = new Writer(new StringBuilder()); var optionInstances = new Writer(new StringBuilder()); var keyConstants = new Writer(new StringBuilder()); var typeInterfaces = new Writer(new StringBuilder()); for (var nestType : this.nestTypes) { typeInterfaces.beginLine("public interface ").write(nestType.getSimpleName().toString()).endLine(" {"); typeInterfaces.beginBlock(); for (var enclosed : nestType.getEnclosedElements()) { if (enclosed.getKind() != ElementKind.FIELD) continue; if (enclosed.getAnnotation(Nest.class) != null) continue; typeInterfaces.beginLine(enclosed.asType().toString()).write(" ").write(enclosed.getSimpleName().toString()).endLine("();"); typeInterfaces.beginLine("void ").write(enclosed.getSimpleName().toString()).write("(").write(enclosed.asType().toString()).endLine(" value);"); } typeInterfaces.endBlock(); typeInterfaces.line("}"); } keyConstants.beginBlock(); for (var field : fields) { field.appendAccessors(accessorMethods, optionInstances, keyConstants); } return baseWrapper .replace("{option_instances}", optionInstances.finish()) .replace("{type_interfaces}\n", typeInterfaces.finish()) .replace("{key_constants}", keyConstants.finish()) .replace("{accessors}\n", accessorMethods.finish()); } private String makeGetAccessor(String fieldName, Option.Key fieldKey, TypeMirror fieldType) { return GET_ACCESSOR_TEMPLATE .replace("{option_instance}", constantNameOf(fieldKey)) .replace("{field_name}", fieldName) .replace("{field_type}", fieldType.toString()); } private String makeSetAccessor(String fieldName, Option.Key fieldKey, TypeMirror fieldType) { return SET_ACCESSOR_TEMPLATE .replace("{option_instance}", constantNameOf(fieldKey)) .replace("{field_name}", fieldName) .replace("{field_type}", fieldType.toString()); } private String makeSubscribe(String fieldName, Option.Key fieldKey, TypeMirror fieldType) { return SUBSCRIBE_TEMPLATE .replace("{option_instance}", constantNameOf(fieldKey)) .replace("{field_name}", fieldName) .replace("{field_type}", this.primitivesToWrappers.getOrDefault(fieldType, fieldType).toString()); } private String constantNameOf(Option.Key key) { return key.asString().replace(".", "_"); } private interface ConfigField { void appendAccessors(Writer accessors, Writer optionInstances, Writer keyConstants); } private final class ValueField implements ConfigField { private final String name; private final Option.Key key; private final TypeMirror type; private final boolean makeSubscribe; private ValueField(String name, Option.Key key, TypeMirror type, boolean makeSubscribe) { this.name = name; this.key = key; this.type = type; this.makeSubscribe = makeSubscribe; } @Override public void appendAccessors(Writer accessors, Writer optionInstances, Writer keyConstants) { keyConstants.line("public final Option.Key " + constantNameOf(this.key) + " = new Option.Key(\"" + this.key.asString() + "\");"); optionInstances.line("private final Option<" + primitivesToWrappers.getOrDefault(type, type) + "> " + constantNameOf(this.key) + " = this.optionForKey(this.keys." + constantNameOf(this.key) + ");"); accessors.append(makeGetAccessor(this.name, this.key, this.type)).write("\n"); accessors.append(makeSetAccessor(this.name, this.key, this.type)).write("\n"); if (this.makeSubscribe) accessors.append(makeSubscribe(capitalize(this.name), this.key, this.type)).write("\n"); } } private record NestField(String nestName, List children, String typeName) implements ConfigField { @Override public void appendAccessors(Writer accessors, Writer optionInstances, Writer keyConstants) { var nestClassName = capitalize(nestName); if (nestClassName.equals(typeName)) nestClassName += "_"; // TODO replace type interface with class and instantiate instead of one class per field accessors.beginLine("public final ").write(nestClassName).write(" ").write(nestName).write(" = new ").write(nestClassName).endLine("();"); accessors.beginLine("public class ").write(nestClassName).write(" implements ").write(typeName).endLine(" {"); accessors.beginBlock(); for (var child : children) { child.appendAccessors(accessors, optionInstances, keyConstants); } accessors.endBlock(); accessors.line("}"); } } private static String capitalize(String string) { return string.substring(0, 1).toUpperCase(Locale.ROOT) + string.substring(1); } private static class Writer implements CharSequence { private final StringBuilder builder; private int indentLevel = 1; private Writer(StringBuilder builder) { this.builder = builder; } public Writer beginLine(CharSequence text) { this.builder.append(" ".repeat(this.indentLevel * 4)).append(text); return this; } public void endLine(CharSequence text) { this.builder.append(text).append("\n"); } public void line(CharSequence text) { this.builder.append(" ".repeat(this.indentLevel)).append(text).append("\n"); } public Writer append(String text) { for (var line : text.split("\n")) { this.line(line); } return this; } public Writer write(CharSequence text) { this.builder.append(text); return this; } public void beginBlock() { this.indentLevel++; } public void endBlock() { this.indentLevel--; } public String finish() { if (this.builder.isEmpty()) return ""; if (this.builder.charAt(builder.length() - 1) == '\n') { this.builder.deleteCharAt(this.builder.length() - 1); } return this.builder.toString(); } @Override public int length() { return this.builder.length(); } @Override public char charAt(int index) { return this.builder.charAt(index); } @Override public @NotNull CharSequence subSequence(int start, int end) { return this.builder.subSequence(start, end); } @Override public @NotNull String toString() { return this.builder.toString(); } } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ConfigSynchronizer.java ================================================ package io.wispforest.owo.config; import com.google.common.collect.HashMultimap; import io.wispforest.endec.Endec; import io.wispforest.endec.impl.StructEndecBuilder; import io.wispforest.owo.Owo; import io.wispforest.owo.mixin.ServerCommonPacketListenerImplAccessor; import io.wispforest.owo.ops.TextOps; import io.wispforest.owo.serialization.CodecUtils; import io.wispforest.owo.serialization.endec.MinecraftEndecs; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.event.Event; import net.fabricmc.fabric.api.networking.v1.PacketByteBufs; import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.ChatFormatting; import net.minecraft.network.Connection; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerPlayer; import net.minecraft.util.Tuple; import org.jetbrains.annotations.Nullable; import java.util.HashMap; import java.util.Map; import java.util.WeakHashMap; import java.util.function.BiConsumer; public class ConfigSynchronizer { public static final Identifier CONFIG_SYNC_CHANNEL = Owo.id("config_sync"); private static final Map>> CLIENT_OPTION_STORAGE = new WeakHashMap<>(); private static final Map> KNOWN_CONFIGS = new HashMap<>(); private static final MutableComponent PREFIX = TextOps.concat(Owo.PREFIX, Component.nullToEmpty("§cunrecoverable config mismatch\n\n")); static void register(ConfigWrapper config) { KNOWN_CONFIGS.put(config.name(), config); } /** * Retrieve the options which the given player's client * sent to the server during config synchronization * * @param player The player for which to retrieve the client values * @param configName The name of the config for which to retrieve values * @return The player's client's values of the given config options, * or {@code null} if no config with the given name was synced */ public static @Nullable Map getClientOptions(ServerPlayer player, String configName) { var storage = CLIENT_OPTION_STORAGE.get(((ServerCommonPacketListenerImplAccessor) player.connection).owo$getConnection()); if (storage == null) return null; return storage.get(configName); } /** * Safer, more clear version of {@link #getClientOptions(ServerPlayer, String)} to * be used when the actual config wrapper is available * * @see #getClientOptions(ServerPlayer, String) */ public static @Nullable Map getClientOptions(ServerPlayer player, ConfigWrapper config) { return getClientOptions(player, config.name()); } private static ConfigSyncPacket toPacket(Option.SyncMode targetMode) { Map configs = new HashMap<>(); KNOWN_CONFIGS.forEach((configName, config) -> { var entry = new ConfigEntry(new HashMap<>()); config.allOptions().forEach((key, option) -> { if (option.syncMode().ordinal() < targetMode.ordinal()) return; FriendlyByteBuf optionBuf = PacketByteBufs.create(); option.write(optionBuf); entry.options().put(key.asString(), optionBuf); }); configs.put(configName, entry); }); return new ConfigSyncPacket(configs); } private static void read(ConfigSyncPacket packet, BiConsumer, FriendlyByteBuf> optionConsumer) { for (var configEntry : packet.configs().entrySet()) { var configName = configEntry.getKey(); var config = KNOWN_CONFIGS.get(configName); if (config == null) { Owo.LOGGER.error("Received overrides for unknown config '{}', skipping", configName); continue; } for (var optionEntry : configEntry.getValue().options().entrySet()) { var optionKey = new Option.Key(optionEntry.getKey()); var option = config.optionForKey(optionKey); if (option == null) { Owo.LOGGER.error("Received override for unknown option '{}' in config '{}', skipping", optionKey, configName); continue; } optionConsumer.accept(option, optionEntry.getValue()); } } } @Environment(EnvType.CLIENT) private static void applyClient(ConfigSyncPacket payload, ClientPlayNetworking.Context context) { Owo.LOGGER.info("Applying server overrides"); var mismatchedOptions = new HashMap, Object>(); if (!(context.client().hasSingleplayerServer() && context.client().getSingleplayerServer().isSingleplayer())) { read(payload, (option, packetByteBuf) -> { var mismatchedValue = option.read(packetByteBuf); if (mismatchedValue != null) mismatchedOptions.put(option, mismatchedValue); }); if (!mismatchedOptions.isEmpty()) { Owo.LOGGER.error("Aborting connection, non-syncable config values were mismatched"); mismatchedOptions.forEach((option, serverValue) -> { Owo.LOGGER.error("- Option {} in config '{}' has value '{}' but server requires '{}'", option.key().asString(), option.configName(), option.value(), serverValue); }); var errorMessage = Component.empty(); var optionsByConfig = HashMultimap., Object>>create(); mismatchedOptions.forEach((option, serverValue) -> optionsByConfig.put(option.configName(), new Tuple<>(option, serverValue))); for (var configName : optionsByConfig.keys()) { errorMessage.append(TextOps.withFormatting("in config ", ChatFormatting.GRAY)).append(configName).append("\n"); for (var option : optionsByConfig.get(configName)) { errorMessage.append(Component.translatable(option.getA().translationKey()).withStyle(ChatFormatting.YELLOW)).append(" -> "); errorMessage.append(option.getA().value().toString()).append(TextOps.withFormatting(" (client)", ChatFormatting.GRAY)); errorMessage.append(TextOps.withFormatting(" / ", ChatFormatting.DARK_GRAY)); errorMessage.append(option.getB().toString()).append(TextOps.withFormatting(" (server)", ChatFormatting.GRAY)).append("\n"); } errorMessage.append("\n"); } errorMessage.append(TextOps.withFormatting("these options could not be synchronized because\n", ChatFormatting.GRAY)); errorMessage.append(TextOps.withFormatting("they require your client to be restarted\n", ChatFormatting.GRAY)); errorMessage.append(TextOps.withFormatting("change them manually and restart if you want to join this server", ChatFormatting.GRAY)); context.player().connection.getConnection().disconnect(TextOps.concat(PREFIX, errorMessage)); return; } } Owo.LOGGER.info("Responding with client values"); context.responseSender().sendPacket(toPacket(Option.SyncMode.INFORM_SERVER)); } private static void applyServer(ConfigSyncPacket payload, ServerPlayNetworking.Context context) { Owo.LOGGER.info("Receiving client config"); var connection = ((ServerCommonPacketListenerImplAccessor) context.player().connection).owo$getConnection(); read(payload, (option, optionBuf) -> { var config = CLIENT_OPTION_STORAGE.computeIfAbsent(connection, $ -> new HashMap<>()).computeIfAbsent(option.configName(), s -> new HashMap<>()); config.put(option.key(), optionBuf.read(option.endec())); }); } private record ConfigSyncPacket(Map configs) implements CustomPacketPayload { public static final Type ID = new Type<>(CONFIG_SYNC_CHANNEL); public static final Endec ENDEC = StructEndecBuilder.of( ConfigEntry.ENDEC.mapOf().fieldOf("configs", ConfigSyncPacket::configs), ConfigSyncPacket::new ); @Override public Type type() { return ID; } } private record ConfigEntry(Map options) { public static final Endec ENDEC = StructEndecBuilder.of( MinecraftEndecs.FRIENDLY_BYTE_BUF.mapOf().fieldOf("options", ConfigEntry::options), ConfigEntry::new ); } static { var packetCodec = CodecUtils.toPacketCodec(ConfigSyncPacket.ENDEC); PayloadTypeRegistry.playS2C().register(ConfigSyncPacket.ID, packetCodec); PayloadTypeRegistry.playC2S().register(ConfigSyncPacket.ID, packetCodec); var earlyPhase = Owo.id("early"); ServerPlayConnectionEvents.JOIN.addPhaseOrdering(earlyPhase, Event.DEFAULT_PHASE); ServerPlayConnectionEvents.JOIN.register(earlyPhase, (handler, sender, server) -> { Owo.LOGGER.info("Sending server config values to client"); sender.sendPacket(toPacket(Option.SyncMode.OVERRIDE_CLIENT)); }); if (FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT) { ClientPlayNetworking.registerGlobalReceiver(ConfigSyncPacket.ID, ConfigSynchronizer::applyClient); ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> { KNOWN_CONFIGS.forEach((name, config) -> config.forEachOption(Option::reattach)); }); } ServerPlayNetworking.registerGlobalReceiver(ConfigSyncPacket.ID, ConfigSynchronizer::applyServer); } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ConfigWrapper.java ================================================ package io.wispforest.owo.config; import blue.endless.jankson.Jankson; import blue.endless.jankson.JsonElement; import blue.endless.jankson.JsonGrammar; import blue.endless.jankson.JsonPrimitive; import blue.endless.jankson.api.DeserializationException; import blue.endless.jankson.api.SyntaxError; import blue.endless.jankson.impl.POJODeserializer; import blue.endless.jankson.magic.TypeMagic; import io.wispforest.endec.Endec; import io.wispforest.endec.format.jankson.JanksonDeserializer; import io.wispforest.endec.format.jankson.JanksonSerializer; import io.wispforest.endec.impl.ReflectiveEndecBuilder; import io.wispforest.owo.Owo; import io.wispforest.owo.config.annotation.*; import io.wispforest.owo.config.ui.ConfigScreen; import io.wispforest.owo.config.ui.ConfigScreenProviders; import io.wispforest.owo.serialization.endec.MinecraftEndecs; import io.wispforest.owo.ui.core.Color; import io.wispforest.owo.util.NumberReflection; import io.wispforest.owo.util.Observable; import io.wispforest.owo.util.ReflectionUtils; import net.fabricmc.api.EnvType; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.regex.Pattern; /** * The common base class of all generated config classes. * The majority of all config functionality resides in here *

* Do not extend this class yourself - instead annotate * a class describing your config model with {@link Config}, * just as you would do with other libraries like Cloth Config * * @see Config */ public abstract class ConfigWrapper { private static final Map> KNOWN_CONFIG_CLASSES = new HashMap<>(); protected final String name; protected final C instance; protected boolean loading = false; protected final Jankson jankson; @SuppressWarnings("rawtypes") protected final Map options = new LinkedHashMap<>(); @SuppressWarnings("rawtypes") protected final Map optionsView = Collections.unmodifiableMap(options); protected final ReflectiveEndecBuilder builder; @Deprecated protected ConfigWrapper(Class clazz, Consumer janksonBuilder) { this(clazz, (SerializationBuilder serializationBuilder) -> janksonBuilder.accept(serializationBuilder.janksonBuilder())); } protected ConfigWrapper(Class clazz) { this(clazz, (SerializationBuilder builder) -> {}); } protected ConfigWrapper(Class clazz, BuilderConsumer consumer) { this.builder = MinecraftEndecs.addDefaults(new ReflectiveEndecBuilder()); ReflectionUtils.requireZeroArgsConstructor(clazz, s -> "Config model class " + s + " must provide a zero-args constructor"); this.instance = ReflectionUtils.tryInstantiateWithNoArgs(clazz); var janksonBuilder = Jankson.builder(); var builder = new SerializationBuilder(janksonBuilder, this.builder); builder.janksonBuilder() .registerSerializer(Identifier.class, (identifier, marshaller) -> new JsonPrimitive(identifier.toString())) .registerDeserializer(JsonPrimitive.class, Identifier.class, (primitive, m) -> Identifier.tryParse(primitive.asString())); builder.addEndec(Color.class, Color.RGBA_HEX_ENDEC); consumer.build(builder); this.jankson = janksonBuilder.build(); var configAnnotation = clazz.getAnnotation(Config.class); this.name = configAnnotation.name(); if (KNOWN_CONFIG_CLASSES.put(this.name, this.getClass()) != null) { throw new IllegalStateException("Config name '" + this.name + "'" + " is already taken by an instance of class '" + KNOWN_CONFIG_CLASSES.get(this.name).getName() + "'"); } if (FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT && clazz.isAnnotationPresent(Modmenu.class)) { var modmenuAnnotation = clazz.getAnnotation(Modmenu.class); ConfigScreenProviders.register( modmenuAnnotation.modId(), screen -> ConfigScreen.createWithCustomModel(Identifier.parse(modmenuAnnotation.uiModelId()), this, screen) ); } try { this.initializeOptions(configAnnotation.saveOnModification()); for (var option : this.options.values()) { if (option.syncMode().isNone()) continue; ConfigSynchronizer.register(this); break; } } catch (IllegalAccessException | NoSuchMethodException e) { throw new RuntimeException("Failed to initialize config " + this.name, e); } } /** * Save the config represented by this wrapper */ public void save() { if (this.loading) return; try { this.fileLocation().getParent().toFile().mkdirs(); Files.writeString(this.fileLocation(), this.jankson.toJson(this.instance).toJson(JsonGrammar.JANKSON), StandardCharsets.UTF_8); } catch (IOException e) { Owo.LOGGER.warn("Could not save config {}", this.name, e); } } /** * Load the config represented by this wrapper from * its associated file, or create it if it does not exist */ @SuppressWarnings({"unchecked"}) public void load() { if (!Files.exists(this.fileLocation())) { this.save(); return; } try { this.loading = true; var configObject = this.jankson.load(Files.readString(this.fileLocation(), StandardCharsets.UTF_8)); for (var option : this.options.values()) { Object newValue; final var clazz = option.clazz(); final var element = configObject.recursiveGet(JsonElement.class, option.key().asString()); if (element == null) { option.set(option.defaultValue()); continue; } if (Map.class.isAssignableFrom(clazz)) { var field = option.backingField().field(); newValue = TypeMagic.createAndCast(clazz); POJODeserializer.unpackMap( (Map) newValue, ReflectionUtils.getTypeArgument(field.getGenericType(), 0), ReflectionUtils.getTypeArgument(field.getGenericType(), 1), element, this.jankson.getMarshaller() ); } else if (List.class.isAssignableFrom(clazz) || Set.class.isAssignableFrom(clazz)) { newValue = TypeMagic.createAndCast(clazz); POJODeserializer.unpackCollection( (Collection) newValue, ReflectionUtils.getTypeArgument(option.backingField().field().getGenericType(), 0), element, this.jankson.getMarshaller() ); } else { newValue = configObject.getMarshaller().marshall(clazz, element); } if (!option.verifyConstraint(newValue)) continue; option.set(newValue == null ? option.defaultValue() : newValue); } } catch (IOException | SyntaxError | DeserializationException e) { Owo.LOGGER.warn("Could not load config {}", this.name, e); } finally { this.loading = false; } } /** * Query the field associated with a given key. This is relevant * in cases where said field is annotated with {@link Nest}, meaning * that {@link #optionForKey(Option.Key)} would return {@code null} * because the field won't be treated as an option in itself. * * @param key The for which to query the field * @return The field described by {@code key}, or {@code null} * if it does not point to a valid field in the config tree */ public @Nullable Field fieldForKey(Option.Key key) { try { var path = new ArrayList<>(List.of(key.path())); var clazz = this.instance.getClass(); while (path.size() > 1) { clazz = clazz.getDeclaredField(path.remove(0)).getType(); } return clazz.getField(path.get(0)); } catch (NoSuchFieldException e) { return null; } } /** * @return The name of this config, used for translation * keys and the filename */ public String name() { return this.name; } /** * @return The location to which this config is saved */ public Path fileLocation() { return FabricLoader.getInstance().getConfigDir().resolve(this.name + ".json5"); } /** * Query the config option associated with a given key * * @param key The key for which to query the option * @return The option described by {@code key}, or {@code null} * if no such option exists */ @SuppressWarnings("unchecked") public @Nullable Option optionForKey(Option.Key key) { return this.options.get(key); } /** * @return A view of all options contained in this config */ @SuppressWarnings("unchecked") public Map> allOptions() { return (Map>) (Object) this.optionsView; } /** * Execute the given action once for each option in this config */ public void forEachOption(Consumer> action) { for (var option : this.options.values()) { action.accept(option); } } private void initializeOptions(boolean hookSave) throws IllegalAccessException, NoSuchMethodException { var fields = new LinkedHashMap>(); collectFieldValues(Option.Key.ROOT, this.instance, fields); var instanceSyncMode = this.instance.getClass().isAnnotationPresent(Sync.class) ? this.instance.getClass().getAnnotation(Sync.class).value() : Option.SyncMode.NONE; for (var entry : fields.entrySet()) { var key = entry.getKey(); var boundField = entry.getValue(); var field = boundField.field(); var fieldType = field.getType(); Constraint constraint = null; if (field.isAnnotationPresent(RangeConstraint.class)) { var annotation = field.getAnnotation(RangeConstraint.class); if (NumberReflection.isNumberType(fieldType)) { Predicate predicate; if (fieldType == long.class || fieldType == Long.class) { predicate = o -> o != null && (Long) o >= annotation.min() && (Long) o <= annotation.max(); } else { predicate = o -> o != null && ((Number) o).doubleValue() >= annotation.min() && ((Number) o).doubleValue() <= annotation.max(); } constraint = new Constraint("Range from " + annotation.min() + " to " + annotation.max(), predicate); } else { throw new IllegalStateException("@RangeConstraint can only be applied to numeric fields"); } } if (field.isAnnotationPresent(RegexConstraint.class)) { var annotation = field.getAnnotation(RegexConstraint.class); if (CharSequence.class.isAssignableFrom(fieldType)) { var pattern = Pattern.compile(annotation.value()); constraint = new Constraint("Regex " + annotation.value(), o -> o != null && pattern.matcher((CharSequence) o).matches()); } else { throw new IllegalStateException("@RegexConstraint can only be applied to fields with a string representation"); } } if (field.isAnnotationPresent(PredicateConstraint.class)) { var annotation = field.getAnnotation(PredicateConstraint.class); var method = boundField.owner().getClass().getMethod(annotation.value(), fieldType); if (method.getReturnType() != boolean.class) { throw new NoSuchMethodException("Return type of predicate implementation '" + annotation.value() + "' must be 'boolean'"); } if (!Modifier.isStatic(method.getModifiers())) { throw new IllegalStateException("Predicate implementation '" + annotation.value() + "' must be static"); } var handle = MethodHandles.publicLookup().unreflect(method); constraint = new Constraint("Predicate method " + annotation.value(), o -> this.invokePredicate(handle, o)); } final var defaultValue = boundField.getValue(); final var observable = Observable.of(defaultValue); if (hookSave) observable.observe(o -> this.save()); var syncMode = instanceSyncMode; if (field.isAnnotationPresent(Sync.class)) { syncMode = field.getAnnotation(Sync.class).value(); } else { var parentKey = key.parent(); while (!parentKey.isRoot()) { var parentField = this.fieldForKey(parentKey); if (parentField.isAnnotationPresent(Sync.class)) { syncMode = parentField.getAnnotation(Sync.class).value(); } parentKey = parentKey.parent(); } } this.options.put(key, new Option<>(this.name, key, defaultValue, observable, boundField, constraint, syncMode, this.builder)); } } private void collectFieldValues(Option.Key parent, Object instance, Map> fields) throws IllegalAccessException { for (var field : instance.getClass().getDeclaredFields()) { if (Modifier.isTransient(field.getModifiers()) || Modifier.isStatic(field.getModifiers())) continue; if (field.isAnnotationPresent(Nest.class)) { var fieldValue = field.get(instance); if (fieldValue != null) { this.collectFieldValues(parent.child(field.getName()), fieldValue, fields); } else { throw new IllegalStateException("Nested config option containers must never be null"); } } else { fields.put(parent.child(field.getName()), new Option.BoundField<>(instance, field)); } } } private boolean invokePredicate(MethodHandle predicate, Object value) { try { return (boolean) predicate.invoke(value); } catch (Throwable e) { throw new RuntimeException("Could not invoke predicate", e); } } @SuppressWarnings({"rawtypes", "unchecked"}) public record Constraint(String formatted, Predicate predicate) { public boolean test(Object value) { return this.predicate.test(value); } } public record SerializationBuilder(Jankson.Builder janksonBuilder, ReflectiveEndecBuilder endecBuilder) { public SerializationBuilder addEndec(Class clazz, Endec endec) { endecBuilder().register(endec, clazz); janksonBuilder() .registerSerializer(clazz, (t, marshaller) -> endec.encodeFully(JanksonSerializer::of, t)) .registerDeserializer(JsonElement.class, clazz, (element, marshaller) -> endec.decodeFully(JanksonDeserializer::of, element)); return this; } } public interface BuilderConsumer { void build(SerializationBuilder builder); } } ================================================ FILE: src/main/java/io/wispforest/owo/config/Option.java ================================================ package io.wispforest.owo.config; import io.wispforest.endec.Endec; import io.wispforest.endec.impl.ReflectiveEndecBuilder; import io.wispforest.owo.Owo; import io.wispforest.owo.config.annotation.RestartRequired; import io.wispforest.owo.util.Observable; import net.minecraft.network.FriendlyByteBuf; import org.jetbrains.annotations.Nullable; import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.function.Consumer; /** * Describes a single option in a config. Instances * of this class keep a reference to the field in * the model class which stores the value used for serialization. *

* An option may enter the so-called "detached" state, which means * its value is being overridden by the server. In this state, the option * is completely immutable and can only be changed again afterwards */ public final class Option { private final String configName; private final Key key; private final String translationKey; private final T defaultValue; private final Observable mirror; private final BoundField backingField; private final Class clazz; private final ConfigWrapper.@Nullable Constraint constraint; private final @Nullable Endec endec; private final SyncMode syncMode; /** * Indicates whether this option is currently being overridden * by the server and should thus never synchronize with its backing * field and behave immutably to the client */ private boolean detached = false; /** * @param configName The name of the config this option is contained in * @param key The key of this option * @param defaultValue The default value of this option * @param mirror A mirror of the value of this option, used for * emitting events when it changes as well as correcting * invalid values after deserialization * @param backingField The backing field in the config model class * which this option describes * @param constraint The constraint placed on the value of this option, * or {@code null} if the option is unconstrained */ @SuppressWarnings("unchecked") public Option(String configName, Key key, T defaultValue, Observable mirror, BoundField backingField, @Nullable ConfigWrapper.Constraint constraint, SyncMode syncMode, ReflectiveEndecBuilder builder ) { this.configName = configName; this.key = key; this.translationKey = "text.config." + this.configName + ".option." + this.key.asString(); this.defaultValue = defaultValue; this.mirror = mirror; this.backingField = backingField; this.clazz = (Class) backingField.field().getType(); this.constraint = constraint; this.syncMode = syncMode; this.endec = syncMode.isNone() ? null : (Endec) builder.get(this.backingField.field.getGenericType()); } /** * Update the current value of this option, * or do nothing if the given value is invalid * * @param value The new value of the option */ public void set(T value) { if (this.detached) return; if (!this.verifyConstraint(value)) return; this.backingField.setValue(value); this.mirror.set(value); } /** * @return The current value of this option */ public T value() { return this.mirror.get(); } /** * @return The class of this option's value */ public Class clazz() { return this.clazz; } /** * Synchronize the value stored in the backing field * and this option's mirror - used for either correcting an * invalid value after updating the field or updating the mirror */ public void synchronizeWithBackingField() { if (this.detached) return; final var fieldValue = (T) this.backingField.getValue(); if (verifyConstraint(fieldValue)) { this.mirror.set(fieldValue); } else { this.backingField.setValue(this.mirror.get()); } } /** * Check whether the given value passes the constraint * of this option and emit a warning if it does not * * @param value The value to test * @return {@code true} if either the given value * passes the constraint put on this option or this * option is unconstrained */ public boolean verifyConstraint(T value) { if (this.constraint == null) return true; final var matched = this.constraint.test(value); if (!matched) { Owo.LOGGER.warn( "Option {} in config '{}' could not be updated, as the given value '{}' does not match its constraint: {}", this.key, this.configName, value, this.constraint.formatted() ); } return matched; } /** * Add an observer function to be run every time * the value of this option changes */ public void observe(Consumer observer) { this.mirror.observe(observer); } /** * Write the current value of this option into the given buffer * * @param buf The packet buffer to write to */ void write(FriendlyByteBuf buf) { buf.write(this.endec, this.value()); } /** * Read a new value of this option from the given buffer * and enter a detached state * * @param buf The packet buffer to read from * @return {@code null} if this option was successfully detached, * the server's value otherwise */ T read(FriendlyByteBuf buf) { final var newValue = buf.read(this.endec); if (!Objects.equals(newValue, this.value()) && this.backingField.hasAnnotation(RestartRequired.class)) { return newValue; } this.mirror.set(newValue); this.detached = true; return null; } /** * @return The serializer for this option's value */ Endec endec() { return this.endec; } /** * Reset this option's attached state and synchronize * it with the backing field again */ void reattach() { if (!this.detached) return; this.detached = false; this.synchronizeWithBackingField(); } // ------------- /** * @return The translation key of this option */ public String translationKey() { return this.translationKey; } /** * @return The name of the config this option is contained in */ public String configName() { return configName; } /** * @return The key of this option */ public Key key() { return key; } /** * @return The default value of this option */ public T defaultValue() { return defaultValue; } /** * @return The field which is backing this option, * used for serialization as well as storing the client's * value while the option is detached */ public BoundField backingField() { return backingField; } /** * @return The constraint placed on the value of this option, * or {@code null} if the option is unconstrained */ public ConfigWrapper.@Nullable Constraint constraint() { return constraint; } /** * @return {@code true} if this option is currently detached */ public boolean detached() { return this.detached; } /** * @return The way in which this option * should be synchronized between sever and client */ public SyncMode syncMode() { return this.syncMode; } @Override public String toString() { return "Option[" + "configName=" + configName + ", " + "key=" + key + ", " + "defaultValue=" + defaultValue + ", " + "constraint=" + (constraint == null ? null : constraint.formatted()) + "]"; } // ------------- public enum SyncMode { /** * Do not ever send this option over the network */ NONE, /** * Only send the client's value to the server, * but not vice-versa */ INFORM_SERVER, /** * Send the client's value to the server * and send the server's value back, * overriding the client's value */ OVERRIDE_CLIENT; public boolean isNone() { return this == NONE; } } /** * Describes an option's location inside a * config, generated from its name a potential * parents it is nested in * * @param path The segments of the path making up this key */ public record Key(String[] path) { public static final Key ROOT = new Key(new String[0]); public Key(List path) { this(path.toArray(String[]::new)); } public Key(String key) { this(key.split("\\.")); } /** * @return The immediate parent of this key, * or {@link #ROOT} if the parent is the root key */ public Key parent() { if (this.path.length <= 1) return ROOT; var newPath = new String[this.path.length - 1]; System.arraycopy(this.path, 0, newPath, 0, this.path.length - 1); return new Key(newPath); } /** * Create the key for a child of this key * * @param childName The name of the child */ public Key child(String childName) { var newPath = new String[this.path.length + 1]; System.arraycopy(this.path, 0, newPath, 0, this.path.length); newPath[this.path.length] = childName; return new Key(newPath); } /** * @return The segments of this key joined with {@code .} */ public String asString() { return String.join(".", this.path); } /** * @return The name of the element this key describes, * without any of its parents */ public String name() { if (this.path.length < 1) return ""; return this.path[this.path.length - 1]; } /** * @return {@code true} if and only if this * key is reference-equal to {@link #ROOT} */ public boolean isRoot() { return this == ROOT; } // Records don't play nicely with arrays, thus need to manually // declare all the record autogenerated stuff here @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Key key = (Key) o; return Arrays.equals(path, key.path); } @Override public int hashCode() { return Arrays.hashCode(path); } @Override public String toString() { return "Key{" + "path=" + Arrays.toString(path) + '}'; } } /** * A simple container which stores both a non-static field * and an instance of the containing class on which to query * values * * @param owner The owner object which holds the value * the field points to * @param field The field itself * @param The type of object this field stores */ @SuppressWarnings("unchecked") public record BoundField(Object owner, Field field) { public boolean hasAnnotation(Class annotationClass) { return field.isAnnotationPresent(annotationClass); } public A getAnnotation(Class annotationClass) { return this.field.getAnnotation(annotationClass); } public T getValue() { try { return (T) this.field.get(this.owner); } catch (IllegalAccessException e) { throw new RuntimeException("Could not access config option field " + field.getName(), e); } } public void setValue(T value) { try { this.field.set(this.owner, value); } catch (IllegalAccessException e) { throw new RuntimeException("Could not set config option field " + field.getName(), e); } } } } ================================================ FILE: src/main/java/io/wispforest/owo/config/OwoConfigCommand.java ================================================ package io.wispforest.owo.config; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.StringReader; import com.mojang.brigadier.arguments.ArgumentType; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; import io.wispforest.owo.Owo; import io.wispforest.owo.config.ui.ConfigScreen; import io.wispforest.owo.config.ui.ConfigScreenProviders; import io.wispforest.owo.ops.TextOps; import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; import net.minecraft.commands.CommandBuildContext; import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.ApiStatus; import java.util.ArrayList; import java.util.concurrent.CompletableFuture; @ApiStatus.Internal public class OwoConfigCommand { public static void register(CommandDispatcher dispatcher, CommandBuildContext access) { dispatcher.register(ClientCommandManager.literal("owo-config") .then(ClientCommandManager.argument("config_id", new ConfigScreenArgumentType()) .executes(context -> { var screen = context.getArgument("config_id", ConfigScreen.class); Minecraft.getInstance().schedule(() -> Minecraft.getInstance().setScreen(screen)); return 0; }))); } private static class ConfigScreenArgumentType implements ArgumentType { private static final SimpleCommandExceptionType NO_SUCH_CONFIG_SCREEN = new SimpleCommandExceptionType( TextOps.concat(Owo.PREFIX, Component.literal("no config screen with that id")) ); @Override public Screen parse(StringReader reader) throws CommandSyntaxException { var provider = ConfigScreenProviders.get(reader.readString()); if (provider == null) throw NO_SUCH_CONFIG_SCREEN.create(); return provider.apply(null); } @Override public CompletableFuture listSuggestions(CommandContext context, SuggestionsBuilder builder) { var configNames = new ArrayList(); ConfigScreenProviders.forEach((s, screenFunction) -> configNames.add(s)); return SharedSuggestionProvider.suggest(configNames, builder); } } } ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/Config.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to a class to mark is as a config model. This means an * implementation of {@link io.wispforest.owo.config.ConfigWrapper} * will be generated which can subsequently be used to manage * the config data described by the annotated class * * @see io.wispforest.owo.config.ConfigWrapper */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Config { /** * @return The name of the wrapper class to generate */ String wrapperName(); /** * @return The name under which to save the config */ String name(); /** * @return {@code true} if all fields should be treated * as if they were annotated with {@link Hook} */ boolean defaultHook() default false; /** * @return {@code true} if this config should automatically * be saved whenever it is modified */ boolean saveOnModification() default true; } ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/ExcludeFromScreen.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to a field to declare that * it should be ignored when generating * the config screen */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface ExcludeFromScreen {} ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/Expanded.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Declares that the annotated, collapsible * config option (list or nested object) should start * expanded when the config screen is opened */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface Expanded {} ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/Hook.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Target; /** * Applied to a field to declare that a method for * registering subscribers should be generated */ @Target(ElementType.FIELD) public @interface Hook {} ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/Modmenu.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to a class also annotated with {@link Config} * to indicate that a standard owo-config screen should * automatically be provided to ModMenu. *

* In case you want more specific control over the generated * screen, potentially with a special subclass, you should instead * implement {@link com.terraformersmc.modmenu.api.ModMenuApi} like usual */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Modmenu { /** * @return The mod ID for which to register * the config screen factory */ String modId(); /** * @return The ID of the UI model to use for the screen. * You can change this to a model you provide in your * mod's resources to customize the generated screen */ String uiModelId() default "owo:config"; } ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/Nest.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to a class to declare that instances of it * should be treated as a container for nested options * within a class annotated with {@link Config} instead of * as an option in itself */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface Nest {} ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/PredicateConstraint.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to fields to define the name of a predicate * method to use for verifying values of said field */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface PredicateConstraint { String value(); } ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/RangeConstraint.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to fields with a numeric value to express * a range of values which should be accepted */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface RangeConstraint { double min(); double max(); /** * @return How many decimals places to show in the config * screen, if this is a floating point option */ int decimalPlaces() default 2; } ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/RegexConstraint.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to fields which can be represented as a {@link CharSequence} * to define a regular expressions all values need to match */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface RegexConstraint { String value(); } ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/RestartRequired.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to a field to indicate * that changes made to its value will only * apply after a restart of the game */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface RestartRequired {} ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/SectionHeader.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to a field to indicate that * the generated screen should prepend * a section header to option */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface SectionHeader { /** * @return The name of the section describe by this annotation. Used to * derive a translation key with the pattern {@code text.config..section.} */ String value(); } ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/Sync.java ================================================ package io.wispforest.owo.config.annotation; import io.wispforest.owo.config.Option; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to a field to indicate that * its value should be synchronized between server * and client in some way */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD, ElementType.TYPE}) public @interface Sync { Option.SyncMode value(); } ================================================ FILE: src/main/java/io/wispforest/owo/config/annotation/WithAlpha.java ================================================ package io.wispforest.owo.config.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to a config option of type * {@link io.wispforest.owo.ui.core.Color} to indicate * that the config screen should expose the alpha * component */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface WithAlpha {} ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/ConfigScreen.java ================================================ package io.wispforest.owo.config.ui; import io.wispforest.owo.Owo; import io.wispforest.owo.config.ConfigWrapper; import io.wispforest.owo.config.Option; import io.wispforest.owo.config.annotation.ExcludeFromScreen; import io.wispforest.owo.config.annotation.Expanded; import io.wispforest.owo.config.annotation.RestartRequired; import io.wispforest.owo.config.annotation.SectionHeader; import io.wispforest.owo.config.ui.component.*; import io.wispforest.owo.ui.base.BaseUIComponent; import io.wispforest.owo.ui.base.BaseUIModelScreen; import io.wispforest.owo.ui.component.ButtonComponent; import io.wispforest.owo.ui.component.LabelComponent; import io.wispforest.owo.ui.component.TextBoxComponent; import io.wispforest.owo.ui.component.UIComponents; import io.wispforest.owo.ui.container.CollapsibleContainer; import io.wispforest.owo.ui.container.FlowLayout; import io.wispforest.owo.ui.container.ScrollContainer; import io.wispforest.owo.ui.container.UIContainers; import io.wispforest.owo.ui.core.*; import io.wispforest.owo.ui.parsing.UIParsing; import io.wispforest.owo.ui.util.UISounds; import io.wispforest.owo.util.NumberReflection; import io.wispforest.owo.util.ReflectionUtils; import net.minecraft.ChatFormatting; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent; import net.minecraft.client.input.KeyEvent; import net.minecraft.client.resources.language.I18n; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import net.minecraft.util.FormattedCharSequence; import org.apache.commons.lang3.mutable.MutableBoolean; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; import java.lang.reflect.Field; import java.util.*; import java.util.function.Predicate; /** * A screen which generates components for each option in the * provided config. The general structure of the screen is determined * by the XML config model it uses - the default one is located at * {@code assets/owo/owo_ui/config.xml}. Changing which model is used * via {@link #createWithCustomModel(Identifier, ConfigWrapper, Screen)} * can often be enough to visually customize the generated screen - should * you need custom functionality however, extending this class is usually * your best bet * * @see io.wispforest.owo.config.annotation.Modmenu * @see ConfigWrapper */ public class ConfigScreen extends BaseUIModelScreen { public static final Identifier DEFAULT_MODEL_ID = Owo.id("config"); private static final Map>, OptionComponentFactory> DEFAULT_FACTORIES = new HashMap<>(); /** * A set of extra option factories - add to this if you want to override * some default factories or add extra ones for specific config options * the standard ones don't support */ protected final Map>, OptionComponentFactory> extraFactories = new HashMap<>(); protected final Screen parent; protected final ConfigWrapper config; @SuppressWarnings("rawtypes") protected final Map options = new HashMap<>(); protected String lastSearchFieldText = ""; protected @Nullable SearchMatches currentMatches = null; protected int currentMatchIndex = 0; protected ConfigScreen(Identifier modelId, ConfigWrapper config, @Nullable Screen parent) { super(FlowLayout.class, DataSource.asset(modelId)); this.parent = parent; this.config = config; } /** * Create a config screen with the default model ({@code owo:config}) * * @param config The config to create a screen for * @param parent The parent screen to return to * when the created screen is closed */ public static ConfigScreen create(ConfigWrapper config, @Nullable Screen parent) { return new ConfigScreen(DEFAULT_MODEL_ID, config, parent); } /** * Create a config screen with a custom model * located in your mod's assets * * @param modelId The ID of the model to use * @param config The config to create a screen for * @param parent The parent screen to return to * when the created screen is closed */ public static ConfigScreen createWithCustomModel(Identifier modelId, ConfigWrapper config, @Nullable Screen parent) { return new ConfigScreen(modelId, config, parent); } @Override @SuppressWarnings({"ConstantConditions", "unchecked"}) protected void build(FlowLayout rootComponent) { this.options.clear(); rootComponent.childById(LabelComponent.class, "title").text(Component.translatable("text.config." + this.config.name() + ".title")); if (this.minecraft.level == null) { rootComponent.surface(Surface.optionsBackground()); } rootComponent.childById(ButtonComponent.class, "done-button").onPress(button -> this.onClose()); rootComponent.childById(ButtonComponent.class, "reload-button").onPress(button -> { this.config.load(); this.uiAdapter = null; this.rebuildWidgets(); // TODO check if any options changed and warn }); var optionPanel = rootComponent.childById(FlowLayout.class, "option-panel"); var sections = new LinkedHashMap(); var containers = new HashMap(); containers.put(Option.Key.ROOT, optionPanel); rootComponent.childById(TextBoxComponent.class, "search-field").configure(searchField -> { var matchIndicator = rootComponent.childById(LabelComponent.class, "search-match-indicator"); var optionScroll = rootComponent.childById(ScrollContainer.class, "option-panel-scroll"); var searchHint = I18n.get("text.owo.config.search"); searchField.setSuggestion(searchHint); searchField.onChanged().subscribe(s -> { searchField.setSuggestion(s.isEmpty() ? searchHint : ""); if (!s.equals(this.lastSearchFieldText)) { searchField.setTextColor(TextBoxComponent.DEFAULT_TEXT_COLOR); matchIndicator.text(Component.empty()); } }); searchField.keyPress().subscribe((input) -> { if (!input.isConfirmation()) return false; var query = searchField.getValue().toLowerCase(Locale.ROOT); if (query.isBlank()) return false; if (this.currentMatches != null && this.currentMatches.query.equals(query)) { if (this.currentMatches.matches().isEmpty()) { this.currentMatchIndex = -1; } else { this.currentMatchIndex = (this.currentMatchIndex + 1) % this.currentMatches.matches.size(); } } else { var splitQuery = query.split(" "); this.currentMatchIndex = 0; this.currentMatches = new SearchMatches(query, this.collectSearchAnchors(optionScroll) .stream() .filter(anchor -> Arrays.stream(splitQuery).allMatch(anchor.currentSearchText()::contains)) .toList()); } if (this.currentMatches.matches.isEmpty()) { matchIndicator.text(Component.translatable("text.owo.config.search.no_matches")); searchField.setTextColor(0xEB1D36); } else { matchIndicator.text(Component.translatable("text.owo.config.search.matches", this.currentMatchIndex + 1, this.currentMatches.matches.size())); searchField.setTextColor(0x28FFBF); var selectedMatch = this.currentMatches.matches.get(this.currentMatchIndex); var anchorFrame = selectedMatch.anchorFrame(); // we specifically build the path backwards, so we can then iterate // it root -> key, otherwise we could potentially be manipulating // unmounted components which is absolutely not desirable var pathToRoot = new ArrayDeque(); var key = selectedMatch.key(); while (!key.isRoot()) { pathToRoot.push(key); key = key.parent(); } while (!pathToRoot.isEmpty()) { if (containers.get(pathToRoot.pop()) instanceof CollapsibleContainer collapsible && !collapsible.expanded()) { collapsible.toggleExpansion(); } } // in the same vein, the component is mounted after the layout is fully // restored, as we would otherwise be mounting onto a partially-built subtree if (anchorFrame instanceof FlowLayout flow) { flow.child(0, selectedMatch.configure(new SearchHighlighterComponent())); } if (anchorFrame.y() < optionScroll.y() || anchorFrame.y() + anchorFrame.height() > optionScroll.y() + optionScroll.height()) { optionScroll.scrollTo(selectedMatch.anchorFrame()); } } return true; }); }); this.config.forEachOption(option -> { if (option.backingField().hasAnnotation(ExcludeFromScreen.class)) return; var parentKey = option.key().parent(); if (!parentKey.isRoot() && this.config.fieldForKey(parentKey).isAnnotationPresent(ExcludeFromScreen.class)) return; var factory = this.factoryForOption(option); if (factory == null) { Owo.LOGGER.warn("Could not create UI component for config option {}", option); return; } var result = factory.make(this.model, option); this.options.put(option, result.optionProvider()); var expanded = !parentKey.isRoot() && this.config.fieldForKey(parentKey).isAnnotationPresent(Expanded.class); var container = containers.getOrDefault( parentKey, UIContainers.collapsible( Sizing.fill(100), Sizing.content(), Component.translatable("text.config." + this.config.name() + ".category." + parentKey.asString()), expanded ).configure(nestedContainer -> { final var categoryKey = "text.config." + this.config.name() + ".category." + parentKey.asString(); if (I18n.exists(categoryKey + ".tooltip")) { nestedContainer.titleLayout().tooltip(Component.translatable(categoryKey + ".tooltip")); } nestedContainer.titleLayout().child(new SearchAnchorComponent( nestedContainer.titleLayout(), option.key(), () -> I18n.get(categoryKey) ).highlightConfigurator(highlight -> highlight.positioning(Positioning.absolute(-5, -5)) .verticalSizing(Sizing.fixed(19)) )); }) ); if (!containers.containsKey(parentKey) && containers.containsKey(parentKey.parent())) { if (this.config.fieldForKey(parentKey).isAnnotationPresent(SectionHeader.class)) { this.appendSection(sections, this.config.fieldForKey(parentKey), containers.get(parentKey.parent())); } containers.put(parentKey, container); containers.get(parentKey.parent()).child(container); } if (option.detached()) { result.baseComponent().tooltip( this.minecraft.font.split(Component.translatable("text.owo.config.managed_by_server"), Integer.MAX_VALUE) .stream().map(ClientTooltipComponent::create).toList() ); } else { var tooltipText = new ArrayList(); var tooltipTranslationKey = option.translationKey() + ".tooltip"; if (I18n.exists(tooltipTranslationKey)) { tooltipText.addAll(this.minecraft.font.split(Component.translatable(tooltipTranslationKey), Integer.MAX_VALUE)); } if (option.backingField().hasAnnotation(RestartRequired.class)) { tooltipText.add(Component.translatable("text.owo.config.applies_after_restart").getVisualOrderText()); } if (!tooltipText.isEmpty()) { result.baseComponent().tooltip(tooltipText.stream().map(ClientTooltipComponent::create).toList()); } } if (option.backingField().hasAnnotation(SectionHeader.class)) { this.appendSection(sections, option.backingField().field(), container); } container.child(result.baseComponent()); }); if (!sections.isEmpty()) { var panelContainer = rootComponent.childById(FlowLayout.class, "option-panel-container"); var panelScroll = rootComponent.childById(ScrollContainer.class, "option-panel-scroll"); panelScroll.margins(Insets.right(10)); var buttonPanel = this.model.expandTemplate(FlowLayout.class, "section-buttons", Map.of()); sections.forEach((component, text) -> { var hoveredText = text.copy().withStyle(ChatFormatting.YELLOW); final var label = UIComponents.label(text); label.cursorStyle(CursorStyle.HAND).margins(Insets.of(2)); label.mouseEnter().subscribe(() -> label.text(hoveredText)); label.mouseLeave().subscribe(() -> label.text(text)); label.mouseDown().subscribe((click, doubled) -> { panelScroll.scrollTo(component); UISounds.playInteractionSound(); return true; }); buttonPanel.child(label); }); var closeButton = UIComponents.label(Component.literal("<").withStyle(ChatFormatting.BOLD)); closeButton.tooltip(Component.translatable("text.owo.config.sections_tooltip")); closeButton.positioning(Positioning.relative(100, 50)).cursorStyle(CursorStyle.HAND).margins(Insets.right(2)); panelContainer.child(closeButton); panelContainer.mouseDown().subscribe((click, doubled) -> { if (click.x() < panelContainer.width() - 10) return false; if (buttonPanel.horizontalSizing().animation() == null) { buttonPanel.horizontalSizing().animate(350, Easing.CUBIC, Sizing.content()); } buttonPanel.horizontalSizing().animation().reverse(); closeButton.text(Component.literal(closeButton.text().getString().equals(">") ? "<" : ">").withStyle(ChatFormatting.BOLD)); UISounds.playInteractionSound(); return true; }); rootComponent.childById(FlowLayout.class, "main-panel").child(buttonPanel); } } protected void appendSection(Map sections, Field field, FlowLayout container) { var translationKey = "text.config." + this.config.name() + ".section." + field.getAnnotation(SectionHeader.class).value(); final var header = this.model.expandTemplate(FlowLayout.class, "section-header", Map.of()); header.childById(LabelComponent.class, "header").configure(label -> { label.text(Component.translatable(translationKey).withStyle(ChatFormatting.YELLOW, ChatFormatting.BOLD)); header.child(new SearchAnchorComponent(header, Option.Key.ROOT, () -> label.text().getString())); }); sections.put(header, Component.translatable(translationKey)); container.child(header); } protected List collectSearchAnchors(ParentUIComponent root) { var discovered = new ArrayList(); var candidates = new ArrayDeque<>(root.children()); while (!candidates.isEmpty()) { var candidate = candidates.poll(); if (candidate instanceof CollapsibleContainer collapsible) { candidates.addAll(collapsible.children()); if (!collapsible.expanded()) candidates.addAll(collapsible.collapsibleChildren()); } else if (candidate instanceof ParentUIComponent parentComponent) { candidates.addAll(parentComponent.children()); } else if (candidate instanceof SearchAnchorComponent anchor) { discovered.add(anchor); } } return discovered; } @Override public boolean keyPressed(KeyEvent input) { if (input.key() == GLFW.GLFW_KEY_F && input.hasControlDown()) { this.uiAdapter.rootComponent.focusHandler().focus( this.uiAdapter.rootComponent.childById(UIComponent.class, "search-field"), UIComponent.FocusSource.MOUSE_CLICK ); return true; } else { return super.keyPressed(input); } } @Override @SuppressWarnings("unchecked") public void onClose() { var shouldRestart = new MutableBoolean(); this.options.forEach((option, component) -> { if (!option.backingField().hasAnnotation(RestartRequired.class)) return; if (Objects.equals(option.value(), component.parsedValue())) return; shouldRestart.setTrue(); }); this.minecraft.setScreen(shouldRestart.booleanValue() ? new RestartRequiredScreen(this.parent) : this.parent); } @Override @SuppressWarnings("unchecked") public void removed() { this.options.forEach((option, component) -> { if (!component.isValid()) return; option.set(component.parsedValue()); }); super.removed(); } @SuppressWarnings("rawtypes") protected @Nullable OptionComponentFactory factoryForOption(Option option) { for (var predicate : this.extraFactories.keySet()) { if (!predicate.test(option)) continue; return this.extraFactories.get(predicate); } for (var predicate : DEFAULT_FACTORIES.keySet()) { if (!predicate.test(option)) continue; return DEFAULT_FACTORIES.get(predicate); } return null; } static { DEFAULT_FACTORIES.put(option -> NumberReflection.isNumberType(option.clazz()), OptionComponentFactory.NUMBER); DEFAULT_FACTORIES.put(option -> option.clazz() == String.class, OptionComponentFactory.STRING); DEFAULT_FACTORIES.put(option -> option.clazz() == Boolean.class || option.clazz() == boolean.class, OptionComponentFactory.BOOLEAN); DEFAULT_FACTORIES.put(option -> option.clazz() == Identifier.class, OptionComponentFactory.IDENTIFIER); DEFAULT_FACTORIES.put(option -> option.clazz() == Color.class, OptionComponentFactory.COLOR); DEFAULT_FACTORIES.put(option -> isStringOrNumberList(option.backingField().field()), OptionComponentFactory.LIST); DEFAULT_FACTORIES.put(option -> option.clazz().isEnum(), OptionComponentFactory.ENUM); UIParsing.registerFactory("config-slider", element -> new ConfigSlider()); UIParsing.registerFactory("config-toggle-button", element -> new ConfigToggleButton()); UIParsing.registerFactory("config-enum-button", element -> new ConfigEnumButton()); UIParsing.registerFactory("config-text-box", element -> new ConfigTextBox()); } protected record SearchMatches(String query, List matches) {} public static class SearchHighlighterComponent extends BaseUIComponent { private final Color startColor = Color.ofArgb(0x008d9be0); private final Color endColor = Color.ofArgb(0x4c8d9be0); private float age = 0; public SearchHighlighterComponent() { this.positioning(Positioning.absolute(0, 0)); this.sizing(Sizing.fill(100), Sizing.fill(100)); } @Override public void draw(OwoUIGraphics graphics, int mouseX, int mouseY, float partialTicks, float delta) { final var mainColor = startColor.interpolate(endColor, (float) Math.sin(age / 25 * Math.PI)).argb(); int segmentWidth = (int) (this.width * .3f); int baseX = (int) ((this.x - segmentWidth) + (Easing.CUBIC.apply(this.age / 25)) * (this.width + segmentWidth * 2)); graphics.drawGradientRect( baseX - segmentWidth, this.y, segmentWidth, this.height, 0, mainColor, mainColor, 0 ); graphics.drawGradientRect( baseX, this.y, segmentWidth, this.height, mainColor, 0, 0, mainColor ); } @Override public void update(float delta, int mouseX, int mouseY) { super.update(delta, mouseX, mouseY); if ((this.age += delta) > 25) { this.parent.queue(() -> this.parent.removeChild(this)); } } } private static boolean isStringOrNumberList(Field field) { if (field.getType() != List.class) return false; var listType = ReflectionUtils.getTypeArgument(field.getGenericType(), 0); if (listType == null) return false; return String.class == listType || NumberReflection.isNumberType(listType); } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/ConfigScreenProviders.java ================================================ package io.wispforest.owo.config.ui; import net.minecraft.client.gui.screens.Screen; import org.jetbrains.annotations.Nullable; import java.util.HashMap; import java.util.Map; import java.util.function.BiConsumer; import java.util.function.Function; public class ConfigScreenProviders { private static final Map> PROVIDERS = new HashMap<>(); private static final Map> OWO_SCREEN_PROVIDERS = new HashMap<>(); /** * Register the given config screen provider. This is primarily * used for making a config screen available in ModMenu and to the * {@code /owo-config} command, although other places my use it as well * * @param modId The mod id for which to supply a config screen * @param supplier The supplier to register - this gets the parent screen * as argument * @throws IllegalArgumentException If a config screen provider is * already registered for the given mod id */ public static void register(String modId, Function supplier) { if (PROVIDERS.put(modId, supplier) != null) { throw new IllegalArgumentException("Tried to register config screen provider for mod id " + modId + " twice"); } } /** * Get the config screen provider associated with * the given mod id * * @return The associated config screen provider, or {@code null} if * none is registered */ public static @Nullable Function get(String modId) { return PROVIDERS.get(modId); } public static void forEach(BiConsumer> action) { PROVIDERS.forEach(action); } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/OptionComponentFactory.java ================================================ package io.wispforest.owo.config.ui; import io.wispforest.owo.config.Option; import io.wispforest.owo.config.annotation.RangeConstraint; import io.wispforest.owo.config.annotation.WithAlpha; import io.wispforest.owo.config.ui.component.ListOptionContainer; import io.wispforest.owo.config.ui.component.OptionValueProvider; import io.wispforest.owo.ui.component.BoxComponent; import io.wispforest.owo.ui.component.ButtonComponent; import io.wispforest.owo.ui.component.ColorPickerComponent; import io.wispforest.owo.ui.component.UIComponents; import io.wispforest.owo.ui.container.UIContainers; import io.wispforest.owo.ui.container.FlowLayout; import io.wispforest.owo.ui.core.*; import io.wispforest.owo.ui.parsing.UIModel; import io.wispforest.owo.util.NumberReflection; import net.minecraft.resources.Identifier; import java.util.List; import java.util.Map; import java.util.function.Supplier; /** * A function which creates an instance of {@link OptionValueProvider} * fitting for the given config option. Whatever component is created * should accurately reflect if the option is currently detached * and thus immutable - ideally it is non-interactable * * @param The type of option for which this factory can create components */ public interface OptionComponentFactory { OptionComponentFactory NUMBER = (model, option) -> { var field = option.backingField().field(); if (field.isAnnotationPresent(RangeConstraint.class)) { return OptionComponents.createRangeControls( model, option, NumberReflection.isFloatingPointType(field.getType()) ? field.getAnnotation(RangeConstraint.class).decimalPlaces() : 0 ); } else { return OptionComponents.createTextBox(model, option, configTextBox -> { configTextBox.configureForNumber(option.clazz()); }); } }; OptionComponentFactory STRING = (model, option) -> { return OptionComponents.createTextBox(model, option, configTextBox -> { if (option.constraint() != null) { configTextBox.applyPredicate(option.constraint()::test); } }); }; OptionComponentFactory IDENTIFIER = (model, option) -> { return OptionComponents.createTextBox(model, option, configTextBox -> { configTextBox.inputPredicate(s -> s.matches("[a-z0-9_.:\\-]*")); configTextBox.applyPredicate(s -> Identifier.tryParse(s) != null); configTextBox.valueParser(Identifier::parse); }); }; @SuppressWarnings("DataFlowIssue") OptionComponentFactory COLOR = (model, option) -> { boolean withAlpha = option.backingField().hasAnnotation(WithAlpha.class); final var result = OptionComponents.createTextBox(model, option, color -> color.asHexString(withAlpha), configTextBox -> { configTextBox.inputPredicate(withAlpha ? s -> s.matches("#[a-zA-Z\\d]{0,8}") : s -> s.matches("#[a-zA-Z\\d]{0,6}")); configTextBox.applyPredicate(withAlpha ? s -> s.matches("#[a-zA-Z\\d]{8}") : s -> s.matches("#[a-zA-Z\\d]{6}")); configTextBox.valueParser(withAlpha ? s -> Color.ofArgb(Integer.parseUnsignedInt(s.substring(1), 16)) : s -> Color.ofRgb(Integer.parseUnsignedInt(s.substring(1), 16)) ); }); result.baseComponent.childById(FlowLayout.class, "controls-flow").configure(controls -> { Supplier valueGetter = () -> result.optionProvider.isValid() ? (Color) result.optionProvider.parsedValue() : Color.BLACK; var box = UIComponents.box(Sizing.fixed(15), Sizing.fixed(15)).color(valueGetter.get()).fill(true); box.margins(Insets.right(5)).cursorStyle(CursorStyle.HAND); controls.child(0, box); result.optionProvider.onChanged().subscribe(value -> box.color(valueGetter.get())); box.mouseDown().subscribe((click, doubled) -> { ((FlowLayout) box.root()).child(UIContainers.overlay( model.expandTemplate( FlowLayout.class, "color-picker-panel", Map.of("color", valueGetter.get().asHexString(withAlpha), "with-alpha", String.valueOf(withAlpha)) ).configure(flowLayout -> { var picker = flowLayout.childById(ColorPickerComponent.class, "color-picker"); var previewBox = flowLayout.childById(BoxComponent.class, "current-color"); picker.onChanged().subscribe(previewBox::color); flowLayout.childById(ButtonComponent.class, "confirm-button").onPress(confirmButton -> { result.optionProvider.text(picker.selectedColor().asHexString(withAlpha)); flowLayout.parent().remove(); }); flowLayout.childById(ButtonComponent.class, "cancel-button").onPress(cancelButton -> { flowLayout.parent().remove(); }); }) )); return true; }); }); return result; }; OptionComponentFactory BOOLEAN = OptionComponents::createToggleButton; OptionComponentFactory> ENUM = OptionComponents::createEnumButton; @SuppressWarnings({"unchecked", "rawtypes"}) OptionComponentFactory> LIST = (model, option) -> { var layout = new ListOptionContainer(option); return new Result(layout, layout); }; /** * Create a new component fitting for, and bound to, * the given config option * * @param model The UI model of the enclosing screen, used * for expanding templates * @param option The option for which to create a component * @return The option component as well as a potential wrapping * component, this simply be the option component itself */ Result make(UIModel model, Option option); record Result(B baseComponent, P optionProvider) {} } ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/OptionComponents.java ================================================ package io.wispforest.owo.config.ui; import io.wispforest.owo.config.Option; import io.wispforest.owo.config.annotation.RangeConstraint; import io.wispforest.owo.config.ui.component.*; import io.wispforest.owo.ui.component.ButtonComponent; import io.wispforest.owo.ui.component.LabelComponent; import io.wispforest.owo.ui.container.FlowLayout; import io.wispforest.owo.ui.core.Positioning; import io.wispforest.owo.ui.parsing.UIModel; import net.minecraft.network.chat.Component; import org.apache.commons.lang3.mutable.MutableBoolean; import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; @SuppressWarnings("ConstantConditions") public class OptionComponents { public static OptionComponentFactory.Result createTextBox(UIModel model, Option option, Consumer processor) { return createTextBox(model, option, Object::toString, processor); } public static OptionComponentFactory.Result createTextBox(UIModel model, Option option, Function toStringFunction, Consumer processor) { var optionComponent = model.expandTemplate(FlowLayout.class, "text-box-config-option", packParameters(option.translationKey(), toStringFunction.apply(option.value())) ); var valueBox = optionComponent.childById(ConfigTextBox.class, "value-box"); var resetButton = optionComponent.childById(ButtonComponent.class, "reset-button"); if (option.detached()) { resetButton.active = false; valueBox.setEditable(false); } else { resetButton.active = !valueBox.getValue().equals(toStringFunction.apply(option.defaultValue())); resetButton.onPress(button -> { valueBox.setValue(toStringFunction.apply(option.defaultValue())); button.active = false; }); valueBox.onChanged().subscribe(s -> resetButton.active = !s.equals(toStringFunction.apply(option.defaultValue()))); } processor.accept(valueBox); optionComponent.child(new SearchAnchorComponent( optionComponent, option.key(), () -> optionComponent.childById(LabelComponent.class, "option-name").text().getString(), valueBox::getValue )); return new OptionComponentFactory.Result<>(optionComponent, valueBox); } public static OptionComponentFactory.Result createRangeControls(UIModel model, Option option, int decimalPlaces) { boolean withDecimals = decimalPlaces > 0; // ------------ // Slider setup // ------------ var value = option.value(); var optionComponent = model.expandTemplate(FlowLayout.class, "range-config-option", packParameters(option.translationKey(), value.toString()) ); var constraint = option.backingField().field().getAnnotation(RangeConstraint.class); double min = constraint.min(), max = constraint.max(); var sliderInput = optionComponent.childById(ConfigSlider.class, "value-slider"); sliderInput.min(min).max(max).decimalPlaces(decimalPlaces).snap(!withDecimals).setFromDiscreteValue(value.doubleValue()); sliderInput.valueType(option.clazz()); var resetButton = optionComponent.childById(ButtonComponent.class, "reset-button"); if (option.detached()) { resetButton.active = false; sliderInput.active = false; } else { resetButton.active = (withDecimals ? value.doubleValue() : Math.round(value.doubleValue())) != option.defaultValue().doubleValue(); resetButton.onPress(button -> { sliderInput.setFromDiscreteValue(option.defaultValue().doubleValue()); button.active = false; }); sliderInput.onChanged().subscribe(newValue -> { resetButton.active = (withDecimals ? newValue : Math.round(newValue)) != option.defaultValue().doubleValue(); }); } // ------------------------------------ // Component handles and text box setup // ------------------------------------ var sliderControls = optionComponent.childById(FlowLayout.class, "slider-controls"); var textControls = createTextBox(model, option, configTextBox -> { configTextBox.configureForNumber(option.clazz()); var predicate = configTextBox.applyPredicate(); configTextBox.applyPredicate(predicate.and(s -> { final var parsed = Double.parseDouble(s); return parsed >= min && parsed <= max; })); }).baseComponent().childById(FlowLayout.class, "controls-flow").positioning(Positioning.layout()); var textInput = textControls.childById(ConfigTextBox.class, "value-box"); // ------------ // Toggle setup // ------------ var controlsLayout = optionComponent.childById(FlowLayout.class, "controls-flow"); var toggleButton = optionComponent.childById(ButtonComponent.class, "toggle-button"); var textMode = new MutableBoolean(false); toggleButton.onPress(button -> { textMode.setValue(textMode.isFalse()); if (textMode.isTrue()) { sliderControls.remove(); textInput.text(sliderInput.decimalPlaces() == 0 ? String.valueOf((int) sliderInput.discreteValue()) : String.valueOf(sliderInput.discreteValue())); controlsLayout.child(textControls); } else { textControls.remove(); sliderInput.setFromDiscreteValue(((Number) textInput.parsedValue()).doubleValue()); controlsLayout.child(sliderControls); } button.tooltip(textMode.isTrue() ? Component.translatable("text.owo.config.button.range.edit_with_slider") : Component.translatable("text.owo.config.button.range.edit_as_text") ); }); optionComponent.child(new SearchAnchorComponent( optionComponent, option.key(), () -> optionComponent.childById(LabelComponent.class, "option-name").text().getString(), () -> textMode.isTrue() ? textInput.getValue() : sliderInput.getMessage().getString() )); return new OptionComponentFactory.Result<>(optionComponent, new OptionValueProvider() { @Override public boolean isValid() { return textMode.isTrue() ? textInput.isValid() : sliderInput.isValid(); } @Override public Object parsedValue() { return textMode.isTrue() ? textInput.parsedValue() : sliderInput.parsedValue(); } }); } public static OptionComponentFactory.Result createToggleButton(UIModel model, Option option) { var optionComponent = model.expandTemplate(FlowLayout.class, "boolean-toggle-config-option", packParameters(option.translationKey(), option.value().toString()) ); var toggleButton = optionComponent.childById(ConfigToggleButton.class, "toggle-button"); var resetButton = optionComponent.childById(ButtonComponent.class, "reset-button"); toggleButton.enabled(option.value()); if (option.detached()) { resetButton.active = false; toggleButton.active = false; } else { resetButton.active = option.value() != option.defaultValue(); resetButton.onPress(button -> { toggleButton.enabled(option.defaultValue()); button.active = false; }); toggleButton.onPress(button -> resetButton.active = toggleButton.parsedValue() != option.defaultValue()); } optionComponent.child(new SearchAnchorComponent( optionComponent, option.key(), () -> optionComponent.childById(LabelComponent.class, "option-name").text().getString(), () -> toggleButton.getMessage().getString() )); return new OptionComponentFactory.Result<>(optionComponent, toggleButton); } public static OptionComponentFactory.Result createEnumButton(UIModel model, Option> option) { var optionComponent = model.expandTemplate(FlowLayout.class, "enum-config-option", packParameters(option.translationKey(), option.value().toString()) ); var enumButton = optionComponent.childById(ConfigEnumButton.class, "enum-button"); var resetButton = optionComponent.childById(ButtonComponent.class, "reset-button"); enumButton.init(option, option.value().ordinal()); if (option.detached()) { resetButton.active = false; enumButton.active = false; } else { resetButton.active = option.value() != option.defaultValue(); resetButton.onPress(button -> { enumButton.select(option.defaultValue().ordinal()); button.active = false; }); enumButton.onPress(button -> resetButton.active = enumButton.parsedValue() != option.defaultValue()); } optionComponent.child(new SearchAnchorComponent( optionComponent, option.key(), () -> optionComponent.childById(LabelComponent.class, "option-name").text().getString(), () -> enumButton.getMessage().getString() )); return new OptionComponentFactory.Result<>(optionComponent, enumButton); } public static Map packParameters(String name, String value) { return Map.of( "config-option-name", name, "config-option-value", value ); } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/RestartRequiredScreen.java ================================================ package io.wispforest.owo.config.ui; import io.wispforest.owo.Owo; import io.wispforest.owo.ui.base.BaseUIModelScreen; import io.wispforest.owo.ui.component.ButtonComponent; import io.wispforest.owo.ui.container.FlowLayout; import io.wispforest.owo.ui.core.Surface; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; import org.jetbrains.annotations.ApiStatus; @ApiStatus.Internal public class RestartRequiredScreen extends BaseUIModelScreen { protected final Screen parent; public RestartRequiredScreen(Screen parent) { super(FlowLayout.class, DataSource.asset(Owo.id("restart_required"))); this.parent = parent; } @Override public void onClose() { this.minecraft.setScreen(parent); } @Override @SuppressWarnings("ConstantConditions") protected void build(FlowLayout rootComponent) { if (this.minecraft.level == null) { rootComponent.surface(Surface.optionsBackground()); } rootComponent.childById(ButtonComponent.class, "exit-button") .onPress(button -> Minecraft.getInstance().stop()); rootComponent.childById(ButtonComponent.class, "ignore-button") .onPress(button -> this.onClose()); } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/component/ConfigEnumButton.java ================================================ package io.wispforest.owo.config.ui.component; import io.wispforest.owo.config.Option; import io.wispforest.owo.ui.component.ButtonComponent; import io.wispforest.owo.ui.core.Sizing; import net.minecraft.client.input.InputWithModifiers; import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.client.input.MouseButtonInfo; import net.minecraft.client.resources.language.I18n; import net.minecraft.network.chat.Component; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; import java.util.Locale; @ApiStatus.Internal public class ConfigEnumButton extends ButtonComponent implements OptionValueProvider { @Nullable protected Option> backingOption = null; @Nullable protected Enum[] backingValues = null; protected int selectedIndex = 0; protected boolean wasRightClicked = false; public ConfigEnumButton() { super(Component.empty(), button -> {}); this.verticalSizing(Sizing.fixed(20)); this.updateMessage(); } @Override public boolean onMouseDown(MouseButtonEvent click, boolean doubled) { this.wasRightClicked = click.button() == GLFW.GLFW_MOUSE_BUTTON_RIGHT; return super.onMouseDown(click, doubled); } @Override public void onPress(InputWithModifiers input) { if (this.wasRightClicked || input.hasShiftDown()) { this.selectedIndex--; if (this.selectedIndex < 0) this.selectedIndex += this.backingValues.length; } else { this.selectedIndex++; if (this.selectedIndex > this.backingValues.length - 1) this.selectedIndex -= this.backingValues.length; } this.updateMessage(); super.onPress(input); } @Override protected boolean isValidClickButton(MouseButtonInfo input) { return input.button() == GLFW.GLFW_MOUSE_BUTTON_RIGHT || super.isValidClickButton(input); } protected void updateMessage() { if (this.backingOption == null) return; var enumName = StringUtils.uncapitalize(this.backingValues.getClass().componentType().getSimpleName()); var valueName = this.backingValues[this.selectedIndex].name().toLowerCase(Locale.ROOT); var optionValueKey = this.backingOption.translationKey() + ".value." + valueName; this.setMessage(I18n.exists(optionValueKey) ? Component.translatable(optionValueKey) : Component.translatable("text.config." + this.backingOption.configName() + ".enum." + enumName + "." + valueName) ); } public ConfigEnumButton init(Option> option, int selectedIndex) { this.backingOption = option; this.backingValues = (Enum[]) option.backingField().field().getType().getEnumConstants(); this.selectedIndex = selectedIndex; this.updateMessage(); return this; } public ConfigEnumButton select(int index) { this.selectedIndex = index; this.updateMessage(); return this; } @Override public boolean isValid() { return true; } @Override public Object parsedValue() { return this.backingValues[this.selectedIndex]; } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/component/ConfigSlider.java ================================================ package io.wispforest.owo.config.ui.component; import io.wispforest.owo.ui.component.DiscreteSliderComponent; import io.wispforest.owo.ui.core.Sizing; import io.wispforest.owo.util.NumberReflection; import org.jetbrains.annotations.ApiStatus; @ApiStatus.Internal public class ConfigSlider extends DiscreteSliderComponent implements OptionValueProvider { protected Class valueType; public ConfigSlider() { super(Sizing.content(), 0, 1); } public ConfigSlider valueType(Class valueType) { this.valueType = valueType; return this; } public ConfigSlider min(double min) { this.min = min; return this; } public ConfigSlider max(double max) { this.max = max; return this; } @Override public boolean isValid() { return true; } @Override public Object parsedValue() { double value = this.min + this.value * (this.max - this.min); if (!NumberReflection.isFloatingPointType(this.valueType)) { value = Math.round(value); } return NumberReflection.convert(value, this.valueType); } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/component/ConfigTextBox.java ================================================ package io.wispforest.owo.config.ui.component; import io.wispforest.owo.ui.component.TextBoxComponent; import io.wispforest.owo.ui.core.Color; import io.wispforest.owo.ui.core.Sizing; import io.wispforest.owo.ui.parsing.UIModel; import io.wispforest.owo.ui.parsing.UIParsing; import io.wispforest.owo.util.NumberReflection; import org.jetbrains.annotations.ApiStatus; import org.w3c.dom.Element; import java.util.Map; import java.util.function.Function; import java.util.function.Predicate; @ApiStatus.Internal @SuppressWarnings("UnusedReturnValue") public class ConfigTextBox extends TextBoxComponent implements OptionValueProvider { protected int invalidColor = 0xFFEB1D36, validColor = 0xFF28FFBF; protected Function valueParser = s -> s; protected Predicate inputPredicate = s -> true, applyPredicate = s -> true; public ConfigTextBox() { super(Sizing.fixed(0)); this.setMaxLength(Integer.MAX_VALUE); this.textValue.observe(s -> { this.setTextColor(this.applyPredicate.test(s) ? this.validColor : this.invalidColor); }); } public ConfigTextBox configureForNumber(Class fieldType) { final boolean floatingPoint = NumberReflection.isFloatingPointType(fieldType); final double min = NumberReflection.minValue(fieldType).doubleValue(), max = NumberReflection.maxValue(fieldType).doubleValue(); this.valueParser = s -> { try { return NumberReflection.convert(floatingPoint ? Double.parseDouble(s) : Long.parseLong(s), fieldType); } catch (NumberFormatException nfe) { return NumberReflection.convert(0L, fieldType); } }; this.inputPredicate(floatingPoint ? s -> s.matches("-?\\d*\\.?\\d*") : s -> s.matches("-?\\d*")); this.applyPredicate(s -> { try { var value = Double.parseDouble(s); return value >= min && value <= max; } catch (NumberFormatException nfe) { return false; } }); return this; } @Override public boolean isValid() { return this.applyPredicate.test(this.getValue()); } @Override public Object parsedValue() { return this.valueParser.apply(this.getValue()); } public ConfigTextBox inputPredicate(Predicate inputPredicate) { this.inputPredicate = inputPredicate; this.setFilter(this.inputPredicate); return this; } public Predicate inputPredicate() { return inputPredicate; } public ConfigTextBox applyPredicate(Predicate applyPredicate) { this.applyPredicate = applyPredicate; return this; } public Predicate applyPredicate() { return applyPredicate; } public ConfigTextBox invalidColor(int invalidColor) { this.invalidColor = invalidColor; return this; } public int invalidColor() { return invalidColor; } public ConfigTextBox validColor(int validColor) { this.validColor = validColor; return this; } public int validColor() { return validColor; } public Function valueParser() { return this.valueParser; } public ConfigTextBox valueParser(Function valueParser) { this.valueParser = valueParser; return this; } @Override public void parseProperties(UIModel model, Element element, Map children) { super.parseProperties(model, element, children); UIParsing.apply(children, "invalid-color", Color::parseAndPack, this::invalidColor); UIParsing.apply(children, "valid-color", Color::parseAndPack, this::validColor); } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/component/ConfigToggleButton.java ================================================ package io.wispforest.owo.config.ui.component; import io.wispforest.owo.ui.component.ButtonComponent; import io.wispforest.owo.ui.core.Sizing; import net.minecraft.client.input.InputWithModifiers; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.ApiStatus; @ApiStatus.Internal public class ConfigToggleButton extends ButtonComponent implements OptionValueProvider { protected static final Component ENABLED_MESSAGE = Component.translatable("text.owo.config.boolean_toggle.enabled"); protected static final Component DISABLED_MESSAGE = Component.translatable("text.owo.config.boolean_toggle.disabled"); protected boolean enabled = false; public ConfigToggleButton() { super(Component.empty(), button -> {}); this.verticalSizing(Sizing.fixed(20)); this.updateMessage(); } @Override public void onPress(InputWithModifiers input) { this.enabled = !this.enabled; this.updateMessage(); super.onPress(input); } protected void updateMessage() { this.setMessage(this.enabled ? ENABLED_MESSAGE : DISABLED_MESSAGE); } public ConfigToggleButton enabled(boolean enabled) { this.enabled = enabled; this.updateMessage(); return this; } @Override public boolean isValid() { return true; } @Override public Object parsedValue() { return this.enabled; } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/component/ListOptionContainer.java ================================================ package io.wispforest.owo.config.ui.component; import io.wispforest.owo.config.Option; import io.wispforest.owo.config.annotation.Expanded; import io.wispforest.owo.ops.TextOps; import io.wispforest.owo.ui.component.ButtonComponent; import io.wispforest.owo.ui.component.UIComponents; import io.wispforest.owo.ui.component.LabelComponent; import io.wispforest.owo.ui.container.CollapsibleContainer; import io.wispforest.owo.ui.container.UIContainers; import io.wispforest.owo.ui.container.FlowLayout; import io.wispforest.owo.ui.core.*; import io.wispforest.owo.ui.util.UISounds; import io.wispforest.owo.util.NumberReflection; import io.wispforest.owo.util.ReflectionUtils; import net.minecraft.ChatFormatting; import net.minecraft.client.gui.components.Button; import net.minecraft.client.resources.language.I18n; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.ApiStatus; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @ApiStatus.Internal public class ListOptionContainer extends CollapsibleContainer implements OptionValueProvider { protected final Option> backingOption; protected final List backingList; protected final Button resetButton; @SuppressWarnings("unchecked") public ListOptionContainer(Option> option) { super( Sizing.fill(100), Sizing.content(), Component.translatable("text.config." + option.configName() + ".option." + option.key().asString()), option.backingField().field().isAnnotationPresent(Expanded.class) ); this.backingOption = option; this.backingList = new ArrayList<>(option.value()); this.padding(this.padding.get().add(0, 5, 0, 0)); this.titleLayout.horizontalSizing(Sizing.fill(100)); this.titleLayout.verticalSizing(Sizing.fixed(30)); this.titleLayout.verticalAlignment(VerticalAlignment.CENTER); if (!option.detached()) { this.titleLayout.child(UIComponents.label(Component.translatable("text.owo.config.list.add_entry").withStyle(ChatFormatting.GRAY)).configure(label -> { label.cursorStyle(CursorStyle.HAND); label.mouseEnter().subscribe(() -> label.text(label.text().copy().withStyle(style -> style.withColor(ChatFormatting.YELLOW)))); label.mouseLeave().subscribe(() -> label.text(label.text().copy().withStyle(style -> style.withColor(ChatFormatting.GRAY)))); label.mouseDown().subscribe((click, doubled) -> { UISounds.playInteractionSound(); this.backingList.add((T) ""); if (!this.expanded) this.toggleExpansion(); this.refreshOptions(); var lastEntry = (ParentUIComponent) this.collapsibleChildren.get(this.collapsibleChildren.size() - 1); this.focusHandler().focus( lastEntry.children().get(lastEntry.children().size() - 1), FocusSource.MOUSE_CLICK ); return true; }); })); } this.resetButton = UIComponents.button(Component.literal("⇄"), (ButtonComponent button) -> { this.backingList.clear(); this.backingList.addAll(option.defaultValue()); this.refreshOptions(); button.active = false; }); this.resetButton.margins(Insets.right(10)); this.resetButton.positioning(Positioning.relative(100, 50)); this.titleLayout.child(resetButton); this.refreshResetButton(); this.refreshOptions(); this.titleLayout.child(new SearchAnchorComponent( this.titleLayout, option.key(), () -> I18n.get("text.config." + option.configName() + ".option." + option.key().asString()), () -> this.backingList.stream().map(Objects::toString).collect(Collectors.joining()) )); } @SuppressWarnings({"unchecked", "ConstantConditions"}) protected void refreshOptions() { this.collapsibleChildren.clear(); var listType = ReflectionUtils.getTypeArgument(this.backingOption.backingField().field().getGenericType(), 0); for (int i = 0; i < this.backingList.size(); i++) { var container = UIContainers.horizontalFlow(Sizing.fill(100), Sizing.content()); container.verticalAlignment(VerticalAlignment.CENTER); int optionIndex = i; final var label = UIComponents.label(TextOps.withFormatting("- ", ChatFormatting.GRAY)); label.margins(Insets.left(10)); if (!this.backingOption.detached()) { label.cursorStyle(CursorStyle.HAND); label.mouseEnter().subscribe(() -> label.text(TextOps.withFormatting("x ", ChatFormatting.GRAY))); label.mouseLeave().subscribe(() -> label.text(TextOps.withFormatting("- ", ChatFormatting.GRAY))); label.mouseDown().subscribe((click, doubled) -> { this.backingList.remove(optionIndex); this.refreshResetButton(); this.refreshOptions(); UISounds.playInteractionSound(); return true; }); } container.child(label); final var box = new ConfigTextBox(); box.setValue(this.backingList.get(i).toString()); box.moveCursorToStart(false); box.setBordered(false); box.margins(Insets.vertical(2)); box.horizontalSizing(Sizing.fill(95)); box.verticalSizing(Sizing.fixed(8)); if (!this.backingOption.detached()) { box.onChanged().subscribe(s -> { if (!box.isValid()) return; this.backingList.set(optionIndex, (T) box.parsedValue()); this.refreshResetButton(); }); } else { box.active = false; } if (NumberReflection.isNumberType(listType)) { box.configureForNumber((Class) listType); } container.child(box); this.collapsibleChildren.add(container); } this.contentLayout.configure(layout -> { layout.clearChildren(); if (this.expanded) layout.children(this.collapsibleChildren); }); this.refreshResetButton(); } protected void refreshResetButton() { this.resetButton.active = !this.backingOption.detached() && !this.backingList.equals(this.backingOption.defaultValue()); } @Override public boolean shouldDrawTooltip(double mouseX, double mouseY) { return ((mouseY - this.y) <= this.titleLayout.height()) && super.shouldDrawTooltip(mouseX, mouseY); } @Override public boolean isValid() { return true; } @Override public Object parsedValue() { return this.backingList; } } ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/component/OptionValueProvider.java ================================================ package io.wispforest.owo.config.ui.component; public interface OptionValueProvider { /** * @return {@code true} if the current state of this component * describes a valid value for the option it is linked to */ boolean isValid(); /** * @return The value described by the current state * of this component */ Object parsedValue(); } ================================================ FILE: src/main/java/io/wispforest/owo/config/ui/component/SearchAnchorComponent.java ================================================ package io.wispforest.owo.config.ui.component; import io.wispforest.owo.config.Option; import io.wispforest.owo.config.ui.ConfigScreen; import io.wispforest.owo.ui.base.BaseUIComponent; import io.wispforest.owo.ui.core.OwoUIGraphics; import io.wispforest.owo.ui.core.ParentUIComponent; import io.wispforest.owo.ui.core.Positioning; import io.wispforest.owo.ui.core.Sizing; import java.util.Arrays; import java.util.Locale; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.stream.Collectors; public class SearchAnchorComponent extends BaseUIComponent { protected final ParentUIComponent anchorFrame; protected final Supplier[] searchTextSources; protected final Option.Key key; protected Consumer highlightConfigurator = highlight -> {}; @SafeVarargs public SearchAnchorComponent(ParentUIComponent anchorFrame, Option.Key key, Supplier... searchTextSources) { this.anchorFrame = anchorFrame; this.searchTextSources = searchTextSources; this.key = key; this.positioning(Positioning.absolute(0, 0)); this.sizing(Sizing.fixed(0)); } @Override public void draw(OwoUIGraphics graphics, int mouseX, int mouseY, float partialTicks, float delta) {} public ParentUIComponent anchorFrame() { return this.anchorFrame; } public ConfigScreen.SearchHighlighterComponent configure(ConfigScreen.SearchHighlighterComponent component) { this.highlightConfigurator.accept(component); return component; } public SearchAnchorComponent highlightConfigurator(Consumer highlightConfigurator) { this.highlightConfigurator = highlightConfigurator; return this; } public Option.Key key() { return this.key; } public String currentSearchText() { return Arrays.stream(this.searchTextSources) .map(Supplier::get) .map(s -> s.toLowerCase(Locale.ROOT)) .collect(Collectors.joining()); } } ================================================ FILE: src/main/java/io/wispforest/owo/ext/DerivedComponentMap.java ================================================ package io.wispforest.owo.ext; import net.minecraft.core.component.DataComponentMap; import net.minecraft.core.component.DataComponentPatch; import net.minecraft.core.component.DataComponentType; import net.minecraft.core.component.PatchedDataComponentMap; import net.minecraft.world.item.ItemStack; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; import java.util.Objects; import java.util.Set; @ApiStatus.Internal public class DerivedComponentMap implements DataComponentMap { private final DataComponentMap base; private final PatchedDataComponentMap delegate; public DerivedComponentMap(DataComponentMap base) { this.base = base; this.delegate = new PatchedDataComponentMap(base); } public static DataComponentMap reWrapIfNeeded(DataComponentMap original) { if (original instanceof DerivedComponentMap derived) { return new DerivedComponentMap(derived.base); } else { return original; } } public void derive(ItemStack owner) { delegate.restorePatch(DataComponentPatch.EMPTY); var builder = DataComponentPatch.builder(); owner.getItem().deriveStackComponents(owner.getComponents(), builder); delegate.restorePatch(builder.build()); } @Nullable @Override public T get(DataComponentType type) { return delegate.get(type); } @Override public Set> keySet() { return delegate.keySet(); } @Override public boolean equals(Object o) { if (this == o) { return true; } else if (o instanceof DerivedComponentMap thatDerived) { return Objects.equals(base, thatDerived.base); } else if (o instanceof DataComponentMap.Builder.SimpleMap simpleComponentMap) { return Objects.equals(base, simpleComponentMap); } return o == EMPTY && this.base == EMPTY; } @Override public int hashCode() { return Objects.hashCode(base); } } ================================================ FILE: src/main/java/io/wispforest/owo/ext/OwoItem.java ================================================ package io.wispforest.owo.ext; import net.minecraft.core.component.DataComponentMap; import net.minecraft.core.component.DataComponentPatch; import org.jetbrains.annotations.ApiStatus; public interface OwoItem { /** * Generates component-derived-components from the stack's components * @param source a map containing the item stack's non-derived components * @param target a builder for the derived component map */ @ApiStatus.Experimental default void deriveStackComponents(DataComponentMap source, DataComponentPatch.Builder target) { } } ================================================ FILE: src/main/java/io/wispforest/owo/itemgroup/Icon.java ================================================ package io.wispforest.owo.itemgroup; import io.wispforest.owo.client.texture.AnimatedTextureDrawable; import io.wispforest.owo.client.texture.SpriteSheetMetadata; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.resources.Identifier; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.ItemLike; /** * An icon used for rendering on buttons in {@link OwoItemGroup}s *

* Default implementations provided for textures and item stacks */ @FunctionalInterface public interface Icon { @Environment(EnvType.CLIENT) void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float delta); static Icon of(ItemStack stack) { return new Icon() { @Override public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float delta) { graphics.renderFakeItem(stack, x, y); } }; } static Icon of(ItemLike item) { return of(new ItemStack(item)); } static Icon of(Identifier texture, int u, int v, int textureWidth, int textureHeight) { return new Icon() { @Override public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float delta) { graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, 16, 16, textureWidth, textureHeight); } }; } /** * Creates an Animated ItemGroup Icon * * @param texture The texture to render, this is the spritesheet * @param textureSize The size of the texture, it is assumed to be square * @param frameDelay The delay in milliseconds between frames. * @param loop Should the animation play once or loop? * @return The created icon instance */ static Icon of(Identifier texture, int textureSize, int frameDelay, boolean loop) { var widget = new AnimatedTextureDrawable(0, 0, 16, 16, texture, new SpriteSheetMetadata(textureSize, 16), frameDelay, loop); return new Icon() { @Override public void render(GuiGraphics graphics, int x, int y, int mouseX, int mouseY, float delta) { widget.render(x, y, graphics, mouseX, mouseY, delta); } }; } } ================================================ FILE: src/main/java/io/wispforest/owo/itemgroup/ItemGroupReference.java ================================================ package io.wispforest.owo.itemgroup; public record ItemGroupReference(OwoItemGroup group, int tab) {} ================================================ FILE: src/main/java/io/wispforest/owo/itemgroup/OwoItemGroup.java ================================================ package io.wispforest.owo.itemgroup; import io.wispforest.owo.itemgroup.gui.ItemGroupButton; import io.wispforest.owo.itemgroup.gui.ItemGroupButtonWidget; import io.wispforest.owo.itemgroup.gui.ItemGroupTab; import io.wispforest.owo.mixin.itemgroup.CreativeModeTabAccessor; import io.wispforest.owo.util.pond.OwoItemExtensions; import it.unimi.dsi.fastutil.ints.IntAVLTreeSet; import it.unimi.dsi.fastutil.ints.IntComparators; import it.unimi.dsi.fastutil.ints.IntSet; import it.unimi.dsi.fastutil.ints.IntSets; import net.fabricmc.api.EnvType; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.core.Registry; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import net.minecraft.tags.TagKey; import net.minecraft.world.flag.FeatureFlagSet; import net.minecraft.world.item.CreativeModeTab; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.level.ItemLike; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Supplier; /** * Extensions for {@link CreativeModeTab} which support multiple sub-tabs * within, as well as arbitrary buttons with defaults provided for links * to places like GitHub, Modrinth, etc. *

* Tabs can be populated by setting the {@link OwoItemSettingsExtension#tab(int)}. * Furthermore, tags can be used for easily populating tabs from data *

* The roots of this implementation originated in Biome Makeover, where it was written by Lemonszz */ public abstract class OwoItemGroup extends CreativeModeTab { public static final BiConsumer DEFAULT_STACK_GENERATOR = (item, stacks) -> stacks.accept(item.getDefaultInstance()); protected static final ItemGroupTab PLACEHOLDER_TAB = new ItemGroupTab(Icon.of(Items.AIR), Component.empty(), (br, uh) -> {}, ItemGroupTab.DEFAULT_TEXTURE, false); public final List tabs = new ArrayList<>(); public final List buttons = new ArrayList<>(); private final Consumer initializer; private final Supplier iconSupplier; private Icon icon; private final IntSet activeTabs = new IntAVLTreeSet(IntComparators.NATURAL_COMPARATOR); private final IntSet activeTabsView = IntSets.unmodifiable(this.activeTabs); private boolean initialized = false; private final @Nullable Identifier backgroundTexture; private final @Nullable ScrollerTextures scrollerTextures; private final @Nullable TabTextures tabTextures; private final int tabStackHeight; private final int buttonStackHeight; private final boolean useDynamicTitle; private final boolean displaySingleTab; private final boolean allowMultiSelect; protected OwoItemGroup(Identifier id, Consumer initializer, Supplier iconSupplier, int tabStackHeight, int buttonStackHeight, @Nullable Identifier backgroundTexture, @Nullable ScrollerTextures scrollerTextures, @Nullable TabTextures tabTextures, boolean useDynamicTitle, boolean displaySingleTab, boolean allowMultiSelect) { super(null, -1, Type.CATEGORY, Component.translatable("itemGroup.%s.%s".formatted(id.getNamespace(), id.getPath())), () -> ItemStack.EMPTY, (displayContext, entries) -> {}); this.initializer = initializer; this.iconSupplier = iconSupplier; this.tabStackHeight = tabStackHeight; this.buttonStackHeight = buttonStackHeight; this.backgroundTexture = backgroundTexture; this.scrollerTextures = scrollerTextures; this.tabTextures = tabTextures; this.useDynamicTitle = useDynamicTitle; this.displaySingleTab = displaySingleTab; this.allowMultiSelect = allowMultiSelect; ((CreativeModeTabAccessor) this).owo$setDisplayItemsGenerator((context, entries) -> { if (!this.initialized) { throw new IllegalStateException("oωo item group not initialized, was 'initialize()' called?"); } this.activeTabs.forEach(tabIdx -> { this.tabs.get(tabIdx).contentSupplier().addItems(context, entries); this.collectItemsFromRegistry(entries, tabIdx); }); }); } public static Builder builder(Identifier id, Supplier iconSupplier) { return new Builder(id, iconSupplier); } // --------- /** * Executes {@link #initializer} and makes sure this item group is ready for use *

* Call this after all of your items have been registered to make sure your icons * show up correctly */ public void initialize() { if (this.initialized) return; if (FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT) this.initializer.accept(this); if (this.tabs.isEmpty()) this.tabs.add(PLACEHOLDER_TAB); if (this.allowMultiSelect) { for (int tabIdx = 0; tabIdx < this.tabs.size(); tabIdx++) { if (!this.tabs.get(tabIdx).primary()) continue; this.activeTabs.add(tabIdx); } if (this.activeTabs.isEmpty()) this.activeTabs.add(0); } else { this.activeTabs.add(0); } this.initialized = true; } /** * Adds the specified button to the buttons on * the right side of the creative menu * * @param button The button to add * @see ItemGroupButton#link(CreativeModeTab, Icon, String, String) * @see ItemGroupButton#curseforge(CreativeModeTab, String) * @see ItemGroupButton#discord(CreativeModeTab, String) */ public void addButton(ItemGroupButton button) { this.buttons.add(button); } /** * Adds a new tab to this group * * @param icon The icon to use * @param name The name of the tab, used for the translation key * @param contentTag The tag used for filling this tab * @param texture The texture to use for drawing the button * @see Icon#of(ItemLike) */ public void addTab(Icon icon, String name, @Nullable TagKey contentTag, Identifier texture, boolean primary) { this.tabs.add(new ItemGroupTab( icon, ButtonDefinition.tooltipFor(this, "tab", name), contentTag == null ? (context, entries) -> {} : (context, entries) -> BuiltInRegistries.ITEM.stream().filter(item -> item.builtInRegistryHolder().is(contentTag)).forEach(entries::accept), texture, primary )); } /** * Adds a new tab to this group, using the default button texture * * @param icon The icon to use * @param name The name of the tab, used for the translation key * @param contentTag The tag used for filling this tab * @see Icon#of(ItemLike) */ public void addTab(Icon icon, String name, @Nullable TagKey contentTag, boolean primary) { addTab(icon, name, contentTag, ItemGroupTab.DEFAULT_TEXTURE, primary); } /** * Adds a new tab to this group, using the default button texture * * @param icon The icon to use * @param name The name of the tab, used for the translation key * @param contentSupplier The function used for filling this tab * @param texture The texture to use for drawing the button * @see Icon#of(ItemLike) */ public void addCustomTab(Icon icon, String name, ItemGroupTab.ContentSupplier contentSupplier, Identifier texture, boolean primary) { this.tabs.add(new ItemGroupTab( icon, ButtonDefinition.tooltipFor(this, "tab", name), contentSupplier, texture, primary )); } /** * Adds a new tab to this group * * @param icon The icon to use * @param name The name of the tab, used for the translation key * @param contentSupplier The function used for filling this tab * @see Icon#of(ItemLike) */ public void addCustomTab(Icon icon, String name, ItemGroupTab.ContentSupplier contentSupplier, boolean primary) { this.addCustomTab(icon, name, contentSupplier, ItemGroupTab.DEFAULT_TEXTURE, primary); } @Override public void buildContents(ItemDisplayParameters context) { super.buildContents(context); var searchEntries = new SearchOnlyEntries(this, context.enabledFeatures()); this.collectItemsFromRegistry(searchEntries, -1); this.tabs.forEach(tab -> tab.contentSupplier().addItems(context, searchEntries)); ((CreativeModeTabAccessor) this).owo$setDisplayItemsSearchTab(searchEntries.searchTabContents); } protected void collectItemsFromRegistry(Output entries, int tab) { BuiltInRegistries.ITEM.stream() .filter(item -> ((OwoItemExtensions) item).owo$group() == this && (tab < 0 || tab == ((OwoItemExtensions) item).owo$tab())) .forEach(item -> ((OwoItemExtensions) item).owo$stackGenerator().accept(item, entries)); } // Getters and setters /** * Select only {@code tab}, deselecting all other tabs, * using {@code context} for re-population */ public void selectSingleTab(int tab, ItemDisplayParameters context) { this.activeTabs.clear(); this.activeTabs.add(tab); this.buildContents(context); } /** * Select {@code tab} in addition to other currently selected * tabs, using {@code context} for re-population. *

* If this group does not allow multiple selection, behaves * like {@link #selectSingleTab(int, ItemDisplayParameters)} */ public void selectTab(int tab, ItemDisplayParameters context) { if (!this.allowMultiSelect) { this.activeTabs.clear(); } this.activeTabs.add(tab); this.buildContents(context); } /** * Deselect {@code tab} if it is currently selected, using {@code context} for * re-population. If this results in no tabs being selected, all tabs are * automatically selected instead */ public void deselectTab(int tab, ItemDisplayParameters context) { if (!this.allowMultiSelect) return; this.activeTabs.remove(tab); if (this.activeTabs.isEmpty()) { for (int tabIdx = 0; tabIdx < this.tabs.size(); tabIdx++) { this.activeTabs.add(tabIdx); } } this.buildContents(context); } /** * Shorthand for {@link #selectTab(int, ItemDisplayParameters)} or * {@link #deselectTab(int, ItemDisplayParameters)}, depending on the tabs * current state */ public void toggleTab(int tab, ItemDisplayParameters context) { if (this.isTabSelected(tab)) { this.deselectTab(tab, context); } else { this.selectTab(tab, context); } } /** * @return A set containing the indices of all currently * selected tabs */ public IntSet selectedTabs() { return this.activeTabsView; } /** * @return {@code true} if {@code tab} is currently selected */ public boolean isTabSelected(int tab) { return this.activeTabs.contains(tab); } public @Nullable Identifier getOwoBackgroundTexture() { return this.backgroundTexture; } public @Nullable ScrollerTextures getScrollerTextures() { return this.scrollerTextures; } public @Nullable TabTextures getTabTextures() { return this.tabTextures; } public int getTabStackHeight() { return tabStackHeight; } public int getButtonStackHeight() { return buttonStackHeight; } public boolean hasDynamicTitle() { return this.useDynamicTitle && (this.tabs.size() > 1 || this.shouldDisplaySingleTab()); } public boolean shouldDisplaySingleTab() { return this.displaySingleTab; } public boolean canSelectMultipleTabs() { return this.allowMultiSelect; } public List getButtons() { return buttons; } public ItemGroupTab getTab(int index) { return index < this.tabs.size() ? this.tabs.get(index) : null; } public Icon icon() { return this.icon == null ? this.icon = this.iconSupplier.get() : this.icon; } @Override public boolean shouldDisplay() { return true; } public Identifier id() { return BuiltInRegistries.CREATIVE_MODE_TAB.getKey(this); } public static class Builder { private final Identifier id; private final Supplier iconSupplier; private Consumer initializer = owoItemGroup -> {}; private int tabStackHeight = 4; private int buttonStackHeight = 4; private @Nullable Identifier backgroundTexture = null; private @Nullable ScrollerTextures scrollerTextures = null; private @Nullable TabTextures tabTextures = null; private boolean useDynamicTitle = true; private boolean displaySingleTab = false; private boolean allowMultiSelect = true; private Builder(Identifier id, Supplier iconSupplier) { this.id = id; this.iconSupplier = iconSupplier; } public Builder initializer(Consumer initializer) { this.initializer = initializer; return this; } public Builder tabStackHeight(int tabStackHeight) { this.tabStackHeight = tabStackHeight; return this; } public Builder buttonStackHeight(int buttonStackHeight) { this.buttonStackHeight = buttonStackHeight; return this; } public Builder backgroundTexture(@Nullable Identifier backgroundTexture) { this.backgroundTexture = backgroundTexture; return this; } public Builder scrollerTextures(ScrollerTextures scrollerTextures) { this.scrollerTextures = scrollerTextures; return this; } public Builder tabTextures(TabTextures tabTextures) { this.tabTextures = tabTextures; return this; } public Builder disableDynamicTitle() { this.useDynamicTitle = false; return this; } public Builder displaySingleTab() { this.displaySingleTab = true; return this; } public Builder withoutMultipleSelection() { this.allowMultiSelect = false; return this; } public OwoItemGroup build() { final var group = new OwoItemGroup(id, initializer, iconSupplier, tabStackHeight, buttonStackHeight, backgroundTexture, scrollerTextures, tabTextures, useDynamicTitle, displaySingleTab, allowMultiSelect) {}; Registry.register(BuiltInRegistries.CREATIVE_MODE_TAB, this.id, group); return group; } } protected static class SearchOnlyEntries extends ItemDisplayBuilder { public SearchOnlyEntries(CreativeModeTab group, FeatureFlagSet enabledFeatures) { super(group, enabledFeatures); } @Override public void accept(ItemStack stack, TabVisibility visibility) { if (visibility == TabVisibility.PARENT_TAB_ONLY) return; super.accept(stack, TabVisibility.SEARCH_TAB_ONLY); } } public record ScrollerTextures(Identifier enabled, Identifier disabled) {} public record TabTextures(Identifier topSelected, Identifier topSelectedFirstColumn, Identifier topUnselected, Identifier bottomSelected, Identifier bottomSelectedFirstColumn, Identifier bottomUnselected) {} // Utility /** * Defines a button's appearance and translation key *

* Used by {@link ItemGroupButtonWidget} */ public interface ButtonDefinition { Icon icon(); Identifier texture(); Component tooltip(); static Component tooltipFor(CreativeModeTab group, String component, String componentName) { var registryId = BuiltInRegistries.CREATIVE_MODE_TAB.getKey(group); var groupId = registryId.getNamespace().equals("minecraft") ? registryId.getPath() : registryId.getNamespace() + "." + registryId.getPath(); return Component.translatable("itemGroup." + groupId + "." + component + "." + componentName); } } } ================================================ FILE: src/main/java/io/wispforest/owo/itemgroup/OwoItemSettingsExtension.java ================================================ package io.wispforest.owo.itemgroup; import net.minecraft.world.InteractionHand; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.CreativeModeTab; import net.minecraft.world.item.Item; import net.minecraft.world.level.Level; import java.util.function.BiConsumer; public interface OwoItemSettingsExtension { default Item.Properties group(ItemGroupReference ref) { throw new IllegalStateException("Implemented in mixin."); } /** * @param group The item group this item should appear in */ default Item.Properties group(OwoItemGroup group) { throw new IllegalStateException("Implemented in mixin."); } default OwoItemGroup group() { throw new IllegalStateException("Implemented in mixin."); } default Item.Properties tab(int tab) { throw new IllegalStateException("Implemented in mixin."); } default int tab() { throw new IllegalStateException("Implemented in mixin."); } /** * @param generator The function this item uses for creating stacks in the * {@link OwoItemGroup} it is in, by default this will be {@link OwoItemGroup#DEFAULT_STACK_GENERATOR} */ default Item.Properties stackGenerator(BiConsumer generator) { throw new IllegalStateException("Implemented in mixin."); } default BiConsumer stackGenerator() { throw new IllegalStateException("Implemented in mixin."); } /** * Automatically increment {@link net.minecraft.stats.Stats#ITEM_USED} * for this item every time {@link Item#use(Level, Player, InteractionHand)} * returns an accepted result */ default Item.Properties trackUsageStat() { throw new IllegalStateException("Implemented in mixin."); } default boolean shouldTrackUsageStat() { throw new IllegalStateException("Implemented in mixin."); } } ================================================ FILE: src/main/java/io/wispforest/owo/itemgroup/gui/ItemGroupButton.java ================================================ package io.wispforest.owo.itemgroup.gui; import io.wispforest.owo.Owo; import io.wispforest.owo.itemgroup.Icon; import io.wispforest.owo.itemgroup.OwoItemGroup; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.ConfirmLinkScreen; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import net.minecraft.util.Util; import net.minecraft.world.item.CreativeModeTab; /** * A button placed to the right side of the creative inventory. Provides defaults * for linking to sites, but can execute arbitrary actions */ public final class ItemGroupButton implements OwoItemGroup.ButtonDefinition { public static final Identifier ICONS_TEXTURE = Owo.id("textures/gui/icons.png"); private final Icon icon; private final Component tooltip; private final Identifier texture; private final Runnable action; public ItemGroupButton(CreativeModeTab group, Icon icon, String name, Identifier texture, Runnable action) { this.icon = icon; this.tooltip = OwoItemGroup.ButtonDefinition.tooltipFor(group, "button", name); this.action = action; this.texture = texture; } public ItemGroupButton(CreativeModeTab group, Icon icon, String name, Runnable action) { this(group, icon, name, ItemGroupTab.DEFAULT_TEXTURE, action); } public static ItemGroupButton github(CreativeModeTab group, String url) { return link(group, Icon.of(ICONS_TEXTURE, 0, 0, 64, 64), "github", url); } public static ItemGroupButton modrinth(CreativeModeTab group, String url) { return link(group, Icon.of(ICONS_TEXTURE, 16, 0, 64, 64), "modrinth", url); } public static ItemGroupButton curseforge(CreativeModeTab group, String url) { return link(group, Icon.of(ICONS_TEXTURE, 32, 0, 64, 64), "curseforge", url); } public static ItemGroupButton discord(CreativeModeTab group, String url) { return link(group, Icon.of(ICONS_TEXTURE, 48, 0, 64, 64), "discord", url); } /** * Creates a button that opens the given link when clicked * * @param icon The icon for this button to use * @param name The name of this button, used for the translation key * @param url The url to open * @return The created button */ public static ItemGroupButton link(CreativeModeTab group, Icon icon, String name, String url) { return new ItemGroupButton(group, icon, name, () -> { final var client = Minecraft.getInstance(); var screen = client.screen; client.setScreen(new ConfirmLinkScreen(confirmed -> { if (confirmed) Util.getPlatform().openUri(url); client.setScreen(screen); }, url, true)); }); } @Override public Identifier texture() { return this.texture; } @Override public Icon icon() { return this.icon; } @Override public Component tooltip() { return this.tooltip; } public Runnable action() { return this.action; } } ================================================ FILE: src/main/java/io/wispforest/owo/itemgroup/gui/ItemGroupButtonWidget.java ================================================ package io.wispforest.owo.itemgroup.gui; import io.wispforest.owo.itemgroup.OwoItemGroup; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Button; import net.minecraft.client.renderer.RenderPipelines; import org.jetbrains.annotations.ApiStatus; import java.util.function.Consumer; @ApiStatus.Internal public class ItemGroupButtonWidget extends Button { public boolean isSelected = false; private final OwoItemGroup.ButtonDefinition definition; private final int baseU; public ItemGroupButtonWidget(int x, int y, int baseU, OwoItemGroup.ButtonDefinition definition, Consumer onPress) { super(x, y, 24, 24, definition.tooltip(), button -> onPress.accept((ItemGroupButtonWidget) button), Button.DEFAULT_NARRATION); this.baseU = baseU; this.definition = definition; } @Override public void renderContents(GuiGraphics context, int mouseX, int mouseY, float delta) { context.blit(RenderPipelines.GUI_TEXTURED, this.definition.texture(), this.getX(), this.getY(), this.baseU, this.isHoveredOrFocused() || this.isSelected ? this.height : 0, this.width, this.height, 64, 64); this.definition.icon().render(context, this.getX() + 4, this.getY() + 4, mouseX, mouseY, delta); } public boolean isTab() { return this.definition instanceof ItemGroupTab; } public boolean trulyHovered() { return this.isHovered; } } ================================================ FILE: src/main/java/io/wispforest/owo/itemgroup/gui/ItemGroupTab.java ================================================ package io.wispforest.owo.itemgroup.gui; import io.wispforest.owo.Owo; import io.wispforest.owo.itemgroup.Icon; import io.wispforest.owo.itemgroup.OwoItemGroup; import io.wispforest.owo.itemgroup.OwoItemSettingsExtension; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import net.minecraft.world.item.CreativeModeTab; /** * Represents a tab inside an {@link OwoItemGroup} that contains all items in the * passed {@code contentTag}. If you want to use {@link OwoItemSettingsExtension#tab(int)} to * define the contents, use {@code null} as the tag */ public record ItemGroupTab( Icon icon, Component name, ContentSupplier contentSupplier, Identifier texture, boolean primary ) implements OwoItemGroup.ButtonDefinition { public static final Identifier DEFAULT_TEXTURE = Owo.id("textures/gui/tabs.png"); @Override public Component tooltip() { return this.name; } @FunctionalInterface public interface ContentSupplier { void addItems(CreativeModeTab.ItemDisplayParameters context, CreativeModeTab.Output entries); } } ================================================ FILE: src/main/java/io/wispforest/owo/itemgroup/json/OwoItemGroupLoader.java ================================================ package io.wispforest.owo.itemgroup.json; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import io.wispforest.owo.itemgroup.Icon; import io.wispforest.owo.itemgroup.OwoItemGroup; import io.wispforest.owo.itemgroup.gui.ItemGroupButton; import io.wispforest.owo.itemgroup.gui.ItemGroupTab; import io.wispforest.owo.moddata.ModDataConsumer; import io.wispforest.owo.util.pond.OwoItemExtensions; import net.fabricmc.fabric.api.event.registry.RegistryEntryAddedCallback; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; import net.minecraft.tags.TagKey; import net.minecraft.util.GsonHelper; import net.minecraft.world.item.CreativeModeTab; import net.minecraft.world.item.CreativeModeTabs; import org.jetbrains.annotations.ApiStatus; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; /** * Manages loading and adding JSON-based tabs to preexisting {@code ItemGroup}s * without needing to depend on owo *

* This is used instead of a {@link net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener} because * it needs to load on the client */ @ApiStatus.Internal public class OwoItemGroupLoader implements ModDataConsumer { public static final OwoItemGroupLoader INSTANCE = new OwoItemGroupLoader(); private static final Map BUFFERED_GROUPS = new HashMap<>(); private OwoItemGroupLoader() {} public static void onGroupCreated(CreativeModeTab group) { var groupId = BuiltInRegistries.CREATIVE_MODE_TAB.getKey(group); if (!BUFFERED_GROUPS.containsKey(groupId)) return; INSTANCE.acceptParsedFile(groupId, BUFFERED_GROUPS.remove(groupId)); } @Override public void acceptParsedFile(Identifier id, JsonObject json) { var targetGroupId = Identifier.parse(GsonHelper.getAsString(json, "target_group")); CreativeModeTab searchGroup = null; for (CreativeModeTab group : CreativeModeTabs.allTabs()) { if (BuiltInRegistries.CREATIVE_MODE_TAB.getKey(group).equals(targetGroupId)) { searchGroup = group; break; } } if (searchGroup == null) { BUFFERED_GROUPS.put(targetGroupId, json); return; } final var targetGroup = searchGroup; var tabsArray = GsonHelper.getAsJsonArray(json, "tabs", new JsonArray()); var tabs = new ArrayList(); tabsArray.forEach(jsonElement -> { if (!jsonElement.isJsonObject()) return; var tabObject = jsonElement.getAsJsonObject(); var texture = Identifier.parse(GsonHelper.getAsString(tabObject, "texture", ItemGroupTab.DEFAULT_TEXTURE.toString())); var tag = TagKey.create(Registries.ITEM, Identifier.parse(GsonHelper.getAsString(tabObject, "tag"))); var icon = BuiltInRegistries.ITEM.getValue(Identifier.parse(GsonHelper.getAsString(tabObject, "icon"))); var name = GsonHelper.getAsString(tabObject, "name"); tabs.add(new ItemGroupTab( Icon.of(icon), OwoItemGroup.ButtonDefinition.tooltipFor(targetGroup, "tab", name), (context, entries) -> BuiltInRegistries.ITEM.stream().filter(item -> item.builtInRegistryHolder().is(tag)).forEach(entries::accept), texture, false )); }); var buttonsArray = GsonHelper.getAsJsonArray(json, "buttons", new JsonArray()); var buttons = new ArrayList(); buttonsArray.forEach(jsonElement -> { if (!jsonElement.isJsonObject()) return; var buttonObject = jsonElement.getAsJsonObject(); String link = GsonHelper.getAsString(buttonObject, "link"); String name = GsonHelper.getAsString(buttonObject, "name"); int u = GsonHelper.getAsInt(buttonObject, "texture_u"); int v = GsonHelper.getAsInt(buttonObject, "texture_v"); int textureWidth = GsonHelper.getAsInt(buttonObject, "texture_width", 64); int textureHeight = GsonHelper.getAsInt(buttonObject, "texture_height", 64); final var textureId = GsonHelper.getAsString(buttonObject, "texture", null); var texture = textureId == null ? ItemGroupButton.ICONS_TEXTURE : Identifier.parse(textureId); buttons.add(ItemGroupButton.link(targetGroup, Icon.of(texture, u, v, textureWidth, textureHeight), name, link)); }); if (targetGroup instanceof WrapperGroup wrapper) { wrapper.addTabs(tabs); wrapper.addButtons(buttons); if (GsonHelper.getAsBoolean(json, "extend", false)) wrapper.markExtension(); } else { var wrapper = new WrapperGroup(targetGroup, targetGroupId, tabs, buttons); wrapper.initialize(); if (GsonHelper.getAsBoolean(json, "extend", false)) wrapper.markExtension(); BuiltInRegistries.ITEM.stream() .filter(item -> ((OwoItemExtensions) item).owo$group() == targetGroup) .forEach(item -> ((OwoItemExtensions) item).owo$setGroup(wrapper)); } } @Override public String getDataSubdirectory() { return "item_group_tabs"; } static { RegistryEntryAddedCallback.event(BuiltInRegistries.CREATIVE_MODE_TAB).register((rawId, id, group) -> { OwoItemGroupLoader.onGroupCreated(group); }); } } ================================================ FILE: src/main/java/io/wispforest/owo/itemgroup/json/WrapperGroup.java ================================================ package io.wispforest.owo.itemgroup.json; import io.wispforest.owo.itemgroup.Icon; import io.wispforest.owo.itemgroup.OwoItemGroup; import io.wispforest.owo.itemgroup.gui.ItemGroupButton; import io.wispforest.owo.itemgroup.gui.ItemGroupTab; import io.wispforest.owo.mixin.itemgroup.CreativeModeTabAccessor; import io.wispforest.owo.util.pond.OwoSimpleRegistryExtensions; import net.minecraft.core.RegistrationInfo; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; import net.minecraft.world.item.CreativeModeTab; import org.jetbrains.annotations.ApiStatus; import java.util.Collection; import java.util.List; /** * Used to replace a vanilla or modded item group to add the JSON-defined * tabs while keeping the same name, id and icon */ @ApiStatus.Internal public class WrapperGroup extends OwoItemGroup { private final CreativeModeTab parent; private boolean extension = false; @SuppressWarnings("unchecked") public WrapperGroup(CreativeModeTab parent, Identifier parentId, List tabs, List buttons) { super(parentId, owoItemGroup -> {}, () -> Icon.of(parent.getIconItem()), 4, 4, null, null, null, true, false, false); int parentRawId = BuiltInRegistries.CREATIVE_MODE_TAB.getId(parent); ((OwoSimpleRegistryExtensions) BuiltInRegistries.CREATIVE_MODE_TAB).owo$set(parentRawId, ResourceKey.create(Registries.CREATIVE_MODE_TAB, parentId), this, RegistrationInfo.BUILT_IN); ((CreativeModeTabAccessor) this).owo$setDisplayName(parent.getDisplayName()); ((CreativeModeTabAccessor) this).owo$setColumn(parent.column()); ((CreativeModeTabAccessor) this).owo$setRow(parent.row()); this.parent = parent; this.tabs.addAll(tabs); this.buttons.addAll(buttons); } public void addTabs(Collection tabs) { this.tabs.addAll(tabs); } public void addButtons(Collection buttons) { this.buttons.addAll(buttons); } public void markExtension() { if (this.extension) return; this.extension = true; if (this.tabs.get(0) == PLACEHOLDER_TAB) { this.tabs.remove(0); } this.tabs.add(0, new ItemGroupTab( Icon.of(this.parent.getIconItem()), this.parent.getDisplayName(), ((CreativeModeTabAccessor) this.parent).owo$getDisplayItemsGenerator()::accept, ItemGroupTab.DEFAULT_TEXTURE, true )); } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/AbstractContainerMenuInvoker.java ================================================ package io.wispforest.owo.mixin; import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.item.ItemStack; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Invoker; @Mixin(AbstractContainerMenu.class) public interface AbstractContainerMenuInvoker { @Invoker("moveItemStackTo") boolean owo$insertItem(ItemStack stack, int startIndex, int endIndex, boolean fromLast); } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/AbstractContainerMenuMixin.java ================================================ package io.wispforest.owo.mixin; import io.wispforest.endec.Endec; import io.wispforest.endec.SerializationContext; import io.wispforest.endec.impl.ReflectiveEndecBuilder; import io.wispforest.owo.client.screens.OwoAbstractContainerMenu; import io.wispforest.owo.client.screens.MenuNetworkingInternals; import io.wispforest.owo.client.screens.ScreenhandlerMessageData; import io.wispforest.owo.client.screens.SyncedProperty; import io.wispforest.owo.network.NetworkException; import io.wispforest.owo.serialization.RegistriesAttribute; import io.wispforest.owo.serialization.endec.MinecraftEndecs; import io.wispforest.owo.util.pond.OwoAbstractContainerMenuExtension; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.networking.v1.PacketByteBufs; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.inventory.MenuType; import org.jetbrains.annotations.NotNull; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Consumer; @Mixin(AbstractContainerMenu.class) public abstract class AbstractContainerMenuMixin implements OwoAbstractContainerMenu, OwoAbstractContainerMenuExtension { @Shadow private boolean suppressRemoteUpdates; @Unique private final List> properties = new ArrayList<>(); @Unique private final Map, ScreenhandlerMessageData> messages = new LinkedHashMap<>(); @Unique private final List> clientBoundMessages = new ArrayList<>(); @Unique private final List> serverBoundMessages = new ArrayList<>(); @Unique private Player player = null; @Unique private ReflectiveEndecBuilder builder; @Inject(method = "", at = @At("TAIL")) private void createReflectiveBuilder(MenuType type, int syncId, CallbackInfo ci) { this.builder = MinecraftEndecs.addDefaults(new ReflectiveEndecBuilder()); } @Override public ReflectiveEndecBuilder endecBuilder() { return builder; } @Override public void owo$attachToPlayer(Player player) { this.player = player; } @Override public Player player() { return this.player; } @Override public void addServerboundMessage(Class messageClass, Endec endec, Consumer handler) { int id = this.serverBoundMessages.size(); var messageData = new ScreenhandlerMessageData<>(id, false, endec, handler); this.serverBoundMessages.add(messageData); if (this.messages.put(messageClass, messageData) != null) { throw new NetworkException(messageClass + " is already registered as a message!"); } } @Override public void addClientboundMessage(Class messageClass, Endec endec, Consumer handler) { int id = this.clientBoundMessages.size(); var messageData = new ScreenhandlerMessageData<>(id, true, endec, handler); this.clientBoundMessages.add(messageData); if (this.messages.put(messageClass, messageData) != null) { throw new NetworkException(messageClass + " is already registered as a message!"); } } @Override @SuppressWarnings({"rawtypes", "unchecked"}) public void sendMessage(@NotNull R message) { if (this.player == null) { throw new NetworkException("Tried to send a message before player was attached"); } ScreenhandlerMessageData messageData = this.messages.get(message.getClass()); if (messageData == null) { throw new NetworkException("Tried to send message of unknown type " + message.getClass()); } var ctx = SerializationContext.attributes(RegistriesAttribute.of(this.player.registryAccess())); var buf = PacketByteBufs.create(); buf.write(ctx, messageData.endec(), message); var packet = new MenuNetworkingInternals.LocalPacket(messageData.id(), buf); if (messageData.clientbound()) { if (!(this.player instanceof ServerPlayer serverPlayer)) { throw new NetworkException("Tried to send clientbound message on the server"); } ServerPlayNetworking.send(serverPlayer, packet); } else { if (!this.player.level().isClientSide()) { throw new NetworkException("Tried to send serverbound message on the client"); } this.owo$sendToServer(packet); } } @Unique @Environment(EnvType.CLIENT) private void owo$sendToServer(CustomPacketPayload payload) { ClientPlayNetworking.send(payload); } @Override @SuppressWarnings({"rawtypes", "unchecked"}) public void owo$handlePacket(MenuNetworkingInternals.LocalPacket packet, boolean clientbound) { ScreenhandlerMessageData messageData = (clientbound ? this.clientBoundMessages : this.serverBoundMessages).get(packet.packetId()); var ctx = SerializationContext.attributes(RegistriesAttribute.of(this.player.registryAccess())); messageData.handler().accept(packet.payload().read(ctx, messageData.endec())); } @Override public SyncedProperty createProperty(Class clazz, Endec endec, T initial) { var prop = new SyncedProperty<>(this.properties.size(), endec, initial, (AbstractContainerMenu)(Object) this); this.properties.add(prop); return prop; } @Override public void owo$readPropertySync(MenuNetworkingInternals.SyncPropertiesPacket packet) { int count = packet.payload().readVarInt(); for (int i = 0; i < count; i++) { int idx = packet.payload().readVarInt(); this.properties.get(idx).read(packet.payload()); } } @Inject(method = "sendAllDataToRemote", at = @At("RETURN")) private void syncOnSyncState(CallbackInfo ci) { this.syncProperties(); } @Inject(method = "broadcastChanges", at = @At("RETURN")) private void syncOnSendContentUpdates(CallbackInfo ci) { if (suppressRemoteUpdates) return; this.syncProperties(); } @Unique private void syncProperties() { if (this.player == null) return; if (!(this.player instanceof ServerPlayer player)) return; int count = 0; for (var property : this.properties) { if (property.needsSync()) count++; } if (count == 0) return; var buf = PacketByteBufs.create(); buf.writeVarInt(count); for (var prop : properties) { if (!prop.needsSync()) continue; buf.writeVarInt(prop.index()); prop.write(buf); } ServerPlayNetworking.send(player, new MenuNetworkingInternals.SyncPropertiesPacket(buf)); } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/ClientCommonPacketListenerImplAccessor.java ================================================ package io.wispforest.owo.mixin; import net.minecraft.client.multiplayer.ClientCommonPacketListenerImpl; import net.minecraft.network.Connection; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; @Mixin(ClientCommonPacketListenerImpl.class) public interface ClientCommonPacketListenerImplAccessor { @Accessor Connection getConnection(); } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/ClientConfigurationPacketListenerImplMixin.java ================================================ package io.wispforest.owo.mixin; import io.wispforest.owo.network.OwoClientConnectionExtension; import io.wispforest.owo.network.QueuedChannelSet; import net.minecraft.client.multiplayer.ClientConfigurationPacketListenerImpl; import net.minecraft.network.Connection; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.ModifyArg; @Mixin(ClientConfigurationPacketListenerImpl.class) public class ClientConfigurationPacketListenerImplMixin { @ModifyArg(method = "handleConfigurationFinished", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/multiplayer/ClientPacketListener;(Lnet/minecraft/client/Minecraft;Lnet/minecraft/network/Connection;Lnet/minecraft/client/multiplayer/CommonListenerCookie;)V")) private Connection applyChannelSet(Connection connection) { ((OwoClientConnectionExtension) connection).owo$setChannelSet(QueuedChannelSet.channels); QueuedChannelSet.channels = null; return connection; } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/ClientHandshakePacketListenerImplAccessor.java ================================================ package io.wispforest.owo.mixin; import net.minecraft.client.multiplayer.ClientHandshakePacketListenerImpl; import net.minecraft.network.Connection; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; @Mixin(ClientHandshakePacketListenerImpl.class) public interface ClientHandshakePacketListenerImplAccessor { @Accessor("connection") Connection owo$getConnection(); } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/ConnectionMixin.java ================================================ package io.wispforest.owo.mixin; import io.wispforest.owo.network.OwoClientConnectionExtension; import net.minecraft.network.Connection; import net.minecraft.resources.Identifier; import org.spongepowered.asm.mixin.Mixin; import java.util.Collections; import java.util.Set; @Mixin(Connection.class) public class ConnectionMixin implements OwoClientConnectionExtension { private Set channels = Collections.emptySet(); @Override public void owo$setChannelSet(Set channels) { this.channels = channels; } @Override public Set owo$getChannelSet() { return this.channels; } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/Copenhagen.java ================================================ package io.wispforest.owo.mixin; import io.wispforest.owo.util.Maldenhagen; import net.minecraft.core.BlockPos; import net.minecraft.util.RandomSource; import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.BulkSectionAccess; import net.minecraft.world.level.chunk.LevelChunkSection; import net.minecraft.world.level.levelgen.feature.OreFeature; import net.minecraft.world.level.levelgen.feature.configurations.OreConfiguration; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import org.spongepowered.asm.mixin.injection.callback.LocalCapture; import java.util.BitSet; import java.util.HashMap; import java.util.Iterator; import java.util.Map; // welcome to maldenhagen, it moved // it originally lived in things, but it was malding too hard there // see Maldenhagen for how this is used @Mixin(OreFeature.class) public class Copenhagen { // this map contains the seethe'd orr blocks. its quite important @Unique private final ThreadLocal> COPING = ThreadLocal.withInitial(HashMap::new); // this target method is just so damn complex that not even mixin can correctly guess the injector signature. // i just kinda gave up and deleted some of them until it worked. very epic // // oh also the method caches all the spots that gleaming ore was placed at, so we can later update them for it to glow. // of course that needs to be done later, because mojang decided it should. the actual reason is that ChunkSectionCache // locks its chunk sections. // // now you would think this throws an error when you then try to modify those sections. but no. // it just silently deadlocks the entire game @SuppressWarnings("InvalidInjectorMethodSignature") @Inject(method = "doPlace", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/chunk/LevelChunkSection;setBlockState(IIILnet/minecraft/world/level/block/state/BlockState;Z)Lnet/minecraft/world/level/block/state/BlockState;"), locals = LocalCapture.CAPTURE_FAILHARD) private void malding(WorldGenLevel world, RandomSource random, OreConfiguration config, double startX, double endX, double startZ, double endZ, double startY, double endY, int p_x, int p_y, int p_z, int p_horizontalSize, int p_verticalSize, CallbackInfoReturnable cir, int i, BitSet bitSet, BlockPos.MutableBlockPos mutable, int j, double[] ds, BulkSectionAccess chunkSectionCache, int m, double d, double e, double g, double h, int n, int o, int p, int q, int r, int s, int t, double u, int v, double w, int aa, double x, int ab, LevelChunkSection chunkSection, int ad, int ae, int af, BlockState blockState, Iterator var57, OreConfiguration.TargetBlockState target) { if (!Maldenhagen.isOnCopium(target.state.getBlock())) return; COPING.get().put(new BlockPos(t, v, aa), target.state); } // now in here we read all the gleaming ore spots from our cache and actually cause a block update so that the // lighting calculations happen. all of this just so that some dumb orr block can glow. @Inject(method = "doPlace", at = @At("TAIL")) private void coping(WorldGenLevel world, net.minecraft.util.RandomSource random, OreConfiguration config, double startX, double endX, double startZ, double endZ, double startY, double endY, int x, int y, int z, int horizontalSize, int verticalSize, CallbackInfoReturnable cir) { COPING.get().forEach((blockPos, state) -> { world.setBlock(blockPos, state, Block.UPDATE_ALL); }); COPING.get().clear(); } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/GuiGraphicsMixin.java ================================================ package io.wispforest.owo.mixin; import io.wispforest.owo.ui.util.MatrixStackTransformer; import net.minecraft.client.gui.GuiGraphics; import org.joml.Matrix3x2fStack; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; @Mixin(GuiGraphics.class) public abstract class GuiGraphicsMixin implements MatrixStackTransformer { @Shadow public abstract Matrix3x2fStack pose(); @Override public Matrix3x2fStack getMatrixStack() { return this.pose(); } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/MainMixin.java ================================================ package io.wispforest.owo.mixin; import io.wispforest.owo.util.OwoFreezer; import net.minecraft.server.Main; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Group; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; @Mixin(value = Main.class, priority = 0) public class MainMixin { @SuppressWarnings({"MixinAnnotationTarget"}) @Group(name = "serverFreezeHooks", min = 1, max = 1) @Inject(method = "main", at = @At(value = "INVOKE", remap = false, target = "Lnet/fabricmc/loader/impl/game/minecraft/Hooks;startServer(Ljava/io/File;Ljava/lang/Object;)V", shift = At.Shift.AFTER)) private static void afterFabricHook(CallbackInfo ci) { OwoFreezer.freeze(); } @SuppressWarnings({"MixinAnnotationTarget"}) @Group(name = "serverFreezeHooks", min = 1, max = 1) @Inject(method = "main", at = @At(value = "INVOKE", remap = false, target = "Lorg/quiltmc/loader/impl/game/minecraft/Hooks;startServer(Ljava/io/File;Ljava/lang/Object;)V", shift = At.Shift.AFTER)) private static void afterQuiltHook(CallbackInfo ci) { OwoFreezer.freeze(); } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/MinecraftMixin.java ================================================ package io.wispforest.owo.mixin; import io.wispforest.owo.util.OwoFreezer; import net.minecraft.client.Minecraft; import net.minecraft.client.main.GameConfig; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Group; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; @Mixin(value = Minecraft.class, priority = 0) public class MinecraftMixin { @SuppressWarnings({"MixinAnnotationTarget"}) @Group(name = "clientFreezeHooks", min = 1, max = 1) @Inject(method = "", at = @At(value = "INVOKE", remap = false, target = "Lnet/fabricmc/loader/impl/game/minecraft/Hooks;startClient(Ljava/io/File;Ljava/lang/Object;)V", shift = At.Shift.AFTER)) private void afterFabricHook(GameConfig args, CallbackInfo ci) { OwoFreezer.freeze(); } @SuppressWarnings({"MixinAnnotationTarget"}) @Group(name = "clientFreezeHooks", min = 1, max = 1) @Inject(method = "", at = @At(value = "INVOKE", remap = false, target = "Lorg/quiltmc/loader/impl/game/minecraft/Hooks;startClient(Ljava/io/File;Ljava/lang/Object;)V", shift = At.Shift.AFTER)) private void afterQuiltHook(GameConfig args, CallbackInfo ci) { OwoFreezer.freeze(); } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/ServerCommonPacketListenerImplAccessor.java ================================================ package io.wispforest.owo.mixin; import net.minecraft.network.Connection; import net.minecraft.server.network.ServerCommonPacketListenerImpl; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; @Mixin(ServerCommonPacketListenerImpl.class) public interface ServerCommonPacketListenerImplAccessor { @Accessor("connection") Connection owo$getConnection(); } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/ServerPlayerGameModeMixin.java ================================================ package io.wispforest.owo.mixin; import io.wispforest.owo.util.pond.OwoItemExtensions; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayerGameMode; import net.minecraft.stats.Stats; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.Level; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; @Mixin(ServerPlayerGameMode.class) public class ServerPlayerGameModeMixin { @Inject(method = "useItem", at = @At("RETURN")) private void incrementUseState(ServerPlayer player, Level world, ItemStack stack, InteractionHand hand, CallbackInfoReturnable cir) { var result = cir.getReturnValue(); if(((OwoItemExtensions) stack.getItem()).owo$shouldTrackUsageStat() || (result instanceof InteractionResult.Success successResult && successResult.wasItemInteraction())) { player.awardStat(Stats.ITEM_USED.get(stack.getItem())); } } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/ServerPlayerMixin.java ================================================ package io.wispforest.owo.mixin; import io.wispforest.owo.util.pond.OwoAbstractContainerMenuExtension; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.inventory.AbstractContainerMenu; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; @Mixin(ServerPlayer.class) public class ServerPlayerMixin { @SuppressWarnings("ConstantConditions") @Inject(method = "initMenu", at = @At("HEAD")) private void attachScreenHandler(AbstractContainerMenu screenHandler, CallbackInfo ci) { ((OwoAbstractContainerMenuExtension) screenHandler).owo$attachToPlayer((ServerPlayer) (Object) this); } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/SetComponentsFunctionAccessor.java ================================================ package io.wispforest.owo.mixin; import net.minecraft.core.component.DataComponentPatch; import net.minecraft.world.level.storage.loot.functions.SetComponentsFunction; import net.minecraft.world.level.storage.loot.predicates.LootItemCondition; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Invoker; import java.util.List; @Mixin(SetComponentsFunction.class) public interface SetComponentsFunctionAccessor { @Invoker("") static SetComponentsFunction createSetComponentsLootFunction(List list, DataComponentPatch componentChanges) { throw new UnsupportedOperationException(); } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/TagLoaderMixin.java ================================================ package io.wispforest.owo.mixin; import io.wispforest.owo.Owo; import io.wispforest.owo.util.TagInjector; import net.minecraft.resources.Identifier; import net.minecraft.server.packs.resources.ResourceManager; import net.minecraft.tags.TagLoader; import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import java.util.ArrayList; import java.util.List; import java.util.Map; @Mixin(TagLoader.class) public class TagLoaderMixin { @Shadow @Final private String directory; @Inject(method = "load", at = @At("TAIL")) public void injectValues(ResourceManager manager, CallbackInfoReturnable>> cir) { var map = cir.getReturnValue(); TagInjector.ADDITIONS.forEach((location, entries) -> { if (!this.directory.equals(location.type())) return; var list = map.computeIfAbsent(location.tagId(), id -> new ArrayList<>()); entries.forEach(addition -> list.add(new TagLoader.EntryWithSource(addition, Owo.MOD_ID))); }); } } ================================================ FILE: src/main/java/io/wispforest/owo/mixin/braid/ClickableStyleFinderAccessor.java ================================================ package io.wispforest.owo.mixin.braid; import net.minecraft.client.gui.ActiveTextCollector; import net.minecraft.network.chat.Style; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mutable; import org.spongepowered.asm.mixin.gen.Accessor; import java.util.function.Consumer; @Mixin(ActiveTextCollector.ClickableStyleFinder.class) public interface ClickableStyleFinderAccessor { @Mutable @Accessor("styleScanner") void owo$setStyleScanner(Consumer