From e6ca5fce3b332b863dfecf31f2abc90a8f1d7c24 Mon Sep 17 00:00:00 2001 From: lily Date: Fri, 10 Jul 2026 01:43:19 -0400 Subject: [PATCH] init commit --- .gitignore | 7 + LICENSE | 674 ++++++++++++++ NOTICE.md | 41 + README.md | 43 + build.gradle | 65 ++ docs/README.md | 16 + docs/animation.md | 39 + docs/architecture.md | 106 +++ docs/commands.md | 42 + docs/components.md | 62 ++ docs/events.md | 50 + docs/getting-started.md | 71 ++ docs/layout.md | 54 ++ docs/migration.md | 49 + docs/rendering.md | 58 ++ docs/text.md | 36 + docs/theming.md | 28 + docs/widgets.md | 40 + example/README.md | 22 + example/build.gradle | 47 + .../xyz/meowing/lattice/example/DemoHud.kt | 22 + .../xyz/meowing/lattice/example/DemoScreen.kt | 172 ++++ .../meowing/lattice/example/LatticeExample.kt | 55 ++ example/src/main/resources/fabric.mod.json | 25 + gradle.properties | 21 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 +++++ settings.gradle | 13 + shell.nix | 15 + .../lattice/mixins/MixinGameRenderer.java | 25 + .../lattice/mixins/MixinGlStateManager.java | 16 + .../lattice/mixins/MixinMinecraft.java | 33 + .../kotlin/xyz/meowing/lattice/Lattice.kt | 30 + .../meowing/lattice/animation/Animation.kt | 118 +++ .../lattice/animation/AnimationManager.kt | 29 + .../meowing/lattice/animation/extensions.kt | 70 ++ .../xyz/meowing/lattice/animation/presets.kt | 164 ++++ .../kotlin/xyz/meowing/lattice/client/Chat.kt | 51 ++ .../xyz/meowing/lattice/client/Client.kt | 73 ++ .../xyz/meowing/lattice/client/Clipboard.kt | 11 + .../xyz/meowing/lattice/client/Desktop.kt | 23 + .../xyz/meowing/lattice/client/Loader.kt | 21 + .../xyz/meowing/lattice/client/Player.kt | 23 + .../xyz/meowing/lattice/command/Commodore.kt | 82 ++ .../command/functions/FunctionInvoker.kt | 90 ++ .../lattice/command/functions/Parameter.kt | 9 + .../lattice/command/nodes/Executable.kt | 179 ++++ .../lattice/command/nodes/LiteralNode.kt | 135 +++ .../xyz/meowing/lattice/command/nodes/Node.kt | 22 + .../command/parsers/CommandParsable.kt | 10 + .../lattice/command/parsers/CommandParser.kt | 141 +++ .../command/parsers/ParserArgumentType.kt | 86 ++ .../command/parsers/impl/BooleanParsers.kt | 13 + .../command/parsers/impl/FunctionParser.kt | 82 ++ .../command/parsers/impl/NumberParsers.kt | 40 + .../command/parsers/impl/StringParsers.kt | 25 + .../lattice/command/utils/GreedyString.kt | 22 + .../lattice/command/utils/SyntaxException.kt | 8 + .../meowing/lattice/command/utils/Utils.kt | 30 + .../kotlin/xyz/meowing/lattice/event/Event.kt | 12 + .../xyz/meowing/lattice/event/EventBus.kt | 78 ++ .../xyz/meowing/lattice/event/EventCall.kt | 45 + .../xyz/meowing/lattice/event/Events.kt | 16 + .../xyz/meowing/lattice/input/InputCode.kt | 15 + .../xyz/meowing/lattice/input/Inputs.kt | 29 + .../xyz/meowing/lattice/input/Keyboard.kt | 34 + .../lattice/input/KeyboardModifiers.kt | 42 + .../kotlin/xyz/meowing/lattice/input/Keys.kt | 125 +++ .../kotlin/xyz/meowing/lattice/input/Mouse.kt | 33 + .../xyz/meowing/lattice/input/MouseButtons.kt | 14 + .../xyz/meowing/lattice/render/Color.kt | 119 +++ .../kotlin/xyz/meowing/lattice/render/Font.kt | 42 + .../xyz/meowing/lattice/render/GLState.kt | 16 + .../xyz/meowing/lattice/render/Gradient.kt | 7 + .../xyz/meowing/lattice/render/Image.kt | 43 + .../xyz/meowing/lattice/render/NVGRenderer.kt | 484 ++++++++++ .../xyz/meowing/lattice/render/Renderer.kt | 51 ++ .../xyz/meowing/lattice/render/Resolution.kt | 27 + .../lattice/scheduler/TickScheduler.kt | 85 ++ .../lattice/scheduler/TimeScheduler.kt | 93 ++ .../xyz/meowing/lattice/text/ChainBuilder.kt | 42 + .../xyz/meowing/lattice/text/ClickEvent.kt | 11 + .../xyz/meowing/lattice/text/ColorCodes.kt | 23 + .../meowing/lattice/text/FormattingCodes.kt | 42 + .../xyz/meowing/lattice/text/HoverEvent.kt | 9 + .../xyz/meowing/lattice/text/TextBuilder.kt | 258 ++++++ .../kotlin/xyz/meowing/lattice/text/Texts.kt | 100 ++ src/main/kotlin/xyz/meowing/lattice/ui/Box.kt | 351 +++++++ .../kotlin/xyz/meowing/lattice/ui/Element.kt | 855 ++++++++++++++++++ .../xyz/meowing/lattice/ui/ElementCache.kt | 26 + .../meowing/lattice/ui/ElementListeners.kt | 21 + .../kotlin/xyz/meowing/lattice/ui/UIScreen.kt | 104 +++ .../kotlin/xyz/meowing/lattice/ui/Window.kt | 56 ++ .../meowing/lattice/ui/component/Container.kt | 14 + .../meowing/lattice/ui/component/Rectangle.kt | 161 ++++ .../meowing/lattice/ui/component/SvgImage.kt | 67 ++ .../xyz/meowing/lattice/ui/component/Text.kt | 54 ++ .../meowing/lattice/ui/component/Tooltip.kt | 97 ++ .../kotlin/xyz/meowing/lattice/ui/events.kt | 18 + .../kotlin/xyz/meowing/lattice/ui/layout.kt | 40 + .../xyz/meowing/lattice/ui/theme/Theme.kt | 31 + .../xyz/meowing/lattice/ui/widget/Button.kt | 132 +++ .../xyz/meowing/lattice/ui/widget/CheckBox.kt | 88 ++ .../meowing/lattice/ui/widget/ColorPicker.kt | 392 ++++++++ .../xyz/meowing/lattice/ui/widget/Dropdown.kt | 239 +++++ .../xyz/meowing/lattice/ui/widget/Keybind.kt | 124 +++ .../meowing/lattice/ui/widget/NumberInput.kt | 500 ++++++++++ .../xyz/meowing/lattice/ui/widget/Slider.kt | 278 ++++++ .../xyz/meowing/lattice/ui/widget/Switch.kt | 147 +++ .../meowing/lattice/ui/widget/TextInput.kt | 487 ++++++++++ .../xyz/meowing/lattice/util/NumberUtils.kt | 188 ++++ .../xyz/meowing/lattice/util/StringUtils.kt | 124 +++ .../resources/assets/lattice/checkmark.svg | 3 + .../resources/assets/lattice/dropdown.svg | 8 + src/main/resources/assets/lattice/font.ttf | Bin 0 -> 342484 bytes src/main/resources/assets/lattice/icon.png | Bin 0 -> 50812 bytes src/main/resources/fabric.mod.json | 23 + src/main/resources/mixins.lattice.json | 14 + 119 files changed, 10326 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 NOTICE.md create mode 100644 README.md create mode 100644 build.gradle create mode 100644 docs/README.md create mode 100644 docs/animation.md create mode 100644 docs/architecture.md create mode 100644 docs/commands.md create mode 100644 docs/components.md create mode 100644 docs/events.md create mode 100644 docs/getting-started.md create mode 100644 docs/layout.md create mode 100644 docs/migration.md create mode 100644 docs/rendering.md create mode 100644 docs/text.md create mode 100644 docs/theming.md create mode 100644 docs/widgets.md create mode 100644 example/README.md create mode 100644 example/build.gradle create mode 100644 example/src/main/kotlin/xyz/meowing/lattice/example/DemoHud.kt create mode 100644 example/src/main/kotlin/xyz/meowing/lattice/example/DemoScreen.kt create mode 100644 example/src/main/kotlin/xyz/meowing/lattice/example/LatticeExample.kt create mode 100644 example/src/main/resources/fabric.mod.json create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 settings.gradle create mode 100644 shell.nix create mode 100644 src/main/java/xyz/meowing/lattice/mixins/MixinGameRenderer.java create mode 100644 src/main/java/xyz/meowing/lattice/mixins/MixinGlStateManager.java create mode 100644 src/main/java/xyz/meowing/lattice/mixins/MixinMinecraft.java create mode 100644 src/main/kotlin/xyz/meowing/lattice/Lattice.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/animation/Animation.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/animation/AnimationManager.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/animation/extensions.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/animation/presets.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/client/Chat.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/client/Client.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/client/Clipboard.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/client/Desktop.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/client/Loader.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/client/Player.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/Commodore.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/functions/FunctionInvoker.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/functions/Parameter.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/nodes/Executable.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/nodes/LiteralNode.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/nodes/Node.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/parsers/CommandParsable.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/parsers/CommandParser.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/parsers/ParserArgumentType.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/BooleanParsers.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/FunctionParser.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/NumberParsers.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/StringParsers.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/utils/GreedyString.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/utils/SyntaxException.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/command/utils/Utils.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/event/Event.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/event/EventBus.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/event/EventCall.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/event/Events.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/input/InputCode.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/input/Inputs.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/input/Keyboard.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/input/KeyboardModifiers.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/input/Keys.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/input/Mouse.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/input/MouseButtons.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/render/Color.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/render/Font.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/render/GLState.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/render/Gradient.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/render/Image.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/render/NVGRenderer.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/render/Renderer.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/render/Resolution.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/scheduler/TickScheduler.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/scheduler/TimeScheduler.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/text/ChainBuilder.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/text/ClickEvent.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/text/ColorCodes.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/text/FormattingCodes.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/text/HoverEvent.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/text/TextBuilder.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/text/Texts.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/Box.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/Element.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/ElementCache.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/ElementListeners.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/UIScreen.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/Window.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/component/Container.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/component/Rectangle.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/component/SvgImage.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/component/Text.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/component/Tooltip.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/events.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/layout.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/theme/Theme.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/widget/Button.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/widget/CheckBox.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/widget/ColorPicker.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/widget/Dropdown.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/widget/Keybind.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/widget/NumberInput.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/widget/Slider.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/widget/Switch.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/ui/widget/TextInput.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/util/NumberUtils.kt create mode 100644 src/main/kotlin/xyz/meowing/lattice/util/StringUtils.kt create mode 100644 src/main/resources/assets/lattice/checkmark.svg create mode 100644 src/main/resources/assets/lattice/dropdown.svg create mode 100644 src/main/resources/assets/lattice/font.ttf create mode 100644 src/main/resources/assets/lattice/icon.png create mode 100644 src/main/resources/fabric.mod.json create mode 100644 src/main/resources/mixins.lattice.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d64448e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +.idea/ +*.iml +.direnv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..7b22c44 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,41 @@ +# Third-party notices + +Lattice is licensed under GPL-3.0-or-later (see LICENSE). + +Portions of the NanoVG rendering layer (`xyz.meowing.lattice.render`) are +derived from OdinFabric, Copyright (c) 2023-2025 odtheking, licensed under the +BSD 3-Clause License, via Vexel (GPL-3.0-or-later). + +BSD 3-Clause License + +Copyright (c) 2023-2025, odtheking + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Lattice supersedes and incorporates code from: + +- Knit (https://github.com/StellariumMC/knit), GPL-3.0-or-later, a fork of OmniCore. +- Vexel (https://github.com/meowing-xyz/vexel-1.21), GPL-3.0-or-later. diff --git a/README.md b/README.md new file mode 100644 index 0000000..57f0a81 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Lattice + +Unified UI framework for Minecraft clients on Fabric (Minecraft 26.1.2). +Lattice supersedes **Knit** (client base library) and **Vexel** (NanoVG UI +library), merging both into one dependency with a single event bus, one input +abstraction, and one screen stack. + +## Features + +- NanoVG vector renderer (rects, gradients, text, images, SVG, shadows) +- Constraint-based layout with scrollable containers and a full widget set +- Semantic theming (`Theme`) — retheme every widget in one place +- Time-based animations with easing and presets +- Single event bus with client lifecycle, tick, and render events +- `UIScreen` bridging vanilla screens, plus direct-draw HUD hooks +- Commodore command DSL over Brigadier, fluent `Component` text builder +- Tick and wall-clock schedulers, client/input utilities + +## Documentation + +Full docs live in [`docs/`](docs/README.md) — start with +[getting started](docs/getting-started.md), or the +[Knit/Vexel migration guide](docs/migration.md). Design rationale is in +[docs/architecture.md](docs/architecture.md). + +A runnable demo mod lives in [`example/`](example/README.md): `/lattice` +opens a screen showing every widget; `/lattice hud`, `/lattice chat`, and +`/lattice greet` demo the render, text, and command APIs. + +## Building + +With Nix: `nix-shell --run './gradlew build'` (requires JDK 25 otherwise). +Publish to your local Maven repository with `./gradlew publish` +(artifact `xyz.meowing:lattice-26.1.2-fabric`). + +Runtime NanoVG natives ship for linux, linux-arm64, windows, macos, and +macos-arm64. + +## License + +GPL-3.0-or-later. Portions of the rendering layer derive from OdinFabric +(BSD-3-Clause); see NOTICE.md. +# lattice diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..9137174 --- /dev/null +++ b/build.gradle @@ -0,0 +1,65 @@ +plugins { + id 'net.fabricmc.fabric-loom' version "${loom_version}" + id 'maven-publish' + id 'org.jetbrains.kotlin.jvm' version "${kotlin_version}" +} + +version = "${mod_version}" +group = project.maven_group + +base { + archivesName = "lattice-${project.minecraft_version}-fabric" +} + +repositories { + mavenCentral() +} + +// 26.1.2 is non-obfuscated: fabric-loom without remapping, no mappings dependency. +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + implementation "net.fabricmc:fabric-loader:${project.loader_version}" + implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + implementation "org.jetbrains.kotlin:kotlin-stdlib" + implementation "net.fabricmc:fabric-language-kotlin:${project.fabric_kotlin_version}" + implementation "com.mojang:brigadier:1.2.9" + + implementation "org.lwjgl:lwjgl-nanovg:${project.nanovg_version}" + ['linux', 'linux-arm64', 'windows', 'macos', 'macos-arm64'].each { platform -> + runtimeOnly "org.lwjgl:lwjgl-nanovg:${project.nanovg_version}:natives-${platform}" + } +} + +processResources { + inputs.property "version", project.version + filesMatching("fabric.mod.json") { + expand "version": inputs.properties.version + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.encoding = "UTF-8" + it.options.release = 25 +} + +java { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} + +kotlin { + jvmToolchain(25) +} + +publishing { + publications { + create("mavenJava", MavenPublication) { + artifactId = "lattice-${project.minecraft_version}-fabric" + from components.java + } + } + repositories { + mavenLocal() + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..10036a6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,16 @@ +# Lattice documentation + +- [Getting started](getting-started.md) — dependency setup, first screen. +- [Layout](layout.md) — the constraint system: `Pos`, `Size`, `Alignment`, `Offset`. +- [Components](components.md) — `Element`, `Box`, `Container`, `Rectangle`, `Text`, `SvgImage`, `Tooltip`. +- [Widgets](widgets.md) — the interactive widget set. +- [Theming](theming.md) — semantic color tokens. +- [Animation](animation.md) — animations, easings, presets. +- [Events & scheduling](events.md) — event bus, lifecycle events, schedulers. +- [Rendering](rendering.md) — the `Renderer` API and NanoVG backend. +- [Commands](commands.md) — the Commodore Brigadier DSL. +- [Text](text.md) — the `Component` builder DSL. +- [Migration](migration.md) — moving off Knit and Vexel. +- [Architecture](architecture.md) — design decisions and rationale. + +A complete runnable demo lives in [`example/`](../example/). diff --git a/docs/animation.md b/docs/animation.md new file mode 100644 index 0000000..55d1bb2 --- /dev/null +++ b/docs/animation.md @@ -0,0 +1,39 @@ +# Animation + +Animations are wall-clock based, updated once per frame by +`AnimationManager.update()` (the `Window` calls it). Registering a new +animation with the same element id and type replaces the running one. + +## Element extensions (`xyz.meowing.lattice.animation`) + +Low-level, animate anything: + +```kotlin +element.animateFloat({ element.rotation }, { element.rotation = it }, 90f, 300, EasingType.EASE_OUT) +element.animateColor({ rect.backgroundColor }, { rect.backgroundColor = it }, target, 200) +element.animatePosition(endX, endY, 500, EasingType.EASE_IN_OUT) // animates constraints +element.animateSize(w, h, 300) +``` + +All return the running animation and accept an optional `onComplete`. + +## Presets + +```kotlin +element.fadeIn(300) // alpha via colors; recurses into children +element.fadeOut(300) { done() } // hides when finished +element.moveTo(x, y) // constraint move +element.scaleTo(w, h) +element.colorTo(argb) // background/text/svg tint by component type +element.slideIn(fromX = -width) // slides to its original position +element.bounceScale(1.2f) // press feedback +``` + +`slideIn`/`bounceScale` remember the element's original geometry on first +use and restore it. + +## Easing + +`EasingType.LINEAR`, `EASE_IN`, `EASE_OUT`, `EASE_IN_OUT` (quadratic). + +Use `Theme.animFast/animNormal/animSlow` for consistent durations. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..670e41e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,106 @@ +# Architecture + +Lattice replaces Knit and Vexel with one library. This document records the +major decisions and what changed relative to the two parents. + +## Module layout + +Single Gradle module; boundaries are packages with a strict dependency +direction (lower layers never import higher ones): + +``` +event/ EventBus, EventCall, lifecycle/tick/render events +client/ Minecraft wrappers: Client, Player, Chat, Clipboard, Desktop, Loader +input/ GLFW-backed Keys/Mouse/Keyboard, InputCode abstraction +render/ Renderer interface, NVGRenderer, Font/Image/Color/Gradient, Resolution, GLState +scheduler/ TickScheduler (game ticks), TimeScheduler (wall clock) +animation/ Animation core, manager, element extensions, presets +ui/ Element, Box, Window, UIScreen, layout enums, element events +ui/component Container, Rectangle, Text, SvgImage, Tooltip +ui/widget Button, CheckBox, ColorPicker, Dropdown, Keybind, NumberInput, Slider, Switch, TextInput +ui/theme Theme tokens +text/ Texts + TextBuilder (Component DSL) +command/ Commodore command DSL over Brigadier +util/ Number/String helpers +mixins/ (Java) Minecraft, GameRenderer, GlStateManager +``` + +A multi-module split was considered and rejected: the whole library is ~10k +lines and every layer targets the same Minecraft version; module plumbing +would outweigh the benefit. + +## Key decisions + +**One event bus.** Knit and Vexel each created their own `EventBus` instance, +so subscribers had to know which library fired an event. `Lattice.eventBus` +is the only bus. The unused class-hierarchy dispatch path (`checkHierarchy`) +was removed — subscribers register on concrete event classes. + +**One screen class.** Knit's `KnitScreen` adapted vanilla `Screen` input and +Vexel's `VexelScreen` layered a `Window` plus render-event wiring on top. +`UIScreen` does both jobs in one class half the size. + +**`Box` deduplicates scrolling.** Vexel's `Container` and `Rectangle` each +carried a full copy of the scroll/scrollbar/hover/padding logic (~300 lines +duplicated). `Box` owns padding, scrolling, scrollbar drawing and the +scroll-adjusted input handling; `Container` is `Box` with no drawing and +`Rectangle` is `Box` plus painting. The layout engine reads padding uniformly +through `Box` instead of type-switching on two unrelated classes. + +**Int ARGB everywhere.** The renderer API mixed Int ARGB, `java.awt.Color`, +and Vexel's HSB `Color` class. The `Renderer` interface now takes Int ARGB +only; the HSB `Color` remains in `render/` as a utility (used by ColorPicker +internals and available to clients). + +**Theme tokens.** Widgets previously hardcoded their palette. Widget +constructor defaults now read `Theme` (accent, surfaces, borders, text, +track/scrollbar, animation durations), initialized to the exact Vexel palette +so the rendered result is unchanged. Aether's theme system inspired the shape. + +**Fabric-only, current-version-only.** Knit carried multi-loader preprocessor +blocks (Forge/NeoForge) and multi-version conditionals that this port could +never exercise. All of it is gone; `client.Loader` is a thin Fabric wrapper. + +## Deleted dead code + +- World rendering layer: `RenderContext` was all no-ops and + `MixinWorldRenderer` threw unconditionally in the 26.1.2 port. Gone, along + with `WorldRenderEvent` (14 event types that could never fire). +- `MixinClientCommonNetworkHandler`: empty mixin. +- `TickEvent.Server` and the server scheduler: nothing ever posted or ticked + them. +- Duplicate mixin sources: both parents shipped identical `.java` files in + `src/main/java` *and* `src/main/kotlin`, which broke `sourcesJar` on + Gradle 9. +- `Renderer.createImage(id)` parameter: ignored by the implementation. +- `scrollbarIgnorePadding`/`scrollbarCustomPadding`: duplicated + `scrollbarPadding`. + +## Fixes + +- **Linux natives**: Vexel shipped only `natives-macos-arm64` for NanoVG. + Lattice ships linux, linux-arm64, windows, macos, macos-arm64 (as Aether + does). +- **Framebuffer reflection cached**: `NVGRenderer.beginFrame` walked class + hierarchies reflectively every frame to find `directStateAccess`/`getFbo`; + the lookup now resolves once. +- **Render hook is lazy**: the GameRenderer mixin skips the whole NanoVG + frame when nothing subscribes to `RenderEvent.Gui`. +- **`Mouse.Scaled.y`** used the width-based scale factor (copy-paste bug in + Knit). +- **`TickScheduler` actually runs**: Knit's was never wired to a tick source; + Lattice registers it on `TickEvent.Start` on first use. +- **`TimeScheduler.repeat` honored `initialDelayMillis`** only in one + overload; the rewrite has one code path. +- **Animation preset leak**: `slideIn`/`bounceScale` stored original geometry + in global maps keyed by `hashCode()`, never cleaned; the bookkeeping now + lives on the element and dies with it. +- **`SvgImage` releases its NVG image** on destroy and no longer generates a + random UUID cache key per recolor. + +## Threading model + +The event bus uses `CopyOnWriteArrayList`/`ConcurrentHashMap`; posting is +safe from any thread, but UI elements must only be touched on the render +thread. `TimeScheduler` runs on a background pool (use it + `display()` / +`TickScheduler.post` to hop back). diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..b137173 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,42 @@ +# Commands (Commodore) + +Commodore is a Kotlin DSL over Brigadier. Function parameters become command +arguments via reflection. + +```kotlin +val command = Commodore("mymod", "mm") { // name + aliases + runs { Chat.fakeMessage("base command") } // /mymod + + literal("teleport", "tp") { // /mymod teleport + runs { x: Float, y: Float, z: Float -> + // typed params are parsed & suggested automatically + } + } + + literal("say") { + runs { message: GreedyString -> // consumes the rest of the line + Chat.sendMessage(message.toString()) + } + } + + literal("mode") { + executable { + param("name") { suggests { listOf("fast", "slow") } } + runs { name: String -> setMode(name) } + } + } +} + +// Fabric client commands: +ClientCommandRegistrationCallback.EVENT.register { dispatcher, _ -> + command.register(dispatcher) +} +``` + +Built-in parsers: `String`, `GreedyString`, `Int`, `Long`, `Float`, +`Double`, `Boolean`. Optional parameters: give the lambda parameter a +nullable type (`Int?`) — trailing optionals may be omitted by the user. + +Custom types: annotate a class with `@CommandParsable` (its primary +constructor parameters must themselves be parsable), or supply a +per-parameter parser with `param("p") { parser { input: String -> ... } }`. diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 0000000..7c88103 --- /dev/null +++ b/docs/components.md @@ -0,0 +1,62 @@ +# Components + +## Element + +`Element` is the self-typed base class: children, layout state, visibility, +focus, hover/press state, listeners, and fluent configuration (every setter +returns `T`). Subclasses implement `onRender(mouseX, mouseY)`. + +Input listeners (fluent): + +```kotlin +element + .onClick { event -> true } // return true to consume + .onRelease { event -> true } + .onScroll { event -> true } + .onMouseEnter { } + .onMouseExit { } + .onMouseMove { } + .onCharType { event -> true } // needs focus, or ignoreFocus() + .onValueChange { value -> } // widgets push their new value here +``` + +Tree management: `childOf(parent)`, `addChild(child)`, `destroy()` (recursive, +detaches from parent, clears listeners). Attach roots to a `Window` (screens +expose one as `window`). + +Focus: clicking focuses an element and unfocuses the rest of the tree; +`setRequiresFocus()` makes clicks elsewhere drop focus; `ignoreFocus()` lets +an element receive key events without focus. + +Debug: `enableDebugRendering()` draws hitboxes (cyan = idle, yellow = +hovered, orange = focused), recursively. + +## Box + +`Box` adds padding and optional vertical scrolling (see +[layout](layout.md)). It is the base for anything that contains other +elements. + +## Built-in components (`ui.component`) + +- **`Container`** — invisible `Box`; pure layout/scroll region. +- **`Rectangle`** — `Box` that paints: background (solid or two-color + gradient), border (solid or gradient), per-corner radii + (`borderRadiusVarying`), hover/pressed background swap, `dropShadow()`, + `rotation`/`rotateTo()`. +- **`Text`** — single-line label; `Auto` size measures the string. Optional + shadow. Ignores the mouse. +- **`SvgImage`** — rasterized SVG with tint (`setSvgColor(argb)`) and + rotation; releases its GPU image on `destroy()`. +- **`Tooltip`** — attach with `element.addTooltip("text")`; fades in/out on + hover. Reposition with `setPosition(TooltipPosition.Top/Bottom/Left/Right)`. + +## Window and UIScreen + +`Window` is the root container: it drives rendering (`draw()`), routes input, +and cleans up (`cleanup()` destroys the tree and stops animations). + +`UIScreen` bridges a vanilla `Screen` to a `Window`: it renders through the +`RenderEvent.Gui` NanoVG frame, translates vanilla input events, closes on +ESC when nothing consumes the key, and calls `afterInitialization()` once. +`display()` opens the screen on the next tick from any thread. diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..bdcb5f3 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,50 @@ +# Events & scheduling + +## Event bus + +One global bus: `Lattice.eventBus`. Subscribers register on concrete event +classes; higher `priority` runs first; exceptions in handlers are caught and +logged. + +```kotlin +val call = Lattice.eventBus.register { doSomething() } +call.unregister() // and call.register() to resubscribe +``` + +`post { EventObject() }` (supplier form) skips construction when nothing +listens. Cancellable events stop dispatch when `cancel()` is called and make +`post` return `true`. + +## Built-in events + +| Event | Fired | +| --- | --- | +| `ClientEvent.Start` / `Stop` | client run/stop | +| `TickEvent.Start` / `End` | each client tick | +| `RenderEvent.Gui` | every frame after vanilla GUI rendering, inside a prepared NanoVG frame (cancellable) | + +Custom events: extend `Event` or `CancellableEvent` and `post` them on the +same bus. + +## Schedulers + +**`TickScheduler`** — game-tick based, runs on the client thread: + +```kotlin +TickScheduler.post { } // next tick +TickScheduler.schedule(20) { } // in 20 ticks +val h = TickScheduler.repeat(100) { } // every 100 ticks +h.cancel() +TickScheduler.repeatDynamic({ nextInterval() }) { } +``` + +**`TimeScheduler`** — wall-clock, runs on a background thread pool (shut +down automatically on client stop): + +```kotlin +TimeScheduler.schedule(500) { } // ms +TimeScheduler.repeat(1000, stopCondition = { done }) { } +``` + +TimeScheduler callbacks are **not** on the render thread — hop back with +`TickScheduler.post` before touching UI or game state. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..f9ca8d1 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,71 @@ +# Getting started + +## Dependency + +Publish Lattice to your local Maven repository: + +```sh +nix-shell --run './gradlew publish' +``` + +Then in your mod: + +```groovy +repositories { mavenLocal() } +dependencies { + implementation "xyz.meowing:lattice-26.1.2-fabric:1.0.0" +} +``` + +Declare `"lattice": "*"` in your `fabric.mod.json` `depends` block so the +loader orders things correctly. + +## A first screen + +```kotlin +import xyz.meowing.lattice.ui.* +import xyz.meowing.lattice.ui.component.* +import xyz.meowing.lattice.ui.theme.Theme +import xyz.meowing.lattice.ui.widget.Button + +class HelloScreen : UIScreen("Hello") { + override fun afterInitialization() { + val panel = Rectangle(backgroundColor = Theme.surface, borderRadius = 8f) + .setSizing(300f, Size.Pixels, 160f, Size.Pixels) + .setPositioning(Pos.ScreenCenter, Pos.ScreenCenter) + .childOf(window) + + Text("Hello, Lattice", fontSize = 16f) + .setPositioning(0f, Pos.ParentCenter, 20f, Pos.ParentPixels) + .childOf(panel) + + Button(text = "Close") + .setPositioning(Pos.ParentCenter, Pos.ParentCenter) + .onClick { onClose(); true } + .childOf(panel) + } +} + +HelloScreen().display() // opens on the next tick, thread-safe +``` + +`afterInitialization` runs once; build your element tree there and attach +roots with `childOf(window)`. Input, resize, and cleanup are handled by +`UIScreen`. Override `onRenderGui()` for per-frame drawing after the window +renders, `onCloseGui()` for teardown. + +## Drawing without a screen (HUDs) + +```kotlin +import xyz.meowing.lattice.Lattice.eventBus +import xyz.meowing.lattice.Lattice.renderer +import xyz.meowing.lattice.event.RenderEvent + +eventBus.register { + renderer.text("hud text", 10f, 10f, 12f, 0xFFFFFFFF.toInt()) +} +``` + +The event fires inside a prepared NanoVG frame every GUI frame; draw with +`Lattice.renderer` directly. Coordinates are window pixels +(`render.Resolution.windowWidth/Height`). diff --git a/docs/layout.md b/docs/layout.md new file mode 100644 index 0000000..ab0d4f6 --- /dev/null +++ b/docs/layout.md @@ -0,0 +1,54 @@ +# Layout + +Every element carries an x/y position constraint, a width/height sizing mode, +an optional alignment, and an optional offset. Layout resolves top-down each +frame, with per-element caches invalidated on change or resize. + +## Position — `Pos` + +Set with `setPositioning(xVal, xPos, yVal, yPos)` or `setPositioning(xPos, yPos)`. + +| Mode | Meaning | +| --- | --- | +| `ParentPixels` | `parent origin + padding + constraint` (the default) | +| `ParentPercent` | percentage across the parent's padded content area | +| `ParentCenter` | centered in the parent, constraint as extra offset | +| `ScreenPixels` / `ScreenPercent` / `ScreenCenter` | same, relative to the window | +| `AfterSibling` | after the previous visible sibling (per axis), constraint as gap | +| `MatchSibling` | same coordinate as the previous sibling | + +## Size — `Size` + +Set with `setSizing(w, wType, h, hType)`. + +| Mode | Meaning | +| --- | --- | +| `Pixels` | fixed size | +| `Percent` | percentage of the parent's padded content area (value via the same setter) | +| `Auto` | fits children (components override this: `Text` measures its string, `Box` adds padding) | +| `Fill` | stretch to the parent's far edge minus visible later siblings | + +Clamp `Auto` with `setMaxAutoSize(maxWidth, maxHeight)`. + +## Alignment and offset + +`alignLeft()/alignRight()/alignTop()/alignBottom()` (or +`setAlignment(x, y)`) snap to the parent's padded edges after positioning; +the position constraint then acts as an inset. `setOffset(x, y)` adds a +final pixel or percent offset. + +## Padding and scrolling + +Padding lives on `Box` (so on `Container`, `Rectangle`, and every widget +built from them) as `floatArrayOf(top, right, bottom, left)`. Children +position inside the padded area. + +Set `scrollable = true` on a `Box` to get vertical scrolling with an +auto-hiding, draggable scrollbar (`scrollbarWidth/Color/Radius/Padding`). +Content is scissored to the padded area. + +## Coordinate spaces + +Element `x/y/width/height` are raw window pixels. `element.raw` and +`element.scaled` expose edges/center in raw and GUI-scaled coordinates +(`scaled` divides by the vanilla GUI scale factor). diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..cf90136 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,49 @@ +# Migrating from Knit / Vexel + +Lattice replaces both libraries with one dependency. Remove `knit` and +`vexel` from your build and `fabric.mod.json`, add `lattice`, then apply the +mappings below. Behavior is preserved unless noted in +[architecture.md](architecture.md#deleted-dead-code). + +## API mapping + +| Before | After | +| --- | --- | +| `Knit.EventBus` / `Vexel.eventBus` | `Lattice.eventBus` | +| `VexelScreen` + `KnitScreen` | `ui.UIScreen` | +| `VexelElement` | `ui.Element` | +| `VexelWindow` | `ui.Window` | +| `components.core.*` (Container, Rectangle, Text, SvgImage, Tooltip) | `ui.component.*` | +| `elements.*` (Button, Slider, …) | `ui.widget.*` | +| `components.base.enums.*` (Pos, Size, Alignment, Offset) | `ui.*` | +| `animations.*` (extensions, presets, EasingType) | `animation.*` | +| `api.RenderAPI` / `api.nvg.NVGRenderer` | `render.Renderer` / `render.NVGRenderer` | +| `api.style.{Color, Font, Gradient, Image}` | `render.*` | +| `GuiEvent.Render` | `event.RenderEvent.Gui` | +| `KnitClient` | `client.Client` (`client` property is now `minecraft`) | +| `KnitPlayer` / `KnitChat` / `KnitClipboard` / `KnitDesktop` / `KnitLoader` | `client.Player` / `Chat` / `Clipboard` / `Desktop` / `Loader` | +| `KnitKeys` / `KnitMouse` / `KnitKeyboard` / `KnitInputs` | `input.Keys` / `Mouse` / `Keyboard` / `Inputs` | +| `KnitKey` / `KnitMouseButton` / `KnitInputCode` | `input.Key` / `MouseButton` / `InputCode` | +| `KnitResolution` | `render.Resolution` | +| `KnitText` / `text.asKnit()` | `text.Texts` / `asBuilder()` | +| `api.command.*` (Commodore) | `command.*` (unchanged API) | +| `api.utils.{NumberUtils, StringUtils}` | `util.*` | +| `TickScheduler.Client.x` | `TickScheduler.x` (now actually ticks) | + +## Behavioral changes + +- **One event bus.** Anything registered on Vexel's bus moves to + `Lattice.eventBus`. `EventBus.post` no longer takes `checkHierarchy` — + subscribe to concrete event classes. +- **Renderer takes Int ARGB only**: `dropShadow` and `createImage` no longer + accept `java.awt.Color`; `createImage` lost its unused `id` parameter. +- **Element listener aliases removed**: `mouseClickListeners` → + `listeners.mouseClick` (the fluent `onClick`-style methods are unchanged). +- **Removed as dead code**: `WorldRenderEvent`/`RenderContext` (were no-op + stubs), `TickEvent.Server`, the server tick scheduler, timer API on + `TickScheduler`, `scrollbarIgnorePadding`/`scrollbarCustomPadding` + (use `scrollbarPadding`). +- **Assets**: `assets/vexel/*` (default font, checkmark, dropdown icons) are + now `assets/lattice/*`. +- **Widget palette** now flows through `ui.theme.Theme`; values are + unchanged, but you can retheme globally. diff --git a/docs/rendering.md b/docs/rendering.md new file mode 100644 index 0000000..a78e919 --- /dev/null +++ b/docs/rendering.md @@ -0,0 +1,58 @@ +# Rendering + +`Lattice.renderer` implements `render.Renderer`, backed by NanoVG +(`NVGRenderer`). All colors are Int ARGB (`0xAARRGGBB`). Coordinates are raw +window pixels. + +## Frame lifecycle + +Inside `RenderEvent.Gui` or `UIScreen` a frame is already open — just draw. +Outside those hooks, wrap calls yourself: + +```kotlin +renderer.beginFrame(Resolution.windowWidth.toFloat(), Resolution.windowHeight.toFloat()) +// ... draw ... +renderer.endFrame() +``` + +`beginFrame` binds Minecraft's main framebuffer and saves GL state; +`endFrame` restores what vanilla expects. + +## Drawing + +```kotlin +renderer.rect(x, y, w, h, color, radius) +renderer.rect(x, y, w, h, color, tr, tl, br, bl) // per-corner radii +renderer.gradientRect(x, y, w, h, c1, c2, Gradient.TopToBottom, radius) +renderer.hollowRect(x, y, w, h, thickness, color, radius) // border only +renderer.circle(cx, cy, radius, color) +renderer.line(x1, y1, x2, y2, thickness, color) +renderer.dropShadow(x, y, w, h, blur, spread, shadowColor, radius) + +renderer.text("hi", x, y, size, color) // Lattice.defaultFont +renderer.shadowedText("hi", x, y, size, color) +renderer.wrappedText("long...", x, y, maxWidth, size, color) +renderer.textWidth("hi", size) // measure +renderer.textBounds("long...", maxWidth, size) // [minX, minY, maxX, maxY] + +renderer.svg("/assets/mymod/icon.svg", x, y, w, h) // after createImage +renderer.image(image, x, y, w, h, radius) +``` + +Transforms and clipping nest with `push()`/`pop()` and +`pushScissor(x, y, w, h)`/`popScissor()`; `translate`, `scale`, `rotate` +(radians), and `globalAlpha` apply to subsequent calls. + +## Fonts and images + +`Font(name, resourcePath | inputStream)` — TTFs registered lazily with +NanoVG. The bundled default is `Lattice.defaultFont`. + +`createImage(path, width, height, tint)` loads a PNG/JPG or rasterizes an +SVG (tint replaces `currentColor`); images are reference-counted — pair with +`deleteImage`. Paths resolve as classpath resources or filesystem paths. + +## Custom backends + +`Renderer` is an interface; swap the implementation with +`Lattice.renderer = MyRenderer`. diff --git a/docs/text.md b/docs/text.md new file mode 100644 index 0000000..2c5c5d0 --- /dev/null +++ b/docs/text.md @@ -0,0 +1,36 @@ +# Text + +`xyz.meowing.lattice.text` wraps vanilla `Component` in a fluent builder. + +```kotlin +import xyz.meowing.lattice.text.* + +val message = buildText { + text("Lattice ") { color(0x4c87f9); bold() } + text("docs: ") + text("click") { green(); underlined(); openUrl("https://example.com") } + space() + text("hover") { yellow(); onHover("shown on hover") } +} + +Chat.fakeMessage(message) // client-side chat line +``` + +Entry points: + +- `Texts.literal("...")` / `"...".asText()` — single styled component. +- `Texts.fromFormatted("§ahello")` / `"...".asFormattedText()` — parse legacy + `§` codes. +- `component.asBuilder()` — wrap an existing vanilla `Component`. +- `buildText { }` — chain of parts (as above). + +Builder styling: the 16 named colors (`red()`, `gold()`, …) or +`color(rgb)` / `color("#RRGGBB")`, plus `bold()`, `italic()`, `underlined()`, +`strikethrough()`, `obfuscated()`, `insertion()`. + +Interactivity: `runCommand("/cmd")`, `suggestCommand`, `copyToClipboard`, +`openUrl`, `changePage`, `onHover(text | builder | HoverEvent)`. + +Convert with `toVanilla()` (alias `build()`); `string()` flattens to plain +text. `ColorCodes` and `FormattingCodes` expose the legacy palette and +`§`-code helpers (`strip`, `translateAlternate`). diff --git a/docs/theming.md b/docs/theming.md new file mode 100644 index 0000000..285c5f3 --- /dev/null +++ b/docs/theming.md @@ -0,0 +1,28 @@ +# Theming + +`xyz.meowing.lattice.ui.theme.Theme` holds the semantic tokens every widget +default reads: + +| Token | Role | +| --- | --- | +| `accent` | primary accent (slider fill, switch on-state, checkmark, dropdown icon) | +| `accentSelection` | text selection highlight | +| `background` / `backgroundHover` / `backgroundPressed` | interactive surfaces | +| `backgroundDark` / `backgroundDisabled` | filled/disabled states | +| `surface` / `surfaceBorder` | panels and tooltips | +| `border` | default widget border | +| `text` / `textMuted` | foreground text | +| `track` / `scrollbar` | slider/switch tracks, scrollbars | +| `animFast` / `animNormal` / `animSlow` | shared animation durations (ms) | + +All colors are Int ARGB. Defaults match the original Vexel palette. + +Tokens are read **at construction time** — widget constructor defaults +evaluate `Theme.x` when the widget is created. So: + +```kotlin +Theme.accent = 0xFF10B981.toInt() // before building UI: everything green +``` + +Retheming a live screen means rebuilding it (see the "Cycle accent" row in +the example mod). Explicit constructor arguments always win over tokens. diff --git a/docs/widgets.md b/docs/widgets.md new file mode 100644 index 0000000..b47ceb2 --- /dev/null +++ b/docs/widgets.md @@ -0,0 +1,40 @@ +# Widgets + +All widgets live in `xyz.meowing.lattice.ui.widget`, extend `Element`, take +their colors from [`Theme`](theming.md) by default, and report changes +through `onValueChange { value -> }`. + +| Widget | Value pushed | Notes | +| --- | --- | --- | +| `Button` | — | `onClick`; text/hover/pressed colors, fluent styling | +| `Switch` | `Boolean` | animated thumb; `setEnabled(value, animated, silent)` | +| `CheckBox` | `Boolean` | SVG checkmark; `setChecked(value, animated, silent)` | +| `Slider` | `Float` | `minValue`/`maxValue`/`step`, draggable + click-to-set | +| `Dropdown` | `Int` (index) | floating popup list; `options: List` | +| `TextInput` | `String` | caret, selection, clipboard (ctrl-C/X/V), placeholder | +| `NumberInput` | number as `String` | `allowDecimals`, `allowNegative` | +| `Keybind` | `Int` (key code) | click, then press a key; ESC clears | +| `ColorPicker` | `java.awt.Color` | floating HSB panel with alpha + hex field | + +Typical usage: + +```kotlin +Slider(value = 50f, minValue = 0f, maxValue = 100f, step = 1f) + .onValueChange { v -> config.volume = v as Float } + .childOf(row) + +Dropdown(options = listOf("A", "B", "C")) + .setSizing(150f, Size.Pixels, 26f, Size.Pixels) + .onValueChange { i -> config.mode = i as Int } + .childOf(row) +``` + +`onValueChange` payloads are typed `Any`; cast to the widget's value type +from the table above. + +Widgets set sensible default sizes in their constructors (e.g. `Slider` +200×24, `CheckBox` 20×20); override with `setSizing`. `TextInput` and +`NumberInput` need explicit sizing. + +Composite widgets expose their parts (`Button.background`, +`Button.innerText`, `Slider.thumb`, …) for fine-grained styling. diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..ea696c5 --- /dev/null +++ b/example/README.md @@ -0,0 +1,22 @@ +# Lattice Example + +A small Fabric client mod that exercises the framework end to end. Built as +part of the root Gradle build (`./gradlew build`). + +In game: + +- `/lattice` (alias `/latticedemo`) — opens the demo screen: every built-in + widget in a scrollable panel, tooltips, `Size.Fill` layout, bounce/fade + animations, and a button that retheme-and-rebuilds via `Theme`. +- `/lattice hud` — toggles a HUD overlay drawn directly with the renderer + from `RenderEvent.Gui` (no screen involved). +- `/lattice chat` — chat message built with the `buildText { }` DSL + (colors, hover, click-to-run). +- `/lattice greet ` — Commodore parsing typed lambda + parameters as command arguments, spaced out with `TickScheduler`. + +Source map: + +- `LatticeExample.kt` — entrypoint, command tree, text DSL. +- `DemoScreen.kt` — `UIScreen`, layout constraints, all widgets, theming. +- `DemoHud.kt` — event-bus render hook with direct renderer calls. diff --git a/example/build.gradle b/example/build.gradle new file mode 100644 index 0000000..44fa085 --- /dev/null +++ b/example/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'net.fabricmc.fabric-loom' + id 'org.jetbrains.kotlin.jvm' +} + +version = rootProject.version +group = rootProject.group + +base { + archivesName = "lattice-example-${rootProject.minecraft_version}-fabric" +} + +repositories { + mavenCentral() +} + +dependencies { + minecraft "com.mojang:minecraft:${rootProject.minecraft_version}" + implementation "net.fabricmc:fabric-loader:${rootProject.loader_version}" + implementation "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_api_version}" + implementation "org.jetbrains.kotlin:kotlin-stdlib" + implementation "net.fabricmc:fabric-language-kotlin:${rootProject.fabric_kotlin_version}" + implementation "org.lwjgl:lwjgl-nanovg:${rootProject.nanovg_version}" + + implementation project(':') +} + +processResources { + inputs.property "version", project.version + filesMatching("fabric.mod.json") { + expand "version": inputs.properties.version + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.encoding = "UTF-8" + it.options.release = 25 +} + +java { + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} + +kotlin { + jvmToolchain(25) +} diff --git a/example/src/main/kotlin/xyz/meowing/lattice/example/DemoHud.kt b/example/src/main/kotlin/xyz/meowing/lattice/example/DemoHud.kt new file mode 100644 index 0000000..9ac6968 --- /dev/null +++ b/example/src/main/kotlin/xyz/meowing/lattice/example/DemoHud.kt @@ -0,0 +1,22 @@ +package xyz.meowing.lattice.example + +import xyz.meowing.lattice.Lattice.eventBus +import xyz.meowing.lattice.Lattice.renderer +import xyz.meowing.lattice.client.Client.minecraft +import xyz.meowing.lattice.event.RenderEvent +import xyz.meowing.lattice.ui.theme.Theme + +/** Draws directly with the renderer every frame — no screen needed. */ +object DemoHud { + var enabled = false + + fun init() { + eventBus.register { + if (!enabled || minecraft.screen is DemoScreen) return@register + + renderer.rect(10f, 10f, 4f, 34f, Theme.accent, 2f) + renderer.shadowedText("Lattice", 22f, 12f, 16f, 0xFFFFFFFF.toInt()) + renderer.text("/lattice opens the demo", 22f, 30f, 11f, Theme.textMuted) + } + } +} diff --git a/example/src/main/kotlin/xyz/meowing/lattice/example/DemoScreen.kt b/example/src/main/kotlin/xyz/meowing/lattice/example/DemoScreen.kt new file mode 100644 index 0000000..8d9b331 --- /dev/null +++ b/example/src/main/kotlin/xyz/meowing/lattice/example/DemoScreen.kt @@ -0,0 +1,172 @@ +package xyz.meowing.lattice.example + +import xyz.meowing.lattice.animation.bounceScale +import xyz.meowing.lattice.client.Chat +import xyz.meowing.lattice.ui.Element +import xyz.meowing.lattice.ui.Pos +import xyz.meowing.lattice.ui.Size +import xyz.meowing.lattice.ui.UIScreen +import xyz.meowing.lattice.ui.component.Rectangle +import xyz.meowing.lattice.ui.component.Text +import xyz.meowing.lattice.ui.theme.Theme +import xyz.meowing.lattice.ui.widget.Button +import xyz.meowing.lattice.ui.widget.CheckBox +import xyz.meowing.lattice.ui.widget.ColorPicker +import xyz.meowing.lattice.ui.widget.Dropdown +import xyz.meowing.lattice.ui.widget.Keybind +import xyz.meowing.lattice.ui.widget.NumberInput +import xyz.meowing.lattice.ui.widget.Slider +import xyz.meowing.lattice.ui.widget.Switch +import xyz.meowing.lattice.ui.widget.TextInput + +/** Showcases every built-in widget, the layout system, theming, and animations. */ +class DemoScreen : UIScreen("Lattice Demo") { + override fun afterInitialization() { + val panel = Rectangle( + backgroundColor = 0xF0141414.toInt(), + borderColor = Theme.border, + borderRadius = 10f, + borderThickness = 1f, + padding = floatArrayOf(14f, 14f, 14f, 14f), + widthType = Size.Pixels, + heightType = Size.Pixels + ) + .setSizing(440f, Size.Pixels, 440f, Size.Pixels) + .setPositioning(Pos.ScreenCenter, Pos.ScreenCenter) + .dropShadow(shadowBlur = 40f) + .childOf(window) + + Text("Lattice Demo", fontSize = 18f, shadowEnabled = true) + .setPositioning(0f, Pos.ParentCenter, 0f, Pos.ParentPixels) + .childOf(panel) + + // Size.Fill stretches to the remaining panel height below the title. + val content = Rectangle( + backgroundColor = 0x00000000, + padding = floatArrayOf(4f, 10f, 4f, 4f), + widthType = Size.Percent, + heightType = Size.Fill, + scrollable = true + ) + .setSizing(100f, Size.Percent, 0f, Size.Fill) + .setPositioning(0f, Pos.ParentPixels, 10f, Pos.AfterSibling) + .childOf(panel) + + row(content, "Button") { + val button = Button(text = "Click me") + .addTooltip("Tooltips fade in on hover") + .alignRight() + .childOf(it) + + button.onClick { + button.bounceScale() + Chat.fakeMessage("Button clicked!") + true + } + } + + row(content, "Switch (toggles the HUD)") { + Switch() + .onValueChange { enabled -> DemoHud.enabled = enabled as Boolean } + .setPositioning(0f, Pos.ParentPixels, 0f, Pos.ParentCenter) + .alignRight() + .childOf(it) + .setEnabled(DemoHud.enabled, animated = false, silent = true) + } + + row(content, "CheckBox") { + CheckBox() + .setPositioning(0f, Pos.ParentPixels, 0f, Pos.ParentCenter) + .alignRight() + .childOf(it) + } + + row(content, "Slider") { r -> + val label = Text("50", textColor = Theme.textMuted, fontSize = 12f) + .setPositioning(0f, Pos.ParentPixels, 0f, Pos.ParentCenter) + .childOf(r) + + Slider(value = 50f, minValue = 0f, maxValue = 100f, step = 1f) + .onValueChange { value -> label.text = "${(value as Float).toInt()}" } + .setPositioning(0f, Pos.ParentPixels, 0f, Pos.ParentCenter) + .alignRight() + .childOf(r) + + label.setPositioning(-215f, Pos.ParentPixels, 0f, Pos.ParentCenter).alignRight() + } + + row(content, "Dropdown") { + Dropdown(options = listOf("Linear", "Ease in", "Ease out", "Ease in/out")) + .setSizing(150f, Size.Pixels, 26f, Size.Pixels) + .setPositioning(0f, Pos.ParentPixels, 0f, Pos.ParentCenter) + .alignRight() + .childOf(it) + } + + row(content, "TextInput") { + TextInput(placeholder = "Type here…") + .setSizing(150f, Size.Pixels, 26f, Size.Pixels) + .setPositioning(0f, Pos.ParentPixels, 0f, Pos.ParentCenter) + .alignRight() + .childOf(it) + } + + row(content, "NumberInput") { + NumberInput(initialValue = 42, allowNegative = true) + .setSizing(80f, Size.Pixels, 26f, Size.Pixels) + .setPositioning(0f, Pos.ParentPixels, 0f, Pos.ParentCenter) + .alignRight() + .childOf(it) + } + + row(content, "Keybind") { + Keybind() + .setPositioning(0f, Pos.ParentPixels, 0f, Pos.ParentCenter) + .alignRight() + .childOf(it) + } + + row(content, "ColorPicker") { + ColorPicker(initialColor = java.awt.Color(Theme.accent, true)) + .setPositioning(0f, Pos.ParentPixels, 0f, Pos.ParentCenter) + .alignRight() + .childOf(it) + } + + row(content, "Theme") { + Button(text = "Cycle accent") + .onClick { + Theme.accent = when (Theme.accent) { + 0xFF4c87f9.toInt() -> 0xFF10B981.toInt() + 0xFF10B981.toInt() -> 0xFFD32F2F.toInt() + else -> 0xFF4c87f9.toInt() + } + // Theme tokens apply at construction, so rebuild the screen. + onClose() + DemoScreen().display() + true + } + .alignRight() + .childOf(it) + } + } + + private fun row(parent: Element<*>, label: String, build: (Rectangle) -> Unit) { + val row = Rectangle( + backgroundColor = Theme.surface, + borderRadius = 6f, + padding = floatArrayOf(8f, 10f, 8f, 10f), + widthType = Size.Percent, + heightType = Size.Pixels + ) + .setSizing(96f, Size.Percent, 42f, Size.Pixels) + .setPositioning(0f, Pos.ParentPixels, 6f, Pos.AfterSibling) + .childOf(parent) + + Text(label, fontSize = 13f) + .setPositioning(0f, Pos.ParentPixels, 0f, Pos.ParentCenter) + .childOf(row) + + build(row) + } +} diff --git a/example/src/main/kotlin/xyz/meowing/lattice/example/LatticeExample.kt b/example/src/main/kotlin/xyz/meowing/lattice/example/LatticeExample.kt new file mode 100644 index 0000000..39113af --- /dev/null +++ b/example/src/main/kotlin/xyz/meowing/lattice/example/LatticeExample.kt @@ -0,0 +1,55 @@ +package xyz.meowing.lattice.example + +import net.fabricmc.api.ClientModInitializer +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback +import xyz.meowing.lattice.client.Chat +import xyz.meowing.lattice.command.Commodore +import xyz.meowing.lattice.scheduler.TickScheduler +import xyz.meowing.lattice.text.buildText + +object LatticeExample : ClientModInitializer { + private val command = Commodore("lattice", "latticedemo") { + runs { DemoScreen().display() } + + literal("hud") { + runs { + DemoHud.enabled = !DemoHud.enabled + Chat.fakeMessage("Lattice HUD ${if (DemoHud.enabled) "enabled" else "disabled"}") + } + } + + literal("chat") { + runs { + Chat.fakeMessage(buildText { + text("Lattice ") { color(0x4c87f9); bold() } + text("text DSL: ") + text("hover me") { + yellow() + underlined() + onHover("Built with buildText { }") + } + text(" or ") + text("click me") { green(); runCommand("/lattice") } + }) + } + } + + // Typed lambda parameters become command arguments, parsed by Commodore. + literal("greet") { + runs { name: String, times: Int -> + repeat(times.coerceIn(1, 5)) { i -> + TickScheduler.schedule((i * 10).toLong()) { + Chat.fakeMessage("Hello, $name!") + } + } + } + } + } + + override fun onInitializeClient() { + DemoHud.init() + ClientCommandRegistrationCallback.EVENT.register { dispatcher, _ -> + command.register(dispatcher) + } + } +} diff --git a/example/src/main/resources/fabric.mod.json b/example/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..ee12789 --- /dev/null +++ b/example/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "lattice-example", + "version": "${version}", + "name": "Lattice Example", + "description": "Example mod showing off the Lattice UI framework: screens, widgets, theming, animations, HUD rendering, and the command DSL.", + "authors": ["Aurielyn"], + "license": "GPL-3.0-or-later", + "environment": "client", + "entrypoints": { + "client": [ + { + "adapter": "kotlin", + "value": "xyz.meowing.lattice.example.LatticeExample" + } + ] + }, + "depends": { + "fabricloader": ">=0.15.11", + "minecraft": "~26.1.2", + "fabric-api": "*", + "fabric-language-kotlin": "*", + "lattice": "*" + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..83c034e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +org.gradle.jvmargs=-Xmx4G +org.gradle.parallel=true +org.gradle.configuration-cache=false +loom.ignoreDependencyLoomVersionValidation=true + +# Fabric / Minecraft (non-obfuscated) +minecraft_version=26.1.2 +loader_version=0.19.3 +loom_version=1.16-SNAPSHOT + +# Mod Properties +mod_id=lattice +mod_name=Lattice +mod_version=1.0.0 +maven_group=xyz.meowing + +# Dependencies +fabric_api_version=0.150.0+26.1.2 +fabric_kotlin_version=1.13.12+kotlin.2.4.0 +kotlin_version=2.4.0 +nanovg_version=3.4.1 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu z%-lD<@61@6?qUPjUg|mF7!P7`hx+st`i!^L7HVHtzwnM z)LuOANIzT#9tU4)C^WIXhZWqrO;jr_O5aErkklzt)R-JmAh8xHMJ>x>OvTiuRi}FY z-o@0kFwwl7p|ro=*2q*cFRX5GCq-v!LPD)Sq+Uz~UkOwx-?X&!Q^4H)$|;=n9{idC z0mJl`tCTs3+e_EFVzQ}s`f_4fijsucWy5y zarHoT>Q06Z4yI1RPNpW`@4hSzZT|J`MU3i(GqNhm*9O@MndJ{31uA^i zXo&^c`EZ}5W)(|YMl##@MuSK#wyZ3dwJEz*n@C(Ry$|d`^D=thayXFqxt*WW&sWdI zdm1wv#VCKa<7d2Qc#qzvUvivhK5wq*djL7Wqjvf}-c~}d#G)eG`(u<`NGei`BFe4Q ztTSs?Gc8Ff%_5T4ce&J0v*FT`y_9r!Po=sPtHs5~BlV6VEUNzxU+)+sX}ffdPTRI^ z+qP}ns9yQgjY^t0ddMx1Yd`|OB{sHnUC-B;qum1|`tR#P_@llx>d z=qpNN&?nZib(t90A9F*U%1GbB+O;dq!cNgmmdCrK=(zS1zg*9(7VMfv)QMkt_F=wz zHX2p4X-R*=tJI4A)3SrL`H^peBNHh&XC#sVR3D zt17qeF>BaCZNlQO7n@@BuWs&l(FtRjaVn~wW^x-GsjpFH!ETyl7Od{Wf;4=bzL5nj zW9c^ZodMnN{3Jkz2j2;qhCm1ede*6891vR9?(Dy)N|iENw}HKLIOrjB0x)pEs-aS{ zZR$tEyZxbP(;(l43^KjRtSuirNmw~Bg&6p;)vqM*>S#L>0+Pw5CU%4@&)8OX2ykYQ z^f^hk-5%!QzuzYniL*1Gs#S5Kp_*ld1EAmkInP+^w?#(?rbC2Bm&0c5Ko@6`_ zi!Nvd391nu^@AmpZ$_0fPR2~kQGJS7lSGwA7U>s@+!d_`(P5y;MT#U~_ONSo9d+bf zVj6MgWN=|%#Qn;vl*TNLE$Mw|*89{yJ=WN>j{?T*vqa$U$2_dg46R)8wl&CNS&iK{ z>HDBC9e3b3roJd}gK!T>takKP);KLj_9T;%knG_fN^S$4hb`E|)qy__^=mm&Z{~CF zhc*PxdrJ@xRkQ-8lbh3Ys@2ZaR)Q3z**-VSgeMHE>c5AH1bpSUor&dgTiMd5Wn|(# z8Rwb{#uWZG(Jo0co98|mg5zF}M*d>gAg|Zdex@}Ps&`51({MmNyHF;GD4EBT`oP|X zd=Tq9JYz*IP%@2oujruVrK#jAT97|%ww60Ov2He^5zA4)VihJ$-bxoaqE7zU$rmK) z#O!xp&k$!TOEiC8+p6`Q)uNg4u8*chnx*aw=#oP~05DS&8gnL>^zpBkqqiSQA{Ita z%-)qosk1^`p&aB@rZ#)&3_|u{QqZO z{f{A3)XMprL}2{=pM$*`z*fY;{=4e=u7&=s+zI)ANd+V!L%#^2hpy@#N-WbB%U2Zl zgD_E0AVVWdMiFi_u2qqxeAsRzD%>l|g-|#$ayD3wHoT{EUS2Qe zEq=ryLi%iMZ`b}tSYzHInTJ{mY{OXy0)T&Rly3ippqpTk%A{T+e?K}j zURM^%!ZIWxW$32?Z&q9)Rao;#KQuLv+^ft>o|6c@QD=_}ql%5Th=cR{P)_51Qxjh# zRJW<|qmpRn3(K1lMwU-ayxjsgKS`Q7J5m0kw|LQb=CbyahnoQTWY z?g8-#_J+=*r`Jc|A0(MOvTc0kT-tBLIIFCd6Y5iCr>cqubJu0`Ox+FkDWs^L{;0mc zxk-nf?rxh(N<1B;<;9PSrR4D<*5!DvA()O7{vl9sps3x_-Y_w>qC3OI!_Wyza8K|E zAvJvWYyu)(z*TK7e+Q#dFWd_7%;fn4Ex*lEY2$X%SP9K9d6yWC2M!3>3>tu}g4R*V zRMC!~oYyF#Izu$lGjfQ?q}KD$rpDMRjF?f>6kuBlE`z4Yxy(Y(Y+Dr#PKA}UsSWD? zm|ER_O==Y22{m%cO1jhu`8bQ05@MlII86NP>-_`<|Q4g1f7Jh*4%=yY_ zafIlUJ2zA?dT8&WTGLE&gvPl|<0zKa=DLzzPOU7i#nate!Z3u|9R6E(6FZ|(EZ%+b zsB!MEkGz1K*oXGdp^tGOWyF0SI{tq>^nbgX|L>uTert_v9gIv#Ma|5OTy0(c_qQUz z!2+;T+eysD^IV+aC=aX$FPzbq+lZ7Gsa%r9l;b5{L-%qurFp89kpztdmZa8Uo!Btl zu7_NZMXQ=6T6+OFOCou6Xc_6tf!t+bSBNk)mLTlQ5ftr247OV6Mc0v+;x&BNW0wvJ zjRR9TWG^(<$&{@;eSs-b796_N#nMB4$rfzYM1jb>Gu$tEpL8-n>zGXVye2xB-qpV z&IZjhW#ka?h8F{QJqaK&xT~T;$AcKQD$V>$$-$x~1&qfWks(mJ8#7v7m4zpWw(NS( z5j0d&Bs4g)>{7yzl-7Fw`07Sj6{vw5nwVyVt8`;Rg5bzISP26=y}0htlPKRa8CaG# z=gw7__ltw`BWvICf>5(LFDFzC7u-Ij7*OKwd7685%wb6a=QD1CjpQs$^2~cx`@xS` zNMz6?Q4OgIR8LYa&m`q*QJ%!CbD#=ha?38!M&7yLA1Wn}M{$nV3-G0@@bD#WjCYI) zKFZ`bf$tFF#}GYZ7MK2U4AKI-GY*y(&DCt~4F1!3!{>cK+7XAfKw<)Jv$b1vHkpC;gl=VNy?f-RI(r=&j z@Dy@&vHYi$GBI*-`1j-=qpI@{qwt%et&>`VuG+PYzF>DUM1!h|8sz~*0>sA7|IH_y zskL`MJ4Yw|Ru~}gzgCOOEDSyuM+ivsjt@13h-SLD|INP2zRO|RKEDz$_zlt)ZWYQg zKHk`_;gygz9b$7*)WKC(<}zQUY8M94a#Tu_OEyX$Lej=Cs`b}zjTYvv-Jt6E^_bV) zCt>gvm2{y2tK8Uy*;ruhTa_?lSIlV;r8b zX?jME!z32pO8`g9ga%`RQ*v=F0O`bnPZebx@b#ZfQWvqZPAb@zl>ORo<_o7Dp&F?6 zP(tBH@~c-Zfx?Ulkb{F`C1S8y3F;;)^MwWBiBPQ1D=;yC{M-i~ILSfh3K!Ai{5c?J zdLm0OmDsWuV>%}MT*Qf<$UT+M=7pMVdJGRi-rdW>7iM&2UO%v@>_!inA`JD)lrKC& z75Y)Lg~PVq0Ge}-g$8cy0w@sHjUuwMm1|~u6X!*fGG>%bAbv5cEU3nR6&6o03J2ff z)*M)kj|gyvZ6Md8Y!m#IuWuP0<9daW2gPDp*=aQA2qm)VLJ($UUQ>-4&3LX|)=-g5 zDTzngTm?JwMM46$Z22o7jlr3Vp3K15k^@=c7JJx9WQg*XbLRkdC zYapmoZr8J8X5n5}a2xjY35bC^@Ez{}9JA&aex@>JiMr#&GtJGn$)Tt=HVKx@B+w50tPaNkh{N0!^9>r<#h(fr3kP@a(N1!O)$rdf&Dd!hhJNtXD zIbx!f3YSHV50oNza38Kzd9Vze|NZlyBd{fKzZOSB7NqO*qDh)*>XW~VnmJ^ zji(MF3D>tHCk-^y37b-c7t1Zrt)VBlefNnY+NH0u=9IPbDZ1z8XbK{5_W?~aGs@o& zTbi2gdn~PB;M%^{Q*d9xWhw;xy?E}nCbBs0rn@{51pJ@6e=LQg2dvlq_FM0;Iel9= zz?V~4Y+a&wJIgvt5@%1FDtB9(A<-f!NpP^nl51v_hp$v8$w{ z=Rh2*Y?stNGlx7wbOLqrFbxg3lqpaaN{@9c)nNxe#D=Xouh@g7Wd}stZ!B8jrc4HPmOW%Xt^a!LcN8M4^efD8wWziBkha6&KggDq^9beRoiLH_z9 zGUiqkIvsoqX!3F)6qr+_HfB$D%@)T=XV3YUews|Tg-Hwn^wh3)q=N>FC*4nHJ+L$K zpR;I6Gt%?U%!6mxrP$mlEEiT&BVf$x(VJRuEIXdqtS+qfX^-@UKefF=?Q z(jc2Y2oyEyr3_bP|F%)C?~RzdfbNXgw%b_zaAs2QbA_QL+IyP^@l+{#{17?2dn80k zljl~W{3$~wO4E?SSij&`vnbpKCUzN%8GY^!-wNR8=XKiz>yng^Xj99@bTW|TDw5XGfDje2@E z*~-mJF8z}cI1eTpHlg*7?K(U5q3H%{y84gCiDbksT+HB=ca!YVTu zgPDuJzB@76rs{is=F^_95WD#mg}F*~wRr~vgN4^*Gy=hUUD_~f0QPh!&J7XP9zv&H zY}Zm4O#rej< zQmBNK_0>1jXd)Y3cJi(*1U|!mL(;nU#j_WV33)oK-!s$XS(mQqWqQ7&ZZ54iT5+r| zi|MH>VJs`1ZQr<{eTMqC#Y~41>Ga4BuQynUV!QuZeaFa6aP(B)SxC~V-r0K5 z5BJ<3nuAkX12%0k5qI=#D*PNg{NNjn>VUnvH!{DfD}FX=e%E5lw-IZgDqD$1an(zv z95TXS9wGg?Bl{w91nOC8HvvD1&ENr~L>4u{^bNaBD>ZHXIw1Ko!;wjz1%zZMbWE8# z7f5xlDTQWK%rH+)0KY&O>*EHs@Ha5t9ltEE{qv`K0tO?W=jgzciZhHZ4As;i<7{@M(!#&K$4UGQ?~d6rbu|rCYd`D!Bgha2*v# z?6){N62Wq7br9`S=y(rk$xKExQsyv0H~Z<~f!Z7~Wt6SlJBO4_KeNahC?2rxh%Z14 z{6vx|=@Pd?8vwjCEbf?V*zgc>36eg4u4w8WMluPe+qB=i60{qnN+XKmud{LfKvd^Rf{8@jDa#RaXtvGeC92KvnMDV3m2 z4Xt7QB96VazV=Z?RrMXb$#mb85@y7X+OE;c6PL94T|ssUhD|n8IM`GhqU%%}=6E(! z@O+LF*%Uy084M_#De*pBSU<)G3|%go1vt<|<(ZKk{3&*44f?ftxS-a(+@u_92o7ot zYq%I+Ztyt1x5RPt_1it>&+05XbK1B{-T~aA+FN6BiF@>|QCJ`#y*u z@e*p+J|+Jzl4qtDnLJPde6Gl8Qfu5eP#Lr_}cyBzGaR912ca0h5s# zbgocm38uvIstvyAPMEgVj^>{XqR&db7$(XJRTRiR@!lH>>CTe{+zRJEgcn{?M627> zsw6}Y)J+s3)u#g*Mo19)oWp785&T@;fee1**^o5#bgS4epuPWP>~Y2v-~{)-me7SK zd!AQUXsd{A=;C;8>vRTE5Dol&>XJ&AYMijyXV3|_46Fr#lz`uF9dT^PhX2e>lDN?r z>wx*9-Pr~siloVs7@`dn*kGmY0xP)2odnz6S437Hi&}MSb1iiwEiwfy=f;yg# zDZojIe7{n|lnmh@$rU>6-%oUGrG#^0y%z_Niq4LG38Yq&Dq<~B-3qLMHLbL;&A)i3w zq0}L%{J2P1a z2OC$%f4j5C`~!#oBU=IP{19v?%zqxLR77sUDKZWk1TEdClEz1yHB10F7>l{;9l0L|=ADc&?i zK#F90YE|)m(u4LGC%M^0?53NrH3M`xl2{P!5+fC(H)Yt|t=X~m+os4b6}Wj|nDvL8 z8n=Bhi`Mq$&2sm(8n4F2)~_ylMf-R2rn!V)Bfzhv7v2SF{79o}>ITpgUpe=zcRpds zp^3fse>q!&ohi{7gYJM|qD$1?s^vyP1XP=26O)1AFu)?|OCYHCJm*LP4*zJ8Raq1u z)9(U+oYRkni_C&!f4&%ORK?w$g6<;rT((@LunPCC_#2P zxJ&Q13mCI_U+H?IvV89Y)i_#NnNt!>xavHwF$|O zXuHG5oCo;G6F&W`KV4I0A-(zyjQ;ws!05mAr~eli{U77e_#bTiA4Hr~$mBnaBxQ^3 zlOJG&4aI|YIUi&Z#TBHjLS(GmY^z5R28NolKW$l^Ym#0I3|0lI-ggSR?CgqX8f;MBaPl&YzSG} z4(9gprQ%M^N3g+r;f^a0BNw0BQ9}e{Op$ssU!0cTdbP z1%BNUh*RkAe#+jya`#(*p*uQ|spESDMarSs8h3e`E#gtvYi=8d#ADvy9g>R@*^D~F z2t#h@kzA0JK)w;AMPg^lWi2XAU}jpiDF!akXK|rSi6}wmaK)KT*81I6M}f%l3XCMR z-&LC;?s53?Q?B;UuDeB{5^S+oOfSGE^CnkvgEc9^13~<4(iGap$VY8}3$6;-sL}t1 z4d0l&nxB@pZuYHH` z{ONm|SH}iy2^)Zg%Ou?*Q?I+u&ZmckE<;nVG0STB`M9GzLE5UAMeRQQJzJxXBBwA&_T6LHe4yGpP7i~lax~#Ub5BlJE zg>YF0Yn0Wcsv`EJIW^d7i>M?PO5_+)OxDS;9?zPfCH;#_rpR4-*9!|aogttErPHlR zUf2d~4Xa7AEaZSe)Mn9=Nd;=@JUDKUaJU-Rx~HXERZPZJTiBwHdXup>tP-Z$yw6H? z{D8e~w09((x@w&~)75oSpJ7o&u#DUKXAP}9afG;3qf=+XWeC!=Ip8PJvw~{@B3H)k zZr>U-w?x^Y3%$zAfoF_*V2Mlr?I=_C57F2k-rurm=_3`CHmW^yY`ye5aJG#E#oU&y z^R4vJ!2z7aF;V5BD1dbHn6(R25;-0cu1Cet+$J~Uw}=H_%79gf!-W2#1g=S`%zSN- zwVT1}5o>Hi-DpkU76(;YW&Y92O;@cEU^coXt>XfiRWI$}_*t&RQ_K?A8!$gpQKZe> z6VsBW458Q0>X1E#m*K&U%))^SmEntSPBAZb7VW{C@EA7Plo3r-`7EMb;;WeQn0bRTSxW7MTSYNoW=(qCsKsMVCbY?$#Z{|k#%NHM zA*6=sc(VKVE`UVqumIooHMGYRSh$SD{ErAy8%i_*n<=4ODdFErVql6WIx-X4fyaoz&jU+aYlbi=W`&5GJ~zS*@5IRv9cn<|il?|!d8>N94!OI0)aLF!Q0nlhtv zV$SFv61Ek9=p#mMT*~J{BfjK)?1ss~7B8LE@RPM6>=Q&sCt<9ZWOlek61x3T53zDy z_Ki;P_XP~dr)aCdrp;^Xx&4zy791bkXYcFE&ul#uoMVnctVZzl-Azp*+fw1N@S40^ zWBY6U4w+j|T8!q!)5)=7rk~;72u(J{qztk$Rb^WOCbU62Z^s|pn=)TqT4{gYcX?y1 z?|~>Cvir?R7Ga#&UI_thW{axhKZmGsOKK2*Z5|H*2nrEoD6q0cA?LAuQGqE#iVxT) zkKFW#vDut&E=}&^_xyn@nKhBk4S$!WNK~%$ z0c&2{SDdyuxlzV0ph!Peph$e2NH|n4;u};Z5-fDRQCkV`hd9~Qhw#l z5yeB&7zlX?y>QU?3e8P%Gzk1X934Q9LPIvcZi~Q>$tU#A^%^O!FsqRvO1M){#{wo# zBk9bs(!8G_zMYJ-^KkkOmXlld6&M}R+at4#TYfha^(?3_OqFsw=T6Gudap+sqFPF0 z*6D8MYBS6E;rkj8{7GbNPpnUPv9*l#u0T^M#yAbod>pw)srdC}u6;9n!}f|*m@!$~ z1aL-1&ei+i_Mkf0!?>5p@ss}z+(4GaIZ0Tu^mr{+M1{}bS8k3r~HKz!?C`p>TW)1H#Yg*vr z7Y{a{9Z}e1N<7QR%urOa_cLshyVKNaKNU@l7j~j>PeI7MIZZ|r0*YSjU6P_&ia|jH zDoChFYF-JCkoNDw*&*{QG3x+J%2L5_4`n1Tg9hatvloFoYL01#hFFj~!}MRSdgSSl z=m-yq{#uwWUIpuCs@%BEy5ob11|s~&TVX8~-XV)oMfeNdXD?Z9E10-tP#Krhiv$@dBpKj5J%t@Y2xI!*8s~Z z29}0zR`_9s&89Brq4Tru3F{G&uQu{ujBFqN`NY$Hb>qnXc(a!g%hbv!R@n6sNonM) zg649UVVIiIE)_J6eMZ?R^6HGdRMn-UD36*c8_Z2r&xc^Cs2p^v6x-_j{J)k91n!wt9I-~_PA$GNiLi=u7ixtk`YUQ4uIF+`SI~U z1J;MiD+DHLSA)nBsc8CJW1Z4F5uFXI0GzFHhs4egAoxF&>1&8*Nl_OA^!wW4GJCRO zwS%7>sOyj*5EN! zUpux=mBP|Q*_J!@%f6V&EZf{?`H}D&1^^@HO#Gta8P{W+FkdO5OW;fnD1|4&tlh3} z@YGnJ3d(Y0t#ep+bksNs#e?8*u-V=@#Dvz21#EB=jam5x3MtG&IuRHU$pr(K+Y-AX zn7FqKEk!?hw{HWBS~^ioY8Dbe(VtwFva+1h5$-}M9!~UYHGIL>zwFFN1`lcLe zwaMY%;tKHw`EL=C_^}jKY3YhWzg-&!anlG&@4E|`Vl}0q!EvCtT1I@}=Ug2;8OzB) zmllrTJ}RHtO2N@|-7)oaf*v0`{>2c|j?-t&WbDWOUDsBIUR24HnS0{I;>(%9+r)y* zg2K$nGPerx{E6HXH@h?eRQC~Y44A2^$`xKRwnOj_7pT5_!?K%>JT+F+ z6(@ZUF%FqvCBG2v8WL04A5>D=m|;&N?Hzcdj=|%{4JK2j_;hMKOfU}I+5PVH87xo# zc>v2%1gFE>V^6x3$7#ymLM62}*)(ex+`ImB7=eUwa2O&zcN_th9iPz)#fXNbq_VnK zg>+Fagfb53(>-Y^v23^|gST@kT%3pG*YUyrd-zn|F0Cr_;Qh)MO;mTE$%x&%B^Oc= zO-<|3$Nplt0sdxXQO`|RVIbVxm_^24G_6XuTxk&{Yyl+?OeXa-!t}8&fuTGLZpS|{?$S9qu^8TDrgtdOu`4*Sqx20lCJ(;z6u7&0EbrB@495}e zvjfw8yG7#Eo7QX+`k$3*tbTCwGm9LGOvTam&Kk&4&(T!!b0d-h(+s160p@Pn+_M|) zwasiA7r)El>t5DJfiBLb@2=gQDN0N*FfYuh&F<6BNcc)=oqju*S(+ucbzy4pyN1%s zgS@}T`xoCKJdeoM>hW-Zt9xSNRYI8RfX^{UPSJ}y8$_k~4-2G8KZDJQl``0lf>>)j z^q^y@`VIX~W%W-QAF*8U#?c|>tGQ{a09;)CL{-NfEv_2<$o(R8`V7xFRTl$)d~KX! zxG^v#xd(Z9R*`P* z8NwYSrl;qaYDzF0iB%{|A(v0($}TDr##;!y6paThkw{fnuKExakKusCdM>46hESJo z6Z4inrJpt`IzSB{l1R?`XS)o3@M9OZsiP&{y4g5QBH!U*Fvdd|9inn^a}Nz>2&)`? zh!|tcpGBMA4e|H2Y3)~7iyNUBsc|aN0$HM9Uc2MDIL(61;J!I)NmIwv>&&25`&+6M zq1}!I%Azc>=L(6nYlCWwU59Ea*szPa>sE|5)2pJsAnOmce3ZqxF(4^b@uZ6D1K#-5 zD6|eu@+l+j4}V7yxluQ@oX?sla^=5dw}yP&j6E+69hswg1L1c=)OyvZ7^wHQJl;ml z_2lX#$i;=Fs}vkh=ukc4y2Vj2Lu7vAHQ*E%@5?3`^a{BzDVU zF)O4|`;uuAO@)kfdwp~fqS#rR$4Oj@c*zBS`-fL6qu8<7qzl8rl--^kjiCV!(vbxC2vIdMo2I^X@+ID zcT&$52_`~JOBXh&mXX+ceO*m*0_=9ArqG>xjMR;+M=q{e-N#QEj-BCAzAVeGSrXNh zCV`uX4qS?7l$u+*J~5P?9xlU2%6rgo30lJ)cd|FHtEmloD@8tO@5y7N5t*NZN|hrm z*0FP5k0_1u5$>dp#I>8az>my1NoIAqBZ!Lx(!ohP^U@&Vmqd8 zH=75V+`}JpR;Wj8!j6BT1WSjMs>H+3_*52JYs(04P<@$3WEVZ7V%N-CLN$onNB~*- za-hT{!s~K{EUyaw7zDbp7n5T~SRV3$*>Zhpg-*51L=Zj|oeHx)1Mr4juj_5;_<5%8 ziMWWR&MhgdLq0$}U0q=ol1xb)TQBdcV!(3$iF4x~ue+F-gFAGMn^|`*YBjuP=jx!~ z06>UuQAq?Ix&zn0^To|<4!CSXZW7o6VrM}5dYxV+Q~8-h^Y9DzNs{5%+kyFy5cysy za}2EkZyRxQ^Rgq)T6r=({uw7y@%D4S?wd{Ck@D0(;mjg4NbY$Z$xd6rCGrNITO04Y zO%6aZ!9hMp%kU=V6dLc($d`AHMbf`&G9BXY%xr$$hovCbBj@|K2-4_HjW4Xn{knIL zaKV)PQkC?JIKYK?u)1`rzd)G(eO222!%q#U6QaT;SUl*MO9AvJ_$WC-@uTOjb58L_ zQo63V8+G)0D~=S&a%3>qqG`7N+Wfi$Logc=SXGBq3&TV|=!!;Nzi4VeqP9=hV>H5k ziX8p2v_i>9nc1rQm(7T8t#sTSGnI9T#Ms(_k_%sm3mT6gc=YrdUm@Ip6xRqL0H93*Yx0O!3Qw+_Y!81*n-ovS%iBlXx62TFNbk8K-j=LOV=1s zwc7i_TsS%sk!R7r81r4v*Ec`Rrl_m zr2$@wBrDGJ1`%wG6Ar259e%+MkZzK88-X>M^WgfA@HcWJmPUeFdO?d0>gvCTn0-ZWgb;$}~gdQiffS0?*jk$T`izb=V-&N#O_U4yp?Y!Mdlk09!o82t}+5dEvSj%vN5 zCBperFlf(sXr6C$n?zYvm=YYyz=~W1tkhvu1wODh>tKoBEiRB9*Py%96luTxm11-k?Q=g$c>y=q9%J< zVbw|kc=&DAiz8G*&G@8XlevEthbWV6a7nM1@VjKNkP|sl%x3(c9h#|9HIdVuC_??C z!MaVTrRI4=oMEugDa}D)#f1zPsr&vLR0Zy!7;QA4?x1w?=X%tH7o_(2z@8LjA`t^# zft3pe@**E=P;MFXEB+)Zh$?+;5%i6ECfT?A^~N`o&QHR5@V8a13HuA~omH+0(xm&s zJn#ru(@aCcl%uY66t2-NPi-*^o`hAyJ}I5kdqib+qh*CNP|jg>f!Wj#HJ<4r?4uCX zvkf`dDbhurH>#bk@3|Ap%0+kV-0PkcrZb0Q6)EJKBfaiae*!zLC7wkQ?cY#avSAHH z-b1`V^N9SgFL7-JrVQZS2rsHMA5v)j^@ga==T4XfE9yy6w7~pXILh8O)Le{Zg)9`|o`-$nca zc~hvlgOB$pGXop$oW3PzOuUbE^uRf@bo%^%%GEHQ}3uc0E<9SxbN+Fk6DEin>4 zHcD4f(K{ENOe$J0HJ#urqwE!{iYCcrgQT6kUmRQ&pZsx(U*x5m938GK3cceA-25P7 z?4_>Rtm;@LOJc>-Es0d2lZed7(#_R8eGm|eZ(xhjbvF{TQvs1jaS#K%R>_hqN0n}TZ* zkc089?X9=$pO*FdJ8a~1LwKU&Tl*+PUpFFBdK=aX&m5jxjDg5G1pXXNL&FXtQoDIi z%I2VE+_J15PN$4XB^X2Yje8=^qT3Q6Up)7auJ|SXIn8t2lJM#_5ql$SZ|nXfb&U<5 z+WD;cxsrkAy@tew0gl8PHWX0(qf>97u#=sJz7BD=`gp*W%GmlPa|+rCER@9rjcWg_ zl26OYrAyJyc>(x*jhp9DekXff;UF2NN;Ui}MJ?5ICzv@f9ALbJ?E#ZUr9Ic3 zzA*o$&I=Ta@JfZOEAMmeNUz9k93p!8X=>FBD$#aW*rJBSOJG_{E4u;M3A)vn3ZA*FCGn+Fg(4w7}cEUuvHYjNe3srT? zjGbTt%LY~=@?&|zrxYJ%v<6_xj4<+!VwleU+BF+z4)}b&?KFik zy?KZ%qJSTxm)WSC(-)vC z_LTIFihr!^y%i5PBEEPCOyW1(0O<=Ad}++TAQlUVUet+p^E3c}!Hm6Ker0kttjBIWHFAYVE28@r68QPb>)Vg<;d0ndg zIOg|&%Z^&B5koUj%;;F55>#Cd>y`X1^41GHDSIjVmR%4uBt$XKaBh6+p3un1m6DKK zM5nC$KuQFHa!O+A!tnBN$&WmSvCPz#nQaEXC!g(?sW+Y@AB1kdg2dM^(Gjmzs6*J zi>IYc&r4tXJ{{+;xx*UGux7GmUyf}GKo{&yc+i^CQk+fM5xwnR=XN< z!u~>Gl{|8NtTsKC_us}+!JbSFv?wd*)?I^VPt2vT`c;a6orPS2Qhe`>N1KB~dB}yP zspLQzZ>`?Hbq-7qJC#l@Vh{gOd0-=i*!QkM8LpL1X8-}g1mS#mh6v^#lwH+V0EAht zLRoZn@;eAS)m=80s0Jn#+sLq@zuIq|XFXByZxLIoN4=#LqQuVVkJJJoqdv}YdIi8` za&=Ppx)n$aP&MKW_^PY6l=m-iPXIGakyd*1%=})EsxHySwRk^AE?qcrR8hTjF`nFh z)+UT>wL0VXkVCY=24X|7B}!a=Gf)c2+1jXZ;lwogP%J5l_LHb4lWDj;(dv}Vr1IJ% zBzmFhafX~i#<1bqv&puIYKuHOPY|K%X&v{<{=yTL{$8uDcy(HHi}VDVjHC}Z7W0`b zEvA9p60jBWkkB5Rk#%5BJPS(P7jy(H&ZM=!PzvrzF1=cb@j0B{!WqXMl>4hvAUG#n zJd@sf-hvm66(tgSb~I9O>_*OH9ggr<9(jkPzpUP5U;9oi{-`RXFkT6&7UzshGl7YK z=w!GA{fajfE6<@$!92K|Md|hQp!i-X2J~nt=D;7#M2;}9l3LG<6`3C2w+L(}Swn*C-B*?`-k7j87(HI0e zOg>|2NSSo0G$Db|yJ=}l3XfUHc3P)1NIM4OhMgn9utTLY8mQE#BnS7N{&WXwxbPTC zj>^Vmu=6JO$5zNwB5NNSl0w;}jb@J-VA6wNi{X~PSBBYYx)&mpWiwGyMd~%>340*O<^m+;13xv+nsl@@4vWer8?fJpf?QLDsIAYG$AW; zLaEVbXdlU68j5l)of@<#27i#8e9acN)RqV5SD02bMKnOYW!RB{72(fvCCTBSVi?ru zbgDA#*GRW68N(c0E>5u>u(SP<+gV#x)7`Bp@SBKiVu<5JAQnY_TkLETuOirHXdSvS zvj3FIepQF6dAlF4aI!UHW_6)6yAM7CrBvn^#Qb^(|KMPUas1SycQijlWVnLIlvayxabGnXVuaQ^dHa@y9)=$QZH>SPegN=OO*~ zE)SFDbmX`%K>u)QKvO4)0Q6_1yp?lfgooarhtt<$z~YTO+(JVl(~ASc`owLsRkis`U_?MIJW!nR@Mo{TY+o9Pv7gjq0Br6 z69CC^k3Y>byZiTYSu$_l7lJPB2#srl$j1$McL;9;1JwOOnTj&h4}mWH-Vn?pBA#s3 zjm-omv~5W85u0g%GVKXOn)WQaVM*sXOrslhX;tKH6?3k};k`m#5;f?oYG{A|jfzVI zEawoElA5$S+%=j>B{ljl6OB6dMOtiz$z|zws<7A7tg64qMADNf&^>0E_v(v4Xo_qH zV^U-nQmvG1&4lmI`ITySApjtTHJlbWG-M3T*jAxeFp8eXd~QuT_;Rtxq6gbbb-=tw zoQ(PY91W&wSS2@?%S!N+c&XI*-Qe>8h;>EoRGL|8iL5JVmPFo`8mCcY@G7$%vVy7X z7@ReiXO;L?;tk6Mm3?VrP%a+9@9N45(_m|XD$^pZCLI=|=N&b3Eye{UTf~qseLt&P z!#sl$Vu>mfVC$4UM*S1iA&A8WT0&j2yWtx^d_y<4cNyNemon|ChjXI5IDRb_6+)L6 zHL>y7N+Zt&p4YiL#W9q4j^;U#_Uo|iALm532s#R|g|RtF1ga%u9(|3q*VEV07-Y_# z={jfTg|b)%84CRox5B4Px#rve>wV`e>F+Ihvw2o<_Q-Nv6Oskz6Xf0(P5Qe*HQ7l- zcH%D^p0}1DkU?Oh5Luxsh!wO zKUM!6-)%F>W(*eN%I<=x(m0rDftloG$@?ufi_0FJPvZ3#aSQ)qBP??BlZ)n3kR!u( ztnUxe)+T0*JsBGnx*NQaQ*rbN@u7$&a*QhLA>#~Ru<77+YbIJviqYiex1fq>1{FT# zFdi=DsQwOIHD+foydCEv&;U6m{f)}zJS3hga=b91my!N=YxAFN>}t3rbzl6j(22F3 zN=wsJ^$u!O$eS~g%{1`E%Z4(MfN(74t3fvCmpBFL^Zwb}W|;;%1`>f&|3*$y)Z>cJ zb4L4u3{QiD>q8`;X78t!poKbPNQ3F!N5@gjzIaM@VHUUjjLWq@kvi9sqbqS?nXGE8 z#+GiOoSb3agPl)kT>OYk63q+oSkS>R1&~Kn8mWrR@Ghg2kK(O=B0gr7cqQS&ZU#=n z!fuWk@yB<^!ZQXKgv|$6V&t7P%_Pw;Z6eX>n7u0VO2tT?Md1A_{XTzc4f!^fy@J`@ zL_xHu4pQ2%+0gi2MYpK?iQ^gAY+ZY~Gl4zpRA+4JCqhte=){_!sS#6~-(u2O33{G&qyu-3N|Q&_I& zrYu8ewgXs?(VGq;pSXyDqUfrqm8MV7=*kn-gajV?A&2rCKCU2b%V#8DjIS?*Vby zKbhSHwl(aey@M#B8n8X&2S?C9fc+T=k|2m>1p1jE^8a*p7GPC1+y5t}yFEv0biZjerCkVf)}=vc*AQeLaes5@b#F77Z6qAz%l-99zN7!krPb@WE@*haV*6;&%ac`t z$p+!J!?T5Q(0fA5a}OU8+PZ!Ndhf30kT((m^9FiJ79WS^vcFZ6gGuSj{S`e2Q%u8$ z*$=`FNUwnT3MQXg2wm@iypIy_wtTRvyLm345nt~Hjh{W&yk9bNXi)x$TYOmqRkBjR z62UrkX=#b5CsQ=dI{nd9hLOmmydWim_?39xb1J`JjsCP(>wNM~^8+bwt(VJK^`0=s z%97EYPT=bjs((ZFX-|N_y>DS zvWRyIuDcghz}MpyZE#*nQw|a4uW0zgqtA>*CLBdpjUhRD`mJFRa&;l=cRkT3S(l<+ zO8=_HSCLh~y|ftK(ajUECd|EE=Wy?Hb%c%#nHYPZLw9akcR7u!w5#-PioD>8RhE)< zt{&UjCzWN|o#^vd8j;6KXf=4}kMkCW| zVSxvE=u0vh*r$0-S(9P7Q5CW%^7bKVu=| zk>ZOJ}2*@xw z%?i%k;pi|RUQ44_+hrd+)y{B|7lfBZp}F!E)I)8)h6ld30f2zQD zTA+dMr02cDX+vCzfK9iwIK=x(6Jyzg^uR7;c;;@nWi3y`O@AqwhJ>;X- zN7gfZGgG5gwbGh~E(12E`qln~DWZnEFRDh%yxmP)2=<8>_4(`U0+5>T-4EU{^0T?< z`+eP>KTJFH+2mikxF_l^Z@%c<4BZl2RS?NPZ1r~7eLM)%xk}0y=Acd)Cm(z~Xvwb0 zQk7zx^wnc%U@M7vM_a$zg(1pPLqISuKU(`;+GHB;XjQ`ED5yW)tP!0z#M2FKs+Ds` z@d($Yzm}Bw#6VTT%Ge5*n?cNZ-1wB^I44Q442Ll-=xb?uqN`n``RUrAJG2xmJW}#I zW1SCEJv%R%*ur!4a{!F-lTBUWI$4=GO;;xgrKZ*Jp3sa<>ilJ{rnNT~(~B#*XEmiU z1~Ed`QBgYpk>YsHbLx#%E)o9--i+ZC9f^_7T3q*re!~_iq1d4WhP8%?V(#=QM(g^7 z>2+F74STNRx~BuypUTi!+)M{gS@jyMH($ZDu zKjsY7wy_tY=^3B$W08}!&<@2c!l~K6&#D)VB-K$kGlCyqCHZOrNP@szFIP8$SAP6l zAIjazY5FRXfEyma)Kg?SYc6gqIrvj&$otnW`!RzBpQi4fq)s=P5CdQP@)yndY7bUH zan{vp_Qu7}wY$KTn$j1%Y@h6=n?MZNqDJhm%WboRANR6CQby3{gRzTJfUkwKimRra z>v20v{=}dJ`%D)e01bVn*OnnAnvxkDMidvnnJEF&DTbM&P+`Ujq+6c9syhcdm!joG z*1W2nVX)Y4=7jc_kF3u24hP6*6e_ugdd-Zx2G;^;ugxy^C3B;tZE{9i)S#}n+Tm^Wl z^%KpO#g^>$))G%Ak1-6LUD#ZTRTn(7!9<4(>I$Q9zeW_j9T{_T6J6i{a*yI=rhgd@ z)gG{9+1{|l$zFGeY|`t&%G=$#LakN(kclKjR)UF-Ix%+c&+>+~j$d4Qmb}LruYMO@ z`qpSxlDi`75!wy{eqU`gG<%ZOL3iz#AK@!h!=>|j1B+Oe$GKu9eUZ!k_(1T+S7_kA zbJn;fO_sAts`Puo#$t6E;ze2?q_a>$w#+0nuk}*bYY8_IQmYk^aF^PtEnm9%vS?g- zl=f(*i$v;};DFLu)Ie}{;wBfYcRZ;#gqu}?q$J)G2lLswTD<(sxB!k1pp9in$Y8=k z^3JyAcETT9MmAB~bYMX>W~mpKeS-AdzQ{3eH)NL0Fva9G(r77Eq^5@T^jqfFHlZW6 zX`)orA@BS6J(?KBp+#ABTs)dY-6)A)m=B$=fl;)gp0w5h=kVgFEy%>zT==t#)Oswq zTr?{tmWGWFbDOksn&?;8ZO@~z1|4maoHqnx;)hZai1Oa97qKZ2`=>=Tqbi7E&k^Na zZ{=(CC~B6eo5t-^lBcfd9J7-)zKvBA>K}~;QMU(%+w1B)Tm0HTIfLh#lU;3Yn~+}d zUP0S|jo8kZ7+vu!d=$BZlVeRdZn#XTYejHx3KQ;O9%HU#dW(r^FcXBZC(y~Sm~%N} z2AJNk$S5a5XzSgPM7Rj`gO_&{#IQ+BaJI7%Cg(lRcrdBsB{DM zT8d*WSa9l7$|3s+xddzetVv2FvHpTmi>HO0ST5olCxQvl(GCf3Q9y&j7i|TuS52RC z$Mq$-RNqf4At8+FuTKP}#H=tDX#`r?5dsa5dEA@$R5+ZaAl)jTIpWtmtDot`nN#*n zhU~NvwXJ2@?Ng4=Ga)ngqKekQp9>riEd9DzgA}4BUwqIm0%Wss9jHUl$nKYqO;2N7 zknpSn9IQrcJR>i>8i4TbCiE{yOjELbLUDeF)~y3Xq^W(@CXkZSMd`R;HHADm=DLkJ zS;1I$?g$Acj(p>KT3D?`z_4LUo}Uvij?k=_H9S~+>bx^)AG{@fB`}K$xi6WJ!FPJGW zB~LoXg!SC`+S#|tF_WQeoMF^8u?W?f)9v=3VwpXM#@dD`br&6k3%WzaC(pjfR0`fM zChRRAn~rhB-s|T5e1XI1$7!j+-kyB4Yw?uPR@@9KfpTk%nATjRS13yeX_R>U?NRR* zYr(<$9=%ADVmjc*1V?@FRwNrtIjAjb6~xw zC-sWFLtc2tkj`HGvT-)9R$lY{zLj=HPa%BG;Eej@!{!SgZ7uQSkiTpuyam5P z5rGi-YQWO|GMX=FapkU`5NRBgpyZCbC47f9)TZ5%PIz1ivCfeoh~;Vbi@p|Pw7gM> zwb+um?aH84>hd{#m`B&9Hw?kAeS3;L=R7r;t*zfqC&7JCTJ}UUynqaE9fG)Oeo+9~ z<)#K&_ox+Nw&lB+9i|2E!p?w#If|`6#-*70{+ZT9cyNps75*mHJhbjb(M$RiL#Im7 zkt@=c&>5xhMt!=^u@mJ>AD$D_6u+1VyRkNNNm4B-5;&h9$MT0M8s71AN$h*tvfb!k&(H`x-=+RpQI>om@b>eBy%{M}3KN2#u_7ZsoV&Xy#uDxoRl2 zhZ9oKR?*q};PbY(m7gWgt{z{7YV^%w zc`Y^X^W2*`zFzR@pZ`FAYXD7ajJxrE>}I9XGO?tURZlH3Izhh)mjN#;L|i9=q<*Nz zeJ$l3es%o;Vkm2YSg0p_sEJfD;4905eJ~)3KL*>sr?_0fwyGKtmV*Mx?gOY(=^nPy z75*rmkv2($3TAtHYhv>G)jB4hBOwj?+DEI7B7nKguhhz2Yd1 z5R{LN%C|hj+rB0#%?eMKUp2KkGARiM^w%6HC3B_ajcD)SC*>BKm^LzSenJ0Ao&OwF zP*SjP9n;qLfKIW#zSsN6#KjQ=N9BF<<&EVWEqo{0Wy95oba_&mA2}DQZ?GFIAE4+$ zTSWyjBPuJ{I>+2{`XjGQUK|-8z?*tIei@>sC0eceal?yJ)H4CGLcpm&tzj$W8yN`# zWW`Z58t<@KB$*M=mUB3S1Ewuu;KvZt)Q44I^sc9(<6KD zz8jzDcL^6W2q>?&+~@GAhGm!bSVyKo4FcZIG@w+Qpt=z*Ug35;iTEV_r3KuuIY@AP z86i%AyiC(GJ?msLDzV2q&uEWf<036blx`(bK34rhL@TD$CD~KAPmc@j?tv4i(U$`9 zcWk#E6!Y?LEsmMJ0&nlU1XdZxd)a(3uMfNLXuUp;?^_>tzV(jaTa$0?-?6+ps6I8M z^B+WMTXsb|tcon?N_dCOn5B9n=!X7x%?0 zTWoPArre~5nAqwvGIZK;G@h1ctA0q9aR>+@?}8?$AnXuMICs=!+GRwXA9E?Tb*cs~c2&|aJbq|eJ7f#q| zoxW$gW$NCNCCs5dI)Z^%IkU1tA%66_qyJRWe0$h5=C+eor|YD9VtX=mo9i~)qd6;iM;BM3`Er9%Vbh*xkQP$9s^g?<6<&loxpnjh84ZhlM9LxMJBc zLXJ0K3!L}(&LVO@gM{JDV-#1QVN~`dv!T2 z2Qn;Li&$}sd(ekuw=gm4*!C?zfH%!{5U? zO_#Y7qV!K-j*(lr3xK97+d&CUgC{~Jh<6M)O$r&FwN{1 z20nbi=4jRBh^n!*wjSy8azByNjBI_hrIYM>2DjX@lKe#Cjb~HNQHwH_8rD&4I!0l; z_yD1aD4HlIRpaTe{;-Dp(o62$P92GK;Vp2_eF?x?niw86wX|gzR^&6S9>(;XlZu!P zg%R|xezBab&$a_p^tvy_W@JtUC?XN}cgE^{$r@Jj0O-eGw1y~*_g%tgOnARkghNuL z-{~{vK;QbpL8{T(kM6bO^)h}ux~es@-LTd;R=9)sxy<}5O;v>vrHj%91Z$l;<`Y(w zbdlOcHl_DeY2!3@#q;ILT9*;B7%PjE-TI@nj;lVk>o~L@x38XcbQ>sb4Q_ergjle2 z=1TP)RfEaI9>j4(%Pj#eMlOU;E^SAsx1HlY$8Ha+YL5x9-9of5SP~`Q!TTkHjuEe( z^@Be9fgW2rMRKH_{6?-ncAL`peXi#-uUai?&<79D<|qcq#{*VhfR0^Bu#$m}waU-a zf?oVYeZ&@3KR+@Wsj@7H(vYJuPF8)?g;g1qgAbPp;Ih|4hUftITYkRimR-QPGaWd7JcGhKSRpMGT&ZPF3KZi+UYK+VsaLymr zv>(Eeqzvw$N+M$wu# z>3e49=_k#bazg|41_rGVT0nT<(dcOP7(s1Ur0>eqr0e92dZHT8*{A<=?8f_)wMpo0 z{|aanXhtrN0z4$6y^uuRVHQ*`pV$MvaOW$EvoxJGG@+{pg z{B(^TDMUY~v>>L4)O#sr#wBegOIOE&*2iEbQW`BhEFF0u>@prRi!1xGtL|1g#KAS$ z2z`cSn6L;ja0_%*HV*2mK3AE;kjTw^YqTooD;21_$*D_&YbZt7kr0YIgDiIM+h3av zgXsG{{f0}-p6NrnC_K3|jZ}V2#|Q~}&q&yQGGhGuzGQpOxN92O13je4X(I|k==cr~ z){SHv(u91WcbB0wZRt+%i7bMlv;!;=?yyQRrb<4vGj{OKNm9nxng!4NsvZZwIjObb z@KC~nsdPY69@6BqZ5_xo2)t2U7f?&S-~;ZL?M-P+2NvUqJyv1rd0k&{^ggm|X#DvU zA1-EY8=0$XfC4GdfipYcF7$esav-K`gw%(SpA#*Orbj6niv@8kHC8^~J1)}`9(X#r zWe+dN@#5LahIxdUkkOvtdVCuX)hsK*ev-=yc~?~I&5QnUdA&FOi2aQH#JHqpMANea zI;p)iNmoZdlH(Y%N7`Q z$tJQ{7&y_+s7g)E&Jh({721M{ps2~O(9SBcraCmcZ0}dc5$rEJ!v9Pbl&6ubxH@S& ztYob|2_`2;c^Oa>H*AXv!H4p7jIMDi7;0~m>)a$fmh^tqSUKkGutJV0J%@winXVE} z1%Efz)uZZ}4@jH2eb^k(9K)`8{RrURx2bPm4BcAoetOQG1Yd9lGtN|#HSUjX16N>h zgp&z_RHqL2#CB%Ab+D{k$HbPfS>)o3Tge}(!1u2$?BrpEgXExq>_cGo??dcNzwR(V z`2az=)m9(}T9VsMQ)TcvTmoO*co=y?Ehmv68vM8`XAYc}We zjk&~={oCs$W&`ksP}g8;6e0#Qzfi1(I;sI<8?wAN#=S{q>b48Z8FtBqMe3Lo?t!EY z^itX@b~44Vwu5KIb~f1^NSYKTZoKLnZZe6uiSTR9JbuYG=>r+hd$|$O8?Z9?6eW!k zTvcHux%(;faiU}^r84lESQ4bMI=%MtQE>xOs(mCe>RrTGIvDfQnE0D5LQjK%wz@pq z{80dAMVzvl{BgUGwK)lIPb$1`LijJNSCwa+)WkhJcWqqlj9V`-C$fYU5EheRA zYafq_r_hB0^C}Z2UoB0XSs!8%AUq)yVUO) zwX6RI_&)zfJ?O}QN})B zszeLFN+26+QHH@RthaWS#8B>Gj$1KjY3qnj(efg95O48)}Hn;x28!H&jZ`_1+LeOo1{$L zw1a-o%V@mzgD3f2q79xeeEC1aKOyC7B61gS*S?_Zh`&^p>&?}@RO{q0!(DW^ec6;M zYT#36iu`t^u4YK394UnkPHrG6(vS#2#W7^a)DseTl(SK{_mRx$SSO(;R_bGn<;tZ{ z)`77$`ig8YMyqtHF!Oe^VW=Tk_L10)5Fg6Lmp5r4<(4)Vuimrx8er5B(n2pC(7r5? z#p<4o`2yc+!ZWADaFv&@35Yi_ve!%T@*JOz%$|SD0Vg&dWx_ie8OD<1#3l8(_F|Jo zCmXF1Uv%5xfF-Fk3?4k)4sbvl&!T!idJn0sbY#s!A+COh21I8hGu6fXK(MHhwc<^7 zjk#}tUy&wBpV8PzVY|f#+K#Y!YbCTm*g~AP zgs!E>RURoH8CYZ1E6;(H%K|7or+2N9^-bbqr-9b9nv)Xdd--LXSApu89O>+r&{j(e zsoCK3=YM5>U@;s1%m%t8n8Ez6Tl$-szkla^0A(mQvov>gGWtbU4d3`(1<+GX_por* zJEnKK!ZAfXWakj?oanK>w98Y9u$CH^O}GD3ny%d#s%lo*wAAtBn7P_V4@?f6B`EFdP27|nUbv{J6fxz z&di#|ozz#*%c7NKR-|Rr$zJ`G^W7UZb$KrG$#u0iQ!4Pom1;dBDrR`K5>p%fuIim| z)uO7-JkL@}EF$p2sMc%(@TkgyPCk7K`eakofj`y_h6>Tv{FFOv?|n8K1nWY~c$J7O zo$OnJ8VwVPt8`m#*V2+6*PL2&p-b36MazIZ^`hSGmUdct9ltF~lGm8yY_CPrcVPqF zbm=0sw{Pc%=v4NPkOWx#dk#Lxd4?Z0s9pr?U_k))RlmZg8}zO3szcme$P5m32;ToK?74f|_(j%4_CBhdvdOZ zAAS*wBz1AnzmDxfU@^OsTn#5a;%Jrku_al3e{

1bvi{DS7E@q1{$_8->K{_OWv2 zCZTgG2Pr3n8|ec9kIu&uC|d?k4-cQ4#}Z`qDX5Y2mhC(jR1Ms;UG4Ho$DE|+SeJ@{ zJQQhAXj|<)*t3KiOWTuh{Wd^mS{u{&ERV)OpZwiQ%#1->r9p zSK_^*U~=?ywH~4IUxb}{0J!SmL!z2Tzq_PpetoC^_az1JFg0=gMcQADuOP%3=H1hH zH_=dG(PD;d*037Ov5G1924U#Zns?~fs+eh1%-bWqa%ssm3=nio1r3J<4G0IBETtr? zycs~0JIOn;MecYG=~OQsYHIrf?~A5>_ob%8+uOrVA+VCJw}{lygrBBdY1k<8B^wf6 zl|<%N$7)fOZX$%y>4ueco_Gb1H@B%XrKVwrn6hUOecnc^PU0rFuCB5=*2;|u-`o(@ zL*tr4bnQzXYLc4XqFbv5sK0}A)`}`8iM8ehtj#Oc5DrE;0VxbPmL@BUa_BQwa$EW~sU#-LP0?sGmqfUGhGWcciGZ*4(}u3z=@b>Ow9DQe7lcO3K}BG3j(t& zH10>sK!&4Q5-=gN@Nxj6{|*nuyqw7KZJ1?p)NUJ?U0bOigGdsOk}Iz&9PmN_5=W*Z9M zy^pA`&dX0oo6?CSuhE~(pYbLuTPp1a1Fa@e3Lu&mmgd$;D}&g-i=D-{sv?J9kIr9r zrX&Z)aFGK^kNY{LxrotP0}k*;uN12i_2a_JJhKwh zBt{D-JRxC$8U+-`u1xD>gJ^H4lbW;7spI-=H506i=ncdK;xq*L6f7jVz$XGMg5aQk zHRJY&$@g}i_SP##iC?lR?ltnWUTT-UDlq(*BTQaYNkg zNG#sNoo{WmP+Vl}U~?+T?g25b$E-7iwhu=VVgw3JdFXm~ba+LC4p>CP3~rNTiNBl7 zL{RfLLepNPEtZj}yL_#R{(^MqIlG)c0Va}>U|9Pl&B_3tV;Ps{r)WqBznD7FcTlP4 z`JQe2DvGhmeeHGGX39zGyOOxZ3tq~Dft(BQ;mDXwwJi?sBtxo$Gf1SS2w*eQ0p&RVMNVi@d zY8v4J0(n}%6*Rw(g~l@sUuxpiJ*Y}7TzBQyU+>-qWm*InUeGt@)T9g^0J#z4){Lw* zT;69if~U9DXBR9fgVPlYy7aDhJU)gDC?_GHQtwa6QXNaah7-CzA|Fx-lH7d@N9>38 zX(F&fd3w7AkZ+ha8-gKfX%@_~<#HDs?kBg5zW>V3%Xw5jwPs6uni{7r zd`EfPYrA*SU;xDtm@E>5TrJKlg5o=h;NSXk)pt4K)GbpP0xkUg>2o|oG=`UnX7^Un zb&@8d6Fj1cBWW^c(K#Csc8xEBa4KfHY>8Lp^77-lhzgWr9kR9_p+g|-9r?VSv?qA%^1O;cqgke)%AqHlR$B{!Y1Mq zj|)Ecg?{_!>kGDAwGa7%cwSUb{BcayJihkv$}ql+yu=O}jVvAFdC{Hjh$4}u+$mx% z5V$sUiGCX%D3A>bKwY8HR)Gv*lisI4q^3vJ*nDwj|mtr!0r!~+Qoe2cw^jPCXkT7tI*01|w@ z&gPC`?O1w7hQ%=&bcHi7(fqhY3${~JepA7y@^aLwHpew^Yk$;R4v{ASHjXjXtaTc_ zuz5*nXB&PrcyWx#gQ%?HyxawmS+Wu(7ssvB1UMh!1$to&o(mv_f=9~!9@VsJCGxpu z`>g5Sp=xDhpsiCy^y>=fI0DON$&pb7o7^d{@@&hj3!6PUd=vA;G;#7&8ChamsE{`^ zY8pDra8Jntp62Ivi)Y`*XbpM60s06v@Rz^-g)TW_F@B!~y7!4AJ>37mAuz!(!C+xQ zSR61?u!{N|qHWOeR%$RXRL~vpN0SGri7-klNHEJuivbi=0qSbdV4&ghf4i|7?$>z( zI{qH?i}`~a7GyB6|8pZRq982+P*r1+m-t&(%U5#ZWFQd-(CXKLHeN@y(c z;wqq1hzE@q1b$GG0VQ_)`{MeylBlVfy%UHR=;Z98>T3M&;{0i?+0T-Bck?I)AUQrz zeF**_iGu$JlCpLnFv`D9?q6R51jKPM{Rd6!0FF#KP=O|b3iQX*TqXSjO?gXaXAmLr zU#g&%@+XpjVArlGkfaPKk^PUSnMLsjlK<9nH*zxl^V2-jGC$4+HGE%?F3%4|y9>HN z|FJgz*HW$VwU8$RNtuBf(2vdZhW3x;R6%eoJM(|2zvKebxCh$s5J-*fhZ75B_yeUs zFTrToFiB^SNH?gV2>l?G&h!UD>UP%uKh1L;Er59!q&NoZRe$VEf?5Ar^&iUad&2gQ z&WE`E%lTg=_3XQT@gJOjkAi-Hbbqrl{(pA<>_GH4O8+xI^=IAhS#v+$vmgOK=>C!~_xFg-pLM>6kUfy=zL|u~KkNJ< z$L?p*?;%(Ze6w%%M(zjE|4dH&5$)_}mG3z{KUQ6s!Y@_+kInPH;kAC&{T^5HKmqz@ z@+!aA{YNIy&r;uKTz=r6e6v>d-%9<%_4R!+-iN^8H#0N(rQbiu-u&}-|2`q@k1agM zdHkW_1&%VDD_|I;NpK*OZfAjAb z`Ttl8km0{|{F`kWKWltH$^Ech;G2y`{7&N^%H;d0$cGv7Z^oJNOSiwAFaP<=em}wX z<8AA6<}bbeZc_7S=ii6PALi)3nOXL)o&Uj%-OnQ52M&L%(%ZaWiu^(R{b!Bu2WJl< h$Zw`p^gE5e2}ml*LW4$nU|{5+pXG<~Ugg7I{||-5t(pJ; literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# 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/2d6327017519d23b96af35865dc997fcb544fb40/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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 + + + +# 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" ) + + 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, 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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" "$@" diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..570360a --- /dev/null +++ b/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'lattice' +include 'example' diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..1985b1c --- /dev/null +++ b/shell.nix @@ -0,0 +1,15 @@ +{ pkgs ? import { } }: + +pkgs.mkShell { + packages = [ pkgs.jdk25 ]; + + JAVA_HOME = pkgs.jdk25.home; + + # NanoVG/GLFW natives need GL and X11/Wayland libraries at runtime. + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ + pkgs.libGL + pkgs.glfw + pkgs.libx11 + pkgs.wayland + ]; +} diff --git a/src/main/java/xyz/meowing/lattice/mixins/MixinGameRenderer.java b/src/main/java/xyz/meowing/lattice/mixins/MixinGameRenderer.java new file mode 100644 index 0000000..f5bc7a7 --- /dev/null +++ b/src/main/java/xyz/meowing/lattice/mixins/MixinGameRenderer.java @@ -0,0 +1,25 @@ +package xyz.meowing.lattice.mixins; + +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.renderer.GameRenderer; +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; +import xyz.meowing.lattice.Lattice; +import xyz.meowing.lattice.event.RenderEvent; +import xyz.meowing.lattice.render.Resolution; + +@Mixin(GameRenderer.class) +public class MixinGameRenderer { + @Inject(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/render/GuiRenderer;endFrame()V", shift = At.Shift.AFTER), cancellable = true) + public void lattice$onRenderGui(DeltaTracker tickCounter, boolean tick, CallbackInfo ci) { + if (!Lattice.getEventBus().hasSubscribers(RenderEvent.Gui.class)) return; + + Lattice.getRenderer().beginFrame(Resolution.getWindowWidth(), Resolution.getWindowHeight()); + boolean cancelled = Lattice.getEventBus().post(new RenderEvent.Gui()); + Lattice.getRenderer().endFrame(); + + if (cancelled) ci.cancel(); + } +} diff --git a/src/main/java/xyz/meowing/lattice/mixins/MixinGlStateManager.java b/src/main/java/xyz/meowing/lattice/mixins/MixinGlStateManager.java new file mode 100644 index 0000000..347c3d9 --- /dev/null +++ b/src/main/java/xyz/meowing/lattice/mixins/MixinGlStateManager.java @@ -0,0 +1,16 @@ +package xyz.meowing.lattice.mixins; + +import com.mojang.blaze3d.opengl.GlStateManager; +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; +import xyz.meowing.lattice.render.GLState; + +@Mixin(GlStateManager.class) +public class MixinGlStateManager { + @Inject(method = "_bindTexture", at = @At("HEAD"), remap = false) + private static void lattice$onBindTexture(int texture, CallbackInfo ci) { + GLState.setPreviousBoundTexture(texture); + } +} diff --git a/src/main/java/xyz/meowing/lattice/mixins/MixinMinecraft.java b/src/main/java/xyz/meowing/lattice/mixins/MixinMinecraft.java new file mode 100644 index 0000000..c414ca1 --- /dev/null +++ b/src/main/java/xyz/meowing/lattice/mixins/MixinMinecraft.java @@ -0,0 +1,33 @@ +package xyz.meowing.lattice.mixins; + +import net.minecraft.client.Minecraft; +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; +import xyz.meowing.lattice.Lattice; +import xyz.meowing.lattice.event.ClientEvent; +import xyz.meowing.lattice.event.TickEvent; + +@Mixin(Minecraft.class) +public class MixinMinecraft { + @Inject(method = "tick", at = @At("HEAD")) + private void lattice$onStartTick(CallbackInfo info) { + Lattice.getEventBus().post(new TickEvent.Start()); + } + + @Inject(method = "tick", at = @At("RETURN")) + private void lattice$onEndTick(CallbackInfo info) { + Lattice.getEventBus().post(new TickEvent.End()); + } + + @Inject(method = "run", at = @At("HEAD")) + private void lattice$onClientStart(CallbackInfo ci) { + Lattice.getEventBus().post(new ClientEvent.Start()); + } + + @Inject(method = "stop", at = @At("RETURN")) + private void lattice$onClientStop(CallbackInfo ci) { + Lattice.getEventBus().post(new ClientEvent.Stop()); + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/Lattice.kt b/src/main/kotlin/xyz/meowing/lattice/Lattice.kt new file mode 100644 index 0000000..c8266c0 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/Lattice.kt @@ -0,0 +1,30 @@ +package xyz.meowing.lattice + +import net.minecraft.resources.Identifier +import xyz.meowing.lattice.client.Client.minecraft +import xyz.meowing.lattice.event.EventBus +import xyz.meowing.lattice.render.Font +import xyz.meowing.lattice.render.NVGRenderer +import xyz.meowing.lattice.render.Renderer + +object Lattice { + const val MOD_ID = "lattice" + + @JvmStatic + val eventBus = EventBus() + + @JvmStatic + val defaultFont: Font by lazy { + val id = Identifier.fromNamespaceAndPath(MOD_ID, "font.ttf") + Font("Default", minecraft.resourceManager.getResourceStack(id).first().open()) + } + + private var _renderer: Renderer? = null + + @JvmStatic + var renderer: Renderer + get() = _renderer ?: NVGRenderer + set(value) { + _renderer = value + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/animation/Animation.kt b/src/main/kotlin/xyz/meowing/lattice/animation/Animation.kt new file mode 100644 index 0000000..0845b76 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/animation/Animation.kt @@ -0,0 +1,118 @@ +package xyz.meowing.lattice.animation + +enum class EasingType { + LINEAR, + EASE_IN, + EASE_OUT, + EASE_IN_OUT, +} + +enum class AnimationType { + POSITION, + SIZE, + COLOR, + ALPHA, + CUSTOM, +} + +data class AnimationTarget( + val startValue: T, + val endValue: T, + val setter: (T) -> Unit +) + +abstract class Animation( + val target: AnimationTarget, + val duration: Long, + val type: EasingType, + val animationType: AnimationType, + val elementId: String, + val onComplete: (() -> Unit)? = null +) { + private var startTime: Long = 0 + private var isStarted = false + var isCompleted = false + private set + + fun start() { + if (!isStarted) { + startTime = System.currentTimeMillis() + isStarted = true + AnimationManager.register(this) + } + } + + fun update(): Boolean { + if (!isStarted || isCompleted) return false + + val elapsed = System.currentTimeMillis() - startTime + val progress = (elapsed.toFloat() / duration).coerceIn(0f, 1f) + val easedProgress = applyEasing(progress) + + target.setter(interpolate(target.startValue, target.endValue, easedProgress)) + + if (progress >= 1f) { + isCompleted = true + onComplete?.invoke() + return true + } + + return false + } + + private fun applyEasing(t: Float): Float = when (type) { + EasingType.LINEAR -> t + EasingType.EASE_IN -> t * t + EasingType.EASE_OUT -> 1f - (1f - t) * (1f - t) + EasingType.EASE_IN_OUT -> if (t < 0.5f) 2f * t * t else 1f - 2f * (1f - t) * (1f - t) + } + + abstract fun interpolate(start: T, end: T, progress: Float): T +} + +class FloatAnimation( + target: AnimationTarget, + duration: Long, + type: EasingType, + animationType: AnimationType, + elementId: String, + onComplete: (() -> Unit)? = null +) : Animation(target, duration, type, animationType, elementId, onComplete) { + override fun interpolate(start: Float, end: Float, progress: Float): Float = start + (end - start) * progress +} + +class ColorAnimation( + target: AnimationTarget, + duration: Long, + type: EasingType, + elementId: String, + onComplete: (() -> Unit)? = null +) : Animation(target, duration, type, AnimationType.COLOR, elementId, onComplete) { + override fun interpolate(start: Int, end: Int, progress: Float): Int { + val a = channel(start shr 24, end shr 24, progress) + val r = channel(start shr 16, end shr 16, progress) + val g = channel(start shr 8, end shr 8, progress) + val b = channel(start, end, progress) + return (a shl 24) or (r shl 16) or (g shl 8) or b + } + + private fun channel(start: Int, end: Int, progress: Float): Int { + val s = start and 0xFF + val e = end and 0xFF + return (s + (e - s) * progress).toInt() + } +} + +class VectorAnimation( + target: AnimationTarget>, + duration: Long, + type: EasingType, + animationType: AnimationType, + elementId: String, + onComplete: (() -> Unit)? = null +) : Animation>(target, duration, type, animationType, elementId, onComplete) { + override fun interpolate(start: Pair, end: Pair, progress: Float): Pair { + return (start.first + (end.first - start.first) * progress) to + (start.second + (end.second - start.second) * progress) + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/animation/AnimationManager.kt b/src/main/kotlin/xyz/meowing/lattice/animation/AnimationManager.kt new file mode 100644 index 0000000..4d0413a --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/animation/AnimationManager.kt @@ -0,0 +1,29 @@ +package xyz.meowing.lattice.animation + +object AnimationManager { + private val activeAnimations = mutableListOf>() + + val activeCount: Int + get() = activeAnimations.size + + fun register(animation: Animation<*>) { + stopAnimations(animation.elementId, animation.animationType) + activeAnimations.add(animation) + } + + fun update() { + val activeList = activeAnimations.toList() + val completed = activeList.filter { it.update() } + activeAnimations.removeAll(completed) + } + + fun clear() { + activeAnimations.clear() + } + + fun stopAnimations(elementId: String, animationType: AnimationType? = null) { + activeAnimations.removeAll { + it.elementId == elementId && (animationType == null || it.animationType == animationType) + } + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/animation/extensions.kt b/src/main/kotlin/xyz/meowing/lattice/animation/extensions.kt new file mode 100644 index 0000000..61d2662 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/animation/extensions.kt @@ -0,0 +1,70 @@ +package xyz.meowing.lattice.animation + +import xyz.meowing.lattice.ui.Element + +fun > T.animateFloat( + getter: () -> Float, + setter: (Float) -> Unit, + endValue: Float, + duration: Long, + type: EasingType = EasingType.LINEAR, + animationType: AnimationType = AnimationType.CUSTOM, + onComplete: (() -> Unit)? = null +): FloatAnimation { + val target = AnimationTarget(getter(), endValue, setter) + val animation = FloatAnimation(target, duration, type, animationType, "${hashCode()}+$endValue", onComplete) + animation.start() + return animation +} + +fun > T.animateColor( + getter: () -> Int, + setter: (Int) -> Unit, + endValue: Int, + duration: Long, + type: EasingType = EasingType.LINEAR, + onComplete: (() -> Unit)? = null +): ColorAnimation { + val target = AnimationTarget(getter(), endValue, setter) + val animation = ColorAnimation(target, duration, type, "${hashCode()}+$endValue", onComplete) + animation.start() + return animation +} + +fun > T.animatePosition( + endX: Float, + endY: Float, + duration: Long, + type: EasingType = EasingType.LINEAR, + onComplete: (() -> Unit)? = null +): VectorAnimation { + val target = AnimationTarget( + xConstraint to yConstraint, + endX to endY + ) { (x, y) -> + xConstraint = x + yConstraint = y + } + val animation = VectorAnimation(target, duration, type, AnimationType.POSITION, "${hashCode()}:pos", onComplete) + animation.start() + return animation +} + +fun > T.animateSize( + endWidth: Float, + endHeight: Float, + duration: Long, + type: EasingType = EasingType.LINEAR, + onComplete: (() -> Unit)? = null +): VectorAnimation { + val target = AnimationTarget( + width to height, + endWidth to endHeight + ) { (w, h) -> + width = w + height = h + } + val animation = VectorAnimation(target, duration, type, AnimationType.SIZE, "${hashCode()}:size", onComplete) + animation.start() + return animation +} diff --git a/src/main/kotlin/xyz/meowing/lattice/animation/presets.kt b/src/main/kotlin/xyz/meowing/lattice/animation/presets.kt new file mode 100644 index 0000000..b8de72a --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/animation/presets.kt @@ -0,0 +1,164 @@ +package xyz.meowing.lattice.animation + +import xyz.meowing.lattice.ui.Element +import xyz.meowing.lattice.ui.component.Rectangle +import xyz.meowing.lattice.ui.component.SvgImage +import xyz.meowing.lattice.ui.component.Text + +fun > T.fadeIn( + duration: Long = 300, + type: EasingType = EasingType.EASE_OUT, + includeChildren: Boolean = true, + onComplete: (() -> Unit)? = null +): T { + visible = true + + if (includeChildren) { + children.forEach { child -> + when (child) { + is Rectangle -> child.fadeIn(duration, type, includeChildren) + is Text -> child.fadeIn(duration, type, includeChildren) + is SvgImage -> child.fadeIn(duration, type, includeChildren) + } + } + } + + when (this) { + is Rectangle -> { + val targetBg = (backgroundColor and 0x00FFFFFF) or (255 shl 24) + val targetBorder = (borderColor and 0x00FFFFFF) or (255 shl 24) + backgroundColor = backgroundColor and 0x00FFFFFF + borderColor = borderColor and 0x00FFFFFF + animateColor({ backgroundColor }, { backgroundColor = it }, targetBg, duration, type, onComplete) + animateColor({ borderColor }, { borderColor = it }, targetBorder, duration, type) + } + is Text -> { + val target = (textColor and 0x00FFFFFF) or (255 shl 24) + textColor = textColor and 0x00FFFFFF + animateColor({ textColor }, { textColor = it }, target, duration, type, onComplete) + } + is SvgImage -> { + val target = (color and 0x00FFFFFF) or (255 shl 24) + animateColor({ color }, { setSvgColor(it) }, target, duration, type, onComplete) + } + else -> animateFloat({ 0f }, {}, 1f, duration, type, AnimationType.ALPHA, onComplete) + } + + return this +} + +fun > T.fadeOut( + duration: Long = 300, + type: EasingType = EasingType.EASE_IN, + includeChildren: Boolean = true, + onComplete: (() -> Unit)? = null +): T { + if (includeChildren) { + children.forEach { child -> + when (child) { + is Rectangle -> child.fadeOut(duration, type, includeChildren) + is Text -> child.fadeOut(duration, type, includeChildren) + is SvgImage -> child.fadeOut(duration, type, includeChildren) + } + } + } + + when (this) { + is Rectangle -> { + animateColor({ backgroundColor }, { backgroundColor = it }, backgroundColor and 0x00FFFFFF, duration, type) { + visible = false + onComplete?.invoke() + } + animateColor({ borderColor }, { borderColor = it }, borderColor and 0x00FFFFFF, duration, type) + } + is Text -> { + animateColor({ textColor }, { textColor = it }, textColor and 0x00FFFFFF, duration, type) { + visible = false + onComplete?.invoke() + } + } + is SvgImage -> { + animateColor({ color }, { setSvgColor(it) }, color and 0x00FFFFFF, duration, type) { + visible = false + onComplete?.invoke() + } + } + else -> animateFloat({ 1f }, {}, 0f, duration, type, AnimationType.ALPHA) { + visible = false + onComplete?.invoke() + } + } + + return this +} + +fun > T.moveTo( + x: Float, + y: Float, + duration: Long = 500, + type: EasingType = EasingType.EASE_OUT, + onComplete: (() -> Unit)? = null +): T { + animatePosition(x, y, duration, type, onComplete) + return this +} + +fun > T.scaleTo( + width: Float, + height: Float, + duration: Long = 300, + type: EasingType = EasingType.EASE_OUT, + onComplete: (() -> Unit)? = null +): T { + animateSize(width, height, duration, type, onComplete) + return this +} + +fun > T.colorTo( + color: Int, + duration: Long = 300, + type: EasingType = EasingType.LINEAR, + onComplete: (() -> Unit)? = null +): T { + when (this) { + is Rectangle -> animateColor({ backgroundColor }, { backgroundColor = it }, color, duration, type, onComplete) + is Text -> animateColor({ textColor }, { textColor = it }, color, duration, type, onComplete) + is SvgImage -> animateColor({ this.color }, { setSvgColor(it) }, color, duration, type, onComplete) + } + return this +} + +fun > T.slideIn( + fromX: Float = -width, + fromY: Float = 0f, + duration: Long = 500, + type: EasingType = EasingType.EASE_OUT, + onComplete: (() -> Unit)? = null +): T { + if (presetOriginalPosition == null) presetOriginalPosition = xConstraint to yConstraint + + val (targetX, targetY) = presetOriginalPosition!! + xConstraint = fromX + yConstraint = fromY + visible = true + + animatePosition(targetX, targetY, duration, type, onComplete) + return this +} + +fun > T.bounceScale( + scale: Float = 1.2f, + duration: Long = 200, + onComplete: (() -> Unit)? = null +): T { + AnimationManager.stopAnimations("${hashCode()}:size", AnimationType.SIZE) + if (presetOriginalSize == null) presetOriginalSize = width to height + + val (originalWidth, originalHeight) = presetOriginalSize!! + + animateSize(originalWidth * scale, originalHeight * scale, duration / 2, EasingType.EASE_OUT) { + animateSize(originalWidth, originalHeight, duration / 2, EasingType.EASE_IN, onComplete) + } + + return this +} diff --git a/src/main/kotlin/xyz/meowing/lattice/client/Chat.kt b/src/main/kotlin/xyz/meowing/lattice/client/Chat.kt new file mode 100644 index 0000000..7bd9b8e --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/client/Chat.kt @@ -0,0 +1,51 @@ +package xyz.meowing.lattice.client + +import net.minecraft.network.chat.Component +import xyz.meowing.lattice.client.Client.minecraft +import xyz.meowing.lattice.client.Client.player +import xyz.meowing.lattice.text.TextBuilder +import kotlin.math.roundToInt + +object Chat { + private const val CHAT_WIDTH = 320 + + @JvmStatic + fun sendMessage(message: String) { + player?.connection?.sendChat(message) + } + + @JvmStatic + fun sendCommand(command: String) { + player?.connection?.sendCommand(command.removePrefix("/")) + } + + @JvmStatic + fun fakeMessage(message: Component) { + minecraft.gui.getChat().addClientSystemMessage(message) + } + + @JvmStatic + fun fakeMessage(message: String) { + fakeMessage(Component.literal(message)) + } + + @JvmStatic + fun fakeMessage(message: TextBuilder) { + fakeMessage(message.toVanilla()) + } + + @JvmStatic + fun getChatBreak(): String { + val dashWidth = minecraft.font.width("-") + return "-".repeat(CHAT_WIDTH / dashWidth) + } + + @JvmStatic + fun getCenteredText(text: String): String { + val textWidth = minecraft.font.width(text) + if (textWidth >= CHAT_WIDTH) return text + val spaceWidth = minecraft.font.width(" ") + val padding = ((CHAT_WIDTH - textWidth) / 2f / spaceWidth).roundToInt() + return " ".repeat(padding) + text + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/client/Client.kt b/src/main/kotlin/xyz/meowing/lattice/client/Client.kt new file mode 100644 index 0000000..0bc62cf --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/client/Client.kt @@ -0,0 +1,73 @@ +package xyz.meowing.lattice.client + +import net.minecraft.SharedConstants +import net.minecraft.client.Minecraft +import net.minecraft.client.multiplayer.ClientLevel +import net.minecraft.client.multiplayer.PlayerInfo +import net.minecraft.client.player.LocalPlayer +import net.minecraft.network.chat.Component +import net.minecraft.world.level.GameType +import net.minecraft.world.scores.DisplaySlot +import java.nio.file.Path + +object Client { + private val tabListComparator: Comparator = compareBy( + { it.getGameMode() == GameType.SPECTATOR }, + { it.getTeam()?.name ?: "" }, + { it.getProfile().name.lowercase() } + ) + + @JvmStatic + val minecraft: Minecraft = Minecraft.getInstance() + + @JvmStatic + val world: ClientLevel? get() = minecraft.level + + @JvmStatic + val player: LocalPlayer? get() = minecraft.player + + @JvmStatic + val minecraftVersion: String by lazy { + SharedConstants.getCurrentVersion().name() + } + + @JvmStatic + val gameDirectory: Path get() = minecraft.gameDirectory.toPath() + + @JvmStatic + val configDirectory: Path get() = gameDirectory.resolve("config") + + @JvmStatic + val tablist: List + get() = minecraft.getConnection() + ?.getListedOnlinePlayers() + ?.sortedWith(tabListComparator) ?: emptyList() + + @JvmStatic + val players: List + get() = tablist.filter { it.getProfile().id.version() == 4 } + + @JvmStatic + val scoreboard: Collection + get() { + val scoreboard = world?.scoreboard ?: return emptyList() + val objective = scoreboard.getDisplayObjective(DisplaySlot.SIDEBAR) ?: return emptyList() + return scoreboard.listPlayerScores(objective) + .sortedBy { -it.value() } + .map { + val ownerName = Component.literal(it.owner()) + val team = scoreboard.getPlayerTeam(it.owner()) + if (team == null) { + ownerName.copy() + } else { + Component.empty().also { main -> + main.append(team.getPlayerPrefix()) + if (ownerName.string.isNotEmpty()) main.append(ownerName) + main.append(team.getPlayerSuffix()) + } + } + } + } + + val scoreboardTitle get() = world?.scoreboard?.getDisplayObjective(DisplaySlot.SIDEBAR)?.displayName +} diff --git a/src/main/kotlin/xyz/meowing/lattice/client/Clipboard.kt b/src/main/kotlin/xyz/meowing/lattice/client/Clipboard.kt new file mode 100644 index 0000000..a588bb8 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/client/Clipboard.kt @@ -0,0 +1,11 @@ +package xyz.meowing.lattice.client + +import org.lwjgl.glfw.GLFW +import xyz.meowing.lattice.client.Client.minecraft + +object Clipboard { + @JvmStatic + var string: String + get() = GLFW.glfwGetClipboardString(minecraft.window.handle()) ?: "" + set(value) { GLFW.glfwSetClipboardString(minecraft.window.handle(), value) } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/client/Desktop.kt b/src/main/kotlin/xyz/meowing/lattice/client/Desktop.kt new file mode 100644 index 0000000..c732e93 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/client/Desktop.kt @@ -0,0 +1,23 @@ +package xyz.meowing.lattice.client + +object Desktop { + @JvmStatic + val isWindows: Boolean + + @JvmStatic + val isMac: Boolean + + @JvmStatic + val isLinux: Boolean + + @JvmStatic + val isWayland: Boolean + + init { + val osName = System.getProperty("os.name")?.lowercase() ?: "" + isWindows = osName.startsWith("windows") + isMac = osName.startsWith("mac") + isLinux = osName.startsWith("linux") + isWayland = isLinux && !System.getenv("WAYLAND_DISPLAY").isNullOrEmpty() + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/client/Loader.kt b/src/main/kotlin/xyz/meowing/lattice/client/Loader.kt new file mode 100644 index 0000000..9e27f2a --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/client/Loader.kt @@ -0,0 +1,21 @@ +package xyz.meowing.lattice.client + +import net.fabricmc.loader.api.FabricLoader +import net.fabricmc.loader.api.ModContainer + +object Loader { + @JvmStatic + val isDevelopment: Boolean + get() = FabricLoader.getInstance().isDevelopmentEnvironment + + @JvmStatic + val mods: List + get() = FabricLoader.getInstance().allMods.toList() + + @JvmStatic + fun isLoaded(id: String): Boolean = FabricLoader.getInstance().isModLoaded(id) + + @JvmStatic + fun findMod(id: String): ModContainer? = + FabricLoader.getInstance().getModContainer(id).orElse(null) +} diff --git a/src/main/kotlin/xyz/meowing/lattice/client/Player.kt b/src/main/kotlin/xyz/meowing/lattice/client/Player.kt new file mode 100644 index 0000000..9771e7c --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/client/Player.kt @@ -0,0 +1,23 @@ +package xyz.meowing.lattice.client + +import net.minecraft.client.player.LocalPlayer +import net.minecraft.world.item.ItemStack +import xyz.meowing.lattice.client.Client.minecraft + +object Player { + @JvmStatic + val player: LocalPlayer? get() = minecraft.player + + @JvmStatic + val name: String? get() = player?.name?.string + + @JvmStatic + val armor: Array + get() { + val inv = player?.inventory ?: return arrayOf(null, null, null, null) + return arrayOf(inv.getItem(36), inv.getItem(37), inv.getItem(38), inv.getItem(39)) + } + + @JvmStatic + val heldItem: ItemStack? get() = player?.mainHandItem +} diff --git a/src/main/kotlin/xyz/meowing/lattice/command/Commodore.kt b/src/main/kotlin/xyz/meowing/lattice/command/Commodore.kt new file mode 100644 index 0000000..e96d19c --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/Commodore.kt @@ -0,0 +1,82 @@ +@file:Suppress("UNUSED", "UNCHECKED_CAST") + +package xyz.meowing.lattice.command + +import xyz.meowing.lattice.command.nodes.Executable +import xyz.meowing.lattice.command.nodes.LiteralNode +import com.mojang.brigadier.CommandDispatcher +import com.mojang.brigadier.builder.LiteralArgumentBuilder.literal + +/** + * # Commodore + * + * A multi-version command library that simplifies the creation of command trees. + * It leverages powerful Kotlin DSL features and reflection to easily build + * complex command structures, streamlining the use of [Mojang's Brigadier](https://github.com/Mojang/brigadier). + * + * The command tree is constructed from a root [LiteralNode], with subsequent branches + * formed by additional nodes. + * The tree must always end in an [Executable][xyz.meowing.lattice.command.nodes.Executable], + * which serves as an exit point for a command. + */ +open class Commodore(private val root: LiteralNode) { + + constructor( + vararg name: String, + block: LiteralNode.() -> Unit + ) : this(LiteralNode(name[0], name.drop(1))) { + root.block() + } + + constructor( + vararg name: String + ) : this(LiteralNode(name[0], name.drop(1))) + + /** + * DSL access to the root node for object-style definitions. + */ + fun runs(function: Function) = root.runs(function) + + fun runs(block: () -> Unit) = root.runs(block) + + fun literal(string: String, block: LiteralNode.() -> Unit = {}): LiteralNode { + return root.literal(string, block) + } + + fun literal(vararg names: String, block: LiteralNode.() -> Unit = {}): LiteralNode { + return root.literal(*names, block = block) + } + + fun executable(block: Executable.() -> Unit): Executable { + return root.executable(block) + } + + /** + * Registers this command to a dispatcher. + * + * For versions that support Brigadier (>=1.13), register the command to a dispatcher using: + * ```kotlin + * command.register(dispatcher) + * ``` + * + * For legacy versions, simply call the function and specify an error callback: + * ```kotlin + * command.register { problem: String, cause: LiteralNode -> + * println("$problem") + * } + * ``` + */ + fun register(dispatcher: CommandDispatcher<*>) { + for (node in root.children) { + node.build(root) + } + (dispatcher as CommandDispatcher).register(root.builder) + val rootCommand = root.builder.build() + for (alias in root.aliases) { + val aliasBuilder = literal(alias) + if (rootCommand.command != null) aliasBuilder.executes(rootCommand.command) + aliasBuilder.redirect(rootCommand) + dispatcher.register(aliasBuilder) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/command/functions/FunctionInvoker.kt b/src/main/kotlin/xyz/meowing/lattice/command/functions/FunctionInvoker.kt new file mode 100644 index 0000000..33f7401 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/functions/FunctionInvoker.kt @@ -0,0 +1,90 @@ +package xyz.meowing.lattice.command.functions + +import java.lang.invoke.MethodHandle +import java.lang.invoke.MethodHandles + +/** + * # FunctionInvoker + * + * Interface to simplify invoking functions, using Java reflection. + * + * @see LambdaInvoker + * @author Stivais + */ +sealed interface FunctionInvoker { + + /** + * List of parameters from the function. + */ + val parameters: List> + + /** + * Invokes the function, with a list of arguments. + * + * This can error, due to incorrect input. + */ + fun invoke(arguments: List): T + + /** + * [FunctionInvoker] implementation, that is used for anonymous [functions][Function], like lambdas. + * + * This class uses [MethodHandle] to invoke the function. + * + * It is recommended to use [FunctionInvoker.from] instead of directly initializing this class. + */ + class LambdaInvoker internal constructor( + lambda: Function, + parameterTypes: Array> + ) : FunctionInvoker { + override val parameters: List> + + /** + * Used for invoking the function. + */ + private val mHandle: MethodHandle + + init { + try { + val lambdaClass = lambda.javaClass + + val invokeMethod = lambdaClass.declaredMethods.firstOrNull { method -> + !method.isSynthetic && + !method.isBridge && + method.name != "equals" && + method.name != "hashCode" && + method.name != "toString" + } ?: throw IllegalStateException("No invoke method found in lambda") + + if (!invokeMethod.isAccessible) invokeMethod.isAccessible = true + mHandle = MethodHandles.lookup().unreflect(invokeMethod).bindTo(lambda) + + if (parameterTypes.isEmpty()) { + parameters = invokeMethod.parameterTypes.mapIndexed { index, type -> + Parameter("param$index", type, false) + } + } else { + parameters = parameterTypes.mapIndexed { index, type -> + Parameter("param$index", type, false) + } + } + } catch (e: Exception) { + throw Exception("[Lattice-Commodore] Error creating Function Invoker.", e) + } + } + + @Suppress("UNCHECKED_CAST") + override fun invoke(arguments: List): T { + return mHandle.invokeWithArguments(arguments) as T + } + } + + companion object { + /** + * Creates a function invoker with explicit parameter types. + * If no parameter types are provided, will attempt to extract them from the lambda's method signature. + */ + fun from(function: Function, vararg parameterTypes: Class<*>): FunctionInvoker { + return LambdaInvoker(function, parameterTypes) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/command/functions/Parameter.kt b/src/main/kotlin/xyz/meowing/lattice/command/functions/Parameter.kt new file mode 100644 index 0000000..34b499d --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/functions/Parameter.kt @@ -0,0 +1,9 @@ +package xyz.meowing.lattice.command.functions + +/** + * Represents a parameter for a function. + * + * @see FunctionInvoker + * @author Stivais + */ +data class Parameter(val name: String, val type: Class, val isNullable: Boolean) diff --git a/src/main/kotlin/xyz/meowing/lattice/command/nodes/Executable.kt b/src/main/kotlin/xyz/meowing/lattice/command/nodes/Executable.kt new file mode 100644 index 0000000..4708a05 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/nodes/Executable.kt @@ -0,0 +1,179 @@ +@file:Suppress("UNUSED") + +package xyz.meowing.lattice.command.nodes + +import xyz.meowing.lattice.command.functions.FunctionInvoker +import xyz.meowing.lattice.command.parsers.CommandParser +import xyz.meowing.lattice.command.parsers.CommandParser.Companion.getParser +import xyz.meowing.lattice.command.parsers.ParserArgumentType +import xyz.meowing.lattice.command.parsers.impl.FunctionParser +import com.mojang.brigadier.Command + +/** + * # Executable + * + * This [Node] implementation, acts as an exit point for a command tree. + * + * It takes a function's (or lambda's) parameters with reflection, + * to create [parsers][CommandParser] that are used to read a string. + * + * You can define custom parsers for certain parameters using [param] before the function is defined with [runs]. + * For it to build correctly, you must have a function defined with [runs]. + * + * @author Stivais + */ +class Executable : Node() { + + /** + * [FunctionInvoker], representing this Executable's function. + */ + private lateinit var funInvoker: FunctionInvoker + + /** + * List of [ParserArgumentType], which retrieves data to invoke [funInvoker]. + */ + val parsers: ArrayList> = arrayListOf() + + /** + * Map, where key is the parameter name and value is the [parameter modifier][ParameterModifier]. + * + * This gets cleared once the function is defined using [runs]. + */ + private var parameterModifiers = mutableMapOf() + + /** + * Defines a function for this executable. + * + * This is primarily intended to be used with lambda like: + * ```kotlin + * runs { x: Float, y: Double, z: Int -> println("$x $y $z") } + * ``` + * however it can be used with a function like: + * ```kotlin + * fun example(x: Float, y: Double, z: Int) { + * println("$x $y $z") + * } + * runs(::example) + * ``` + * + * The function's parameters are used as command inputs. + * The parameter must have a [CommandParser] available. + * + * Classes that already have a parser provided: [String], [GreedyString][xyz.meowing.lattice.command.utils.GreedyString], + * [Int], [Long], [Float], [Double], [Boolean]. + * + * To create custom parsers, + * you can either: + * + * - Apply the [CommandParsable][xyz.meowing.lattice.command.parsers.CommandParsable] + * annotation to class for it to generate a parser using the parameters of the primary constructor. + * (All parameters for that class must be also parsable). + * + * - Use a [ParameterModifier] to apply a custom parser to a certain parameter. + * You need to ensure you're parsing for the correct class. + */ + fun runs(function: Function) { + funInvoker = FunctionInvoker.from(function) + + for (parameter in funInvoker.parameters) { + val modifier = parameterModifiers[parameter.name] + val parser = modifier?.customParser ?: getParser(parameter.type) + if (parser == null) { + throw IllegalStateException( + "No parser found for parameter: ${parameter.name}(type = ${parameter.type}). " + + "Consider creating a parser for ${parameter.type} or applying @CommandParsable annotation to that class if possible." + ) + } + val argumentType = ParserArgumentType(parameter, parser) + if (modifier != null) argumentType.suggestionCallback = modifier.suggestionCallback + parsers.add(argumentType) + } + + // not used after it is built, so no reason to keep the data. + parameterModifiers.clear() + } + + override fun build(parent: LiteralNode) { + if (!::funInvoker.isInitialized) { + throw IllegalStateException( + "Executable for ${parent.name} must have a defined function to work." + ) + } + + val brigadierCommand = Command { ctx -> + funInvoker.invoke(List(parsers.size) { index -> + parsers[index].getValue(ctx) } + ) + // return value is supposed to represent success level of a command. + // however, im not sure if returning a different actually changes anything. + Command.SINGLE_SUCCESS + } + + val allOptional = parsers.reversed().all { parser -> + parser.builder.executes(brigadierCommand) + parser.optional() + } + if (allOptional) parent.builder.executes(brigadierCommand) + + // has to be done in reverse unfortunately + for (i in parsers.size - 1 downTo 0) { + val parser = parsers[i] + val nextBuilder = if (i == 0) parent.builder else parsers[i - 1].builder + nextBuilder.then(parser.builder) + } + } + + /** + * Creates and adds a modifier for the parameter with the specified [name]. + */ + fun param(name: String): ParameterModifier { + val modifier = ParameterModifier() + parameterModifiers[name] = modifier + return modifier + } + + /** + * Creates and adds a modifier for the parameter with the specified [name]. + */ + inline fun param(name: String, block: ParameterModifier.() -> Unit): ParameterModifier { + val param = param(name) + param.block() + return param + } + + /** + * ## ParameterModifier + * + * A modifier is used to apply either a custom parser, and or custom suggestions to a parameter. + * + * @see Executable + */ + class ParameterModifier( + var customParser: CommandParser<*>? = null, + var suggestionCallback: (() -> Collection)? = null + ) { + /** + * Specifies a custom parser for a parameter. + * + * Make sure the value it returns is same as parameter you're applying this parser to. + */ + fun parser(function: Function) { + customParser = FunctionParser(function) + } + + /** + * Adds a callback to give mutable suggestions for a parameter. + */ + fun suggests(callback: () -> Collection) { + suggestionCallback = callback + } + + /** + * Adds constant suggestions for a parameter. + */ + fun suggests(vararg option: String) { + val list = option.toList() + suggestionCallback = { list } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/command/nodes/LiteralNode.kt b/src/main/kotlin/xyz/meowing/lattice/command/nodes/LiteralNode.kt new file mode 100644 index 0000000..40bd20d --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/nodes/LiteralNode.kt @@ -0,0 +1,135 @@ +@file:Suppress("UNUSED", "MemberVisibilityCanBePrivate") + +package xyz.meowing.lattice.command.nodes + +import com.mojang.brigadier.Command.SINGLE_SUCCESS +import com.mojang.brigadier.builder.LiteralArgumentBuilder +import com.mojang.brigadier.builder.LiteralArgumentBuilder.literal + +/** + * # LiteralNode + * + * Represents a node within a [Commodore][xyz.meowing.lattice.command.Commodore] command tree. + * + * A `LiteralNode` can either branch into more nodes that progress down the tree + * or contain an [Executable], which acts as an exit-point for the command, which executes a function. + * + * The input for this node is its [name] (and any defined aliases), and it is used + * for fixed, non-varying input in a command structure. + */ +open class LiteralNode(val name: String) : Node() { + + constructor(name: String, aliases: List) : this(name) { + this.aliases = aliases + } + + /** + * Aliases for this current node. + */ + var aliases: List = listOf() + + /** + * Nodes, which branch from the current one. + */ + var children: ArrayList = arrayListOf() + + /** + * Brigadier builder, representing this node. + * + * Used to construct/finalize the command tree and make it usable on top of brigadier. + */ + val builder: LiteralArgumentBuilder = literal(name) + + /** + * Sets up the node and its children + * + * NOTE: Once you build, you are unable to make any changes to the command-tree. + */ + override fun build(parent: LiteralNode) { + for (node in children) { + node.build(this) + } + + parent.builder.then(builder) + val builtNode = builder.build() + + for (alias in aliases) { + val aliasBuilder = literal(alias).redirect(builtNode) + parent.builder.then(aliasBuilder) + } + } + + /** + * Creates a new literal node, branching from the current one. + */ + fun literal(string: String, block: LiteralNode.() -> Unit = {}): LiteralNode { + val node = LiteralNode(string) + node.block() + return addNode(node) + } + + /** + * Creates a new literal node, branching from the current one. + * + * @param names vararg for the name and aliases of the node. + */ + fun literal(vararg names: String, block: LiteralNode.() -> Unit = {}): LiteralNode { + val node = LiteralNode(names[0], names.drop(1)) + node.block() + return addNode(node) + } + + /** + * DSL for [literal] using operator overloading. + * + * Example: + * ``` + * "node" { /* ... */ } + * // is the same as + * literal("node") { /* ... */ } + * ``` + */ + operator fun String.invoke(block: LiteralNode.() -> Unit = {}): LiteralNode { + return literal(this, block = block) + } + + /** + * Creates an [Executable] and apply function directly. + * + * This won't allow you to apply [modifiers][Executable.ParameterModifier] + * to the function's parameters. + * + * @see runs + */ + fun runs(function: Function) = executable { + runs(function) + } + + /** + * Create and adds a [Executable] node to the current literal node. + * + * @see Executable + * @see runs + */ + fun executable(block: Executable.() -> Unit): Executable { + val executable = Executable() + executable.block() + return addNode(executable) + } + + /** + * Faster alternative to [runs], for lambdas which don't have any parameters. + */ + inline fun runs(crossinline block: () -> Unit) { + builder.executes { + block() + SINGLE_SUCCESS + } + } + + private fun addNode(node: N): N { + children.add(node) + node.parent = this + return node + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/command/nodes/Node.kt b/src/main/kotlin/xyz/meowing/lattice/command/nodes/Node.kt new file mode 100644 index 0000000..4712b94 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/nodes/Node.kt @@ -0,0 +1,22 @@ +package xyz.meowing.lattice.command.nodes + +/** + * # Node + * + * Base class for all nodes for [Commodore][xyz.meowing.lattice.command.Commodore]. + * + * @see LiteralNode + * @see Executable + * @author Stivais + */ +abstract class Node { + + /** + * This node's parent. + * + * This will always be a [literal node][LiteralNode], since [Executable] can never have children nodes. + */ + var parent: LiteralNode? = null + + internal abstract fun build(parent: LiteralNode) +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/command/parsers/CommandParsable.kt b/src/main/kotlin/xyz/meowing/lattice/command/parsers/CommandParsable.kt new file mode 100644 index 0000000..10e6644 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/parsers/CommandParsable.kt @@ -0,0 +1,10 @@ +package xyz.meowing.lattice.command.parsers + +/** + * # CommandParsable + * + * This annotation indicates that a parser should attempt to be generated, based on the primary constructor parameters. + * If a parameter doesn't have a parser it will fail to generate. + * @author Stivais + */ +annotation class CommandParsable // todo: suggestions as a parameter in array diff --git a/src/main/kotlin/xyz/meowing/lattice/command/parsers/CommandParser.kt b/src/main/kotlin/xyz/meowing/lattice/command/parsers/CommandParser.kt new file mode 100644 index 0000000..63a063f --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/parsers/CommandParser.kt @@ -0,0 +1,141 @@ +@file:Suppress("UNCHECKED_CAST") + +package xyz.meowing.lattice.command.parsers + +import com.mojang.brigadier.StringReader +import xyz.meowing.lattice.command.parsers.impl.BooleanParser +import xyz.meowing.lattice.command.parsers.impl.DoubleParser +import xyz.meowing.lattice.command.parsers.impl.FloatParser +import xyz.meowing.lattice.command.parsers.impl.FunctionParser +import xyz.meowing.lattice.command.parsers.impl.GreedyStringParser +import xyz.meowing.lattice.command.parsers.impl.IntParser +import xyz.meowing.lattice.command.parsers.impl.LongParser +import xyz.meowing.lattice.command.parsers.impl.StringParser +import xyz.meowing.lattice.command.utils.GreedyString +import xyz.meowing.lattice.command.utils.SyntaxException +import java.util.* + +/** + * # CommandParser + * + * This interface is responsible for reading a string input and parsing it into a valid output + * that can be used for an [Executable][xyz.meowing.lattice.command.nodes.Executable]. + * + * Classes with pre-defined parsers include: [String], [GreedyString], [Int], [Long], [Float], + * [Double], and [Boolean]. + * + * @author Stivais + */ +interface CommandParser { + + /** + * Uses Brigadiers [StringReader] to parse an output. + */ + fun parse(reader: StringReader): T + + /** + * Used to provide a collection of suggested strings for command completion. + */ + fun suggestions(): Collection = Collections.emptyList() + + // im not quite sure what it does in modern versions so ill keep this here just in case it has a use + fun examples(): Collection = Collections.emptyList() + + companion object { + /** + * Map of available parsers. + * + * Key == Class, Value == Parser for the class. + */ + private val parsers: MutableMap> = mutableMapOf( + String::class.java to StringParser, + GreedyString::class.java to GreedyStringParser, + Integer::class.java to IntParser, Int::class.java to IntParser, + java.lang.Long::class.java to LongParser, Long::class.java to LongParser, + java.lang.Float::class.java to FloatParser, Float::class.java to FloatParser, + java.lang.Double::class.java to DoubleParser, Double::class.java to DoubleParser, + java.lang.Boolean::class.java to BooleanParser, Boolean::class.java to BooleanParser + ) + + /** + * Registers a [parser builder][CommandParser] to the [custom parsers map][parsers]]. + * + * @param clazz the key for the map + * @param builder the parser builder + */ + fun registerParser(clazz: Class, builder: CommandParser) { + parsers[clazz] = builder + } + + /** + * Retrieves a parser for the given class type. + * + * If a parser for the class is already registered, it returns the existing parser. + * If no parser is found but the class is annotated with [CommandParsable], + * it attempts to create a parser using the primary constructor of the class. + * All parameters of the constructor must also be parsable. + * + * @throws IllegalArgumentException if the class annotated with [CommandParsable] + * does not have a primary constructor or if its parameters are not parsable + */ + fun getParser(type: Class<*>): CommandParser<*>? { + parsers[type]?.let { return it } + + val annotation = type.getAnnotation(CommandParsable::class.java) + if (annotation != null) { + if (type.isEnum) { + val parser = object : CommandParser { + val enumMap = buildMap { + for (constant in type.enumConstants) { + val str = constant.toString().lowercase().replace("_", " ") + put(str, constant) + } + } + override fun parse(reader: StringReader): Any { + val builder = StringBuilder() + while (reader.canRead()) { + builder.append(reader.read()) + val value = enumMap[builder.toString()] + if (value != null) return value + } + throw SyntaxException( + //#if MC == 1.8.9 + //$$ "Invalid argument (valid arguments: ${enumMap.keys.joinToString(separator = ", ") { it }}) for command" + //#else + "Invalid argument for command" + //#endif + ) + } + + override fun suggestions(): Collection { + return enumMap.keys + } + } + registerParser( + type as Class, + parser as CommandParser + ) + return parser + } else { + try { + val constructors = type.constructors + if (constructors.isEmpty()) throw IllegalArgumentException("No constructor found") + + val constructor = constructors[0] + val parser = FunctionParser { args: List -> + constructor.newInstance(*args.toTypedArray()) + } + registerParser( + type as Class, + parser as CommandParser + ) + return parser + } catch (e: Exception) { + throw IllegalArgumentException("Failed to create parser for $type", e) + } + } + } + return null + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/command/parsers/ParserArgumentType.kt b/src/main/kotlin/xyz/meowing/lattice/command/parsers/ParserArgumentType.kt new file mode 100644 index 0000000..d130ca0 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/parsers/ParserArgumentType.kt @@ -0,0 +1,86 @@ +package xyz.meowing.lattice.command.parsers + +import xyz.meowing.lattice.command.functions.Parameter +import com.mojang.brigadier.StringReader +import com.mojang.brigadier.arguments.ArgumentType +import com.mojang.brigadier.builder.RequiredArgumentBuilder +import com.mojang.brigadier.builder.RequiredArgumentBuilder.argument +import com.mojang.brigadier.context.CommandContext +import com.mojang.brigadier.exceptions.CommandExceptionType +import com.mojang.brigadier.exceptions.CommandSyntaxException +import com.mojang.brigadier.suggestion.Suggestions +import com.mojang.brigadier.suggestion.SuggestionsBuilder +import xyz.meowing.lattice.command.utils.SyntaxException +import java.util.concurrent.CompletableFuture + +/** + * Implementation of [ArgumentType], to integrate into Brigadier. + * + * @see CommandParser + * @author Stivais + */ +class ParserArgumentType( + private val parameter: Parameter, + parser: CommandParser<*> +) : ArgumentType { + + /** + * Parser for this argument type. + */ + @Suppress("UNCHECKED_CAST") + private val parser: CommandParser = parser as CommandParser + + /** + * The [Argument builder][RequiredArgumentBuilder] tied to this class + */ + val builder: RequiredArgumentBuilder = argument(parameter.name, this) + + /** + * Callback for getting suggestions for this parameter. + */ + var suggestionCallback : (() -> Collection)? = null + + override fun parse(reader: StringReader): T { + try { + return parser.parse(reader) + } catch (e: SyntaxException) { + throw CommandSyntaxException( + object : CommandExceptionType {}, + { e.message }, + reader.string, + reader.cursor + ) + } + } + + override fun listSuggestions(context: CommandContext, builder: SuggestionsBuilder): CompletableFuture { + val suggestions = suggestionCallback?.invoke() ?: parser.suggestions() + for (str in suggestions) { + if (str.startsWith(builder.remaining)) { + builder.suggest(str) + } + } + return builder.buildFuture() + } + + /** + * Gets the value from the [context][CommandContext] + * + * @return the value or null if it failed. + */ + fun getValue(ctx: CommandContext): T? = try { + ctx.getArgument(parameter.name, parameter.type) + } catch (_: IllegalArgumentException) { + null + } + + fun name() = parameter.name + + /** + * Checks if this parameter is optional, + * meaning it isn't required for an [Executable][xyz.meowing.lattice.command.nodes.Executable], to run. + * + * A parameter is optional if it is nullable. + */ + fun optional() = parameter.isNullable +} diff --git a/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/BooleanParsers.kt b/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/BooleanParsers.kt new file mode 100644 index 0000000..03b2a04 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/BooleanParsers.kt @@ -0,0 +1,13 @@ +package xyz.meowing.lattice.command.parsers.impl + +import xyz.meowing.lattice.command.parsers.CommandParser +import com.mojang.brigadier.StringReader + +/** + * @author Stivais + */ +object BooleanParser : CommandParser { + override fun parse(reader: StringReader): Boolean { + return reader.readBoolean() + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/FunctionParser.kt b/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/FunctionParser.kt new file mode 100644 index 0000000..2ab151f --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/FunctionParser.kt @@ -0,0 +1,82 @@ +package xyz.meowing.lattice.command.parsers.impl + +import xyz.meowing.lattice.command.functions.FunctionInvoker +import xyz.meowing.lattice.command.parsers.CommandParser +import xyz.meowing.lattice.command.parsers.CommandParser.Companion.getParser +import com.mojang.brigadier.StringReader + +/** + * ## FunctionParser + * + * A parser that uses a provided function to process input parameters and output a value. + * The function's parameters define the expected input types, and its result is returned + * after processing the input. + * + * Example: + * ```kotlin + * // This command expects two float values. If they are the same, it throws an error. + * // The function outputs a Pair if valid. + * parser { x: Float, y: Float -> + * if (x == y) throw SyntaxException("Values cannot be equal") + * Pair(x, y) + * } + * ``` + * + * Unlike an [Executable][xyz.meowing.lattice.command.nodes.Executable] function, the parameters cannot be optional. + * + * @author Stivais + */ +class FunctionParser : CommandParser { + private val funInvoker: FunctionInvoker? + private val constructorInvoker: ((List) -> T)? + private val parsers: ArrayList> = arrayListOf() + + constructor(function: Function, vararg parameterTypes: Class<*>) { + funInvoker = if (parameterTypes.isEmpty()) { + FunctionInvoker.from(function) + } else { + FunctionInvoker.from(function, *parameterTypes) + } + constructorInvoker = null + initParsers() + } + + constructor(invoker: (List) -> T) { + funInvoker = null + constructorInvoker = invoker + + val constructor = invoker.javaClass.enclosingClass?.constructors?.firstOrNull() + ?: throw IllegalStateException("Cannot find constructor") + + for (param in constructor.parameters) { + val parser = getParser(param.type) + ?: throw IllegalStateException("No parser found for parameter: ${param.name}(type=${param.type})") + parsers.add(parser) + } + + require(parsers.isNotEmpty()) { "You need at least one parameter in the function for this parser." } + } + + private fun initParsers() { + for (parameter in funInvoker!!.parameters) { + val parser = getParser(parameter.type) + ?: throw IllegalStateException("No parser found for parameter: ${parameter.name}(type=${parameter.type})") + parsers.add(parser) + } + require(parsers.isNotEmpty()) { "You need at least one parameter in the function for this parser." } + } + + override fun parse(reader: StringReader): T { + val arguments = mutableListOf() + for (parser in parsers) { + reader.skipWhitespace() + arguments.add(parser.parse(reader)) + } + + return if (funInvoker != null) { + funInvoker.invoke(arguments) + } else { + constructorInvoker!!(arguments) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/NumberParsers.kt b/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/NumberParsers.kt new file mode 100644 index 0000000..c4e2e8a --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/NumberParsers.kt @@ -0,0 +1,40 @@ +package xyz.meowing.lattice.command.parsers.impl + +import xyz.meowing.lattice.command.parsers.CommandParser +import com.mojang.brigadier.StringReader + +/** + * @author Stivais + */ +object LongParser : CommandParser { + override fun parse(reader: StringReader): Long { + return reader.readLong() + } +} + +/** + * @author Stivais + */ +object IntParser : CommandParser { + override fun parse(reader: StringReader): Int { + return reader.readInt() + } +} + +/** + * @author Stivais + */ +object FloatParser : CommandParser { + override fun parse(reader: StringReader): Float { + return reader.readFloat() + } +} + +/** + * @author Stivais + */ +object DoubleParser : CommandParser { + override fun parse(reader: StringReader): Double { + return reader.readDouble() + } +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/StringParsers.kt b/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/StringParsers.kt new file mode 100644 index 0000000..225cc70 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/parsers/impl/StringParsers.kt @@ -0,0 +1,25 @@ +package xyz.meowing.lattice.command.parsers.impl + +import xyz.meowing.lattice.command.parsers.CommandParser +import com.mojang.brigadier.StringReader +import xyz.meowing.lattice.command.utils.GreedyString + +/** + * @author Stivais + */ +object GreedyStringParser : CommandParser { + override fun parse(reader: StringReader): GreedyString { + val string = reader.remaining + reader.cursor = reader.totalLength + return GreedyString(string) + } +} + +/** + * @author Stivais + */ +object StringParser : CommandParser { + override fun parse(reader: StringReader): String { + return reader.readString() + } +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/command/utils/GreedyString.kt b/src/main/kotlin/xyz/meowing/lattice/command/utils/GreedyString.kt new file mode 100644 index 0000000..008106a --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/utils/GreedyString.kt @@ -0,0 +1,22 @@ +package xyz.meowing.lattice.command.utils + +/** + * Used as a command argument, that takes all remaining strings from the input + * ``` + * runs { greedy: GreedyString -> + * println("$greedy") // when given the input of "hello world" it accepts it and prints "hello world" + * } + * + * runs { string: String -> + * println("$string") // when given the input of "hello world" it doesn't accept it because too many arguments + * } + * ``` + * + * NOTE: You can't use any more parameters past a greedy string as it takes all remaining strings, + * causing the command to error because it is unable to fulfill the remaining parameters + * + * @author Stivais + */ +data class GreedyString(val string: String) { + override fun toString(): String = string +} diff --git a/src/main/kotlin/xyz/meowing/lattice/command/utils/SyntaxException.kt b/src/main/kotlin/xyz/meowing/lattice/command/utils/SyntaxException.kt new file mode 100644 index 0000000..859a308 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/utils/SyntaxException.kt @@ -0,0 +1,8 @@ +package xyz.meowing.lattice.command.utils + +/** + * Used to indicate and error in parsing. + * + * @author Stivais + */ +class SyntaxException(override val message: String) : Exception() diff --git a/src/main/kotlin/xyz/meowing/lattice/command/utils/Utils.kt b/src/main/kotlin/xyz/meowing/lattice/command/utils/Utils.kt new file mode 100644 index 0000000..88c5076 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/command/utils/Utils.kt @@ -0,0 +1,30 @@ +package xyz.meowing.lattice.command.utils + +import xyz.meowing.lattice.command.nodes.LiteralNode +import com.mojang.brigadier.ParseResults +import com.mojang.brigadier.tree.LiteralCommandNode + +/** + * Returns the latest [LiteralNode] from a string. + * + * @return The corresponding node + * @author Stivais + */ +fun findCorrespondingNode(node: LiteralNode, name: String): LiteralNode? { + for (child in node.children) { + if (child !is LiteralNode) continue + findCorrespondingNode(child, name)?.let { return it } + } + return if (node.name == name) node else null +} + +/** + * Returns the latest [LiteralNode] from a [parse result][ParseResults] + * + * @return The corresponding node + * @author Stivais + */ +fun findCorrespondingNode(node: LiteralNode, results: ParseResults): LiteralNode? { + val last = results.context.nodes.last { it.node is LiteralCommandNode }.node.name + return findCorrespondingNode(node, last) +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/event/Event.kt b/src/main/kotlin/xyz/meowing/lattice/event/Event.kt new file mode 100644 index 0000000..f600fa1 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/event/Event.kt @@ -0,0 +1,12 @@ +package xyz.meowing.lattice.event + +abstract class Event + +abstract class CancellableEvent : Event() { + var cancelled = false + private set + + fun cancel() { + cancelled = true + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/event/EventBus.kt b/src/main/kotlin/xyz/meowing/lattice/event/EventBus.kt new file mode 100644 index 0000000..b8ebf61 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/event/EventBus.kt @@ -0,0 +1,78 @@ +package xyz.meowing.lattice.event + +import org.jetbrains.annotations.ApiStatus +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +open class EventBus { + val subscribers: MutableMap, MutableList>> = ConcurrentHashMap() + + inline fun register( + priority: Int = 0, + add: Boolean = true, + noinline callback: (T) -> Unit + ): EventCall { + return register(T::class.java, priority, add, callback) + } + + @JvmOverloads + fun register( + eventClass: Class, + priority: Int = 0, + add: Boolean = true, + callback: (T) -> Unit + ): EventCall { + val handlers = subscribers.getOrPut(eventClass) { CopyOnWriteArrayList() } + val prioritizedCallback = PrioritizedCallback(priority, callback) + if (add) addSorted(handlers, prioritizedCallback) + return EventCallImpl(prioritizedCallback, handlers, this, add) + } + + fun post(event: T): Boolean { + val handlers = subscribers[event::class.java] ?: return false + if (handlers.isEmpty()) return false + + for (handler in handlers) { + if (event is CancellableEvent && event.cancelled) return true + + try { + @Suppress("UNCHECKED_CAST") + (handler.callback as (T) -> Unit)(event) + } catch (e: Exception) { + handleException(event, e) + } + } + + return event is CancellableEvent && event.cancelled + } + + /** Builds and posts the event only if it has subscribers. */ + inline fun post(supplier: () -> T): Boolean { + if (!hasSubscribers(T::class.java)) return false + return post(supplier()) + } + + open fun handleException(event: Any, exception: Exception) { + exception.printStackTrace() + } + + fun hasSubscribers(eventClass: Class<*>): Boolean { + return subscribers[eventClass]?.isNotEmpty() == true + } + + inline fun hasSubscribers(): Boolean = hasSubscribers(T::class.java) + + fun clear() { + subscribers.clear() + } + + @ApiStatus.Internal + fun addSorted( + list: MutableList>, + callback: PrioritizedCallback + ) { + val index = list.binarySearch { it.priority.compareTo(callback.priority) } + val insertIndex = if (index < 0) -(index + 1) else index + list.add(insertIndex, callback) + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/event/EventCall.kt b/src/main/kotlin/xyz/meowing/lattice/event/EventCall.kt new file mode 100644 index 0000000..f15e509 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/event/EventCall.kt @@ -0,0 +1,45 @@ +package xyz.meowing.lattice.event + +interface EventCall { + fun unregister(): Boolean + fun register(): Boolean + fun isRegistered(): Boolean +} + +data class PrioritizedCallback( + val priority: Int, + val callback: (T) -> Unit +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PrioritizedCallback<*>) return false + return callback === other.callback + } + + override fun hashCode(): Int = callback.hashCode() +} + +internal class EventCallImpl( + private val callback: PrioritizedCallback, + private val handlers: MutableList>, + private val eventBus: EventBus, + initiallyRegistered: Boolean +) : EventCall { + private var registered = initiallyRegistered + + override fun unregister(): Boolean { + if (!registered) return false + val removed = handlers.remove(callback) + if (removed) registered = false + return removed + } + + override fun register(): Boolean { + if (registered) return false + eventBus.addSorted(handlers, callback) + registered = true + return true + } + + override fun isRegistered(): Boolean = registered +} diff --git a/src/main/kotlin/xyz/meowing/lattice/event/Events.kt b/src/main/kotlin/xyz/meowing/lattice/event/Events.kt new file mode 100644 index 0000000..128fd5e --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/event/Events.kt @@ -0,0 +1,16 @@ +package xyz.meowing.lattice.event + +sealed class ClientEvent { + class Start : Event() + class Stop : Event() +} + +sealed class TickEvent { + class Start : Event() + class End : Event() +} + +sealed class RenderEvent { + /** Posted every frame after vanilla GUI rendering, inside a renderer frame. */ + class Gui : CancellableEvent() +} diff --git a/src/main/kotlin/xyz/meowing/lattice/input/InputCode.kt b/src/main/kotlin/xyz/meowing/lattice/input/InputCode.kt new file mode 100644 index 0000000..0bf5169 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/input/InputCode.kt @@ -0,0 +1,15 @@ +package xyz.meowing.lattice.input + +sealed interface InputCode { + val code: Int + val isPressed: Boolean + val displayName: String get() = Inputs.getDisplayName(code) +} + +data class Key(override val code: Int) : InputCode { + override val isPressed: Boolean get() = Keyboard.isPressed(code) +} + +data class MouseButton(override val code: Int) : InputCode { + override val isPressed: Boolean get() = Mouse.isPressed(code) +} diff --git a/src/main/kotlin/xyz/meowing/lattice/input/Inputs.kt b/src/main/kotlin/xyz/meowing/lattice/input/Inputs.kt new file mode 100644 index 0000000..223143c --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/input/Inputs.kt @@ -0,0 +1,29 @@ +package xyz.meowing.lattice.input + +import com.mojang.blaze3d.platform.InputConstants +import org.lwjgl.glfw.GLFW + +object Inputs { + fun get(code: Int): InputCode { + return when { + Keyboard.isKeyboardButton(code) -> Key(code) + Mouse.isMouseButton(code) -> MouseButton(code) + else -> throw IllegalArgumentException("Code $code is not a valid key or mouse button") + } + } + + @JvmStatic + @JvmOverloads + fun getDisplayName(code: Int, scanCode: Int = -1): String { + val keyName = GLFW.glfwGetKeyName(code, scanCode) + if (keyName != null) { + return if (keyName.length == 1) keyName.uppercase() else keyName + } + val name = (if (code == -1) { + InputConstants.Type.SCANCODE.getOrCreate(scanCode) + } else { + InputConstants.Type.KEYSYM.getOrCreate(code) + }).displayName.string + return if (name.length == 1) name.uppercase() else name + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/input/Keyboard.kt b/src/main/kotlin/xyz/meowing/lattice/input/Keyboard.kt new file mode 100644 index 0000000..9c89647 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/input/Keyboard.kt @@ -0,0 +1,34 @@ +package xyz.meowing.lattice.input + +import org.lwjgl.glfw.GLFW +import xyz.meowing.lattice.render.Resolution.windowHandle + +object Keyboard { + @JvmStatic + val isShiftKeyPressed: Boolean + get() = Keys.KEY_LEFT_SHIFT.isPressed || Keys.KEY_RIGHT_SHIFT.isPressed + + @JvmStatic + val isCtrlKeyPressed: Boolean + get() = Keys.KEY_LEFT_CONTROL.isPressed || Keys.KEY_RIGHT_CONTROL.isPressed + + @JvmStatic + val isAltKeyPressed: Boolean + get() = Keys.KEY_LEFT_ALT.isPressed || Keys.KEY_RIGHT_ALT.isPressed + + @JvmStatic + val isSuperKeyPressed: Boolean + get() = Keys.KEY_LEFT_SUPER.isPressed || Keys.KEY_RIGHT_SUPER.isPressed + + @JvmStatic + fun isKeyboardButton(code: Int): Boolean { + return code in 0 until GLFW.GLFW_KEY_LAST + } + + @JvmStatic + fun isPressed(code: Int): Boolean { + if (!isKeyboardButton(code)) return false + val state = GLFW.glfwGetKey(windowHandle, code) + return state == GLFW.GLFW_PRESS || state == GLFW.GLFW_REPEAT + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/input/KeyboardModifiers.kt b/src/main/kotlin/xyz/meowing/lattice/input/KeyboardModifiers.kt new file mode 100644 index 0000000..2762c12 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/input/KeyboardModifiers.kt @@ -0,0 +1,42 @@ +package xyz.meowing.lattice.input + +import org.lwjgl.glfw.GLFW + +data class KeyboardModifiers( + val isShift: Boolean, + val isCtrl: Boolean, + val isAlt: Boolean, + val isSuper: Boolean, + val isCapsLock: Boolean = false, + val isNumLock: Boolean = false +) { + companion object { + val current: KeyboardModifiers + get() = KeyboardModifiers( + isShift = Keyboard.isShiftKeyPressed, + isCtrl = Keyboard.isCtrlKeyPressed, + isAlt = Keyboard.isAltKeyPressed, + isSuper = Keyboard.isSuperKeyPressed, + ) + + fun wrap(mods: Int): KeyboardModifiers { + return KeyboardModifiers( + isShift = (mods and GLFW.GLFW_MOD_SHIFT) != 0, + isCtrl = (mods and GLFW.GLFW_MOD_CONTROL) != 0, + isAlt = (mods and GLFW.GLFW_MOD_ALT) != 0, + isSuper = (mods and GLFW.GLFW_MOD_SUPER) != 0, + ) + } + } + + fun toMods(): Int { + return listOf( + isShift to GLFW.GLFW_MOD_SHIFT, + isCtrl to GLFW.GLFW_MOD_CONTROL, + isAlt to GLFW.GLFW_MOD_ALT, + isSuper to GLFW.GLFW_MOD_SUPER, + isCapsLock to GLFW.GLFW_MOD_CAPS_LOCK, + isNumLock to GLFW.GLFW_MOD_NUM_LOCK + ).sumOf { (value, mod) -> if (value) mod else 0 } + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/input/Keys.kt b/src/main/kotlin/xyz/meowing/lattice/input/Keys.kt new file mode 100644 index 0000000..2e58602 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/input/Keys.kt @@ -0,0 +1,125 @@ +package xyz.meowing.lattice.input + +import org.lwjgl.glfw.GLFW + +object Keys { + val KEY_NONE = Key(GLFW.GLFW_KEY_UNKNOWN) + + val KEY_ESCAPE = Key(GLFW.GLFW_KEY_ESCAPE) + val KEY_F1 = Key(GLFW.GLFW_KEY_F1) + val KEY_F2 = Key(GLFW.GLFW_KEY_F2) + val KEY_F3 = Key(GLFW.GLFW_KEY_F3) + val KEY_F4 = Key(GLFW.GLFW_KEY_F4) + val KEY_F5 = Key(GLFW.GLFW_KEY_F5) + val KEY_F6 = Key(GLFW.GLFW_KEY_F6) + val KEY_F7 = Key(GLFW.GLFW_KEY_F7) + val KEY_F8 = Key(GLFW.GLFW_KEY_F8) + val KEY_F9 = Key(GLFW.GLFW_KEY_F9) + val KEY_F10 = Key(GLFW.GLFW_KEY_F10) + val KEY_F11 = Key(GLFW.GLFW_KEY_F11) + val KEY_F12 = Key(GLFW.GLFW_KEY_F12) + val KEY_F13 = Key(GLFW.GLFW_KEY_F13) + val KEY_F14 = Key(GLFW.GLFW_KEY_F14) + val KEY_F15 = Key(GLFW.GLFW_KEY_F15) + + val KEY_LEFT_SHIFT = Key(GLFW.GLFW_KEY_LEFT_SHIFT) + val KEY_RIGHT_SHIFT = Key(GLFW.GLFW_KEY_RIGHT_SHIFT) + val KEY_LEFT_CONTROL = Key(GLFW.GLFW_KEY_LEFT_CONTROL) + val KEY_RIGHT_CONTROL = Key(GLFW.GLFW_KEY_RIGHT_CONTROL) + val KEY_LEFT_ALT = Key(GLFW.GLFW_KEY_LEFT_ALT) + val KEY_RIGHT_ALT = Key(GLFW.GLFW_KEY_RIGHT_ALT) + val KEY_LEFT_SUPER = Key(GLFW.GLFW_KEY_LEFT_SUPER) + val KEY_RIGHT_SUPER = Key(GLFW.GLFW_KEY_RIGHT_SUPER) + val KEY_CAPS_LOCK = Key(GLFW.GLFW_KEY_CAPS_LOCK) + val KEY_NUM_LOCK = Key(GLFW.GLFW_KEY_NUM_LOCK) + val KEY_SCROLL_LOCK = Key(GLFW.GLFW_KEY_SCROLL_LOCK) + + val KEY_TAB = Key(GLFW.GLFW_KEY_TAB) + val KEY_ENTER = Key(GLFW.GLFW_KEY_ENTER) + val KEY_BACKSPACE = Key(GLFW.GLFW_KEY_BACKSPACE) + val KEY_DELETE = Key(GLFW.GLFW_KEY_DELETE) + val KEY_INSERT = Key(GLFW.GLFW_KEY_INSERT) + val KEY_PAGE_UP = Key(GLFW.GLFW_KEY_PAGE_UP) + val KEY_PAGE_DOWN = Key(GLFW.GLFW_KEY_PAGE_DOWN) + val KEY_HOME = Key(GLFW.GLFW_KEY_HOME) + val KEY_END = Key(GLFW.GLFW_KEY_END) + + val KEY_LEFT = Key(GLFW.GLFW_KEY_LEFT) + val KEY_RIGHT = Key(GLFW.GLFW_KEY_RIGHT) + val KEY_UP = Key(GLFW.GLFW_KEY_UP) + val KEY_DOWN = Key(GLFW.GLFW_KEY_DOWN) + + val KEY_0 = Key(GLFW.GLFW_KEY_0) + val KEY_1 = Key(GLFW.GLFW_KEY_1) + val KEY_2 = Key(GLFW.GLFW_KEY_2) + val KEY_3 = Key(GLFW.GLFW_KEY_3) + val KEY_4 = Key(GLFW.GLFW_KEY_4) + val KEY_5 = Key(GLFW.GLFW_KEY_5) + val KEY_6 = Key(GLFW.GLFW_KEY_6) + val KEY_7 = Key(GLFW.GLFW_KEY_7) + val KEY_8 = Key(GLFW.GLFW_KEY_8) + val KEY_9 = Key(GLFW.GLFW_KEY_9) + + val KEY_A = Key(GLFW.GLFW_KEY_A) + val KEY_B = Key(GLFW.GLFW_KEY_B) + val KEY_C = Key(GLFW.GLFW_KEY_C) + val KEY_D = Key(GLFW.GLFW_KEY_D) + val KEY_E = Key(GLFW.GLFW_KEY_E) + val KEY_F = Key(GLFW.GLFW_KEY_F) + val KEY_G = Key(GLFW.GLFW_KEY_G) + val KEY_H = Key(GLFW.GLFW_KEY_H) + val KEY_I = Key(GLFW.GLFW_KEY_I) + val KEY_J = Key(GLFW.GLFW_KEY_J) + val KEY_K = Key(GLFW.GLFW_KEY_K) + val KEY_L = Key(GLFW.GLFW_KEY_L) + val KEY_M = Key(GLFW.GLFW_KEY_M) + val KEY_N = Key(GLFW.GLFW_KEY_N) + val KEY_O = Key(GLFW.GLFW_KEY_O) + val KEY_P = Key(GLFW.GLFW_KEY_P) + val KEY_Q = Key(GLFW.GLFW_KEY_Q) + val KEY_R = Key(GLFW.GLFW_KEY_R) + val KEY_S = Key(GLFW.GLFW_KEY_S) + val KEY_T = Key(GLFW.GLFW_KEY_T) + val KEY_U = Key(GLFW.GLFW_KEY_U) + val KEY_V = Key(GLFW.GLFW_KEY_V) + val KEY_W = Key(GLFW.GLFW_KEY_W) + val KEY_X = Key(GLFW.GLFW_KEY_X) + val KEY_Y = Key(GLFW.GLFW_KEY_Y) + val KEY_Z = Key(GLFW.GLFW_KEY_Z) + + val KEY_SPACE = Key(GLFW.GLFW_KEY_SPACE) + val KEY_APOSTROPHE = Key(GLFW.GLFW_KEY_APOSTROPHE) + val KEY_COMMA = Key(GLFW.GLFW_KEY_COMMA) + val KEY_MINUS = Key(GLFW.GLFW_KEY_MINUS) + val KEY_PERIOD = Key(GLFW.GLFW_KEY_PERIOD) + val KEY_SLASH = Key(GLFW.GLFW_KEY_SLASH) + val KEY_SEMICOLON = Key(GLFW.GLFW_KEY_SEMICOLON) + val KEY_EQUAL = Key(GLFW.GLFW_KEY_EQUAL) + val KEY_LEFT_BRACKET = Key(GLFW.GLFW_KEY_LEFT_BRACKET) + val KEY_BACKSLASH = Key(GLFW.GLFW_KEY_BACKSLASH) + val KEY_RIGHT_BRACKET = Key(GLFW.GLFW_KEY_RIGHT_BRACKET) + val KEY_GRAVE_ACCENT = Key(GLFW.GLFW_KEY_GRAVE_ACCENT) + + val KEY_NUMPAD_0 = Key(GLFW.GLFW_KEY_KP_0) + val KEY_NUMPAD_1 = Key(GLFW.GLFW_KEY_KP_1) + val KEY_NUMPAD_2 = Key(GLFW.GLFW_KEY_KP_2) + val KEY_NUMPAD_3 = Key(GLFW.GLFW_KEY_KP_3) + val KEY_NUMPAD_4 = Key(GLFW.GLFW_KEY_KP_4) + val KEY_NUMPAD_5 = Key(GLFW.GLFW_KEY_KP_5) + val KEY_NUMPAD_6 = Key(GLFW.GLFW_KEY_KP_6) + val KEY_NUMPAD_7 = Key(GLFW.GLFW_KEY_KP_7) + val KEY_NUMPAD_8 = Key(GLFW.GLFW_KEY_KP_8) + val KEY_NUMPAD_9 = Key(GLFW.GLFW_KEY_KP_9) + + val KEY_NUMPAD_DECIMAL = Key(GLFW.GLFW_KEY_KP_DECIMAL) + val KEY_NUMPAD_DIVIDE = Key(GLFW.GLFW_KEY_KP_DIVIDE) + val KEY_NUMPAD_MULTIPLY = Key(GLFW.GLFW_KEY_KP_MULTIPLY) + val KEY_NUMPAD_SUBTRACT = Key(GLFW.GLFW_KEY_KP_SUBTRACT) + val KEY_NUMPAD_ADD = Key(GLFW.GLFW_KEY_KP_ADD) + val KEY_NUMPAD_ENTER = Key(GLFW.GLFW_KEY_KP_ENTER) + val KEY_NUMPAD_EQUAL = Key(GLFW.GLFW_KEY_KP_EQUAL) + + val KEY_PRINT_SCREEN = Key(GLFW.GLFW_KEY_PRINT_SCREEN) + val KEY_PAUSE = Key(GLFW.GLFW_KEY_PAUSE) + val KEY_MENU = Key(GLFW.GLFW_KEY_MENU) +} diff --git a/src/main/kotlin/xyz/meowing/lattice/input/Mouse.kt b/src/main/kotlin/xyz/meowing/lattice/input/Mouse.kt new file mode 100644 index 0000000..e58b550 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/input/Mouse.kt @@ -0,0 +1,33 @@ +package xyz.meowing.lattice.input + +import org.lwjgl.glfw.GLFW +import xyz.meowing.lattice.client.Client.minecraft +import xyz.meowing.lattice.render.Resolution +import xyz.meowing.lattice.render.Resolution.windowHandle +import kotlin.math.max + +object Mouse { + object Raw { + val x: Double get() = minecraft.mouseHandler.xpos() + val y: Double get() = minecraft.mouseHandler.ypos() + } + + object Scaled { + val x: Double get() = Raw.x * Resolution.scaledWidth / max(1, Resolution.windowWidth) + val y: Double get() = Raw.y * Resolution.scaledHeight / max(1, Resolution.windowHeight) + } + + var isCursorGrabbed: Boolean + get() = minecraft.mouseHandler.isMouseGrabbed + set(value) { + if (value) minecraft.mouseHandler.grabMouse() else minecraft.mouseHandler.releaseMouse() + } + + fun isMouseButton(code: Int): Boolean = code in GLFW.GLFW_MOUSE_BUTTON_1..GLFW.GLFW_MOUSE_BUTTON_8 + + fun isPressed(code: Int): Boolean { + if (!isMouseButton(code)) return false + val state = GLFW.glfwGetMouseButton(windowHandle, code) + return state == GLFW.GLFW_PRESS || state == GLFW.GLFW_REPEAT + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/input/MouseButtons.kt b/src/main/kotlin/xyz/meowing/lattice/input/MouseButtons.kt new file mode 100644 index 0000000..cfc4a28 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/input/MouseButtons.kt @@ -0,0 +1,14 @@ +package xyz.meowing.lattice.input + +import org.lwjgl.glfw.GLFW + +object MouseButtons { + val LEFT = MouseButton(GLFW.GLFW_MOUSE_BUTTON_LEFT) + val RIGHT = MouseButton(GLFW.GLFW_MOUSE_BUTTON_RIGHT) + val MIDDLE = MouseButton(GLFW.GLFW_MOUSE_BUTTON_MIDDLE) + val BACK = MouseButton(GLFW.GLFW_MOUSE_BUTTON_4) + val FORWARD = MouseButton(GLFW.GLFW_MOUSE_BUTTON_5) + val BUTTON6 = MouseButton(GLFW.GLFW_MOUSE_BUTTON_6) + val BUTTON7 = MouseButton(GLFW.GLFW_MOUSE_BUTTON_7) + val BUTTON8 = MouseButton(GLFW.GLFW_MOUSE_BUTTON_8) +} diff --git a/src/main/kotlin/xyz/meowing/lattice/render/Color.kt b/src/main/kotlin/xyz/meowing/lattice/render/Color.kt new file mode 100644 index 0000000..ac0fbf9 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/render/Color.kt @@ -0,0 +1,119 @@ +package xyz.meowing.lattice.render + +// Derived from OdinFabric (BSD-3-Clause, (c) odtheking); see NOTICE.md. +class Color(hue: Float, saturation: Float, brightness: Float, alpha: Float = 1f) { + constructor(hsb: FloatArray, alpha: Float = 1f) : this(hsb[0], hsb[1], hsb[2], alpha) + constructor(r: Int, g: Int, b: Int, alpha: Float = 1f) : this( + java.awt.Color.RGBtoHSB(r, g, b, FloatArray(size = 3)), alpha + ) + constructor(rgba: Int) : this(rgba.red, rgba.green, rgba.blue, alpha = rgba.alpha / 255f) + constructor(rgba: Int, alpha: Float) : this(rgba.red, rgba.green, rgba.blue, alpha) + constructor(hex: String) : this( + hex.take(2).toInt(16), + hex.substring(2, 4).toInt(16), + hex.substring(4, 6).toInt(16), + hex.substring(6, 8).toInt(16) / 255f + ) + + var hue = hue + set(value) { + field = value + needsUpdate = true + } + + var saturation = saturation + set(value) { + field = value + needsUpdate = true + } + + var brightness = brightness + set(value) { + field = value + needsUpdate = true + } + + var alphaFloat = alpha + set(value) { + field = value + needsUpdate = true + } + + private var needsUpdate = true + + /** ARGB value, recomputed lazily when the HSBA components change. */ + var rgba: Int = 0 + get() { + if (needsUpdate) { + field = (java.awt.Color.HSBtoRGB(hue, saturation, brightness) and 0x00FFFFFF) or + ((this.alphaFloat * 255).toInt() shl 24) + needsUpdate = false + } + return field + } + private set + + inline val red get() = rgba.red + inline val green get() = rgba.green + inline val blue get() = rgba.blue + inline val alpha get() = rgba.alpha + + inline val redFloat get() = red / 255f + inline val greenFloat get() = green / 255f + inline val blueFloat get() = blue / 255f + + @OptIn(ExperimentalStdlibApi::class) + fun hex(includeAlpha: Boolean = true): String { + val hexString = rgba.toHexString(HexFormat.UpperCase) + return if (includeAlpha) hexString.substring(2) + hexString.take(2) + else hexString.substring(2) + } + + inline val isTransparent: Boolean get() = this.alphaFloat == 0f + + override fun toString(): String = "Color(red=$red,green=$green,blue=$blue,alpha=$alpha)" + + override fun hashCode(): Int { + var result = hue.toInt() + result = 31 * result + saturation.toInt() + result = 31 * result + brightness.toInt() + result = 31 * result + this.alphaFloat.toInt() + return result + } + + override fun equals(other: Any?): Boolean { + if (other === this) return true + return other is Color && rgba == other.rgba + } + + fun copy(): Color = Color(this.rgba) + + companion object { + inline val Int.red get() = this shr 16 and 0xFF + inline val Int.green get() = this shr 8 and 0xFF + inline val Int.blue get() = this and 0xFF + inline val Int.alpha get() = this shr 24 and 0xFF + + fun Color.brighter(factor: Float = 1.3f): Color { + return Color(hue, saturation, (brightness * factor.coerceAtLeast(1f)).coerceAtMost(1f), this.alphaFloat) + } + + fun Color.darker(factor: Float = 0.7f): Color { + return Color(hue, saturation, brightness * factor, this.alphaFloat) + } + + fun Color.withAlpha(alpha: Float, newInstance: Boolean = true): Color { + return if (newInstance) Color(red, green, blue, alpha) + else { + this.alphaFloat = alpha + this + } + } + + fun Color.multiplyAlpha(factor: Float): Color { + return Color(red, green, blue, (alphaFloat * factor).coerceIn(0f, 1f)) + } + + fun Color.hsbMax(): Color = Color(hue, 1f, 1f) + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/render/Font.kt b/src/main/kotlin/xyz/meowing/lattice/render/Font.kt new file mode 100644 index 0000000..4213ffd --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/render/Font.kt @@ -0,0 +1,42 @@ +package xyz.meowing.lattice.render + +import java.io.FileNotFoundException +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +// Derived from OdinFabric (BSD-3-Clause, (c) odtheking); see NOTICE.md. +class Font { + val name: String + private val resourcePath: String? + private val cachedBytes: ByteArray? + + constructor(name: String, resourcePath: String) { + this.name = name + this.resourcePath = resourcePath + this.cachedBytes = null + } + + constructor(name: String, inputStream: InputStream) { + this.name = name + this.resourcePath = null + this.cachedBytes = inputStream.use { it.readBytes() } + } + + fun buffer(): ByteBuffer { + val bytes = cachedBytes ?: run { + val stream = this::class.java.getResourceAsStream(resourcePath!!) + ?: throw FileNotFoundException(resourcePath) + stream.use { it.readBytes() } + } + + return ByteBuffer.allocateDirect(bytes.size) + .order(ByteOrder.nativeOrder()) + .put(bytes) + .flip() as ByteBuffer + } + + override fun hashCode(): Int = name.hashCode() + + override fun equals(other: Any?): Boolean = other is Font && name == other.name +} diff --git a/src/main/kotlin/xyz/meowing/lattice/render/GLState.kt b/src/main/kotlin/xyz/meowing/lattice/render/GLState.kt new file mode 100644 index 0000000..45e7a7b --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/render/GLState.kt @@ -0,0 +1,16 @@ +package xyz.meowing.lattice.render + +/** Tracks GL state that NanoVG frames must restore for vanilla rendering. */ +object GLState { + @JvmStatic + var previousBoundTexture = -1 + + @JvmStatic + var previousActiveTexture = -1 + + @JvmStatic + var previousProgram = -1 + + @JvmStatic + var drawing = false +} diff --git a/src/main/kotlin/xyz/meowing/lattice/render/Gradient.kt b/src/main/kotlin/xyz/meowing/lattice/render/Gradient.kt new file mode 100644 index 0000000..de267ab --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/render/Gradient.kt @@ -0,0 +1,7 @@ +package xyz.meowing.lattice.render + +enum class Gradient { + LeftToRight, + TopToBottom, + TopLeftToBottomRight, +} diff --git a/src/main/kotlin/xyz/meowing/lattice/render/Image.kt b/src/main/kotlin/xyz/meowing/lattice/render/Image.kt new file mode 100644 index 0000000..45d9e59 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/render/Image.kt @@ -0,0 +1,43 @@ +package xyz.meowing.lattice.render + +import org.lwjgl.system.MemoryUtil +import java.io.File +import java.io.FileNotFoundException +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.file.Files + +// Derived from OdinFabric (BSD-3-Clause, (c) odtheking); see NOTICE.md. +class Image( + val identifier: String, + var stream: InputStream = getStream(identifier), + private var buffer: ByteBuffer? = null +) { + val isSVG: Boolean = identifier.endsWith(".svg", true) + + fun buffer(): ByteBuffer { + if (buffer == null) { + val bytes = stream.readBytes() + buffer = MemoryUtil.memAlloc(bytes.size).put(bytes).flip() as ByteBuffer + stream.close() + } + return buffer ?: throw IllegalStateException("Image has no data") + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Image) return false + return identifier == other.identifier + } + + override fun hashCode(): Int = identifier.hashCode() + + companion object { + private fun getStream(path: String): InputStream { + val trimmedPath = path.trim() + val file = File(trimmedPath) + return if (file.exists() && file.isFile) Files.newInputStream(file.toPath()) + else this::class.java.getResourceAsStream(trimmedPath) ?: throw FileNotFoundException(trimmedPath) + } + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/render/NVGRenderer.kt b/src/main/kotlin/xyz/meowing/lattice/render/NVGRenderer.kt new file mode 100644 index 0000000..48ae032 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/render/NVGRenderer.kt @@ -0,0 +1,484 @@ +package xyz.meowing.lattice.render + +import com.mojang.blaze3d.opengl.GlStateManager +import com.mojang.blaze3d.systems.RenderSystem +import org.lwjgl.nanovg.NVGColor +import org.lwjgl.nanovg.NVGPaint +import org.lwjgl.nanovg.NanoSVG +import org.lwjgl.nanovg.NanoVG +import org.lwjgl.nanovg.NanoVGGL3 +import org.lwjgl.opengl.GL11 +import org.lwjgl.opengl.GL13 +import org.lwjgl.opengl.GL20 +import org.lwjgl.opengl.GL30 +import org.lwjgl.stb.STBImage +import org.lwjgl.system.MemoryUtil +import xyz.meowing.lattice.client.Client.minecraft +import xyz.meowing.lattice.render.Color.Companion.alpha +import xyz.meowing.lattice.render.Color.Companion.blue +import xyz.meowing.lattice.render.Color.Companion.green +import xyz.meowing.lattice.render.Color.Companion.red +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.nio.ByteBuffer +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round + +// Derived from OdinFabric (BSD-3-Clause, (c) odtheking); see NOTICE.md. +object NVGRenderer : Renderer { + private val nvgPaint = NVGPaint.malloc() + private val nvgColor = NVGColor.malloc() + private val nvgColor2 = NVGColor.malloc() + + private val fontMap = HashMap() + private val fontBounds = FloatArray(4) + + private val images = HashMap() + + private var scissor: Scissor? = null + + private var vg = -1L + + init { + vg = NanoVGGL3.nvgCreate(NanoVGGL3.NVG_ANTIALIAS or NanoVGGL3.NVG_STENCIL_STROKES) + require(vg != -1L) { "Failed to initialize NanoVG" } + } + + // Resolved reflectively once: GlTexture.getFbo(GlStateManager$DirectStateAccess, depth) is not public API. + private var fboLookup: Pair? = null + private var fboLookupFailed = false + + private fun mainFramebuffer(colorTex: Any): Int { + if (fboLookupFailed) return 0 + + try { + val lookup = fboLookup ?: run { + val device = RenderSystem.getDevice() ?: return 0 + var dsaField: Field? = null + var clazz: Class<*>? = device.javaClass + while (clazz != null && dsaField == null) { + try { + dsaField = clazz.getDeclaredField("directStateAccess") + } catch (_: NoSuchFieldException) {} + clazz = clazz.superclass + } + dsaField?.isAccessible = true + val dsa = dsaField?.get(device) ?: throw IllegalStateException("no directStateAccess") + + var getFbo: Method? = null + var texClazz: Class<*>? = colorTex.javaClass + while (texClazz != null && getFbo == null) { + getFbo = texClazz.declaredMethods.firstOrNull { it.name == "getFbo" && it.parameterCount == 2 } + texClazz = texClazz.superclass + } + getFbo?.isAccessible = true + if (getFbo == null) throw IllegalStateException("no getFbo") + + Pair(dsa, getFbo).also { fboLookup = it } + } + + return (lookup.second.invoke(colorTex, lookup.first, null) as? Int) ?: 0 + } catch (_: Exception) { + fboLookupFailed = true + return 0 + } + } + + override fun beginFrame(width: Float, height: Float) { + check(!GLState.drawing) { "[NVGRenderer] Already drawing, but called beginFrame" } + + GLState.previousActiveTexture = GL11.glGetInteger(GL13.GL_ACTIVE_TEXTURE) + GLState.previousProgram = GL11.glGetInteger(GL20.GL_CURRENT_PROGRAM) + + val renderTarget = minecraft.mainRenderTarget ?: return + val colorTex = renderTarget.getColorTexture() ?: return + if (vg == -1L) return + + GlStateManager._glBindFramebuffer(GL30.GL_FRAMEBUFFER, mainFramebuffer(colorTex)) + GlStateManager._viewport(0, 0, renderTarget.width, renderTarget.height) + GlStateManager._activeTexture(GL30.GL_TEXTURE0) + + NanoVG.nvgBeginFrame(vg, width, height, 1f) + NanoVG.nvgTextAlign(vg, NanoVG.NVG_ALIGN_LEFT or NanoVG.NVG_ALIGN_TOP) + GLState.drawing = true + } + + override fun endFrame() { + check(GLState.drawing) { "[NVGRenderer] Not drawing, but called endFrame" } + NanoVG.nvgEndFrame(vg) + + // Restore the default state vanilla expects after NanoVG's frame. + GlStateManager._disableCull() + GlStateManager._disableDepthTest() + GlStateManager._enableBlend() + GlStateManager._blendFuncSeparate(770, 771, 1, 0) + + if (GLState.previousProgram != -1) GlStateManager._glUseProgram(GLState.previousProgram) + + if (GLState.previousActiveTexture != -1) { + GlStateManager._activeTexture(GLState.previousActiveTexture) + if (GLState.previousBoundTexture != -1) GlStateManager._bindTexture(GLState.previousBoundTexture) + } + + GlStateManager._glBindFramebuffer(GL30.GL_FRAMEBUFFER, 0) + GLState.drawing = false + } + + override fun push() = NanoVG.nvgSave(vg) + + override fun pop() = NanoVG.nvgRestore(vg) + + override fun scale(x: Float, y: Float) = NanoVG.nvgScale(vg, x, y) + + override fun translate(x: Float, y: Float) = NanoVG.nvgTranslate(vg, x, y) + + override fun rotate(amount: Float) = NanoVG.nvgRotate(vg, amount) + + override fun globalAlpha(amount: Float) = NanoVG.nvgGlobalAlpha(vg, amount.coerceIn(0f, 1f)) + + override fun pushScissor(x: Float, y: Float, w: Float, h: Float) { + scissor = Scissor(scissor, x, y, w + x, h + y) + scissor?.applyScissor() + } + + override fun popScissor() { + NanoVG.nvgResetScissor(vg) + scissor = scissor?.previous + scissor?.applyScissor() + } + + override fun line(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, color: Int) { + NanoVG.nvgBeginPath(vg) + NanoVG.nvgMoveTo(vg, x1, y1) + NanoVG.nvgLineTo(vg, x2, y2) + NanoVG.nvgStrokeWidth(vg, thickness) + color(color) + NanoVG.nvgStrokeColor(vg, nvgColor) + NanoVG.nvgStroke(vg) + } + + override fun rect(x: Float, y: Float, w: Float, h: Float, color: Int, radius: Float, roundTop: Boolean) { + NanoVG.nvgBeginPath(vg) + + if (roundTop) { + NanoVG.nvgMoveTo(vg, x, y + h) + NanoVG.nvgLineTo(vg, x + w, y + h) + NanoVG.nvgLineTo(vg, x + w, y + radius) + NanoVG.nvgArcTo(vg, x + w, y, x + w - radius, y, radius) + NanoVG.nvgLineTo(vg, x + radius, y) + NanoVG.nvgArcTo(vg, x, y, x, y + radius, radius) + NanoVG.nvgLineTo(vg, x, y + h) + } else { + NanoVG.nvgMoveTo(vg, x, y) + NanoVG.nvgLineTo(vg, x + w, y) + NanoVG.nvgLineTo(vg, x + w, y + h - radius) + NanoVG.nvgArcTo(vg, x + w, y + h, x + w - radius, y + h, radius) + NanoVG.nvgLineTo(vg, x + radius, y + h) + NanoVG.nvgArcTo(vg, x, y + h, x, y + h - radius, radius) + NanoVG.nvgLineTo(vg, x, y) + } + + NanoVG.nvgClosePath(vg) + color(color) + NanoVG.nvgFillColor(vg, nvgColor) + NanoVG.nvgFill(vg) + } + + override fun rect(x: Float, y: Float, w: Float, h: Float, color: Int, radius: Float) { + NanoVG.nvgBeginPath(vg) + NanoVG.nvgRoundedRect(vg, x, y, w, h + .5f, radius) + color(color) + NanoVG.nvgFillColor(vg, nvgColor) + NanoVG.nvgFill(vg) + } + + override fun rect(x: Float, y: Float, w: Float, h: Float, color: Int) { + NanoVG.nvgBeginPath(vg) + NanoVG.nvgRect(vg, x, y, w, h + .5f) + color(color) + NanoVG.nvgFillColor(vg, nvgColor) + NanoVG.nvgFill(vg) + } + + override fun rect(x: Float, y: Float, w: Float, h: Float, color: Int, topRight: Float, topLeft: Float, bottomRight: Float, bottomLeft: Float) { + NanoVG.nvgBeginPath(vg) + NanoVG.nvgRoundedRectVarying(vg, round(x), round(y), round(w), round(h), topRight, topLeft, bottomRight, bottomLeft) + color(color) + NanoVG.nvgFillColor(vg, nvgColor) + NanoVG.nvgFill(vg) + } + + override fun hollowRect(x: Float, y: Float, w: Float, h: Float, thickness: Float, color: Int, radius: Float) { + NanoVG.nvgBeginPath(vg) + NanoVG.nvgRoundedRect(vg, x, y, w, h, radius) + NanoVG.nvgStrokeWidth(vg, thickness) + NanoVG.nvgPathWinding(vg, NanoVG.NVG_HOLE) + color(color) + NanoVG.nvgStrokeColor(vg, nvgColor) + NanoVG.nvgStroke(vg) + } + + override fun hollowGradientRect(x: Float, y: Float, w: Float, h: Float, thickness: Float, color1: Int, color2: Int, gradient: Gradient, radius: Float) { + NanoVG.nvgBeginPath(vg) + NanoVG.nvgRoundedRect(vg, x, y, w, h, radius) + NanoVG.nvgStrokeWidth(vg, thickness) + gradient(color1, color2, x, y, w, h, gradient) + NanoVG.nvgStrokePaint(vg, nvgPaint) + NanoVG.nvgStroke(vg) + } + + override fun gradientRect(x: Float, y: Float, w: Float, h: Float, color1: Int, color2: Int, gradient: Gradient, radius: Float) { + NanoVG.nvgBeginPath(vg) + NanoVG.nvgRoundedRect(vg, x, y, w, h, radius) + gradient(color1, color2, x, y, w, h, gradient) + NanoVG.nvgFillPaint(vg, nvgPaint) + NanoVG.nvgFill(vg) + } + + override fun dropShadow(x: Float, y: Float, width: Float, height: Float, blur: Float, spread: Float, shadowColor: Int, radius: Float) { + val r = shadowColor.red.toByte() + val g = shadowColor.green.toByte() + val b = shadowColor.blue.toByte() + + NanoVG.nvgRGBA(r, g, b, 125, nvgColor) + NanoVG.nvgRGBA(r, g, b, 0, nvgColor2) + + NanoVG.nvgBoxGradient( + vg, + x - spread, + y - spread, + width + 2 * spread, + height + 2 * spread, + radius + spread, + blur, + nvgColor, + nvgColor2, + nvgPaint + ) + NanoVG.nvgBeginPath(vg) + NanoVG.nvgRoundedRect( + vg, + x - spread - blur, + y - spread - blur, + width + 2 * spread + 2 * blur, + height + 2 * spread + 2 * blur, + radius + spread + ) + NanoVG.nvgRoundedRect(vg, x, y, width, height, radius) + NanoVG.nvgPathWinding(vg, NanoVG.NVG_HOLE) + NanoVG.nvgFillPaint(vg, nvgPaint) + NanoVG.nvgFill(vg) + } + + override fun circle(x: Float, y: Float, radius: Float, color: Int) { + NanoVG.nvgBeginPath(vg) + NanoVG.nvgCircle(vg, x, y, radius) + color(color) + NanoVG.nvgFillColor(vg, nvgColor) + NanoVG.nvgFill(vg) + } + + override fun text(text: String, x: Float, y: Float, size: Float, color: Int, font: Font) { + NanoVG.nvgFontSize(vg, size) + NanoVG.nvgFontFaceId(vg, getFontID(font)) + color(color) + NanoVG.nvgFillColor(vg, nvgColor) + NanoVG.nvgText(vg, x, y + .5f, text) + } + + override fun shadowedText(text: String, x: Float, y: Float, size: Float, color: Int, font: Font, shadowColor: Int, offsetX: Float, offsetY: Float, blur: Float) { + NanoVG.nvgFontFaceId(vg, getFontID(font)) + NanoVG.nvgFontSize(vg, size) + + NanoVG.nvgFontBlur(vg, blur) + color(shadowColor) + NanoVG.nvgFillColor(vg, nvgColor) + NanoVG.nvgText(vg, x + offsetX, y + offsetY, text) + + NanoVG.nvgFontBlur(vg, 0f) + color(color) + NanoVG.nvgFillColor(vg, nvgColor) + NanoVG.nvgText(vg, x, y + .5f, text) + } + + override fun textWidth(text: String, size: Float, font: Font): Float { + NanoVG.nvgFontSize(vg, size) + NanoVG.nvgFontFaceId(vg, getFontID(font)) + return NanoVG.nvgTextBounds(vg, 0f, 0f, text, fontBounds) + } + + override fun wrappedText(text: String, x: Float, y: Float, w: Float, size: Float, color: Int, font: Font, lineHeight: Float) { + NanoVG.nvgFontSize(vg, size) + NanoVG.nvgFontFaceId(vg, getFontID(font)) + NanoVG.nvgTextLineHeight(vg, lineHeight) + color(color) + NanoVG.nvgFillColor(vg, nvgColor) + NanoVG.nvgTextBox(vg, x, y, w, text) + } + + override fun textBounds(text: String, w: Float, size: Float, font: Font, lineHeight: Float): FloatArray { + val bounds = FloatArray(4) + NanoVG.nvgFontSize(vg, size) + NanoVG.nvgFontFaceId(vg, getFontID(font)) + NanoVG.nvgTextLineHeight(vg, lineHeight) + NanoVG.nvgTextBoxBounds(vg, 0f, 0f, w, text, bounds) + return bounds // [minX, minY, maxX, maxY] + } + + override fun image(image: Int, textureWidth: Int, textureHeight: Int, subX: Int, subY: Int, subW: Int, subH: Int, x: Float, y: Float, w: Float, h: Float, radius: Float) { + if (image == -1) return + + val sx = subX.toFloat() / textureWidth + val sy = subY.toFloat() / textureHeight + val sw = subW.toFloat() / textureWidth + val sh = subH.toFloat() / textureHeight + + val iw = w / sw + val ih = h / sh + val ix = x - iw * sx + val iy = y - ih * sy + + NanoVG.nvgImagePattern(vg, ix, iy, iw, ih, 0f, image, 1f, nvgPaint) + NanoVG.nvgBeginPath(vg) + NanoVG.nvgRoundedRect(vg, x, y, w, h + .5f, radius) + NanoVG.nvgFillPaint(vg, nvgPaint) + NanoVG.nvgFill(vg) + } + + override fun image(image: Image, x: Float, y: Float, w: Float, h: Float, radius: Float) { + NanoVG.nvgImagePattern(vg, x, y, w, h, 0f, getImage(image), 1f, nvgPaint) + NanoVG.nvgBeginPath(vg) + NanoVG.nvgRoundedRect(vg, x, y, w, h + .5f, radius) + NanoVG.nvgFillPaint(vg, nvgPaint) + NanoVG.nvgFill(vg) + } + + override fun svg(id: String, x: Float, y: Float, w: Float, h: Float, alpha: Float) { + val nvg = getImage(Image(id)) + + NanoVG.nvgImagePattern(vg, x, y, w, h, 0f, nvg, alpha, nvgPaint) + NanoVG.nvgBeginPath(vg) + NanoVG.nvgRect(vg, x, y, w, h + .5f) + NanoVG.nvgFillPaint(vg, nvgPaint) + NanoVG.nvgFill(vg) + } + + fun createNVGImage(glId: Int, width: Int, height: Int): Int { + if (vg == -1L) return -1 + return NanoVGGL3.nvglCreateImageFromHandle(vg, glId, width, height, 0) + } + + override fun createImage(resourcePath: String, width: Int, height: Int, color: Int): Image { + val image = Image(resourcePath) + + if (image.isSVG) { + images.getOrPut(image) { NVGImage(0, loadSVG(image, width, height, color)) }.count++ + } else { + images.getOrPut(image) { NVGImage(0, loadImage(image)) }.count++ + } + return image + } + + override fun cleanCache() { + val iter = images.entries.iterator() + while (iter.hasNext()) { + val entry = iter.next() + NanoVG.nvgDeleteImage(vg, entry.value.nvg) + iter.remove() + } + } + + override fun deleteImage(image: Image) { + val nvgImage = images[image] ?: return + nvgImage.count-- + if (nvgImage.count == 0) { + NanoVG.nvgDeleteImage(vg, nvgImage.nvg) + images.remove(image) + } + } + + private fun getImage(image: Image): Int { + return images[image]?.nvg ?: throw IllegalStateException("Image (${image.identifier}) doesn't exist") + } + + private fun loadImage(image: Image): Int { + val w = IntArray(1) + val h = IntArray(1) + val channels = IntArray(1) + val buffer = STBImage.stbi_load_from_memory(image.buffer(), w, h, channels, 4) + ?: throw NullPointerException("Failed to load image: ${image.identifier}") + return NanoVG.nvgCreateImageRGBA(vg, w[0], h[0], 0, buffer) + } + + private fun loadSVG(image: Image, svgWidth: Int, svgHeight: Int, color: Int): Int { + var vec = image.stream.use { it.bufferedReader().readText() } + + val hexColor = "#%06X".format(color and 0xFFFFFF) + vec = vec.replace("currentColor", hexColor) + + val svg = NanoSVG.nsvgParse(vec, "px", 96f) + ?: throw IllegalStateException("Failed to parse ${image.identifier}") + + val width = if (svgWidth > 0) svgWidth else svg.width().toInt() + val height = if (svgHeight > 0) svgHeight else svg.height().toInt() + val buffer = MemoryUtil.memAlloc(width * height * 4) + + val previousTexture = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D) + + try { + val rasterizer = NanoSVG.nsvgCreateRasterizer() + NanoSVG.nsvgRasterize(rasterizer, svg, 0f, 0f, width.toFloat() / svg.width(), buffer, width, height, width * 4) + val nvgImage = NanoVG.nvgCreateImageRGBA(vg, width, height, 0, buffer) + NanoSVG.nsvgDeleteRasterizer(rasterizer) + + GL11.glBindTexture(GL11.GL_TEXTURE_2D, previousTexture) + + return nvgImage + } finally { + NanoSVG.nsvgDelete(svg) + MemoryUtil.memFree(buffer) + } + } + + private fun color(color: Int) { + NanoVG.nvgRGBA(color.red.toByte(), color.green.toByte(), color.blue.toByte(), color.alpha.toByte(), nvgColor) + } + + private fun color(color1: Int, color2: Int) { + NanoVG.nvgRGBA(color1.red.toByte(), color1.green.toByte(), color1.blue.toByte(), color1.alpha.toByte(), nvgColor) + NanoVG.nvgRGBA(color2.red.toByte(), color2.green.toByte(), color2.blue.toByte(), color2.alpha.toByte(), nvgColor2) + } + + private fun gradient(color1: Int, color2: Int, x: Float, y: Float, w: Float, h: Float, direction: Gradient) { + color(color1, color2) + when (direction) { + Gradient.LeftToRight -> NanoVG.nvgLinearGradient(vg, x, y, x + w, y, nvgColor, nvgColor2, nvgPaint) + Gradient.TopToBottom -> NanoVG.nvgLinearGradient(vg, x, y, x, y + h, nvgColor, nvgColor2, nvgPaint) + Gradient.TopLeftToBottomRight -> NanoVG.nvgLinearGradient(vg, x, y, x + w, y + h, nvgColor, nvgColor2, nvgPaint) + } + } + + private fun getFontID(font: Font): Int { + return fontMap.getOrPut(font) { + val buffer = font.buffer() + NVGFont(NanoVG.nvgCreateFontMem(vg, font.name, buffer, false), buffer) + }.id + } + + private class Scissor(val previous: Scissor?, val x: Float, val y: Float, val maxX: Float, val maxY: Float) { + fun applyScissor() { + if (previous == null) NanoVG.nvgScissor(vg, x, y, maxX - x, maxY - y) + else { + val x = max(x, previous.x) + val y = max(y, previous.y) + val width = max(0f, (min(maxX, previous.maxX) - x)) + val height = max(0f, (min(maxY, previous.maxY) - y)) + NanoVG.nvgScissor(vg, x, y, width, height) + } + } + } + + private data class NVGImage(var count: Int, val nvg: Int) + private data class NVGFont(val id: Int, val buffer: ByteBuffer) +} diff --git a/src/main/kotlin/xyz/meowing/lattice/render/Renderer.kt b/src/main/kotlin/xyz/meowing/lattice/render/Renderer.kt new file mode 100644 index 0000000..76bfcb2 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/render/Renderer.kt @@ -0,0 +1,51 @@ +package xyz.meowing.lattice.render + +import xyz.meowing.lattice.Lattice.defaultFont + +interface Renderer { + fun beginFrame(width: Float, height: Float) + fun endFrame() + + fun push() + fun pop() + + fun scale(x: Float, y: Float) + fun translate(x: Float, y: Float) + fun rotate(amount: Float) + fun globalAlpha(amount: Float) + + fun pushScissor(x: Float, y: Float, w: Float, h: Float) + fun popScissor() + + fun line(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, color: Int) + + fun rect(x: Float, y: Float, w: Float, h: Float, color: Int) + fun rect(x: Float, y: Float, w: Float, h: Float, color: Int, radius: Float) + fun rect(x: Float, y: Float, w: Float, h: Float, color: Int, radius: Float, roundTop: Boolean) + fun rect(x: Float, y: Float, w: Float, h: Float, color: Int, topRight: Float, topLeft: Float, bottomRight: Float, bottomLeft: Float) + fun gradientRect(x: Float, y: Float, w: Float, h: Float, color1: Int, color2: Int, gradient: Gradient, radius: Float) + + fun hollowRect(x: Float, y: Float, w: Float, h: Float, thickness: Float, color: Int, radius: Float) + fun hollowGradientRect(x: Float, y: Float, w: Float, h: Float, thickness: Float, color1: Int, color2: Int, gradient: Gradient, radius: Float) + + fun circle(x: Float, y: Float, radius: Float, color: Int) + + fun dropShadow(x: Float, y: Float, width: Float, height: Float, blur: Float, spread: Float, shadowColor: Int, radius: Float) + + fun text(text: String, x: Float, y: Float, size: Float, color: Int, font: Font = defaultFont) + fun wrappedText(text: String, x: Float, y: Float, w: Float, size: Float, color: Int, font: Font = defaultFont, lineHeight: Float = 1f) + fun shadowedText(text: String, x: Float, y: Float, size: Float, color: Int, font: Font = defaultFont, shadowColor: Int = 0x80000000.toInt(), offsetX: Float = 1.5f, offsetY: Float = 1.5f, blur: Float = 2f) + + fun textWidth(text: String, size: Float, font: Font = defaultFont): Float + fun textBounds(text: String, w: Float, size: Float, font: Font = defaultFont, lineHeight: Float = 1f): FloatArray + + fun image(image: Int, textureWidth: Int, textureHeight: Int, subX: Int, subY: Int, subW: Int, subH: Int, x: Float, y: Float, w: Float, h: Float, radius: Float) + fun image(image: Image, x: Float, y: Float, w: Float, h: Float, radius: Float = 0f) + + fun svg(id: String, x: Float, y: Float, w: Float, h: Float, alpha: Float = 1f) + + fun createImage(resourcePath: String, width: Int = -1, height: Int = -1, color: Int = 0xFFFFFFFF.toInt()): Image + fun deleteImage(image: Image) + + fun cleanCache() +} diff --git a/src/main/kotlin/xyz/meowing/lattice/render/Resolution.kt b/src/main/kotlin/xyz/meowing/lattice/render/Resolution.kt new file mode 100644 index 0000000..2e65ef5 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/render/Resolution.kt @@ -0,0 +1,27 @@ +package xyz.meowing.lattice.render + +import com.mojang.blaze3d.platform.Window +import xyz.meowing.lattice.client.Client.minecraft + +object Resolution { + @JvmStatic + val window: Window get() = minecraft.window + + @JvmStatic + val windowHandle: Long get() = window.handle() + + @JvmStatic + val windowWidth: Int get() = window.getWidth() + + @JvmStatic + val windowHeight: Int get() = window.getHeight() + + @JvmStatic + val scaledWidth: Int get() = window.getGuiScaledWidth() + + @JvmStatic + val scaledHeight: Int get() = window.getGuiScaledHeight() + + @JvmStatic + val scaleFactor: Double get() = window.getGuiScale().toDouble() +} diff --git a/src/main/kotlin/xyz/meowing/lattice/scheduler/TickScheduler.kt b/src/main/kotlin/xyz/meowing/lattice/scheduler/TickScheduler.kt new file mode 100644 index 0000000..b43b15d --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/scheduler/TickScheduler.kt @@ -0,0 +1,85 @@ +package xyz.meowing.lattice.scheduler + +import org.apache.logging.log4j.LogManager +import xyz.meowing.lattice.Lattice +import xyz.meowing.lattice.event.TickEvent +import java.util.PriorityQueue + +/** Runs actions on the client tick. Wired to the event bus on first use. */ +object TickScheduler { + private val logger = LogManager.getLogger(TickScheduler::class.java) + private val queue = PriorityQueue(compareBy { it.executeTick }) + private var nextId = 0L + + var currentTick = 0L + private set + + interface Handle { + val isCancelled: Boolean + fun cancel() + } + + private class Task( + var executeTick: Long, + val action: () -> Unit, + val interval: () -> Long = { 0 } + ) : Handle { + override var isCancelled = false + private set + + override fun cancel() { + isCancelled = true + } + } + + init { + Lattice.eventBus.register { onTick() } + } + + private fun onTick() { + currentTick++ + while (true) { + val task = synchronized(queue) { + queue.peek()?.takeIf { currentTick >= it.executeTick }?.let { queue.poll() } + } ?: break + + if (task.isCancelled) continue + + try { + task.action() + } catch (e: Exception) { + logger.error("Caught error while trying to run action", e) + } + + val interval = task.interval() + if (interval > 0 && !task.isCancelled) { + task.executeTick = currentTick + interval + synchronized(queue) { queue.offer(task) } + } + } + } + + fun post(action: () -> Unit) { + schedule(1, action) + } + + fun schedule(delay: Long, action: () -> Unit): Handle { + val task = Task(currentTick + delay, action) + synchronized(queue) { queue.offer(task) } + return task + } + + fun repeat(interval: Long, initialDelay: Long = interval, action: () -> Unit): Handle { + return repeatDynamic({ interval }, initialDelay, action) + } + + fun repeatDynamic( + intervalProvider: () -> Long, + initialDelay: Long = intervalProvider(), + action: () -> Unit + ): Handle { + val task = Task(currentTick + initialDelay, action, intervalProvider) + synchronized(queue) { queue.offer(task) } + return task + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/scheduler/TimeScheduler.kt b/src/main/kotlin/xyz/meowing/lattice/scheduler/TimeScheduler.kt new file mode 100644 index 0000000..4c6a893 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/scheduler/TimeScheduler.kt @@ -0,0 +1,93 @@ +package xyz.meowing.lattice.scheduler + +import org.apache.logging.log4j.LogManager +import org.jetbrains.annotations.ApiStatus +import xyz.meowing.lattice.Lattice +import xyz.meowing.lattice.event.ClientEvent +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +/** Runs actions on background threads using wall-clock delays. */ +object TimeScheduler { + private val executor = Executors.newScheduledThreadPool(Runtime.getRuntime().availableProcessors()) + private val tasks = ConcurrentHashMap>() + private val logger = LogManager.getLogger(TimeScheduler::class.java) + private val nextId = AtomicLong() + + interface Handle { + val isCancelled: Boolean + fun cancel() + } + + init { + Lattice.eventBus.register { shutdown() } + } + + fun schedule(delayMillis: Long, action: () -> Unit): Handle { + val id = nextId.getAndIncrement() + tasks[id] = executor.schedule({ + runSafely(action) + tasks.remove(id) + }, delayMillis, TimeUnit.MILLISECONDS) + return createHandle(id) + } + + fun repeat( + intervalMillis: Long, + initialDelayMillis: Long = intervalMillis, + stopCondition: () -> Boolean = { false }, + action: () -> Unit + ): Handle { + return repeatDynamic({ intervalMillis }, initialDelayMillis, stopCondition, action) + } + + fun repeatDynamic( + intervalProvider: () -> Long, + initialDelayMillis: Long = intervalProvider(), + stopCondition: () -> Boolean = { false }, + action: () -> Unit + ): Handle { + val id = nextId.getAndIncrement() + + fun scheduleNext(delay: Long) { + if (stopCondition()) { + tasks.remove(id) + return + } + tasks[id] = executor.schedule({ + runSafely(action) + if (tasks.containsKey(id)) scheduleNext(intervalProvider()) + }, delay, TimeUnit.MILLISECONDS) + } + + scheduleNext(initialDelayMillis) + return createHandle(id) + } + + @ApiStatus.Internal + fun shutdown() { + tasks.clear() + executor.shutdown() + } + + private fun createHandle(id: Long): Handle { + return object : Handle { + override val isCancelled: Boolean get() = !tasks.containsKey(id) + + override fun cancel() { + tasks.remove(id)?.cancel(false) + } + } + } + + private fun runSafely(action: () -> Unit) { + try { + action() + } catch (e: Exception) { + logger.error("Caught error while trying to run action", e) + } + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/text/ChainBuilder.kt b/src/main/kotlin/xyz/meowing/lattice/text/ChainBuilder.kt new file mode 100644 index 0000000..19a5808 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/text/ChainBuilder.kt @@ -0,0 +1,42 @@ +package xyz.meowing.lattice.text + +import xyz.meowing.lattice.text.Texts + +import net.minecraft.network.chat.Component as VanillaText + +class ChainBuilder { + private val parts = mutableListOf() + + fun text(content: String, builder: TextBuilder.() -> Unit = {}): ChainBuilder { + parts.add(Texts.literal(content).apply(builder)) + return this + } + + fun append(builder: TextBuilder): ChainBuilder { + parts.add(builder) + return this + } + + fun newLine(): ChainBuilder { + parts.add(Texts.literal("\n")) + return this + } + + fun space(): ChainBuilder { + parts.add(Texts.literal(" ")) + return this + } + + fun build(): TextBuilder { + if (parts.isEmpty()) return Texts.empty() + val result = parts.first() + for (i in 1 until parts.size) { + result.append(parts[i]) + } + return result + } + + fun toVanilla(): VanillaText { + return build().toVanilla() + } +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/text/ClickEvent.kt b/src/main/kotlin/xyz/meowing/lattice/text/ClickEvent.kt new file mode 100644 index 0000000..e01ca53 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/text/ClickEvent.kt @@ -0,0 +1,11 @@ +package xyz.meowing.lattice.text + +import java.net.URI + +sealed interface ClickEvent { + data class OpenUrl(val url: URI) : ClickEvent + data class RunCommand(val command: String) : ClickEvent + data class SuggestCommand(val command: String) : ClickEvent + data class CopyToClipboard(val text: String) : ClickEvent + data class ChangePage(val page: Int) : ClickEvent +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/text/ColorCodes.kt b/src/main/kotlin/xyz/meowing/lattice/text/ColorCodes.kt new file mode 100644 index 0000000..e4d2e69 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/text/ColorCodes.kt @@ -0,0 +1,23 @@ +package xyz.meowing.lattice.text + +object ColorCodes { + const val BLACK: Int = 0x000000 + const val DARK_BLUE: Int = 0x0000AA + const val DARK_GREEN: Int = 0x00AA00 + const val DARK_AQUA: Int = 0x00AAAA + const val DARK_RED: Int = 0xAA0000 + const val DARK_PURPLE: Int = 0xAA00AA + const val GOLD: Int = 0xFFAA00 + const val GRAY: Int = 0xAAAAAA + const val DARK_GRAY: Int = 0x555555 + const val BLUE: Int = 0x5555FF + const val GREEN: Int = 0x55FF55 + const val AQUA: Int = 0x55FFFF + const val RED: Int = 0xFF5555 + const val LIGHT_PURPLE: Int = 0xFF55FF + const val YELLOW: Int = 0xFFFF55 + const val WHITE: Int = 0xFFFFFF + + @JvmStatic + fun hex(hex: String): Int = hex.removePrefix("#").toInt(16) +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/text/FormattingCodes.kt b/src/main/kotlin/xyz/meowing/lattice/text/FormattingCodes.kt new file mode 100644 index 0000000..032284f --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/text/FormattingCodes.kt @@ -0,0 +1,42 @@ +package xyz.meowing.lattice.text + +object FormattingCodes { + const val SECTION = '§' + private val stripRegex = "$SECTION[0-9a-fk-or]".toRegex(RegexOption.IGNORE_CASE) + + @JvmStatic + fun codeToColor(code: Char): Int? = when (code.lowercaseChar()) { + '0' -> ColorCodes.BLACK + '1' -> ColorCodes.DARK_BLUE + '2' -> ColorCodes.DARK_GREEN + '3' -> ColorCodes.DARK_AQUA + '4' -> ColorCodes.DARK_RED + '5' -> ColorCodes.DARK_PURPLE + '6' -> ColorCodes.GOLD + '7' -> ColorCodes.GRAY + '8' -> ColorCodes.DARK_GRAY + '9' -> ColorCodes.BLUE + 'a' -> ColorCodes.GREEN + 'b' -> ColorCodes.AQUA + 'c' -> ColorCodes.RED + 'd' -> ColorCodes.LIGHT_PURPLE + 'e' -> ColorCodes.YELLOW + 'f' -> ColorCodes.WHITE + else -> null + } + + @JvmStatic + fun strip(text: String): String = text.replace(stripRegex, "") + + @JvmStatic + fun translateAlternate(altChar: Char, text: String): String { + val chars = text.toCharArray() + for (i in 0 until chars.size - 1) { + if (chars[i] == altChar && "0123456789AaBbCcDdEeFfKkLlMmNnOoRr".indexOf(chars[i + 1]) > -1) { + chars[i] = SECTION + chars[i + 1] = chars[i + 1].lowercaseChar() + } + } + return String(chars) + } +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/text/HoverEvent.kt b/src/main/kotlin/xyz/meowing/lattice/text/HoverEvent.kt new file mode 100644 index 0000000..4028f0d --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/text/HoverEvent.kt @@ -0,0 +1,9 @@ +package xyz.meowing.lattice.text + +import xyz.meowing.lattice.text.TextBuilder +import net.minecraft.world.item.ItemStackTemplate + +sealed interface HoverEvent { + data class ShowText(val text: TextBuilder) : HoverEvent + data class ShowItem(val stack: ItemStackTemplate) : HoverEvent +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/text/TextBuilder.kt b/src/main/kotlin/xyz/meowing/lattice/text/TextBuilder.kt new file mode 100644 index 0000000..7e7f123 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/text/TextBuilder.kt @@ -0,0 +1,258 @@ +package xyz.meowing.lattice.text + +import net.minecraft.network.chat.ClickEvent as ModernClickEvent +import net.minecraft.network.chat.HoverEvent as ModernHoverEvent +import xyz.meowing.lattice.text.Texts +import xyz.meowing.lattice.text.ClickEvent +import xyz.meowing.lattice.text.HoverEvent +import xyz.meowing.lattice.text.ColorCodes +import java.net.URI + +import net.minecraft.network.chat.Component as VanillaText +import net.minecraft.network.chat.TextColor + +class TextBuilder internal constructor( + internal var text: String +) { + internal var vanilla: VanillaText? = null + + private val siblings = mutableListOf() + + private var color: Int? = null + private var bold: Boolean? = null + private var italic: Boolean? = null + private var underlined: Boolean? = null + private var strikethrough: Boolean? = null + private var obfuscated: Boolean? = null + private var clickEvent: ClickEvent? = null + private var hoverEvent: HoverEvent? = null + private var insertion: String? = null + + fun color(hex: String): TextBuilder { + color = hex.removePrefix("#").toInt(16) + return this + } + + fun color(rgb: Int): TextBuilder { + color = rgb + return this + } + + fun black(): TextBuilder = color(ColorCodes.BLACK) + fun darkBlue(): TextBuilder = color(ColorCodes.DARK_BLUE) + fun darkGreen(): TextBuilder = color(ColorCodes.DARK_GREEN) + fun darkAqua(): TextBuilder = color(ColorCodes.DARK_AQUA) + fun darkRed(): TextBuilder = color(ColorCodes.DARK_RED) + fun darkPurple(): TextBuilder = color(ColorCodes.DARK_PURPLE) + fun gold(): TextBuilder = color(ColorCodes.GOLD) + fun gray(): TextBuilder = color(ColorCodes.GRAY) + fun darkGray(): TextBuilder = color(ColorCodes.DARK_GRAY) + fun blue(): TextBuilder = color(ColorCodes.BLUE) + fun green(): TextBuilder = color(ColorCodes.GREEN) + fun aqua(): TextBuilder = color(ColorCodes.AQUA) + fun red(): TextBuilder = color(ColorCodes.RED) + fun lightPurple(): TextBuilder = color(ColorCodes.LIGHT_PURPLE) + fun yellow(): TextBuilder = color(ColorCodes.YELLOW) + fun white(): TextBuilder = color(ColorCodes.WHITE) + + fun bold(value: Boolean = true): TextBuilder { + bold = value + return this + } + + fun italic(value: Boolean = true): TextBuilder { + italic = value + return this + } + + fun underlined(value: Boolean = true): TextBuilder { + underlined = value + return this + } + + fun strikethrough(value: Boolean = true): TextBuilder { + strikethrough = value + return this + } + + fun obfuscated(value: Boolean = true): TextBuilder { + obfuscated = value + return this + } + + fun reset(): TextBuilder { + color = null + bold = null + italic = null + underlined = null + strikethrough = null + obfuscated = null + return this + } + + fun onClick(event: ClickEvent): TextBuilder { + clickEvent = event + return this + } + + fun onClick(url: String): TextBuilder { + clickEvent = ClickEvent.OpenUrl(URI.create(url)) + return this + } + + fun onHover(event: HoverEvent): TextBuilder { + hoverEvent = event + return this + } + + fun onHover(text: String): TextBuilder { + hoverEvent = HoverEvent.ShowText(Texts.literal(text)) + return this + } + + fun onHover(builder: TextBuilder): TextBuilder { + hoverEvent = HoverEvent.ShowText(builder) + return this + } + + fun insertion(text: String): TextBuilder { + insertion = text + return this + } + + fun append(builder: TextBuilder): TextBuilder { + siblings.add(builder) + return this + } + + fun append(text: String): TextBuilder { + siblings.add(Texts.literal(text)) + return this + } + + fun appendLine(): TextBuilder { + siblings.add(Texts.literal("\n")) + return this + } + + fun appendLine(text: String): TextBuilder { + siblings.add(Texts.literal(text)) + siblings.add(Texts.literal("\n")) + return this + } + + fun appendLine(builder: TextBuilder): TextBuilder { + siblings.add(builder) + siblings.add(Texts.literal("\n")) + return this + } + + fun suggestCommand(command: String): TextBuilder { + clickEvent = ClickEvent.SuggestCommand(command) + return this + } + + fun runCommand(command: String): TextBuilder { + clickEvent = ClickEvent.RunCommand(command) + return this + } + + fun copyToClipboard(text: String): TextBuilder { + clickEvent = ClickEvent.CopyToClipboard(text) + return this + } + + fun openUrl(url: String): TextBuilder { + clickEvent = ClickEvent.OpenUrl(URI.create(url)) + return this + } + + fun changePage(page: Int): TextBuilder { + clickEvent = ClickEvent.ChangePage(page) + return this + } + + fun build(): VanillaText { + vanilla?.let { return it } + + val base = VanillaText.literal(text) + var style = base.getStyle() + color?.let { + //#if MC >= 1.21.5 + style = style.withColor(TextColor.fromRgb(it)) + //#else + //$$ style = style.withColor(it) + //#endif + } + + bold?.let { style = style.withBold(it) } + italic?.let { style = style.withItalic(it) } + underlined?.let { style = style.withUnderlined(it) } + strikethrough?.let { style = style.withStrikethrough(it) } + obfuscated?.let { style = style.withObfuscated(it) } + insertion?.let { style = style.withInsertion(it) } + + clickEvent?.let { + style = style.withClickEvent(when (it) { + //#if MC >= 1.21.5 + is ClickEvent.OpenUrl -> ModernClickEvent.OpenUrl(it.url) + is ClickEvent.RunCommand -> ModernClickEvent.RunCommand(it.command) + is ClickEvent.SuggestCommand -> ModernClickEvent.SuggestCommand(it.command) + is ClickEvent.CopyToClipboard -> ModernClickEvent.CopyToClipboard(it.text) + is ClickEvent.ChangePage -> ModernClickEvent.ChangePage(it.page) + //#else + //$$ is ClickEvent.OpenUrl -> ModernClickEvent(ModernClickEvent.Action.OPEN_URL, it.url.toString()) + //$$ is ClickEvent.RunCommand -> ModernClickEvent(ModernClickEvent.Action.RUN_COMMAND, it.command) + //$$ is ClickEvent.SuggestCommand -> ModernClickEvent(ModernClickEvent.Action.SUGGEST_COMMAND, it.command) + //$$ is ClickEvent.CopyToClipboard -> ModernClickEvent(ModernClickEvent.Action.COPY_TO_CLIPBOARD, it.text) + //$$ is ClickEvent.ChangePage -> ModernClickEvent(ModernClickEvent.Action.CHANGE_PAGE, it.page.toString()) + //#endif + }) + } + + hoverEvent?.let { + style = style.withHoverEvent(when (it) { + //#if MC >= 1.21.5 + is HoverEvent.ShowText -> ModernHoverEvent.ShowText(it.text.build()) + is HoverEvent.ShowItem -> ModernHoverEvent.ShowItem(it.stack) + //#else + //$$ is HoverEvent.ShowText -> ModernHoverEvent(ModernHoverEvent.Action.SHOW_TEXT, it.text.build()) + //$$ is HoverEvent.ShowItem -> { + //$$ ModernHoverEvent( + //$$ ModernHoverEvent.Action.SHOW_ITEM, + //#if FABRIC + //$$ ModernHoverEvent.ItemStackContent(it.stack) + //#else + //$$ ModernHoverEvent.ItemStackInfo(it.stack) + //#endif + //$$ ) + //$$ } + //#endif + }) + } + + base.setStyle(style) + siblings.forEach { base.append(it.build()) } + return base + } + + fun toVanilla(): VanillaText { + return build() + } + + fun string(): String { + return build().string + } + + fun formatted(): String { + return build().string + } + + operator fun plus(other: TextBuilder): TextBuilder { + return this.append(other) + } + + operator fun plus(other: String): TextBuilder { + return this.append(other) + } +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/text/Texts.kt b/src/main/kotlin/xyz/meowing/lattice/text/Texts.kt new file mode 100644 index 0000000..9c53512 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/text/Texts.kt @@ -0,0 +1,100 @@ +package xyz.meowing.lattice.text + +import xyz.meowing.lattice.text.ChainBuilder +import xyz.meowing.lattice.text.TextBuilder +import xyz.meowing.lattice.text.FormattingCodes + +import net.minecraft.network.chat.Component as VanillaText + +object Texts { + @JvmStatic + fun literal(text: String): TextBuilder = TextBuilder(text) + + @JvmStatic + fun empty(): TextBuilder = TextBuilder("") + + @JvmStatic + fun fromVanilla( + text: VanillaText + ): TextBuilder { + val builder = TextBuilder("") + builder.vanilla = text + return builder + } + + @JvmStatic + fun fromFormatted(text: String): TextBuilder { + val parts = text.split(FormattingCodes.SECTION) + if (parts.size <= 1) return literal(text) + + val root = empty() + var current = literal("") + + for (i in parts.indices) { + if (i == 0 && parts[i].isNotEmpty()) { + root.append(literal(parts[i])) + continue + } + + val part = parts[i] + if (part.isEmpty()) continue + + val code = part[0].lowercaseChar() + val content = if (part.length > 1) part.substring(1) else "" + + when (code) { + in '0'..'9', in 'a'..'f' -> { + if (current.text.isNotEmpty()) root.append(current) + current = literal(content) + FormattingCodes.codeToColor(code)?.let { current.color(it) } + } + 'r' -> { + if (current.text.isNotEmpty()) root.append(current) + current = literal(content) + } + 'l' -> { + current.bold() + current.text += content + } + 'o' -> { + current.italic() + current.text += content + } + 'n' -> { + current.underlined() + current.text += content + } + 'm' -> { + current.strikethrough() + current.text += content + } + 'k' -> { + current.obfuscated() + current.text += content + } + else -> current.text += FormattingCodes.SECTION + part + } + } + + if (current.text.isNotEmpty()) root.append(current) + return root + } + + @JvmStatic + fun builder(): ChainBuilder = ChainBuilder() +} + +fun String.asText(): TextBuilder = Texts.literal(this) + +fun String.asFormattedText(): TextBuilder = Texts.fromFormatted(this) + +fun VanillaText.asBuilder(): TextBuilder = Texts.fromVanilla(this) +fun VanillaText.toBuilder(): TextBuilder = Texts.fromVanilla(this) + +fun text(content: String, builder: TextBuilder.() -> Unit = {}): TextBuilder { + return Texts.literal(content).apply(builder) +} + +fun buildText(builder: ChainBuilder.() -> Unit): TextBuilder { + return Texts.builder().apply(builder).build() +} \ No newline at end of file diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/Box.kt b/src/main/kotlin/xyz/meowing/lattice/ui/Box.kt new file mode 100644 index 0000000..9529541 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/Box.kt @@ -0,0 +1,351 @@ +@file:Suppress("UNCHECKED_CAST") + +package xyz.meowing.lattice.ui + +import xyz.meowing.lattice.ui.theme.Theme +import xyz.meowing.lattice.Lattice.renderer +import xyz.meowing.lattice.animation.EasingType +import xyz.meowing.lattice.animation.fadeIn +import xyz.meowing.lattice.animation.fadeOut + +/** Base for elements with padding and optional vertical scrolling. */ +abstract class Box>( + var padding: FloatArray = floatArrayOf(0f, 0f, 0f, 0f), + var scrollable: Boolean = false, + widthType: Size = Size.Auto, + heightType: Size = Size.Auto +) : Element(widthType, heightType) { + var scrollOffset: Float = 0f + var showScrollbar: Boolean = true + var scrollbarWidth: Float = 6f + var scrollbarColor: Int = Theme.scrollbar + var scrollbarRadius: Float = 3f + var scrollbarPadding: Float = 0f + var scissorBufferVertical: Float = 0f + var scissorBufferHorizontal: Float = 2f + + private var isDraggingScrollbar = false + private var scrollbarDragOffset = 0f + + private val viewWidth: Float get() = width - padding[1] - padding[3] + private val viewHeight: Float get() = height - padding[0] - padding[2] + + private fun drawScrollbar() { + if (!scrollable || !showScrollbar) return + val contentHeight = getContentHeight() + if (contentHeight <= viewHeight) return + + val scrollbarX = x + width - padding[1] - scrollbarWidth - scrollbarPadding + val scrollbarHeight = (viewHeight / contentHeight) * viewHeight + val scrollbarY = y + padding[0] + (scrollOffset / contentHeight) * viewHeight + + renderer.rect(scrollbarX, scrollbarY, scrollbarWidth, scrollbarHeight, scrollbarColor, scrollbarRadius) + } + + private fun isPointInScrollbar(mouseX: Float, mouseY: Float): Boolean { + if (!scrollable || !showScrollbar) return false + val contentHeight = getContentHeight() + if (contentHeight <= viewHeight) return false + + val scrollbarX = x + width - padding[1] - scrollbarWidth - scrollbarPadding + val scrollbarHeight = (viewHeight / contentHeight) * viewHeight + val scrollbarY = y + padding[0] + (scrollOffset / contentHeight) * viewHeight + + return mouseX >= scrollbarX && mouseX <= scrollbarX + scrollbarWidth && + mouseY >= scrollbarY && mouseY <= scrollbarY + scrollbarHeight + } + + protected fun updateHoverStates(mouseX: Float, mouseY: Float) { + val adjustedMouseY = if (scrollable) mouseY + scrollOffset else mouseY + val wasHovered = isHovered + isHovered = isPointInside(mouseX, mouseY) + + when { + isHovered && !wasHovered -> { + listeners.mouseEnter.forEach { it(MouseEvent.Move.Enter(mouseX, mouseY, this)) } + onHoverChanged(true) + } + !isHovered && wasHovered -> { + listeners.mouseExit.forEach { it(MouseEvent.Move.Exit(mouseX, mouseY, this)) } + onHoverChanged(false) + } + } + + if (isHovered) listeners.mouseMove.forEach { it(MouseEvent.Move(mouseX, mouseY, this)) } + + children.reversed().forEach { child -> + if (scrollable && !isMouseOnVisible(mouseX, mouseY)) { + unhoverRecursive(child, mouseX, adjustedMouseY) + } else { + child.handleMouseMove(mouseX, adjustedMouseY) + } + } + } + + protected open fun onHoverChanged(hovered: Boolean) { + tooltipElement?.let { tooltip -> + if (hovered) { + tooltip.fadeIn(Theme.animNormal, EasingType.EASE_OUT) + tooltip.innerText.fadeIn(Theme.animNormal, EasingType.EASE_OUT) + } else { + tooltip.fadeOut(Theme.animNormal, EasingType.EASE_OUT) + tooltip.innerText.fadeOut(Theme.animNormal, EasingType.EASE_OUT) + } + } + } + + private fun unhoverRecursive(element: Element<*>, mouseX: Float, mouseY: Float) { + if (element.isHovered) { + element.isHovered = false + element.listeners.mouseExit.forEach { it(MouseEvent.Move.Exit(mouseX, mouseY, this)) } + } + element.children.forEach { unhoverRecursive(it, mouseX, mouseY) } + } + + override fun handleMouseScroll(mouseX: Float, mouseY: Float, horizontal: Double, vertical: Double): Boolean { + if (!visible) return false + if (!isMouseOnVisible(mouseX, mouseY)) return false + + val adjustedMouseY = if (scrollable) mouseY + scrollOffset else mouseY + val childHandled = children.reversed().any { it.handleMouseScroll(mouseX, adjustedMouseY, horizontal, vertical) } + + if (!childHandled && scrollable && isPointInside(mouseX, mouseY)) { + val contentHeight = getContentHeight() + + if (contentHeight > viewHeight) { + val scrollAmount = vertical.toFloat() * -30f + val maxScroll = contentHeight - viewHeight + scrollOffset = (scrollOffset + scrollAmount).coerceIn(0f, maxScroll) + + updateHoverStates(mouseX, mouseY) + return true + } + } + + return childHandled + } + + override fun handleMouseMove(mouseX: Float, mouseY: Float): Boolean { + if (!visible) return false + + if (isDraggingScrollbar) { + val contentHeight = getContentHeight() + val relativeY = mouseY - scrollbarDragOffset - (y + padding[0]) + val scrollRatio = (relativeY / viewHeight).coerceIn(0f, 1f) + val maxScroll = contentHeight - viewHeight + scrollOffset = (scrollRatio * maxScroll).coerceIn(0f, maxScroll) + updateHoverStates(mouseX, mouseY) + return true + } + + updateHoverStates(mouseX, mouseY) + return isHovered + } + + override fun handleMouseClick(mouseX: Float, mouseY: Float, button: Int): Boolean { + if (!visible) return false + + if (isPointInScrollbar(mouseX, mouseY)) { + isDraggingScrollbar = true + val contentHeight = getContentHeight() + val scrollbarY = y + padding[0] + (scrollOffset / contentHeight) * viewHeight + scrollbarDragOffset = mouseY - scrollbarY + return true + } + + if (scrollable && !isMouseOnVisible(mouseX, mouseY)) return false + + val adjustedMouseY = if (scrollable) mouseY + scrollOffset else mouseY + val childHandled = children.reversed().any { it.handleMouseClick(mouseX, adjustedMouseY, button) } + + return when { + childHandled -> true + isPointInside(mouseX, mouseY) -> { + isPressed = true + focus() + listeners.mouseClick.any { it(MouseEvent.Click(mouseX, mouseY, button, this)) } || listeners.mouseClick.isEmpty() + } + else -> false + } + } + + override fun handleMouseRelease(mouseX: Float, mouseY: Float, button: Int): Boolean { + if (!visible) return false + + if (isDraggingScrollbar) { + isDraggingScrollbar = false + return true + } + + val adjustedMouseY = if (scrollable) mouseY + scrollOffset else mouseY + val wasPressed = isPressed + isPressed = false + + val childHandled = if (scrollable && !isMouseOnVisible(mouseX, mouseY)) { + false + } else { + children.reversed().any { it.handleMouseRelease(mouseX, adjustedMouseY, button) } + } + + return childHandled || + ( + wasPressed && + isPointInside(mouseX, mouseY) && + ( + listeners.mouseRelease.any { it(MouseEvent.Release(mouseX, mouseY, button, this)) } || + listeners.mouseRelease.isEmpty() + ) + ) + } + + fun getContentHeight(): Float { + val visibleChildren = children.filter { !it.isFloating } + if (visibleChildren.isEmpty()) return 0f + + val bottomChild = visibleChildren.maxByOrNull { it.y + it.height } ?: return 0f + return bottomChild.y + bottomChild.height - (y + padding[0]) + } + + fun isMouseOnVisible(mouseX: Float, mouseY: Float): Boolean { + if (!scrollable) return true + + val contentX = x + padding[3] + val contentY = y + padding[0] + + return mouseX >= contentX && mouseX <= contentX + viewWidth && mouseY >= contentY && mouseY <= contentY + viewHeight + } + + public override fun getAutoWidth(): Float { + val visibleChildren = children.filter { it.visible && !it.isFloating } + if (visibleChildren.isEmpty()) return padding[1] + padding[3] + + val minX = visibleChildren.minOf { it.x } + val maxX = visibleChildren.maxOf { it.x + it.width } + + val calculated = (maxX - minX) + padding[3] + padding[1] + return maxAutoWidth?.let { calculated.coerceAtMost(it) } ?: calculated + } + + public override fun getAutoHeight(): Float { + val visibleChildren = children.filter { it.visible && !it.isFloating } + if (visibleChildren.isEmpty()) return padding[0] + padding[2] + + val minY = visibleChildren.minOf { it.y } + val maxY = visibleChildren.maxOf { it.y + it.height } + + val calculated = (maxY - minY) + padding[0] + padding[2] + return maxAutoHeight?.let { calculated.coerceAtMost(it) } ?: calculated + } + + override fun renderChildren(mouseX: Float, mouseY: Float) { + if (scrollable) { + renderer.push() + renderer.pushScissor( + x + padding[3] - scissorBufferHorizontal, + y + padding[0] - scissorBufferVertical, + viewWidth + scissorBufferHorizontal * 2, + viewHeight + scissorBufferVertical * 2 + ) + renderer.translate(0f, -scrollOffset) + } + + children.forEach { it.render(mouseX, mouseY) } + + if (scrollable) { + renderer.popScissor() + renderer.pop() + } + + if (showScrollbar && (isHovered || isDraggingScrollbar)) drawScrollbar() + } + + fun isVisibleInScrollableParents(): Boolean { + var current: Any? = this + while (current != null) { + when (current) { + is Element<*> -> { + if (!current.visible) return false + if (current is Box<*> && current.scrollable) { + val centerX = getScreenX() + width / 2 + val centerY = getScreenY() + height / 2 + if (!current.isMouseOnVisible(centerX, centerY)) return false + } + current = current.parent + } + else -> break + } + } + return true + } + + fun getScreenX(): Float = x + + fun getScreenY(): Float { + var totalScrollOffset = 0f + var current = parent + while (current != null) { + when (current) { + is Box<*> -> totalScrollOffset += current.scrollOffset + is Window -> break + } + current = if (current is Element<*>) current.parent else null + } + return y - totalScrollOffset + } + + fun scrollable(enabled: Boolean): T { + scrollable = enabled + return this as T + } + + fun setScissorBuffer(vertical: Float, horizontal: Float): T { + scissorBufferVertical = vertical + scissorBufferHorizontal = horizontal + return this as T + } + + fun showScrollbar(show: Boolean): T { + showScrollbar = show + return this as T + } + + fun scrollbarWidth(width: Float): T { + scrollbarWidth = width + return this as T + } + + fun scrollbarColor(color: Int): T { + scrollbarColor = color + return this as T + } + + fun scrollbarRadius(radius: Float): T { + scrollbarRadius = radius + return this as T + } + + fun scrollbarPadding(padding: Float): T { + scrollbarPadding = padding + return this as T + } + + fun padding(top: Float = 0f, right: Float = 0f, bottom: Float = 0f, left: Float = 0f): T { + padding[0] = top + padding[1] = right + padding[2] = bottom + padding[3] = left + return this as T + } + + fun padding(all: Float): T = padding(all, all, all, all) + + fun width(newWidth: Float): T { + width = newWidth + return this as T + } + + fun height(newHeight: Float): T { + height = newHeight + return this as T + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/Element.kt b/src/main/kotlin/xyz/meowing/lattice/ui/Element.kt new file mode 100644 index 0000000..d80a35e --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/Element.kt @@ -0,0 +1,855 @@ +@file:Suppress("UNCHECKED_CAST") + +package xyz.meowing.lattice.ui + +import xyz.meowing.lattice.Lattice.renderer +import xyz.meowing.lattice.animation.AnimationManager +import xyz.meowing.lattice.animation.EasingType +import xyz.meowing.lattice.animation.fadeIn +import xyz.meowing.lattice.animation.fadeOut +import xyz.meowing.lattice.input.Mouse +import xyz.meowing.lattice.render.Resolution +import xyz.meowing.lattice.ui.component.Tooltip +import xyz.meowing.lattice.ui.theme.Theme + +abstract class Element>( + var widthType: Size = Size.Pixels, + var heightType: Size = Size.Pixels +) { + val children: MutableList> = mutableListOf() + + open var renderHitbox = false + open var xPositionConstraint = Pos.ParentPixels + open var yPositionConstraint = Pos.ParentPixels + open var xAlignment = Alignment.None + open var yAlignment = Alignment.None + + open var x: Float = 0f + set(value) { + field = value + invalidateChildrenPositions() + } + + open var y: Float = 0f + set(value) { + field = value + invalidateChildrenPositions() + } + + open var width: Float = 0f + set(value) { + field = value + invalidateChildrenPositions() + invalidateChildrenSizes() + } + + open var height: Float = 0f + set(value) { + field = value + invalidateChildrenPositions() + invalidateChildrenSizes() + } + + inner class Scaled { + val scaleFactor get() = Resolution.scaleFactor.toFloat() + + val left: Float get() = x / scaleFactor + val top: Float get() = y / scaleFactor + val right: Float get() = (x + width) / scaleFactor + val bottom: Float get() = (y + height) / scaleFactor + val centerX: Float get() = (x + width / 2f) / scaleFactor + val centerY: Float get() = (y + height / 2f) / scaleFactor + val width: Float get() = this@Element.width / scaleFactor + val height: Float get() = this@Element.height / scaleFactor + } + + inner class Raw { + val left get() = x + val top get() = y + val right get() = x + width + val bottom get() = y + height + val centerX get() = (left + right) / 2f + val centerY get() = (top + bottom) / 2f + val width get() = this@Element.width + val height get() = this@Element.height + } + + val raw = Raw() + val scaled = Scaled() + + var widthPercent: Float = 100f + var heightPercent: Float = 100f + + open var visible: Boolean = true + set(value) { + if (field != value) { + field = value + cache.invalidate() + invalidateChildrenCache() + } + } + + open var xConstraint: Float = 0f + open var yConstraint: Float = 0f + + open var maxAutoWidth: Float? = null + open var maxAutoHeight: Float? = null + + open var xOffset: Float = 0f + open var yOffset: Float = 0f + open var xOffsetType: Offset = Offset.Pixels + open var yOffsetType: Offset = Offset.Pixels + + open var isHovered: Boolean = false + open var isPressed: Boolean = false + open var isFocused: Boolean = false + open var isFloating: Boolean = false + open var ignoreFocus: Boolean = false + open var requiresFocus: Boolean = false + + val screenWidth: Int get() = Resolution.windowWidth + val screenHeight: Int get() = Resolution.windowHeight + + var parent: Any? = null + set(value) { + field = value + cache.invalidate() + } + + var tooltipElement: Tooltip? = null + val onValueChange = mutableListOf<(Any) -> Unit>() + + val cache = ElementCache() + val listeners = ElementListeners() + + // Bookkeeping for animation presets that restore the original geometry. + internal var presetOriginalSize: Pair? = null + internal var presetOriginalPosition: Pair? = null + + open fun invalidateChildrenCache() { + for (child in children) child.cache.invalidate() + } + + open fun invalidateChildrenPositions() { + for (child in children) child.cache.invalidatePosition() + } + + open fun invalidateChildrenSizes() { + for (child in children) child.cache.invalidateSize() + } + + private fun checkScreenResize() { + val resized = cache.lastScreenWidth != screenWidth || cache.lastScreenHeight != screenHeight + if (resized) { + cache.lastScreenWidth = screenWidth + cache.lastScreenHeight = screenHeight + + val screenPosModes = setOf(Pos.ScreenPercent, Pos.ScreenPixels, Pos.ScreenCenter) + if (xPositionConstraint in screenPosModes || xAlignment != Alignment.None) cache.positionCacheValid = false + if (yPositionConstraint in screenPosModes || yAlignment != Alignment.None) cache.positionCacheValid = false + if (widthType == Size.Percent && parent !is Element<*>) cache.sizeCacheValid = false + if (heightType == Size.Percent && parent !is Element<*>) cache.sizeCacheValid = false + } + } + + private fun renderDebugHitbox() { + if (!renderHitbox) return + children.forEach { it.enableDebugRendering() } + + val color = if (isFocused) 0xFFFFA500.toInt() else if (isHovered) 0xFFFFFF00.toInt() else 0xFF00FFFF.toInt() + + renderer.push() + renderer.hollowRect(x, y, width, height, 1f, color, 0f) + renderer.pop() + } + + open fun destroy() { + children.toList().forEach { it.destroy() } + children.clear() + listeners.clear() + tooltipElement?.destroy() + tooltipElement = null + onValueChange.clear() + + (parent as? Element<*>)?.children?.remove(this) + parent = null + } + + fun drawAsRoot() { + renderer.beginFrame(screenWidth.toFloat(), screenHeight.toFloat()) + renderer.push() + render(Mouse.Raw.x.toFloat(), Mouse.Raw.y.toFloat()) + AnimationManager.update() + renderer.pop() + renderer.endFrame() + } + + fun findFirstVisibleParent(): Element<*>? { + if (cache.parentCacheValid) return cache.cachedParent + + var current = parent + while (current != null) { + if (current is Element<*> && current.visible) { + cache.cachedParent = current + cache.parentCacheValid = true + return current + } + if (current is Window) { + cache.cachedParent = null + cache.parentCacheValid = true + return null + } + current = (current as? Element<*>)?.parent + } + + cache.cachedParent = null + cache.parentCacheValid = true + return null + } + + private fun getParentPadding(): FloatArray { + return (findFirstVisibleParent() as? Box<*>)?.padding ?: ZERO_PADDING + } + + private fun getSiblingsAfterSize(width: Boolean): Float { + val parentElement = parent as? Element<*> ?: return 0f + val myIndex = parentElement.children.indexOf(this) + if (myIndex == -1) return 0f + + return parentElement.children + .drop(myIndex + 1) + .filter { it.visible && !it.isFloating } + .sumOf { (if (width) it.width else it.height).toDouble() } + .toFloat() + } + + open fun updateWidth() { + if (cache.sizeCacheValid) { + width = cache.cachedWidth + return + } + + width = when (widthType) { + Size.Auto -> getAutoWidth() + Size.Percent -> { + val parentElement = findFirstVisibleParent() + if (parentElement == null) { + screenWidth * (widthPercent / 100f) + } else { + val padding = getParentPadding() + val availableWidth = parentElement.width - (padding[1] + padding[3]) + availableWidth * (widthPercent / 100f) + } + } + Size.Pixels -> width + Size.Fill -> { + val parentElement = findFirstVisibleParent() + if (parentElement == null) { + (screenWidth.toFloat() - x).coerceAtLeast(0f) + } else { + val padding = getParentPadding() + val parentRightEdge = parentElement.x + parentElement.width - padding[1] + (parentRightEdge - x - getSiblingsAfterSize(width = true)).coerceAtLeast(0f) + } + } + } + + cache.cachedWidth = width + } + + open fun updateHeight() { + if (cache.sizeCacheValid) { + height = cache.cachedHeight + return + } + + height = when (heightType) { + Size.Auto -> getAutoHeight() + Size.Percent -> { + val parentElement = findFirstVisibleParent() + if (parentElement == null) { + screenHeight * (heightPercent / 100f) + } else { + val padding = getParentPadding() + val availableHeight = parentElement.height - (padding[0] + padding[2]) + availableHeight * (heightPercent / 100f) + } + } + Size.Pixels -> height + Size.Fill -> { + val parentElement = findFirstVisibleParent() + if (parentElement == null) { + (screenHeight.toFloat() - y).coerceAtLeast(0f) + } else { + val padding = getParentPadding() + val parentBottomEdge = parentElement.y + parentElement.height - padding[2] + (parentBottomEdge - y - getSiblingsAfterSize(width = false)).coerceAtLeast(0f) + } + } + } + + cache.cachedHeight = height + } + + protected open fun getAutoWidth(): Float { + val maxWidth = children + .filter { it.visible && !it.isFloating } + .maxOfOrNull { (x - it.x) + it.width } + val calculated = maxWidth?.coerceAtLeast(0f) ?: 0f + return maxAutoWidth?.let { calculated.coerceAtMost(it) } ?: calculated + } + + protected open fun getAutoHeight(): Float { + val maxHeight = children + .filter { it.visible && !it.isFloating } + .maxOfOrNull { (y - it.y) + it.height } + val calculated = maxHeight?.coerceAtLeast(0f) ?: 0f + return maxAutoHeight?.let { calculated.coerceAtMost(it) } ?: calculated + } + + private fun computeOffset(offset: Float, offsetType: Offset, isWidth: Boolean): Float { + return when (offsetType) { + Offset.Pixels -> offset + Offset.Percent -> { + val parentElement = findFirstVisibleParent() + val base = if (isWidth) { + parentElement?.width ?: screenWidth.toFloat() + } else { + parentElement?.height ?: screenHeight.toFloat() + } + base * (offset / 100f) + } + } + } + + fun updateX() { + if (cache.positionCacheValid) return + + val visibleParent = findFirstVisibleParent() + val padding = getParentPadding() + val computedXOffset = computeOffset(xOffset, xOffsetType, true) + + x = when (xPositionConstraint) { + Pos.ParentPercent -> { + val base = if (visibleParent != null) { + visibleParent.x + padding[3] + (visibleParent.width - padding[1] - padding[3]) * (xConstraint / 100f) + } else { + xConstraint + } + base + computedXOffset + } + Pos.ScreenPercent -> screenWidth * (xConstraint / 100f) + computedXOffset + Pos.ParentPixels -> { + val base = if (visibleParent != null) visibleParent.x + padding[3] + xConstraint else xConstraint + base + computedXOffset + } + Pos.ScreenPixels -> xConstraint + computedXOffset + Pos.ParentCenter -> { + val base = if (visibleParent != null) { + val availableWidth = visibleParent.width - padding[1] - padding[3] + visibleParent.x + padding[3] + (availableWidth - width) / 2f + } else { + xConstraint + } + base + computedXOffset + } + Pos.ScreenCenter -> (screenWidth / 2f) - (width / 2f) + xConstraint + computedXOffset + Pos.AfterSibling -> computeAfterSiblingX(visibleParent) + computedXOffset + Pos.MatchSibling -> computeMatchSiblingX() + computedXOffset + } + + x = applyXAlignment(x, visibleParent, padding) + } + + private fun computeAfterSiblingX(visibleParent: Element<*>?): Float { + val parentElement = parent as? Element<*> ?: return xConstraint + + val padding = getParentPadding() + val index = parentElement.children.indexOf(this) + if (index <= 0) { + return if (visibleParent != null) visibleParent.x + padding[3] + xConstraint else xConstraint + } + + val prevVisible = parentElement.children.subList(0, index).lastOrNull { it.visible } + + val prev = prevVisible?.x ?: 0f + val width = prevVisible?.width ?: 0f + return prev + width + xConstraint + } + + private fun computeMatchSiblingX(): Float { + val parentElement = parent as? Element<*> ?: return 0f + + val index = parentElement.children.indexOf(this) + return if (index > 0) parentElement.children[index - 1].x else 0f + } + + fun updateY() { + if (cache.positionCacheValid) return + + val visibleParent = findFirstVisibleParent() + val padding = getParentPadding() + val computedYOffset = computeOffset(yOffset, yOffsetType, false) + + y = when (yPositionConstraint) { + Pos.ParentPercent -> { + val base = if (visibleParent != null) { + visibleParent.y + padding[0] + (visibleParent.height - padding[0] - padding[2]) * (yConstraint / 100f) + } else { + yConstraint + } + base + computedYOffset + } + Pos.ScreenPercent -> screenHeight * (yConstraint / 100f) + computedYOffset + Pos.ParentPixels -> { + val base = if (visibleParent != null) visibleParent.y + padding[0] + yConstraint else yConstraint + base + computedYOffset + } + Pos.ScreenPixels -> yConstraint + computedYOffset + Pos.ParentCenter -> { + val base = if (visibleParent != null) { + val availableHeight = visibleParent.height - padding[0] - padding[2] + visibleParent.y + padding[0] + (availableHeight - height) / 2f + } else { + yConstraint + } + base + computedYOffset + } + Pos.ScreenCenter -> (screenHeight / 2f) - (height / 2f) + yConstraint + computedYOffset + Pos.AfterSibling -> computeAfterSiblingY(visibleParent) + computedYOffset + Pos.MatchSibling -> computeMatchSiblingY() + computedYOffset + } + + y = applyYAlignment(y, visibleParent, padding) + } + + private fun computeAfterSiblingY(visibleParent: Element<*>?): Float { + val parentElement = parent as? Element<*> ?: return yConstraint + + val padding = getParentPadding() + val index = parentElement.children.indexOf(this) + if (index <= 0) { + return if (visibleParent != null) visibleParent.y + padding[0] + yConstraint else yConstraint + } + + val prevVisible = parentElement.children.subList(0, index).lastOrNull { it.visible } + + val prev = prevVisible?.y ?: 0f + val height = prevVisible?.height ?: 0f + return prev + height + yConstraint + } + + private fun computeMatchSiblingY(): Float { + val parentElement = parent as? Element<*> ?: return yConstraint + + val index = parentElement.children.indexOf(this) + return if (index > 0) parentElement.children[index - 1].y else yConstraint + } + + private fun applyXAlignment(baseX: Float, visibleParent: Element<*>?, padding: FloatArray): Float { + return when (xAlignment) { + Alignment.None -> baseX + Alignment.Start -> { + val leftEdge = if (visibleParent != null) visibleParent.x + padding[3] else 0f + leftEdge + xConstraint + } + Alignment.End -> { + val rightEdge = if (visibleParent != null) visibleParent.x + visibleParent.width - padding[1] else screenWidth.toFloat() + rightEdge - width + xConstraint + } + } + } + + private fun applyYAlignment(baseY: Float, visibleParent: Element<*>?, padding: FloatArray): Float { + return when (yAlignment) { + Alignment.None -> baseY + Alignment.Start -> { + val topEdge = if (visibleParent != null) visibleParent.y + padding[0] else 0f + topEdge + yConstraint + } + Alignment.End -> { + val bottomEdge = if (visibleParent != null) visibleParent.y + visibleParent.height - padding[2] else screenHeight.toFloat() + bottomEdge - height + yConstraint + } + } + } + + open fun isPointInside(mouseX: Float, mouseY: Float): Boolean { + return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height + } + + open fun handleMouseMove(mouseX: Float, mouseY: Float): Boolean { + if (!visible) return false + + val wasHovered = isHovered + isHovered = isPointInside(mouseX, mouseY) + + when { + isHovered && !wasHovered -> { + for (listener in listeners.mouseEnter) { + listener(MouseEvent.Move.Enter(mouseX, mouseY, this)) + } + + tooltipElement?.let { tooltip -> + tooltip.fadeIn(Theme.animNormal, EasingType.EASE_OUT) + tooltip.innerText.fadeIn(Theme.animNormal, EasingType.EASE_OUT) + } + } + !isHovered && wasHovered -> { + for (listener in listeners.mouseExit) { + listener(MouseEvent.Move.Exit(mouseX, mouseY, this)) + } + + tooltipElement?.let { tooltip -> + tooltip.fadeOut(Theme.animNormal, EasingType.EASE_OUT) + tooltip.innerText.fadeOut(Theme.animNormal, EasingType.EASE_OUT) + } + } + } + + if (isHovered) { + for (listener in listeners.mouseMove) { + listener(MouseEvent.Move(mouseX, mouseY, this)) + } + } + + val childHandled = children.reversed().any { it.handleMouseMove(mouseX, mouseY) } + return childHandled || isHovered + } + + open fun handleMouseClick(mouseX: Float, mouseY: Float, button: Int): Boolean { + if (!visible) return false + + val childHandled = children.reversed().any { + it.handleMouseClick(mouseX, mouseY, button) + } + + return when { + childHandled -> true + isPointInside(mouseX, mouseY) -> { + isPressed = true + focus() + val listenerHandled = listeners.mouseClick.any { + it(MouseEvent.Click(mouseX, mouseY, button, this)) + } + + listenerHandled || listeners.mouseClick.isEmpty() + } + else -> { + if (requiresFocus && isFocused) unfocus() + false + } + } + } + + open fun handleMouseRelease(mouseX: Float, mouseY: Float, button: Int): Boolean { + if (!visible) return false + + val wasPressed = isPressed + isPressed = false + + val childHandled = children.reversed().any { + it.handleMouseRelease(mouseX, mouseY, button) + } + + if (childHandled) return true + + if (wasPressed && isPointInside(mouseX, mouseY)) { + val listenerHandled = listeners.mouseRelease.any { + it(MouseEvent.Release(mouseX, mouseY, button, this)) + } + + return listenerHandled || listeners.mouseRelease.isEmpty() + } + + return false + } + + open fun handleMouseScroll(mouseX: Float, mouseY: Float, horizontal: Double, vertical: Double): Boolean { + if (!visible) return false + + val childHandled = children.reversed().any { + it.handleMouseScroll(mouseX, mouseY, horizontal, vertical) + } + + if (childHandled) return true + + if (isPointInside(mouseX, mouseY)) { + return listeners.mouseScroll.any { + it(MouseEvent.Scroll(mouseX, mouseY, horizontal, vertical, this)) + } + } + + return false + } + + open fun handleCharType(keyCode: Int, scanCode: Int, charTyped: Char): Boolean { + if (!visible) return false + + val childHandled = children.reversed().any { + it.handleCharType(keyCode, scanCode, charTyped) + } + + val selfHandled = if (isFocused || ignoreFocus) { + listeners.charType.any { it(KeyEvent.Type(keyCode, scanCode, charTyped, this)) } + } else false + + return childHandled || selfHandled + } + + fun focus() { + getRootElement().unfocusAll() + isFocused = true + } + + fun unfocus() { + isFocused = false + } + + private fun unfocusAll() { + if (isFocused) unfocus() + children.forEach { it.unfocusAll() } + } + + fun getRootElement(): Element<*> { + var current: Element<*> = this + while (current.parent is Element<*>) { + current = current.parent as Element<*> + } + return current + } + + open fun onWindowResize() { + cache.invalidate() + for (child in children) child.onWindowResize() + } + + open fun render(mouseX: Float, mouseY: Float) { + if (!visible) return + + checkScreenResize() + + updateHeight() + updateWidth() + updateX() + updateY() + + cache.sizeCacheValid = true + cache.positionCacheValid = true + + onRender(mouseX, mouseY) + renderChildren(mouseX, mouseY) + renderDebugHitbox() + } + + protected open fun renderChildren(mouseX: Float, mouseY: Float) { + children.forEach { it.render(mouseX, mouseY) } + } + + protected abstract fun onRender(mouseX: Float, mouseY: Float) + + fun childOf(parent: Element<*>): T { + parent.addChild(this) + return this as T + } + + fun childOf(parent: Window): T { + parent.addChild(this) + return this as T + } + + fun addChild(child: Element<*>): T { + child.parent = this + children.add(child) + return this as T + } + + fun setMaxAutoSize(maxWidth: Float? = null, maxHeight: Float? = null): T { + this.maxAutoWidth = maxWidth + this.maxAutoHeight = maxHeight + cache.sizeCacheValid = false + return this as T + } + + fun setSizing(widthType: Size, heightType: Size): T { + this.widthType = widthType + this.heightType = heightType + cache.sizeCacheValid = false + return this as T + } + + fun setSizing(width: Float, widthType: Size, height: Float, heightType: Size): T { + this.widthType = widthType + this.heightType = heightType + + if (widthType == Size.Pixels) this.width = width else this.widthPercent = width + if (heightType == Size.Pixels) this.height = height else this.heightPercent = height + + cache.sizeCacheValid = false + return this as T + } + + fun setPositioning(xConstraint: Pos, yConstraint: Pos): T { + this.xPositionConstraint = xConstraint + this.yPositionConstraint = yConstraint + cache.positionCacheValid = false + return this as T + } + + fun setPositioning(xVal: Float, xPos: Pos, yVal: Float, yPos: Pos): T { + this.xConstraint = xVal + this.xPositionConstraint = xPos + this.yConstraint = yVal + this.yPositionConstraint = yPos + cache.positionCacheValid = false + return this as T + } + + fun setAlignment(xAlignment: Alignment, yAlignment: Alignment): T { + this.xAlignment = xAlignment + this.yAlignment = yAlignment + cache.positionCacheValid = false + return this as T + } + + fun alignLeft(): T { + this.xAlignment = Alignment.Start + cache.positionCacheValid = false + return this as T + } + + fun alignRight(): T { + this.xAlignment = Alignment.End + cache.positionCacheValid = false + return this as T + } + + fun alignTop(): T { + this.yAlignment = Alignment.Start + cache.positionCacheValid = false + return this as T + } + + fun alignBottom(): T { + this.yAlignment = Alignment.End + cache.positionCacheValid = false + return this as T + } + + fun setOffset(xOffset: Float, xOffsetType: Offset, yOffset: Float, yOffsetType: Offset): T { + this.xOffset = xOffset + this.xOffsetType = xOffsetType + this.yOffset = yOffset + this.yOffsetType = yOffsetType + cache.positionCacheValid = false + return this as T + } + + fun setOffset(xOffset: Float, yOffset: Float): T { + return setOffset(xOffset, Offset.Pixels, yOffset, Offset.Pixels) + } + + fun addTooltip(tooltip: String): T { + tooltipElement = Tooltip().apply { + innerText.text = tooltip + childOf(this@Element) + } + return this as T + } + + fun onMouseEnter(callback: (MouseEvent.Move.Enter) -> Unit): T { + listeners.mouseEnter.add(callback) + return this as T + } + + fun onMouseExit(callback: (MouseEvent.Move.Exit) -> Unit): T { + listeners.mouseExit.add(callback) + return this as T + } + + fun onMouseMove(callback: (MouseEvent.Move) -> Unit): T { + listeners.mouseMove.add(callback) + return this as T + } + + fun onHover(onEnter: (MouseEvent.Move.Enter) -> Unit = { }, onExit: (MouseEvent.Move.Exit) -> Unit = { }): T { + onMouseEnter(onEnter) + onMouseExit(onExit) + return this as T + } + + fun onMouseClick(callback: (MouseEvent.Click) -> Boolean): T { + listeners.mouseClick.add(callback) + return this as T + } + + fun onClick(callback: (MouseEvent.Click) -> Boolean): T = onMouseClick(callback) + + fun onMouseRelease(callback: (MouseEvent.Release) -> Boolean): T { + listeners.mouseRelease.add(callback) + return this as T + } + + fun onRelease(callback: (MouseEvent.Release) -> Boolean): T = onMouseRelease(callback) + + fun onMouseScroll(callback: (MouseEvent.Scroll) -> Boolean): T { + listeners.mouseScroll.add(callback) + return this as T + } + + fun onScroll(callback: (MouseEvent.Scroll) -> Boolean): T = onMouseScroll(callback) + + fun onCharType(callback: (KeyEvent.Type) -> Boolean): T { + listeners.charType.add(callback) + return this as T + } + + fun onValueChange(callback: (Any) -> Unit): T { + this.onValueChange.add(callback) + return this as T + } + + fun ignoreMouseEvents(): T { + listeners.mouseClick.add { false } + listeners.mouseRelease.add { false } + listeners.mouseScroll.add { false } + listeners.mouseMove.add { } + listeners.mouseEnter.add { } + listeners.mouseExit.add { } + return this as T + } + + fun ignoreFocus(): T { + ignoreFocus = true + return this as T + } + + fun setFloating(): T { + isFloating = true + return this as T + } + + fun setRequiresFocus(): T { + requiresFocus = true + return this as T + } + + fun show(): T { + visible = true + return this as T + } + + fun hide(): T { + visible = false + return this as T + } + + fun enableDebugRendering(): T { + renderHitbox = true + return this as T + } + + private companion object { + val ZERO_PADDING = floatArrayOf(0f, 0f, 0f, 0f) + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/ElementCache.kt b/src/main/kotlin/xyz/meowing/lattice/ui/ElementCache.kt new file mode 100644 index 0000000..badf7bf --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/ElementCache.kt @@ -0,0 +1,26 @@ +package xyz.meowing.lattice.ui + +class ElementCache { + var cachedParent: Element<*>? = null + var parentCacheValid = false + var positionCacheValid = false + var sizeCacheValid = false + var cachedWidth: Float = 0f + var cachedHeight: Float = 0f + var lastScreenWidth = 0 + var lastScreenHeight = 0 + + fun invalidate() { + parentCacheValid = false + positionCacheValid = false + sizeCacheValid = false + } + + fun invalidatePosition() { + positionCacheValid = false + } + + fun invalidateSize() { + sizeCacheValid = false + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/ElementListeners.kt b/src/main/kotlin/xyz/meowing/lattice/ui/ElementListeners.kt new file mode 100644 index 0000000..e2b6a4d --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/ElementListeners.kt @@ -0,0 +1,21 @@ +package xyz.meowing.lattice.ui + +class ElementListeners { + val mouseEnter = mutableListOf<(MouseEvent.Move.Enter) -> Unit>() + val mouseExit = mutableListOf<(MouseEvent.Move.Exit) -> Unit>() + val mouseMove = mutableListOf<(MouseEvent.Move) -> Unit>() + val mouseScroll = mutableListOf<(MouseEvent.Scroll) -> Boolean>() + val mouseClick = mutableListOf<(MouseEvent.Click) -> Boolean>() + val mouseRelease = mutableListOf<(MouseEvent.Release) -> Boolean>() + val charType = mutableListOf<(KeyEvent.Type) -> Boolean>() + + fun clear() { + mouseEnter.clear() + mouseExit.clear() + mouseMove.clear() + mouseScroll.clear() + mouseClick.clear() + mouseRelease.clear() + charType.clear() + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/UIScreen.kt b/src/main/kotlin/xyz/meowing/lattice/ui/UIScreen.kt new file mode 100644 index 0000000..1444471 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/UIScreen.kt @@ -0,0 +1,104 @@ +package xyz.meowing.lattice.ui + +import net.minecraft.client.gui.GuiGraphicsExtractor +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.input.CharacterEvent +import net.minecraft.client.input.MouseButtonEvent +import net.minecraft.network.chat.Component +import xyz.meowing.lattice.Lattice.eventBus +import xyz.meowing.lattice.client.Client.minecraft +import xyz.meowing.lattice.event.EventCall +import xyz.meowing.lattice.event.RenderEvent +import xyz.meowing.lattice.input.Keys +import xyz.meowing.lattice.input.Mouse +import xyz.meowing.lattice.scheduler.TimeScheduler + +/** Base screen backed by a Lattice [Window], rendered through the NanoVG overlay hook. */ +abstract class UIScreen(screenName: String = "Lattice-Screen") : Screen(Component.literal(screenName)) { + val window = Window() + + private var renderEvent: EventCall? = null + private var hasInitialized = false + private var lastX = -1.0 + private var lastY = -1.0 + + /** Called once, the first time the screen initializes. Build the UI here. */ + open fun afterInitialization() {} + + /** Called every frame after the window and animations render. */ + open fun onRenderGui() {} + + open fun onResizeGui() {} + + open fun onCloseGui() {} + + final override fun init() { + if (!hasInitialized) { + hasInitialized = true + afterInitialization() + + renderEvent = eventBus.register { + if (minecraft.screen == this) { + window.draw() + onRenderGui() + } + } + } else { + onResizeGui() + window.onWindowResize() + } + super.init() + } + + override fun extractRenderState(context: GuiGraphicsExtractor, mouseX: Int, mouseY: Int, deltaTicks: Float) { + val newX = Mouse.Raw.x + val newY = Mouse.Raw.y + if (newX != lastX || newY != lastY) { + window.mouseMove() + lastX = newX + lastY = newY + } + } + + override fun mouseClicked(click: MouseButtonEvent, doubled: Boolean): Boolean { + return window.mouseClick(click.button()) || super.mouseClicked(click, doubled) + } + + override fun mouseReleased(click: MouseButtonEvent): Boolean { + return window.mouseRelease(click.button()) || super.mouseReleased(click) + } + + override fun mouseScrolled(mouseX: Double, mouseY: Double, horizontalAmount: Double, verticalAmount: Double): Boolean { + window.mouseScroll(horizontalAmount, verticalAmount) + return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount) + } + + override fun keyPressed(input: net.minecraft.client.input.KeyEvent): Boolean { + val handled = window.charType(input.key(), input.scancode(), ' ') + if (!handled && input.key() == Keys.KEY_ESCAPE.code) { + onClose() + return true + } + return handled || super.keyPressed(input) + } + + override fun charTyped(input: CharacterEvent): Boolean { + return window.charType(0, 0, input.codepoint().toChar()) || super.charTyped(input) + } + + override fun onClose() { + window.cleanup() + renderEvent?.unregister() + renderEvent = null + hasInitialized = false + onCloseGui() + super.onClose() + } + + /** Opens this screen on the next client tick, safe to call from any thread. */ + fun display() { + TimeScheduler.schedule(50) { + minecraft.setScreen(this@UIScreen) + } + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/Window.kt b/src/main/kotlin/xyz/meowing/lattice/ui/Window.kt new file mode 100644 index 0000000..99cb0f7 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/Window.kt @@ -0,0 +1,56 @@ +package xyz.meowing.lattice.ui + +import xyz.meowing.lattice.animation.AnimationManager +import xyz.meowing.lattice.input.Mouse + +class Window { + val children: MutableList> = mutableListOf() + + private val mouseX: Float get() = Mouse.Raw.x.toFloat() + private val mouseY: Float get() = Mouse.Raw.y.toFloat() + + fun addChild(element: Element<*>) { + element.parent = this + children.add(element) + } + + fun removeChild(element: Element<*>) { + element.parent = null + children.remove(element) + } + + fun draw() { + children.forEach { it.render(mouseX, mouseY) } + AnimationManager.update() + } + + fun mouseClick(button: Int): Boolean { + return children.reversed().any { it.handleMouseClick(mouseX, mouseY, button) } + } + + fun mouseRelease(button: Int): Boolean { + return children.reversed().any { it.handleMouseRelease(mouseX, mouseY, button) } + } + + fun mouseMove() { + children.reversed().forEach { it.handleMouseMove(mouseX, mouseY) } + } + + fun mouseScroll(horizontalDelta: Double, verticalDelta: Double) { + children.reversed().forEach { it.handleMouseScroll(mouseX, mouseY, horizontalDelta, verticalDelta) } + } + + fun charType(keyCode: Int, scanCode: Int, charTyped: Char): Boolean { + return children.reversed().any { it.handleCharType(keyCode, scanCode, charTyped) } + } + + fun onWindowResize() { + children.forEach { it.onWindowResize() } + } + + fun cleanup() { + children.toList().forEach { it.destroy() } + children.clear() + AnimationManager.clear() + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/component/Container.kt b/src/main/kotlin/xyz/meowing/lattice/ui/component/Container.kt new file mode 100644 index 0000000..c17d0c1 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/component/Container.kt @@ -0,0 +1,14 @@ +package xyz.meowing.lattice.ui.component + +import xyz.meowing.lattice.ui.Box +import xyz.meowing.lattice.ui.Size + +/** Invisible layout box: padding and optional scrolling with no drawing of its own. */ +open class Container( + padding: FloatArray = floatArrayOf(0f, 0f, 0f, 0f), + scrollable: Boolean = false, + widthType: Size = Size.Auto, + heightType: Size = Size.Auto +) : Box(padding, scrollable, widthType, heightType) { + override fun onRender(mouseX: Float, mouseY: Float) {} +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/component/Rectangle.kt b/src/main/kotlin/xyz/meowing/lattice/ui/component/Rectangle.kt new file mode 100644 index 0000000..c3c4d3c --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/component/Rectangle.kt @@ -0,0 +1,161 @@ +package xyz.meowing.lattice.ui.component + +import xyz.meowing.lattice.Lattice.renderer +import xyz.meowing.lattice.animation.EasingType +import xyz.meowing.lattice.animation.animateFloat +import xyz.meowing.lattice.render.Gradient +import xyz.meowing.lattice.ui.Box +import xyz.meowing.lattice.ui.Size + +open class Rectangle( + var backgroundColor: Int = 0x80000000.toInt(), + var borderColor: Int = 0xFFFFFFFF.toInt(), + var borderRadius: Float = 0f, + var borderThickness: Float = 0f, + padding: FloatArray = floatArrayOf(0f, 0f, 0f, 0f), + var hoverColor: Int? = null, + var pressedColor: Int? = null, + widthType: Size = Size.Auto, + heightType: Size = Size.Auto, + scrollable: Boolean = false +) : Box(padding, scrollable, widthType, heightType) { + var secondBorderColor: Int = -1 + var secondBackgroundColor: Int = -1 + var gradientType: Gradient = Gradient.TopLeftToBottomRight + var dropShadow: Boolean = false + var rotation: Float = 0f + + var shadowBlur = 30f + var shadowSpread = 1f + var shadowColor = 0x80000000.toInt() + + var borderRadiusTopLeft: Float? = null + var borderRadiusTopRight: Float? = null + var borderRadiusBottomLeft: Float? = null + var borderRadiusBottomRight: Float? = null + + public override fun onRender(mouseX: Float, mouseY: Float) { + if (!visible || (height - (padding[0] + padding[2])) == 0f || (width - (padding[1] + padding[3])) == 0f) return + + val currentBgColor = when { + isPressed && pressedColor != null -> pressedColor!! + isHovered && hoverColor != null -> hoverColor!! + else -> backgroundColor + } + + val centerX = x + width / 2f + val centerY = y + height / 2f + + if (rotation != 0f) { + renderer.push() + renderer.translate(centerX, centerY) + renderer.rotate(Math.toRadians(rotation.toDouble()).toFloat()) + renderer.translate(-centerX, -centerY) + } + + if (dropShadow) { + renderer.dropShadow(x, y, width, height, shadowBlur, shadowSpread, shadowColor, borderRadius) + } + + if (currentBgColor != 0) { + if (hasVaryingRadius()) { + renderer.rect( + x, y, width, height, currentBgColor, + borderRadiusTopRight ?: borderRadius, + borderRadiusTopLeft ?: borderRadius, + borderRadiusBottomRight ?: borderRadius, + borderRadiusBottomLeft ?: borderRadius + ) + } else if (currentBgColor == backgroundColor && secondBackgroundColor != -1) { + renderer.gradientRect(x, y, width, height, backgroundColor, secondBackgroundColor, gradientType, borderRadius) + } else { + renderer.rect(x, y, width, height, currentBgColor, borderRadius) + } + } + + if (borderThickness > 0f) { + if (secondBorderColor != -1) { + renderer.hollowGradientRect(x, y, width, height, borderThickness, borderColor, secondBorderColor, gradientType, borderRadius) + } else { + renderer.hollowRect(x, y, width, height, borderThickness, borderColor, borderRadius) + } + } + + if (rotation != 0f) { + renderer.pop() + } + } + + private fun hasVaryingRadius(): Boolean { + return borderRadiusTopLeft != null || borderRadiusTopRight != null || + borderRadiusBottomLeft != null || borderRadiusBottomRight != null + } + + fun rotateTo(angle: Float, duration: Long = 300, type: EasingType = EasingType.EASE_OUT, onComplete: (() -> Unit)? = null): Rectangle { + animateFloat({ rotation }, { rotation = it }, angle, duration, type, onComplete = onComplete) + return this + } + + fun dropShadow(shadowBlur: Float = 30f, shadowSpread: Float = 1f, shadowColor: Int = 0x000000): Rectangle = apply { + dropShadow = true + this.shadowBlur = shadowBlur + this.shadowSpread = shadowSpread + this.shadowColor = shadowColor + } + + open fun backgroundColor(color: Int): Rectangle = apply { + backgroundColor = color + secondBackgroundColor = -1 + } + + open fun setBackgroundGradientColor(color1: Int, color2: Int): Rectangle = apply { + backgroundColor = color1 + secondBackgroundColor = color2 + } + + open fun setBorderGradientColor(color1: Int, color2: Int): Rectangle = apply { + borderColor = color1 + secondBorderColor = color2 + } + + open fun borderColor(color: Int): Rectangle = apply { + borderColor = color + secondBorderColor = -1 + } + + open fun borderGradient(type: Gradient): Rectangle = apply { + gradientType = type + } + + open fun borderRadius(radius: Float): Rectangle = apply { + borderRadius = radius + borderRadiusTopLeft = null + borderRadiusTopRight = null + borderRadiusBottomLeft = null + borderRadiusBottomRight = null + } + + open fun borderRadiusVarying( + topLeft: Float = borderRadius, + topRight: Float = borderRadius, + bottomLeft: Float = borderRadius, + bottomRight: Float = borderRadius + ): Rectangle = apply { + borderRadiusTopLeft = topLeft + borderRadiusTopRight = topRight + borderRadiusBottomLeft = bottomLeft + borderRadiusBottomRight = bottomRight + } + + open fun borderThickness(thickness: Float): Rectangle = apply { + borderThickness = thickness + } + + open fun hoverColor(color: Int): Rectangle = apply { + hoverColor = color + } + + open fun pressedColor(color: Int): Rectangle = apply { + pressedColor = color + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/component/SvgImage.kt b/src/main/kotlin/xyz/meowing/lattice/ui/component/SvgImage.kt new file mode 100644 index 0000000..dbe3410 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/component/SvgImage.kt @@ -0,0 +1,67 @@ +package xyz.meowing.lattice.ui.component + +import xyz.meowing.lattice.Lattice.renderer +import xyz.meowing.lattice.animation.EasingType +import xyz.meowing.lattice.animation.animateFloat +import xyz.meowing.lattice.ui.Element +import xyz.meowing.lattice.ui.Pos +import xyz.meowing.lattice.ui.Size + +class SvgImage( + var svgPath: String = "", + startingWidth: Float = 80f, + startingHeight: Float = 80f, + color: Int = 0xFFFFFFFF.toInt() +) : Element() { + var color = color + private set + + var image = renderer.createImage(svgPath, startingWidth.toInt(), startingHeight.toInt(), color) + private set + + var rotation: Float = 0f + + init { + setPositioning(Pos.ParentPixels, Pos.ParentPixels) + ignoreMouseEvents() + setSizing(startingWidth, Size.Pixels, startingHeight, Size.Pixels) + } + + override fun onRender(mouseX: Float, mouseY: Float) { + if (svgPath.isEmpty()) return + + val centerX = x + width / 2f + val centerY = y + height / 2f + + if (rotation != 0f) { + renderer.push() + renderer.translate(centerX, centerY) + renderer.rotate(Math.toRadians(rotation.toDouble()).toFloat()) + renderer.translate(-centerX, -centerY) + } + + renderer.image(image, x, y, width, height) + + if (rotation != 0f) { + renderer.pop() + } + } + + override fun destroy() { + renderer.deleteImage(image) + super.destroy() + } + + fun rotateTo(angle: Float, duration: Long = 300, type: EasingType = EasingType.EASE_OUT, onComplete: (() -> Unit)? = null): SvgImage { + animateFloat({ rotation }, { rotation = it }, angle, duration, type, onComplete = onComplete) + return this + } + + fun setSvgColor(newColor: Int) { + if (color != newColor) { + color = newColor + renderer.deleteImage(image) + image = renderer.createImage(svgPath, width.toInt(), height.toInt(), color) + } + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/component/Text.kt b/src/main/kotlin/xyz/meowing/lattice/ui/component/Text.kt new file mode 100644 index 0000000..9b047f2 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/component/Text.kt @@ -0,0 +1,54 @@ +package xyz.meowing.lattice.ui.component + +import xyz.meowing.lattice.Lattice +import xyz.meowing.lattice.Lattice.renderer +import xyz.meowing.lattice.render.Font +import xyz.meowing.lattice.ui.Element +import xyz.meowing.lattice.ui.Pos +import xyz.meowing.lattice.ui.Size + +class Text( + var text: String = "", + var textColor: Int = 0xFFFFFFFF.toInt(), + var fontSize: Float = 12f, + var shadowEnabled: Boolean = false, + var font: Font = Lattice.defaultFont +) : Element() { + + init { + setSizing(Size.Auto, Size.Auto) + setPositioning(Pos.ParentPixels, Pos.ParentPixels) + ignoreMouseEvents() + } + + override fun onRender(mouseX: Float, mouseY: Float) { + if (text.isEmpty()) return + + if (shadowEnabled) renderer.shadowedText(text, x, y, fontSize, textColor, font) + else renderer.text(text, x, y, fontSize, textColor, font) + } + + override fun getAutoWidth(): Float = renderer.textWidth(text, fontSize, font) + + override fun getAutoHeight(): Float = fontSize + + fun text(newText: String): Text = apply { + text = newText + } + + fun color(color: Int): Text = apply { + textColor = color + } + + fun fontSize(size: Float): Text = apply { + fontSize = size + } + + fun font(newFont: Font): Text = apply { + font = newFont + } + + fun shadow(enabled: Boolean = true): Text = apply { + shadowEnabled = enabled + } +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/component/Tooltip.kt b/src/main/kotlin/xyz/meowing/lattice/ui/component/Tooltip.kt new file mode 100644 index 0000000..48c1331 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/component/Tooltip.kt @@ -0,0 +1,97 @@ +package xyz.meowing.lattice.ui.component + +import xyz.meowing.lattice.ui.theme.Theme +import xyz.meowing.lattice.ui.Box +import xyz.meowing.lattice.ui.Element +import xyz.meowing.lattice.ui.Pos +import xyz.meowing.lattice.ui.Size +import xyz.meowing.lattice.ui.TooltipPosition + +class Tooltip( + backgroundColor: Int = Theme.surface, + borderColor: Int = Theme.surfaceBorder, + borderRadius: Float = 4f, + borderThickness: Float = 1f, + var padding: FloatArray = floatArrayOf(4f, 4f, 4f, 4f), + hoverColor: Int? = Theme.surface, + pressedColor: Int? = Theme.surface, + widthType: Size = Size.Auto, + heightType: Size = Size.Auto, + var position: TooltipPosition = TooltipPosition.Top +) : Element(widthType, heightType) { + val backgroundRect = Rectangle( + backgroundColor, + borderColor, + borderRadius, + borderThickness, + padding, + hoverColor, + pressedColor, + widthType, + heightType + ) + .childOf(this) + + val innerText = Text("Tooltip", 0xFFFFFFFF.toInt(), 12f) + .setPositioning(Pos.ParentCenter, Pos.ParentCenter) + .childOf(backgroundRect) + + override var visible: Boolean = false + set(value) { + if (field != value) { + field = value + cache.invalidate() + invalidateChildrenCache() + } + if (value) { + backgroundRect.visible = true + backgroundRect.width = width + backgroundRect.height = height + } else { + backgroundRect.visible = false + } + } + + init { + setSizing(Size.Auto, Size.Auto) + updatePosition() + ignoreMouseEvents() + setFloating() + backgroundRect.visible = false + innerText.visible = false + } + + fun setPosition(newPosition: TooltipPosition): Tooltip { + position = newPosition + updatePosition() + return this + } + + private fun updatePosition() { + val parentPadding = (parent as? Box<*>)?.padding ?: floatArrayOf(0f, 0f, 0f, 0f) + val offset = 24f + + when (position) { + TooltipPosition.Top -> { + setPositioning(0f, Pos.ParentCenter, -offset - parentPadding[0], Pos.ParentPixels) + } + TooltipPosition.Bottom -> { + setPositioning(0f, Pos.ParentCenter, offset + parentPadding[2], Pos.ParentPixels) + alignBottom() + } + TooltipPosition.Left -> { + setPositioning(-offset - parentPadding[3], Pos.ParentPixels, 0f, Pos.ParentCenter) + } + TooltipPosition.Right -> { + setPositioning(offset + parentPadding[1], Pos.ParentPixels, 0f, Pos.ParentCenter) + alignRight() + } + } + } + + override fun getAutoWidth(): Float = backgroundRect.getAutoWidth() + + override fun getAutoHeight(): Float = backgroundRect.getAutoHeight() + + override fun onRender(mouseX: Float, mouseY: Float) {} +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/events.kt b/src/main/kotlin/xyz/meowing/lattice/ui/events.kt new file mode 100644 index 0000000..4a51000 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/events.kt @@ -0,0 +1,18 @@ +package xyz.meowing.lattice.ui + +sealed class MouseEvent { + class Move(val x: Float, val y: Float, val element: Element<*>) { + class Enter(val x: Float, val y: Float, val element: Element<*>) + class Exit(val x: Float, val y: Float, val element: Element<*>) + } + + class Scroll(val x: Float, val y: Float, val horizontal: Double, val vertical: Double, val element: Element<*>?) + + class Click(val x: Float, val y: Float, val button: Int, val element: Element<*>?) + + class Release(val x: Float, val y: Float, val button: Int, val element: Element<*>?) +} + +sealed class KeyEvent { + class Type(val keyCode: Int, val scanCode: Int, val char: Char, val element: Element<*>) +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/layout.kt b/src/main/kotlin/xyz/meowing/lattice/ui/layout.kt new file mode 100644 index 0000000..e93232a --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/layout.kt @@ -0,0 +1,40 @@ +package xyz.meowing.lattice.ui + +enum class Pos { + ParentPercent, + ScreenPercent, + + ParentPixels, + ScreenPixels, + + ParentCenter, + ScreenCenter, + + AfterSibling, + MatchSibling, +} + +enum class Size { + Auto, + Percent, + Pixels, + Fill, +} + +enum class Alignment { + None, + Start, + End, +} + +enum class Offset { + Pixels, + Percent, +} + +enum class TooltipPosition { + Top, + Bottom, + Left, + Right, +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/theme/Theme.kt b/src/main/kotlin/xyz/meowing/lattice/ui/theme/Theme.kt new file mode 100644 index 0000000..df129ad --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/theme/Theme.kt @@ -0,0 +1,31 @@ +package xyz.meowing.lattice.ui.theme + +/** + * Semantic color tokens and animation timings shared by the built-in widgets. + * Mutable so a client can retheme every widget default in one place. + */ +object Theme { + var accent = 0xFF4c87f9.toInt() + var accentSelection = 0x80aac7ff.toInt() + + var background = 0x80404040.toInt() + var backgroundHover = 0x80505050.toInt() + var backgroundPressed = 0x80303030.toInt() + var backgroundDark = 0xFF212121.toInt() + var backgroundDisabled = 0xFF303030.toInt() + + var surface = 0xFF1e1e1e.toInt() + var surfaceBorder = 0xFF555759.toInt() + + var border = 0xFF606060.toInt() + + var text = 0xFFFFFFFF.toInt() + var textMuted = 0xFF787878.toInt() + + var track = 0xFF424242.toInt() + var scrollbar = 0xFF7c7c7d.toInt() + + var animFast = 100L + var animNormal = 200L + var animSlow = 300L +} diff --git a/src/main/kotlin/xyz/meowing/lattice/ui/widget/Button.kt b/src/main/kotlin/xyz/meowing/lattice/ui/widget/Button.kt new file mode 100644 index 0000000..7522969 --- /dev/null +++ b/src/main/kotlin/xyz/meowing/lattice/ui/widget/Button.kt @@ -0,0 +1,132 @@ +package xyz.meowing.lattice.ui.widget + +import xyz.meowing.lattice.ui.theme.Theme +import xyz.meowing.lattice.Lattice +import xyz.meowing.lattice.ui.component.Rectangle +import xyz.meowing.lattice.ui.component.Text +import xyz.meowing.lattice.ui.Pos +import xyz.meowing.lattice.ui.Size +import xyz.meowing.lattice.ui.Element +import xyz.meowing.lattice.render.Font + +class Button( + var text: String = "", + var textColor: Int = 0xFFFFFFFF.toInt(), + var hoverTextColor: Int? = null, + var pressedTextColor: Int? = null, + fontSize: Float = 12f, + font: Font = Lattice.defaultFont, + shadowEnabled: Boolean = false, + backgroundColor: Int = Theme.background, + borderColor: Int = Theme.border, + borderRadius: Float = 4f, + borderThickness: Float = 1f, + padding: FloatArray = floatArrayOf(8f, 16f, 8f, 16f), + hoverColor: Int? = Theme.backgroundHover, + pressedColor: Int? = Theme.backgroundPressed, + widthType: Size = Size.Auto, + heightType: Size = Size.Auto +) : Element