From 6390acc340d2bfb58eb3c62d09fd26ceeaebac7d Mon Sep 17 00:00:00 2001 From: Kam Date: Mon, 2 Mar 2026 14:54:24 -0500 Subject: [PATCH] NODEX --- .gitignore | 1 - dependencies.gradle | 11 + nodex/.gitattributes | 9 + nodex/.github/ISSUE_TEMPLATE/bug_report.md | 27 + .../.github/ISSUE_TEMPLATE/feature_request.md | 17 + nodex/.github/workflows/build.yml | 38 + nodex/.gitignore | 41 + nodex/.idea/icon.svg | 507 ++++ nodex/LICENSE | 504 ++++ nodex/README.md | 103 + nodex/build.gradle.kts | 167 ++ nodex/docs/CHANGELOG.md | 55 + nodex/docs/LICENSES/AtlantaFX | 21 + nodex/docs/LICENSES/DirectoryWatcher | 202 ++ nodex/docs/LICENSES/Feather | 21 + nodex/docs/LICENSES/Flawless | 23 + nodex/docs/LICENSES/JacksonCore | 202 ++ nodex/docs/LICENSES/Material2 | 202 ++ nodex/docs/LICENSES/OpenJFX | 392 +++ nodex/docs/LICENSES/ReactFX | 10 + nodex/docs/LICENSES/RichTextFX | 10 + nodex/docs/LICENSES/UndoFX | 23 + nodex/docs/LICENSES/WellBehavedFX | 23 + nodex/docs/LICENSES/ikonli | 177 ++ nodex/docs/LICENSES/jsvg | 21 + .../Datapack-IDE-0.0.1-FullScreen-README.png | Bin 0 -> 25084 bytes ...Datapack-IDE-0.0.1-SettingsMenu-README.png | Bin 0 -> 30313 bytes nodex/docs/README_Pictures/Nodex_IconSVG.svg | 507 ++++ nodex/docs/SOFTWARE_USED.md | 25 + nodex/gradle.properties | 15 + nodex/gradle/libs.versions.toml | 38 + nodex/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + nodex/gradlew | 251 ++ nodex/gradlew.bat | 94 + nodex/settings.gradle.kts | 25 + .../io/github/frostzie/nodex/RichJsonFX.java | 109 + .../mixin/CaseInsensitiveCommandMapMixin.java | 26 + .../nodex/mixin/PauseScreenMixin.java | 46 + .../nodex/mixin/TitleScreenMixin.java | 46 + .../frostzie/nodex/svg_icon/SVGException.java | 15 + .../frostzie/nodex/svg_icon/SVGImage.java | 107 + .../nodex/svg_icon/SVGImageReader.java | 111 + .../nodex/svg_icon/SVGImageReaderSpi.java | 73 + .../nodex/svg_icon/SVGTransformer.java | 89 + .../github/frostzie/nodex/svg_icon/jsvgfx.md | 29 + .../kotlin/io/github/frostzie/nodex/Nodex.kt | 33 + .../frostzie/nodex/config/ConfigManager.kt | 113 + .../frostzie/nodex/config/LayoutManager.kt | 67 + .../frostzie/nodex/events/BottomBarEvents.kt | 5 + .../frostzie/nodex/events/EditorEvents.kt | 15 + .../github/frostzie/nodex/events/EventBus.kt | 81 + .../frostzie/nodex/events/FileEvents.kt | 30 + .../nodex/events/MenuControlEvents.kt | 8 + .../github/frostzie/nodex/events/ModEvents.kt | 4 + .../frostzie/nodex/events/PopupEvents.kt | 27 + .../frostzie/nodex/events/ResizeEvents.kt | 13 + .../frostzie/nodex/events/ThemeEvents.kt | 10 + .../frostzie/nodex/events/ToggleFileTree.kt | 3 + .../frostzie/nodex/events/WorkspaceEvents.kt | 23 + .../nodex/features/FeatureRegistry.kt | 46 + .../features/dev/showcase/MessageShowcase.kt | 56 + .../dev/showcase/NotificationShowcase.kt | 69 + .../nodex/features/editor/CaretColor.kt | 55 + .../features/editor/DirtyTabDecorator.kt | 47 + .../features/editor/EditorTabDecorator.kt | 21 + .../features/editor/FileIconDecorator.kt | 34 + .../nodex/handlers/bars/BottomBarHandler.kt | 13 + .../nodex/handlers/bars/LeftBarHandler.kt | 8 + .../nodex/handlers/bars/top/TopBarHandler.kt | 55 + .../handlers/popup/file/FilePopupHandler.kt | 18 + .../popup/settings/SettingsHandler.kt | 49 + .../handlers/popup/settings/ThemeHandler.kt | 33 + .../frostzie/nodex/ingame/DefaultCommands.kt | 37 + .../nodex/ingame/ReloadDataPacksCommand.kt | 24 + .../nodex/loader/fabric/CommandDSL.kt | 44 + .../nodex/loader/fabric/CommandUtils.kt | 34 + .../frostzie/nodex/loader/fabric/Folders.kt | 12 + .../frostzie/nodex/loader/fabric/Keybinds.kt | 48 + .../nodex/loader/fabric/ModVersion.kt | 12 + .../nodex/loader/fabric/WorldDetection.kt | 44 + .../CaseInsensitiveLiteralCommandNode.kt | 74 + .../loader/minecraft/ChatMessageBuilder.kt | 86 + .../nodex/loader/minecraft/MCColorUtils.kt | 34 + .../nodex/loader/minecraft/MCInterface.kt | 52 + .../nodex/modules/bars/BottomBarModule.kt | 19 + .../nodex/modules/bars/LeftBarModule.kt | 32 + .../nodex/modules/bars/top/TopBarViewModel.kt | 86 + .../nodex/modules/main/FileTreeViewModel.kt | 353 +++ .../nodex/modules/main/TextEditorViewModel.kt | 421 +++ .../popup/file/FileOverrideDialogViewModel.kt | 25 + .../modules/popup/file/FilePopupModule.kt | 42 + .../popup/file/MoveConfirmationViewModel.kt | 58 + .../modules/popup/settings/SettingsModule.kt | 256 ++ .../modules/popup/settings/ThemeModule.kt | 84 + .../project/ProjectManagerViewModel.kt | 53 + .../preview/ProjectPreviewViewModel.kt | 129 + .../modules/universal/UniversalPackManager.kt | 125 + .../universal/UniversalWorldIntegration.kt | 41 + .../github/frostzie/nodex/project/Project.kt | 27 + .../frostzie/nodex/project/Workspace.kt | 8 + .../nodex/project/WorkspaceManager.kt | 374 +++ .../nodex/project/metadata/DatapackParser.kt | 130 + .../nodex/project/state/ProjectState.kt | 12 + .../project/state/ProjectStateHandler.kt | 82 + .../project/validation/ProjectValidator.kt | 90 + .../frostzie/nodex/screen/MainApplication.kt | 438 ++++ .../screen/elements/bars/BottomBarView.kt | 54 + .../nodex/screen/elements/bars/LeftBarView.kt | 78 + .../screen/elements/bars/top/TopBarView.kt | 253 ++ .../screen/elements/main/FileTreeItem.kt | 19 + .../screen/elements/main/FileTreeView.kt | 167 ++ .../screen/elements/main/TextEditorView.kt | 367 +++ .../popup/file/FileOverrideDialogView.kt | 31 + .../popup/file/MoveConfirmationView.kt | 91 + .../popup/settings/SettingsContent.kt | 107 + .../elements/popup/settings/SettingsNav.kt | 170 ++ .../elements/popup/settings/SettingsView.kt | 40 + .../elements/project/ProjectManagerView.kt | 221 ++ .../elements/project/RecentProjectListCell.kt | 84 + .../project/preview/ProjectPreviewView.kt | 206 ++ .../frostzie/nodex/services/ArchiveService.kt | 75 + .../frostzie/nodex/services/FileService.kt | 116 + .../nodex/settings/ConfigFieldManager.kt | 165 ++ .../frostzie/nodex/settings/KeyCombination.kt | 50 + .../frostzie/nodex/settings/SettingsLoader.kt | 197 ++ .../nodex/settings/SettingsManager.kt | 238 ++ .../settings/annotations/ConfigCategory.kt | 8 + .../annotations/ConfigEditorBoolean.kt | 5 + .../annotations/ConfigEditorButton.kt | 7 + .../annotations/ConfigEditorColorPicker.kt | 5 + .../annotations/ConfigEditorDropdown.kt | 7 + .../annotations/ConfigEditorFolder.kt | 5 + .../settings/annotations/ConfigEditorInfo.kt | 5 + .../annotations/ConfigEditorKeybind.kt | 5 + .../annotations/ConfigEditorSlider.kt | 9 + .../annotations/ConfigEditorSpinner.kt | 8 + .../settings/annotations/ConfigEditorText.kt | 5 + .../annotations/ConfigEditorTextField.kt | 5 + .../settings/annotations/ConfigOption.kt | 8 + .../nodex/settings/annotations/Expose.kt | 5 + .../settings/annotations/SubscribeEvent.kt | 5 + .../settings/categories/AdvancedConfig.kt | 51 + .../settings/categories/ExampleConfig.kt | 191 ++ .../nodex/settings/categories/MainConfig.kt | 117 + .../settings/categories/MinecraftConfig.kt | 22 + .../nodex/settings/categories/ThemeConfig.kt | 112 + .../nodex/settings/data/ConfigField.kt | 133 + .../nodex/settings/data/SettingsData.kt | 34 + .../nodex/styling/common/BaseStyleManager.kt | 51 + .../styling/common/NotificationMessageArea.kt | 261 ++ .../nodex/styling/common/commonModels.kt | 64 + .../nodex/styling/messages/MessageFactory.kt | 97 + .../nodex/styling/messages/MessageModels.kt | 58 + .../styling/messages/MessageStyleManager.kt | 27 + .../messages/appliers/MessageStyleApplier.kt | 56 + .../messages/rules/StandardMessageRules.kt | 66 + .../notifications/NotificationFactory.kt | 100 + .../notifications/NotificationModels.kt | 31 + .../notifications/NotificationStyleManager.kt | 34 + .../appliers/NotificationStyleApplier.kt | 24 + .../rules/StandardNotificationRules.kt | 59 + .../frostzie/nodex/styling/tabs/TabModels.kt | 35 + .../nodex/styling/tabs/TabStyleManager.kt | 31 + .../frostzie/nodex/styling/tabs/TabStyler.kt | 46 + .../styling/tabs/appliers/TabStyleApplier.kt | 65 + .../nodex/styling/tabs/rules/ConfigRule.kt | 27 + .../nodex/styling/tabs/rules/DefaultRule.kt | 27 + .../styling/tabs/rules/DirtyStateRule.kt | 32 + .../github/frostzie/nodex/utils/CSSManager.kt | 69 + .../github/frostzie/nodex/utils/ColorUtils.kt | 61 + .../frostzie/nodex/utils/ComponentResizer.kt | 80 + .../github/frostzie/nodex/utils/IconUtils.kt | 48 + .../frostzie/nodex/utils/JavaFXInitializer.kt | 95 + .../frostzie/nodex/utils/LoggerProvider.kt | 16 + .../frostzie/nodex/utils/LoggerWrapper.kt | 102 + .../github/frostzie/nodex/utils/OpenLinks.kt | 27 + .../frostzie/nodex/utils/ProjectIconUtils.kt | 55 + .../frostzie/nodex/utils/ThemeManager.kt | 128 + .../github/frostzie/nodex/utils/ThemeUtils.kt | 32 + .../frostzie/nodex/utils/UIConstants.kt | 53 + .../github/frostzie/nodex/utils/WindowDrag.kt | 109 + .../frostzie/nodex/utils/WindowResizer.kt | 148 ++ .../frostzie/nodex/utils/dev/DebugManager.kt | 29 + .../nodex/utils/file/DirectoryChooseUtils.kt | 183 ++ .../nodex/utils/file/FileSystemWatcher.kt | 186 ++ .../nodex/utils/ui/SettingsControlBuilder.kt | 255 ++ .../github/frostzie/nodex/utils/ui/Tiles.kt | 193 ++ .../utils/ui/controls/KeybindInputButton.kt | 114 + .../src/main/resources/assets/nodex/icon.png | Bin 0 -> 58338 bytes .../resources/assets/nodex/lang/en_us.json | 5 + .../main/resources/assets/nodex/svg/icon.svg | 508 ++++ .../assets/nodex/themes/styles/Debug.css | 80 + .../nodex/themes/styles/FileTreeView.css | 21 + .../assets/nodex/themes/styles/Override.css | 36 + .../nodex/themes/styles/TextEditorView.css | 24 + .../assets/nodex/themes/styles/Window.css | 4 + nodex/src/main/resources/fabric.mod.json | 46 + nodex/src/main/resources/nodex.mixins.json | 18 + nodex/stonecutter.gradle.kts | 12 + nodex/versions/1.21.10/gradle.properties | 14 + nodex/versions/1.21.8/gradle.properties | 14 + .../noppes/npcs/client/fx/JavaFxBridge.java | 106 + .../noppes/npcs/client/fx/NodexFxWindow.java | 2329 +++++++++++++++++ .../npcs/client/fx/NodexScriptBinding.java | 69 + .../noppes/npcs/client/gui/GuiScript.java | 72 + .../client/gui/script/GuiScriptInterface.java | 103 +- .../assets/customnpcs/lang/en_US.lang | 4 + 208 files changed, 18856 insertions(+), 6 deletions(-) create mode 100644 nodex/.gitattributes create mode 100644 nodex/.github/ISSUE_TEMPLATE/bug_report.md create mode 100644 nodex/.github/ISSUE_TEMPLATE/feature_request.md create mode 100644 nodex/.github/workflows/build.yml create mode 100644 nodex/.gitignore create mode 100644 nodex/.idea/icon.svg create mode 100644 nodex/LICENSE create mode 100644 nodex/README.md create mode 100644 nodex/build.gradle.kts create mode 100644 nodex/docs/CHANGELOG.md create mode 100644 nodex/docs/LICENSES/AtlantaFX create mode 100644 nodex/docs/LICENSES/DirectoryWatcher create mode 100644 nodex/docs/LICENSES/Feather create mode 100644 nodex/docs/LICENSES/Flawless create mode 100644 nodex/docs/LICENSES/JacksonCore create mode 100644 nodex/docs/LICENSES/Material2 create mode 100644 nodex/docs/LICENSES/OpenJFX create mode 100644 nodex/docs/LICENSES/ReactFX create mode 100644 nodex/docs/LICENSES/RichTextFX create mode 100644 nodex/docs/LICENSES/UndoFX create mode 100644 nodex/docs/LICENSES/WellBehavedFX create mode 100644 nodex/docs/LICENSES/ikonli create mode 100644 nodex/docs/LICENSES/jsvg create mode 100644 nodex/docs/README_Pictures/Datapack-IDE-0.0.1-FullScreen-README.png create mode 100644 nodex/docs/README_Pictures/Datapack-IDE-0.0.1-SettingsMenu-README.png create mode 100644 nodex/docs/README_Pictures/Nodex_IconSVG.svg create mode 100644 nodex/docs/SOFTWARE_USED.md create mode 100644 nodex/gradle.properties create mode 100644 nodex/gradle/libs.versions.toml create mode 100644 nodex/gradle/wrapper/gradle-wrapper.jar create mode 100644 nodex/gradle/wrapper/gradle-wrapper.properties create mode 100644 nodex/gradlew create mode 100644 nodex/gradlew.bat create mode 100644 nodex/settings.gradle.kts create mode 100644 nodex/src/main/java/io/github/frostzie/nodex/RichJsonFX.java create mode 100644 nodex/src/main/java/io/github/frostzie/nodex/mixin/CaseInsensitiveCommandMapMixin.java create mode 100644 nodex/src/main/java/io/github/frostzie/nodex/mixin/PauseScreenMixin.java create mode 100644 nodex/src/main/java/io/github/frostzie/nodex/mixin/TitleScreenMixin.java create mode 100644 nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGException.java create mode 100644 nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImage.java create mode 100644 nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImageReader.java create mode 100644 nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImageReaderSpi.java create mode 100644 nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGTransformer.java create mode 100644 nodex/src/main/java/io/github/frostzie/nodex/svg_icon/jsvgfx.md create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/Nodex.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/config/ConfigManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/config/LayoutManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/events/BottomBarEvents.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/events/EditorEvents.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/events/EventBus.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/events/FileEvents.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/events/MenuControlEvents.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/events/ModEvents.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/events/PopupEvents.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/events/ResizeEvents.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/events/ThemeEvents.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/events/ToggleFileTree.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/events/WorkspaceEvents.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/features/FeatureRegistry.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/features/dev/showcase/MessageShowcase.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/features/dev/showcase/NotificationShowcase.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/CaretColor.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/DirtyTabDecorator.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/EditorTabDecorator.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/FileIconDecorator.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/BottomBarHandler.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/LeftBarHandler.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/top/TopBarHandler.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/file/FilePopupHandler.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/settings/SettingsHandler.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/settings/ThemeHandler.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/ingame/DefaultCommands.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/ingame/ReloadDataPacksCommand.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/CommandDSL.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/CommandUtils.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/Folders.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/Keybinds.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/ModVersion.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/WorldDetection.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/CaseInsensitiveLiteralCommandNode.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/ChatMessageBuilder.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/MCColorUtils.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/MCInterface.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/BottomBarModule.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/LeftBarModule.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/top/TopBarViewModel.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/main/FileTreeViewModel.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/main/TextEditorViewModel.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/FileOverrideDialogViewModel.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/FilePopupModule.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/MoveConfirmationViewModel.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/settings/SettingsModule.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/settings/ThemeModule.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/project/ProjectManagerViewModel.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/project/preview/ProjectPreviewViewModel.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/universal/UniversalPackManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/modules/universal/UniversalWorldIntegration.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/project/Project.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/project/Workspace.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/project/WorkspaceManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/project/metadata/DatapackParser.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/project/state/ProjectState.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/project/state/ProjectStateHandler.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/project/validation/ProjectValidator.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/MainApplication.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/BottomBarView.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/LeftBarView.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/top/TopBarView.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/FileTreeItem.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/FileTreeView.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/TextEditorView.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/file/FileOverrideDialogView.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/file/MoveConfirmationView.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsContent.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsNav.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsView.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/ProjectManagerView.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/RecentProjectListCell.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/preview/ProjectPreviewView.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/services/ArchiveService.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/services/FileService.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/ConfigFieldManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/KeyCombination.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/SettingsLoader.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/SettingsManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigCategory.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorBoolean.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorButton.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorColorPicker.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorDropdown.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorFolder.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorInfo.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorKeybind.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorSlider.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorSpinner.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorText.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorTextField.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigOption.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/Expose.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/SubscribeEvent.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/AdvancedConfig.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/ExampleConfig.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/MainConfig.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/MinecraftConfig.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/ThemeConfig.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/data/ConfigField.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/settings/data/SettingsData.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/BaseStyleManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/NotificationMessageArea.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/commonModels.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageFactory.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageModels.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageStyleManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/appliers/MessageStyleApplier.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/rules/StandardMessageRules.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationFactory.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationModels.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationStyleManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/appliers/NotificationStyleApplier.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/rules/StandardNotificationRules.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabModels.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabStyleManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabStyler.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/appliers/TabStyleApplier.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/ConfigRule.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/DefaultRule.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/DirtyStateRule.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/CSSManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ColorUtils.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ComponentResizer.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/IconUtils.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/JavaFXInitializer.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/LoggerProvider.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/LoggerWrapper.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/OpenLinks.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ProjectIconUtils.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ThemeManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ThemeUtils.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/UIConstants.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/WindowDrag.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/WindowResizer.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/dev/DebugManager.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/file/DirectoryChooseUtils.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/file/FileSystemWatcher.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/SettingsControlBuilder.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/Tiles.kt create mode 100644 nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/controls/KeybindInputButton.kt create mode 100644 nodex/src/main/resources/assets/nodex/icon.png create mode 100644 nodex/src/main/resources/assets/nodex/lang/en_us.json create mode 100644 nodex/src/main/resources/assets/nodex/svg/icon.svg create mode 100644 nodex/src/main/resources/assets/nodex/themes/styles/Debug.css create mode 100644 nodex/src/main/resources/assets/nodex/themes/styles/FileTreeView.css create mode 100644 nodex/src/main/resources/assets/nodex/themes/styles/Override.css create mode 100644 nodex/src/main/resources/assets/nodex/themes/styles/TextEditorView.css create mode 100644 nodex/src/main/resources/assets/nodex/themes/styles/Window.css create mode 100644 nodex/src/main/resources/fabric.mod.json create mode 100644 nodex/src/main/resources/nodex.mixins.json create mode 100644 nodex/stonecutter.gradle.kts create mode 100644 nodex/versions/1.21.10/gradle.properties create mode 100644 nodex/versions/1.21.8/gradle.properties create mode 100644 src/main/java/noppes/npcs/client/fx/JavaFxBridge.java create mode 100644 src/main/java/noppes/npcs/client/fx/NodexFxWindow.java create mode 100644 src/main/java/noppes/npcs/client/fx/NodexScriptBinding.java diff --git a/.gitignore b/.gitignore index 4d121433c..167993f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,3 @@ src/main/resources/assets/customnpcs/api df/ tmp/ src/test - diff --git a/dependencies.gradle b/dependencies.gradle index 2ed8d72da..a05003449 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -45,6 +45,17 @@ dependencies { shadowImplementation(project.files("tools/standalone-JaninoLoader-1.0.1-ALPHA.jar")) annotationProcessor(project.files("tools/standalone-JaninoLoader-1.0.1-ALPHA.jar")) + + // JavaFX editor stack (Nodex-style popup IDE window) + shadowImplementation("org.fxmisc.richtext:richtextfx:0.8.1") + + // JavaFX classes are required at compile-time for the optional Nodex-style editor. + // The legacy 1.7.10 toolchain JDK may not ship JavaFX, so we wire a local jfxrt.jar when present. + def localJfxRt = file("C:/Program Files/Java/jdk1.8.0_202/jre/lib/ext/jfxrt.jar") + if (localJfxRt.exists()) { + compileOnly(files(localJfxRt)) + runtimeOnlyNonPublishable(files(localJfxRt)) + } testImplementation("junit:junit:4.13.2") diff --git a/nodex/.gitattributes b/nodex/.gitattributes new file mode 100644 index 000000000..097f9f98d --- /dev/null +++ b/nodex/.gitattributes @@ -0,0 +1,9 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + diff --git a/nodex/.github/ISSUE_TEMPLATE/bug_report.md b/nodex/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..1b9b491e3 --- /dev/null +++ b/nodex/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve Datapack IDE +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior + +**Expected behavior** +A clear description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Operating System:** + - [Linux, Windows, MacOS, etc.] + + +**Additional context** +Add any other context about the problem here. diff --git a/nodex/.github/ISSUE_TEMPLATE/feature_request.md b/nodex/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..32c9f7183 --- /dev/null +++ b/nodex/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for Datapack IDE +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe it and that the solution would be.** +A clear description of what the problem is and your ideal solution or alternative use! + +**Describe brand new feature you've considered** +A clear description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/nodex/.github/workflows/build.yml b/nodex/.github/workflows/build.yml new file mode 100644 index 000000000..f8d3c6ae9 --- /dev/null +++ b/nodex/.github/workflows/build.yml @@ -0,0 +1,38 @@ +# Automatically build the project and run any configured tests for every push +# and submitted pull request. This can help catch issues that only occur on +# certain platforms or Java versions, and provides a first line of defence +# against bad commits. + +name: build +on: [pull_request, push] + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: checkout repository + uses: actions/checkout@v4 + - name: validate gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + - name: setup jdk + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'microsoft' + + - name: make gradle wrapper executable + if: runner.os != 'Windows' + run: chmod +x ./gradlew + + - name: build + run: ./gradlew build + + - name: capture build artifacts + uses: actions/upload-artifact@v4 + with: + name: Artifacts-${{ runner.os }} + path: build/libs/ \ No newline at end of file diff --git a/nodex/.gitignore b/nodex/.gitignore new file mode 100644 index 000000000..32b421a27 --- /dev/null +++ b/nodex/.gitignore @@ -0,0 +1,41 @@ +# gradle + +.gradle/ +build/ +out/ +classes/ + +# eclipse + +*.launch + +# idea + +.idea/**/*.* +!.idea/icon.svg +*.iml +*.ipr +*.iws + +# vscode + +.settings/ +.vscode/ +bin/ +.classpath +.project + +# macos + +*.DS_Store + +# fabric + +run/ + +# java + +hs_err_*.log +replay_*.log +*.hprof +*.jfr diff --git a/nodex/.idea/icon.svg b/nodex/.idea/icon.svg new file mode 100644 index 000000000..f19d4d8e4 --- /dev/null +++ b/nodex/.idea/icon.svg @@ -0,0 +1,507 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nodex/LICENSE b/nodex/LICENSE new file mode 100644 index 000000000..8000a6faa --- /dev/null +++ b/nodex/LICENSE @@ -0,0 +1,504 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +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 this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +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 +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser 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 Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "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 +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY 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 +LIBRARY (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 LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey 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 library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library 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 + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random + Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/nodex/README.md b/nodex/README.md new file mode 100644 index 000000000..9465e9deb --- /dev/null +++ b/nodex/README.md @@ -0,0 +1,103 @@ +

+
+ datapack_color +
+ Nodex +

+

A work in progress minecraft in-game IDE mod for datapack development.

+
+ + [![Discord](https://img.shields.io/discord/1163847082080211025?label=discord&color=9089DA&logo=discord&style=for-the-badge)](https://discord.com/invite/qZ885qTvkx) + [![Downloads](https://img.shields.io/github/downloads/Frostzie/DataPack-IDE/total?label=downloads&color=208a19&logo=github&style=for-the-badge)](https://github.com/Frostzie/DataPack-IDE/releases) + + [![Fabric](https://img.shields.io/badge/Fabric-0.131.0+1.21.8-blue.svg?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2Ij48cGF0aCBmaWxsPSIjMzgzNDJhIiBkPSJNOSAxaDF2MWgxdjFoMXYxaDF2MWgxdjFoMXYyaC0xdjFoLTJ2MWgtMXYxaC0xdjFIOXYySDh2MUg2di0xSDV2LTFINHYtMUgzdi0xSDJWOWgxVjhoMVY3aDFWNmgxVjVoMVY0aDFWMmgxeiIvPjxwYXRoIGZpbGw9IiNkYmQwYjQiIGQ9Ik00IDlWOGgxVjdoMVY2aDFsMS0xVjRoMVYyaDF2MWgxdjFoMXYxaDF2MWwtMSAxLTIgMy0zIDMtMy0zeiIvPjxwYXRoIGZpbGw9IiNiY2IyOWMiIGQ9Ik05IDNoMXYxaDF2MWgxdjFoMXYxaC0xTDkgNHpNMTAgMTBoMVY5aDFWN2gtMXYxaC0xekg4djJoMXYtMWgxek04IDEySDd2MWgxeiIvPjxwYXRoIGZpbGw9IiNjNmJjYTUiIGQ9Ik03IDVoMXYyaDN2MUg5VjZIN3pNNiA4aDF2MmgyVjlINnoiLz48cGF0aCBmaWxsPSIjYWVhNjk0IiBkPSJNMyA5djFsMyAzaDF2LTFINnYtMUg1di0xSDRWOXoiLz48cGF0aCBmaWxsPSIjOWE5MjdlIiBkPSJNMyAxMHYxaDJ2MmgydjFINnYtMkg0di0yeiIvPjxwYXRoIGZpbGw9IiM4MDdhNmQiIGQ9Ik0xMyA3aDF2MWgtMXoiLz48cGF0aCBmaWxsPSIjMzgzNDJhIiBkPSJNOSA0djFoMnYyaDFWNmgtMlY0eiIvPjwvc3ZnPgo=)](https://fabricmc.net/) + [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-orange?logo=kotlin&logocolor=white)](https://kotlinlang.org/) + [![Java](https://img.shields.io/badge/Java-jdk%2021-red?logo=openjdk&logocolor=white)](https://jdk.java.net/21/) + + This mod is currently in alpha and is in active development
+ many features of this mod have not been fully implemented +
+ +## 📝 Overview + +Nodex is a minecraft mod that allows in game data pack editing with a built-in text editor.
+**These screenshots are OLD** + +| GUI | Menu | +|:---------------------------------------------------------------------------:|:-----------------------------------------------------------------------------:| +| ![preview_1](docs/README_Pictures/Datapack-IDE-0.0.1-FullScreen-README.png) | ![preview_2](docs/README_Pictures/Datapack-IDE-0.0.1-SettingsMenu-README.png) | + +The goal of this mod aside from basic coding functionality expected from vscode is: +- To provide in-game tools for debugging or fast implementation to speed up datapack development. +- To provide a collaborative datapack programming environment (multiplayer support). + +## ✅ What's implemented? + +* Saving, loading, editing datapack files +* Code Editor with JSON Syntax highlighting +* [AtlantaFX themes](https://mkpaz.github.io/atlantafx/) support +* File Tree +* Project manager +* Universal folders for datapacks and configs +* Datapack Mirroring to a world + +## 👷 Future plans +* Multiplayer support +* Plugin system +* [SpyglassMC](https://github.com/SpyglassMC/Spyglass) plugin +* [Beet](https://github.com/mcbeet/beet) plugin +* [Datapack Icons](https://github.com/FuncFusion/mc-dp-icons) plugin + + +## 🧪 Want to test out the mod? +
+click to view process to get experimental build for play testing +
+go to Actions -> go to the latest workflow shown on top +image + +Then download Artifacts. +Unzip Artifact folder upon download and drag the jar file into your mod folder make sure to install the appropriate dependencies, such as [Fabric Language Kotlin](https://modrinth.com/mod/fabric-language-kotlin) and [Fabric API](https://modrinth.com/mod/fabric-api). Then you should be good to go! + +Warning that this version isn't a stable build as it is a dev build, if the current artifact build is having issues, feel free to file a issue report.
+If you are only interested in running a build for your own use, try older artifacts if the lastest isn't working. +
+
+ +## ❓FAQ + +#### Will we support other loaders? +We plan on supporting all loaders as well as any server software. Support for other loaders will come while beta is being released and fabric will be the default until then and will be focused on first! + +#### When will it be FULLY released? +The beta release isn't expected any time soon as there isn't any exact set date. If you are looking for updates, check up on our discord where we have regular updates on our progress posted up on there. + +#### What versions of minecraft will you be supporting? +Currently supporting versions from 1.20.5 to newest!
+Check the modrinth versions page for more info [here](https://modrinth.com/project/XlilVGvF). + +## 🖐️ Want to support the project? +Here's a donation link:
+[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/frostzie) +
+All proceeds goes to frostzie + +## ☝️ Want to give feedback or request features? +Any input is appreciated here +

+ 🗒️ Open Feedback Issue +
+
+ 💡 Open Feature Issue +

+ +## License & Dependencies + +Nodex is free software; you can redistribute it and/or modify it +under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation; **either version 2.1 of the License, or +(at your option) any later version**. + +A copy of the GNU LGPL is provided in the [LICENSE](LICENSE) file.
+ +Dependencies used: [List](docs/SOFTWARE_USED.md) \ No newline at end of file diff --git a/nodex/build.gradle.kts b/nodex/build.gradle.kts new file mode 100644 index 000000000..f3c5e56cc --- /dev/null +++ b/nodex/build.gradle.kts @@ -0,0 +1,167 @@ +import org.gradle.api.tasks.bundling.Jar +import kotlin.String + +plugins { + id("fabric-loom") + java + `maven-publish` + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.javafx) +} + +val mcVersion = property("mcVersion")!!.toString() + +val javafx = libs.versions.javafxapp.get() +// http://insecure.repo1.maven.org/maven2/org/openjfx/javafx-base/21.0.8/ +val javafxClassifiers = listOf( + "win", + "mac", + "mac-aarch64", + "linux" +) + +fun javafxDep(module: String, classifier: String) = + "org.openjfx:javafx-$module:$javafx:$classifier" + +// Naming examples +// Version Name: 0.0.1-fabric+mc1.20.5-1.21.8 +// Jar Name: Nodex-0.0.1-fabric+mc1.20.5-1.21.8.jar +version = "${property("mod.version")}-fabric+mc${property("mod.mc_targets").toString().replace(" ", "-")}" +group = project.findProperty("maven_group") as String + +base.archivesName = property("mod.id") as String + +repositories { + mavenCentral() + maven("https://pkgs.dev.azure.com/djtheredstoner/DevAuth/_packaging/public/maven/v1") + mavenLocal() +} + +dependencies { + minecraft("com.mojang:minecraft:$mcVersion") + + mappings(loom.officialMojangMappings()) + + modImplementation("net.fabricmc:fabric-loader:${property("deps.fabric_loader")}") + + modImplementation("net.fabricmc.fabric-api:fabric-api:${property("deps.fabric_api")}") + + modImplementation(libs.fabric.kotlin) + modRuntimeOnly(libs.devauth) + + // Style and icon packs + implementation(libs.atlantaFX) + implementation(libs.ikonliJavaFX) + implementation(libs.ikonliCore) + implementation(libs.material2) + implementation(libs.feather) + + include(libs.atlantaFX) + include(libs.ikonliJavaFX) + include(libs.ikonliCore) + include(libs.material2) + include(libs.feather) + + // RichTextFX and it's dependencies + implementation(libs.richTextFX) + implementation(libs.flowless) + implementation(libs.reactFX) + implementation(libs.undofx) + implementation(libs.wellbehavedfx) + + include(libs.richTextFX) + include(libs.flowless) + include(libs.reactFX) + include(libs.undofx) + include(libs.wellbehavedfx) + + implementation(libs.jacksonCore) + implementation(libs.directoryWatcher) + implementation(libs.jsvg) + include(libs.jsvg) + include(libs.directoryWatcher) + include(libs.jacksonCore) + + // JavaFX + for (classifier in javafxClassifiers) { + implementation(javafxDep("base", classifier)) + implementation(javafxDep("graphics", classifier)) + implementation(javafxDep("web", classifier)) + implementation(javafxDep("media", classifier)) + implementation(javafxDep("controls", classifier)) + + include(javafxDep("base", classifier)) + include(javafxDep("graphics", classifier)) + include(javafxDep("web", classifier)) + include(javafxDep("media", classifier)) + include(javafxDep("controls", classifier)) + } +} + +// Taken from Stonecutter template +loom { + runConfigs.all { + ideConfigGenerated(true) + vmArgs("-Dmixin.debug.export=true") // Exports transformed classes for debugging + runDir = "../../run" // Shares the run directory between versions + } +} + +tasks.named("processResources") { + inputs.property("id", project.findProperty("mod.id")) + inputs.property("name", project.findProperty("mod.name")) + inputs.property("version", project.findProperty("mod.version")) + inputs.property("minecraft", project.findProperty("mod.mc_dep")) + + val props = mapOf( + "id" to project.property("mod.id"), + "name" to project.property("mod.name"), + "version" to project.property("mod.version"), + "minecraft" to project.property("mod.mc_dep") + ) + + filesMatching("fabric.mod.json") { expand(props) } +} + +tasks.withType { + options.release.set(21) +} + +tasks.named("jar") { + from(rootProject.file("LICENSE")) { + rename { "${it}_${project.findProperty("archives_base_name")}" } + } + from(rootProject.file("docs")) { + into("docs") + exclude("README_Pictures") + } + exclude("module-info.class") + exclude("**/module-info.class") + exclude("META-INF/MANIFEST.MF") + exclude("META-INF/*.SF") + exclude("META-INF/*.DSA") + exclude("META-INF/*.RSA") + + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +tasks.named("remapJar") { + doLast { + copy { + from(outputs.files) + into(rootProject.file("build/libs")) + } + } +} + +publishing { + publications { + create("mavenJava") { + artifactId = project.findProperty("archives_base_name") as String + from(components["java"]) + } + } + repositories { + + } +} \ No newline at end of file diff --git a/nodex/docs/CHANGELOG.md b/nodex/docs/CHANGELOG.md new file mode 100644 index 000000000..0b58c0c05 --- /dev/null +++ b/nodex/docs/CHANGELOG.md @@ -0,0 +1,55 @@ +# Nodex Changelog + +## [0.0.1] + +### Added +- Initial release of DataPack IDE. +- In-game IDE for editing datapack files. +- UI built with JavaFX and AtlantaFX theme library. +- Features include: + - File tree view with directory selection and file moving. + - Tabbed text editor with RichTextFX for code editing. + - Dirty file indicators (suffix and color). + - Customizable themes and font sizes. +- Minecraft integration: + - `/datapack-ide` command to open the IDE. + - Keybinding to toggle the IDE window. + - "Reload Datapack" button to execute `/reload`. + +## [0.0.2] + +**Rebrand from "Datapack IDE" to "Nodex"** + +### Added +- Updated to Minecraft version 1.21.11 +- Project Manager Screen with a recent project list. +- Datapack validation and previews. +- Universal Folder System for config and project sync between instances. +- Datapack Mirroring System for universal folders. +- JSON and mcmeta syntax highlighting (Non IntelliSense). +- Custom Theme CSS editing and live reloading. +- Toggle to show a button on the start and pause screen. +- Added Notification when reloading datapack. +- The Folder Button in LeftBar now collapses the FileTree. + +### Fixes +- TopBar's buttons adding white background. +- Fixed crash with too much text in the settings search field. +- Line & Column count always stays at 0. +- Curser should be more visible by default on light themes. +- Disabled non-functional buttons from being pressed. +- Made TopBar buttons actually work. +- Fixed Text Area and Fields not having limits causing crashes. +- TextEditor not gaining focus on file open. +- Curser not saving location. +- Text no longer pushes buttons out in settings. + +### Technical Changes +- Migrated to mojang mappings. +- Added Stonecutter for version control. +- Removed ModMenu dependency. +- Added support for SVG icons. +- Added styles to Notification, Message, and Tab controls. +- Massively improved file system watcher. +- Removed multiple unused UI Screens. +- Removed Assets folder from mods configs. \ No newline at end of file diff --git a/nodex/docs/LICENSES/AtlantaFX b/nodex/docs/LICENSES/AtlantaFX new file mode 100644 index 000000000..4cfc0c479 --- /dev/null +++ b/nodex/docs/LICENSES/AtlantaFX @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [2022] [mkpaz] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/nodex/docs/LICENSES/DirectoryWatcher b/nodex/docs/LICENSES/DirectoryWatcher new file mode 100644 index 000000000..7a4a3ea24 --- /dev/null +++ b/nodex/docs/LICENSES/DirectoryWatcher @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. \ No newline at end of file diff --git a/nodex/docs/LICENSES/Feather b/nodex/docs/LICENSES/Feather new file mode 100644 index 000000000..f26358aec --- /dev/null +++ b/nodex/docs/LICENSES/Feather @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013-2023 Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/nodex/docs/LICENSES/Flawless b/nodex/docs/LICENSES/Flawless new file mode 100644 index 000000000..b0e4deeb2 --- /dev/null +++ b/nodex/docs/LICENSES/Flawless @@ -0,0 +1,23 @@ +Copyright (c) 2014, TomasMikula +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* 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. + +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. \ No newline at end of file diff --git a/nodex/docs/LICENSES/JacksonCore b/nodex/docs/LICENSES/JacksonCore new file mode 100644 index 000000000..7a4a3ea24 --- /dev/null +++ b/nodex/docs/LICENSES/JacksonCore @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. \ No newline at end of file diff --git a/nodex/docs/LICENSES/Material2 b/nodex/docs/LICENSES/Material2 new file mode 100644 index 000000000..7a4a3ea24 --- /dev/null +++ b/nodex/docs/LICENSES/Material2 @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. \ No newline at end of file diff --git a/nodex/docs/LICENSES/OpenJFX b/nodex/docs/LICENSES/OpenJFX new file mode 100644 index 000000000..75fc511a7 --- /dev/null +++ b/nodex/docs/LICENSES/OpenJFX @@ -0,0 +1,392 @@ + +GNU General Public License, version 2, +with the Classpath Exception + +The GNU General Public License (GPL) + +Version 2, June 1991 + +Copyright (C) 1989, 1991 Free Software Foundation, Inc. +59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble + +The licenses for most software are designed to take away your freedom to share +and change it. By contrast, the GNU General Public License is intended to +guarantee your freedom to share and change free software--to make sure the +software is free for all its users. This General Public License applies to +most of the Free Software Foundation's software and to any other program whose +authors commit to using it. (Some other Free Software Foundation software is +covered by the GNU Library General Public License instead.) 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 this service 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 make restrictions that forbid anyone to deny +you these rights or to ask you to surrender the rights. These restrictions +translate to certain responsibilities for you if you distribute copies of the +software, or if you modify it. + +For example, if you distribute copies of such a program, whether gratis or for +a fee, you must give the recipients all the rights that you have. 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. + +We protect your rights with two steps: (1) copyright the software, and (2) +offer you this license which gives you legal permission to copy, distribute +and/or modify the software. + +Also, for each author's protection and ours, we want to make certain that +everyone understands that there is no warranty for this free software. If the +software is modified by someone else and passed on, we want its recipients to +know that what they have is not the original, so that any problems introduced +by others will not reflect on the original authors' reputations. + +Finally, any free program is threatened constantly by software patents. We +wish to avoid the danger that redistributors of a free program will +individually obtain patent licenses, in effect making the program proprietary. +To prevent this, we have made it clear that any patent must be licensed for +everyone's free use or not licensed at all. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License applies to any program or other work which contains a notice +placed by the copyright holder saying it may be distributed under the terms of +this General Public License. The "Program", below, refers to any such program +or work, and a "work based on the Program" means either the Program or any +derivative work under copyright law: that is to say, a work containing the +Program or a portion of it, either verbatim or with modifications and/or +translated into another language. (Hereinafter, translation is included +without limitation in the term "modification".) Each licensee is addressed as +"you". + +Activities other than copying, distribution and modification are not covered by +this License; they are outside its scope. The act of running the Program is +not restricted, and the output from the Program is covered only if its contents +constitute a work based on the Program (independent of having been made by +running the Program). Whether that is true depends on what the Program does. + +1. You may copy and distribute 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 and +disclaimer of warranty; keep intact all the notices that refer to this License +and to the absence of any warranty; and give any other recipients of the +Program a copy of this License along with the Program. + +You may charge a fee for the physical act of transferring a copy, and you may +at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Program or any portion of it, thus +forming a work based on the Program, and copy and distribute such modifications +or work under the terms of Section 1 above, provided that you also meet all of +these conditions: + + a) You must cause the modified files to carry prominent notices stating + that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in whole or + in part contains or is derived from the Program or any part thereof, to be + licensed as a whole at no charge to all third parties under the terms of + this License. + + c) If the modified program normally reads commands interactively when run, + you must cause it, when started running for such interactive use in the + most ordinary way, to print or display an announcement including an + appropriate copyright notice and a notice that there is no warranty (or + else, saying that you provide a warranty) and that users may redistribute + the program under these conditions, and telling the user how to view a copy + of this License. (Exception: if the Program itself is interactive but does + not normally print such an announcement, your work based on the Program is + not required to print an announcement.) + +These requirements apply to the modified work as a whole. If identifiable +sections of that work are not derived from the Program, and can be reasonably +considered independent and separate works in themselves, then this License, and +its terms, do not apply to those sections when you distribute them as separate +works. But when you distribute the same sections as part of a whole which is a +work based on the Program, the distribution of the whole must be on the terms +of this License, whose permissions for other licensees extend to the entire +whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your +rights to work written entirely by you; rather, the intent is to exercise the +right to control the distribution of derivative or collective works based on +the Program. + +In addition, mere aggregation of another work not based on the Program with the +Program (or with a work based on the Program) on a volume of a storage or +distribution medium does not bring the other work under the scope of this +License. + +3. You may copy and distribute the Program (or a work based on it, under +Section 2) in object code or executable form under the terms of Sections 1 and +2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable source + code, which must be distributed under the terms of Sections 1 and 2 above + on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three years, to + give any third party, for a charge no more than your cost of physically + performing source distribution, a complete machine-readable copy of the + corresponding source code, to be distributed under the terms of Sections 1 + and 2 above on a medium customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer to + distribute corresponding source code. (This alternative is allowed only + for noncommercial distribution and only if you received the program in + object code or executable form with such an offer, in accord with + Subsection b above.) + +The source code for a work means the preferred form of the work for making +modifications to it. For an executable work, complete source code means all +the source code for all modules it contains, plus any associated interface +definition files, plus the scripts used to control compilation and installation +of the executable. However, as a special exception, the source code +distributed need not include anything that is normally distributed (in either +source or binary form) with the major components (compiler, kernel, and so on) +of the operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the source +code from the same place counts as distribution of the source code, even though +third parties are not compelled to copy the source along with the object code. + +4. You may not copy, modify, sublicense, or distribute the Program except as +expressly provided under this License. Any attempt otherwise to copy, modify, +sublicense or distribute the Program is void, and will automatically terminate +your rights under this License. However, parties who have received copies, or +rights, from you under this License will not have their licenses terminated so +long as such parties remain in full compliance. + +5. You are not required to accept this License, since you have not signed it. +However, nothing else grants you permission to modify or distribute the Program +or its derivative works. These actions are prohibited by law if you do not +accept this License. Therefore, by modifying or distributing the Program (or +any work based on the Program), you indicate your acceptance of this License to +do so, and all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + +6. Each time you redistribute the Program (or any work based on the Program), +the recipient automatically receives a license from the original licensor to +copy, distribute or modify the Program subject to these terms and conditions. +You may not impose any further restrictions on the recipients' exercise of the +rights granted herein. You are not responsible for enforcing compliance by +third parties to this License. + +7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), 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 distribute so as to satisfy +simultaneously your obligations under this License and any other pertinent +obligations, then as a consequence you may not distribute the Program at all. +For example, if a patent license would not permit royalty-free redistribution +of the Program by all those who receive copies directly or indirectly through +you, then the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply and +the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or +other property right claims or to contest validity of any such claims; this +section has the sole purpose of protecting the integrity of the free software +distribution system, which is implemented by public license practices. Many +people have made generous contributions to the wide range of software +distributed through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing to +distribute software through any other system and a licensee cannot impose that +choice. + +This section is intended to make thoroughly clear what is believed to be a +consequence of the rest of this License. + +8. If the distribution and/or use of the Program is restricted in certain +countries either by patents or by copyrighted interfaces, the original +copyright holder who places the Program under this License may add an explicit +geographical distribution limitation excluding those countries, so that +distribution is permitted only in or among countries not thus excluded. In +such case, this License incorporates the limitation as if written in the body +of this License. + +9. The Free Software Foundation may publish revised and/or new versions of the +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 a version number of this License which applies to it and "any later +version", you have the option of following the terms and conditions either of +that version or of any later version published by the Free Software Foundation. +If the Program does not specify a version number of this License, you may +choose any version ever published by the Free Software Foundation. + +10. If you wish to incorporate parts of the Program into other free programs +whose distribution conditions are different, write to the author to ask for +permission. For software which is copyrighted by the Free Software Foundation, +write to the Free Software Foundation; we sometimes make exceptions for this. +Our decision will be guided by the two goals of preserving the free status of +all derivatives of our free software and of promoting the sharing and reuse of +software generally. + +NO WARRANTY + +11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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. + +12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE 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. + +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 convey the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + + One line to give the program's name and a brief idea of what it does. + + 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 2 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, write to the Free Software Foundation, Inc., 59 + Temple Place, Suite 330, Boston, MA 02111-1307 USA + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this when it +starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author Gnomovision 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, the commands you use may be +called something other than 'show w' and 'show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your school, +if any, to sign a "copyright disclaimer" for the program, if necessary. Here +is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + 'Gnomovision' (which makes passes at compilers) written by James Hacker. + + signature of Ty Coon, 1 April 1989 + + Ty Coon, President of Vice + +This 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 Library General Public +License instead of this License. + + +"CLASSPATH" EXCEPTION TO THE GPL + +Certain source files distributed by Oracle America and/or its affiliates are +subject to the following clarification and special exception to the GPL, but +only where Oracle has expressly included in the particular source file's header +the words "Oracle designates this particular file as subject to the "Classpath" +exception as provided by Oracle in the LICENSE file that accompanied this code." + + Linking this library statically or dynamically with other modules is making + a combined work based on this library. Thus, the terms and conditions of + the GNU General Public License cover the whole combination. + + As a special exception, the copyright holders of this library give you + permission to link this library with independent modules to produce an + executable, regardless of the license terms of these independent modules, + and to copy and distribute the resulting executable under terms of your + choice, provided that you also meet, for each linked independent module, + the terms and conditions of the license of that module. An independent + module is a module which is not derived from or based on this library. If + you modify this library, you may extend this exception to your version of + the library, but you are not obligated to do so. If you do not wish to do + so, delete this exception statement from your version. + + + + +ADDITIONAL INFORMATION ABOUT LICENSING + +Certain files distributed by Oracle America, Inc. and/or its affiliates are +subject to the following clarification and special exception to the GPLv2, +based on the GNU Project exception for its Classpath libraries, known as the +GNU Classpath Exception. + +Note that Oracle includes multiple, independent programs in this software +package. Some of those programs are provided under licenses deemed +incompatible with the GPLv2 by the Free Software Foundation and others. +For example, the package includes programs licensed under the Apache +License, Version 2.0 and may include FreeType. Such programs are licensed +to you under their original licenses. + +Oracle facilitates your further distribution of this package by adding the +Classpath Exception to the necessary parts of its GPLv2 code, which permits +you to use that code in combination with other independent modules not +licensed under the GPLv2. However, note that this would not permit you to +commingle code under an incompatible license with Oracle's GPLv2 licensed +code by, for example, cutting and pasting such code into a file also +containing Oracle's GPLv2 licensed code and then distributing the result. + +Additionally, if you were to remove the Classpath Exception from any of the +files to which it applies and distribute the result, you would likely be +required to license some or all of the other code in that distribution under +the GPLv2 as well, and since the GPLv2 is incompatible with the license terms +of some items included in the distribution by Oracle, removing the Classpath +Exception could therefore effectively compromise your ability to further +distribute the package. + +Failing to distribute notices associated with some files may also create +unexpected legal consequences. + +Proceed with caution and we recommend that you obtain the advice of a lawyer +skilled in open source matters before removing the Classpath Exception or +making modifications to this package which may subsequently be redistributed +and/or involve the use of third party software. \ No newline at end of file diff --git a/nodex/docs/LICENSES/ReactFX b/nodex/docs/LICENSES/ReactFX new file mode 100644 index 000000000..c79049c34 --- /dev/null +++ b/nodex/docs/LICENSES/ReactFX @@ -0,0 +1,10 @@ +Copyright (c) 2013-2014, Tomas Mikula +All rights reserved. + +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. + +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. \ No newline at end of file diff --git a/nodex/docs/LICENSES/RichTextFX b/nodex/docs/LICENSES/RichTextFX new file mode 100644 index 000000000..a005da8c2 --- /dev/null +++ b/nodex/docs/LICENSES/RichTextFX @@ -0,0 +1,10 @@ +Copyright (c) 2013-2023, Tomas Mikula and contributors +All rights reserved. + +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. + +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. \ No newline at end of file diff --git a/nodex/docs/LICENSES/UndoFX b/nodex/docs/LICENSES/UndoFX new file mode 100644 index 000000000..55982dcd4 --- /dev/null +++ b/nodex/docs/LICENSES/UndoFX @@ -0,0 +1,23 @@ +Copyright (c) 2014, TomasMikula +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* 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. + +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. \ No newline at end of file diff --git a/nodex/docs/LICENSES/WellBehavedFX b/nodex/docs/LICENSES/WellBehavedFX new file mode 100644 index 000000000..21be4ebc1 --- /dev/null +++ b/nodex/docs/LICENSES/WellBehavedFX @@ -0,0 +1,23 @@ +Copyright (c) 2014, TomasMikula +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* 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. + +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. diff --git a/nodex/docs/LICENSES/ikonli b/nodex/docs/LICENSES/ikonli new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/nodex/docs/LICENSES/ikonli @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/nodex/docs/LICENSES/jsvg b/nodex/docs/LICENSES/jsvg new file mode 100644 index 000000000..0c95adebf --- /dev/null +++ b/nodex/docs/LICENSES/jsvg @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2024 Jannis Weis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/nodex/docs/README_Pictures/Datapack-IDE-0.0.1-FullScreen-README.png b/nodex/docs/README_Pictures/Datapack-IDE-0.0.1-FullScreen-README.png new file mode 100644 index 0000000000000000000000000000000000000000..e6023e29282888fe6007d9b30d00ade76b289ce4 GIT binary patch literal 25084 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�&2mzj|8`u*WZ~G&y;f5e-#c_<42vp^M}zhIu)A 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 literal 0 HcmV?d00001 diff --git a/nodex/docs/README_Pictures/Nodex_IconSVG.svg b/nodex/docs/README_Pictures/Nodex_IconSVG.svg new file mode 100644 index 000000000..f19d4d8e4 --- /dev/null +++ b/nodex/docs/README_Pictures/Nodex_IconSVG.svg @@ -0,0 +1,507 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nodex/docs/SOFTWARE_USED.md b/nodex/docs/SOFTWARE_USED.md new file mode 100644 index 000000000..bf9cc0b54 --- /dev/null +++ b/nodex/docs/SOFTWARE_USED.md @@ -0,0 +1,25 @@ +# Libraries Used + +Datapack IDE relies on the following software: + +| Software | License | Included In Jar | +|-------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|-----------------| +| [GSON](https://github.com/google/gson) | [Apache 2.0](https://github.com/google/gson/blob/master/LICENSE) | | +| [Fabric API](https://github.com/FabricMC/fabric) | [Apache 2.0](https://github.com/FabricMC/fabric/blob/1.21.6/LICENSE) | | +| [Fabric Language Kotlin](https://github.com/FabricMC/fabric-language-kotlin/) | [Apache 2.0](https://github.com/FabricMC/fabric-language-kotlin/blob/master/LICENSE) | | +| [Fabric Loom](https://github.com/FabricMC/fabric-loom) | [MIT](https://github.com/FabricMC/fabric-loom/blob/dev/1.10/LICENSE) | | +| [DevAuth](https://github.com/DJtheRedstoner/DevAuth) | [MIT](https://github.com/DJtheRedstoner/DevAuth/blob/master/LICENSE) | | +| [JavaFX](https://openjfx.io/) | [GPL v2 with Classpath Exception](https://openjdk.org/legal/gplv2+ce.html) | ✅ | +| [AtlantaFX](https://github.com/mkpaz/atlantafx) | [MIT](https://github.com/mkpaz/atlantafx/blob/master/LICENSE) | ✅ | +| [Ikonli](https://github.com/kordamp/ikonli/) | [Apache 2.0](https://github.com/kordamp/ikonli/blob/master/LICENSE) | ✅ | +| [Material2](https://github.com/material-icons/material-icons-font) | [Apache 2.0](https://github.com/material-icons/material-icons-font/blob/master/LICENSE) | ✅ | +| [Feather](https://github.com/feathericons/feather/) | [Apache 2.0](https://github.com/feathericons/feather/blob/main/LICENSE) | ✅ | +| [RichTextFX](https://github.com/FXMisc/RichTextFX/) | [BSD 2-Clause](https://github.com/FXMisc/RichTextFX/blob/master/LICENSE) | ✅ | +| [Flowless](https://github.com/FXMisc/Flowless) | [BSD 2-Clause](https://github.com/FXMisc/Flowless/blob/master/LICENSE) | ✅ | +| [ReactFX](https://github.com/TomasMikula/ReactFX) | [BSD 2-Clause](https://github.com/TomasMikula/ReactFX/blob/master/LICENSE) | ✅ | +| [UndoFX](https://github.com/FXMisc/UndoFX) | [BSD 2-Clause](https://github.com/FXMisc/UndoFX/blob/master/LICENSE) | ✅ | +| [WellBehavedFX](https://github.com/FXMisc/WellBehavedFX) | [BSD 2-Clause](https://github.com/FXMisc/WellBehavedFX/blob/master/LICENSE) | ✅ | +| [Stonecutter](https://stonecutter.kikugie.dev/) | [LGPL-3.0](https://codeberg.org/stonecutter/stonecutter/src/branch/0.7/LICENSE) | | +| [Directory Watcher](https://github.com/gmethvin/directory-watcher) | [Apache 2.0](https://github.com/gmethvin/directory-watcher/blob/main/LICENSE) | ✅ | +| [JSVG](https://github.com/weisJ/jsvg) | [MIT](https://github.com/weisJ/jsvg/blob/master/LICENSE) | ✅ | +| [Jackson Core](https://github.com/FasterXML/jackson-core/) | [Apache 2.0](https://github.com/FasterXML/jackson-core/blob/2.x/LICENSE) | ✅ | \ No newline at end of file diff --git a/nodex/gradle.properties b/nodex/gradle.properties new file mode 100644 index 000000000..838f2cc70 --- /dev/null +++ b/nodex/gradle.properties @@ -0,0 +1,15 @@ +org.gradle.jvmargs=-Xmx4G +org.gradle.parallel=true + +loom.platform=fabric +maven_group=io.github.frostzie.nodex +archives_base_name=Nodex + +# Mod properties +mod.version=0.0.2 +mod.group=io.github.frostzie.nodex +mod.id=nodex +mod.name=Nodex + +deps.fabric_loader = 0.18.1 +deps.fabric_kotlin = 1.13.4+kotlin.2.2.0 \ No newline at end of file diff --git a/nodex/gradle/libs.versions.toml b/nodex/gradle/libs.versions.toml new file mode 100644 index 000000000..7df11961c --- /dev/null +++ b/nodex/gradle/libs.versions.toml @@ -0,0 +1,38 @@ +[versions] +kotlin = "2.1.0" +fabric_kotlin = "1.13.4+kotlin.2.2.0" +devauth = "1.2.1" +javafxapp = "21.0.8" +javafx_plugin = "0.1.0" +atlantafx-base = "2.1.0" +ikonli = "12.4.0" +richTextFXv = "0.11.6" +flowlessv = "0.7.4" +reactFXv = "2.0-M5" +undoFXv = "2.1.1" +wellbehavedFXv = "0.3.3" +directoryWatcherV = "0.19.1" +jsvgV = "2.0.0" +jacksonCoreV = "2.17.1" + +[libraries] +fabric_kotlin = { module = "net.fabricmc:fabric-language-kotlin", version.ref = "fabric_kotlin" } +devauth = { module = "me.djtheredstoner:DevAuth-fabric", version.ref = "devauth" } + +jacksonCore = { module = "com.fasterxml.jackson.core:jackson-core", version.ref = "jacksonCoreV" } +directoryWatcher = { module = "io.methvin:directory-watcher", version.ref = "directoryWatcherV" } +jsvg = { module = "com.github.weisj:jsvg", version.ref = "jsvgV" } +richTextFX = { module = "org.fxmisc.richtext:richtextfx", version.ref = "richTextFXv"} +flowless = { module = "org.fxmisc.flowless:flowless", version.ref = "flowlessv" } +reactFX = { module = "org.reactfx:reactfx", version.ref = "reactFXv" } +undofx = { module = "org.fxmisc.undo:undofx", version.ref = "undoFXv" } +wellbehavedfx = { module = "org.fxmisc.wellbehaved:wellbehavedfx", version.ref = "wellbehavedFXv" } +atlantaFX = { module = "io.github.mkpaz:atlantafx-base", version.ref = "atlantafx-base" } +ikonliJavaFX = { module = "org.kordamp.ikonli:ikonli-javafx", version.ref = "ikonli"} +ikonliCore = { module = "org.kordamp.ikonli:ikonli-core", version.ref = "ikonli"} +material2 = { module = "org.kordamp.ikonli:ikonli-material2-pack", version.ref = "ikonli"} +feather = { module = "org.kordamp.ikonli:ikonli-feather-pack", version.ref = "ikonli"} + +[plugins] +kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +javafx = { id = "org.openjfx.javafxplugin", version.ref = "javafx_plugin" } \ No newline at end of file diff --git a/nodex/gradle/wrapper/gradle-wrapper.jar b/nodex/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + 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 + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -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/nodex/gradlew.bat b/nodex/gradlew.bat new file mode 100644 index 000000000..db3a6ac20 --- /dev/null +++ b/nodex/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/nodex/settings.gradle.kts b/nodex/settings.gradle.kts new file mode 100644 index 000000000..445bc45c8 --- /dev/null +++ b/nodex/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + maven("https://maven.fabricmc.net") + maven("https://maven.quiltmc.org/repository/release") + maven("https://maven.kikugie.dev/snapshots") + } +} + + +plugins { + id("dev.kikugie.stonecutter") version "0.7.11" +} + +stonecutter { + kotlinController = true + centralScript = "build.gradle.kts" + shared { + versions("1.21.8", "1.21.10") + } + create(rootProject) +} + +rootProject.name = "Nodex" diff --git a/nodex/src/main/java/io/github/frostzie/nodex/RichJsonFX.java b/nodex/src/main/java/io/github/frostzie/nodex/RichJsonFX.java new file mode 100644 index 000000000..8ea847b0b --- /dev/null +++ b/nodex/src/main/java/io/github/frostzie/nodex/RichJsonFX.java @@ -0,0 +1,109 @@ +package io.github.frostzie.nodex; + +// Taken from https://github.com/iliareshetov/rich-json-fx/blob/main/src/main/java/com/github/iliareshetov/RichJsonFX.java + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import org.fxmisc.richtext.CodeArea; +import org.fxmisc.richtext.model.StyleSpans; +import org.fxmisc.richtext.model.StyleSpansBuilder; + +import java.io.IOException; +import java.util.*; + +public class RichJsonFX { + private final JsonFactory jsonFactory; + + public RichJsonFX() { + jsonFactory = new JsonFactory(); + } + + public static String jsonTokenToClassName(JsonToken jsonToken) { + if (jsonToken == null) { + return ""; + } + return switch (jsonToken) { + case FIELD_NAME -> "json-property"; + case VALUE_STRING -> "json-string"; + case START_OBJECT -> "json-start-object"; + case END_OBJECT -> "json-end-object"; + case VALUE_NUMBER_FLOAT -> "json-float"; + case VALUE_NUMBER_INT -> "json-int"; + case VALUE_TRUE -> "json-true"; + case VALUE_FALSE -> "json-false"; + case START_ARRAY -> "json-start-array"; + case END_ARRAY -> "json-end-array"; + case VALUE_EMBEDDED_OBJECT -> "json-embedded"; + case VALUE_NULL -> "json-null"; + default -> ""; + }; + } + + /** + * Highlights json by applying styles to different tokens. + * + * @param json The json to be highlighted. + * @return StyleSpans containing the highlighted tokens. + */ + public StyleSpans> highlight(String json) throws IOException { + List matches = new ArrayList<>(); + + JsonParser parser = jsonFactory.createParser(json); + while (!parser.isClosed()) { + JsonToken jsonToken = parser.nextToken(); + int start = (int) parser.currentTokenLocation().getCharOffset(); + int end = start + parser.getTextLength(); + + // add surrounding "" + if (jsonToken == JsonToken.VALUE_STRING || jsonToken == JsonToken.FIELD_NAME) { + end += 2; + } + + String className = jsonTokenToClassName(jsonToken); + if (!className.isEmpty()) { + matches.add(new Match(className, start, end)); + } + } + + StyleSpansBuilder> spansBuilder = new StyleSpansBuilder<>(); + int lastPos = 0; + for (Match match : matches) { + if (match.start() > lastPos) { + spansBuilder.add(Collections.emptyList(), match.start() - lastPos); + } + + spansBuilder.add(Collections.singleton(match.kind()), match.end() - match.start()); + lastPos = match.end(); + } + + if (lastPos < json.length()) { + spansBuilder.add(Collections.emptyList(), json.length() - lastPos); + } + + return spansBuilder.create(); + } + + /** + * Method to highlight JSON in a CodeArea. + * + * @param codeArea the CodeArea containing JSON text. + */ + public void highlightCodeArea(CodeArea codeArea) throws IOException { + String text = codeArea.getText(); + if (text != null && !text.isEmpty()) { + StyleSpans> highlighting = highlight(text); + codeArea.setStyleSpans(0, highlighting); + } + } + + public record Match(String kind, int start, int end) implements Comparable { + + @Override + public int compareTo(Match match) { + return Integer.compare(start, match.start); + } + + } + +} \ No newline at end of file diff --git a/nodex/src/main/java/io/github/frostzie/nodex/mixin/CaseInsensitiveCommandMapMixin.java b/nodex/src/main/java/io/github/frostzie/nodex/mixin/CaseInsensitiveCommandMapMixin.java new file mode 100644 index 000000000..dd8bc9112 --- /dev/null +++ b/nodex/src/main/java/io/github/frostzie/nodex/mixin/CaseInsensitiveCommandMapMixin.java @@ -0,0 +1,26 @@ +package io.github.frostzie.nodex.mixin; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.brigadier.tree.CommandNode; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import java.util.Locale; +import java.util.Map; + +// Taken from Firmament +@Mixin(value = CommandNode.class, remap = false) +public class CaseInsensitiveCommandMapMixin { + @WrapOperation( + method = "getRelevantNodes", + at = @At(value = "INVOKE", target = "Ljava/util/Map;get(Ljava/lang/Object;)Ljava/lang/Object;"), + remap = false + ) + public Object modifyCommandLookup(Map map, Object text, Operation op) { + var original = op.call(map, text); + if (original == null && text instanceof String) { + return map.get(((String) text).toLowerCase(Locale.ROOT)); + } + return original; + } +} \ No newline at end of file diff --git a/nodex/src/main/java/io/github/frostzie/nodex/mixin/PauseScreenMixin.java b/nodex/src/main/java/io/github/frostzie/nodex/mixin/PauseScreenMixin.java new file mode 100644 index 000000000..bf7f4fb95 --- /dev/null +++ b/nodex/src/main/java/io/github/frostzie/nodex/mixin/PauseScreenMixin.java @@ -0,0 +1,46 @@ +package io.github.frostzie.nodex.mixin; + +import io.github.frostzie.nodex.screen.MainApplication; +import io.github.frostzie.nodex.settings.categories.MinecraftConfig; +import io.github.frostzie.nodex.utils.JavaFXInitializer; +import io.github.frostzie.nodex.utils.LoggerProvider; +import net.minecraft.client.gui.screens.PauseScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.components.Button; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PauseScreen.class) +public abstract class PauseScreenMixin extends Screen { + + @Unique + private final @NotNull Logger logger = LoggerProvider.INSTANCE.getLogger("PauseScreenMixin"); + protected PauseScreenMixin() { + super(null); + } + + @Inject(method = "init", at = @At("TAIL")) + private void onInit(CallbackInfo ci) { + if (!MinecraftConfig.INSTANCE.getOnScreenButton().get()) { + return; + } + + PauseScreen self = (PauseScreen)(Object)this; + + Button button = Button.builder(Component.literal("IDE"), b -> { + if (JavaFXInitializer.INSTANCE.isJavaFXAvailable()) { + MainApplication.Companion.showMainWindow(); + } else { + logger.error("Cannot open IDE window - JavaFX is not available"); + } + }).bounds(self.width / 2 + 72, self.height / 4 - 16, 30, 20).build(); + + this.addRenderableWidget(button); + } +} \ No newline at end of file diff --git a/nodex/src/main/java/io/github/frostzie/nodex/mixin/TitleScreenMixin.java b/nodex/src/main/java/io/github/frostzie/nodex/mixin/TitleScreenMixin.java new file mode 100644 index 000000000..780af7e42 --- /dev/null +++ b/nodex/src/main/java/io/github/frostzie/nodex/mixin/TitleScreenMixin.java @@ -0,0 +1,46 @@ +package io.github.frostzie.nodex.mixin; + +import io.github.frostzie.nodex.screen.MainApplication; +import io.github.frostzie.nodex.settings.categories.MinecraftConfig; +import io.github.frostzie.nodex.utils.JavaFXInitializer; +import io.github.frostzie.nodex.utils.LoggerProvider; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(TitleScreen.class) +public abstract class TitleScreenMixin extends Screen { + + @Unique + private final @NotNull Logger logger = LoggerProvider.INSTANCE.getLogger("TitleScreenMixin"); + protected TitleScreenMixin() { + super(null); + } + + @Inject(method = "init", at = @At("TAIL")) + private void onInit(CallbackInfo ci) { + if (!MinecraftConfig.INSTANCE.getOnScreenButton().get()) { + return; + } + + TitleScreen self = (TitleScreen)(Object)this; + + Button button = Button.builder(Component.literal("IDE"), b -> { + if (JavaFXInitializer.INSTANCE.isJavaFXAvailable()) { + MainApplication.Companion.showMainWindow(); + } else { + logger.error("Cannot open IDE window - JavaFX is not available"); + } + }).bounds(self.width / 2 + 128, self.height / 4 + 132, 30, 20).build(); + + this.addRenderableWidget(button); + } +} \ No newline at end of file diff --git a/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGException.java b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGException.java new file mode 100644 index 000000000..0462cad59 --- /dev/null +++ b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGException.java @@ -0,0 +1,15 @@ +package io.github.frostzie.nodex.svg_icon; + +public class SVGException extends RuntimeException { + + public SVGException() { + } + + public SVGException(String message) { + super(message); + } + + public SVGException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImage.java b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImage.java new file mode 100644 index 000000000..3fcf8950e --- /dev/null +++ b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImage.java @@ -0,0 +1,107 @@ +package io.github.frostzie.nodex.svg_icon; + +import com.github.weisj.jsvg.SVGDocument; +import com.github.weisj.jsvg.parser.LoaderContext; +import com.github.weisj.jsvg.parser.SVGLoader; +import com.github.weisj.jsvg.view.ViewBox; +import javafx.scene.image.Image; +import javafx.scene.image.PixelFormat; +import javafx.scene.image.PixelWriter; +import javafx.scene.image.WritableImage; +import javax.imageio.stream.ImageInputStream; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.Objects; + +public class SVGImage { + + // should be either TYPE_INT_ARGB or TYPE_INT_ARGB_PRE, + // otherwise SwingFXUtils.toFXImage() will convert it to the TYPE_INT_ARGB_PRE + public static final int BUFFERED_IMAGE_TYPE = BufferedImage.TYPE_INT_ARGB_PRE; + + protected final SVGDocument svgDocument; + + protected SVGImage(SVGDocument svgDocument) { + this.svgDocument = svgDocument; + } + + public int getWidth() { + return (int) svgDocument.size().getWidth(); + } + + public int getHeight() { + return (int) svgDocument.size().getHeight(); + } + + public BufferedImage toBufferedImage(int width, int height) { + var image = new BufferedImage(width, height, BUFFERED_IMAGE_TYPE); + + Graphics2D graphics = image.createGraphics(); + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + graphics.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + + svgDocument.render(null, graphics, new ViewBox(width, height)); + graphics.dispose(); + + return image; + } + + // Removed SwingFXUtils usage here - Frostzie + public Image toImage(int width, int height) { + BufferedImage bufferedImage = toBufferedImage(width, height); + WritableImage wr = new WritableImage(width, height); + PixelWriter pw = wr.getPixelWriter(); + int[] data = ((DataBufferInt) bufferedImage.getRaster().getDataBuffer()).getData(); + pw.setPixels(0, 0, width, height, PixelFormat.getIntArgbPreInstance(), data, 0, width); + return wr; + } + + //========================================================================= + + public static SVGImage of(ImageInputStream input) throws IOException { + var loader = new SVGLoader(); + + var bos = new ByteArrayOutputStream(); + var buf = new byte[8192]; + + int bytesRead; + while ((bytesRead = input.read(buf)) != -1) { + bos.write(buf, 0, bytesRead); + } + + var svgDocument = loader.load( + new ByteArrayInputStream(bos.toByteArray()), null, LoaderContext.createDefault() + ); + + Objects.requireNonNull(svgDocument, "Unable to read SVG from ImageInputStream"); + + return new SVGImage(svgDocument); + } + + public static SVGImage of(InputStream input) throws IOException { + var loader = new SVGLoader(); + + var svgDocument = loader.load( + input, null, LoaderContext.createDefault() + ); + + Objects.requireNonNull(svgDocument, "Unable to read SVG from InputStream"); + + return new SVGImage(svgDocument); + } + + public static SVGImage of(URL url) { + var svgDocument = new SVGLoader().load(url); + + Objects.requireNonNull(svgDocument, "Unable to read SVG from URL"); + + return new SVGImage(svgDocument); + } +} diff --git a/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImageReader.java b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImageReader.java new file mode 100644 index 000000000..7bda2b0eb --- /dev/null +++ b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImageReader.java @@ -0,0 +1,111 @@ +package io.github.frostzie.nodex.svg_icon; + +import javax.imageio.ImageReadParam; +import javax.imageio.ImageReader; +import javax.imageio.ImageTypeSpecifier; +import javax.imageio.metadata.IIOMetadata; +import javax.imageio.stream.ImageInputStream; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.Collections; +import java.util.Iterator; + + +public class SVGImageReader extends ImageReader { + + protected SVGImage svgImage; + + protected SVGImageReader(SVGImageReaderSpi spi) { + super(spi); + } + + @Override + public int getNumImages(boolean allowSearch) { + return 1; + } + + @Override + public int getWidth(int imageIndex) { + return svgImage != null ? svgImage.getWidth() : 0; + } + + @Override + public int getHeight(int imageIndex) { + return svgImage != null ? svgImage.getHeight() : 0; + } + + @Override + public Iterator getImageTypes(int imageIndex) { + return Collections.singletonList( + ImageTypeSpecifier.createFromBufferedImageType(SVGImage.BUFFERED_IMAGE_TYPE) + ).iterator(); + } + + @Override + public IIOMetadata getStreamMetadata() { + return null; + } + + @Override + public IIOMetadata getImageMetadata(int imageIndex) { + return null; + } + + @Override + public BufferedImage read(int imageIndex, ImageReadParam param) { + Dimension size = null; + + if (param != null) { + size = param.getSourceRenderSize(); + } + + if (size == null) { + size = new Dimension(getWidth(imageIndex), getHeight(imageIndex)); + } + + if (svgImage == null) { + throw new SVGException("You must set input before reading the image"); + } + + return svgImage.toBufferedImage((int) size.getWidth(), (int) size.getHeight()); + } + + @Override + public void setInput(Object input, boolean seekForwardOnly, boolean ignoreMetadata) { + super.setInput(input, true, true); + + try { + if (input instanceof ImageInputStream iis) { + svgImage = SVGImage.of(iis); + } else { + throw new SVGException("Unexpected input type: " + input.getClass().getCanonicalName()); + } + } catch (IOException e) { + throw new SVGException(e.getMessage(), e); + } + } + + @Override + public ImageReadParam getDefaultReadParam() { + return new SVGImageReadParam(); + } + + @Override + public void dispose() { + super.dispose(); + svgImage = null; + } + + //========================================================================= + + protected static final class SVGImageReadParam extends ImageReadParam { + + @Override + public boolean canSetSourceRenderSize() { + // Image cannot be adjusted to the available frame size here + // https://mail.openjdk.org/pipermail/openjfx-dev/2025-July/055365.html + return false; + } + } +} diff --git a/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImageReaderSpi.java b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImageReaderSpi.java new file mode 100644 index 000000000..da43a9b08 --- /dev/null +++ b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGImageReaderSpi.java @@ -0,0 +1,73 @@ +package io.github.frostzie.nodex.svg_icon; + +import javax.imageio.ImageReader; +import javax.imageio.spi.ImageReaderSpi; +import javax.imageio.stream.ImageInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.util.Locale; + +public class SVGImageReaderSpi extends ImageReaderSpi { + + protected static final String[] FORMAT_NAMES = {"svg", "SVG"}; + protected static final String[] EXTENSIONS = {"svg"}; + protected static final String[] MIME_TYPES = {"image/svg", "image/x-svg", "image/svg+xml", "image/svg-xml"}; + + public SVGImageReaderSpi() { + super( + "jsvgfx", + "1.0", + FORMAT_NAMES, + EXTENSIONS, + MIME_TYPES, + "jsvgfx.SVGImageReader", + new Class[]{ImageInputStream.class}, + null, + false, + null, + null, + null, + null, + true, + null, + null, + null, + null + ); + } + + @Override + public String getDescription(Locale locale) { + return "Scalable Vector Graphics (SVG) format image reader"; + } + + @Override + public boolean canDecodeInput(Object input) throws IOException { + return input instanceof ImageInputStream iis && canDecode(iis); + } + + @Override + public ImageReader createReaderInstance(Object extension) { + return new SVGImageReader(this); + } + + //========================================================================= + + @SuppressWarnings("StatementWithEmptyBody") + protected boolean canDecode(ImageInputStream iis) throws IOException { + try { + iis.mark(); + + int b; + while (Character.isWhitespace((char) (b = iis.read()))) { + // skip leading whitespaces + } + + return b == '<'; + } catch (EOFException ignore) { + return false; + } finally { + iis.reset(); + } + } +} diff --git a/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGTransformer.java b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGTransformer.java new file mode 100644 index 000000000..b148053f0 --- /dev/null +++ b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/SVGTransformer.java @@ -0,0 +1,89 @@ +package io.github.frostzie.nodex.svg_icon; + +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.StringWriter; + +public class SVGTransformer { + + public static final String SVG_WIDTH_EXPR = "/svg/@width"; + public static final String SVG_HEIGHT_EXPR = "/svg/@height"; + + protected final XPath xPath = XPathFactory.newInstance().newXPath(); + protected final Document document; + + public SVGTransformer(String path) { + try { + var builder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); + document = builder.parse(path); + } catch (Exception e) { + throw new SVGException(e.getMessage(), e); + } + } + + public SVGTransformer setWidth(int width) { + return setNodeValue(SVG_WIDTH_EXPR, String.valueOf(width)); + } + + public SVGTransformer setHeight(int height) { + return setNodeValue(SVG_HEIGHT_EXPR, String.valueOf(height)); + } + + public SVGTransformer setNodeValue(String xpathExpression, String value) { + try { + var nodes = (NodeList) xPath.evaluate(xpathExpression, document, XPathConstants.NODESET); + for (int i = 0; i < nodes.getLength(); i++) { + nodes.item(i).setNodeValue(value); + } + } catch (XPathExpressionException e) { + throw new SVGException(e.getMessage(), e); + } + + return this; + } + + public InputStream toInputStream() { + try { + var transformer = TransformerFactory.newInstance().newTransformer(); + + try (var bos = new ByteArrayOutputStream()) { + var result = new StreamResult(bos); + transformer.transform(new DOMSource(document), result); + + return new ByteArrayInputStream(bos.toByteArray()); + } + } catch (Exception e) { + throw new SVGException(e.getMessage(), e); + } + } + + @Override + public String toString() { + try { + var transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); + transformer.setOutputProperty(OutputKeys.METHOD, "xml"); + + var writer = new StringWriter(); + transformer.transform(new DOMSource(document), new StreamResult(writer)); + + return writer.getBuffer().toString(); + } catch (Exception e) { + throw new SVGException(e.getMessage(), e); + } + } +} diff --git a/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/jsvgfx.md b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/jsvgfx.md new file mode 100644 index 000000000..2d4db2b56 --- /dev/null +++ b/nodex/src/main/java/io/github/frostzie/nodex/svg_icon/jsvgfx.md @@ -0,0 +1,29 @@ +The classes inside package (`io.github.frostzie.nodex.svg_icon`) are from **jsvgfx** by **mkpaz** slightly modified by Frostzie
+ +### Original Source + - **Repository:** https://github.com/mkpaz/jsvgfx/ + - **License:** [MIT](https://github.com/mkpaz/jsvgfx/blob/master/LICENSE) + +### Original README.md: + +A simple project demonstrating loading SVG images with [jsvg](https://github.com/weisJ/jsvg) +and JavaFX [pluggable image loading](https://bugs.openjdk.org/browse/JDK-8306707) via javax.imageio. + +To run the demo: + +```sh +git clone https://github.com/mkpaz/jsvgfx +cd jsvgfx + +# if your system JVM version < 24 +JAVA_HOME=/path/to/jdk/24 + +./mvnw javafx:run +``` + +

+ +

+ + + diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/Nodex.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/Nodex.kt new file mode 100644 index 000000000..faec9f1e2 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/Nodex.kt @@ -0,0 +1,33 @@ +package io.github.frostzie.nodex + +import io.github.frostzie.nodex.ingame.DefaultCommands +import io.github.frostzie.nodex.config.ConfigManager +import io.github.frostzie.nodex.loader.fabric.Keybinds +import io.github.frostzie.nodex.modules.universal.UniversalWorldIntegration +import io.github.frostzie.nodex.screen.MainApplication +import io.github.frostzie.nodex.settings.SettingsLoader +import io.github.frostzie.nodex.utils.JavaFXInitializer +import io.github.frostzie.nodex.utils.LoggerProvider +import net.fabricmc.api.ModInitializer + +class Nodex : ModInitializer { + private val logger = LoggerProvider.getLogger("Nodex") + + override fun onInitialize() { + System.setProperty("javafx.allowSystemPropertiesAccess", "true") + System.setProperty("javafx.platform", "desktop") + + if (JavaFXInitializer.isJavaFXAvailable()) { + logger.info("Pre-initializing JavaFX platform...") + MainApplication.initializeJavaFX() + } else { + logger.warn("JavaFX is not available - GUI features will be disabled") + } + + ConfigManager.initialize() // Loads config file management and Layout system and Workspace data + SettingsLoader.initialize() // Loads settings and their builder and universal logic + DefaultCommands.registerCommands() // Loads commands + Keybinds.register() // Loads Minecraft (Fabric) keybinds + UniversalWorldIntegration.initialize() // Loads world detection for universal datapacks + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/config/ConfigManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/config/ConfigManager.kt new file mode 100644 index 000000000..816e29d67 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/config/ConfigManager.kt @@ -0,0 +1,113 @@ +package io.github.frostzie.nodex.config + +import com.google.gson.GsonBuilder +import io.github.frostzie.nodex.loader.fabric.Folders +import io.github.frostzie.nodex.project.WorkspaceManager +import io.github.frostzie.nodex.utils.LoggerProvider +import java.io.FileReader +import java.io.FileWriter +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.exists + +/** + * The Main configuration manager for DataPack IDE + * Handles initialization and management of all config subsystems + */ +object ConfigManager { + + private val logger = LoggerProvider.getLogger("ConfigManager") + private val gson = GsonBuilder().setPrettyPrinting().create() + + // The default configuration dir + private val defaultConfigDir: Path = Folders.configDir.resolve("nodex") + + // File that determines the actual config location + private val locationFile: Path = defaultConfigDir.resolve("config_location.json") + + // The active configuration directory + private var _configDir: Path? = null + val configDir: Path + get() { + if (_configDir == null) _configDir = resolveConfigDir() + return _configDir!! + } + + private data class LocationConfig(var customConfigPath: String? = null) + + private fun resolveConfigDir(): Path { + // Ensure the default directory exists so we can read/write the locationFile + if (!defaultConfigDir.exists()) { + defaultConfigDir.toFile().mkdirs() + } + + if (locationFile.exists()) { + try { + FileReader(locationFile.toFile()).use { reader -> + val config = gson.fromJson(reader, LocationConfig::class.java) + if (!config.customConfigPath.isNullOrBlank()) { + val customPath = Paths.get(config.customConfigPath!!) + logger.debug("Using custom config directory: {}", customPath) + return customPath + } + } + } catch (e: Exception) { + logger.error("Failed to read config location file, falling back to default.", e) + } + } + return defaultConfigDir + } + + fun initialize() { + // TODO: Enable migration when switching to "Nodex" + // migrateLegacyConfig() + + if (!configDir.toFile().exists()) { + configDir.toFile().mkdirs() + logger.debug("Created config directory: {}", configDir) + } + + LayoutManager.initialize() + WorkspaceManager.initialize() + } + + fun reload() { + _configDir = resolveConfigDir() + if (!configDir.toFile().exists()) { + configDir.toFile().mkdirs() + } + logger.debug("Config directory reloaded: {}", configDir) + } + + /** + * Sets a custom configuration path and saves it to the locationFile. + */ + fun setCustomConfigPath(path: Path?) { + try { + if (!defaultConfigDir.exists()) defaultConfigDir.toFile().mkdirs() + + val config = LocationConfig(path?.toAbsolutePath()?.toString()) + FileWriter(locationFile.toFile()).use { writer -> + gson.toJson(config, writer) + } + logger.debug("Updated config location. New path: {}", path ?: "Default") + } catch (e: Exception) { + logger.error("Failed to save config location file.", e) + } + } + + private fun migrateLegacyConfig() { + val legacyDir = Folders.configDir.resolve("datapack-ide") + val newDir = Folders.configDir.resolve("Nodex") + + if (legacyDir.exists() && !newDir.exists()) { + try { + Files.move(legacyDir, newDir) + logger.info("Migrated legacy config directory from 'datapack-ide' to 'Nodex'") + } catch (e: Exception) { + logger.error("Failed to migrate legacy config directory", e) + } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/config/LayoutManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/config/LayoutManager.kt new file mode 100644 index 000000000..fda4db24f --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/config/LayoutManager.kt @@ -0,0 +1,67 @@ +package io.github.frostzie.nodex.config + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import io.github.frostzie.nodex.utils.LoggerProvider +import io.github.frostzie.nodex.utils.UIConstants +import java.io.FileReader +import java.io.FileWriter +import java.nio.file.Files + +data class LayoutConfig( + // Main IDE Window + var x: Double = -1.0, + var y: Double = -1.0, + var width: Double = UIConstants.DEFAULT_WINDOW_WIDTH, + var height: Double = UIConstants.DEFAULT_WINDOW_HEIGHT, + + // Project Manager Screen + var projectManagerViewX: Double = -1.0, + var projectManagerViewY: Double = -1.0, + var projectManagerViewWidth: Double = 810.0, + var projectManagerViewHeight: Double = 812.0 +) + +object LayoutManager { + private val logger = LoggerProvider.getLogger("LayoutManager") + private val gson: Gson = GsonBuilder().setPrettyPrinting().create() + private val layoutFile get() = ConfigManager.configDir.resolve("layout.json") + + var config = LayoutConfig() + private set + + fun initialize() { + load() + } + + fun load() { + if (Files.exists(layoutFile)) { + try { + FileReader(layoutFile.toFile()).use { reader -> + val loadedConfig = gson.fromJson(reader, LayoutConfig::class.java) + if (loadedConfig != null) { + config = loadedConfig + } + logger.debug("Loaded layout from {}", layoutFile) + } + } catch (e: Exception) { + logger.error("Failed to load layout file, using defaults.", e) + config = LayoutConfig() + } + } else { + logger.info("Layout file not found, using defaults.") + config = LayoutConfig() + } + } + + fun save() { + try { + FileWriter(layoutFile.toFile()).use { writer -> + gson.toJson(config, writer) + logger.debug("Saved layout to {}", layoutFile) + } + } catch (e: Exception) { + logger.error("Failed to save layout file.", e) + } + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/events/BottomBarEvents.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/BottomBarEvents.kt new file mode 100644 index 000000000..d84ecc20b --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/BottomBarEvents.kt @@ -0,0 +1,5 @@ +package io.github.frostzie.nodex.events + +data class UpdateCursorLabel(val text: String) +data class UpdateEncodingLabel(val text: String) +data class UpdateIdeVersionLabel(val text: String) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/events/EditorEvents.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/EditorEvents.kt new file mode 100644 index 000000000..0140c4f42 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/EditorEvents.kt @@ -0,0 +1,15 @@ +package io.github.frostzie.nodex.events + +import java.nio.file.Path + +class EditorCopy +class EditorCut +class EditorPaste +class EditorUndo +class EditorRedo +class EditorSelectAll +class EditorCloseTab +class EditorFind + +data class EditorCursorPosition(val line: Int, val column: Int) +class ActiveTabChangedEvent(val path: Path?) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/events/EventBus.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/EventBus.kt new file mode 100644 index 000000000..15ef5f90c --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/EventBus.kt @@ -0,0 +1,81 @@ +package io.github.frostzie.nodex.events + +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import io.github.frostzie.nodex.utils.LoggerProvider +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.memberFunctions +import kotlin.reflect.jvm.isAccessible + +object EventBus { + val logger = LoggerProvider.getLogger("EventBus") + private val listeners = ConcurrentHashMap, CopyOnWriteArrayList<(Any) -> Unit>>() + private val handlerMap = ConcurrentHashMap, (Any) -> Unit>>>() + + fun register(handler: Any) { + if (handlerMap.containsKey(handler)) { + logger.warn("Handler ${handler::class.simpleName} is already registered") + return + } + + val methods = handler::class.memberFunctions.filter { + it.findAnnotation() != null + } + + val registered = mutableListOf, (Any) -> Unit>>() + + for (method in methods) { + val parameters = method.parameters + if (parameters.size != 2) continue + + val eventType = parameters[1].type.classifier as? KClass<*> ?: continue + val eventClass = eventType.java + + method.isAccessible = true + val fn: (Any) -> Unit = { event -> method.call(handler, event) } + + listeners.getOrPut(eventClass) { CopyOnWriteArrayList() }.add(fn) + registered += eventClass to fn + + logger.debug("Registered listener ${method.name} for event ${eventClass.simpleName}") + } + + handlerMap[handler] = registered + logger.debug("Registered handler ${handler::class.simpleName} with ${registered.size} subscribers") + } + + fun unregister(handler: Any) { + val registered = handlerMap.remove(handler) ?: return + for ((eventClass, fn) in registered) { + listeners[eventClass]?.remove(fn) + if (listeners[eventClass]?.isEmpty() == true) listeners.remove(eventClass) + } + logger.debug("Unregistered handler ${handler::class.simpleName}") + } + + fun post(event: Any) { + listeners[event::class.java]?.forEach { l -> + try { + l(event) + } catch (t: Throwable) { + logger.error("Error delivering ${event::class.simpleName} to a subscriber", t) + } + } + logger.debug("Posted Event: ${event::class.simpleName}") + } + + fun clear() { + listeners.clear() + handlerMap.clear() + logger.debug("Cleared all event listeners") + } + + /** + * Call when the component is no longer needed + */ + fun cleanup() { + clear() + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/events/FileEvents.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/FileEvents.kt new file mode 100644 index 000000000..2ea232953 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/FileEvents.kt @@ -0,0 +1,30 @@ +package io.github.frostzie.nodex.events + +import java.nio.file.Path + +class ChooseDirectory +data class DirectorySelected(val directoryPath: Path) + +class NewFile +data class MoveFile(val sourcePath: Path, val targetPath: Path) +data class RequestMoveConfirmation(val sourcePath: Path, val targetPath: Path) +data class RequestFileOverride(val sourcePath: Path, val targetPath: Path) + +class CopyFile +class CutFile +class PasteFile + +class CopyPath +class OpenWith + +class SaveFile +class SaveAsFile +class SaveAllFiles + +data class OpenFile(val path: Path) + + +data class FileMoved(val oldPath: Path, val newPath: Path) +data class FileModified(val path: Path) +data class FileDeleted(val path: Path) +data class FileOverrideConfirmed(val sourcePath: Path, val targetPath: Path) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/events/MenuControlEvents.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/MenuControlEvents.kt new file mode 100644 index 000000000..d861783fc --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/MenuControlEvents.kt @@ -0,0 +1,8 @@ +package io.github.frostzie.nodex.events + +class ToggleMenuControls +class GitHubLink +class DiscordLink +class ReportBugLink +class BuyMeACoffeeLink +class ModrinthLink \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ModEvents.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ModEvents.kt new file mode 100644 index 000000000..66a517b6e --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ModEvents.kt @@ -0,0 +1,4 @@ +package io.github.frostzie.nodex.events + +class Version +class VersionUpdate \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/events/PopupEvents.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/PopupEvents.kt new file mode 100644 index 000000000..bdb751362 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/PopupEvents.kt @@ -0,0 +1,27 @@ +package io.github.frostzie.nodex.events + +import io.github.frostzie.nodex.settings.data.CategoryItem +import io.github.frostzie.nodex.settings.data.ConfigField +import io.github.frostzie.nodex.settings.data.SearchResult +import kotlin.reflect.KClass + +class SettingsWindowOpen + +// Settings events +class SettingsSave +class CloseSettingsEvent +data class SettingsSearchQueryChanged(val query: String) +data class SettingsSearchResultsAvailable(val query: String, val results: List) +data class SettingsSearchResultSelected(val result: SearchResult) + +data class SettingsCategorySelected(val item: CategoryItem) +data class SelectTreeItem(val categoryIndex: Int, val subCategory: String?) + +class RequestSettingsCategories +data class CategoryData(val name: String, val configClass: KClass<*>, val subCategories: List) +data class SettingsCategoriesAvailable(val categories: List) + +data class SectionData(val name: String, val description: String?, val fields: List) +data class SettingsContentUpdate(val title: String, val sections: List, val filterFields: Set? = null) + +data class HighlightField(val field: ConfigField) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ResizeEvents.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ResizeEvents.kt new file mode 100644 index 000000000..8f69fd7f6 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ResizeEvents.kt @@ -0,0 +1,13 @@ +package io.github.frostzie.nodex.events + +/** + * Event for WindowControls + */ +//TODO: check usage of each event and if they are still needed +class MainWindowClose +class MainWindowMinimize +class MainWindowMaximize +class MainWindowToggleMaximize +class MainWindowRestore + +data class MainWindowMaximizedStateChanged(val isMaximized: Boolean) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ThemeEvents.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ThemeEvents.kt new file mode 100644 index 000000000..227c24196 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ThemeEvents.kt @@ -0,0 +1,10 @@ +package io.github.frostzie.nodex.events + +import java.nio.file.Path + +class ReloadThemeEvent +class ThemeChangeEvent(val themeName: String) +class ImportThemeEvent +class OpenThemeEvent +class ThemeEditingSessionClosedEvent +class SaveFileEvent(val path: Path) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ToggleFileTree.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ToggleFileTree.kt new file mode 100644 index 000000000..2c1f9f0c0 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/ToggleFileTree.kt @@ -0,0 +1,3 @@ +package io.github.frostzie.nodex.events + +class ToggleFileTree \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/events/WorkspaceEvents.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/WorkspaceEvents.kt new file mode 100644 index 000000000..a555ce20c --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/events/WorkspaceEvents.kt @@ -0,0 +1,23 @@ +package io.github.frostzie.nodex.events + +import io.github.frostzie.nodex.project.Workspace + +/** + * Fired when the workspace configuration changes (project added/removed). + */ +data class WorkspaceUpdated(val workspace: Workspace) + +/** + * Event fired to request a complete reset of the workspace and session history. + */ +class ResetWorkspaceEvent + +/** + * Event fired to open the project manager. + */ +class OpenProjectManagerEvent + +/** + * Event fired to open folder of the currently active workspace + */ +class OpenWorkspaceFolder \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/features/FeatureRegistry.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/FeatureRegistry.kt new file mode 100644 index 000000000..bded28dcd --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/FeatureRegistry.kt @@ -0,0 +1,46 @@ +package io.github.frostzie.nodex.features + +import io.github.frostzie.nodex.features.editor.CaretColor +import io.github.frostzie.nodex.features.editor.DirtyTabDecorator +import io.github.frostzie.nodex.features.editor.EditorTabDecorator +import io.github.frostzie.nodex.features.editor.FileIconDecorator +import io.github.frostzie.nodex.styling.tabs.TabStyleManager +import io.github.frostzie.nodex.styling.tabs.TabStyler +import io.github.frostzie.nodex.styling.tabs.rules.ConfigRule +import io.github.frostzie.nodex.styling.tabs.rules.DefaultRule +import io.github.frostzie.nodex.styling.tabs.rules.DirtyStateRule +import io.github.frostzie.nodex.styling.messages.MessageStyleManager +import io.github.frostzie.nodex.styling.messages.rules.CustomIconRule +import io.github.frostzie.nodex.styling.messages.rules.DefaultMessageRule +import io.github.frostzie.nodex.styling.messages.rules.SeverityMessageRule + +/** + * A central registry for discovering and accessing all feature services. + */ +object FeatureRegistry { + + /** + * A list of all services that can decorate an editor tab. + * To add a new tab feature, create a class that implements [EditorTabDecorator] + * and add an instance of it to this list. + */ + //TODO: split up + val editorTabDecorators: List = listOf( + TabStyler(), + DirtyTabDecorator(), + FileIconDecorator(), + CaretColor() + ) + + init { + //Register the style rules with the central TabStyleManager. + TabStyleManager.registerRule(DefaultRule()) + TabStyleManager.registerRule(DirtyStateRule()) + TabStyleManager.registerRule(ConfigRule()) + + // Register message style rules + MessageStyleManager.registerRule(DefaultMessageRule()) + MessageStyleManager.registerRule(SeverityMessageRule()) + MessageStyleManager.registerRule(CustomIconRule()) + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/features/dev/showcase/MessageShowcase.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/dev/showcase/MessageShowcase.kt new file mode 100644 index 000000000..357ed3cce --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/dev/showcase/MessageShowcase.kt @@ -0,0 +1,56 @@ +package io.github.frostzie.nodex.features.dev.showcase + +import io.github.frostzie.nodex.settings.categories.ExampleConfig +import io.github.frostzie.nodex.styling.common.IconSource +import io.github.frostzie.nodex.styling.messages.MessageFactory +import io.github.frostzie.nodex.styling.messages.MessageSeverity +import io.github.frostzie.nodex.styling.messages.NotificationPosition +import org.kordamp.ikonli.material2.Material2OutlinedMZ + +object MessageShowcase { + + fun showExampleMessage() { + val title = ExampleConfig.messageTitle.get() + val description = ExampleConfig.messageDescription.get() + + val severity = try { + MessageSeverity.valueOf(ExampleConfig.messageSeverity.get()) + } catch (e: Exception) { + MessageSeverity.REGULAR + } + + val position = try { + NotificationPosition.valueOf(ExampleConfig.messagePosition.get()) + } catch (e: Exception) { + NotificationPosition.BOTTOM_RIGHT + } + + val duration = ExampleConfig.messageDuration.get().toLong() + + val widthVal = ExampleConfig.messageWidth.get() + val width = if (widthVal > 0.0) widthVal else null + + val heightVal = ExampleConfig.messageHeight.get() + val height = if (heightVal > 0.0) heightVal else null + + val iconName = ExampleConfig.messageIcon.get() + val icon = try { + if (iconName.isNotBlank()) { + IconSource.IkonIcon(Material2OutlinedMZ.valueOf(iconName.trim().uppercase())) + } else null + } catch (e: Exception) { + null + } + + MessageFactory.createAndShow( + title = title, + description = description, + severity = severity, + position = position, + durationMillis = duration, + width = width, + height = height, + icon = icon + ) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/features/dev/showcase/NotificationShowcase.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/dev/showcase/NotificationShowcase.kt new file mode 100644 index 000000000..7773a4419 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/dev/showcase/NotificationShowcase.kt @@ -0,0 +1,69 @@ +package io.github.frostzie.nodex.features.dev.showcase + +import io.github.frostzie.nodex.settings.categories.ExampleConfig +import io.github.frostzie.nodex.styling.common.IconSource +import io.github.frostzie.nodex.styling.messages.MessageSeverity +import io.github.frostzie.nodex.styling.messages.NotificationPosition +import io.github.frostzie.nodex.styling.notifications.NotificationFactory +import javafx.scene.control.Button +import javafx.scene.control.MenuItem +import org.kordamp.ikonli.material2.Material2OutlinedMZ + +object NotificationShowcase { + + fun showExampleNotification() { + val text = ExampleConfig.notificationText.get() + + val severity = try { + MessageSeverity.valueOf(ExampleConfig.notificationSeverity.get()) + } catch (e: Exception) { + MessageSeverity.REGULAR + } + + val position = try { + NotificationPosition.valueOf(ExampleConfig.notificationPosition.get()) + } catch (e: Exception) { + NotificationPosition.BOTTOM_RIGHT + } + + val duration = ExampleConfig.notificationDuration.get().toLong() + + val widthVal = ExampleConfig.notificationWidth.get() + val width = if (widthVal > 0.0) widthVal else null + + val heightVal = ExampleConfig.notificationHeight.get() + val height = if (heightVal > 0.0) heightVal else null + + val iconName = ExampleConfig.notificationIcon.get() + val icon = try { + if (iconName.isNotBlank()) { + IconSource.IkonIcon(Material2OutlinedMZ.valueOf(iconName.trim().uppercase())) + } else null + } catch (e: Exception) { + null + } + + val msg = NotificationFactory.createAndShow( + text = text, + severity = severity, + position = position, + durationMillis = duration, + width = width, + height = height, + icon = icon + ) + + if (ExampleConfig.notificationShowActions.get()) { + val yesBtn = Button("Yes") + yesBtn.isDefaultButton = true + + val noBtn = Button("No") + + msg.setPrimaryActions(yesBtn, noBtn) + msg.setSecondaryActions( + MenuItem("Item 1"), + MenuItem("Item 2") + ) + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/CaretColor.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/CaretColor.kt new file mode 100644 index 000000000..393cf5cac --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/CaretColor.kt @@ -0,0 +1,55 @@ +package io.github.frostzie.nodex.features.editor + +import atlantafx.base.controls.Tab +import io.github.frostzie.nodex.modules.main.TextEditorViewModel +import io.github.frostzie.nodex.settings.categories.MainConfig +import javafx.beans.value.ChangeListener +import org.fxmisc.richtext.CodeArea + +/** + * An implementation of [EditorTabDecorator] that changes the caret color + * based on user settings. + */ +class CaretColor : EditorTabDecorator { + + override fun decorate(tab: Tab, codeArea: CodeArea, tabData: TextEditorViewModel.TabData): () -> Unit { + val configListener = ChangeListener { _, _, _ -> + // Only update if it has focus. + if (codeArea.isFocused) { + updateCaretColor(codeArea) + } + } + + val focusListener = ChangeListener { _, _, isFocused -> + if (isFocused) { + updateCaretColor(codeArea) + } + } + + MainConfig.enableCaretColor.addListener(configListener) + MainConfig.caretColor.addListener(configListener) + codeArea.focusedProperty().addListener(focusListener) + + // Set initial color if it's already focused (e.g. first tab) + if (codeArea.isFocused) { + updateCaretColor(codeArea) + } + + // Return cleanup function + return { + MainConfig.enableCaretColor.removeListener(configListener) + MainConfig.caretColor.removeListener(configListener) + codeArea.focusedProperty().removeListener(focusListener) + } + } + + private fun updateCaretColor(codeArea: CodeArea) { + val caret = codeArea.lookup(".caret") + if (MainConfig.enableCaretColor.get()) { + val color = MainConfig.caretColor.get() + caret?.style = "-fx-stroke: $color;" + } else { + caret?.style = "" + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/DirtyTabDecorator.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/DirtyTabDecorator.kt new file mode 100644 index 000000000..5b3e10aeb --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/DirtyTabDecorator.kt @@ -0,0 +1,47 @@ +package io.github.frostzie.nodex.features.editor + +import atlantafx.base.controls.Tab +import io.github.frostzie.nodex.modules.main.TextEditorViewModel +import io.github.frostzie.nodex.settings.categories.MainConfig +import javafx.beans.value.ChangeListener +import javafx.scene.control.Label +import javafx.scene.layout.HBox +import org.fxmisc.richtext.CodeArea + +/** + * An implementation of [EditorTabDecorator] that adds a "dirty" suffix + * to a tab when its file has unsaved changes. + */ +class DirtyTabDecorator : EditorTabDecorator { + + override fun decorate(tab: Tab, codeArea: CodeArea, tabData: TextEditorViewModel.TabData): () -> Unit { + // This listener reacts to both the dirty status and changes to the indicator setting itself. + val listener = ChangeListener { _, _, _ -> + updateTab(tab, tabData) + } + + tabData.isDirty.addListener(listener) + MainConfig.dirtyIndicator.addListener(listener) + + // Set the initial state correctly + updateTab(tab, tabData) + + // Cleanup when the tab is closed + return { + tabData.isDirty.removeListener(listener) + MainConfig.dirtyIndicator.removeListener(listener) + } + } + + private fun updateTab(tab: Tab, tabData: TextEditorViewModel.TabData) { + val graphic = tab.graphic as? HBox ?: return + val label = graphic.children.find { it is Label } as? Label ?: return + + val isDirty = tabData.isDirty.get() + label.text = if (isDirty) { + "${tabData.displayName}${MainConfig.dirtyIndicator.get()}" + } else { + tabData.displayName + } + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/EditorTabDecorator.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/EditorTabDecorator.kt new file mode 100644 index 000000000..4551c2840 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/EditorTabDecorator.kt @@ -0,0 +1,21 @@ +package io.github.frostzie.nodex.features.editor + +import atlantafx.base.controls.Tab +import io.github.frostzie.nodex.modules.main.TextEditorViewModel +import org.fxmisc.richtext.CodeArea + +/** + * Defines a contract for features that wish to decorate or add behavior to an editor tab. + */ +interface EditorTabDecorator { + /** + * Applies decorations to the given tab based on the tab's data. + * + * @param tab The UI Tab control from AtlantaFX. + * @param codeArea The CodeArea containing the text editor for this tab. + * @param tabData The view model's data for this tab. + * @return A cleanup function (lambda) that will be called when the tab is closed. + * This is for removing listeners and preventing memory leaks. + */ + fun decorate(tab: Tab, codeArea: CodeArea, tabData: TextEditorViewModel.TabData): () -> Unit +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/FileIconDecorator.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/FileIconDecorator.kt new file mode 100644 index 000000000..05be89356 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/features/editor/FileIconDecorator.kt @@ -0,0 +1,34 @@ +package io.github.frostzie.nodex.features.editor + +import atlantafx.base.controls.Tab +import io.github.frostzie.nodex.modules.main.TextEditorViewModel +import io.github.frostzie.nodex.settings.categories.MainConfig +import javafx.beans.value.ChangeListener +import javafx.scene.layout.HBox +import org.fxmisc.richtext.CodeArea +import org.kordamp.ikonli.javafx.FontIcon + +/** + * An implementation of [EditorTabDecorator] that toggles the visibility of the file + * icon in a tab based on the "Show File Icons" setting. + */ +class FileIconDecorator : EditorTabDecorator { + override fun decorate(tab: Tab, codeArea: CodeArea, tabData: TextEditorViewModel.TabData): () -> Unit { + val graphic = tab.graphic as? HBox ?: return { } + val icon = graphic.children.find { it is FontIcon } as? FontIcon ?: return { } + + val listener = ChangeListener { _, _, show -> + icon.isVisible = show + icon.isManaged = show + } + MainConfig.showFileIcons.addListener(listener) + + val initialVisibility = MainConfig.showFileIcons.get() + icon.isVisible = initialVisibility + icon.isManaged = initialVisibility + + return { + MainConfig.showFileIcons.removeListener(listener) + } + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/BottomBarHandler.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/BottomBarHandler.kt new file mode 100644 index 000000000..50ecdd78c --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/BottomBarHandler.kt @@ -0,0 +1,13 @@ +package io.github.frostzie.nodex.handlers.bars + +import io.github.frostzie.nodex.events.EditorCursorPosition +import io.github.frostzie.nodex.modules.bars.BottomBarModule +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent + +@Suppress("unused") +class BottomBarHandler(private val bottomBarModule: BottomBarModule) { + @SubscribeEvent + fun onCursorPositionChanged(event: EditorCursorPosition) { + bottomBarModule.updateCursorPosition(event.line, event.column) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/LeftBarHandler.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/LeftBarHandler.kt new file mode 100644 index 000000000..b71ca1039 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/LeftBarHandler.kt @@ -0,0 +1,8 @@ +package io.github.frostzie.nodex.handlers.bars + +import io.github.frostzie.nodex.modules.bars.LeftBarModule + +@Suppress("unused") +class LeftBarHandler(private val leftBarModule: LeftBarModule) { + +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/top/TopBarHandler.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/top/TopBarHandler.kt new file mode 100644 index 000000000..7857f436a --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/bars/top/TopBarHandler.kt @@ -0,0 +1,55 @@ +package io.github.frostzie.nodex.handlers.bars.top + +import io.github.frostzie.nodex.events.* +import io.github.frostzie.nodex.modules.bars.top.TopBarViewModel +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import io.github.frostzie.nodex.utils.OpenLinks + +@Suppress("unused") +class TopBarHandler(private val topBarViewModel: TopBarViewModel) { + + @SubscribeEvent + fun onMinimize(event: MainWindowMinimize) { + topBarViewModel.minimize() + } + + @SubscribeEvent + fun onMaximize(event: MainWindowMaximize) { + topBarViewModel.maximize() + } + + @SubscribeEvent + fun onToggleMaximize(event: MainWindowToggleMaximize) { + topBarViewModel.toggleMaximize() + } + + @SubscribeEvent + fun onRestoreBack(event: MainWindowRestore) { + topBarViewModel.restore() + } + + @SubscribeEvent + fun onClose(event: MainWindowClose) { + topBarViewModel.hideWindow() + } + + @SubscribeEvent + fun openDatapackFolder(event: OpenWorkspaceFolder) { + topBarViewModel.openWorkspaceFolder() + } + + @SubscribeEvent + fun onDiscordLink(event: DiscordLink) { + OpenLinks.discordLink() + } + + @SubscribeEvent + fun onGitHubLink(event: GitHubLink) { + OpenLinks.gitHubLink() + } + + @SubscribeEvent + fun onReportBugLink(event: ReportBugLink) { + OpenLinks.reportBugLink() + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/file/FilePopupHandler.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/file/FilePopupHandler.kt new file mode 100644 index 000000000..bc60fd74a --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/file/FilePopupHandler.kt @@ -0,0 +1,18 @@ +package io.github.frostzie.nodex.handlers.popup.file + +import io.github.frostzie.nodex.events.RequestFileOverride +import io.github.frostzie.nodex.events.RequestMoveConfirmation +import io.github.frostzie.nodex.modules.popup.file.FilePopupModule +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent + +class FilePopupHandler(private val filePopupModule: FilePopupModule) { + @SubscribeEvent + fun onMoveFileRequested(event: RequestMoveConfirmation) { + filePopupModule.showMoveConfirmation(event.sourcePath, event.targetPath) + } + + @SubscribeEvent + fun onFileOverrideRequested(event: RequestFileOverride) { + filePopupModule.showFileOverrideDialog(event.sourcePath, event.targetPath) + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/settings/SettingsHandler.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/settings/SettingsHandler.kt new file mode 100644 index 000000000..a697b4cd0 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/settings/SettingsHandler.kt @@ -0,0 +1,49 @@ +package io.github.frostzie.nodex.handlers.popup.settings + +import io.github.frostzie.nodex.events.* +import io.github.frostzie.nodex.modules.popup.settings.SettingsModule +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent + +@Suppress("unused") +class SettingsHandler(private val settingsModule: SettingsModule) { + + @SubscribeEvent + fun onShowSettingsWindow(event: SettingsWindowOpen) { + settingsModule.showSettingsWindow() + } + + @SubscribeEvent + fun onSettingsSearchQueryChanged(event: SettingsSearchQueryChanged) { + settingsModule.search(event.query) + } + + @SubscribeEvent + fun onSettingsCategorySelected(event: SettingsCategorySelected) { + settingsModule.selectCategory(event.item) + } + + @SubscribeEvent + fun onSettingsSearchResultSelected(event: SettingsSearchResultSelected) { + settingsModule.selectSearchResult(event.result) + } + + @SubscribeEvent + fun onSettingsSave(event: SettingsSave) { + settingsModule.saveSettings() + } + + @SubscribeEvent + fun onRequestSettingsCategories(event: RequestSettingsCategories) { + settingsModule.loadAndSendCategories() + } + + @SubscribeEvent + fun onOpenProjectManager(event: OpenProjectManagerEvent) { + settingsModule.close() + } + + @SubscribeEvent + fun onResetWorkspace(event: ResetWorkspaceEvent) { + settingsModule.close() + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/settings/ThemeHandler.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/settings/ThemeHandler.kt new file mode 100644 index 000000000..b1894815f --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/handlers/popup/settings/ThemeHandler.kt @@ -0,0 +1,33 @@ +package io.github.frostzie.nodex.handlers.popup.settings + +import io.github.frostzie.nodex.events.* +import io.github.frostzie.nodex.modules.popup.settings.ThemeModule +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent + +class ThemeHandler(private val themeModule: ThemeModule) { + + @SubscribeEvent + fun onThemeChange(event: ThemeChangeEvent) { + themeModule.changeTheme(event.themeName) + } + + @SubscribeEvent + fun onReloadTheme(event: ReloadThemeEvent) { + themeModule.reloadStyles() + } + + @SubscribeEvent + fun onOpenTheme(event: OpenThemeEvent) { + themeModule.openTheme(event) + } + + @SubscribeEvent + fun onThemeEditingSessionClosed(event: ThemeEditingSessionClosedEvent) { + themeModule.closeEditingSession() + } + + @SubscribeEvent + fun onActiveTabChanged(event: ActiveTabChangedEvent) { + themeModule.onActiveTabChanged(event.path) + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/ingame/DefaultCommands.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/ingame/DefaultCommands.kt new file mode 100644 index 000000000..be4b6cdd3 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/ingame/DefaultCommands.kt @@ -0,0 +1,37 @@ +package io.github.frostzie.nodex.ingame + +import io.github.frostzie.nodex.loader.fabric.CommandRegistration +import io.github.frostzie.nodex.loader.minecraft.MCInterface +import io.github.frostzie.nodex.modules.universal.UniversalPackManager +import io.github.frostzie.nodex.project.WorkspaceManager +import io.github.frostzie.nodex.screen.MainApplication +import io.github.frostzie.nodex.settings.categories.MainConfig + +object DefaultCommands { + fun registerCommands() { + CommandRegistration.register("nodex") { + executes { + MainApplication.showMainWindow() + 1 + } + + // Mirrors current project to world. //TODO: Support any universal project mirroring + literal("internal") { + literal("mirror_current") { + executes { + if (!MainConfig.universalFolderToggle.get() || !MainConfig.universalDatapackToggle.get()) return@executes 0 + + val root = WorkspaceManager.currentWorkspaceRoot + if (root != null && UniversalPackManager.isUniversalProject(root)) { + if (UniversalPackManager.mirrorToWorld(root)) { + // Execute reload to apply changes + MCInterface.sendCommand("reload") + } + } + 1 + } + } + } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/ingame/ReloadDataPacksCommand.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/ingame/ReloadDataPacksCommand.kt new file mode 100644 index 000000000..8affae82b --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/ingame/ReloadDataPacksCommand.kt @@ -0,0 +1,24 @@ +package io.github.frostzie.nodex.ingame + +import io.github.frostzie.nodex.loader.minecraft.MCInterface +import io.github.frostzie.nodex.utils.LoggerProvider + +object ReloadDataPacksCommand { + + private val logger = LoggerProvider.getLogger("ReloadDataPacksCommand") + + /** + * Executes the `/reload` command in Minecraft. + */ + fun reload() { + MCInterface.sendCommand("reload") + logger.debug("Sent /reload with button press") + } + + /** + * Placeholder for executing the `/reload` command via a hotkey. + */ + fun executeCommandHotKey() { + //TODO: Add a hotkey to execute the command + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/CommandDSL.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/CommandDSL.kt new file mode 100644 index 000000000..539788541 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/CommandDSL.kt @@ -0,0 +1,44 @@ +package io.github.frostzie.nodex.loader.fabric + +import io.github.frostzie.nodex.loader.minecraft.CaseInsensitiveLiteralCommandNode +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource + +/** + * A DSL wrapper around Brigadier's LiteralArgumentBuilder to simplify command registration + * and enforce case-insensitive matching by default. + */ +class CommandBuilder(internal val builder: CaseInsensitiveLiteralCommandNode.Builder) { + + /** + * Adds a child literal command node. + */ + fun literal(name: String, block: CommandBuilder.() -> Unit) { + val childBuilder = CommandUtils.caseInsensitiveLiteral(name) + val childWrapper = CommandBuilder(childBuilder) + childWrapper.block() + builder.then(childWrapper.builder) + } + + /** + * Sets the execution logic for this command node. + * The action should return 1 for success, 0 for failure (standard Brigadier convention). + */ + fun executes(action: () -> Int) { + builder.executes { _ -> action() } + } +} + +object CommandRegistration { + /** + * Registers a new root command with the given name. + */ + fun register(name: String, block: CommandBuilder.() -> Unit) { + ClientCommandRegistrationCallback.EVENT.register { dispatcher, _ -> + val rootBuilder = CommandUtils.caseInsensitiveLiteral(name) + val wrapper = CommandBuilder(rootBuilder) + wrapper.block() + dispatcher.register(rootBuilder) + } + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/CommandUtils.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/CommandUtils.kt new file mode 100644 index 000000000..ec0fc4a35 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/CommandUtils.kt @@ -0,0 +1,34 @@ +package io.github.frostzie.nodex.loader.fabric + +import com.mojang.brigadier.builder.LiteralArgumentBuilder +import io.github.frostzie.nodex.loader.minecraft.CaseInsensitiveLiteralCommandNode +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource + +object CommandUtils { + + fun caseInsensitiveLiteral(name: String): CaseInsensitiveLiteralCommandNode.Builder { + return CaseInsensitiveLiteralCommandNode.Builder(name) + } + + fun LiteralArgumentBuilder.caseInsensitive(): CaseInsensitiveLiteralCommandNode.Builder { + val builder = CaseInsensitiveLiteralCommandNode.Builder(this.literal) + + if (this.command != null) { + builder.executes(this.command) + } + + if (this.requirement != null) { + builder.requires(this.requirement) + } + + if (this.redirect != null) { + builder.forward(this.redirect, this.redirectModifier, this.isFork) + } + + for (argument in this.arguments) { + builder.then(argument) + } + + return builder + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/Folders.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/Folders.kt new file mode 100644 index 000000000..7dc627dd0 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/Folders.kt @@ -0,0 +1,12 @@ +package io.github.frostzie.nodex.loader.fabric + +import net.fabricmc.loader.api.FabricLoader +import java.nio.file.Path + +/** + * Simple wrapper to not need to depend on fabric + */ +object Folders { + val configDir: Path + get() = FabricLoader.getInstance().configDir +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/Keybinds.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/Keybinds.kt new file mode 100644 index 000000000..dc54da185 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/Keybinds.kt @@ -0,0 +1,48 @@ +package io.github.frostzie.nodex.loader.fabric + +import com.mojang.blaze3d.platform.InputConstants +import io.github.frostzie.nodex.screen.MainApplication +import io.github.frostzie.nodex.utils.JavaFXInitializer +import io.github.frostzie.nodex.utils.LoggerProvider +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper +import net.minecraft.client.KeyMapping +import net.minecraft.resources.ResourceLocation +import org.lwjgl.glfw.GLFW + +//TODO: Wrap keybind building and move to ingame dir +object Keybinds { + private val logger = LoggerProvider.getLogger("Keybinds") + private var toggleIDEKey: KeyMapping? = null + + fun register() { + //? if <=1.21.8 { + /*toggleIDEKey = KeyBindingHelper.registerKeyBinding(KeyMapping( + "key.nodex.toggle_ide", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_H, + "category.nodex.general" + )) + *///?} else { + toggleIDEKey = KeyBindingHelper.registerKeyBinding( + KeyMapping( + "key.nodex.toggle_ide", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_H, + KeyMapping.Category.register(ResourceLocation.fromNamespaceAndPath("nodex", "general")) + )) + //?} + + ClientTickEvents.END_CLIENT_TICK.register { client -> + while (toggleIDEKey?.consumeClick() == true) { + logger.info("IDE toggle keybind pressed!") + + if (JavaFXInitializer.isJavaFXAvailable()) { + MainApplication.Companion.showMainWindow() + } else { + logger.error("Cannot open IDE window - JavaFX is not available") + } + } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/ModVersion.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/ModVersion.kt new file mode 100644 index 000000000..a994cabcf --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/ModVersion.kt @@ -0,0 +1,12 @@ +package io.github.frostzie.nodex.loader.fabric + +import net.fabricmc.loader.api.FabricLoader + +object ModVersion { + val current: String by lazy { + FabricLoader.getInstance() + .getModContainer("nodex") + .map { it.metadata.version.friendlyString } + .orElse("unknown") + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/WorldDetection.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/WorldDetection.kt new file mode 100644 index 000000000..52a6f7682 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/fabric/WorldDetection.kt @@ -0,0 +1,44 @@ +package io.github.frostzie.nodex.loader.fabric + +import io.github.frostzie.nodex.loader.minecraft.MCInterface +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents +import java.nio.file.Path + +object WorldDetection { + /** + * Check if player connected to a world + */ + fun isWorldOpen(): Boolean { + return MCInterface.isWorldOpen + } + + /** + * Check if player connected to singleplayer + */ + fun isSingleplayer(): Boolean { + return MCInterface.isSingleplayer + } + + /** + * Check if player connected to server + */ + fun isServer(): Boolean { + return MCInterface.isServer + } + + /** + * Get world path + */ + fun getWorldPath(): Path? { + return MCInterface.getWorldPath() + } + + /** + * Register a listener for world join events + */ + fun registerWorldJoinListener(listener: () -> Unit) { + ClientPlayConnectionEvents.JOIN.register { _, _, _ -> + listener() + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/CaseInsensitiveLiteralCommandNode.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/CaseInsensitiveLiteralCommandNode.kt new file mode 100644 index 000000000..ff70d8ea1 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/CaseInsensitiveLiteralCommandNode.kt @@ -0,0 +1,74 @@ +package io.github.frostzie.nodex.loader.minecraft + +import com.mojang.brigadier.Command +import com.mojang.brigadier.RedirectModifier +import com.mojang.brigadier.StringReader +import com.mojang.brigadier.builder.LiteralArgumentBuilder +import com.mojang.brigadier.context.CommandContextBuilder +import com.mojang.brigadier.context.StringRange +import com.mojang.brigadier.exceptions.CommandSyntaxException +import com.mojang.brigadier.tree.CommandNode +import com.mojang.brigadier.tree.LiteralCommandNode +import java.util.function.Predicate + +// Taken and modified from Firmament +class CaseInsensitiveLiteralCommandNode( + literal: String, command: Command?, requirement: Predicate?, + redirect: CommandNode?, modifier: RedirectModifier?, forks: Boolean +) : LiteralCommandNode( + literal.lowercase(), command, requirement, redirect, modifier, forks +) { + + class Builder(literal: String) : LiteralArgumentBuilder(literal) { + override fun build(): LiteralCommandNode { + val result = CaseInsensitiveLiteralCommandNode( + literal, + command, requirement, redirect, redirectModifier, isFork + ) + for (argument in arguments) { + result.addChild(argument) + } + return result + } + } + + override fun createBuilder(): LiteralArgumentBuilder { + return Builder(literal).also { + it.requires(requirement) + it.forward(redirect, redirectModifier, isFork) + if (command != null) + it.executes(command) + } + } + + override fun parse(reader: StringReader, contextBuilder: CommandContextBuilder) { + val start = reader.cursor + val end = parse0(reader) + if (end > -1) { + contextBuilder.withNode(this, StringRange.between(start, end)) + return + } + + throw CommandSyntaxException.BUILT_IN_EXCEPTIONS.literalIncorrect().createWithContext(reader, literal) + } + + override fun toString(): String { + return "" + } + + private fun parse0(reader: StringReader): Int { + val start = reader.cursor + if (reader.canRead(literal.length)) { + val end = start + literal.length + if (reader.string.substring(start, end).equals(literal, true)) { + reader.cursor = end + if (!reader.canRead() || reader.peek() == ' ') { + return end + } else { + reader.cursor = start + } + } + } + return -1 + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/ChatMessageBuilder.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/ChatMessageBuilder.kt new file mode 100644 index 000000000..e58fc441c --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/ChatMessageBuilder.kt @@ -0,0 +1,86 @@ +package io.github.frostzie.nodex.loader.minecraft + +import net.minecraft.network.chat.ClickEvent +import net.minecraft.network.chat.HoverEvent +import net.minecraft.network.chat.Style +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.MutableComponent +import net.minecraft.network.chat.TextColor +import java.net.URI + +object ChatMessageBuilder { + fun warning(warningMessage: String, copyableText: String = warningMessage) { + val fullMessage = buildMessage(warningMessage, copyableText, "WARN", 0xFFA500, "warning") + + MCInterface.runOnRenderThread { + MCInterface.displayClientMessage(fullMessage, false) + } + } + + fun error(errorMessage: String, copyableText: String = errorMessage) { + val fullMessage = buildMessage(errorMessage, copyableText, "ERROR", 0xFF5555, "error") + + MCInterface.runOnRenderThread { + MCInterface.displayClientMessage(fullMessage, false) + } + } + + //TODO: Allow loading not just current project + fun promptUniversalLoad(projectName: String) { + val command = "/nodex internal mirror_current" + + val clickable = Component.literal(" [Load into World]") + .setStyle(Style.EMPTY + .withColor(TextColor.fromRgb(0x55FF55)) // Green + .withBold(true) + .withClickEvent(ClickEvent.RunCommand(command)) + .withHoverEvent(HoverEvent.ShowText(Component.literal("§eClick to mirror '$projectName' to this world"))) + ) + + val fullMessage = buildPrefix() + .append(Component.literal("Detected Universal Pack: '§b$projectName§r'.")) + .append(clickable) + + MCInterface.runOnRenderThread { + MCInterface.displayClientMessage(fullMessage, false) + } + } + + fun testWarning() { + warning("This is a test warning message.", "Test Warning Copy Text") + } + + fun testError() { + error("This is a test error message.", "Test Error Copy Text") + } + + private fun buildMessage(message: String, copyableText: String, level: String, color: Int, type: String): Component { + val styledMessage = Component.literal(message) + .setStyle( + Style.EMPTY.withColor(TextColor.fromRgb(color)) + .withClickEvent(ClickEvent.CopyToClipboard(copyableText)) + .withHoverEvent(HoverEvent.ShowText(Component.literal("§eClick to copy $type message"))) + ) + val builder = buildPrefix() + .append(Component.literal("[$level]§r ").setStyle(Style.EMPTY.withColor(TextColor.fromRgb(color)))) + .append(styledMessage) + + if (level == "ERROR") { + builder.append( + Component.literal("\nPlease report this in the discord server!") + .setStyle( + Style.EMPTY.withColor(TextColor.fromRgb(0xAAAAAA)) + .withClickEvent(ClickEvent.OpenUrl(URI("https://discord.gg/qZ885qTvkx"))) + .withHoverEvent(HoverEvent.ShowText(Component.literal("§eClick to open the Discord server invite"))) + ) + ) + } + return builder + } + + private fun buildPrefix(): MutableComponent { + return Component.empty() + .append(MCColorUtils.nodexPrefixChat()) + .append(Component.literal("§r ")) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/MCColorUtils.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/MCColorUtils.kt new file mode 100644 index 000000000..d52d52bd1 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/MCColorUtils.kt @@ -0,0 +1,34 @@ +package io.github.frostzie.nodex.loader.minecraft + +import net.minecraft.network.chat.Component +import net.minecraft.network.chat.MutableComponent +import net.minecraft.network.chat.Style +import net.minecraft.network.chat.TextColor + +object MCColorUtils { + + /** + * Prefix for chat messages sent by the mod. + * Looks like: "[Nodex] message + */ + fun nodexPrefixChat(): MutableComponent { + val text = "Nodex" + val colors = listOf( + 0x14EE72, // N + 0x1AE188, // o + 0x1FD49E, // d + 0x25C6B4, // e + 0x2AB9CA, // x + ) + val result = Component.empty() + result.append(Component.literal("§7[")) + for (i in text.indices) { + result.append( + Component.literal(text[i].toString()) + .setStyle(Style.EMPTY.withColor(TextColor.fromRgb(colors[i]))) + ) + } + result.append(Component.literal("§7]")) + return result + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/MCInterface.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/MCInterface.kt new file mode 100644 index 000000000..353bba1af --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/loader/minecraft/MCInterface.kt @@ -0,0 +1,52 @@ +package io.github.frostzie.nodex.loader.minecraft + +import net.minecraft.client.Minecraft +import net.minecraft.network.chat.Component +import net.minecraft.world.level.storage.LevelResource +import java.nio.file.Path + +/** + * Bridge to allow the main project to interact with Minecraft + * without direct dependency on its classes. + */ +object MCInterface { + /** + * Sends a command as the current player. + */ + fun sendCommand(command: String) { + Minecraft.getInstance().player?.connection?.sendCommand(command) + } + + /** + * Executes a task on the main Minecraft thread. + */ + fun runOnRenderThread(action: () -> Unit) { + Minecraft.getInstance()?.execute(action) + } + + /** + * Displays a client-side chat message to the player. + */ + fun displayClientMessage(message: Component, actionBar: Boolean = false) { + Minecraft.getInstance().player?.displayClientMessage(message, actionBar) + } + + // World State Checks + val isWorldOpen: Boolean get() = Minecraft.getInstance().level != null + val isSingleplayer: Boolean get() = Minecraft.getInstance().singleplayerServer != null + val isServer: Boolean get() = Minecraft.getInstance().currentServer != null + + /** + * Gets the path to the current world (if in singleplayer). + */ + fun getWorldPath(): Path? { + return Minecraft.getInstance().singleplayerServer?.getWorldPath(LevelResource.ROOT) + } + + /** + * Gets the game directory path. + */ + fun getGamePath(): Path? { + return Minecraft.getInstance().gameDirectory?.toPath() + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/BottomBarModule.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/BottomBarModule.kt new file mode 100644 index 000000000..530c82090 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/BottomBarModule.kt @@ -0,0 +1,19 @@ +package io.github.frostzie.nodex.modules.bars + +import io.github.frostzie.nodex.loader.fabric.ModVersion +import javafx.beans.property.SimpleStringProperty + +object BottomBarModule { + + val cursorPositionProperty = SimpleStringProperty("") + val encodingProperty = SimpleStringProperty("UTF-8") + val ideVersionProperty = SimpleStringProperty("DataPack IDE v${ModVersion.current}") + + fun updateCursorPosition(line: Int, column: Int) { + cursorPositionProperty.set("Ln $line, Col $column") + } + + fun updateEncoding(encoding: String) { + encodingProperty.set(encoding) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/LeftBarModule.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/LeftBarModule.kt new file mode 100644 index 000000000..6a15a72fc --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/LeftBarModule.kt @@ -0,0 +1,32 @@ +package io.github.frostzie.nodex.modules.bars + +import io.github.frostzie.nodex.events.DirectorySelected +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.utils.LoggerProvider +import io.github.frostzie.nodex.utils.file.DirectoryChooseUtils +import javafx.stage.DirectoryChooser +import javafx.stage.Stage + +class LeftBarModule(private val parentStage: Stage) { + private val logger = LoggerProvider.getLogger("LeftBarModule") + + fun openDirectoryChooser() { + val directoryChooser = DirectoryChooser().apply { + try { + val datapackPath = DirectoryChooseUtils.getDatapackPath() + if (DirectoryChooseUtils.isSingleplayer() && datapackPath != null) { + initialDirectory = datapackPath.toFile() + } else { + initialDirectory = DirectoryChooseUtils.getInstancePath()?.toFile() + } + } catch (e: Exception) { + logger.warn("Could not set initial directory", e) + } + } + + val selectedDirectory = directoryChooser.showDialog(parentStage) + if (selectedDirectory != null) { + EventBus.post(DirectorySelected(selectedDirectory.toPath())) + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/top/TopBarViewModel.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/top/TopBarViewModel.kt new file mode 100644 index 000000000..e74bfc81b --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/bars/top/TopBarViewModel.kt @@ -0,0 +1,86 @@ +package io.github.frostzie.nodex.modules.bars.top + +import io.github.frostzie.nodex.ingame.ReloadDataPacksCommand +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.MainWindowMaximizedStateChanged +import io.github.frostzie.nodex.modules.universal.UniversalPackManager +import io.github.frostzie.nodex.project.WorkspaceManager +import io.github.frostzie.nodex.screen.MainApplication +import io.github.frostzie.nodex.settings.categories.MainConfig +import javafx.geometry.Rectangle2D +import javafx.stage.Screen +import javafx.stage.Stage +import net.minecraft.Util +import java.nio.file.Path + +class TopBarViewModel(private val stage: Stage?) { + + private var previousBounds: Rectangle2D? = null + var isMaximized: Boolean = false + + fun minimize() { + stage?.isIconified = true + } + + fun maximize() { + stage?.let { + val screen = Screen.getScreensForRectangle(it.x, it.y, it.width, it.height).firstOrNull() ?: Screen.getPrimary() + val visualBounds = screen.visualBounds + + previousBounds = Rectangle2D(it.x, it.y, it.width, it.height) + it.x = visualBounds.minX + it.y = visualBounds.minY + it.width = visualBounds.width + it.height = visualBounds.height + + isMaximized = true + EventBus.post(MainWindowMaximizedStateChanged(true)) + } + } + + fun restore() { + previousBounds?.let { bounds -> + stage?.let { stg -> + stg.x = bounds.minX + stg.y = bounds.minY + stg.width = bounds.width + stg.height = bounds.height + + isMaximized = false + EventBus.post(MainWindowMaximizedStateChanged(false)) + } + } + } + + fun toggleMaximize() { + if (isMaximized) { + restore() + } else { + maximize() + } + } + + fun hideWindow() { + MainApplication.hideMainWindow() + } + + fun reloadDatapacks() { + val root = WorkspaceManager.currentWorkspaceRoot + if (root != null && MainConfig.universalFolderToggle.get() && MainConfig.universalDatapackToggle.get()) { + if (UniversalPackManager.isUniversalProject(root)) { + UniversalPackManager.mirrorToWorld(root) + } + } + ReloadDataPacksCommand.reload() + } + + fun openWorkspaceFolder() { + WorkspaceManager.currentWorkspaceRoot?.let { + Util.getPlatform().openFile(it.toFile()) + } + } + + fun importProject(path: Path) { + WorkspaceManager.addProject(path) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/main/FileTreeViewModel.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/main/FileTreeViewModel.kt new file mode 100644 index 000000000..0551561e4 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/main/FileTreeViewModel.kt @@ -0,0 +1,353 @@ +package io.github.frostzie.nodex.modules.main + +import io.github.frostzie.nodex.events.DirectorySelected +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.FileMoved +import io.github.frostzie.nodex.events.MoveFile +import io.github.frostzie.nodex.events.ToggleFileTree +import io.github.frostzie.nodex.events.WorkspaceUpdated +import io.github.frostzie.nodex.project.Project +import io.github.frostzie.nodex.project.WorkspaceManager +import io.github.frostzie.nodex.screen.elements.main.FileTreeItem +import io.github.frostzie.nodex.services.FileService +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import io.github.frostzie.nodex.utils.LoggerProvider +import io.github.frostzie.nodex.utils.file.FileSystemUpdate +import javafx.application.Platform +import javafx.beans.property.SimpleBooleanProperty +import java.nio.file.Files +import javafx.beans.property.SimpleObjectProperty +import javafx.scene.control.TreeItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.isDirectory + +class FileTreeViewModel { + private val logger = LoggerProvider.getLogger("FileTreeViewModel") + + // The invisible root of the TreeView. Its children are the Project roots. + val root = SimpleObjectProperty>() + val isVisible = SimpleBooleanProperty(true) + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + // Track expanded paths for persistence + private val expandedPaths = mutableSetOf() + private var isRestoringExpansion = false + + init { + val dummyRoot = TreeItem(FileTreeItem(Paths.get("Workspace"), "Workspace")) + dummyRoot.isExpanded = true + root.set(dummyRoot) + + EventBus.register(this) + + // Initial load if workspace is already ready + updateWorkspace(WorkspaceManager.workspace.projects) + } + + fun cleanup() { + scope.cancel() + EventBus.unregister(this) + } + + @Suppress("unused") + @SubscribeEvent + fun onToggleFileTree(event: ToggleFileTree) { + isVisible.set(!isVisible.get()) + } + + /** + * Handles the "Open Folder" action. + */ + @Suppress("unused") + @SubscribeEvent + fun onDirectorySelected(event: DirectorySelected) { + logger.info("Opening single directory as project: ${event.directoryPath}") + WorkspaceManager.openSingleProject(event.directoryPath) + } + + @Suppress("unused") + @SubscribeEvent + fun onWorkspaceUpdated(event: WorkspaceUpdated) { + updateWorkspace(event.workspace.projects) + } + + private fun updateWorkspace(projects: List) { + // Run on background thread to avoid blocking UI with IO + scope.launch { + // Load saved expansion state + val state = WorkspaceManager.getCurrentState() + isRestoringExpansion = true + expandedPaths.clear() + expandedPaths.addAll(state.expandedPaths) + + // 2. Update the UI Tree + Platform.runLater { + val rootNode = root.get() + + // Remove nodes that are no longer in the project list + rootNode.children.removeIf { item -> + val path = item.value?.path + path != null && projects.none { it.path == path } + } + + // Add or Update nodes + projects.forEach { project -> + val existingNode = rootNode.children.find { it.value?.path == project.path } + if (existingNode == null) { + // Create new project node + val projectNode = TreeItem(FileTreeItem(project.path, project.name)) + projectNode.isExpanded = true // Auto-expand project roots + + // Load children asynchronously + scope.launch { + val children = loadChildren(project.path) + Platform.runLater { + projectNode.children.setAll(children) + restoreExpandedPaths(projectNode) // Try to restore + } + } + rootNode.children.add(projectNode) + + // Add listener to project root itself + addExpansionListener(projectNode, project.path) + } + } + isRestoringExpansion = false + } + } + } + + private fun restoreExpandedPaths(node: TreeItem) { + if (node.value == null) return + + // Check children + node.children.forEach { child -> + val childPath = child.value?.path + if (childPath != null && childPath in expandedPaths) { + child.isExpanded = true + restoreExpandedPaths(child) + } + } + } + + @Suppress("unused") + @SubscribeEvent + fun onFileMoved(event: MoveFile) { + logger.debug("Moving file from {}\nto {}", event.sourcePath, event.targetPath) + try { + WorkspaceManager.ignoreWatcherPath(event.sourcePath) + WorkspaceManager.ignoreWatcherPath(event.targetPath) + + FileService.move(event.sourcePath, event.targetPath) + + EventBus.post(FileMoved(event.sourcePath, event.targetPath)) + + refreshNode(event.sourcePath.parent) + refreshNode(event.targetPath.parent) + + } catch (e: Exception) { + logger.error("Failed to move file: ${event.sourcePath}", e) + } + } + + @SubscribeEvent + fun onFileSystemUpdate(event: FileSystemUpdate) { + refreshNode(event.path) + } + + private fun refreshNode(path: Path?) { + if (path == null) return + + // If the path to refresh doesn't exist (it was just renamed/deleted) + // refresh its parent to update the tree structure. + if (!FileService.isDirectory(path) && !Files.exists(path)) { + refreshNode(path.parent) + return + } + + Platform.runLater { + var currentPath = path + var targetNode: TreeItem? = null + + // Find the closest existing ancestor node + while (currentPath != null && targetNode == null) { + targetNode = findNode(root.get(), currentPath) + if (targetNode == null) { + currentPath = currentPath.parent + } + } + + if (targetNode != null && targetNode.isExpanded) { + val nodeToRefresh = targetNode + val pathToRefresh = nodeToRefresh.value.path + + scope.launch { + val children = loadChildren(pathToRefresh) + Platform.runLater { + nodeToRefresh.children.setAll(children) + restoreExpandedPaths(nodeToRefresh) + } + } + } + } + } + + private fun findNode(root: TreeItem, path: Path): TreeItem? { + if (root.value?.path == path) return root + + // Optimization: Check if path belongs to this branch + if (root.value?.displayName == "Workspace") { + for (child in root.children) { + if (path.startsWith(child.value.path)) { + return findNode(child, path) + } + } + return null + } + + for (child in root.children) { + val childPath = child.value?.path ?: continue + if (path == childPath) return child + if (path.startsWith(childPath)) { + return findNode(child, path) + } + } + return null + } + + /** + * Loads the children for a given directory, sorting them and compacting empty parent directories. + */ + private fun loadChildren(directory: Path): List> { + return try { + // This set tracks paths that have been processed as part of a compacted directory + // to avoid adding them as duplicate, separate entries in the tree. + val processedPaths = mutableSetOf() + FileService.listDirectory(directory) + .sortedWith(compareBy { !it.isDirectory() }.thenComparator { a, b -> + naturalOrderComparator.compare(a.fileName.toString(), b.fileName.toString()) + }) + .mapNotNull { entry -> + if (entry in processedPaths) return@mapNotNull null + + if (entry.isDirectory()) { + val (finalPath, displayName) = findCompactedPath(entry) + var current = finalPath + while (current != entry.parent) { + if (current != entry) processedPaths.add(current) + current = current.parent ?: break + } + createNode(FileTreeItem(finalPath, displayName)) + } else { + createNode(FileTreeItem(entry, entry.fileName.toString())) + } + } + } catch (e: Exception) { + logger.error("Failed to load children for directory: $directory", e) + emptyList() + } + } + + /** + * Compacts chains of single-child directories into a single, dot-separated display name. + * For example, a structure like `src/main/kotlin` where `src` and `main` only contain + * one directory will be treated as a single logical node with the display name "src.main.kotlin". + * + * @param startPath The initial directory to begin compaction from. + * @return A Pair containing the final, deepest path in the chain and the compacted display name. + */ //TODO: Add settings to change separation character + private fun findCompactedPath(startPath: Path): Pair { + var currentPath = startPath + val nameParts = mutableListOf(startPath.fileName.toString()) + + while (true) { + val entries = FileService.listDirectory(currentPath) + if (entries.size == 1 && entries.first().isDirectory()) { + currentPath = entries.first() + nameParts.add(currentPath.fileName.toString()) + } else { + break + } + } + return Pair(currentPath, nameParts.joinToString(".")) + } + + private fun createNode(itemData: FileTreeItem): TreeItem { + val treeItem = TreeItem(itemData) + + addExpansionListener(treeItem, itemData.path) + + if (itemData.path.isDirectory()) { + treeItem.children.add(TreeItem()) // Fake item for expandability + + treeItem.expandedProperty().addListener { _, _, isExpanded -> + if (isExpanded && treeItem.children.firstOrNull()?.value == null) { + scope.launch { + val children = loadChildren(itemData.path) + Platform.runLater { + treeItem.children.setAll(children) + restoreExpandedPaths(treeItem) // Restore children state + } + } + } + } + } + return treeItem + } + + private fun addExpansionListener(item: TreeItem, path: Path) { + item.expandedProperty().addListener { _, _, isExpanded -> + if (isRestoringExpansion) return@addListener + + if (isExpanded) { + expandedPaths.add(path) + } else { + expandedPaths.remove(path) + } + WorkspaceManager.updateExpandedPaths(expandedPaths) + } + } + + //TODO: Move to utils + private val naturalOrderComparator = Comparator { a, b -> + var i = 0 + var j = 0 + + while (i < a.length && j < b.length) { + val ca = a[i] + val cb = b[j] + + if (ca.isDigit() && cb.isDigit()) { + var na = StringBuilder() + var nb = StringBuilder() + + while (i < a.length && a[i].isDigit()) { + na.append(a[i]) + i++ + } + while (j < b.length && b[j].isDigit()) { + nb.append(b[j]) + j++ + } + + val lenDiff = na.length - nb.length + val diff = if (lenDiff != 0 ) lenDiff else na.toString().compareTo(nb.toString()) + if (diff != 0) return@Comparator diff + } else { + val diff = ca.lowercaseChar() - cb.lowercaseChar() + if (diff != 0) return@Comparator diff + i++ + j++ + } + } + + a.length - b.length + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/main/TextEditorViewModel.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/main/TextEditorViewModel.kt new file mode 100644 index 000000000..b300b0c52 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/main/TextEditorViewModel.kt @@ -0,0 +1,421 @@ +package io.github.frostzie.nodex.modules.main + +import io.github.frostzie.nodex.events.* +import io.github.frostzie.nodex.modules.bars.BottomBarModule +import io.github.frostzie.nodex.project.WorkspaceManager +import io.github.frostzie.nodex.services.FileService +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import io.github.frostzie.nodex.utils.LoggerProvider +import javafx.application.Platform +import javafx.beans.InvalidationListener +import javafx.beans.property.BooleanProperty +import javafx.beans.property.IntegerProperty +import javafx.beans.property.SimpleBooleanProperty +import javafx.beans.property.SimpleIntegerProperty +import javafx.beans.property.SimpleObjectProperty +import javafx.collections.FXCollections +import javafx.collections.ObservableList +import javafx.beans.value.ChangeListener +import javafx.beans.property.SimpleStringProperty +import javafx.beans.property.StringProperty +import java.nio.file.Files +import java.nio.file.Path +import java.util.UUID + +/** + * ViewModel for the text editor that manages multiple tabs. + * Each tab represents an open file with its own CodeArea instance. + */ +class TextEditorViewModel { + private val logger = LoggerProvider.getLogger("TextEditorViewModel") + + enum class EditorAction { + UNDO, REDO, CUT, COPY, PASTE, SELECT_ALL + } + + val currentAction = SimpleObjectProperty(null) + + /** + * Data class representing a single editor tab + */ + data class TabData( + val id: String = UUID.randomUUID().toString(), + val filePath: Path, + val displayName: String, + val content: StringProperty = SimpleStringProperty(""), + val isDirty: BooleanProperty = SimpleBooleanProperty(false), + // Listeners to be managed for cleanup + var isDirtyListener: ChangeListener? = null + ) + + // Observable list of all open tabs + val tabs: ObservableList = FXCollections.observableArrayList() + + // Currently active tab //TODO: Fix: (External renaming) FileX -> FileY then FolderX -> FolderY then focus app = dir as tab... + val activeTab = SimpleObjectProperty() + + // Current line and column of the caret in the active editor + val currentLine: IntegerProperty = SimpleIntegerProperty(1) + val currentColumn: IntegerProperty = SimpleIntegerProperty(1) + + private var isRestoringSession = false + + init { + EventBus.register(this) + + // Listen for tab changes to persist state + tabs.addListener(InvalidationListener { + persistState() + }) + + activeTab.addListener { _, _, newTab -> + persistState() + EventBus.post(ActiveTabChangedEvent(newTab?.filePath)) + } + + // Initial session restoration for when the VM is created after WorkspaceManager is already ready + Platform.runLater { + restoreSession() + } + } + + @SubscribeEvent @Suppress("unused") + fun onWorkspaceUpdated(event: WorkspaceUpdated) { + // Reload open files from session state + if (isRestoringSession) return + + Platform.runLater { + restoreSession() + } + } + + private fun restoreSession() { + isRestoringSession = true + try { + val state = WorkspaceManager.getCurrentState() + val savedFiles = state.openFiles + val lastActive = state.activeFile + + // Close existing tabs that are not in the new session (e.g. on reset or project switch) + val toClose = tabs.filter { it.filePath !in savedFiles } + + // Close removed tabs + toClose.forEach { closeTab(it, persist = false) } + + // Close tabs that are in the session but missing on disk (prevent resurrection) + val missingTabs = tabs.filter { it.filePath in savedFiles && !Files.exists(it.filePath) } + missingTabs.forEach { closeTab(it, persist = false, save = false) } + + val currentPaths = tabs.map { it.filePath }.toSet() + + // Open new files + savedFiles.forEach { path -> + if (path !in currentPaths && Files.exists(path)) { + createNewTab(path) + } + } + + // Restore active tab + if (lastActive != null) { + val tab = tabs.find { it.filePath == lastActive } + if (tab != null) { + activeTab.set(tab) + } + } + } finally { + isRestoringSession = false + } + } + + private fun persistState() { + if (isRestoringSession) return + + val openPaths = tabs.map { it.filePath }.toSet() + val activePath = activeTab.get()?.filePath + + val currentState = WorkspaceManager.getCurrentState() + if (openPaths != currentState.openFiles || activePath != currentState.activeFile) { + WorkspaceManager.updateOpenFiles(openPaths, activePath) + } + } + + /** + * Opens a file in the editor. If the file is already open, switch to that tab. + * Otherwise, create a new tab. + */ + @SubscribeEvent @Suppress("unused") + fun onOpenFile(event: OpenFile) { + Platform.runLater { + val existingTab = tabs.find { it.filePath == event.path } + if (existingTab != null) { + logger.info("File already open, switching to tab: ${event.path.fileName}") + activeTab.set(existingTab) + return@runLater + } + + logger.debug("Opening new file in tab: {}", event.path.fileName) + createNewTab(event.path) + } + } + + /** + * Saves all modified files + */ + @SubscribeEvent @Suppress("unused") + fun onSaveAll(event: SaveAllFiles) { + logger.debug("Saving all modified files...") + tabs.filter { it.isDirty.get() }.forEach { saveFile(it) } + } + + // Currently only used for while editing Theme, but overall file saving should move out of this + //TODO: should be stricter MVVM + @SubscribeEvent @Suppress("unused") + fun onSaveFile(event: SaveFileEvent) { + Platform.runLater { + val tab = tabs.find { it.filePath == event.path } + if (tab != null) { + saveFile(tab) + } + } + } + + @SubscribeEvent @Suppress("unused") + fun onEditorUndo(event: EditorUndo) { + currentAction.set(EditorAction.UNDO) + } + + @SubscribeEvent @Suppress("unused") + fun onEditorRedo(event: EditorRedo) { + currentAction.set(EditorAction.REDO) + } + + @SubscribeEvent @Suppress("unused") + fun onEditorCut(event: EditorCut) { + currentAction.set(EditorAction.CUT) + } + + @SubscribeEvent @Suppress("unused") + fun onEditorCopy(event: EditorCopy) { + currentAction.set(EditorAction.COPY) + } + + @SubscribeEvent @Suppress("unused") + fun onEditorPaste(event: EditorPaste) { + currentAction.set(EditorAction.PASTE) + } + + @SubscribeEvent @Suppress("unused") + fun onEditorSelectAll(event: EditorSelectAll) { + currentAction.set(EditorAction.SELECT_ALL) + } + + @SubscribeEvent @Suppress("unused") + fun onFileModified(event: FileModified) { + Platform.runLater { + val tab = tabs.find { it.filePath == event.path } ?: return@runLater + // Only reload if not dirty to avoid overwriting user changes (Maybe add de-sync notification) + if (!tab.isDirty.get()) { + try { + val newContent = FileService.readText(event.path) + tab.content.set(newContent) + // Reset dirty state since this is a sync with disk + tab.isDirty.set(false) + } catch (e: Exception) { + logger.error("Failed to reload modified file: ${event.path}", e) + } + } + } + } + + @SubscribeEvent @Suppress("unused") + fun onFileDeleted(event: FileDeleted) { + Platform.runLater { + val tab = tabs.find { it.filePath == event.path } ?: return@runLater + // Close the tab without trying to save, as the file is gone + closeTab(tab, save = false) + } + } + + @SubscribeEvent @Suppress("unused") + fun onFileMoved(event: FileMoved) { + Platform.runLater { + val oldTab = tabs.find { it.filePath == event.oldPath } ?: return@runLater + + // Detach old listeners + oldTab.isDirtyListener?.let { oldTab.isDirty.removeListener(it) } + + // Create new TabData with an updated path but same CodeArea/ID + val newTab = oldTab.copy( + filePath = event.newPath, + displayName = event.newPath.fileName.toString(), + isDirtyListener = null + ) + + // Setup listeners on new tab data + setupTabListeners(newTab) + + // Update dirtyFiles set if it was dirty + if (oldTab.isDirty.get()) { + WorkspaceManager.dirtyFiles.remove(oldTab.filePath) + WorkspaceManager.dirtyFiles.add(newTab.filePath) + } + + val index = tabs.indexOf(oldTab) + if (index != -1) { + tabs[index] = newTab + } + + // Note: TextEditorView's addTab automatically selects the new tab, so activeTab update is handled there. + } + } + + /** + * Creates a new tab for the given file path + */ + private fun createNewTab(filePath: Path) { + try { + // Safety Guard: Never open a directory as a tab + if (FileService.isDirectory(filePath)) { + logger.warn("Attempted to open a directory as a tab: $filePath") + return + } + + // Read file content + val contentText = FileService.readText(filePath) + logger.debug("Read file content: {} ({} characters)", filePath.fileName, contentText.length) + + val tabData = TabData( + filePath = filePath, + displayName = filePath.fileName.toString(), + content = SimpleStringProperty(contentText) + ) + + setupTabListeners(tabData) + + // Insert after the currently active tab, or at the end if none is active + val active = activeTab.get() + val index = if (active != null) { + val activeIndex = tabs.indexOf(active) + if (activeIndex != -1) activeIndex + 1 else tabs.size + } else { + tabs.size + } + + tabs.add(index.coerceIn(0, tabs.size), tabData) + + activeTab.set(tabData) + + logger.debug("Tab created for file: {}", filePath.fileName) + + } catch (e: Exception) { + logger.error("Failed to create tab for file: ${filePath.fileName}", e) + } + } + + private fun setupTabListeners(tabData: TabData) { + tabData.isDirtyListener = ChangeListener { _, _, isDirty -> + if (isDirty) { + WorkspaceManager.dirtyFiles.add(tabData.filePath) + } else { + WorkspaceManager.dirtyFiles.remove(tabData.filePath) + } + }.also { tabData.isDirty.addListener(it) } + } + + /** + * Updates the line and column properties. + */ + fun updateLineAndColumn(line: Int, column: Int) { + currentLine.set(line) + currentColumn.set(column) + BottomBarModule.updateCursorPosition(line, column) + } + + /** + * Closes the specified tab and auto-saves before closing + */ + fun closeTab(tabData: TabData, persist: Boolean = true, save: Boolean = true) { + logger.debug("Closing tab: ${tabData.displayName}") + + if (save) { + // Auto-save before closing + try { + saveFile(tabData) + logger.debug("Auto-saved file before closing: ${tabData.displayName}") + } catch (e: Exception) { + logger.error("Failed to auto-save before closing: ${tabData.displayName}", e) + } + } + + // Ensure the path is removed from the global dirty set + WorkspaceManager.dirtyFiles.remove(tabData.filePath) + + // Remove listeners to prevent memory leaks + tabData.isDirtyListener?.let { tabData.isDirty.removeListener(it) } + tabData.isDirtyListener = null + + tabs.remove(tabData) + + // If the closed tab was active, switch to another tab + if (activeTab.get() == tabData) { + activeTab.set(tabs.firstOrNull()) + } + + if (persist) persistState() + } + + // Overload for API compatibility if needed elsewhere + fun closeTab(tabData: TabData) { + closeTab(tabData, persist = true, save = true) + } + + /** + * Saves the active tab's content + */ + fun saveActiveTab() { + val tab = activeTab.get() ?: return + saveFile(tab) + logger.info("Manually saved file: ${tab.displayName}") + } + + private fun saveFile(tabData: TabData) { + try { + // Safety guard: Never try to write content to a directory path + // This prevents crashes if a tab accidentally points to a directory + if (FileService.isDirectory(tabData.filePath)) { + logger.warn("Attempted to save to a directory path: ${tabData.filePath}. Aborting save!") + tabData.isDirty.set(false) + return + } + + val content = tabData.content.get() + WorkspaceManager.ignoreWatcherPath(tabData.filePath) + FileService.writeText(tabData.filePath, content) + tabData.isDirty.set(false) + logger.debug("File saved: {} ({} characters)", tabData.filePath.fileName, content.length) + } catch (e: Exception) { + logger.error("Failed to save file: ${tabData.filePath.fileName}", e) + // TODO: Show error notification to user + } + } + + /** + * Cleanup method to be called when the editor is closed + */ + fun cleanup() { + // Auto-save all tabs before cleanup + tabs.forEach { tab -> + try { + saveFile(tab) + logger.debug("Auto-saved during cleanup: ${tab.displayName}") + } catch (e: Exception) { + logger.error("Failed to auto-save during cleanup: ${tab.displayName}", e) + } + } + + tabs.clear() + activeTab.set(null) + EventBus.unregister(this) + logger.info("TextEditorViewModel cleaned up") + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/FileOverrideDialogViewModel.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/FileOverrideDialogViewModel.kt new file mode 100644 index 000000000..7e7eb264d --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/FileOverrideDialogViewModel.kt @@ -0,0 +1,25 @@ +package io.github.frostzie.nodex.modules.popup.file + +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.FileOverrideConfirmed +import io.github.frostzie.nodex.events.MoveFile +import javafx.beans.property.SimpleStringProperty +import java.nio.file.Path + +class FileOverrideDialogViewModel( + private val sourcePath: Path, + private val targetPath: Path +) { + val headerText = SimpleStringProperty("Confirm Overwrite") + val contentText = SimpleStringProperty() + + init { + val fileName = targetPath.fileName?.toString() ?: targetPath.toString() + contentText.set("The destination already has a file named \"$fileName\".\nDo you want to replace it?") + } + + fun confirm() { + EventBus.post(FileOverrideConfirmed(sourcePath, targetPath)) + EventBus.post(MoveFile(sourcePath, targetPath)) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/FilePopupModule.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/FilePopupModule.kt new file mode 100644 index 000000000..fa95cffd3 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/FilePopupModule.kt @@ -0,0 +1,42 @@ +package io.github.frostzie.nodex.modules.popup.file + +import io.github.frostzie.nodex.screen.elements.popup.file.FileOverrideDialogView +import io.github.frostzie.nodex.screen.elements.popup.file.MoveConfirmationView +import javafx.application.Platform +import javafx.stage.Stage +import java.nio.file.Path + +/** + * A module responsible for creating and managing file-operation-related popups. + * + * This class acts as a factory for various file dialogs, [MoveConfirmationView]. (More to come) + * It is responsible for creating the necessary ViewModels and Views, ensuring that the logic is + * decoupled from the event handlers that trigger these dialogs. + * + * @param parentStage The main application stage, used as the owner for the popups. + */ +class FilePopupModule(private val parentStage: Stage) { + /** + * Creates a dialog to confirm a file or directory moval. + * + * This function sets up the [MoveConfirmationViewModel] with the initial paths + * and then constructs and shows the [MoveConfirmationView]. + * + * @param sourcePath The path of the file or directory being moved. + * @param targetPath The initial proposed destination path for the item. + */ + fun showMoveConfirmation(sourcePath: Path, targetPath: Path) { + Platform.runLater { + val viewModel = MoveConfirmationViewModel(sourcePath) + viewModel.targetDirectory.set(targetPath.parent.toString()) + MoveConfirmationView(viewModel, parentStage).show() + } + } + + fun showFileOverrideDialog(sourcePath: Path, targetPath: Path) { + Platform.runLater { + val viewModel = FileOverrideDialogViewModel(sourcePath, targetPath) + FileOverrideDialogView(viewModel, parentStage).show() + } + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/MoveConfirmationViewModel.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/MoveConfirmationViewModel.kt new file mode 100644 index 000000000..3cb6ae1e1 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/file/MoveConfirmationViewModel.kt @@ -0,0 +1,58 @@ +package io.github.frostzie.nodex.modules.popup.file + +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.MoveFile +import io.github.frostzie.nodex.events.RequestFileOverride +import javafx.beans.property.SimpleStringProperty +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path +import kotlin.io.path.isDirectory + +class MoveConfirmationViewModel( + private val sourcePath: Path +) { + val sourcePathLabel = SimpleStringProperty() + val targetDirectory = SimpleStringProperty() + val error = SimpleStringProperty() + + init { + sourcePathLabel.set(buildSourcePathLabel()) + } + + fun confirm(): Boolean { + error.set(null) + try { + val targetDirValue = targetDirectory.get()?.takeIf { it.isNotBlank() } ?: run { + error.set("Target directory cannot be empty") + return false + } + val newTargetDirPath = Path.of(targetDirValue) + if (Files.isDirectory(newTargetDirPath)) { + val fileName = sourcePath.fileName ?: run { + error.set("Invalid file name") + return false + } + val newTargetPath = newTargetDirPath.resolve(fileName) + if (Files.exists(newTargetPath)) { + EventBus.post(RequestFileOverride(sourcePath, newTargetPath)) + return true + } + EventBus.post(MoveFile(sourcePath, newTargetPath)) + return true + } else { + //TODO: Add Error and Refactoring move. + error.set("Invalid directory: Path does not exist or is not a directory.") + return false + } + } catch (_: InvalidPathException) { + error.set("Invalid path format.") + return false + } + } + + private fun buildSourcePathLabel(): String { + val label = if (sourcePath.isDirectory()) "Current directory:" else "Current file:" + return "$label $sourcePath" + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/settings/SettingsModule.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/settings/SettingsModule.kt new file mode 100644 index 000000000..0ba30fe91 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/settings/SettingsModule.kt @@ -0,0 +1,256 @@ +package io.github.frostzie.nodex.modules.popup.settings + +import io.github.frostzie.nodex.events.* +import io.github.frostzie.nodex.screen.elements.popup.settings.SettingsView +import io.github.frostzie.nodex.settings.SettingsManager +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import io.github.frostzie.nodex.settings.data.CategoryItem +import io.github.frostzie.nodex.settings.data.CategoryType +import io.github.frostzie.nodex.settings.data.ConfigField +import io.github.frostzie.nodex.settings.data.SearchResult +import io.github.frostzie.nodex.utils.LoggerProvider +import javafx.scene.control.Dialog +import io.github.frostzie.nodex.utils.UIConstants +import io.github.frostzie.nodex.utils.ThemeManager +import javafx.beans.value.ChangeListener +import javafx.event.ActionEvent +import javafx.scene.control.Button +import javafx.scene.control.ButtonBar +import javafx.scene.control.ButtonType +import javafx.stage.Stage +import io.github.frostzie.nodex.settings.categories.ThemeConfig + +/** + * Manages the logic for the settings window, including searching, category selection, + * displaying settings content, and saving changes. It acts as an intermediary between + * the settings UI elements and the [SettingsManager]. + * + * @param parentStage The parent JavaFX Stage for the settings dialog. + */ +class SettingsModule(private val parentStage: Stage) { + companion object { + private val logger = LoggerProvider.getLogger("SettingsModule") + // Relevance scores for search + private const val EXACT_NAME_SCORE = 100 + private const val NAME_CONTAINS_SCORE = 50 + private const val DESC_CONTAINS_SCORE = 25 + private const val CATEGORY_NAME_SCORE = 15 + private const val CATEGORY_DESC_SCORE = 10 + } + + private var currentQuery: String = "" + private var settingsDialog: Dialog? = null + + init { + EventBus.register(this) + } + + @SubscribeEvent @Suppress("unused") + fun onImportTheme(event: ImportThemeEvent) { + ThemeManager.importTheme(parentStage) + } + + @SubscribeEvent @Suppress("unused") + fun onCloseSettings(event: CloseSettingsEvent) { + saveSettings() + close() + } + + /** + * Closes the settings dialog if it is open. + */ + fun close() { + settingsDialog?.close() + } + + /** + * Initiates a search for settings based on the provided query string. + * The search results are then posted as a [SettingsSearchResultsAvailable] event. + * + * @param query The search string. + */ + fun search(query: String) { + this.currentQuery = query + if (query.isBlank()) { + EventBus.post(SettingsSearchResultsAvailable(query, emptyList())) + } else { + val results = searchSettings(query) + EventBus.post(SettingsSearchResultsAvailable(query, results)) + } + } + + /** + * Handles the selection of a settings category (either main or subcategory) from the navigation tree. + * It retrieves the relevant settings fields and updates the content area of the settings window + * by posting a [SettingsContentUpdate] event. The order of sections reflects the declaration order. + * + * @param item The [CategoryItem] representing the selected category. + */ + fun selectCategory(item: CategoryItem) { + val sections = mutableListOf() + val title: String + var filterFields: Set? = null + + if (currentQuery.isNotBlank()) { + filterFields = searchSettings(currentQuery).map { it.field }.toSet() + } + + when (item.type) { + CategoryType.MAIN_CATEGORY -> { + title = "${item.name} Settings" + item.configClass?.let { configClass -> + val nestedCategories = SettingsManager.getNestedCategories(configClass) + nestedCategories.forEach { (subCategoryName, fields) -> + sections.add(SectionData(subCategoryName, fields.firstOrNull()?.category?.desc, fields)) + } + } + } + CategoryType.SUB_CATEGORY -> { + title = "${item.subCategory} Settings" + item.configClass?.let { configClass -> + item.subCategory?.let { subCategory -> + val nestedCategories = SettingsManager.getNestedCategories(configClass) + val fields = nestedCategories[subCategory] ?: emptyList() + sections.add(SectionData(subCategory, fields.firstOrNull()?.category?.desc, fields)) + } + } + } + else -> return + } + + EventBus.post(SettingsContentUpdate(title, sections, filterFields)) + } + + /** + * Handles the selection of a search result, navigating the settings tree to highlight + * the corresponding category and field. + * + * @param result The [SearchResult] that was selected. + */ + fun selectSearchResult(result: SearchResult) { + val categories = SettingsManager.getConfigCategories() + val categoryIndex = categories.indexOfFirst { it.first == result.mainCategory } + + if (categoryIndex != -1) { + EventBus.post(SelectTreeItem(categoryIndex, result.subCategory)) + } + } + + /** + * Displays the main settings window dialog. + */ + fun showSettingsWindow() { + val view = SettingsView() + + settingsDialog = Dialog().apply { + title = "Settings" + isResizable = true + dialogPane.content = view + dialogPane.style = "-fx-font-size: ${ThemeConfig.fontSize.get()}px;" + + initOwner(parentStage) + dialogPane.minWidth = UIConstants.SETTINGS_MIN_WIDTH + dialogPane.minHeight = UIConstants.SETTINGS_MIN_HEIGHT + dialogPane.prefWidth = UIConstants.SETTINGS_WIDTH + dialogPane.prefHeight = UIConstants.SETTINGS_HEIGHT + + val applyBtn = ButtonType("Apply", ButtonBar.ButtonData.APPLY) + val closeBtn = ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE) + dialogPane.buttonTypes.addAll(applyBtn, closeBtn) + + val applyButton = dialogPane.lookupButton(applyBtn) as Button + applyButton.isDefaultButton = false + applyButton.addEventFilter(ActionEvent.ACTION) { event -> + saveSettings() + event.consume() // Prevents the dialog from closing + } + } + + val dialog = settingsDialog ?: return // Should not be null here + + val fontSizeListener = ChangeListener { _, _, _ -> + dialog.dialogPane.style = "-fx-font-size: ${ThemeConfig.fontSize.get()}px;" + } + ThemeConfig.fontSize.addListener(fontSizeListener) + + dialog.showAndWait() + + ThemeConfig.fontSize.removeListener(fontSizeListener) + saveSettings() + settingsDialog = null // Cleanup reference + } + + /** + * Loads the available setting categories and their subcategories from the [SettingsManager] + * and sends them to the UI components via a [SettingsCategoriesAvailable] event. + * This method ensures the categories are sent in their declared order. + */ + fun loadAndSendCategories() { + val categoryDataList = SettingsManager.getConfigCategories().map { (categoryName, configClass) -> + // Subcategories are retrieved in their declared order due to the LinkedHashMap in SettingsManager. + val subCategories = SettingsManager.getNestedCategories(configClass).keys.toList() + CategoryData(categoryName.replaceFirstChar { it.uppercase() }, configClass, subCategories) + } + EventBus.post(SettingsCategoriesAvailable(categoryDataList)) + } + + /** + * Triggers the saving of all current settings values through the [SettingsManager]. + */ + fun saveSettings() { + SettingsManager.saveSettings() + logger.debug("Settings saved.") + } + + /** + * Performs a search across all registered settings and returns a list of matching [SearchResult]s. + * Results are ranked by relevance. + * + * @param query The search query string. + * @return A list of [SearchResult]s, sorted by relevance score in descending order. + */ + private fun searchSettings(query: String): List { + if (query.isBlank()) return emptyList() + + val lowerQuery = query.lowercase() + + return SettingsManager.getConfigCategories() + .flatMap { (categoryName, configClass) -> + SettingsManager.getConfigFields(configClass).mapNotNull { field -> + val relevanceScore = calculateRelevance(field, lowerQuery) + if (relevanceScore > 0) { + SearchResult(categoryName, field.category?.name ?: "General", field, relevanceScore) + } else null + } + } + .sortedByDescending { it.relevanceScore } + } + + /** + * Calculates a relevance score for a given [ConfigField] against a search query. + * + * @param field The [ConfigField] to evaluate. + * @param query The search query in lowercase. + * @return An integer representing the relevance score. Higher scores mean more relevant. + */ + private fun calculateRelevance(field: ConfigField, query: String): Int { + var score = 0 + + // Exact name match gets the highest score + if (field.name.lowercase() == query) score += EXACT_NAME_SCORE + + // Name contains query + if (field.name.lowercase().contains(query)) score += NAME_CONTAINS_SCORE + + // Description contains query + if (field.description.lowercase().contains(query)) score += DESC_CONTAINS_SCORE + + // Category name contains query + if (field.category?.name?.lowercase()?.contains(query) == true) score += CATEGORY_NAME_SCORE + + // Category description contains query + if (field.category?.desc?.lowercase()?.contains(query) == true) score += CATEGORY_DESC_SCORE + + return score + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/settings/ThemeModule.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/settings/ThemeModule.kt new file mode 100644 index 000000000..262df34dc --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/popup/settings/ThemeModule.kt @@ -0,0 +1,84 @@ +package io.github.frostzie.nodex.modules.popup.settings + +import atlantafx.base.controls.Notification +import io.github.frostzie.nodex.events.* +import io.github.frostzie.nodex.settings.categories.ThemeConfig +import io.github.frostzie.nodex.styling.messages.MessageSeverity +import io.github.frostzie.nodex.styling.notifications.NotificationFactory +import io.github.frostzie.nodex.utils.ThemeManager +import io.github.frostzie.nodex.utils.ThemeUtils +import javafx.scene.Scene +import javafx.scene.control.Button +import java.nio.file.Files +import java.nio.file.Path + +class ThemeModule { + val scenes = mutableSetOf() + private var editingNotification: Notification? = null + private var editingSessionPath: Path? = null + private var editingSessionTitle: String? = null + + fun changeTheme(themeName: String) { + ThemeUtils.applyTheme(themeName) + } + + fun reloadStyles() { + changeTheme(ThemeConfig.theme.get()) + } + + fun openTheme(event: OpenThemeEvent) { + ThemeManager.openTheme(event) + + val themeName = ThemeConfig.theme.get() + val customThemePath = ThemeManager.customThemesDir.toPath().resolve("$themeName.css") + + if (Files.exists(customThemePath)) { + startEditingSession("Theme: $themeName", customThemePath) + } + } + + fun closeEditingSession() { + editingNotification?.let { NotificationFactory.hide(it) } + editingNotification = null + editingSessionPath = null + editingSessionTitle = null + } + + private fun startEditingSession(title: String, path: Path) { + editingSessionTitle = title + editingSessionPath = path + showEditingNotification(title, path) + } + + fun onActiveTabChanged(path: Path?) { + if (editingSessionPath != null) { + if (path == editingSessionPath) { + if (editingNotification == null) { + showEditingNotification(editingSessionTitle ?: "", editingSessionPath!!) + } + } else { + editingNotification?.let { NotificationFactory.hide(it) } + editingNotification = null + } + } + } + + private fun showEditingNotification(title: String, targetPath: Path) { + editingNotification?.let { NotificationFactory.hide(it) } + + val btn = Button("Save & Refresh") + btn.setOnAction { + EventBus.post(SaveFileEvent(targetPath)) + EventBus.post(ReloadThemeEvent()) + } + + val notification = NotificationFactory.createAndShow( + text = "Editing $title", + severity = MessageSeverity.INFO, + durationMillis = -1 + ) + notification.setPrimaryActions(btn) + notification.setOnClose(null) + editingNotification = notification + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/project/ProjectManagerViewModel.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/project/ProjectManagerViewModel.kt new file mode 100644 index 000000000..831acf5f5 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/project/ProjectManagerViewModel.kt @@ -0,0 +1,53 @@ +package io.github.frostzie.nodex.modules.project + +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.WorkspaceUpdated +import io.github.frostzie.nodex.project.Project +import io.github.frostzie.nodex.project.WorkspaceManager +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import javafx.application.Platform +import javafx.collections.FXCollections +import javafx.collections.ObservableList +import javafx.scene.control.Alert +import javafx.scene.control.ButtonType +import java.nio.file.Files + +class ProjectManagerViewModel { + val recentProjects: ObservableList = FXCollections.observableArrayList() + + init { + EventBus.register(this) + refreshRecents() + } + + @SubscribeEvent + fun onWorkspaceUpdated(event: WorkspaceUpdated) { + Platform.runLater { + refreshRecents() + } + } + + fun refreshRecents() { + recentProjects.setAll(WorkspaceManager.recentProjects) + } + + fun openProject(project: Project) { + if (!Files.exists(project.path)) { //TODO: Actually good UI + val removeBtn = ButtonType("Remove") + val alert = Alert( + Alert.AlertType.ERROR, + "The project directory '${project.name}' does not exist.\nIt may have been moved or deleted.", + removeBtn, ButtonType.OK + ) + alert.headerText = "Project Not Found" + val result = alert.showAndWait() + + if (result.isPresent && result.get() == removeBtn) { + WorkspaceManager.removeRecentProject(project) + } + return + } + + WorkspaceManager.openSingleProject(project.path) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/project/preview/ProjectPreviewViewModel.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/project/preview/ProjectPreviewViewModel.kt new file mode 100644 index 000000000..40e64b4f4 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/project/preview/ProjectPreviewViewModel.kt @@ -0,0 +1,129 @@ +package io.github.frostzie.nodex.modules.project.preview + +import io.github.frostzie.nodex.project.WorkspaceManager +import io.github.frostzie.nodex.project.metadata.DatapackMetadata +import io.github.frostzie.nodex.services.ArchiveService +import javafx.beans.property.SimpleObjectProperty +import java.nio.file.Files +import java.nio.file.Path + +sealed class ProjectPreviewViewModel { + abstract val title: String + abstract val confirmButtonText: String + abstract val isWarning: Boolean + + abstract fun onConfirm(): Path +} + +class SingleProjectPreviewViewModel( + val path: Path, + val metadata: DatapackMetadata +) : ProjectPreviewViewModel() { + override val title = "Import Project" + override val confirmButtonText = "Open Project" + override val isWarning = false + + override fun onConfirm(): Path = path +} + +class ZipProjectPreviewViewModel( + val path: Path, + val metadata: DatapackMetadata +) : ProjectPreviewViewModel() { + override val title = "Import Zip Project" + override val confirmButtonText = "Import & Open" + override val isWarning = false + + val targetDirectory = SimpleObjectProperty(path.parent) + + fun calculateTargetDir(): Path { + val parent = targetDirectory.get() ?: path.parent + val folderName = path.fileName.toString().removeSuffix(".zip") + return parent.resolve(folderName) + } + + fun targetExists(): Boolean { + return calculateTargetDir().toFile().exists() + } + + override fun onConfirm(): Path { + val target = targetDirectory.get() ?: path.parent + + val finalDir = calculateTargetDir() + if (Files.exists(finalDir)) { + WorkspaceManager.clearProjectHistory(finalDir) + } + + return ArchiveService.unzip(path, target) + } +} + +class WorkspaceProjectPreviewViewModel( + val root: Path, + val projects: List +) : ProjectPreviewViewModel() { + val isZip = root.toString().endsWith(".zip") + + override val title = if (isZip) "Import Workspace Zip" else "Multi-Project Workspace" + override val confirmButtonText = if (isZip) "Import & Open" else "Open Workspace" + override val isWarning = false + + val targetDirectory = SimpleObjectProperty(root.parent) + + fun calculateTargetDir(): Path { + val parent = targetDirectory.get() ?: root.parent + val folderName = root.fileName.toString().removeSuffix(".zip") + return parent.resolve(folderName) + } + + fun targetExists(): Boolean { + return calculateTargetDir().toFile().exists() + } + + override fun onConfirm(): Path { + if (isZip) { + val target = targetDirectory.get() ?: root.parent + val finalDir = calculateTargetDir() + if (Files.exists(finalDir)) { + WorkspaceManager.clearProjectHistory(finalDir) + } + return ArchiveService.unzip(root, target) + } + return root + } +} + +class InvalidProjectPreviewViewModel( + val path: Path, + val reason: String +) : ProjectPreviewViewModel() { + val isZip = path.toString().endsWith(".zip") + + override val title = "Validation Failed" + override val confirmButtonText = if (isZip) "Ignore & Import" else "Ignore & Open" + override val isWarning = true + + val targetDirectory = SimpleObjectProperty(path.parent) + + fun calculateTargetDir(): Path { + val parent = targetDirectory.get() ?: path.parent + val folderName = path.fileName.toString().removeSuffix(".zip") + return parent.resolve(folderName) + } + + fun targetExists(): Boolean { + return calculateTargetDir().toFile().exists() + } + + override fun onConfirm(): Path { + if (isZip) { + val target = targetDirectory.get() ?: path.parent + val finalDir = calculateTargetDir() + if (Files.exists(finalDir)) { + WorkspaceManager.clearProjectHistory(finalDir) + } + return ArchiveService.unzip(path, target) + } + return path + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/universal/UniversalPackManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/universal/UniversalPackManager.kt new file mode 100644 index 000000000..7fea8543f --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/universal/UniversalPackManager.kt @@ -0,0 +1,125 @@ +package io.github.frostzie.nodex.modules.universal + +import io.github.frostzie.nodex.config.ConfigManager +import io.github.frostzie.nodex.loader.fabric.Folders +import io.github.frostzie.nodex.loader.fabric.WorldDetection +import io.github.frostzie.nodex.services.ArchiveService +import io.github.frostzie.nodex.utils.LoggerProvider +import io.github.frostzie.nodex.utils.file.DirectoryChooseUtils +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import kotlin.io.path.exists +import kotlin.io.path.name + +object UniversalPackManager { + private val logger = LoggerProvider.getLogger("UniversalPackManager") + + // Get the root folder: .../Nodex/datapacks + private val universalRoot: Path + get() { + return if (ConfigManager.configDir.endsWith("config")) { + ConfigManager.configDir.parent.resolve("datapacks") + } else { + // Fallback if structure is different + Folders.configDir.resolve("Nodex").resolve("datapacks") + } + } + + init { + if (!universalRoot.exists()) { + Files.createDirectories(universalRoot) + } + } + + /** + * Checks if a specific project path is inside the Universal Folder. + */ + fun isUniversalProject(path: Path): Boolean { + // Ensure absolute paths for comparison + return path.toAbsolutePath().startsWith(universalRoot.toAbsolutePath()) + } + + /** + * Checks if a pack with this name already exists in the Universal Folder. + */ + fun existsInUniversal(folderName: String): Boolean { + return universalRoot.resolve(folderName).exists() + } + + /** + * Imports a project (Folder or Zip) into the Universal Folder. + * @param source The original path (zip or folder). + * @param targetName The name to use in the universal folder. + * @return The new Path in the universal folder. + */ + fun importProject(source: Path, targetName: String): Path { + val targetDir = universalRoot.resolve(targetName) + + logger.info("Importing project from $source to $targetDir") + + if (Files.isDirectory(source)) { + // Copy Directory + copyDirectory(source, targetDir) + } else { + // Unzip + ArchiveService.unzip(source, universalRoot) + // ArchiveService.unzip uses the zip name. If renamed, need to rename the result. + val unzippedPath = universalRoot.resolve(source.fileName.toString().removeSuffix(".zip")) + if (unzippedPath != targetDir) { + Files.move(unzippedPath, targetDir, StandardCopyOption.REPLACE_EXISTING) + } + } + + return targetDir + } + + /** + * Mirrors the given project path to the current World's datapack folder. + */ + fun mirrorToWorld(projectPath: Path): Boolean { + if (!WorldDetection.isSingleplayer()) { + logger.warn("Cannot mirror: Not in singleplayer.") + return false + } + + val datapacksDir = DirectoryChooseUtils.getDatapackPath() + if (datapacksDir == null || !datapacksDir.exists()) { + logger.error("Cannot find world datapacks directory.") + return false + } + + val targetDir = datapacksDir.resolve(projectPath.name) + + try { + logger.debug("Mirroring {} to {}", projectPath, targetDir) + + // 1. Clean target if exists + if (targetDir.exists()) { + // Recursive delete + targetDir.toFile().deleteRecursively() + } + + // 2. Copy + copyDirectory(projectPath, targetDir) + + return true + } catch (e: Exception) { + logger.error("Failed to mirror project to world.", e) + return false + } + } + + private fun copyDirectory(source: Path, target: Path) { + source.toFile().walkTopDown().forEach { sourceFile -> + val relativePath = source.relativize(sourceFile.toPath()) + val targetFile = target.resolve(relativePath) + + if (sourceFile.isDirectory) { + if (!targetFile.exists()) Files.createDirectories(targetFile) + } else { + Files.copy(sourceFile.toPath(), targetFile, StandardCopyOption.REPLACE_EXISTING) + } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/universal/UniversalWorldIntegration.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/universal/UniversalWorldIntegration.kt new file mode 100644 index 000000000..04fa1d3d4 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/modules/universal/UniversalWorldIntegration.kt @@ -0,0 +1,41 @@ +package io.github.frostzie.nodex.modules.universal + +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.WorkspaceUpdated +import io.github.frostzie.nodex.loader.fabric.WorldDetection +import io.github.frostzie.nodex.loader.minecraft.ChatMessageBuilder +import io.github.frostzie.nodex.project.WorkspaceManager +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import io.github.frostzie.nodex.settings.categories.MainConfig +import kotlin.io.path.name + +object UniversalWorldIntegration { + + fun initialize() { + EventBus.register(this) + + // Listen for when the player joins a world + WorldDetection.registerWorldJoinListener { + checkAndPrompt() + } + } + + @SubscribeEvent + fun onWorkspaceUpdated(event: WorkspaceUpdated) { + // Listen for when the user switches projects while already in a world + checkAndPrompt() + } + + private fun checkAndPrompt() { + if (!WorldDetection.isSingleplayer()) return + + if (!MainConfig.universalFolderToggle.get() || !MainConfig.universalDatapackToggle.get()) return + + val root = WorkspaceManager.currentWorkspaceRoot ?: return + + // Only prompt if it is a Universal Project + if (UniversalPackManager.isUniversalProject(root)) { + ChatMessageBuilder.promptUniversalLoad(root.name) + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/project/Project.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/Project.kt new file mode 100644 index 000000000..fb1e923a0 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/Project.kt @@ -0,0 +1,27 @@ +package io.github.frostzie.nodex.project + +import io.github.frostzie.nodex.project.metadata.DatapackMetadata +import io.github.frostzie.nodex.project.metadata.DatapackParser +import java.nio.file.Path +import kotlin.io.path.exists + +/** + * Represents a single project (datapack) root in the workspace. + */ +data class Project( + val path: Path, + val name: String = path.fileName.toString(), + var metadata: DatapackMetadata? = null, + var iconPath: Path? = null, + val additionalPaths: MutableList = mutableListOf() +) { + fun loadMetadata() { + metadata = DatapackParser.parse(path) + + // It indeed can only have pack.png + val pack = path.resolve("pack.png") + if (pack.exists()) { + iconPath = pack + } + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/project/Workspace.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/Workspace.kt new file mode 100644 index 000000000..23e0a9568 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/Workspace.kt @@ -0,0 +1,8 @@ +package io.github.frostzie.nodex.project + +/** + * Represents the current workspace configuration, holding a list of open projects. + */ +data class Workspace( + val projects: MutableList = mutableListOf() +) diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/project/WorkspaceManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/WorkspaceManager.kt new file mode 100644 index 000000000..63b549883 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/WorkspaceManager.kt @@ -0,0 +1,374 @@ +package io.github.frostzie.nodex.project + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import io.github.frostzie.nodex.config.ConfigManager +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.OpenProjectManagerEvent +import io.github.frostzie.nodex.events.ResetWorkspaceEvent +import io.github.frostzie.nodex.events.WorkspaceUpdated +import io.github.frostzie.nodex.project.metadata.DatapackParser +import io.github.frostzie.nodex.project.state.ProjectState +import io.github.frostzie.nodex.project.state.ProjectStateHandler +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import io.github.frostzie.nodex.utils.LoggerProvider +import io.github.frostzie.nodex.utils.file.FileSystemWatcher +import javafx.collections.FXCollections +import javafx.collections.ObservableSet +import java.io.FileReader +import java.io.FileWriter +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.LinkedList +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries + +/** + * Represents the persisted state of the application session (Global List). + */ +data class SessionState( + var activeWorkspace: Workspace = Workspace(), + var recentProjects: LinkedList = LinkedList() +) + +/** + * Manages the active workspace and persists its state (open projects and history). + */ +object WorkspaceManager { + private val logger = LoggerProvider.getLogger("WorkspaceManager") + private val workspaceFile get() = ConfigManager.configDir.resolve("workspace.json") + + private val gson: Gson = GsonBuilder() + .setPrettyPrinting() + .registerTypeHierarchyAdapter(Path::class.java, PathTypeAdapter()) + .registerTypeAdapter(Project::class.java, ProjectSerializer()) + .create() + + // The entire session state (workspace + history) + private var sessionState = SessionState() + + // Shared state for dirty files (unsaved changes) + val dirtyFiles: ObservableSet = FXCollections.observableSet() + + // File System Watchers + private val watchers = mutableMapOf() + + // Public accessors + val workspace: Workspace + get() = sessionState.activeWorkspace + + val recentProjects: List + get() = sessionState.recentProjects.toList() + + val currentWorkspaceRoot: Path? + get() = workspace.projects.firstOrNull()?.path + + fun initialize() { + EventBus.register(this) + load() + } + + fun load() { + if (Files.exists(workspaceFile)) { + try { + FileReader(workspaceFile.toFile()).use { reader -> + val loadedState = gson.fromJson(reader, SessionState::class.java) + if (loadedState != null) { + val validActiveProjects = loadedState.activeWorkspace.projects + .filter { Files.exists(it.path) } + .toMutableList() + + validActiveProjects.forEach { + it.loadMetadata() + setupWatcher(it.path) + } + + val validRecentProjects = loadedState.recentProjects + .filter { Files.exists(it.path) } + + validRecentProjects.forEach { + if (it.additionalPaths.isEmpty()) { + it.loadMetadata() + } + } + + sessionState = SessionState( + Workspace(validActiveProjects), + LinkedList(validRecentProjects) + ) + loadCurrentState() + } + logger.debug("Loaded workspace session from {}", workspaceFile) + } + } catch (e: Exception) { + logger.error("Failed to load workspace file, using empty session.", e) + sessionState = SessionState() + } + } else { + logger.info("Workspace file not found, creating new empty session.") + sessionState = SessionState() + } + EventBus.post(WorkspaceUpdated(workspace)) + } + + fun save() { + try { + FileWriter(workspaceFile.toFile()).use { writer -> + gson.toJson(sessionState, writer) + logger.debug("Saved workspace session to {}", workspaceFile) + } + } catch (e: Exception) { + logger.error("Failed to save workspace file.", e) + } + } + + fun addProject(path: Path, shouldAddToRecent: Boolean = false) { + if (workspace.projects.none { it.path == path }) { + val project = Project(path) + project.loadMetadata() + workspace.projects.add(project) + + if (shouldAddToRecent) { + // Preserve additional paths if updating an existing entry + val existing = sessionState.recentProjects.find { it.path == path } + if (existing != null) { + project.additionalPaths.addAll(existing.additionalPaths) + } + addToRecent(project) + } else { + // Importing a secondary project. Update the main recent entry. + val mainProject = workspace.projects.firstOrNull() + if (mainProject != null) { + val recentEntry = sessionState.recentProjects.find { it.path == mainProject.path } + if (recentEntry != null) { + if (recentEntry.additionalPaths.none { it == path }) { + recentEntry.additionalPaths.add(path) + } + // Mark as multi-project workspace (generic folder icon) + recentEntry.metadata = null + recentEntry.iconPath = null + save() + } + } + } + + loadCurrentState() + setupWatcher(path) // Start watching + save() + EventBus.post(WorkspaceUpdated(workspace)) + logger.debug("Added project to workspace: {}", path) + } else { + logger.debug("Project already exists in workspace: {}", path) + } + } + + fun openSingleProject(path: Path) { + stopWatchers() // Stop previous + workspace.projects.clear() + + // Capture extra paths from history before they might be reset + val recentEntry = sessionState.recentProjects.find { it.path == path } + val extraPaths = recentEntry?.additionalPaths?.toList() ?: emptyList() + + if (DatapackParser.parse(path) != null) { + logger.debug("Detected single datapack at {}", path) + addProject(path, true) // This calls setupWatcher + extraPaths.forEach { addProject(it, false) } + return + } + + var foundDatapacks = false + try { + val children = path.listDirectoryEntries() + for (child in children) { + if ((child.isDirectory() || child.toString().endsWith(".zip")) && DatapackParser.parse(child) != null) { + val project = Project(child) + project.loadMetadata() + workspace.projects.add(project) + setupWatcher(child) // Start watching subproject + foundDatapacks = true + } + } + } catch (e: Exception) { + logger.error("Failed to scan directory for datapacks: $path", e) + } + + if (foundDatapacks) { + val workspaceProject = Project(path) + workspaceProject.loadMetadata() + workspaceProject.additionalPaths.addAll(extraPaths) + addToRecent(workspaceProject) + loadCurrentState() + save() + EventBus.post(WorkspaceUpdated(workspace)) + } else { + logger.info("No datapacks detected, opening as generic project: $path") + addProject(path, true) + } + + extraPaths.forEach { addProject(it, false) } + } + + fun removeRecentProject(project: Project) { + sessionState.recentProjects.removeIf { it.path == project.path } + save() + EventBus.post(WorkspaceUpdated(workspace)) + } + + fun clearProjectHistory(path: Path) { + sessionState.recentProjects.removeIf { it.path == path } + + ProjectStateHandler.clearState(path) + + save() + EventBus.post(WorkspaceUpdated(workspace)) + } + + private fun addToRecent(project: Project) { + sessionState.recentProjects.removeIf { it.path == project.path } + sessionState.recentProjects.addFirst(project) + while (sessionState.recentProjects.size > 20) { + sessionState.recentProjects.removeLast() + } + } + + // State Persistence + private var currentProjectState = ProjectState() + + fun updateOpenFiles(files: Set, activeFile: Path?) { + currentProjectState = currentProjectState.copy(openFiles = files, activeFile = activeFile) + saveCurrentState() + } + + fun updateExpandedPaths(paths: Set) { + currentProjectState = currentProjectState.copy(expandedPaths = paths) + saveCurrentState() + } + + private fun saveCurrentState() { + val root = currentWorkspaceRoot ?: return + ProjectStateHandler.saveState(root, currentProjectState) + } + + private fun loadCurrentState() { + val root = currentWorkspaceRoot + currentProjectState = if (root != null) { + ProjectStateHandler.loadState(root) + } else { + ProjectState() + } + } + + fun getCurrentState(): ProjectState { + return currentProjectState + } + + private fun setupWatcher(path: Path) { + if (watchers.containsKey(path)) return + val watcher = FileSystemWatcher(path) + watcher.start() + watchers[path] = watcher + logger.debug("Started watcher for: {}", path) + } + + private fun stopWatchers() { + watchers.values.forEach { it.stop() } + watchers.clear() + logger.debug("Stopped all file system watchers") + } + + fun setWindowFocused(focused: Boolean) { + watchers.values.forEach { it.setWindowFocused(focused) } + } + + fun ignoreWatcherPath(path: Path) { + watchers.values.forEach { watcher -> + watcher.ignoreChanges(path) + } + } + + // Reset / Navigation + @SubscribeEvent @Suppress("unused") + fun onOpenProjectManager(event: OpenProjectManagerEvent) { + logger.info("Returning to project manager...") + stopWatchers() + workspace.projects.clear() + save() + EventBus.post(WorkspaceUpdated(workspace)) + } + + @SubscribeEvent @Suppress("unused") + fun onResetWorkspace(event: ResetWorkspaceEvent) { + stopWatchers() + try { + Files.deleteIfExists(workspaceFile) + } catch (e: Exception) { + logger.error("Failed to delete workspace file", e) + } + sessionState = SessionState() + loadCurrentState() + EventBus.post(WorkspaceUpdated(workspace)) + } +} + +/** + * GSON Adapter for Path serialization + */ +class PathTypeAdapter : TypeAdapter() { + override fun write(out: JsonWriter, value: Path?) { + out.value(value?.toString()) + } + + override fun read(reader: JsonReader): Path? { + val pathString = reader.nextString() + return if (pathString != null) Paths.get(pathString) else null + } +} + +/** + * GSON Adapter for Project serialization (Path only) + */ +class ProjectSerializer : TypeAdapter() { + override fun write(out: JsonWriter, value: Project?) { + if (value == null) { + out.nullValue() + return + } + out.beginObject() + out.name("path").value(value.path.toString()) + + if (value.additionalPaths.isNotEmpty()) { + out.name("additionalPaths").beginArray() + value.additionalPaths.forEach { out.value(it.toString()) } + out.endArray() + } + out.endObject() + } + + override fun read(reader: JsonReader): Project? { + var path: Path? = null + val additionalPaths = mutableListOf() + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "path" -> path = Paths.get(reader.nextString()) + "additionalPaths" -> { + reader.beginArray() + while (reader.hasNext()) { + additionalPaths.add(Paths.get(reader.nextString())) + } + reader.endArray() + } + else -> reader.skipValue() + } + } + reader.endObject() + + return if (path != null) Project(path, additionalPaths = additionalPaths) else null + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/project/metadata/DatapackParser.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/metadata/DatapackParser.kt new file mode 100644 index 000000000..078e02947 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/metadata/DatapackParser.kt @@ -0,0 +1,130 @@ +package io.github.frostzie.nodex.project.metadata + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.github.frostzie.nodex.utils.LoggerProvider +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText + +data class DatapackMetadata( + val packFormat: Int?, + val description: String, + val supportedFormats: FormatRange?, +) + +data class FormatRange(val min: Int, val max: Int) + +object DatapackParser { + private val logger = LoggerProvider.getLogger("DatapackParser") + private val COLOR_CODE_REGEX = Regex("(?i)§[0-9a-fk-or]") + + fun parse(path: Path): DatapackMetadata? { + val mcmetaPath = path.resolve("pack.mcmeta") + if (!mcmetaPath.exists()) return null + + return try { + val content = mcmetaPath.readText() + val json = JsonParser.parseString(content).asJsonObject + val pack = json.getAsJsonObject("pack") ?: return null + + val packFormat = pack.get("pack_format")?.let { if (it.isJsonPrimitive) it.asInt else null } + + var description = parseDescription(pack.get("description")) + description = description.replace(COLOR_CODE_REGEX, "") + + val supportedFormats = resolveSupportedFormats(pack) + + DatapackMetadata(packFormat, description, supportedFormats) + } catch (e: Exception) { + logger.error("Failed to parse pack.mcmeta at $path", e) + null + } + } + + private fun resolveSupportedFormats(pack: JsonObject): FormatRange? { + // 1. Try min_format / max_format + val min = parseMajorVersion(pack.get("min_format")) + val max = parseMajorVersion(pack.get("max_format")) + + if (min != null || max != null) { + return FormatRange(min ?: 0, max ?: Int.MAX_VALUE) + } + + // 2. Try supported_formats field (Legacy) + val supported = parseSupportedFormats(pack.get("supported_formats")) + if (supported != null) return supported + + // 3. Fallback to pack_format + val fmt = pack.get("pack_format")?.let { if (it.isJsonPrimitive) it.asInt else null } + if (fmt != null) { + return FormatRange(fmt, fmt) + } + + return null + } + + private fun parseMajorVersion(element: JsonElement?): Int? { + if (element == null) return null + if (element.isJsonPrimitive && element.asJsonPrimitive.isNumber) return element.asInt + if (element.isJsonArray && element.asJsonArray.size() > 0) { + return element.asJsonArray.get(0).asInt + } + return null + } + + private fun parseSupportedFormats(element: JsonElement?): FormatRange? { + if (element == null) return null + + if (element.isJsonPrimitive && element.asJsonPrimitive.isNumber) { + val fmt = element.asInt + return FormatRange(fmt, fmt) + } + + if (element.isJsonArray) { + val array = element.asJsonArray + if (array.size() == 1) { + val f = array.get(0).asInt + return FormatRange(f, f) + } else if (array.size() >= 2) { + return FormatRange(array.get(0).asInt, array.get(1).asInt) + } + } + + if (element.isJsonObject) { + val obj = element.asJsonObject + val min = obj.get("min_inclusive")?.asInt ?: 0 + val max = obj.get("max_inclusive")?.asInt ?: Int.MAX_VALUE + return FormatRange(min, max) + } + + return null + } + + private fun parseDescription(element: JsonElement?): String { + if (element == null) return "" + if (element.isJsonPrimitive) return element.asString + + // Handle JSON text component (list or object) + return try { + if (element.isJsonArray) { + element.asJsonArray.joinToString("") { parseDescription(it) } + } else if (element.isJsonObject) { + val obj = element.asJsonObject + val text = obj.get("text")?.asString ?: "" + val extra = obj.get("extra") + val extraText = parseDescription(extra) + if (text.isEmpty() && extraText.isEmpty()) { + obj.toString() + } else { + text + extraText + } + } else { + element.toString() + } + } catch (e: Exception) { + element.toString() + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/project/state/ProjectState.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/state/ProjectState.kt new file mode 100644 index 000000000..4edd985a1 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/state/ProjectState.kt @@ -0,0 +1,12 @@ +package io.github.frostzie.nodex.project.state + +import java.nio.file.Path + +/** + * Represents the UI state for a specific project workspace. + */ +data class ProjectState( + val openFiles: Set = emptySet(), + val activeFile: Path? = null, + val expandedPaths: Set = emptySet() +) diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/project/state/ProjectStateHandler.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/state/ProjectStateHandler.kt new file mode 100644 index 000000000..1adbb7ddc --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/state/ProjectStateHandler.kt @@ -0,0 +1,82 @@ +package io.github.frostzie.nodex.project.state + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import io.github.frostzie.nodex.config.ConfigManager +import io.github.frostzie.nodex.project.PathTypeAdapter +import io.github.frostzie.nodex.utils.LoggerProvider +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import kotlin.io.path.exists + +object ProjectStateHandler { + private val logger = LoggerProvider.getLogger("ProjectStateHandler") + private val statesDir get() = ConfigManager.configDir.resolve("project_states") + + private val gson: Gson = GsonBuilder() + .setPrettyPrinting() + .registerTypeHierarchyAdapter(Path::class.java, PathTypeAdapter()) + .create() + + /** + * Loads the UI state for a specific project/workspace root. + */ + fun loadState(rootPath: Path): ProjectState { + val file = getStateFile(rootPath) + if (!file.exists()) return ProjectState() + + return try { + Files.newBufferedReader(file).use { reader -> + gson.fromJson(reader, ProjectState::class.java) ?: ProjectState() + } + } catch (e: Exception) { + logger.error("Failed to load project state for $rootPath", e) + ProjectState() + } + } + + /** + * Saves the UI state for a specific project/workspace root. + */ + fun saveState(rootPath: Path, state: ProjectState) { + val file = getStateFile(rootPath) + try { + Files.newBufferedWriter(file).use { writer -> + gson.toJson(state, writer) + } + } catch (e: Exception) { + logger.error("Failed to save project state for $rootPath", e) + } + } + + /** + * Clears the persisted state for a specific project. + */ + fun clearState(rootPath: Path) { + val file = getStateFile(rootPath) + try { + Files.deleteIfExists(file) + } catch (e: Exception) { + logger.error("Failed to delete project state for $rootPath", e) + } + } + + /** + * Generates a unique filename based on the path hash. + * Use MD5 or SHA-1 to get a safe filename. + */ + private fun getStateFile(path: Path): Path { + if (!statesDir.exists()) { + Files.createDirectories(statesDir) + } + val hash = hashString(path.toAbsolutePath().toString()) + return statesDir.resolve("$hash.json") + } + + private fun hashString(input: String): String { + return MessageDigest.getInstance("MD5") + .digest(input.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/project/validation/ProjectValidator.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/validation/ProjectValidator.kt new file mode 100644 index 000000000..f351f465b --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/project/validation/ProjectValidator.kt @@ -0,0 +1,90 @@ +package io.github.frostzie.nodex.project.validation + +import io.github.frostzie.nodex.project.metadata.DatapackMetadata +import io.github.frostzie.nodex.project.metadata.DatapackParser +import io.github.frostzie.nodex.utils.LoggerProvider +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.extension +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name + +sealed class ValidationResult { + data class ValidSingle(val path: Path, val metadata: DatapackMetadata) : ValidationResult() + data class ValidZip(val path: Path, val metadata: DatapackMetadata) : ValidationResult() + data class ValidWorkspace(val root: Path, val projects: List) : ValidationResult() + data class Invalid(val path: Path, val reason: String) : ValidationResult() +} + +object ProjectValidator { + private val logger = LoggerProvider.getLogger("ProjectValidator") + + /** + * Validates if a path is a valid Datapack Project or a Workspace containing Datapacks. + */ + fun validate(path: Path): ValidationResult { + if (!Files.exists(path)) { + return ValidationResult.Invalid(path, "Path does not exist.") + } + + // Zip + if (path.extension.lowercase() == "zip") { + return validateZip(path) + } + + // Single Datapack (Root has pack.mcmeta) + val metadata = DatapackParser.parse(path) + if (metadata != null) { + return ValidationResult.ValidSingle(path, metadata) + } + + // Workspace Detection (1 level deep by default) + if (path.isDirectory()) { + val subProjects = mutableListOf() + try { + val children = path.listDirectoryEntries() + + // Safety check for massive directories + if (children.size > 2000) { + return ValidationResult.Invalid(path, "Directory contains too many items (${children.size}). Please select a specific folder.") + } + + for (child in children) { + if (child.isDirectory() && !child.name.startsWith(".")) { + if (DatapackParser.parse(child) != null) { + subProjects.add(child) + } + } + } + } catch (e: Exception) { + logger.error("Failed to scan directory: $path", e) + return ValidationResult.Invalid(path, "Failed to read directory.") + } + + if (subProjects.isNotEmpty()) { + return ValidationResult.ValidWorkspace(path, subProjects) + } + } + + return ValidationResult.Invalid(path, "No 'pack.mcmeta' found in this folder or its immediate subfolders.") + } + + private fun validateZip(path: Path): ValidationResult { + return try { + FileSystems.newFileSystem(path, null as ClassLoader?).use { fs -> + val root = fs.getPath("/") + val metadata = DatapackParser.parse(root) + if (metadata != null) { + ValidationResult.ValidZip(path, metadata) + } else { + ValidationResult.Invalid(path, "Zip file does not contain a valid 'pack.mcmeta' at the root.") + } + } + } catch (e: Exception) { + logger.error("Failed to open zip: $path", e) + ValidationResult.Invalid(path, "Failed to open zip file.") + } + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/MainApplication.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/MainApplication.kt new file mode 100644 index 000000000..c88818474 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/MainApplication.kt @@ -0,0 +1,438 @@ +package io.github.frostzie.nodex.screen + +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.WorkspaceUpdated +import io.github.frostzie.nodex.handlers.bars.BottomBarHandler +import io.github.frostzie.nodex.handlers.bars.LeftBarHandler +import io.github.frostzie.nodex.handlers.bars.top.TopBarHandler +import io.github.frostzie.nodex.handlers.popup.file.FilePopupHandler +import io.github.frostzie.nodex.handlers.popup.settings.SettingsHandler +import io.github.frostzie.nodex.modules.bars.BottomBarModule +import io.github.frostzie.nodex.modules.bars.LeftBarModule +import io.github.frostzie.nodex.modules.bars.top.TopBarViewModel +import io.github.frostzie.nodex.modules.popup.file.FilePopupModule +import io.github.frostzie.nodex.modules.popup.settings.SettingsModule +import io.github.frostzie.nodex.handlers.popup.settings.ThemeHandler +import io.github.frostzie.nodex.modules.popup.settings.ThemeModule +import io.github.frostzie.nodex.styling.common.NotificationMessageArea +import io.github.frostzie.nodex.screen.elements.bars.BottomBarView +import io.github.frostzie.nodex.screen.elements.bars.LeftBarView +import io.github.frostzie.nodex.screen.elements.bars.top.TopBarView +import io.github.frostzie.nodex.screen.elements.main.FileTreeView +import io.github.frostzie.nodex.screen.elements.main.TextEditorView +import io.github.frostzie.nodex.screen.elements.popup.settings.SettingsView +import io.github.frostzie.nodex.screen.elements.project.ProjectManagerView +import io.github.frostzie.nodex.project.WorkspaceManager +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import io.github.frostzie.nodex.utils.JavaFXInitializer +import io.github.frostzie.nodex.utils.LoggerProvider +import io.github.frostzie.nodex.utils.WindowResizer +import io.github.frostzie.nodex.utils.dev.DebugManager +import javafx.scene.layout.Pane +import io.github.frostzie.nodex.utils.CSSManager +import io.github.frostzie.nodex.utils.WindowDrag +import io.github.frostzie.nodex.utils.UIConstants +import javafx.application.Platform +import javafx.scene.Scene +import javafx.scene.image.Image +import javafx.scene.layout.BorderPane +import javafx.scene.layout.HBox +import javafx.scene.layout.Priority +import javafx.scene.control.SplitPane +import javafx.scene.layout.StackPane +import javafx.stage.Stage +import javafx.stage.StageStyle +import io.github.frostzie.nodex.settings.categories.ThemeConfig +import io.github.frostzie.nodex.utils.ThemeUtils +import io.github.frostzie.nodex.config.LayoutManager + +class MainApplication { + + companion object { + private val logger = LoggerProvider.getLogger("MainApplication") + private var primaryStage: Stage? = null + private var fxInitialized = false + + // UI Components + private var topBarView: TopBarView? = null + private var leftBarView: LeftBarView? = null + private var fileTreeView: FileTreeView? = null + private var bottomBarView: BottomBarView? = null + private var settingsView: SettingsView? = null + private var textEditorView: TextEditorView? = null + private var contentArea: SplitPane? = null + + // View Containers + private var projectManagerView: ProjectManagerView? = null + private var ideLayout: BorderPane? = null + private var rootContainer: StackPane? = null + + // Modules and Handlers + private var topBarViewModel: TopBarViewModel? = null + private var topBarHandler: TopBarHandler? = null + + private var leftBarModule: LeftBarModule? = null + private var leftBarHandler: LeftBarHandler? = null + + private var bottomBarModule: BottomBarModule? = null + private var bottomBarHandler: BottomBarHandler? = null + + private var settingsModule: SettingsModule? = null + private var settingsHandler: SettingsHandler? = null + + private var filePopupModule: FilePopupModule? = null + private var filePopupHandler: FilePopupHandler? = null + + private var themeModule: ThemeModule? = null + private var themeHandler: ThemeHandler? = null + + // Drag cleanup callback + private var dragCleanup: (() -> Unit)? = null + + private val workspaceHandler = object { + @SubscribeEvent + fun onWorkspaceUpdated(event: WorkspaceUpdated) { + JavaFXInitializer.runLater { + updateMainView() + } + } + } + + fun initializeJavaFX() { + + if (fxInitialized) return + fxInitialized = true + + System.setProperty("javafx.allowSystemPropertiesAccess", "true") + System.setProperty("prism.allowhidpi", "false") + + try { + JavaFXInitializer.startup { + ThemeUtils.applyTheme(ThemeConfig.theme.get()) + ThemeConfig.theme.addListener { _, _, newTheme -> ThemeUtils.applyTheme(newTheme) } + ThemeConfig.fontSize.addListener { _, _, _ -> + primaryStage?.scene?.root?.style = "-fx-font-size: ${ThemeConfig.fontSize.get()}px;" + } + JavaFXInitializer.setImplicitExit(false) + createMainWindow() + logger.info("JavaFX Platform initialized and main window pre-created!") + } + } catch (e: Exception) { + // If it's already initialized, JavaFXInitializer.startup handles it and runs the runnable + // This is only if truly bad + logger.error("Failed to initialize JavaFX", e) + } + } + + private fun createMainUI(stage: Stage): Pane { + // IDE Layout Construction + ideLayout = BorderPane() + stage.icons.add(Image("assets/nodex/icon.png")) + + topBarViewModel = TopBarViewModel(stage) + topBarView = TopBarView(topBarViewModel!!) + topBarHandler = TopBarHandler(topBarViewModel!!) + + leftBarView = LeftBarView() + leftBarModule = LeftBarModule(stage) + leftBarHandler = LeftBarHandler(leftBarModule!!) + + bottomBarView = BottomBarView() + bottomBarModule = BottomBarModule + bottomBarHandler = BottomBarHandler(bottomBarModule!!) + + textEditorView = TextEditorView() + fileTreeView = FileTreeView() + + themeModule = ThemeModule() + themeHandler = ThemeHandler(themeModule!!) + + settingsModule = SettingsModule(stage) + settingsHandler = SettingsHandler(settingsModule!!) + + filePopupModule = FilePopupModule(stage) + filePopupHandler = FilePopupHandler(filePopupModule!!) + + setupEventHandlers() + + val defaultDividerPosition = UIConstants.FILE_TREE_DEFAULT_WIDTH / (UIConstants.DEFAULT_WINDOW_WIDTH - UIConstants.LEFT_BAR_WIDTH) + + contentArea = SplitPane().apply { + items.addAll(fileTreeView, textEditorView) + + setDividerPosition(0, defaultDividerPosition) + + SplitPane.setResizableWithParent(fileTreeView, false) + SplitPane.setResizableWithParent(textEditorView, true) + } + + var lastDividerPosition = defaultDividerPosition + fileTreeView!!.viewModel.isVisible.addListener { _, _, isVisible -> + val splitPane = contentArea ?: return@addListener + if (isVisible) { + if (!splitPane.items.contains(fileTreeView)) { + splitPane.items.add(0, fileTreeView) + Platform.runLater { splitPane.setDividerPositions(lastDividerPosition) } + } + } else { + if (splitPane.items.contains(fileTreeView)) { + if (splitPane.dividers.isNotEmpty()) lastDividerPosition = splitPane.dividers[0].position + splitPane.items.remove(fileTreeView) + } + } + } + + val centerContent = HBox().apply { + children.addAll(leftBarView, contentArea) + HBox.setHgrow(contentArea, Priority.ALWAYS) + } + + ideLayout!!.top = topBarView + ideLayout!!.center = centerContent + ideLayout!!.bottom = bottomBarView + + // Start Screen Construction + projectManagerView = ProjectManagerView() + + // Root Container + rootContainer = StackPane() + rootContainer!!.styleClass.add("window") + // Wrap in modal pane stack + val rootStack = StackPane(rootContainer!!, NotificationMessageArea) + setupStageDimensions(stage, rootContainer!!) + + // The resizable wrapper should wrap the entire root stack + val resizableWrapper = WindowResizer.install(stage, rootStack, ::saveCurrentLayout) + DebugManager.initialize(resizableWrapper) + + return resizableWrapper + } + + private fun saveCurrentLayout() { + val stage = primaryStage ?: return + + if (stage.isIconified || topBarViewModel?.isMaximized == true) { + return + } + + val isProjectManagerView = rootContainer?.children?.getOrNull(0) == projectManagerView + if (isProjectManagerView) { + LayoutManager.config.projectManagerViewX = stage.x + LayoutManager.config.projectManagerViewY = stage.y + LayoutManager.config.projectManagerViewWidth = stage.width + LayoutManager.config.projectManagerViewHeight = stage.height + } else { + LayoutManager.config.x = stage.x + LayoutManager.config.y = stage.y + LayoutManager.config.width = stage.width + LayoutManager.config.height = stage.height + } + LayoutManager.save() + } + + private fun updateMainView() { + if (rootContainer == null || primaryStage == null) return + + val stage = primaryStage!! + val hasProjects = WorkspaceManager.workspace.projects.isNotEmpty() + logger.debug("Updating Main View. Has Projects: $hasProjects") + + // Clear previous drag listener + dragCleanup?.invoke() + dragCleanup = null + + if (hasProjects) { + if (rootContainer!!.children.isEmpty() || rootContainer!!.children[0] != ideLayout) { + + // Save Start Screen State before switching + if (rootContainer!!.children.isNotEmpty() && rootContainer!!.children[0] == projectManagerView) { + saveCurrentLayout() + } + + rootContainer!!.children.setAll(ideLayout) + + // Apply IDE State + stage.width = LayoutManager.config.width + stage.height = LayoutManager.config.height + if (LayoutManager.config.x != -1.0) { + stage.x = LayoutManager.config.x + stage.y = LayoutManager.config.y + } else { + stage.centerOnScreen() + } + + // Enable Drag for TopBar + dragCleanup = WindowDrag.makeDraggable(stage, topBarView!!, ::saveCurrentLayout) { event -> + topBarView!!.isOverDraggableArea(event) + } + } + } else { + if (rootContainer!!.children.isEmpty() || rootContainer!!.children[0] != projectManagerView) { + + // Save IDE State before switching + if (rootContainer!!.children.isNotEmpty() && rootContainer!!.children[0] == ideLayout) { + saveCurrentLayout() + } + + rootContainer!!.children.setAll(projectManagerView) + + // Apply Start Screen State + stage.width = LayoutManager.config.projectManagerViewWidth + stage.height = LayoutManager.config.projectManagerViewHeight + if (LayoutManager.config.projectManagerViewX != -1.0) { + stage.x = LayoutManager.config.projectManagerViewX + stage.y = LayoutManager.config.projectManagerViewY + } else { + stage.centerOnScreen() + } + + // Enable Drag for Start Screen Header + dragCleanup = WindowDrag.makeDraggable(stage, projectManagerView!!.dragTarget, ::saveCurrentLayout) + } + } + } + + private fun setupStageDimensions(stage: Stage, root: Pane) { + val minContentWidth = UIConstants.MIN_CONTENT_WIDTH + val minContentHeight = UIConstants.MIN_CONTENT_HEIGHT + val maxContentWidth = Double.MAX_VALUE + val maxContentHeight = Double.MAX_VALUE + + val borderWidth = UIConstants.WINDOW_BORDER_WIDTH + val topBarHeight = UIConstants.TOP_BAR_HEIGHT + val statusBarHeight = UIConstants.BOTTOM_BAR_HEIGHT + + root.minWidth = minContentWidth + borderWidth + root.maxWidth = maxContentWidth + borderWidth + root.minHeight = minContentHeight + topBarHeight + statusBarHeight + borderWidth + root.maxHeight = maxContentHeight + topBarHeight + statusBarHeight + borderWidth + + stage.minWidth = root.minWidth + UIConstants.STAGE_BORDER_WIDTH + stage.minHeight = root.minHeight + UIConstants.STAGE_BORDER_WIDTH + stage.maxWidth = root.maxWidth + UIConstants.STAGE_BORDER_WIDTH + stage.maxHeight = root.maxHeight + UIConstants.STAGE_BORDER_WIDTH + + logger.debug("Stage dimensions set: min=${stage.minWidth}x${stage.minHeight}, max=${stage.maxWidth}x${stage.maxHeight}") + } + + private fun setupEventHandlers() { + // EventBus registrations - only for handlers, not views + EventBus.register(topBarHandler!!) + topBarView?.let { EventBus.register(it) } + + EventBus.register(leftBarHandler!!) + leftBarView?.let { EventBus.register(it) } + + EventBus.register(bottomBarHandler!!) + bottomBarView?.let { EventBus.register(it) } + + EventBus.register(settingsHandler!!) + settingsView?.let { EventBus.register(it) } + + EventBus.register(filePopupHandler!!) + + EventBus.register(themeHandler!!) + + EventBus.register(workspaceHandler) + + logger.debug("Event handlers initialized") + } + + fun showMainWindow() { + if (!fxInitialized) { + initializeJavaFX() + return + } + JavaFXInitializer.runLater { + if (primaryStage == null) { + createMainWindow() + } + if (primaryStage?.isIconified == true) { + primaryStage?.isIconified = false + } + primaryStage?.show() + primaryStage?.toFront() + textEditorView?.requestFocus() + logger.info("Main IDE Window shown!") + } + } + + private fun createMainWindow() { + if (primaryStage != null) return + + try { + val stage = Stage() + stage.initStyle(StageStyle.UNDECORATED) + + val mainUI = createMainUI(stage) + mainUI.style = "-fx-font-size: ${ThemeConfig.fontSize.get()}px;" + val scene = Scene(mainUI, LayoutManager.config.width, LayoutManager.config.height) + + CSSManager.applyAllStyles(scene) + themeModule?.scenes?.add(scene) + stage.scene = scene + stage.title = "DataPack IDE" + stage.width = LayoutManager.config.width + stage.height = LayoutManager.config.height + stage.isResizable = true + + if (LayoutManager.config.x > 0 && LayoutManager.config.y > 0) { + stage.x = LayoutManager.config.x + stage.y = LayoutManager.config.y + } else { + stage.centerOnScreen() + } + + stage.setOnCloseRequest { e -> + e.consume() + hideMainWindow() + } + + stage.focusedProperty().addListener { _, _, focused -> + WorkspaceManager.setWindowFocused(focused) + } + + primaryStage = stage + updateMainView() + logger.info("Main IDE Window created with ResizeHandler (hidden)!") + } catch (e: Exception) { + logger.error("Failed to create main window: ${e.message}", e) + } + } + + fun hideMainWindow() { + JavaFXInitializer.runLater { + primaryStage?.takeIf { it.isShowing }?.let { stage -> + saveCurrentLayout() + stage.hide() + logger.debug("Main IDE Window hidden via hideMainWindow()!") + } + } + } + + fun toggleMainWindow() { + if (!fxInitialized) { + logger.info("JavaFX not initialized yet, initializing...") + initializeJavaFX() + return + } + Platform.runLater { + if (primaryStage == null) { + createMainWindow() + } + primaryStage?.let { stage -> + if (stage.isShowing) { + logger.info("Window is showing, hiding it...") + stage.hide() + logger.info("Main IDE Window hidden!") + } else { + stage.show() + stage.toFront() + textEditorView?.requestFocus() + logger.info("Main IDE Window shown from hidden!") + } + } + } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/BottomBarView.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/BottomBarView.kt new file mode 100644 index 000000000..0fbf3f2ba --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/BottomBarView.kt @@ -0,0 +1,54 @@ +package io.github.frostzie.nodex.screen.elements.bars + +import io.github.frostzie.nodex.modules.bars.BottomBarModule +import javafx.scene.control.Label +import javafx.scene.control.Separator +import javafx.scene.control.ToolBar +import javafx.scene.layout.HBox +import javafx.scene.layout.Priority +import javafx.scene.layout.Region + +class BottomBarView() : ToolBar() { + + private val cursorLabel = Label() + private val encodingLabel = Label() + private val ideVersionLabel = Label() + + init { + styleClass.add("status-bar") + createStatusElements() + bindProperties() + } + + private fun createStatusElements() { + cursorLabel.styleClass.add("status-label") + encodingLabel.styleClass.add("status-label") + ideVersionLabel.styleClass.add("status-label") + + val spacer = Region().apply { + HBox.setHgrow(this, Priority.ALWAYS) + styleClass.add("status-spacer") + } + + items.addAll( + cursorLabel, + createSeparator(), + encodingLabel, + createSeparator(), + spacer, + ideVersionLabel + ) + } + + private fun bindProperties() { + cursorLabel.textProperty().bind(BottomBarModule.cursorPositionProperty) + encodingLabel.textProperty().bind(BottomBarModule.encodingProperty) + ideVersionLabel.textProperty().bind(BottomBarModule.ideVersionProperty) + } + + private fun createSeparator(): Separator { + return Separator(javafx.geometry.Orientation.VERTICAL).apply { + styleClass.add("status-separator") + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/LeftBarView.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/LeftBarView.kt new file mode 100644 index 000000000..2a13efdf6 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/LeftBarView.kt @@ -0,0 +1,78 @@ +package io.github.frostzie.nodex.screen.elements.bars + +import atlantafx.base.theme.Styles +import io.github.frostzie.nodex.events.ToggleFileTree +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.utils.LoggerProvider +import io.github.frostzie.nodex.utils.UIConstants +import javafx.geometry.Orientation +import javafx.scene.control.Button +import javafx.scene.control.ToolBar +import javafx.scene.control.Tooltip +import javafx.scene.layout.Priority +import javafx.scene.layout.Region +import javafx.scene.layout.VBox +import org.kordamp.ikonli.Ikon +import org.kordamp.ikonli.feather.Feather +import org.kordamp.ikonli.javafx.FontIcon + +class LeftBarView : ToolBar() { + + companion object { + private val logger = LoggerProvider.getLogger("LeftBarView") + } + + init { + setupLeftBar() + + val directoryChooseButton = createLeftBarButton( + Feather.FOLDER, + "Toggle File Tree" + ) { + EventBus.post(ToggleFileTree()) + } + + val searchButton = createLeftBarButton( + Feather.SEARCH, + "Search" + ) { + logger.warn("Search button not implemented yet!") + }.apply { + isDisable = true + } + + val spacer = Region().apply { + VBox.setVgrow(this, Priority.ALWAYS) + styleClass.add("left-bar-spacer") + } + + items.addAll(directoryChooseButton, searchButton, spacer) + } + + private fun setupLeftBar() { + styleClass.add("left-bar") + orientation = Orientation.VERTICAL + prefWidth = UIConstants.LEFT_BAR_WIDTH + minWidth = UIConstants.LEFT_BAR_WIDTH + maxWidth = UIConstants.LEFT_BAR_WIDTH + logger.debug("Left bar initialized") + } + + private fun createLeftBarButton(icon: Ikon, tooltipText: String, vararg styleClasses: String, action: () -> Unit): Button { + return Button().apply { + graphic = FontIcon(icon) + tooltip = Tooltip(tooltipText) + styleClass.addAll(Styles.FLAT, *styleClasses) + + val size = UIConstants.TOP_BAR_BUTTON_SIZE + prefWidth = size + minWidth = size + maxWidth = size + prefHeight = size + minHeight = size + maxHeight = size + + setOnAction { action() } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/top/TopBarView.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/top/TopBarView.kt new file mode 100644 index 000000000..4704c1667 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/bars/top/TopBarView.kt @@ -0,0 +1,253 @@ +package io.github.frostzie.nodex.screen.elements.bars.top + +import atlantafx.base.theme.Styles +import io.github.frostzie.nodex.events.* +import io.github.frostzie.nodex.modules.bars.top.TopBarViewModel +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import io.github.frostzie.nodex.utils.LoggerProvider +import javafx.application.Platform +import javafx.geometry.Insets +import javafx.geometry.Pos +import javafx.scene.control.Button +import javafx.scene.control.Menu +import javafx.scene.control.MenuBar +import javafx.scene.control.MenuItem +import javafx.scene.control.Tooltip +import javafx.scene.input.MouseButton +import javafx.scene.input.MouseEvent +import javafx.scene.layout.HBox +import javafx.scene.layout.Priority +import javafx.scene.layout.Region +import javafx.stage.DirectoryChooser +import org.kordamp.ikonli.Ikon +import org.kordamp.ikonli.feather.Feather +import org.kordamp.ikonli.javafx.FontIcon +import org.kordamp.ikonli.material2.Material2AL +import io.github.frostzie.nodex.utils.UIConstants +import org.kordamp.ikonli.material2.Material2MZ +import org.kordamp.ikonli.material2.Material2OutlinedMZ +import io.github.frostzie.nodex.utils.IconUtils +import io.github.frostzie.nodex.styling.common.IconSource +import io.github.frostzie.nodex.styling.messages.* + +class TopBarView(private val viewModel: TopBarViewModel) : HBox() { + + private val maximizeButton: Button + private val spacer: Region + private val logger = LoggerProvider.getLogger("TopBarView") + + init { + alignment = Pos.CENTER_LEFT + setHgrow(this, Priority.ALWAYS) + prefHeight = UIConstants.TOP_BAR_HEIGHT + minHeight = UIConstants.TOP_BAR_HEIGHT + maxHeight = UIConstants.TOP_BAR_HEIGHT + styleClass.add("top-bar-view") + styleClass.add("menu-bar") + + val appIcon = IconUtils.createIcon(IconSource.SvgIcon("/assets/nodex/svg/icon.svg", 25)) + setMargin(appIcon, Insets(0.0, 8.0, 0.0, 8.0)) + + setOnMouseClicked { event -> + if (event.button == MouseButton.PRIMARY && event.clickCount == 2) { + viewModel.toggleMaximize() + } + } + + spacer = Region().apply { + setHgrow(this, Priority.ALWAYS) + styleClass.add("title-spacer") + } + + val menuBar = createMenuBar() + menuBar.isVisible = false + menuBar.isManaged = false + + val toggleMenuBar = createTopBarButton( + Material2OutlinedMZ.REORDER, + "Toggle Menu Bar", + Styles.BUTTON_OUTLINED + ) { + val newState = !menuBar.isVisible + menuBar.isVisible = newState + menuBar.isManaged = newState + } + + val runDataPackButton = createTopBarButton( + Feather.PLAY, + "Reload Datapack" + ) { + EventBus.post(SaveAllFiles()) + viewModel.reloadDatapacks() + showReloadNotification() + } + //runDataPackButton.isDisable = !WorldDetection.isWorldOpen() + + val settingsButton = createTopBarButton( + Material2MZ.SETTINGS, + "Settings" + ) { + EventBus.post(SettingsWindowOpen()) + } + + val minimizeButton = createTopBarButton( + Material2OutlinedMZ.MINIMIZE, + "Minimize" + ) { + EventBus.post(MainWindowMinimize()) + } + + maximizeButton = createTopBarButton( + Material2AL.CROP_SQUARE, + "Maximize/Restore" + ) { + EventBus.post(MainWindowToggleMaximize()) + } + + val closeButton = createTopBarButton( + Material2AL.CLOSE, + "Close", + Styles.DANGER + ) { + EventBus.post(MainWindowClose()) + } + + updateMaximizeButtonIcon(false) + + children.addAll( + appIcon, + toggleMenuBar, + menuBar, + spacer, + runDataPackButton, + settingsButton, + minimizeButton, + maximizeButton, + closeButton + ) + } + + private fun createMenuBar(): MenuBar { + val menuBar = MenuBar() + menuBar.menus.addAll( + + Menu("File", null, + MenuItem("Import").apply { setOnAction { + val dc = DirectoryChooser() + dc.title = "Import Project" + val selected = dc.showDialog(scene.window) + if (selected != null) { + viewModel.importProject(selected.toPath()) + } + } }, + MenuItem("New Project").apply { setOnAction { logger.warn("New Project button not implemented yet! ") }; isDisable = true }, //TODO: Enabled when create project added + MenuItem("Close Project").apply { setOnAction { + EventBus.post(SaveAllFiles()) + EventBus.post(OpenProjectManagerEvent()) + } }, + MenuItem("Save All").apply { setOnAction { EventBus.post(SaveAllFiles()) } }, + MenuItem("Exit").apply { setOnAction { EventBus.post(MainWindowClose()) } } + ), + + Menu("Edit", null, + MenuItem("Undo Typing").apply { setOnAction { EventBus.post(EditorUndo()) } }, + MenuItem("Redo Typing").apply { setOnAction { EventBus.post(EditorRedo()) } }, + MenuItem("Cut").apply { setOnAction { EventBus.post(EditorCut()) } }, + MenuItem("Copy").apply { setOnAction { EventBus.post(EditorCopy()) } }, + MenuItem("Paste").apply { setOnAction { EventBus.post(EditorPaste()) } }, + MenuItem("Find").apply { setOnAction { EventBus.post(EditorFind()) }; isDisable = true }, + MenuItem("Select All").apply { setOnAction { EventBus.post(EditorSelectAll()) } } + ), + + Menu("Build", null, + MenuItem("Reload Datapack").apply { + setOnAction { + EventBus.post(SaveAllFiles()) + viewModel.reloadDatapacks() + showReloadNotification() + } + //isDisable = !WorldDetection.isWorldOpen() + }, + MenuItem("Zip Datapack").apply { setOnAction { logger.warn("Zip Datapack button not implemented yet!") }; isDisable = true }, + MenuItem("Open Folder").apply { setOnAction { EventBus.post(OpenWorkspaceFolder()) } } + ), + + Menu("Help", null, + MenuItem("Discord").apply { setOnAction { EventBus.post(DiscordLink()) } }, + MenuItem("Github").apply { setOnAction { EventBus.post(GitHubLink()) } }, + MenuItem("Report a Bug").apply { setOnAction { EventBus.post(ReportBugLink()) } } + ) + ) + return menuBar + } + + /** + * Check if a mouse event is over a draggable area (spacer or empty toolbar space) + * This method is used by DragForwarding utility. + */ + fun isOverDraggableArea(event: MouseEvent): Boolean { + val target = event.target + + if (target == spacer || (target as? Region)?.styleClass?.contains("title-spacer") == true) { + return true + } + + if (target == this) { + val localPoint = sceneToLocal(event.sceneX, event.sceneY) + for (item in children) { + if (item is Region && item != spacer) { + val bounds = item.boundsInParent + if (bounds.contains(localPoint)) { + return false + } + } + } + return true + } + + return false + } + + //TODO: only show when world is open also disable buttons when world isn't opened + private fun showReloadNotification() { + MessageFactory.createAndShow( + title = "Reloaded", + description = "Datapack Reloaded!", + severity = MessageSeverity.SUCCESS, + position = NotificationPosition.BOTTOM_RIGHT, + icon = IconSource.IkonIcon(Material2OutlinedMZ.REFRESH), + maxMessages = 1, + durationMillis = 2500 + ) + } + + private fun createTopBarButton(icon: Ikon, tooltipText: String, vararg styleClasses: String, action: () -> Unit): Button { + return Button().apply { + graphic = FontIcon(icon) + tooltip = Tooltip(tooltipText) + styleClass.addAll(Styles.FLAT, *styleClasses) + + val size = UIConstants.TOP_BAR_BUTTON_SIZE + prefWidth = size + minWidth = size + maxWidth = size + prefHeight = size + minHeight = size + maxHeight = size + + setOnAction { action() } + } + } + + private fun updateMaximizeButtonIcon(maximized: Boolean) { + maximizeButton.graphic = FontIcon(if (maximized) Material2AL.FILTER_NONE else Material2AL.CROP_SQUARE) + } + + @Suppress("unused") + @SubscribeEvent + fun onWindowStateChanged(event: MainWindowMaximizedStateChanged) { + Platform.runLater { + updateMaximizeButtonIcon(event.isMaximized) + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/FileTreeItem.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/FileTreeItem.kt new file mode 100644 index 000000000..aa8f08172 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/FileTreeItem.kt @@ -0,0 +1,19 @@ +package io.github.frostzie.nodex.screen.elements.main + +import java.nio.file.Path + +/** + * A simple data class representing a single item in the file tree. + */ +data class FileTreeItem( + val path: Path, + val displayName: String +) { + /** + * Overriding toString() provides a default, human-readable name + * that the TreeView can display. Use the custom displayName here. + */ + override fun toString(): String { + return displayName + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/FileTreeView.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/FileTreeView.kt new file mode 100644 index 000000000..319345ee4 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/FileTreeView.kt @@ -0,0 +1,167 @@ +package io.github.frostzie.nodex.screen.elements.main + +import atlantafx.base.theme.Tweaks +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.OpenFile +import io.github.frostzie.nodex.events.RequestMoveConfirmation +import io.github.frostzie.nodex.modules.main.FileTreeViewModel +import io.github.frostzie.nodex.project.WorkspaceManager +import io.github.frostzie.nodex.settings.categories.MainConfig +import io.github.frostzie.nodex.settings.categories.ThemeConfig +import io.github.frostzie.nodex.utils.UIConstants +import javafx.beans.InvalidationListener +import javafx.scene.control.TreeCell +import javafx.scene.control.TreeView +import javafx.scene.input.ClipboardContent +import javafx.scene.input.DataFormat +import javafx.scene.input.DragEvent +import javafx.scene.input.MouseButton +import javafx.scene.input.TransferMode +import javafx.scene.layout.Priority +import javafx.scene.layout.VBox +import java.nio.file.Path +import org.kordamp.ikonli.javafx.FontIcon +import org.kordamp.ikonli.material2.Material2AL +import kotlin.io.path.isDirectory + +/** + * The View for the file tree. This class is responsible for displaying the tree. + */ +class FileTreeView : VBox() { + internal val viewModel = FileTreeViewModel() + private val treeView = TreeView() + + // A custom DataFormat used to identify drag-and-drop operations initiated from this file tree. + // This ensures that the tree only handles drops that it originated, preventing conflicts with + // other drag-and-drop sources. The string is a unique identifier, conventionally using a package name format. + private val dragDataFormat = DataFormat("io.github.frostzie.nodex.FileTreeItem") + + init { + styleClass.add("file-tree-container") + + prefWidth = UIConstants.FILE_TREE_DEFAULT_WIDTH + minWidth = UIConstants.FILE_TREE_MIN_WIDTH + maxWidth = UIConstants.FILE_TREE_MAX_WIDTH + setVgrow(treeView, Priority.ALWAYS) + children.add(treeView) + + visibleProperty().bind(viewModel.isVisible) + managedProperty().bind(viewModel.isVisible) + + treeView.rootProperty().bind(viewModel.root) + treeView.styleClass.add(Tweaks.EDGE_TO_EDGE) + treeView.styleClass.add(Tweaks.ALT_ICON) + + MainConfig.showFileIcons.addListener { _ -> treeView.refresh() } + + treeView.isShowRoot = false + + // A cell factory is used to customize each cell in the tree. This includes setting up + // mouse click listeners for opening files and handling all drag-and-drop gestures. + treeView.setCellFactory { _ -> + object : TreeCell() { + private val invalidationListener = InvalidationListener { updateStyle() } + + init { + WorkspaceManager.dirtyFiles.addListener(invalidationListener) + MainConfig.dirtyFileColor.addListener(invalidationListener) + + setOnMouseClicked { event -> + if (event.button == MouseButton.PRIMARY && event.clickCount == 2) { + val currentItem = item ?: return@setOnMouseClicked + if (!currentItem.path.isDirectory()) { + EventBus.post(OpenFile(currentItem.path)) + } + } + } + + setOnDragDetected { event -> + if (item == null) return@setOnDragDetected + val db = startDragAndDrop(TransferMode.MOVE) + val content = ClipboardContent() + content[dragDataFormat] = item.path.toString() + db.setContent(content) + event.consume() + } + + setOnDragOver { event -> + if (isValidDropTarget(event)) { + event.acceptTransferModes(TransferMode.MOVE) + } + event.consume() + } + + setOnDragEntered { event -> + if (isValidDropTarget(event)) { + styleClass.add("drag-over") + } + } + + setOnDragExited { + styleClass.remove("drag-over") + } + + setOnDragDropped { event -> + styleClass.remove("drag-over") + val db = event.dragboard + var success = false + if (db.hasContent(dragDataFormat)) { + val targetItem = item ?: return@setOnDragDropped + val sourcePath = Path.of(db.getContent(dragDataFormat) as String) + val targetPath = targetItem.path.resolve(sourcePath.fileName) + + EventBus.post(RequestMoveConfirmation(sourcePath, targetPath)) + success = true + } + event.isDropCompleted = success + event.consume() + } + } + + override fun updateItem(item: FileTreeItem?, empty: Boolean) { + super.updateItem(item, empty) + + if (empty || item == null) { + text = null + graphic = null + style = "" // Reset style + } else { + text = item.toString() + graphic = if (MainConfig.showFileIcons.get()) { + val iconCode = if (item.path.isDirectory()) Material2AL.FOLDER else Material2AL.DESCRIPTION + FontIcon(iconCode).apply { iconSize = ThemeConfig.fontSize.get() } + } else { + null + } + updateStyle() + } + } + + private fun updateStyle() { + if (isEmpty || item == null) { + style = "" + return + } + + style = if (item.path in WorkspaceManager.dirtyFiles) { + "-fx-text-fill: ${MainConfig.dirtyFileColor.get()};" + } else { + "" // Reset to default + } + } + + private fun isValidDropTarget(event: DragEvent): Boolean { + if (event.gestureSource == this || !event.dragboard.hasContent(dragDataFormat)) { + return false + } + + val targetItem = item ?: return false + val sourcePath = Path.of(event.dragboard.getContent(dragDataFormat) as String) + + // Valid if the target is a directory, not the source itself, and not a child of the source + return targetItem.path.isDirectory() && sourcePath != targetItem.path && !targetItem.path.startsWith(sourcePath) + } + } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/TextEditorView.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/TextEditorView.kt new file mode 100644 index 000000000..152e5730c --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/main/TextEditorView.kt @@ -0,0 +1,367 @@ +package io.github.frostzie.nodex.screen.elements.main + +import atlantafx.base.controls.Tab +import atlantafx.base.controls.TabLine +import atlantafx.base.theme.Styles +import io.github.frostzie.nodex.features.FeatureRegistry +import io.github.frostzie.nodex.modules.main.TextEditorViewModel +import io.github.frostzie.nodex.RichJsonFX +import io.github.frostzie.nodex.settings.categories.ThemeConfig +import io.github.frostzie.nodex.utils.LoggerProvider +import javafx.animation.PauseTransition +import javafx.application.Platform +import javafx.beans.InvalidationListener +import javafx.beans.value.ChangeListener +import javafx.collections.ListChangeListener +import javafx.geometry.Pos +import javafx.scene.control.Label +import javafx.scene.control.Tooltip +import javafx.scene.layout.HBox +import javafx.scene.layout.Region +import javafx.scene.layout.Priority +import javafx.scene.layout.StackPane +import javafx.scene.layout.VBox +import org.fxmisc.flowless.VirtualizedScrollPane +import org.fxmisc.richtext.CodeArea +import org.fxmisc.richtext.LineNumberFactory +import org.kordamp.ikonli.javafx.FontIcon +import org.kordamp.ikonli.material2.Material2AL +import javafx.util.Duration +import java.nio.file.Path + +/** + * View for the text editor that displays multiple tabs using AtlantaFX TabLine. + * Each tab contains a CodeArea with the text editor. + */ +class TextEditorView : VBox() { + + companion object { + private val logger = LoggerProvider.getLogger("TextEditorView") + } + + internal val viewModel = TextEditorViewModel() + private val tabLine = TabLine() + private val contentArea = StackPane() + private val decoratorCleanups = mutableMapOf Unit>>() + private val tabCodeAreas = mutableMapOf() + private var currentThemeStyleSheet: String? = null + + init { + styleClass.add("text-editor-container") + + setupTabLine() + setupContentArea() + setupListeners() + setupThemeListener() + + children.addAll(tabLine, contentArea) + setVgrow(contentArea, Priority.ALWAYS) + } + + // Temp for Json syntax + private fun setupThemeListener() { + val updateListener = InvalidationListener { updateThemeColors() } + ThemeConfig.jsonStartObjectColor.addListener(updateListener) + ThemeConfig.jsonPropertyColor.addListener(updateListener) + ThemeConfig.jsonStringColor.addListener(updateListener) + ThemeConfig.jsonArrayColor.addListener(updateListener) + ThemeConfig.jsonFloatColor.addListener(updateListener) + ThemeConfig.jsonIntColor.addListener(updateListener) + ThemeConfig.jsonNullColor.addListener(updateListener) + ThemeConfig.jsonEmbeddedColor.addListener(updateListener) + ThemeConfig.jsonTrueColor.addListener(updateListener) + ThemeConfig.jsonFalseColor.addListener(updateListener) + + updateThemeColors() + } + + private fun updateThemeColors() { + val css = """ + .code-area .json-start-object, .code-area .json-end-object { -fx-fill: ${ThemeConfig.jsonStartObjectColor.get()}; } + .code-area .json-property { -fx-fill: ${ThemeConfig.jsonPropertyColor.get()}; } + .code-area .json-string { -fx-fill: ${ThemeConfig.jsonStringColor.get()}; } + .code-area .json-start-array, .code-area .json-end-array { -fx-fill: ${ThemeConfig.jsonArrayColor.get()}; } + .code-area .json-float { -fx-fill: ${ThemeConfig.jsonFloatColor.get()}; } + .code-area .json-int { -fx-fill: ${ThemeConfig.jsonIntColor.get()}; } + .code-area .json-null { -fx-fill: ${ThemeConfig.jsonNullColor.get()}; } + .code-area .json-embedded { -fx-fill: ${ThemeConfig.jsonEmbeddedColor.get()}; } + .code-area .json-true { -fx-fill: ${ThemeConfig.jsonTrueColor.get()}; } + .code-area .json-false { -fx-fill: ${ThemeConfig.jsonFalseColor.get()}; } + """.trimIndent() + + val dataUri = "data:text/css;base64," + java.util.Base64.getEncoder().encodeToString(css.toByteArray()) + + if (currentThemeStyleSheet != null) { + this.stylesheets.remove(currentThemeStyleSheet) + } + + this.stylesheets.add(dataUri) + currentThemeStyleSheet = dataUri + } + + /** + * Configures the TabLine with AtlantaFX styles and policies + */ + private fun setupTabLine() { + tabLine.styleClass.add(Styles.TABS_BORDER_TOP) + tabLine.animated = false // No more PowerPoint Animation yeay lol + tabLine.setTabDragPolicy(Tab.DragPolicy.REORDER) + tabLine.setTabResizePolicy(Tab.ResizePolicy.COMPUTED_WIDTH) + tabLine.setTabClosingPolicy(Tab.ClosingPolicy.SELECTED_TAB) //TODO: Possibly? add hover closing + } + + private fun setupContentArea() { + contentArea.styleClass.add("editor-content-area") + setVgrow(contentArea, Priority.ALWAYS) + } + + /** + * Sets up listeners for ViewModel changes + */ + private fun setupListeners() { + // Listen for new tabs being added + viewModel.tabs.addListener { change: ListChangeListener.Change -> + while (change.next()) { + // Handle removals first. In a replacement event (like file move), wasRemoved and wasAdded are both true. + // We must remove the old tab first to avoid ID conflicts or index shifting issues. + if (change.wasRemoved()) { + change.removed.forEach { tabData -> + removeTab(tabData) + } + } + if (change.wasAdded()) { + var index = change.from + change.addedSubList.forEach { tabData -> + addTab(tabData, index) + index++ + } + } + } + } + + // Listen for active tab changes + viewModel.activeTab.addListener { _, _, newTab -> + if (newTab != null) { + switchToTab(newTab) + } else { + contentArea.children.clear() + } + } + + // Listen for tab selection changes in the UI + tabLine.selectionModel.selectedItemProperty().addListener { _, _, newTab -> + if (newTab != null) { + val tabData = viewModel.tabs.find { it.id == newTab.id } + if (tabData != null && viewModel.activeTab.get() != tabData) { + viewModel.activeTab.set(tabData) + } + } + } + + // Listen for editor actions (Undo, Redo, etc.) + viewModel.currentAction.addListener { _, _, action -> + if (action != null) { + handleEditorAction(action) + Platform.runLater { viewModel.currentAction.set(null) } + } + } + } + + /** + * Adds a new tab to the TabLine for the given TabData + */ + private fun addTab(tabData: TextEditorViewModel.TabData, index: Int = -1) { + // Create CodeArea and binding logic + val codeArea = CodeArea(tabData.content.get()) + codeArea.paragraphGraphicFactory = LineNumberFactory.get(codeArea) + codeArea.styleClass.add("code-area") + + // Temp for json syntax + if (isJsonFile(tabData.filePath)) { + setupJsonSyntaxHighlighting(codeArea) + } + + // Listener to sync CodeArea -> ViewModel (User typing) + val textListener = InvalidationListener { + if (codeArea.text != tabData.content.get()) { + tabData.content.set(codeArea.text) + if (!tabData.isDirty.get()) { + tabData.isDirty.set(true) + } + } + } + codeArea.textProperty().addListener(textListener) + + // Listener to sync ViewModel -> CodeArea (External reload) + val contentListener = InvalidationListener { + if (codeArea.text != tabData.content.get()) { + codeArea.replaceText(tabData.content.get()) + } + } + tabData.content.addListener(contentListener) + + // Listener for caret position + val caretListener = InvalidationListener { + if (viewModel.activeTab.get() == tabData) { + viewModel.updateLineAndColumn(codeArea.currentParagraph + 1, codeArea.caretColumn + 1) + } + } + codeArea.caretPositionProperty().addListener(caretListener) + + tabCodeAreas[tabData.id] = codeArea + + // Create a custom graphic for the tab content, allowing direct access to the label for styling + val tabLabel = Label(tabData.displayName) + val tabIcon = FontIcon(Material2AL.FOLDER) + + // An invisible placeholder that reserves space for the close button. + // The width is an estimate of the close button's size. If you find a more exact one, change pls. + val closeButtonPlaceholder = Region().apply { + prefWidth = 22.0 + } + + val graphic = HBox(tabIcon, tabLabel, closeButtonPlaceholder).apply { + alignment = Pos.CENTER_LEFT + spacing = 5.0 // Space between icon and label + } + + // We pass null for text and use our custom graphic instead + val tab = Tab(tabData.id, null, graphic) + tab.tooltip = Tooltip(tabData.filePath.toString()) + + // When the tab is selected, the close button appears, so we hide the placeholder. + // When it's deselected, we show the placeholder to keep the tab width consistent. + val selectionListener = ChangeListener { _, _, isSelected -> + closeButtonPlaceholder.isVisible = !isSelected + closeButtonPlaceholder.isManaged = !isSelected + } + tab.selectedProperty().addListener(selectionListener) + + val cleanups = mutableListOf<() -> Unit>() + FeatureRegistry.editorTabDecorators.forEach { decorator -> + cleanups.add(decorator.decorate(tab, codeArea, tabData)) + } + decoratorCleanups[tabData.id] = cleanups + + tab.setOnCloseRequest { event -> + tab.selectedProperty().removeListener(selectionListener) // Clean up listener + viewModel.closeTab(tabData) + event.consume() + } + + if (index >= 0 && index <= tabLine.tabs.size) { + tabLine.tabs.add(index, tab) + } else { + tabLine.tabs.add(tab) + } + tabLine.selectionModel.select(tab) + + logger.debug("Added tab: ${tabData.displayName}, ID: ${tabData.id}") + } + + // Temp Json syntax highlighting + private fun isJsonFile(path: Path): Boolean { + val fileName = path.fileName.toString().lowercase() + return fileName.endsWith(".json") || fileName.endsWith(".mcmeta") + } + + private fun setupJsonSyntaxHighlighting(codeArea: CodeArea) { + val highlighter = RichJsonFX() + val debounce = PauseTransition(Duration.millis(300.0)) + + fun applyHighlighting() { + try { + highlighter.highlightCodeArea(codeArea) + } catch (e: Exception) { + // Ignore parsing errors while typing (e.g. incomplete JSON) + } + } + + debounce.setOnFinished { applyHighlighting() } + + codeArea.textProperty().addListener { _, _, _ -> + debounce.playFromStart() + } + + // Initial highlight + applyHighlighting() + } + + /** + * Removes a tab from the TabLine + */ + private fun removeTab(tabData: TextEditorViewModel.TabData) { + tabLine.tabs.find { it.id == tabData.id }?.let { + tabLine.tabs.remove(it) + } + + tabCodeAreas.remove(tabData.id) + + // Execute and remove all cleanup functions associated with the closed tab + decoratorCleanups.remove(tabData.id)?.forEach { cleanup -> + try { + cleanup() + } catch (e: Exception) { + logger.error("Error during tab decorator cleanup for ${tabData.displayName}", e) + } + } + logger.debug("Removed tab and cleaned up decorators: ${tabData.displayName}, ID: ${tabData.id}") + } + + /** + * Switches the content area to display the CodeArea for the given tab + */ + private fun switchToTab(tabData: TextEditorViewModel.TabData) { + contentArea.children.clear() + val codeArea = tabCodeAreas[tabData.id] ?: return + + contentArea.children.add(VirtualizedScrollPane(codeArea)) + + // Update status bar for this tab + viewModel.updateLineAndColumn(codeArea.currentParagraph + 1, codeArea.caretColumn + 1) + + // Select the corresponding tab in the TabLine + val tab = tabLine.tabs.find { it.id == tabData.id } + if (tab != null && tabLine.selectionModel.selectedItem != tab) { + tabLine.selectionModel.select(tab) + } + + Platform.runLater { + codeArea.requestFocus() + } + + logger.debug("Switched to tab: ${tabData.displayName}, ID: ${tabData.id}") + } + + private fun handleEditorAction(action: TextEditorViewModel.EditorAction) { + val activeTab = viewModel.activeTab.get() ?: return + val codeArea = tabCodeAreas[activeTab.id] ?: return + + when (action) { + TextEditorViewModel.EditorAction.UNDO -> codeArea.undo() + TextEditorViewModel.EditorAction.REDO -> codeArea.redo() + TextEditorViewModel.EditorAction.CUT -> codeArea.cut() + TextEditorViewModel.EditorAction.COPY -> codeArea.copy() + TextEditorViewModel.EditorAction.PASTE -> codeArea.paste() + TextEditorViewModel.EditorAction.SELECT_ALL -> codeArea.selectAll() + } + codeArea.requestFocus() + } + + /** + * Request focus for the active tab's CodeArea + */ + override fun requestFocus() { + super.requestFocus() + val active = viewModel.activeTab.get() ?: return + tabCodeAreas[active.id]?.requestFocus() + } + + /** + * Cleanup method + */ + fun cleanup() { + viewModel.cleanup() + logger.info("TextEditorView closed") + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/file/FileOverrideDialogView.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/file/FileOverrideDialogView.kt new file mode 100644 index 000000000..e9360d87b --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/file/FileOverrideDialogView.kt @@ -0,0 +1,31 @@ +package io.github.frostzie.nodex.screen.elements.popup.file + +import io.github.frostzie.nodex.modules.popup.file.FileOverrideDialogViewModel +import javafx.scene.control.Alert +import javafx.scene.control.ButtonBar +import javafx.scene.control.ButtonType +import javafx.stage.Stage + +class FileOverrideDialogView( + private val viewModel: FileOverrideDialogViewModel, + private val parentStage: Stage? +) { + fun show() { + val alert = Alert(Alert.AlertType.CONFIRMATION) + alert.title = "File Conflict" + alert.headerText = viewModel.headerText.get() + alert.contentText = viewModel.contentText.get() + alert.initOwner(parentStage) + + val overrideButton = ButtonType("Override", ButtonBar.ButtonData.OK_DONE) + val cancelButton = ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE) + + alert.buttonTypes.setAll(overrideButton, cancelButton) + alert.dialogPane.styleClass.add("file-override-dialog") + + val result = alert.showAndWait() + if (result.isPresent && result.get() == overrideButton) { + viewModel.confirm() + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/file/MoveConfirmationView.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/file/MoveConfirmationView.kt new file mode 100644 index 000000000..edfd6d72b --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/file/MoveConfirmationView.kt @@ -0,0 +1,91 @@ +package io.github.frostzie.nodex.screen.elements.popup.file + +import io.github.frostzie.nodex.modules.popup.file.MoveConfirmationViewModel +import io.github.frostzie.nodex.utils.UIConstants +import javafx.geometry.Pos +import javafx.scene.control.Alert +import javafx.scene.control.ButtonBar +import javafx.scene.control.ButtonType +import javafx.scene.control.Dialog +import javafx.scene.control.Label +import javafx.scene.control.OverrunStyle +import javafx.scene.control.TextField +import javafx.scene.layout.HBox +import javafx.scene.layout.Priority +import javafx.scene.layout.VBox +import javafx.stage.Stage + +//TODO: Make this actually look okay lol +class MoveConfirmationView( + private val viewModel: MoveConfirmationViewModel, + private val parentStage: Stage? +) { + fun show() { + val dialog = Dialog() + dialog.title = "Confirm Move" + dialog.initOwner(parentStage) + + dialog.dialogPane.prefWidth = UIConstants.MOVE_CONFIRMATION_WIDTH + dialog.dialogPane.prefHeight = UIConstants.MOVE_CONFIRMATION_HEIGHT + dialog.dialogPane.styleClass.add("move-confirmation-dialog") + + val sourceLabel = Label() + sourceLabel.textProperty().bind(viewModel.sourcePathLabel) + sourceLabel.textOverrun = OverrunStyle.CENTER_ELLIPSIS + + val targetDirTextField = TextField() + targetDirTextField.textProperty().bindBidirectional(viewModel.targetDirectory) + + val targetDirHBox = HBox(10.0).apply { + alignment = Pos.CENTER_LEFT + children.addAll( + Label("To directory:"), + targetDirTextField + ) + HBox.setHgrow(targetDirTextField, Priority.ALWAYS) + } + + val content = VBox(10.0).apply { + alignment = Pos.CENTER_LEFT + children.addAll( + sourceLabel, + targetDirHBox + ) + } + dialog.dialogPane.content = content + + val confirmButton = ButtonType("Confirm", ButtonBar.ButtonData.OK_DONE) // For making pressing Enter confirm + val cancelButton = ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE) // For making pressing Esc cancel + dialog.dialogPane.buttonTypes.addAll(confirmButton, cancelButton) + + viewModel.error.addListener { _, _, newValue -> + if (!newValue.isNullOrBlank()) { + showError(newValue) + } + } + + dialog.setResultConverter { button -> + if (button == confirmButton) { + if (viewModel.confirm()) { + return@setResultConverter button + } else { + return@setResultConverter null + } + } + null + } + + dialog.showAndWait() + } + + // TODO: Improve / refactor instead of error in most cases + private fun showError(message: String) { + val alert = Alert(Alert.AlertType.ERROR).apply { + title = "Invalid Path" + headerText = null + contentText = message + initOwner(parentStage) + } + alert.showAndWait() + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsContent.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsContent.kt new file mode 100644 index 000000000..644d79852 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsContent.kt @@ -0,0 +1,107 @@ +package io.github.frostzie.nodex.screen.elements.popup.settings + +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.SettingsContentUpdate +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import io.github.frostzie.nodex.settings.data.ConfigField +import io.github.frostzie.nodex.utils.ui.SettingsControlBuilder +import javafx.geometry.Pos +import javafx.scene.control.Label +import javafx.scene.control.ScrollPane +import javafx.scene.layout.HBox +import javafx.scene.layout.HBox.setHgrow +import javafx.scene.layout.Priority +import javafx.scene.layout.VBox + +class SettingsContent : VBox() { + init { + styleClass.add("settings-content-area") + setVgrow(this, Priority.ALWAYS) + setHgrow(this, Priority.ALWAYS) + EventBus.register(this) + } + + @Suppress("unused") + @SubscribeEvent + fun onContentUpdate(event: SettingsContentUpdate) { + if (event.sections.isEmpty()) { + val noResultsLabel = Label("No results found.").apply { + styleClass.add("no-results-label") + } + val container = VBox(noResultsLabel).apply { + styleClass.add("category-content") + alignment = Pos.CENTER + } + setVgrow(container, Priority.ALWAYS) + children.setAll(container) + return + } + + val content = VBox().apply { + styleClass.add("category-content") + spacing = 15.0 + + val categoryTitle = Label(event.title).apply { + styleClass.add("category-title") + } + children.add(categoryTitle) + + event.sections.forEachIndexed { index, sectionData -> + val section = createSubCategorySection(sectionData.name, sectionData.description, sectionData.fields, event.filterFields) + if (section.children.any { it.isVisible }) { + children.add(section) + } + } + } + + val scrollPane = ScrollPane(content).apply { + styleClass.add("category-scroll") + isFitToWidth = true + hbarPolicy = ScrollPane.ScrollBarPolicy.NEVER + vbarPolicy = ScrollPane.ScrollBarPolicy.AS_NEEDED + } + setVgrow(scrollPane, Priority.ALWAYS) + children.setAll(scrollPane) + } + + + private fun createSubCategorySection(subCategoryName: String, description: String?, fields: List, filterFields: Set?): VBox { + return VBox().apply { + styleClass.add("subcategory-section") + spacing = 15.0 + + val subCategoryHeader = HBox().apply { + styleClass.add("subcategory-header") + + val subTitle = Label(subCategoryName).apply { + styleClass.add("subcategory-title") + } + children.addAll(subTitle) + } + + children.add(subCategoryHeader) + + if (!description.isNullOrBlank()) { + val descLabel = Label(description).apply { + styleClass.add("subcategory-description") + isWrapText = true + } + children.add(descLabel) + } + + val fieldsToShow = if (filterFields != null) fields.filter { it in filterFields } else fields + + val fieldVBox = VBox().apply { + styleClass.add("fields-container") + } + fieldsToShow.forEach { field -> + fieldVBox.children.add(SettingsControlBuilder.createSettingTile(field)) + } + children.add(fieldVBox) + + val hasVisibleFields = fieldsToShow.isNotEmpty() + this.isVisible = hasVisibleFields + this.isManaged = hasVisibleFields + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsNav.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsNav.kt new file mode 100644 index 000000000..b32f25591 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsNav.kt @@ -0,0 +1,170 @@ +package io.github.frostzie.nodex.screen.elements.popup.settings + +import io.github.frostzie.nodex.events.* +import io.github.frostzie.nodex.settings.SettingsManager +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import io.github.frostzie.nodex.settings.data.CategoryItem +import io.github.frostzie.nodex.settings.data.CategoryType +import io.github.frostzie.nodex.utils.UIConstants +import javafx.application.Platform +import javafx.beans.value.ChangeListener +import javafx.scene.control.TextField +import javafx.scene.control.TextFormatter +import javafx.scene.control.TreeCell +import javafx.scene.control.TreeItem +import javafx.scene.control.TreeView +import javafx.scene.layout.HBox +import javafx.scene.layout.Priority +import javafx.scene.layout.VBox + +class SettingsNav : VBox() { + private lateinit var searchField: TextField + private lateinit var categoryTreeView: TreeView + private var originalRoot: TreeItem? = null + + init { + styleClass.add("left-panel") + minWidth = UIConstants.SETTINGS_SIDE_PANEL_MIN_WIDTH + maxWidth = UIConstants.SETTINGS_SIDE_PANEL_MAX_WIDTH + + createViewComponents() + + children.addAll( + createSearchSection(), + categoryTreeView + ) + + EventBus.register(this) + } + + private fun createSearchSection(): HBox { + searchField = TextField().apply { + styleClass.add("search-field") + promptText = "Search" + textFormatter = TextFormatter { change -> // Limits to 20 char should be enough and no more crash by art + if (change.controlNewText.length > 20) { + null + } else { + change + } + } + textProperty().addListener(ChangeListener { _, _, newValue -> + EventBus.post(SettingsSearchQueryChanged(newValue)) + }) + } + + return HBox().apply { + styleClass.add("search-section") + children.add(searchField) + HBox.setHgrow(searchField, Priority.ALWAYS) + } + } + + private fun createViewComponents() { + categoryTreeView = TreeView().apply { + styleClass.add("category-tree") + isShowRoot = false + setVgrow(this, Priority.ALWAYS) + selectionModel.selectedItemProperty().addListener { _, _, newItem -> + newItem?.value?.let { EventBus.post(SettingsCategorySelected(it)) } + } + + setCellFactory { + object : TreeCell() { + init { isWrapText = true } + override fun updateItem(item: CategoryItem?, empty: Boolean) { + super.updateItem(item, empty) + text = if (empty) null else item?.name + } + } + } + } + } + + @SubscribeEvent + fun onCategoriesAvailable(event: SettingsCategoriesAvailable) { + val rootItem = TreeItem(CategoryItem("Settings", CategoryType.ROOT)) + rootItem.isExpanded = true + + event.categories.forEach { categoryData -> + val categoryItem = TreeItem( + CategoryItem( + categoryData.name, + CategoryType.MAIN_CATEGORY, + categoryData.configClass + ) + ) + categoryItem.isExpanded = true + + categoryData.subCategories.forEach { subCategoryName -> + val subItem = TreeItem( + CategoryItem( + subCategoryName, + CategoryType.SUB_CATEGORY, + categoryData.configClass, + subCategoryName + ) + ) + categoryItem.children.add(subItem) + } + rootItem.children.add(categoryItem) + } + + originalRoot = rootItem + categoryTreeView.root = originalRoot + + Platform.runLater { + categoryTreeView.selectionModel.select(1) + } + } + + @SubscribeEvent + fun onSearchResultsAvailable(event: SettingsSearchResultsAvailable) { + if (event.query.isBlank()) { + categoryTreeView.root = originalRoot + if (originalRoot?.children?.isNotEmpty() == true) { + categoryTreeView.selectionModel.select(1) + } + return + } + + val matchingFields = event.results.map { it.field }.toSet() + val filteredRoot = TreeItem(CategoryItem("Settings", CategoryType.ROOT)) + + originalRoot?.children?.forEach { mainCategoryItem -> + val newMainCategoryItem = TreeItem(mainCategoryItem.value) + + mainCategoryItem.children.forEach { subCategoryItem -> + val configClass = subCategoryItem.value.configClass + val subCategory = subCategoryItem.value.subCategory + val fieldsForSubCategory = if (configClass != null && subCategory != null) { + SettingsManager.getNestedCategories(configClass)[subCategory] + } else null + val hasMatch = fieldsForSubCategory?.any { it in matchingFields } == true + + if (hasMatch) { + newMainCategoryItem.children.add(TreeItem(subCategoryItem.value)) + } + } + + if (newMainCategoryItem.children.isNotEmpty()) { + newMainCategoryItem.isExpanded = true + filteredRoot.children.add(newMainCategoryItem) + } + } + + categoryTreeView.root = filteredRoot + + if (filteredRoot.children.isEmpty()) { + EventBus.post(SettingsContentUpdate("No Results Found", emptyList())) + } else { + val firstItem = filteredRoot.children.first() + val itemToSelect = if (firstItem.children.isNotEmpty()) firstItem.children.first() else firstItem + categoryTreeView.selectionModel.select(itemToSelect) + } + } + + fun cleanup() { + EventBus.unregister(this) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsView.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsView.kt new file mode 100644 index 000000000..d781751bd --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/popup/settings/SettingsView.kt @@ -0,0 +1,40 @@ +package io.github.frostzie.nodex.screen.elements.popup.settings + +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.RequestSettingsCategories +import io.github.frostzie.nodex.settings.categories.AdvancedConfig +import javafx.beans.value.ChangeListener +import javafx.scene.control.SplitPane + +/** + * The main content view for the settings window, containing the navigation and content areas. + */ +class SettingsView : SplitPane() { + private val debugLayoutListener = ChangeListener { _, _, newValue -> + if (newValue) { + styleClass.add("debug-layout") + } else { + styleClass.remove("debug-layout") + } + } + + init { + val nav = SettingsNav() + val content = SettingsContent() + + styleClass.add("main-content") + items.addAll(nav, content) + setDividerPositions(0.25) + + AdvancedConfig.debugLayoutBounds.addListener(debugLayoutListener) + if (AdvancedConfig.debugLayoutBounds.get()) { + styleClass.add("debug-layout") + } + + EventBus.post(RequestSettingsCategories()) + } + + fun dispose() { + AdvancedConfig.debugLayoutBounds.removeListener(debugLayoutListener) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/ProjectManagerView.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/ProjectManagerView.kt new file mode 100644 index 000000000..6c88cd2c3 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/ProjectManagerView.kt @@ -0,0 +1,221 @@ +package io.github.frostzie.nodex.screen.elements.project + +import atlantafx.base.theme.Styles +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.MainWindowClose +import io.github.frostzie.nodex.events.SettingsWindowOpen +import io.github.frostzie.nodex.modules.project.ProjectManagerViewModel +import io.github.frostzie.nodex.project.Project +import io.github.frostzie.nodex.styling.common.IconSource +import io.github.frostzie.nodex.utils.IconUtils +import io.github.frostzie.nodex.utils.LoggerProvider +import io.github.frostzie.nodex.utils.file.DirectoryChooseUtils +import io.github.frostzie.nodex.utils.OpenLinks +import javafx.application.Platform +import javafx.geometry.Insets +import javafx.geometry.Pos +import javafx.scene.control.Button +import javafx.scene.control.Hyperlink +import javafx.scene.control.Label +import javafx.scene.control.ListView +import javafx.scene.control.Separator +import javafx.scene.control.Tooltip +import javafx.scene.layout.BorderPane +import javafx.scene.layout.HBox +import javafx.scene.layout.Priority +import javafx.scene.layout.Region +import javafx.scene.layout.VBox +import org.kordamp.ikonli.Ikon +import org.kordamp.ikonli.javafx.FontIcon +import org.kordamp.ikonli.material2.Material2AL +import org.kordamp.ikonli.material2.Material2MZ + +class ProjectManagerView : BorderPane() { + private val logger = LoggerProvider.getLogger("ProjectManagerView") + private val viewModel = ProjectManagerViewModel() + + // Exposed for WindowDrag + val dragTarget: VBox + + init { + styleClass.add("start-screen") + + // Header: Name + Window Controls + dragTarget = createHeader() + top = dragTarget + + // Left Side: Recent Projects + val recentPane = createRecentProjectsPane() + left = recentPane + setMargin(recentPane, Insets(20.0)) + + // Center: Actions + val actionsPane = createActionsPane() + center = actionsPane + setMargin(actionsPane, Insets(20.0)) + } + + private fun createHeader(): VBox { + val root = VBox() + + val hBox = HBox(10.0) + hBox.alignment = Pos.CENTER_LEFT + hBox.styleClass.add("start-screen-header") + hBox.padding = Insets(10.0) + hBox.minHeight = 40.0 + + val logo = IconUtils.createIcon(IconSource.SvgIcon("/assets/nodex/svg/icon.svg", 40)) + + val appTitle = Label("DataPack IDE") + appTitle.styleClass.add(Styles.TITLE_3) + + val spacer = Region() + HBox.setHgrow(spacer, Priority.ALWAYS) + + val closeBtn = Button() + closeBtn.graphic = FontIcon(Material2AL.CLOSE) + closeBtn.styleClass.addAll(Styles.FLAT, Styles.DANGER) + closeBtn.tooltip = Tooltip("Close") + closeBtn.setOnAction { + EventBus.post(MainWindowClose()) + } + + val settingsBtn = Button() + settingsBtn.graphic = FontIcon(Material2MZ.SETTINGS) + settingsBtn.styleClass.addAll((Styles.FLAT)) + settingsBtn.tooltip = Tooltip("Settings") + settingsBtn.setOnAction { + EventBus.post(SettingsWindowOpen()) + } + + hBox.children.addAll(logo, appTitle, spacer, settingsBtn, closeBtn) + + root.children.addAll(hBox, Separator()) + return root + } + + private fun createRecentProjectsPane(): VBox { + val vbox = VBox(10.0) + vbox.prefWidth = 300.0 + vbox.styleClass.add("recent-projects-pane") + + val title = Label("Recent Projects") + title.styleClass.add(Styles.TITLE_3) + + val listView = ListView() + listView.items = viewModel.recentProjects + listView.styleClass.add("edge-to-edge") + listView.fixedCellSize = 60.0 + listView.placeholder = Label("No recent projects").apply { styleClass.add(Styles.TEXT_MUTED) } + VBox.setVgrow(listView, Priority.ALWAYS) + + listView.setCellFactory { RecentProjectListCell() } + + // Hide scrollbars + listView.skinProperty().addListener { _, _, _ -> + Platform.runLater { + listView.lookupAll(".scroll-bar").forEach { + it.style = "-fx-opacity: 0; -fx-padding: -10;" + } + } + } + + listView.setOnMouseClicked { event -> + if (event.clickCount == 2) { + val selected = listView.selectionModel.selectedItem + if (selected != null) { + viewModel.openProject(selected) + } + } + } + + vbox.children.addAll(title, listView) + return vbox + } + + private fun createActionsPane(): VBox { + val vBox = VBox(20.0) + vBox.styleClass.add("actions-pane") + vBox.alignment = Pos.CENTER + + val topSpacer = Region() + VBox.setVgrow(topSpacer, Priority.ALWAYS) + + val actionsBox = VBox(10.0) + actionsBox.alignment = Pos.CENTER + actionsBox.styleClass.add("actions-box") + actionsBox.maxWidth = 300.0 + + val newProjectBtn = createActionButton("New Project", Material2AL.ADD) { + // TODO: Open New Project + logger.info("New Project clicked (Not Implemented)") + } + newProjectBtn.isDisable = true + + val openButtonsBox = HBox(10.0) + openButtonsBox.alignment = Pos.CENTER + + val openFolderBtn = Button("Folder") + openFolderBtn.graphic = FontIcon(Material2AL.FOLDER_OPEN) + openFolderBtn.styleClass.add(Styles.ACCENT) + openFolderBtn.prefWidth = 95.0 + openFolderBtn.prefHeight = 40.0 + openFolderBtn.setOnAction { + DirectoryChooseUtils.promptOpenProject(scene.window) + } + + val openZipBtn = Button("Zip") + openZipBtn.graphic = FontIcon(Material2AL.ARCHIVE) + openZipBtn.styleClass.add(Styles.ACCENT) + openZipBtn.prefWidth = 95.0 + openZipBtn.prefHeight = 40.0 + openZipBtn.setOnAction { + DirectoryChooseUtils.promptOpenZip(scene.window) + } + openButtonsBox.children.addAll(openFolderBtn, openZipBtn) + + val openWorldBtn = createActionButton("Open from World", Material2MZ.PUBLIC) { + logger.info("Open from World clicked (Not Implemented)") + } + openWorldBtn.isDisable = true + + actionsBox.children.addAll(newProjectBtn, openButtonsBox, openWorldBtn) + + // TODO: Implement more tips (just filling the empty space a bit) + val tipLabel = Label("Tip: There will be tips soon!") + tipLabel.styleClass.add(Styles.TEXT_MUTED) + + val bottomSpacer = Region() + VBox.setVgrow(bottomSpacer, Priority.ALWAYS) + + val footer = createFooter() + + vBox.children.addAll(topSpacer, actionsBox, tipLabel, bottomSpacer, footer) + return vBox + } + + private fun createFooter(): HBox { + val hBox = HBox(15.0) + hBox.alignment = Pos.CENTER + hBox.padding = Insets(10.0) + + val github = Hyperlink("GitHub").apply { setOnAction { OpenLinks.gitHubLink() } } + val discord = Hyperlink("Discord").apply { setOnAction { OpenLinks.discordLink() } } + val issues = Hyperlink("Report Bug").apply { setOnAction { OpenLinks.reportBugLink() } } + val modrinth = Hyperlink("Modrinth").apply { setOnAction { OpenLinks.modrinthLink() } } + val buyMeACoffee = Hyperlink("Buy Me a Coffee").apply { setOnAction { OpenLinks.buyMeACoffeeLink() } } + + hBox.children.addAll(github, discord, modrinth, buyMeACoffee, issues) + return hBox + } + + private fun createActionButton(text: String, iconCode: Ikon, action: () -> Unit): Button { + val btn = Button(text) + btn.graphic = FontIcon(iconCode) + btn.styleClass.add(Styles.ACCENT) + btn.prefWidth = 200.0 + btn.prefHeight = 40.0 + btn.setOnAction { action() } + return btn + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/RecentProjectListCell.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/RecentProjectListCell.kt new file mode 100644 index 000000000..ccc4223a6 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/RecentProjectListCell.kt @@ -0,0 +1,84 @@ +package io.github.frostzie.nodex.screen.elements.project + +import atlantafx.base.theme.Styles +import io.github.frostzie.nodex.project.Project +import io.github.frostzie.nodex.utils.ProjectIconUtils +import javafx.geometry.Pos +import javafx.scene.control.Label +import javafx.scene.control.ListCell +import javafx.scene.layout.HBox +import javafx.scene.layout.Priority +import javafx.scene.layout.VBox +import org.kordamp.ikonli.javafx.FontIcon +import org.kordamp.ikonli.material2.Material2AL + +class RecentProjectListCell : ListCell() { + + init { + styleClass.add("recent-project-cell") + } + + override fun updateItem(item: Project?, empty: Boolean) { + super.updateItem(item, empty) + + if (empty || item == null) { + text = null + graphic = null + tooltip = null + } else { + val iconNode = if (item.additionalPaths.isNotEmpty()) { + FontIcon(Material2AL.FOLDER).apply { iconSize = 32 } + } else { + ProjectIconUtils.getIcon(item.path, 32.0) + } + + val nameLabel = Label(item.name) + nameLabel.styleClass.add(Styles.TEXT_BOLD) + nameLabel.maxWidth = Double.MAX_VALUE + + val contentBox = VBox(2.0) + HBox.setHgrow(contentBox, Priority.ALWAYS) + contentBox.children.add(nameLabel) + + val metadata = item.metadata + if (metadata != null) { + val fullDesc = metadata.description.trim() + if (fullDesc.isNotBlank()) { + // Logic: Row 1 = First line, Row 2 = Rest joined by space + val lines = fullDesc.split("\n") + val header = lines[0].trim() + val rest = lines.drop(1).joinToString(" ").trim() + + if (header.isNotBlank()) { + val headerLabel = Label(header.take(60).let { if (it.length == 60) "$it..." else it }) + headerLabel.styleClass.add(Styles.TEXT_SMALL) + headerLabel.maxWidth = Double.MAX_VALUE + contentBox.children.add(headerLabel) + } + + if (rest.isNotBlank()) { + val restLabel = Label(rest.take(60).let { if (it.length == 60) "$it..." else it }) + restLabel.styleClass.addAll(Styles.TEXT_SMALL, Styles.TEXT_MUTED) + restLabel.maxWidth = Double.MAX_VALUE + contentBox.children.add(restLabel) + } + } + } else { + val pathLabel = Label(item.path.toString()) + pathLabel.styleClass.addAll(Styles.TEXT_SMALL, Styles.TEXT_MUTED) + pathLabel.maxWidth = Double.MAX_VALUE + contentBox.children.add(pathLabel) + } + + val root = HBox(15.0) + root.alignment = Pos.CENTER_LEFT + root.minHeight = 60.0 + root.prefHeight = 60.0 + root.maxHeight = 60.0 + root.children.addAll(iconNode, contentBox) + + graphic = root + text = null + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/preview/ProjectPreviewView.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/preview/ProjectPreviewView.kt new file mode 100644 index 000000000..2c1b1d06b --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/screen/elements/project/preview/ProjectPreviewView.kt @@ -0,0 +1,206 @@ +package io.github.frostzie.nodex.screen.elements.project.preview + +import atlantafx.base.theme.Styles +import io.github.frostzie.nodex.modules.project.preview.* +import io.github.frostzie.nodex.project.metadata.DatapackMetadata +import io.github.frostzie.nodex.utils.ProjectIconUtils +import javafx.event.ActionEvent +import javafx.geometry.Insets +import javafx.geometry.Pos +import javafx.beans.property.SimpleObjectProperty +import javafx.scene.control.* +import javafx.scene.layout.HBox +import javafx.scene.layout.Priority +import javafx.scene.layout.VBox +import javafx.stage.DirectoryChooser +import javafx.stage.StageStyle +import org.kordamp.ikonli.javafx.FontIcon +import org.kordamp.ikonli.material2.Material2AL +import java.nio.file.Path + +class ProjectPreviewView(val viewModel: ProjectPreviewViewModel) : Dialog() { + + init { + initStyle(StageStyle.UNDECORATED) + + val content = when (viewModel) { + is SingleProjectPreviewViewModel -> createValidView(viewModel.path, viewModel.metadata) + is ZipProjectPreviewViewModel -> createZipView(viewModel) + is WorkspaceProjectPreviewViewModel -> createWorkspaceView(viewModel) + is InvalidProjectPreviewViewModel -> createInvalidView(viewModel) + } + + dialogPane.content = content + dialogPane.padding = Insets(20.0) + dialogPane.style = "-fx-border-color: -color-border-default; -fx-border-width: 1px;" + + val openType = ButtonType(viewModel.confirmButtonText, ButtonBar.ButtonData.OK_DONE) + val cancelType = ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE) + + dialogPane.buttonTypes.addAll(openType, cancelType) + + val openBtn = dialogPane.lookupButton(openType) as? Button + openBtn?.styleClass?.add(if (viewModel.isWarning) Styles.DANGER else Styles.ACCENT) + + if (viewModel is ZipProjectPreviewViewModel) { + openBtn?.addEventFilter(ActionEvent.ACTION) { event -> + if (viewModel.targetExists()) { + val folderName = viewModel.calculateTargetDir().fileName.toString() + val alert = Alert(Alert.AlertType.CONFIRMATION) + alert.initOwner(dialogPane.scene.window) + alert.title = "Folder Exists" + alert.headerText = "Folder '$folderName' already exists." + alert.contentText = "Do you want to overwrite it? This will delete the existing folder." + + val res = alert.showAndWait() + if (res.isPresent && res.get() != ButtonType.OK) { + event.consume() + } + } + } + } + + if (viewModel is WorkspaceProjectPreviewViewModel && viewModel.isZip) { + openBtn?.addEventFilter(ActionEvent.ACTION) { event -> + if (viewModel.targetExists()) { + val folderName = viewModel.calculateTargetDir().fileName.toString() + val alert = Alert(Alert.AlertType.CONFIRMATION) + alert.initOwner(dialogPane.scene.window) + alert.title = "Folder Exists" + alert.headerText = "Folder '$folderName' already exists." + alert.contentText = "Do you want to overwrite it? This will delete the existing folder." + + val res = alert.showAndWait() + if (res.isPresent && res.get() != ButtonType.OK) { + event.consume() + } + } + } + } + + if (viewModel is InvalidProjectPreviewViewModel && viewModel.isZip) { + openBtn?.addEventFilter(ActionEvent.ACTION) { event -> + if (viewModel.targetExists()) { + val folderName = viewModel.calculateTargetDir().fileName.toString() + val alert = Alert(Alert.AlertType.CONFIRMATION) + alert.initOwner(dialogPane.scene.window) + alert.title = "Folder Exists" + alert.headerText = "Folder '$folderName' already exists." + alert.contentText = "Do you want to overwrite it? This will delete the existing folder." + + val res = alert.showAndWait() + if (res.isPresent && res.get() != ButtonType.OK) { + event.consume() + } + } + } + } + } + + private fun createValidView(path: Path, metadata: DatapackMetadata): VBox { + val root = VBox(10.0) + root.prefWidth = 450.0 + + val infoBox = HBox(15.0) + infoBox.alignment = Pos.CENTER_LEFT + + val iconNode = ProjectIconUtils.getIcon(path, 64.0) + + val textContainer = VBox(5.0) + textContainer.alignment = Pos.CENTER_LEFT + HBox.setHgrow(textContainer, Priority.ALWAYS) + + val nameLabel = Label(path.fileName.toString()).apply { styleClass.add(Styles.TITLE_3) } + textContainer.children.add(nameLabel) + + val fullDesc = metadata.description.trim() + if (fullDesc.isNotBlank()) { + val lines = fullDesc.split("\n") + val header = lines[0].trim() + val rest = lines.drop(1).joinToString(" ").trim() + + if (header.isNotBlank()) { + textContainer.children.add(Label(header.take(60).let { if (it.length == 60) "$it..." else it })) + } + if (rest.isNotBlank()) { + textContainer.children.add(Label(rest.take(60).let { if (it.length == 60) "$it..." else it })) + } + Tooltip.install( + textContainer, + Tooltip("${path.fileName}\n$fullDesc").apply { isWrapText = true; maxWidth = 400.0 } + ) + } + + infoBox.children.addAll(iconNode, textContainer) + root.children.add(infoBox) + return root + } + + private fun createZipView(vm: ZipProjectPreviewViewModel): VBox { + val root = createValidView(vm.path, vm.metadata) + + root.children.add(createUnzipBox(vm.targetDirectory, vm.path.parent)) + return root + } + + private fun createWorkspaceView(vm: WorkspaceProjectPreviewViewModel): VBox { + val vbox = VBox(15.0).apply { alignment = Pos.CENTER; prefWidth = 400.0 } + vbox.children.addAll( + FontIcon(Material2AL.LAYERS).apply { iconSize = 64 }, + Label("Multi-Project Workspace").apply { styleClass.add(Styles.TITLE_3) }, + Label("Found ${vm.projects.size} datapacks in this folder.") + ) + + if (vm.isZip) { + vbox.children.add(createUnzipBox(vm.targetDirectory, vm.root.parent)) + } + + return vbox + } + + private fun createInvalidView(vm: InvalidProjectPreviewViewModel): VBox { + val vbox = VBox(15.0).apply { alignment = Pos.CENTER; prefWidth = 400.0 } + vbox.children.addAll( + FontIcon(Material2AL.ERROR_OUTLINE).apply { iconSize = 64; styleClass.add(Styles.DANGER) }, + Label("Validation Failed").apply { styleClass.addAll(Styles.TITLE_3, Styles.DANGER) }, + Label(vm.reason).apply { isWrapText = true; alignment = Pos.CENTER }, + Label("This folder does not appear to be a valid Datapack.").apply { styleClass.add(Styles.TEXT_MUTED); isWrapText = true; alignment = Pos.CENTER } + ) + + if (vm.isZip) { + vbox.children.add(createUnzipBox(vm.targetDirectory, vm.path.parent)) + } + + return vbox + } + + private fun createUnzipBox(targetDirectory: SimpleObjectProperty, defaultParent: Path): VBox { + val unzipBox = VBox(5.0) + val label = Label("Unzip to:") + label.styleClass.add(Styles.TEXT_SMALL) + + val pathField = TextField(targetDirectory.get()?.toString()) + pathField.isEditable = false + + targetDirectory.addListener { _, _, newVal -> pathField.text = newVal?.toString() } + + val browseBtn = Button(null, FontIcon(Material2AL.FOLDER_OPEN)) + browseBtn.setOnAction { + val dc = DirectoryChooser() + dc.title = "Select Unzip Location" + dc.initialDirectory = targetDirectory.get()?.toFile() ?: defaultParent.toFile() + val selected = dc.showDialog(dialogPane.scene.window) + if (selected != null) { + targetDirectory.set(selected.toPath()) + } + } + + val row = HBox(5.0) + row.alignment = Pos.CENTER_LEFT + HBox.setHgrow(pathField, Priority.ALWAYS) + row.children.addAll(pathField, browseBtn) + + unzipBox.children.addAll(label, row) + return unzipBox + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/services/ArchiveService.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/services/ArchiveService.kt new file mode 100644 index 000000000..3e3cbf0be --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/services/ArchiveService.kt @@ -0,0 +1,75 @@ +package io.github.frostzie.nodex.services + +import io.github.frostzie.nodex.utils.LoggerProvider +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.zip.ZipFile + +object ArchiveService { + private val logger = LoggerProvider.getLogger("ArchiveService") + + fun unzip(zipPath: Path, targetDir: Path): Path { + val folderName = zipPath.fileName.toString().removeSuffix(".zip") + val finalDir = targetDir.resolve(folderName) + + if (Files.exists(finalDir)) { + logger.info("Deleting existing directory for override: $finalDir") + try { + deleteDirectory(finalDir) + } catch (e: Exception) { + logger.error("Failed to delete directory: $finalDir", e) + throw IOException("Failed to delete existing project folder. Please ensure it is not open in another program.", e) + } + } + + // Small delay to ensure OS releases handles (Windows fix) + try { Thread.sleep(50) } catch (_: InterruptedException) {} + + Files.createDirectories(finalDir) + + logger.info("Unzipping $zipPath to $finalDir") + ZipFile(zipPath.toFile()).use { zip -> + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + // Prevent Zip Slip + val entryPath = finalDir.resolve(entry.name).normalize() + if (!entryPath.startsWith(finalDir)) { + throw IOException("Zip entry is outside of the target dir: ${entry.name}") + } + + if (entry.isDirectory) { + Files.createDirectories(entryPath) + } else { + if (entryPath.parent != null && !Files.exists(entryPath.parent)) { + Files.createDirectories(entryPath.parent) + } + Files.copy(zip.getInputStream(entry), entryPath, StandardCopyOption.REPLACE_EXISTING) + } + } + } + return finalDir + } + + private fun deleteDirectory(path: Path) { + if (!Files.exists(path)) return + + Files.walkFileTree(path, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (exc != null) throw exc + Files.delete(dir) + return FileVisitResult.CONTINUE + } + }) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/services/FileService.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/services/FileService.kt new file mode 100644 index 000000000..d72697fa5 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/services/FileService.kt @@ -0,0 +1,116 @@ +package io.github.frostzie.nodex.services + +import io.github.frostzie.nodex.utils.LoggerProvider +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** + * A singleton service for performing file I/O operations. + * Centralizes error handling and logging for filesystem interactions. + */ +object FileService { + private val logger = LoggerProvider.getLogger("FileService") + + /** + * Reads the content of a text file. + * @throws Exception if reading fails. + */ + fun readText(path: Path): String { + try { + return path.readText() + } catch (e: Exception) { + logger.error("Failed to read text from file: $path", e) + throw e + } + } + + /** + * Writes content to a text file. + * @throws Exception if writing fails. + */ + fun writeText(path: Path, content: String) { + try { + path.writeText(content) + } catch (e: Exception) { + logger.error("Failed to write text to file: $path", e) + throw e + } + } + + /** + * Moves a file or directory from source to target. + * Tries atomic move first, falls back to standard copy-delete. + * @throws Exception if the move fails. + */ + fun move(source: Path, target: Path) { + try { + try { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } catch (_: AtomicMoveNotSupportedException) { + logger.warn("Atomic move not supported, falling back to standard move for: $source -> $target") + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING) + } + logger.info("Moved file: {} -> {}", source, target) + } catch (e: Exception) { + logger.error("Failed to move file from $source to $target", e) + throw e + } + } + + /** + * Deletes a file or directory (recursively not guaranteed here, standard delete). + * @throws Exception if deletion fails. + */ + fun delete(path: Path) { + try { + Files.delete(path) + logger.info("Deleted file: {}", path) + } catch (e: Exception) { + logger.error("Failed to delete file: $path", e) + throw e + } + } + + /** + * Creates a directory. + */ + fun createDirectory(path: Path) { + try { + Files.createDirectories(path) + logger.info("Created directory: {}", path) + } catch (e: Exception) { + logger.error("Failed to create directory: $path", e) + throw e + } + } + + /** + * Lists directory entries safely. + * @return List of paths, or empty list if failed. + */ + fun listDirectory(path: Path): List { + return try { + path.listDirectoryEntries() + } catch (e: Exception) { + logger.error("Failed to list directory entries: $path", e) + emptyList() + } + } + + /** + * Checks if a path is a directory. + */ + fun isDirectory(path: Path): Boolean { + return try { + path.isDirectory() + } catch (_: Exception) { + false + } + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/ConfigFieldManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/ConfigFieldManager.kt new file mode 100644 index 000000000..18a9f2fd1 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/ConfigFieldManager.kt @@ -0,0 +1,165 @@ +package io.github.frostzie.nodex.settings + +import io.github.frostzie.nodex.settings.annotations.* +import io.github.frostzie.nodex.settings.data.* +import io.github.frostzie.nodex.utils.LoggerProvider +import javafx.beans.property.Property +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.findAnnotation + +internal object ConfigFieldManager { + private val logger = LoggerProvider.getLogger("ConfigFieldManager") + + fun create(instance: Any, property: KProperty1, option: ConfigOption): ConfigField? { + val propValue = property.get(instance) + + return when { + property.findAnnotation() != null -> createBooleanField(instance, property, option, propValue) + property.findAnnotation() != null -> createTextAreaField(instance, property, option, propValue) + property.findAnnotation() != null -> createSliderField(instance, property, option, propValue) + property.findAnnotation() != null -> createDropdownField(instance, property, option, propValue) + property.findAnnotation() != null -> createButtonField(instance, property, option, propValue) + property.findAnnotation() != null -> createKeybindField(instance, property, option, propValue) + property.findAnnotation() != null -> createInfoField(instance, property, option) + property.findAnnotation() != null -> createSpinnerField(instance, property, option, propValue) + property.findAnnotation() != null -> createColorPickerField(instance, property, option, propValue) + property.findAnnotation() != null -> createTextField(instance, property, option, propValue) + property.findAnnotation() != null -> createFolderField(instance, property, option, propValue) + else -> null + } + } + + @Suppress("UNCHECKED_CAST") + private fun createBooleanField(instance: Any, property: KProperty1, option: ConfigOption, propValue: Any): BooleanConfigField? { + if (propValue is Property<*> && propValue.value is Boolean) { + return BooleanConfigField( + instance, property as KProperty1>, option.name, option.desc, + property.findAnnotation() + ) + } + logger.warn("Mismatched annotation/type for ${property.name} in ${instance::class.simpleName}. Expected Property for Boolean.") + return null + } + + @Suppress("UNCHECKED_CAST") + private fun createTextAreaField(instance: Any, property: KProperty1, option: ConfigOption, propValue: Any): TextAreaConfigField? { + if (propValue is Property<*> && propValue.value is String) { + return TextAreaConfigField( + instance, property as KProperty1>, option.name, option.desc, + property.findAnnotation() + ) + } + logger.warn("Mismatched annotation/type for ${property.name} in ${instance::class.simpleName}. Expected Property for TextArea.") + return null + } + + @Suppress("UNCHECKED_CAST") + private fun createSliderField(instance: Any, property: KProperty1, option: ConfigOption, propValue: Any): SliderConfigField? { + if (propValue is Property<*> && propValue.value is Number) { + return SliderConfigField( + instance, property as KProperty1>, option.name, option.desc, + property.findAnnotation(), + property.findAnnotation()!! + ) + } + logger.warn("Mismatched annotation/type for ${property.name} in ${instance::class.simpleName}. Expected Property for Slider.") + return null + } + + @Suppress("UNCHECKED_CAST") + private fun createDropdownField(instance: Any, property: KProperty1, option: ConfigOption, propValue: Any): DropdownConfigField? { + if (propValue is Property<*> && propValue.value is String) { + return DropdownConfigField( + instance, property as KProperty1>, option.name, option.desc, + property.findAnnotation(), + property.findAnnotation()!! + ) + } + logger.warn("Mismatched annotation/type for ${property.name} in ${instance::class.simpleName}. Expected Property for Dropdown.") + return null + } + + @Suppress("UNCHECKED_CAST") + private fun createButtonField(instance: Any, property: KProperty1, option: ConfigOption, propValue: Any): ButtonConfigField? { + if (propValue is Function0<*>) { + return ButtonConfigField( + instance, property as KProperty1 Unit>, option.name, option.desc, + property.findAnnotation(), + property.findAnnotation()!! + ) + } + logger.warn("Mismatched annotation/type for ${property.name} in ${instance::class.simpleName}. Expected () -> Unit.") + return null + } + + @Suppress("UNCHECKED_CAST") + private fun createKeybindField(instance: Any, property: KProperty1, option: ConfigOption, propValue: Any): KeybindConfigField? { + if (propValue is Property<*> && propValue.value is KeyCombination) { + return KeybindConfigField( + instance, + property as KProperty1>, + option.name, + option.desc, + property.findAnnotation() + ) + } + logger.warn("Mismatched annotation/type for ${property.name} in ${instance::class.simpleName}. Expected Property for Keybind.") + return null + } + + private fun createInfoField(instance: Any, property: KProperty1, option: ConfigOption): InfoConfigField { + return InfoConfigField( + instance, property, option.name, option.desc, + property.findAnnotation() + ) + } + + @Suppress("UNCHECKED_CAST") + private fun createSpinnerField(instance: Any, property: KProperty1, option: ConfigOption, propValue: Any): SpinnerConfigField? { + if (propValue is Property<*> && propValue.value is Int) { + return SpinnerConfigField( + instance, property as KProperty1>, option.name, option.desc, + property.findAnnotation(), + property.findAnnotation()!! + ) + } + logger.warn("Mismatched annotation/type for ${property.name} in ${instance::class.simpleName}. Expected Property for Spinner.") + return null + } + + @Suppress("UNCHECKED_CAST") + private fun createColorPickerField(instance: Any, property: KProperty1, option: ConfigOption, propValue: Any): ColorPickerConfigField? { + if (propValue is Property<*> && propValue.value is String) { + return ColorPickerConfigField( + instance, property as KProperty1>, option.name, option.desc, + property.findAnnotation() + ) + } + logger.warn("Mismatched annotation/type for ${property.name} in ${instance::class.simpleName}. Expected Property for ColorPicker.") + return null + } + + @Suppress("UNCHECKED_CAST") + private fun createTextField(instance: Any, property: KProperty1, option: ConfigOption, propValue: Any): TextFieldConfigField? { + if (propValue is Property<*> && propValue.value is String) { + return TextFieldConfigField( + instance, property as KProperty1>, option.name, option.desc, + property.findAnnotation() + ) + } + logger.warn("Mismatched annotation/type for ${property.name} in ${instance::class.simpleName}. Expected Property for TextField.") + return null + } + + @Suppress("UNCHECKED_CAST") + private fun createFolderField(instance: Any, property: KProperty1, option: ConfigOption, propValue: Any): FolderConfigField? { + if (propValue is Property<*> && propValue.value is String) { + return FolderConfigField( + instance, property as KProperty1>, option.name, option.desc, + property.findAnnotation() + ) + } + logger.warn("Mismatched annotation/type for ${property.name} in ${instance::class.simpleName}. Expected Property for Folder.") + return null + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/KeyCombination.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/KeyCombination.kt new file mode 100644 index 000000000..915437996 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/KeyCombination.kt @@ -0,0 +1,50 @@ +package io.github.frostzie.nodex.settings + +import javafx.scene.input.KeyCode +import javafx.scene.input.KeyEvent + +/** + * A data class to represent a key combination in a structured way. + * + * @param key The primary, non-modifier key. + * @param ctrl Whether the Control key is required. + * @param alt Whether the Alt key is required. + * @param shift Whether the Shift key is required. + * @param meta Whether the Meta (e.g., Command on macOS) key is required. + */ +data class KeyCombination( + val key: KeyCode, + val ctrl: Boolean = false, + val alt: Boolean = false, + val shift: Boolean = false, + val meta: Boolean = false +) { + /** + * Provides a human-readable string representation for display purposes. + * Example: "Ctrl + Alt + S" + */ + override fun toString(): String { + val parts = mutableListOf() + if (ctrl) parts.add("Ctrl") + if (alt) parts.add("Alt") + if (shift) parts.add("Shift") + if (meta) parts.add("Meta") + parts.add(key.getName()) + return parts.joinToString(" + ") + } + + companion object { + /** + * Creates a [KeyCombination] instance from a JavaFX [KeyEvent]. + */ + fun fromEvent(event: KeyEvent): KeyCombination { + return KeyCombination( + key = event.code, + ctrl = event.isControlDown, + alt = event.isAltDown, + shift = event.isShiftDown, + meta = event.isMetaDown + ) + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/SettingsLoader.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/SettingsLoader.kt new file mode 100644 index 000000000..62209666a --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/SettingsLoader.kt @@ -0,0 +1,197 @@ +package io.github.frostzie.nodex.settings + +import io.github.frostzie.nodex.config.ConfigManager +import io.github.frostzie.nodex.config.LayoutManager +import io.github.frostzie.nodex.events.CloseSettingsEvent +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.OpenProjectManagerEvent +import io.github.frostzie.nodex.loader.fabric.Folders +import io.github.frostzie.nodex.project.WorkspaceManager +import io.github.frostzie.nodex.settings.categories.AdvancedConfig +import io.github.frostzie.nodex.settings.categories.ExampleConfig +import io.github.frostzie.nodex.settings.categories.MainConfig +import io.github.frostzie.nodex.settings.categories.MinecraftConfig +import io.github.frostzie.nodex.settings.categories.ThemeConfig +import io.github.frostzie.nodex.utils.LoggerProvider +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption + +object SettingsLoader { + private val logger = LoggerProvider.getLogger("SettingsLoader") + private var isReloading = false + + fun initialize() { + SettingsManager.register("main", MainConfig::class) + SettingsManager.register("theme", ThemeConfig::class) + SettingsManager.register("minecraft", MinecraftConfig::class) + SettingsManager.register("advanced", AdvancedConfig::class) + SettingsManager.register("example", ExampleConfig::class) + + SettingsManager.initialize() + + setupUniversalConfigListeners() + + MainConfig.syncConfigFiles = { reloadSystem() } + } + + + // Start of universal config logic + + private fun setupUniversalConfigListeners() { + // The listener that triggers when any Universal Folder setting changes (Toggle or Path). + // Wrap it to check 'isReloading' to prevent infinite loops during system resets (when loadSettings() changes values). + val listener = { + if (!isReloading) updateConfigLocation() + } + + // Attach the listener to the UI toggles and text field (Overall need a better system for detecting setting changes in future) + MainConfig.universalFolderToggle.addListener { _, _, _ -> listener() } + MainConfig.universalConfigToggle.addListener { _, _, _ -> listener() } + MainConfig.universalFolderPath.addListener { _, _, _ -> listener() } + } + + /** + * Determine where the config should be based on the UI. + * This updates the 'config_location.json' pointer file. + * It handles creating the folder, copying files if empty, and triggering a system reload. + */ + private fun updateConfigLocation() { + val useUniversal = MainConfig.universalFolderToggle.get() && MainConfig.universalConfigToggle.get() + val defaultPath = Folders.configDir.resolve("nodex").toAbsolutePath() + val currentConfigPath = ConfigManager.configDir.toAbsolutePath() + + // Only switch to the custom path if BOTH the Master Toggle and Config Toggle are ON + if (useUniversal) { + val pathStr = MainConfig.universalFolderPath.get() + if (!pathStr.isNullOrBlank()) { + try { + val rootPath = Paths.get(pathStr) + val nodexPath = rootPath.resolve("Nodex").resolve("config").toAbsolutePath() + + // Prevents unnecessary reloads if the user chooses the same path + if (currentConfigPath == nodexPath) { + return + } + + var isEmpty = false + + // Create the directory if it doesn't exist yet + if (!Files.exists(nodexPath)) { + Files.createDirectories(nodexPath) + isEmpty = true + } else { + // Check if the directory is empty + Files.list(nodexPath).use { stream -> + isEmpty = !stream.findAny().isPresent + } + } + + // Only copy configs if the target folder is empty + if (isEmpty) { + copyConfigs(nodexPath) + } + + // Update the pointer file and reload the system to use the new location + ConfigManager.setCustomConfigPath(nodexPath) + reloadSystem() + } catch (e: Exception) { + logger.error("Failed to set custom config path.", e) + } + } + } else { + // If toggles are OFF, revert pointer to NULL (Default Dir) + + // Check if we are already in default. + if (currentConfigPath == defaultPath) { + return + } + + ConfigManager.setCustomConfigPath(null) + // Reload the system to go back to default + reloadSystem() + } + } + + /** + * Copies files from the Default directory to the target directory. + */ + private fun copyConfigs(targetDir: Path) { + val sourceDir = Folders.configDir.resolve("nodex") + + if (!Files.exists(sourceDir)) { + return + } + + try { + Files.walk(sourceDir).use { stream -> + stream.forEach { source -> + val destination = targetDir.resolve(sourceDir.relativize(source)) + try { + if (Files.isDirectory(source)) { + if (!Files.exists(destination)) { + Files.createDirectories(destination) + } + } else { + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING) + } + } catch (e: Exception) { + logger.error("Failed to copy file: $source", e) + } + } + } + logger.debug("Successfully moved configs to {}", targetDir) + } catch (e: Exception) { + logger.error("Failed to move configs.", e) + } + } + + /** + * Hot-reloads the application state. + * 1. Close UI/Projects + * 2. Re-resolve the Config Directory + * 3. Load settings from the new directory + * 4. Sync the UI toggles to match reality + */ + private fun reloadSystem() { + // Prevent recursive calls if loadSettings() triggers listeners + if (isReloading) return + isReloading = true + try { + // 1. Close active components + EventBus.post(CloseSettingsEvent()) // Close settings to not cause de-sync + EventBus.post(OpenProjectManagerEvent()) // To not cause any possible de-sync, we simply save/close + + // 2. Tell ConfigManager to read config_location.json again + ConfigManager.reload() + + // 3. Load all data from the NEW directory + SettingsManager.loadSettings() + LayoutManager.load() + WorkspaceManager.load() + + // 4. Sync UI with Reality + // Sets universal toggle to false when detected inside default configs to not run into a loop + val defaultPath = Folders.configDir.resolve("nodex").toAbsolutePath() + val currentPath = ConfigManager.configDir.toAbsolutePath() + + if (currentPath == defaultPath) { + if (MainConfig.universalFolderToggle.get()) { + MainConfig.universalFolderToggle.set(false) + // Save immediately so the file on disk is corrected for the next restart + SettingsManager.saveSettings() + } + } else { + // We are in Custom (Universal). Toggle MUST be True to reflect that we are using the custom path. + if (!MainConfig.universalFolderToggle.get()) { + MainConfig.universalFolderToggle.set(true) + SettingsManager.saveSettings() + } + } + } finally { + // Release the lock so user interactions work again + isReloading = false + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/SettingsManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/SettingsManager.kt new file mode 100644 index 000000000..da2aa6384 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/SettingsManager.kt @@ -0,0 +1,238 @@ +package io.github.frostzie.nodex.settings + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import io.github.frostzie.nodex.config.ConfigManager +import io.github.frostzie.nodex.settings.annotations.* +import io.github.frostzie.nodex.settings.data.* +import io.github.frostzie.nodex.utils.LoggerProvider +import java.io.FileReader +import java.io.FileWriter +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.findAnnotation + +/** + * Manages the registration, initialization, loading, and saving of application settings. + * Settings are defined in various configuration classes (e.g., `MainConfig`, `ThemeConfig`) + * and are exposed via annotations to be managed and displayed in the UI. + */ +object SettingsManager { + private val logger = LoggerProvider.getLogger("SettingsManager") + private val gson: Gson = GsonBuilder().setPrettyPrinting().create() + private val settingsFile get() = ConfigManager.configDir.resolve("settings.json").toFile() + private val defaultValues = mutableMapOf, Any?>() + + // Stores a list of registered configuration categories, mapping a category name to its KClass. + private val configClasses = mutableListOf>>() + + /** + * Registers a new settings category with the manager. + * This allows the settings defined within the [configClass] to be managed and displayed. + * + * @param categoryName A user-friendly name for the settings category (e.g., "Main", "Theme"). + * @param configClass The Kotlin class (KClass) that holds the setting properties. + */ + fun register(categoryName: String, configClass: KClass<*>) { + configClasses.add(categoryName to configClass) + logger.debug("Registered settings category '$categoryName' with ${configClass.simpleName}") + } + + /** + * Initializes the settings manager by caching default values and loading any existing settings + * from the settings file. + */ + fun initialize() { + logger.debug("Initializing SettingsManager...") + cacheDefaultValues() + loadSettings() + } + + /** + * Iterates through all registered configuration classes and caches the default values + * of all exposed setting properties. These default values are used if a setting + * is not found in the loaded settings file or needs to be reset. + */ + private fun cacheDefaultValues() { + logger.debug("Caching default setting values...") + configClasses.forEach { (_, configClass) -> + getConfigFields(configClass).forEach { field -> + when (field) { + is ButtonConfigField -> { /* Buttons don't have a value to cache */ } + is InfoConfigField -> { /* Info fields don't have a value to cache */ } + is BooleanConfigField -> defaultValues[field.property] = field.property.get(field.objectInstance).value + is DropdownConfigField -> defaultValues[field.property] = field.property.get(field.objectInstance).value + is KeybindConfigField -> defaultValues[field.property] = field.property.get(field.objectInstance).value + is SliderConfigField -> defaultValues[field.property] = field.property.get(field.objectInstance).value + is TextAreaConfigField -> defaultValues[field.property] = field.property.get(field.objectInstance).value + is SpinnerConfigField -> defaultValues[field.property] = field.property.get(field.objectInstance).value + is ColorPickerConfigField -> defaultValues[field.property] = field.property.get(field.objectInstance).value + is TextFieldConfigField -> defaultValues[field.property] = field.property.get(field.objectInstance).value + is FolderConfigField -> defaultValues[field.property] = field.property.get(field.objectInstance).value + } + } + } + logger.debug("Cached ${defaultValues.size} default values.") + } + + /** + * Retrieves the cached default value for a given setting property. + * + * @param property The KProperty1 representing the setting. + * @return The default value of the property, or `null` if not found or not applicable (e.g., for buttons). + */ + fun getDefaultValue(property: KProperty1<*, *>): Any? = defaultValues[property] + + /** + * Returns a list of all registered top-level settings categories. + * + * @return A list of pairs, where each pair contains the category name and its corresponding [KClass]. + */ + fun getConfigCategories(): List>> = configClasses + + /** + * Retrieves all [ConfigField]s (individual setting properties) from a given configuration class. + * It uses reflection to find properties annotated with `@Expose` and `@ConfigOption`, + * ensuring the order of fields is maintained as declared in the source file. + * + * @param configClass The [KClass] of the configuration object (e.g., `MainConfig::class`). + * @return A list of [ConfigField] objects representing the settings, in declaration order. + */ + @Suppress("UNCHECKED_CAST") // Only to not show warning in IntelliJ + fun getConfigFields(configClass: KClass<*>): List { + val objectInstance = configClass.objectInstance ?: return emptyList() + val propertiesByName = configClass.declaredMemberProperties.associateBy { it.name } + + return configClass.java.declaredFields.mapNotNull { field -> propertiesByName[field.name] } + .mapNotNull { property -> + val expose = property.findAnnotation() + val option = property.findAnnotation() + + if (expose != null && option != null) { + val p = property as KProperty1 + ConfigFieldManager.create(objectInstance, p, option) + } else null + } + } + + /** + * Organizes the [ConfigField]s from a given configuration class into nested categories. + * The categories are determined by the `@ConfigCategory` annotation on each field. + * + * The order of categories and fields within them is preserved based on their declaration + * order in the source file. If a field does not have a `@ConfigCategory` annotation, + * or if its name is blank, it will be grouped under the "General" category. + * + * @param configClass The [KClass] of the configuration object. + * @return A [Map] where keys are category names (String) and values are lists of [ConfigField]s + * belonging to that category. The map preserves the insertion order of categories. + */ + fun getNestedCategories(configClass: KClass<*>): Map> { + val fields = getConfigFields(configClass) + val categories = LinkedHashMap>() + fields.forEach { field -> + val categoryName = field.category?.name?.takeIf { it.isNotBlank() } ?: "General" //TODO: Prob change to simply not give a sub category + categories.getOrPut(categoryName) { mutableListOf() }.add(field) + } + return categories + } + + /** + * Saves all current setting values to a JSON file (`settings.json`). + * Only properties corresponding to [ConfigField]s (excluding buttons and info fields) + * are saved. + */ + fun saveSettings() { + try { + val jsonObject = JsonObject() + + configClasses.forEach { (categoryName, configClass) -> + val categoryObject = JsonObject() + val objectInstance = configClass.objectInstance + + if (objectInstance != null) { + getConfigFields(configClass).forEach { field -> + when (field) { + is ButtonConfigField -> { /* Skip buttons */ } + is InfoConfigField -> { /* Skip info */ } + is BooleanConfigField -> categoryObject.addProperty(field.property.name, field.property.get(objectInstance).value) + is DropdownConfigField -> categoryObject.addProperty(field.property.name, field.property.get(objectInstance).value) + is KeybindConfigField -> categoryObject.add(field.property.name, gson.toJsonTree(field.property.get(objectInstance).value)) + is SliderConfigField -> categoryObject.addProperty(field.property.name, field.property.get(objectInstance).value) + is TextAreaConfigField -> categoryObject.addProperty(field.property.name, field.property.get(objectInstance).value) + is SpinnerConfigField -> categoryObject.addProperty(field.property.name, field.property.get(objectInstance).value) + is ColorPickerConfigField -> categoryObject.addProperty(field.property.name, field.property.get(objectInstance).value) + is TextFieldConfigField -> categoryObject.addProperty(field.property.name, field.property.get(objectInstance).value) + is FolderConfigField -> categoryObject.addProperty(field.property.name, field.property.get(objectInstance).value) + } + } + } + + jsonObject.add(categoryName, categoryObject) + } + + FileWriter(settingsFile).use { writer -> + gson.toJson(jsonObject, writer) + } + + logger.debug("Settings saved to ${settingsFile.absolutePath}") + } catch (e: Exception) { + logger.error("Failed to save settings", e) + } + } + + /** + * Loads setting values from the `settings.json` file into the corresponding + * properties of the registered configuration classes. If the file does not exist, + * default values remain. + */ + fun loadSettings() { + if (!settingsFile.exists()) { + logger.info("Settings file doesn't exist, creating with defaults") + return + } + + try { + FileReader(settingsFile).use { reader -> + val jsonObject = gson.fromJson(reader, JsonObject::class.java) + + configClasses.forEach { (categoryName, configClass) -> + val categoryObject = jsonObject.getAsJsonObject(categoryName) + val objectInstance = configClass.objectInstance + + if (categoryObject != null && objectInstance != null) { + getConfigFields(configClass).forEach { field -> + val jsonElement = categoryObject.get(field.property.name) + if (jsonElement != null && !jsonElement.isJsonNull) { + try { + when (field) { + is ButtonConfigField -> { /* Skip buttons */ } + is InfoConfigField -> { /* Skip info */ } + is BooleanConfigField -> field.property.get(objectInstance).value = jsonElement.asBoolean + is DropdownConfigField -> field.property.get(objectInstance).value = jsonElement.asString + is KeybindConfigField -> field.property.get(objectInstance).value = gson.fromJson(jsonElement, KeyCombination::class.java) + is SliderConfigField -> field.property.get(objectInstance).value = jsonElement.asDouble + is TextAreaConfigField -> field.property.get(objectInstance).value = jsonElement.asString + is SpinnerConfigField -> field.property.get(objectInstance).value = jsonElement.asInt + is ColorPickerConfigField -> field.property.get(objectInstance).value = jsonElement.asString + is TextFieldConfigField -> field.property.get(objectInstance).value = jsonElement.asString + is FolderConfigField -> field.property.get(objectInstance).value = jsonElement.asString + } + logger.debug("Loaded setting: {} = {}", field.name, jsonElement) + } catch (e: Exception) { + logger.warn("Failed to load setting ${field.name}: ${e.message}") + } + } + } + } + } + } + + logger.debug("Settings loaded from ${settingsFile.absolutePath}") + } catch (e: Exception) { + logger.error("Failed to load settings", e) + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigCategory.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigCategory.kt new file mode 100644 index 000000000..e62ed2098 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigCategory.kt @@ -0,0 +1,8 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigCategory( + val name: String, + val desc: String = "" +) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorBoolean.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorBoolean.kt new file mode 100644 index 000000000..0b9d22c1c --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorBoolean.kt @@ -0,0 +1,5 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigEditorBoolean \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorButton.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorButton.kt new file mode 100644 index 000000000..6abdb8cac --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorButton.kt @@ -0,0 +1,7 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigEditorButton( + val text: String +) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorColorPicker.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorColorPicker.kt new file mode 100644 index 000000000..61c6c1060 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorColorPicker.kt @@ -0,0 +1,5 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigEditorColorPicker diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorDropdown.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorDropdown.kt new file mode 100644 index 000000000..ff22c91a7 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorDropdown.kt @@ -0,0 +1,7 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigEditorDropdown( + val values: Array +) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorFolder.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorFolder.kt new file mode 100644 index 000000000..5061130d3 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorFolder.kt @@ -0,0 +1,5 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigEditorFolder \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorInfo.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorInfo.kt new file mode 100644 index 000000000..a24635fba --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorInfo.kt @@ -0,0 +1,5 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigEditorInfo diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorKeybind.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorKeybind.kt new file mode 100644 index 000000000..ad46b7fff --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorKeybind.kt @@ -0,0 +1,5 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigEditorKeybind \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorSlider.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorSlider.kt new file mode 100644 index 000000000..8b2233a3d --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorSlider.kt @@ -0,0 +1,9 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigEditorSlider( + val minValue: Double = 0.0, + val maxValue: Double = 100.0, + val stepSize: Double = 1.0 +) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorSpinner.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorSpinner.kt new file mode 100644 index 000000000..b8c321741 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorSpinner.kt @@ -0,0 +1,8 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigEditorSpinner( + val minValue: Int = 0, + val maxValue: Int = 100 +) diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorText.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorText.kt new file mode 100644 index 000000000..6dcb1728f --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorText.kt @@ -0,0 +1,5 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigEditorTextArea \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorTextField.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorTextField.kt new file mode 100644 index 000000000..a42e67870 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigEditorTextField.kt @@ -0,0 +1,5 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigEditorTextField \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigOption.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigOption.kt new file mode 100644 index 000000000..b27d72e1e --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/ConfigOption.kt @@ -0,0 +1,8 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ConfigOption( + val name: String, + val desc: String = "" +) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/Expose.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/Expose.kt new file mode 100644 index 000000000..a31b770ef --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/Expose.kt @@ -0,0 +1,5 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class Expose \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/SubscribeEvent.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/SubscribeEvent.kt new file mode 100644 index 000000000..8392fc06a --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/annotations/SubscribeEvent.kt @@ -0,0 +1,5 @@ +package io.github.frostzie.nodex.settings.annotations + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class SubscribeEvent \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/AdvancedConfig.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/AdvancedConfig.kt new file mode 100644 index 000000000..eedd8a9bb --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/AdvancedConfig.kt @@ -0,0 +1,51 @@ +package io.github.frostzie.nodex.settings.categories + +import io.github.frostzie.nodex.events.* +import io.github.frostzie.nodex.loader.minecraft.ChatMessageBuilder +import io.github.frostzie.nodex.settings.annotations.* +import javafx.beans.property.SimpleBooleanProperty + +object AdvancedConfig { + + @Expose + @ConfigCategory(name = "Dev") + @ConfigOption(name = "Reload Theme", desc = "Reloads theme file") + @ConfigEditorButton(text = "Reload All Styles") + val reloadStyles: () -> Unit = { EventBus.post(ReloadThemeEvent()) } + + @Expose + @ConfigCategory(name = "Dev") + @ConfigOption(name = "Reset Workspace", desc = "Resets ALL workspace history and returns to start screen") + @ConfigEditorButton(text = "Reset Workspace") + val resetWorkspace: () -> Unit = { EventBus.post(ResetWorkspaceEvent()) } + + @Expose + @ConfigCategory(name = "Debug") + @ConfigOption(name = "Show TreeView Hitboxes", desc = "Shows visual borders around File Tree components for debugging layout") + @ConfigEditorBoolean + val debugTreeViewHitbox = SimpleBooleanProperty(false) + + @Expose + @ConfigCategory(name = "Debug") + @ConfigOption(name = "Show Layout Bounds", desc = "Shows visual borders around major UI components for debugging layout") + @ConfigEditorBoolean + val debugLayoutBounds = SimpleBooleanProperty(false) + + @Expose + @ConfigCategory(name = "Debug") + @ConfigOption(name = "Show Resize Handles", desc = "Shows visual borders for window resize handles") + @ConfigEditorBoolean + val debugResizeHandles = SimpleBooleanProperty(false) + + @Expose + @ConfigCategory(name = "Debug") + @ConfigOption(name = "Test Warning", desc = "Sends a test warning message to chat") + @ConfigEditorButton(text = "Test Warning") + val testWarning: () -> Unit = { ChatMessageBuilder.testWarning() } + + @Expose + @ConfigCategory(name = "Debug") + @ConfigOption(name = "Test Error", desc = "Sends a test error message to chat") + @ConfigEditorButton(text = "Test Error") + val testError: () -> Unit = { ChatMessageBuilder.testError() } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/ExampleConfig.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/ExampleConfig.kt new file mode 100644 index 000000000..3145b8e26 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/ExampleConfig.kt @@ -0,0 +1,191 @@ +package io.github.frostzie.nodex.settings.categories + +import io.github.frostzie.nodex.features.dev.showcase.MessageShowcase +import io.github.frostzie.nodex.features.dev.showcase.NotificationShowcase +import io.github.frostzie.nodex.settings.KeyCombination +import io.github.frostzie.nodex.settings.annotations.* +import javafx.beans.property.SimpleBooleanProperty +import javafx.beans.property.SimpleDoubleProperty +import javafx.beans.property.SimpleIntegerProperty +import javafx.beans.property.SimpleObjectProperty +import javafx.beans.property.SimpleStringProperty +import javafx.scene.input.KeyCode + +@Suppress("unused") +object ExampleConfig { + + @Expose + @ConfigCategory(name = "Example Settings", desc = "Example description") + @ConfigOption(name = "Info", desc = "Pray observe, dear user, this most distinguished informational tile. Though it performs no remarkable feats nor commands the slightest hint of wizardry, it stands proudly as a beacon of gentle guidance. One might employ this panel to impart notes, advisories, or particularly polite warnings to future travellers of the settings menu. Should you choose to bestow upon it a purpose of genuine importance, it shall accept such responsibility with quiet dignity. Until then, let it loiter here in graceful idleness, murmuring softly of its own exemplary usefulness.") + @ConfigEditorInfo + val info = "" + + @Expose + @ConfigCategory(name = "Example Settings") + @ConfigOption(name = "Boolean", desc = "Description") + @ConfigEditorBoolean + val bool = SimpleBooleanProperty(true) + + @Expose + @ConfigCategory(name = "Example Settings") + @ConfigOption(name = "Keybinds", desc = "Description") + @ConfigEditorKeybind + val key = SimpleObjectProperty(KeyCombination(key = KeyCode.S, ctrl = true, alt = true)) + + @Expose + @ConfigCategory(name = "Example Settings") + @ConfigOption(name = "Dropdown", desc = "Description") + @ConfigEditorDropdown(values = ["Option 1", "Option 2", "Option 3", "Option 4"]) + val combo = SimpleStringProperty("Option 3") + + @Expose + @ConfigCategory(name = "Example Settings") + @ConfigOption(name = "Text Area", desc = "Description") + @ConfigEditorTextArea + val text = SimpleStringProperty("Text Area...") + + @Expose + @ConfigCategory(name = "Example Settings") + @ConfigOption(name = "Slider", desc = "Description") + @ConfigEditorSlider(minValue = 5.0, maxValue = 300.0, stepSize = 5.0) + val slider = SimpleDoubleProperty(30.0) + + @Expose + @ConfigCategory(name = "Example Settings") + @ConfigOption(name = "Spinner", desc = "Description") + @ConfigEditorSpinner(minValue = 1, maxValue = 10) + val spinner = SimpleIntegerProperty(5) + + @Expose + @ConfigCategory(name = "Example Settings") + @ConfigOption(name = "Button", desc = "Description") + @ConfigEditorButton(text = "Button") + val button: () -> Unit = {} + + @Expose + @ConfigCategory(name = "Example Settings") + @ConfigOption(name = "Color Picker", desc = "Description") + @ConfigEditorColorPicker + val color = SimpleStringProperty("#FF0000") + + @Expose + @ConfigCategory(name = "Example Settings") + @ConfigOption(name = "Text Field", desc = "Description") + @ConfigEditorTextField + val textField = SimpleStringProperty("Text Field...") + + @Expose + @ConfigCategory(name = "Example Settings") + @ConfigOption(name = "Folder Selector", desc = "Description") + @ConfigEditorFolder + val folder = SimpleStringProperty("") + + @Expose + @ConfigCategory(name = "Message Showcase") + @ConfigOption(name = "Title", desc = "Title of the showcase message" + + "\nYes Art I know you can put inf text here but whats the point of doing that? That's why there is no limit. (Applies to desc too.)") + @ConfigEditorTextField + val messageTitle = SimpleStringProperty("Test Title") + + @Expose + @ConfigCategory(name = "Message Showcase") + @ConfigOption(name = "Description", desc = "Body text of the showcase message") + @ConfigEditorTextArea + val messageDescription = SimpleStringProperty("Description area for the message.") + + @Expose + @ConfigCategory(name = "Message Showcase") + @ConfigOption(name = "Severity", desc = "Visual style of the message") + @ConfigEditorDropdown(values = ["REGULAR", "INFO", "SUCCESS", "WARNING", "DANGER"]) + val messageSeverity = SimpleStringProperty("INFO") + + @Expose + @ConfigCategory(name = "Message Showcase") + @ConfigOption(name = "Position", desc = "Screen corner for the message") + @ConfigEditorDropdown(values = ["TOP_LEFT", "TOP_RIGHT", "BOTTOM_LEFT", "BOTTOM_RIGHT"]) + val messagePosition = SimpleStringProperty("BOTTOM_RIGHT") + + @Expose + @ConfigCategory(name = "Message Showcase") + @ConfigOption(name = "Duration (ms)", desc = "How long the message stays visible") + @ConfigEditorSlider(minValue = 500.0, maxValue = 10000.0, stepSize = 500.0) + val messageDuration = SimpleDoubleProperty(3000.0) + + @Expose + @ConfigCategory(name = "Message Showcase") + @ConfigOption(name = "Width", desc = "Custom width (0 = default)") + @ConfigEditorSlider(minValue = 0.0, maxValue = 600.0, stepSize = 25.0) + val messageWidth = SimpleDoubleProperty(0.0) + + @Expose + @ConfigCategory(name = "Message Showcase") + @ConfigOption(name = "Height", desc = "Custom height (0 = default)") + @ConfigEditorSlider(minValue = 0.0, maxValue = 600.0, stepSize = 25.0) + val messageHeight = SimpleDoubleProperty(0.0) + + @Expose + @ConfigCategory(name = "Message Showcase") + @ConfigOption(name = "Icon", desc = "Icon name (e.g. STAR, WARNING, CHECK) Using Ikonli Material2OutlinedMZ icon pack.") + @ConfigEditorTextField + val messageIcon = SimpleStringProperty("") + + @Expose + @ConfigCategory(name = "Message Showcase") + @ConfigOption(name = "Trigger", desc = "Click to show the message") + @ConfigEditorButton(text = "Show Message") + val showMessage: () -> Unit = { MessageShowcase.showExampleMessage() } + + @Expose + @ConfigCategory(name = "Notification Showcase") + @ConfigOption(name = "Text", desc = "Text content of the notification\nYeah Yeah same thing here art it's fine...") + @ConfigEditorTextArea + val notificationText = SimpleStringProperty("This is a notification.") + + @Expose + @ConfigCategory(name = "Notification Showcase") + @ConfigOption(name = "Severity", desc = "Visual style of the notification") + @ConfigEditorDropdown(values = ["REGULAR", "INFO", "SUCCESS", "WARNING", "DANGER"]) + val notificationSeverity = SimpleStringProperty("INFO") + + @Expose + @ConfigCategory(name = "Notification Showcase") + @ConfigOption(name = "Position", desc = "Screen corner for the notification") + @ConfigEditorDropdown(values = ["TOP_LEFT", "TOP_RIGHT", "BOTTOM_LEFT", "BOTTOM_RIGHT"]) + val notificationPosition = SimpleStringProperty("BOTTOM_RIGHT") + + @Expose + @ConfigCategory(name = "Notification Showcase") + @ConfigOption(name = "Duration (ms)", desc = "How long the notification stays visible") + @ConfigEditorSlider(minValue = 500.0, maxValue = 10000.0, stepSize = 500.0) + val notificationDuration = SimpleDoubleProperty(3000.0) + + @Expose + @ConfigCategory(name = "Notification Showcase") + @ConfigOption(name = "Width", desc = "Custom width (0 = default)") + @ConfigEditorSlider(minValue = 0.0, maxValue = 600.0, stepSize = 25.0) + val notificationWidth = SimpleDoubleProperty(0.0) + + @Expose + @ConfigCategory(name = "Notification Showcase") + @ConfigOption(name = "Height", desc = "Custom height (0 = default)") + @ConfigEditorSlider(minValue = 0.0, maxValue = 600.0, stepSize = 25.0) + val notificationHeight = SimpleDoubleProperty(0.0) + + @Expose + @ConfigCategory(name = "Notification Showcase") + @ConfigOption(name = "Icon", desc = "Icon name (e.g. STAR, WARNING, CHECK) Using Ikonli Material2OutlinedMZ icon pack.") + @ConfigEditorTextField + val notificationIcon = SimpleStringProperty("") + + @Expose + @ConfigCategory(name = "Notification Showcase") + @ConfigOption(name = "Show Actions", desc = "Add example buttons and menu items") + @ConfigEditorBoolean + val notificationShowActions = SimpleBooleanProperty(false) + + @Expose + @ConfigCategory(name = "Notification Showcase") + @ConfigOption(name = "Trigger", desc = "Click to show the notification") + @ConfigEditorButton(text = "Show Notification") + val showNotification: () -> Unit = { NotificationShowcase.showExampleNotification() } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/MainConfig.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/MainConfig.kt new file mode 100644 index 000000000..74bfe1204 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/MainConfig.kt @@ -0,0 +1,117 @@ +package io.github.frostzie.nodex.settings.categories + +import io.github.frostzie.nodex.settings.annotations.* +import io.github.frostzie.nodex.utils.OpenLinks +import javafx.beans.property.SimpleBooleanProperty +import javafx.beans.property.SimpleStringProperty + +object MainConfig { + + // UI Category + @Expose + @ConfigCategory(name = "UI") + @ConfigOption( + name = "Modified Indicator", + desc = "Suffix added on a file in tab when it has unsaved changes." + ) + @ConfigEditorTextField + val dirtyIndicator = SimpleStringProperty(" ●") + + @Expose + @ConfigCategory(name = "UI") + @ConfigOption( + name = "Modified File Color", + desc = "Changes the color of the name of a file that has unsaved changes." + ) + @ConfigEditorColorPicker + val dirtyFileColor = SimpleStringProperty("#f7aeae") + + @Expose //TODO: Fix font size not impacting icon size + @ConfigCategory(name = "UI") + @ConfigOption( + name = "Show File Icons", + desc = "Toggles the visibility of file and folder icons in the File Tree and Editor Tabs. \n Not recommend currently due to incorrect icon sizes!" + ) + @ConfigEditorBoolean + val showFileIcons = SimpleBooleanProperty(false) + + @Expose + @ConfigCategory(name = "UI") + @ConfigOption(name = "Enable Caret Color", desc = "When enable the caret will change to the color selected below.") + @ConfigEditorBoolean + val enableCaretColor = SimpleBooleanProperty(false) + + @Expose + @ConfigCategory(name = "UI") + @ConfigOption(name = "Caret Color", desc = "Change the color of the caret (Typing mouse curser).") + @ConfigEditorColorPicker + val caretColor = SimpleStringProperty("#000000") + + // File Category + @Expose + @ConfigCategory(name = "File") + @ConfigOption(name = "", desc = "") + @ConfigEditorInfo + val infoFile = "" + + @Expose + @ConfigCategory(name = "File") + @ConfigOption(name = "Universal Folder", desc = "Toggles Universal Folder system. When enabled mod will save to directory selected below.") + @ConfigEditorBoolean + val universalFolderToggle = SimpleBooleanProperty(false) + + @Expose + @ConfigCategory(name = "File") + @ConfigOption(name = "Folder Path", desc = "Select where the universal folder would be located.\nIt will create a folder named ``Nodex`` inside selected path, with configs in ``Nodex/config``.") + @ConfigEditorFolder + val universalFolderPath = SimpleStringProperty("") + + @Expose + @ConfigCategory(name = "File") + @ConfigOption(name = "Universal Config", desc = "Select if Config files should be saved to universal folder.") + @ConfigEditorBoolean + val universalConfigToggle = SimpleBooleanProperty(false) + + @Expose + @ConfigCategory(name = "File") + @ConfigOption(name = "Universal Datapack", desc = "Select if Datapack Files should be saved to universal folder.") + @ConfigEditorBoolean + val universalDatapackToggle = SimpleBooleanProperty(false) + + @Expose + @ConfigCategory(name = "File") + @ConfigOption(name = "Sync Configs", desc = "Press to sync with files if universal path has been used.") + @ConfigEditorButton("Sync") + var syncConfigFiles: () -> Unit = {} + + // Link Category + @Expose + @ConfigCategory(name = "Links") + @ConfigOption(name = "Discord Server", desc = "Join our Discord server for support and community!") + @ConfigEditorButton(text = "Discord") + val discordLink: () -> Unit = { OpenLinks.discordLink() } + + @Expose + @ConfigCategory(name = "Links") + @ConfigOption(name = "Github", desc = "Check out the source code and contribute on GitHub!") + @ConfigEditorButton(text = "GitHub") + val githubLink: () -> Unit = { OpenLinks.gitHubLink() } + + @Expose + @ConfigCategory(name = "Links") + @ConfigOption(name = "Report Issues", desc = "Report bugs or issues you encounter!") + @ConfigEditorButton(text = "Report Issue") + val reportBugLink: () -> Unit = { OpenLinks.reportBugLink() } + + @Expose + @ConfigCategory(name = "Links") + @ConfigOption(name = "Support Me", desc = "Support the development of this mod!") + @ConfigEditorButton(text = "Buy Me A Coffee") + val buyMeACoffeeLink: () -> Unit = { OpenLinks.buyMeACoffeeLink() } + + @Expose + @ConfigCategory(name = "Links") + @ConfigOption(name = "Modrinth", desc = "Check out the project on Modrinth!") + @ConfigEditorButton(text = "Modrinth") + val modrinthLink: () -> Unit = { OpenLinks.modrinthLink() } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/MinecraftConfig.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/MinecraftConfig.kt new file mode 100644 index 000000000..43d37fb24 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/MinecraftConfig.kt @@ -0,0 +1,22 @@ +package io.github.frostzie.nodex.settings.categories + +import io.github.frostzie.nodex.settings.annotations.ConfigCategory +import io.github.frostzie.nodex.settings.annotations.ConfigEditorBoolean +import io.github.frostzie.nodex.settings.annotations.ConfigEditorInfo +import io.github.frostzie.nodex.settings.annotations.ConfigOption +import io.github.frostzie.nodex.settings.annotations.Expose +import javafx.beans.property.SimpleBooleanProperty + +object MinecraftConfig { + @Expose + @ConfigCategory(name = "") + @ConfigOption(name = "Info", desc = "This category will contain every settings that directly changes minecraft in someway. Changes might be simply UI or some more impactful.\nAll settings in this category will be by default disabled to keep the vanilla experience intact.") + @ConfigEditorInfo + val info = "" + + @Expose + @ConfigCategory(name = "") + @ConfigOption(name = "On Screen Button", desc = "Adds an on screen IDE button to access the in-game editor from the title screen or pause menu.") + @ConfigEditorBoolean + val onScreenButton = SimpleBooleanProperty(false) +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/ThemeConfig.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/ThemeConfig.kt new file mode 100644 index 000000000..f401b1572 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/categories/ThemeConfig.kt @@ -0,0 +1,112 @@ +package io.github.frostzie.nodex.settings.categories + +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.ImportThemeEvent +import io.github.frostzie.nodex.events.OpenThemeEvent +import io.github.frostzie.nodex.settings.annotations.* +import javafx.beans.property.SimpleIntegerProperty +import javafx.beans.property.SimpleStringProperty + +object ThemeConfig { + + @Expose + @ConfigCategory(name = "Theme") + @ConfigOption(name = "Theme Selection", desc = "Select the application theme.") + @ConfigEditorDropdown( + values = [ + "Primer Light", + "Primer Dark", + "Nord Light", + "Nord Dark", + "Cupertino Light", + "Cupertino Dark", + "Dracula" + ] + ) + val theme = SimpleStringProperty("Primer Dark") + + @Expose + @ConfigCategory(name = "Theme") + @ConfigOption(name = "Font Size", desc = "Adjust the base font size for the UI. (Default: 13)") + @ConfigEditorSpinner(minValue = 8, maxValue = 24) + val fontSize = SimpleIntegerProperty(13) + + @Expose + @ConfigCategory(name = "Custom Themes", desc = "Manage your custom themes.") + @ConfigOption(name = "Import Custom Theme", desc = "Select a .css theme file to import and apply.") + @ConfigEditorButton(text = "Import") + val importTheme: () -> Unit = { EventBus.post(ImportThemeEvent()) } + + @Expose + @ConfigCategory(name = "Custom Themes") + @ConfigOption(name = "Edit Selected Theme", desc = "Open the selected custom theme's CSS file in the editor.") + @ConfigEditorButton(text = "Edit") + val editTheme: () -> Unit = { EventBus.post(OpenThemeEvent()) } + + // Temp + @Expose + @ConfigCategory(name = "Json Colors") + @ConfigOption(name = "Json Syntax Info", desc = "This is a temporary setting until LSP support is added!\nExpect this to be removed and/or changed at a later date!") + @ConfigEditorInfo + val jsonColorInfo = "" + + @Expose + @ConfigCategory(name = "Json Colors") + @ConfigOption(name = "Start/End Object", desc = "Default: #FFD700") + @ConfigEditorColorPicker + val jsonStartObjectColor = SimpleStringProperty("#FFD700") + + @Expose + @ConfigCategory(name = "Json Colors") + @ConfigOption(name = "Property", desc = "Default: #00FFFF") + @ConfigEditorColorPicker + val jsonPropertyColor = SimpleStringProperty("#00FFFF") + + @Expose + @ConfigCategory(name = "Json Colors") + @ConfigOption(name = "String", desc = "Default: #ADFF2F") + @ConfigEditorColorPicker + val jsonStringColor = SimpleStringProperty("#ADFF2F") + + @Expose + @ConfigCategory(name = "Json Colors") + @ConfigOption(name = "Array Start/End", desc = "Default: #7FFFD4") + @ConfigEditorColorPicker + val jsonArrayColor = SimpleStringProperty("#7FFFD4") + + @Expose + @ConfigCategory(name = "Json Colors") + @ConfigOption(name = "Float", desc = "Default: #FF8C00") + @ConfigEditorColorPicker + val jsonFloatColor = SimpleStringProperty("#FF8C00") + + @Expose + @ConfigCategory(name = "Json Colors") + @ConfigOption(name = "Int", desc = "Default: #FF4500") + @ConfigEditorColorPicker + val jsonIntColor = SimpleStringProperty("#FF4500") + + @Expose + @ConfigCategory(name = "Json Colors") + @ConfigOption(name = "Null", desc = "Default: #00008B") + @ConfigEditorColorPicker + val jsonNullColor = SimpleStringProperty("#00008B") + + @Expose + @ConfigCategory(name = "Json Colors") + @ConfigOption(name = "Embedded", desc = "Default: #FF00FF") + @ConfigEditorColorPicker + val jsonEmbeddedColor = SimpleStringProperty("#FF00FF") + + @Expose + @ConfigCategory(name = "Json Colors") + @ConfigOption(name = "True", desc = "Default: #32CD32") + @ConfigEditorColorPicker + val jsonTrueColor = SimpleStringProperty("#32CD32") + + @Expose + @ConfigCategory(name = "Json Colors") + @ConfigOption(name = "False", desc = "Default: #FF6347") + @ConfigEditorColorPicker + val jsonFalseColor = SimpleStringProperty("#FF6347") +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/data/ConfigField.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/data/ConfigField.kt new file mode 100644 index 000000000..4c2fd8b04 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/data/ConfigField.kt @@ -0,0 +1,133 @@ +package io.github.frostzie.nodex.settings.data + +import io.github.frostzie.nodex.settings.KeyCombination +import io.github.frostzie.nodex.settings.annotations.* +import javafx.beans.property.Property +import kotlin.reflect.KProperty1 + +sealed interface ConfigField { + val objectInstance: Any + val property: KProperty1 + val name: String + val description: String + val editorType: EditorType + val category: ConfigCategory? +} + +data class BooleanConfigField( + override val objectInstance: Any, + override val property: KProperty1>, + override val name: String, + override val description: String, + override val category: ConfigCategory? +) : ConfigField { + override val editorType: EditorType = EditorType.BOOLEAN +} + +data class TextAreaConfigField( + override val objectInstance: Any, + override val property: KProperty1>, + override val name: String, + override val description: String, + override val category: ConfigCategory? +) : ConfigField { + override val editorType: EditorType = EditorType.TEXT +} + +data class SliderConfigField( + override val objectInstance: Any, + override val property: KProperty1>, + override val name: String, + override val description: String, + override val category: ConfigCategory?, + val sliderAnnotation: ConfigEditorSlider +) : ConfigField { + override val editorType: EditorType = EditorType.SLIDER +} + +data class DropdownConfigField( + override val objectInstance: Any, + override val property: KProperty1>, + override val name: String, + override val description: String, + override val category: ConfigCategory?, + val dropdownAnnotation: ConfigEditorDropdown +) : ConfigField { + override val editorType: EditorType = EditorType.DROPDOWN +} + +data class KeybindConfigField( + override val objectInstance: Any, + override val property: KProperty1>, + override val name: String, + override val description: String, + override val category: ConfigCategory? +) : ConfigField { + override val editorType: EditorType = EditorType.KEYBIND +} + +data class ButtonConfigField( + override val objectInstance: Any, + override val property: KProperty1 Unit>, + override val name: String, + override val description: String, + override val category: ConfigCategory?, + val buttonAnnotation: ConfigEditorButton +) : ConfigField { + override val editorType: EditorType = EditorType.BUTTON +} + +data class InfoConfigField( + override val objectInstance: Any, + override val property: KProperty1, + override val name: String, + override val description: String, + override val category: ConfigCategory? +) : ConfigField { + override val editorType: EditorType = EditorType.INFO +} + +data class SpinnerConfigField( + override val objectInstance: Any, + override val property: KProperty1>, + override val name: String, + override val description: String, + override val category: ConfigCategory?, + val spinnerAnnotation: ConfigEditorSpinner +) : ConfigField { + override val editorType: EditorType = EditorType.SPINNER +} + +data class ColorPickerConfigField( + override val objectInstance: Any, + override val property: KProperty1>, + override val name: String, + override val description: String, + override val category: ConfigCategory? +) : ConfigField { + override val editorType: EditorType = EditorType.COLOR_PICKER +} + +data class TextFieldConfigField( + override val objectInstance: Any, + override val property: KProperty1>, + override val name: String, + override val description: String, + override val category: ConfigCategory? +) : ConfigField { + override val editorType: EditorType = EditorType.TEXT_FIELD +} + +data class FolderConfigField( + override val objectInstance: Any, + override val property: KProperty1>, + override val name: String, + override val description: String, + override val category: ConfigCategory? +) : ConfigField { + override val editorType: EditorType = EditorType.FOLDER +} + +enum class EditorType { + BOOLEAN, TEXT, SLIDER, DROPDOWN, BUTTON, KEYBIND, INFO, SPINNER, COLOR_PICKER, TEXT_FIELD, FOLDER +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/data/SettingsData.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/data/SettingsData.kt new file mode 100644 index 000000000..49c80220a --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/settings/data/SettingsData.kt @@ -0,0 +1,34 @@ +package io.github.frostzie.nodex.settings.data + +import kotlin.reflect.KClass + +/** + * Search result for settings search functionality + */ +data class SearchResult( + val mainCategory: String, + val subCategory: String, + val field: ConfigField, + val relevanceScore: Int +) + +/** + * Type of category in the settings tree + */ +enum class CategoryType { + ROOT, + MAIN_CATEGORY, + SUB_CATEGORY +} + +/** + * Item in the settings category tree + */ +data class CategoryItem( + val name: String, + val type: CategoryType, + val configClass: KClass<*>? = null, + val subCategory: String? = null +) { + override fun toString(): String = name +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/BaseStyleManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/BaseStyleManager.kt new file mode 100644 index 000000000..72206d1e0 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/BaseStyleManager.kt @@ -0,0 +1,51 @@ +package io.github.frostzie.nodex.styling.common + +import io.github.frostzie.nodex.utils.LoggerProvider + +/** + * A generic, abstract base class that provides the core for any style manager. + * + * This class contains the reusable logic for registering rules, evaluating them based on priority, + * and merging the resulting styles. It is designed to be extended by concrete, singleton + * objects (like `TabStyleManager`) that provide the specific types and merge implementation. + * + * @param C The data context type (e.g., `TextEditorViewModel.TabData`). + * @param S The style model type (e.g., `TabStyle`), which must implement [DynamicStyle]. + * @property emptyStyle An instance of the style model representing a "no style" state. + * @property mergeStyles A lambda function that knows how to merge two instances of the style model. + */ +abstract class BaseStyleManager( + private val emptyStyle: S, + private val mergeStyles: (current: S, new: S) -> S +) { + private val logger = LoggerProvider.getLogger(this::class.simpleName ?: "BaseStyleManager") + private val rules = mutableListOf>() + + /** + * Registers a new style rule with this manager. + * @param rule The rule to add to the registry. + */ + fun registerRule(rule: StyleRule) { + rules.add(rule) + logger.debug("Registered rule: ${rule::class.simpleName} with priority ${rule.priority}") + } + + /** + * Evaluates all applicable rules for the given context and merges them into a single style. + * + * @param context The data context for the component to be styled. + * @return The final, consolidated style model. + */ + open fun evaluate(context: C): S { + val matchingRules = rules + .filter { it.appliesTo(context) } + .sortedBy { it.priority } + + if (matchingRules.isEmpty()) return emptyStyle + + // Reduce the list of styles into a single, merged style using the provided merge function. + return matchingRules.fold(emptyStyle) { merged, rule -> + mergeStyles(merged, rule.getStyle(context)) + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/NotificationMessageArea.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/NotificationMessageArea.kt new file mode 100644 index 000000000..227998f21 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/NotificationMessageArea.kt @@ -0,0 +1,261 @@ +package io.github.frostzie.nodex.styling.common + +import atlantafx.base.controls.Message +import atlantafx.base.controls.Notification +import io.github.frostzie.nodex.styling.messages.NotificationPosition +import javafx.application.Platform +import javafx.geometry.Insets +import javafx.geometry.Pos +import javafx.scene.Node +import javafx.scene.layout.Region +import javafx.scene.layout.StackPane +import javafx.scene.layout.VBox +import java.util.Timer +import kotlin.concurrent.schedule + +/** + * A singleton overlay that manages the display of notifications and Messages in all four corners. + */ +object NotificationMessageArea : StackPane() { + + private val topLeftContainer = createContainer(Pos.TOP_LEFT) + private val topRightContainer = createContainer(Pos.TOP_RIGHT) + private val bottomLeftContainer = createContainer(Pos.BOTTOM_LEFT) + private val bottomRightContainer = createContainer(Pos.BOTTOM_RIGHT) + + init { + // Allow mouse events to pass through the empty parts of the overlay + this.isPickOnBounds = false + this.styleClass.add("notification-overlay") + + children.addAll(topLeftContainer, topRightContainer, bottomLeftContainer, bottomRightContainer) + } + + /** + * Creates a [VBox] container for a specific corner of the screen. + * + * @param alignment The position ([Pos]) where the container will be aligned. + * @return A configured [VBox] ready to hold notifications. + */ + private fun createContainer(alignment: Pos): VBox { + val box = VBox(10.0) // 10px spacing + box.isPickOnBounds = false + box.styleClass.add("notification-container") + box.padding = Insets(50.0, 10.0, 30.0, 50.0) + + box.maxHeight = 400.0 + box.maxWidth = 400.0 + + box.alignment = alignment + setAlignment(box, alignment) + return box + } + + /** + * Shows a message in the overlay. + * @param message The styled Message control. + * @param position The corner to display the message in. + * @param durationMillis Duration in milliseconds to show. -1 for indefinite. + * @param maxMessages The maximum number of messages allowed in the container before old ones are removed. + */ + fun show(message: Message, position: NotificationPosition, durationMillis: Long = 5000, maxMessages: Int = 5) { + // Ensure UI updates happen on JavaFX thread + if (!Platform.isFxApplicationThread()) { + Platform.runLater { show(message, position, durationMillis, maxMessages) } + return + } + + val container = getContainer(position) + + // Enforce container size limits on the message + if (container.maxWidth > 0) { + if (message.minWidth > container.maxWidth) message.minWidth = container.maxWidth + if (message.prefWidth > container.maxWidth) message.prefWidth = container.maxWidth + if (message.maxWidth > container.maxWidth) message.maxWidth = container.maxWidth + } + + if (container.maxHeight > 0) { + if (message.minHeight > container.maxHeight) message.minHeight = container.maxHeight + if (message.prefHeight > container.maxHeight) message.prefHeight = container.maxHeight + if (message.maxHeight > container.maxHeight) message.maxHeight = container.maxHeight + } + + // Manage Capacity (Count) + manageCapacity(container, position, maxMessages) + + // Manage Capacity (Height/Bounds) + manageBounds(container, message, position) + + // Add to container + // Top: Newest on top. + // Bottom: Newest on bottom. + + if (position == NotificationPosition.TOP_LEFT || position == NotificationPosition.TOP_RIGHT) { + container.children.add(0, message) + } else { + container.children.add(message) // Adds to bottom + } + + message.setOnClose { + closeMessage(container, message) + } + + if (durationMillis > 0) { + Timer().schedule(durationMillis) { + Platform.runLater { + if (container.children.contains(message)) { + closeMessage(container, message) + } + } + } + } + } + + /** + * Shows a notification in the overlay. + */ + fun show(notification: Notification, position: NotificationPosition, durationMillis: Long = 5000, maxMessages: Int = 5) { + // Ensure UI updates happen on JavaFX thread + if (!Platform.isFxApplicationThread()) { + Platform.runLater { show(notification, position, durationMillis, maxMessages) } + return + } + + val container = getContainer(position) + + // Enforce container size limits on the notification + if (container.maxWidth > 0) { + if (notification.minWidth > container.maxWidth) notification.minWidth = container.maxWidth + if (notification.prefWidth > container.maxWidth) notification.prefWidth = container.maxWidth + if (notification.maxWidth > container.maxWidth) notification.maxWidth = container.maxWidth + } + + if (container.maxHeight > 0) { + if (notification.minHeight > container.maxHeight) notification.minHeight = container.maxHeight + if (notification.prefHeight > container.maxHeight) notification.prefHeight = container.maxHeight + if (notification.maxHeight > container.maxHeight) notification.maxHeight = container.maxHeight + } + + // Manage Capacity (Count) + manageCapacity(container, position, maxMessages) + + // Manage Capacity (Height/Bounds) + manageBounds(container, notification, position) + + // Add to container + if (position == NotificationPosition.TOP_LEFT || position == NotificationPosition.TOP_RIGHT) { + container.children.add(0, notification) + } else { + container.children.add(notification) + } + + notification.setOnClose { + closeMessage(container, notification) + } + + if (durationMillis > 0) { + Timer().schedule(durationMillis) { + Platform.runLater { + if (container.children.contains(notification)) { + closeMessage(container, notification) + } + } + } + } + } + + private fun getContainer(position: NotificationPosition): VBox = when (position) { + NotificationPosition.TOP_LEFT -> topLeftContainer + NotificationPosition.TOP_RIGHT -> topRightContainer + NotificationPosition.BOTTOM_LEFT -> bottomLeftContainer + NotificationPosition.BOTTOM_RIGHT -> bottomRightContainer + } + + /** + * Manages the number of messages in a container, removing the oldest ones if the `maxMessages` limit is exceeded. + * + * @param container The [VBox] holding the messages. + * @param position The corner position, used to determine which message is "oldest". + * @param maxMessages The maximum number of messages allowed. + */ + private fun manageCapacity(container: VBox, position: NotificationPosition, maxMessages: Int) { + while (container.children.size >= maxMessages) { + removeOldest(container, position) + } + } + + /** + * Manages the total height of messages in a container, removing old messages if adding a new one + * would exceed the container's `maxHeight`. + * + * @param container The [VBox] holding the messages. + * @param newMessage The new [Node] being added. + * @param position The corner position, used to determine which message is "oldest". + */ + private fun manageBounds(container: VBox, newMessage: Node, position: NotificationPosition) { + val maxHeight = container.maxHeight + if (maxHeight <= 0) return // No limit + + val spacing = container.spacing + val newHeight = getEstimatedHeight(newMessage) + + // Calculate current height estimation + var currentHeight = container.children.sumOf { getEstimatedHeight(it) } + if (container.children.isNotEmpty()) currentHeight += (container.children.size - 1) * spacing + + var neededSpace = newHeight + if (container.children.isNotEmpty()) neededSpace += spacing + + // If adding this message exceeds max height, remove old ones + while (currentHeight + neededSpace > maxHeight && container.children.isNotEmpty()) { + removeOldest(container, position) + + // Recalculate height + currentHeight = container.children.sumOf { getEstimatedHeight(it) } + if (container.children.isNotEmpty()) currentHeight += (container.children.size - 1) * spacing + } + } + + /** + * Removes the oldest message from a container. + * For top-aligned containers, the oldest is at the bottom. + * For bottom-aligned containers, the oldest is at the top. + * + * @param container The [VBox] holding the messages. + * @param position The corner position to determine the removal order. + */ + private fun removeOldest(container: VBox, position: NotificationPosition) { + val isTop = (position == NotificationPosition.TOP_LEFT || position == NotificationPosition.TOP_RIGHT) + + // Top: Oldest at end + // Bottom: Oldest at start + val index = if (isTop) container.children.size - 1 else 0 + if (index in container.children.indices) { + closeMessage(container, container.children[index]) + } + } + + /** + * Estimates the height of a [Node]. It prioritizes `prefHeight` if available. + * + * @param node The node to measure. + * @return The estimated height of the node. + */ + private fun getEstimatedHeight(node: Node): Double { + if (node is Region && node.prefHeight != USE_COMPUTED_SIZE) { + return node.prefHeight + } + return node.prefHeight(-1.0) + } + + /** + * Removes a message from its container. + * + * @param container The parent [VBox]. + * @param node The [Node] to remove. + */ + private fun closeMessage(container: VBox, node: Node) { + // TODO: Fade out animation + container.children.remove(node) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/commonModels.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/commonModels.kt new file mode 100644 index 000000000..e9354a7ec --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/common/commonModels.kt @@ -0,0 +1,64 @@ +package io.github.frostzie.nodex.styling.common + +import io.github.frostzie.nodex.styling.tabs.TabStyle +import org.kordamp.ikonli.Ikon + +/** + * A marker interface for all style models (e.g., [TabStyle]). + * This provides a common base type for generic constraints. + */ +interface DynamicStyle + +/** + * A generic interface for a style rule, parameterized by context and style types. + * + * This is the core contract for all styling logic. + * + * @param C The data context type (e.g., `TextEditorViewModel.TabData`). + * @param S The style model type (e.g., `TabStyle`). + */ +interface StyleRule { + /** The priority of the rule, used for sorting and resolving conflicts. */ + val priority: Int + + /** Determines if this rule should be applied to the given context. */ + fun appliesTo(context: C): Boolean + + /** Gets the style to be applied if the rule matches. */ + fun getStyle(context: C): S +} + +/** + * Represents the source of an icon, allowing Ikonli font icons or svgs. + */ +sealed class IconSource { + abstract val size: Int + + /** An icon from the Ikonli font pack. */ + data class IkonIcon(val ikon: Ikon, override val size: Int = 16) : IconSource() + + /** + * An SVG icon, represented by its path to it. + */ + data class SvgIcon(val path: String, override val size: Int = 16) : IconSource() +} + +/** + * Defines standardized priority levels for style rules. + * This creates a contract for the core application, user settings, and plugins + * to ensure styling is applied in a predictable order. + */ +object StylePriority { + /** Applied first, intended for base/default styles. */ + const val BASE = 0 + /** For generic, application-wide rules (e.g., dirty state). */ + const val DEFAULT_RULES = 1 + /** For more specific, application-level rules (e.g., config files, asset files). */ + const val SPECIFIC_RULES = 2 + /** The default priority for a typical plugin rule. */ + const val PLUGIN_DEFAULT = 10 + /** For a plugin that wishes to forcefully override most other styles. */ + const val PLUGIN_OVERRIDE = 20 + /** The absolute highest priority, used internally for "locked" styles. */ + const val FRAMEWORK_FINAL = Int.MAX_VALUE +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageFactory.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageFactory.kt new file mode 100644 index 000000000..c8abb4330 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageFactory.kt @@ -0,0 +1,97 @@ +package io.github.frostzie.nodex.styling.messages + +import atlantafx.base.controls.Message +import io.github.frostzie.nodex.features.FeatureRegistry +import io.github.frostzie.nodex.styling.common.IconSource +import io.github.frostzie.nodex.styling.messages.appliers.MessageStyleApplier +import io.github.frostzie.nodex.styling.common.NotificationMessageArea + +object MessageFactory { + + init { + // Ensure FeatureRegistry is initialized to register default rules + FeatureRegistry + } + + /** + * Creates a new [Message] with styles applied dynamically based on the provided context. + * + * @param title The message title. + * @param description The message body. + * @param severity The severity of the message. + * @param icon Custom icon. + * @return A styled AtlantaFX Message control. + */ + fun create( + title: String, + description: String, + severity: MessageSeverity = MessageSeverity.REGULAR, + icon: IconSource? = null + ): Message { + val message = Message(title, description) + + val context = MessageContext( + title = title, + description = description, + severity = severity, + customIcon = icon + ) + + val style = MessageStyleManager.evaluate(context) + MessageStyleApplier.apply(message, style) + + return message + } + + /** + * Creates and immediately shows a message in the notification overlay. + * + * @param title The title for message. + * @param description The description of message. + * @param severity The severity of the message. + * @param icon Custom icon. + * @param position The corner to display the message in. + * @param durationMillis Duration in milliseconds to show. -1 for indefinite. + * @param width Optional fixed width for the message (Max 400). + * @param height Optional fixed height for the message (Max 400). + * @param maxMessages The maximum number of messages allowed in the container before old ones are removed. + */ + fun createAndShow( + title: String, + description: String, + severity: MessageSeverity = MessageSeverity.REGULAR, + icon: IconSource? = null, + position: NotificationPosition = NotificationPosition.BOTTOM_RIGHT, + durationMillis: Long = 5000, + width: Double? = null, + height: Double? = null, + maxMessages: Int = 5 + ): Message { + val message = create(title, description, severity, icon) + + // Use provided width or default to 350.0 + val finalWidth = width ?: 350.0 + message.prefWidth = finalWidth + message.minWidth = finalWidth + message.maxWidth = finalWidth + + if (height != null) { + val maxHeightLimit = 350.0 + val finalHeight = if (height > maxHeightLimit) maxHeightLimit else height + + message.prefHeight = finalHeight + message.minHeight = finalHeight + message.maxHeight = finalHeight + } + + show(message, position, durationMillis, maxMessages) + return message + } + + /** + * Shows an existing message in the notification overlay. + */ + fun show(message: Message, position: NotificationPosition, durationMillis: Long = 5000, maxMessages: Int = 5) { + NotificationMessageArea.show(message, position, durationMillis, maxMessages) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageModels.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageModels.kt new file mode 100644 index 000000000..6f86e5398 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageModels.kt @@ -0,0 +1,58 @@ +package io.github.frostzie.nodex.styling.messages + +import io.github.frostzie.nodex.styling.common.DynamicStyle +import io.github.frostzie.nodex.styling.common.IconSource +import io.github.frostzie.nodex.styling.common.StyleRule + +/** + * Defines the context/data available to rules when styling a message. + * Plugins can inspect this to decide if they should apply their style. + * + * @property title The header text of the message. + * @property description The satisfied text of the message. + * @property severity The semantic type of the message (Info, Warning, etc.). + * @property customIcon Custom icon. + */ +data class MessageContext( + val title: String, + val description: String, + val severity: MessageSeverity = MessageSeverity.REGULAR, + val customIcon: IconSource? = null +) + +/** + * Represents the semantic severity of a message. + */ +enum class MessageSeverity { + REGULAR, + INFO, + SUCCESS, + WARNING, + DANGER +} + +/** + * Defines where notifications should appear on the screen. + */ +enum class NotificationPosition { + TOP_LEFT, + TOP_RIGHT, + BOTTOM_LEFT, + BOTTOM_RIGHT +} + +/** + * A data class representing the visual style properties of a Message. + * + * @property styleClasses List of CSS style classes to add (e.g., "accent", "success"). + * @property iconSource The icon to display. + */ +data class MessageStyle( + val styleClasses: Set = emptySet(), + val iconSource: IconSource? = null, +) : DynamicStyle + +/** + * A typealias for a [StyleRule] specifically for styling messages. + */ +typealias MessageStyleRule = StyleRule \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageStyleManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageStyleManager.kt new file mode 100644 index 000000000..cab6a1825 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/MessageStyleManager.kt @@ -0,0 +1,27 @@ +package io.github.frostzie.nodex.styling.messages + +import io.github.frostzie.nodex.styling.common.BaseStyleManager +import io.github.frostzie.nodex.utils.LoggerProvider + +/** + * Style manager for application messages/notifications. + */ +object MessageStyleManager : BaseStyleManager( + emptyStyle = MessageStyle(), + mergeStyles = { current, new -> + current.copy( + // Union of style classes to allow composition (e.g., "accent" + "rounded") + styleClasses = current.styleClasses + new.styleClasses, + // New icon overrides old icon + iconSource = new.iconSource ?: current.iconSource + ) + } +) { + private val logger = LoggerProvider.getLogger("MessageStyleManager") + + override fun evaluate(context: MessageContext): MessageStyle { + val result = super.evaluate(context) + logger.debug("Evaluated message style for context: {} -> {}", context, result) + return result + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/appliers/MessageStyleApplier.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/appliers/MessageStyleApplier.kt new file mode 100644 index 000000000..eef6bd9e1 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/appliers/MessageStyleApplier.kt @@ -0,0 +1,56 @@ +package io.github.frostzie.nodex.styling.messages.appliers + +import atlantafx.base.controls.Message +import atlantafx.base.theme.Styles +import io.github.frostzie.nodex.styling.common.IconSource +import io.github.frostzie.nodex.styling.messages.MessageStyle +import io.github.frostzie.nodex.utils.LoggerProvider +import org.kordamp.ikonli.javafx.FontIcon + +import javafx.css.PseudoClass + +/** + * Applies a [MessageStyle] to an AtlantaFX [Message] control. + */ +object MessageStyleApplier { + + private val logger = LoggerProvider.getLogger("MessageStyleApplier") + + private val ACCENT = PseudoClass.getPseudoClass("accent") + private val SUCCESS = PseudoClass.getPseudoClass("success") + private val WARNING = PseudoClass.getPseudoClass("warning") + private val DANGER = PseudoClass.getPseudoClass("danger") + + fun apply(message: Message, style: MessageStyle) { + logger.debug("Applying style to message. Classes: {}, Icon: {}", + style.styleClasses, + style.iconSource + ) + + if (style.styleClasses.isNotEmpty()) { + message.styleClass.addAll(style.styleClasses) + + // Also toggle pseudo-classes if the style class matches standard AtlantaFX names + message.pseudoClassStateChanged(ACCENT, style.styleClasses.contains(Styles.ACCENT)) + message.pseudoClassStateChanged(SUCCESS, style.styleClasses.contains(Styles.SUCCESS)) + message.pseudoClassStateChanged(WARNING, style.styleClasses.contains(Styles.WARNING)) + message.pseudoClassStateChanged(DANGER, style.styleClasses.contains(Styles.DANGER)) + } + + // Apply Icon + when (val icon = style.iconSource) { + is IconSource.IkonIcon -> { + val fontIcon = FontIcon(icon.ikon) + fontIcon.iconSize = 24 // Ensure a visible size + message.graphic = fontIcon + } + is IconSource.SvgIcon -> { + // TODO: Implement SVG icon support + } + null -> { + logger.debug("No icon source provided in style. Clearing graphic.") + message.graphic = null + } + } + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/rules/StandardMessageRules.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/rules/StandardMessageRules.kt new file mode 100644 index 000000000..c9be47cc4 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/messages/rules/StandardMessageRules.kt @@ -0,0 +1,66 @@ +package io.github.frostzie.nodex.styling.messages.rules + +import atlantafx.base.theme.Styles +import io.github.frostzie.nodex.styling.common.StylePriority +import io.github.frostzie.nodex.styling.messages.MessageContext +import io.github.frostzie.nodex.styling.messages.MessageSeverity +import io.github.frostzie.nodex.styling.messages.MessageStyle +import io.github.frostzie.nodex.styling.messages.MessageStyleRule + +/** + * The base rule that applies to all messages. + */ +class DefaultMessageRule : MessageStyleRule { + override val priority: Int = StylePriority.BASE + + override fun appliesTo(context: MessageContext): Boolean = true + + override fun getStyle(context: MessageContext): MessageStyle { + return MessageStyle( iconSource = null ) + } +} + +/** + * Applies a custom icon if one is provided in the context. + */ +class CustomIconRule : MessageStyleRule { + override val priority: Int = StylePriority.SPECIFIC_RULES + + override fun appliesTo(context: MessageContext): Boolean { + return context.customIcon != null + } + + override fun getStyle(context: MessageContext): MessageStyle { + return MessageStyle(iconSource = context.customIcon) + } +} + +/** + * Applies styles based on the message severity (Info, Success, Warning, Danger). + */ +class SeverityMessageRule : MessageStyleRule { + override val priority: Int = StylePriority.DEFAULT_RULES + + override fun appliesTo(context: MessageContext): Boolean { + // Apply to all, so we can handle REGULAR explicitly if needed + return true + } + + override fun getStyle(context: MessageContext): MessageStyle { + return when (context.severity) { + MessageSeverity.INFO -> MessageStyle( + styleClasses = setOf(Styles.ACCENT) + ) + MessageSeverity.SUCCESS -> MessageStyle( + styleClasses = setOf(Styles.SUCCESS) + ) + MessageSeverity.WARNING -> MessageStyle( + styleClasses = setOf(Styles.WARNING) + ) + MessageSeverity.DANGER -> MessageStyle( + styleClasses = setOf(Styles.DANGER) + ) + MessageSeverity.REGULAR -> MessageStyle() + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationFactory.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationFactory.kt new file mode 100644 index 000000000..d796d84c9 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationFactory.kt @@ -0,0 +1,100 @@ +package io.github.frostzie.nodex.styling.notifications + +import atlantafx.base.controls.Notification +import io.github.frostzie.nodex.features.FeatureRegistry +import io.github.frostzie.nodex.styling.common.IconSource +import io.github.frostzie.nodex.styling.common.NotificationMessageArea +import io.github.frostzie.nodex.styling.messages.MessageSeverity +import io.github.frostzie.nodex.styling.messages.NotificationPosition +import io.github.frostzie.nodex.styling.notifications.appliers.NotificationStyleApplier +import javafx.scene.Node +import javafx.scene.layout.Pane +import org.kordamp.ikonli.javafx.FontIcon + +object NotificationFactory { + + init { + // Ensure FeatureRegistry is initialized to register default rules + FeatureRegistry + } + + /** + * Creates a new [Notification] with styles applied dynamically based on the provided context. + */ + fun create( + text: String, + severity: MessageSeverity = MessageSeverity.REGULAR, + icon: IconSource? = null + ): Notification { + val context = NotificationContext( + text = text, + severity = severity, + customIcon = icon + ) + + val style = NotificationStyleManager.evaluate(context) + + val graphicNode: Node? = when (val source = style.iconSource) { + is IconSource.IkonIcon -> { + val fontIcon = FontIcon(source.ikon) + fontIcon.iconSize = 24 + fontIcon + } + is IconSource.SvgIcon -> { null } // TODO: Implement SVG icon support + null -> null + } + + val notification = if (graphicNode != null) Notification(text, graphicNode) else Notification(text) + + NotificationStyleApplier.apply(notification, style) + + return notification + } + + /** + * Creates and immediately shows a notification in the notification overlay. + */ + fun createAndShow( + text: String, + severity: MessageSeverity = MessageSeverity.REGULAR, + icon: IconSource? = null, + position: NotificationPosition = NotificationPosition.BOTTOM_RIGHT, + durationMillis: Long = 5000, + width: Double? = null, + height: Double? = null, + maxMessages: Int = 5 + ): Notification { + val notification = create(text, severity, icon) + + val finalWidth = width ?: 350.0 + notification.prefWidth = finalWidth + notification.minWidth = finalWidth + notification.maxWidth = finalWidth + + if (height != null) { + val maxHeightLimit = 350.0 + val finalHeight = if (height > maxHeightLimit) maxHeightLimit else height + + notification.prefHeight = finalHeight + notification.minHeight = finalHeight + notification.maxHeight = finalHeight + } + + show(notification, position, durationMillis, maxMessages) + return notification + } + + /** + * Shows an existing notification in the notification overlay. + */ + fun show(notification: Notification, position: NotificationPosition, durationMillis: Long = 5000, maxMessages: Int = 5) { + NotificationMessageArea.show(notification, position, durationMillis, maxMessages) + } + + /** + * Hides an existing notification from the notification overlay. + */ + fun hide(notification: Notification) { + (notification.parent as? Pane)?.children?.remove(notification) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationModels.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationModels.kt new file mode 100644 index 000000000..05bba8edf --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationModels.kt @@ -0,0 +1,31 @@ +package io.github.frostzie.nodex.styling.notifications + +import io.github.frostzie.nodex.styling.common.DynamicStyle +import io.github.frostzie.nodex.styling.common.IconSource +import io.github.frostzie.nodex.styling.common.StyleRule +import io.github.frostzie.nodex.styling.messages.MessageSeverity + +/** + * Defines the context/data available to rules when styling a notification. + */ +data class NotificationContext( + val text: String, + val severity: MessageSeverity = MessageSeverity.REGULAR, + val customIcon: IconSource? = null +) + +/** + * A data class representing the visual style properties of a Notification. + * + * @property styleClasses List of CSS style classes to add (e.g., "accent", "success"). + * @property iconSource The icon to display. + */ +data class NotificationStyle( + val styleClasses: Set = emptySet(), + val iconSource: IconSource? = null, +) : DynamicStyle + +/** + * A typealias for a [StyleRule] specifically for styling notifications. + */ +typealias NotificationStyleRule = StyleRule \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationStyleManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationStyleManager.kt new file mode 100644 index 000000000..16e8a66e2 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/NotificationStyleManager.kt @@ -0,0 +1,34 @@ +package io.github.frostzie.nodex.styling.notifications + +import io.github.frostzie.nodex.styling.common.BaseStyleManager +import io.github.frostzie.nodex.styling.notifications.rules.CustomIconNotificationRule +import io.github.frostzie.nodex.styling.notifications.rules.DefaultNotificationRule +import io.github.frostzie.nodex.styling.notifications.rules.SeverityNotificationRule +import io.github.frostzie.nodex.utils.LoggerProvider + +/** + * Style manager for application notifications. + */ +object NotificationStyleManager : BaseStyleManager( + emptyStyle = NotificationStyle(), + mergeStyles = { current, new -> + current.copy( + styleClasses = current.styleClasses + new.styleClasses, + iconSource = new.iconSource ?: current.iconSource + ) + } +) { + private val logger = LoggerProvider.getLogger("NotificationStyleManager") + + init { + registerRule(DefaultNotificationRule()) + registerRule(SeverityNotificationRule()) + registerRule(CustomIconNotificationRule()) + } + + override fun evaluate(context: NotificationContext): NotificationStyle { + val result = super.evaluate(context) + logger.debug("Evaluated notification style for context: {} -> {}", context, result) + return result + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/appliers/NotificationStyleApplier.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/appliers/NotificationStyleApplier.kt new file mode 100644 index 000000000..bb5d48bd8 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/appliers/NotificationStyleApplier.kt @@ -0,0 +1,24 @@ +package io.github.frostzie.nodex.styling.notifications.appliers + +import atlantafx.base.controls.Notification +import io.github.frostzie.nodex.styling.notifications.NotificationStyle +import io.github.frostzie.nodex.utils.LoggerProvider + +/** + * Applies a [NotificationStyle] to an AtlantaFX [Notification] control. + */ +object NotificationStyleApplier { + + private val logger = LoggerProvider.getLogger("NotificationStyleApplier") + + fun apply(notification: Notification, style: NotificationStyle) { + logger.debug("Applying style to notification. Classes: {}, Icon: {}", + style.styleClasses, + style.iconSource + ) + + if (style.styleClasses.isNotEmpty()) { + notification.styleClass.addAll(style.styleClasses) + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/rules/StandardNotificationRules.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/rules/StandardNotificationRules.kt new file mode 100644 index 000000000..6d374b369 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/notifications/rules/StandardNotificationRules.kt @@ -0,0 +1,59 @@ +package io.github.frostzie.nodex.styling.notifications.rules + +import atlantafx.base.theme.Styles +import io.github.frostzie.nodex.styling.common.StylePriority +import io.github.frostzie.nodex.styling.messages.MessageSeverity +import io.github.frostzie.nodex.styling.notifications.NotificationContext +import io.github.frostzie.nodex.styling.notifications.NotificationStyle +import io.github.frostzie.nodex.styling.notifications.NotificationStyleRule + +/** + * The base rule that applies to all notifications. + * Adds ELEVATED_1 by default as per standard styling. + */ +class DefaultNotificationRule : NotificationStyleRule { + override val priority: Int = StylePriority.BASE + + override fun appliesTo(context: NotificationContext): Boolean = true + + override fun getStyle(context: NotificationContext): NotificationStyle { + return NotificationStyle( + styleClasses = setOf(Styles.ELEVATED_1), + iconSource = null + ) + } +} + +/** + * Applies a custom icon if one is provided in the context. + */ +class CustomIconNotificationRule : NotificationStyleRule { + override val priority: Int = StylePriority.SPECIFIC_RULES + + override fun appliesTo(context: NotificationContext): Boolean { + return context.customIcon != null + } + + override fun getStyle(context: NotificationContext): NotificationStyle { + return NotificationStyle(iconSource = context.customIcon) + } +} + +/** + * Applies styles based on the notification severity. + */ +class SeverityNotificationRule : NotificationStyleRule { + override val priority: Int = StylePriority.DEFAULT_RULES + + override fun appliesTo(context: NotificationContext): Boolean = true + + override fun getStyle(context: NotificationContext): NotificationStyle { + return when (context.severity) { + MessageSeverity.INFO -> NotificationStyle(styleClasses = setOf(Styles.ACCENT)) + MessageSeverity.SUCCESS -> NotificationStyle(styleClasses = setOf(Styles.SUCCESS)) + MessageSeverity.WARNING -> NotificationStyle(styleClasses = setOf(Styles.WARNING)) + MessageSeverity.DANGER -> NotificationStyle(styleClasses = setOf(Styles.DANGER)) + MessageSeverity.REGULAR -> NotificationStyle() + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabModels.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabModels.kt new file mode 100644 index 000000000..28036adec --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabModels.kt @@ -0,0 +1,35 @@ +package io.github.frostzie.nodex.styling.tabs + +import io.github.frostzie.nodex.modules.main.TextEditorViewModel +import io.github.frostzie.nodex.styling.common.DynamicStyle +import io.github.frostzie.nodex.styling.common.IconSource +import io.github.frostzie.nodex.styling.common.StyleRule + +/** + * A typealias for a [StyleRule] specifically for styling text editor tabs. + * It uses [TextEditorViewModel.TabData] as its context and [TabStyle] as its style model. + */ +typealias TabStyleRule = StyleRule + +/** + * A data class representing the visual style properties specific to an editor tab. + * + * @property textColor The color of the tab's text. + * @property backgroundColor The background color of the tab's main content area. + * @property isBold Whether the tab's text should be bold. + * @property isItalic Whether the tab's text should be italic. + * @property isUnderline Whether to add an underline to the tab's text. + * @property iconSource The source for the tab's icon (e.g., Ikonli or SVG). + * @property prefix A string to prepend to the tab's label. + * @property isFinal A "lock" from being overridden by lower-priority rules. + */ +data class TabStyle( + val textColor: String? = null, + val backgroundColor: String? = null, + val isBold: Boolean? = null, + val isItalic: Boolean? = null, + val isUnderline: Boolean? = null, + val iconSource: IconSource? = null, + val prefix: String? = null, + val isFinal: Boolean = false +) : DynamicStyle \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabStyleManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabStyleManager.kt new file mode 100644 index 000000000..49f511ea7 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabStyleManager.kt @@ -0,0 +1,31 @@ +package io.github.frostzie.nodex.styling.tabs + +import io.github.frostzie.nodex.modules.main.TextEditorViewModel +import io.github.frostzie.nodex.styling.common.BaseStyleManager + +/** + * Style manager for text editor tabs. + * + * This object extends the [BaseStyleManager], providing the specific types and the merge logic + * required for styling tabs. + */ +object TabStyleManager : BaseStyleManager( + emptyStyle = TabStyle(), + mergeStyles = { current, new -> + if (current.isFinal) { + current + } else { + current.copy( + textColor = new.textColor ?: current.textColor, + backgroundColor = new.backgroundColor ?: current.backgroundColor, + isBold = new.isBold ?: current.isBold, + isItalic = new.isItalic ?: current.isItalic, + isUnderline = new.isUnderline ?: current.isUnderline, + iconSource = new.iconSource ?: current.iconSource, + prefix = new.prefix ?: current.prefix, + // If either style is final, the resulting merged style is final. + isFinal = current.isFinal || new.isFinal + ) + } + } +) \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabStyler.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabStyler.kt new file mode 100644 index 000000000..ed63868a0 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/TabStyler.kt @@ -0,0 +1,46 @@ +package io.github.frostzie.nodex.styling.tabs + +import atlantafx.base.controls.Tab +import io.github.frostzie.nodex.features.editor.EditorTabDecorator +import io.github.frostzie.nodex.modules.main.TextEditorViewModel +import io.github.frostzie.nodex.styling.tabs.appliers.TabStyleApplier +import javafx.beans.value.ChangeListener +import org.fxmisc.richtext.CodeArea + +/** + * An [EditorTabDecorator] that orchestrates the dynamic styling of an editor tab. + * + * This decorator acts as the link between the tab's data and the styling engine. + * It listens for changes in the tab's state (e.g., its dirty status) and triggers + * a re-evaluation of the styles, applying the result to the tab's UI nodes. + */ +class TabStyler : EditorTabDecorator { + + /** + * Applies dynamic styling to the tab and sets up listeners for state changes. + */ + override fun decorate(tab: Tab, codeArea: CodeArea, tabData: TextEditorViewModel.TabData): () -> Unit { + val listener = ChangeListener { _, _, _ -> + updateTabStyle(tab, tabData) + } + + updateTabStyle(tab, tabData) + + tabData.isDirty.addListener(listener) + // Add listeners for other relevant properties as new rules are created. + + return { + tabData.isDirty.removeListener(listener) + // Reset the style to default to ensure no styles linger if the tab is reused. + TabStyleApplier.apply(tab, TabStyle()) + } + } + + /** + * Evaluates the styles for the given tab data and applies them to the tab's UI. + */ + private fun updateTabStyle(tab: Tab, tabData: TextEditorViewModel.TabData) { + val finalStyle = TabStyleManager.evaluate(tabData) + TabStyleApplier.apply(tab, finalStyle) + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/appliers/TabStyleApplier.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/appliers/TabStyleApplier.kt new file mode 100644 index 000000000..fd5a5e127 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/appliers/TabStyleApplier.kt @@ -0,0 +1,65 @@ +package io.github.frostzie.nodex.styling.tabs.appliers + +import atlantafx.base.controls.Tab +import io.github.frostzie.nodex.styling.tabs.TabStyle +import javafx.scene.control.Label +import javafx.scene.layout.Background +import javafx.scene.layout.HBox +import javafx.scene.paint.Color + +/** + * A singleton object responsible for applying a [TabStyle] to the JavaFX nodes of an editor tab. + */ +object TabStyleApplier { + + /** + * Applies the given style to the visual components of a tab. + * + * @param tab The target [Tab] whose graphic will be styled. + * @param style The [TabStyle] containing the properties to apply. + */ + fun apply(tab: Tab, style: TabStyle) { + val graphic = tab.graphic as? HBox ?: return + val label = graphic.children.find { it is Label } as? Label ?: return + + // Text Color + val textColor = style.textColor + if (textColor != null && !textColor.startsWith("-")) { + label.textFill = Color.valueOf(textColor) + } + + // Underline + label.isUnderline = style.isUnderline ?: false + + // Background Color + // We apply the background color to the Tab itself, not the graphic. + // Applying to 'graphic' only colors the content box (label area), leaving gaps. + + // To avoid overwriting the structural background (e.g., the top line/border used by AtlantaFX), + // we set the CSS variable '-color-bg-default' instead of '-fx-background-color'. + if (style.backgroundColor != null) { + tab.style = "-color-bg-default: ${style.backgroundColor};" + } else { + tab.style = "" + } + + // Ensure the graphic doesn't have a conflicting background (though it should be empty/transparent by default) + graphic.background = Background.EMPTY + + // CSS for Font Properties + val fontCss = buildString { + val fontWeight = if (style.isBold == true) "bold" else "normal" + append("-fx-font-weight: $fontWeight;") + + val fontStyle = if (style.isItalic == true) "italic" else "normal" + append("-fx-font-style: $fontStyle;") + + // Re-apply text fill via CSS if it was a variable (e.g. -color-fg-default) + if (textColor != null && textColor.startsWith("-")) { + append("-fx-text-fill: $textColor;") + } + } + + label.style = fontCss + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/ConfigRule.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/ConfigRule.kt new file mode 100644 index 000000000..62e3cc035 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/ConfigRule.kt @@ -0,0 +1,27 @@ +package io.github.frostzie.nodex.styling.tabs.rules + +import io.github.frostzie.nodex.config.ConfigManager +import io.github.frostzie.nodex.modules.main.TextEditorViewModel +import io.github.frostzie.nodex.styling.common.StylePriority +import io.github.frostzie.nodex.styling.tabs.TabStyle +import io.github.frostzie.nodex.styling.tabs.TabStyleRule + +/** + * A style rule that applies a distinct visual style to tabs representing + * files inside the Datapack-ide config folder + */ +class ConfigRule : TabStyleRule { + override val priority: Int = StylePriority.SPECIFIC_RULES + + private val configDirPath = ConfigManager.configDir.toAbsolutePath().toString() + + override fun appliesTo(context: TextEditorViewModel.TabData): Boolean { + val filePath = context.filePath.toAbsolutePath().toString() + return filePath.startsWith(configDirPath) + } + + override fun getStyle(context: TextEditorViewModel.TabData): TabStyle { + // Using a hardcoded color for now. Might allow changing in configs later on. + return TabStyle(textColor = "#ffffff", isItalic = true, backgroundColor = "#755709") + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/DefaultRule.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/DefaultRule.kt new file mode 100644 index 000000000..2caa140f3 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/DefaultRule.kt @@ -0,0 +1,27 @@ +package io.github.frostzie.nodex.styling.tabs.rules + +import io.github.frostzie.nodex.modules.main.TextEditorViewModel +import io.github.frostzie.nodex.styling.common.StylePriority +import io.github.frostzie.nodex.styling.tabs.TabStyle +import io.github.frostzie.nodex.styling.tabs.TabStyleRule + +class DefaultRule : TabStyleRule { + + override val priority: Int = StylePriority.BASE + + /** + * This rule applies to every tab, unconditionally. + */ + override fun appliesTo(context: TextEditorViewModel.TabData): Boolean { + return true + } + + override fun getStyle(context: TextEditorViewModel.TabData): TabStyle { + return TabStyle( + textColor = "-color-fg-default", + isBold = false, + isItalic = false, + isUnderline = false + ) + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/DirtyStateRule.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/DirtyStateRule.kt new file mode 100644 index 000000000..1d9049bdf --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/styling/tabs/rules/DirtyStateRule.kt @@ -0,0 +1,32 @@ +package io.github.frostzie.nodex.styling.tabs.rules + +import io.github.frostzie.nodex.modules.main.TextEditorViewModel +import io.github.frostzie.nodex.settings.categories.MainConfig +import io.github.frostzie.nodex.styling.common.StylePriority +import io.github.frostzie.nodex.styling.tabs.TabStyle +import io.github.frostzie.nodex.styling.tabs.TabStyleRule + +/** + * A style rule that applies a specific text color to editor tabs for files + * that have unsaved changes (are "dirty"). + */ +class DirtyStateRule : TabStyleRule { + /** + * Uses the standardized priority for default application rules. + */ + override val priority: Int = StylePriority.DEFAULT_RULES + + /** + * This rule applies only if the tab's underlying file is dirty. + */ + override fun appliesTo(context: TextEditorViewModel.TabData): Boolean { + return context.isDirty.get() + } + + /** + * Returns a [TabStyle] with the text color set to the user-configured 'dirty file color'. + */ + override fun getStyle(context: TextEditorViewModel.TabData): TabStyle { + return TabStyle(textColor = MainConfig.dirtyFileColor.get()) //TODO: Add bg color? + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/CSSManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/CSSManager.kt new file mode 100644 index 000000000..abc21903c --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/CSSManager.kt @@ -0,0 +1,69 @@ +package io.github.frostzie.nodex.utils + +import javafx.scene.Scene + +/** + * Handles loading and applying stylesheets to scenes and components + */ +object CSSManager { + private val logger = LoggerProvider.getLogger("CSSManager") + + private val cssFiles = listOf( + "Debug.css", + "Override.css", + "FileTreeView.css", + "TextEditorView.css" + ) + + /** + * Applies all known CSS files to a scene. This is the primary method for styling the main application window. + */ + fun applyAllStyles(scene: Scene) { + logger.debug("Applying all ${cssFiles.size} application CSS styles to the scene...") + scene.stylesheets.clear() + applyStyles(scene, *cssFiles.toTypedArray()) + } + + + /** + * Apply specific CSS files to a scene. This is now an internal helper. + */ + private fun applyStyles(scene: Scene, vararg styleNames: String) { + styleNames.forEach { styleName -> + val cssFile = if (styleName.endsWith(".css")) styleName else "$styleName.css" + val resourcePath = "/assets/nodex/themes/styles/$cssFile" + val url = CSSManager::class.java.getResource(resourcePath) + if (url != null) { + scene.stylesheets.add(url.toExternalForm()) + } else { + logger.error("CSS resource not found: $resourcePath") + } + } + logger.debug("Applied ${styleNames.size} CSS styles to scene: ${styleNames.joinToString()}") + } + + /** + * Apply specific popup window styles. + * Use this for individual popup windows to avoid loading all application styles. + */ + fun applyPopupStyles(scene: Scene, vararg styleNames: String) { + applyStyles(scene, *styleNames) + logger.debug("Applied popup styles to scene: ${styleNames.joinToString()}") + } + + /** + * Reloads all CSS files for one or more scenes efficiently. + * It reads each CSS file only once and applies the result to all provided scenes. + */ + fun reloadAllStyles(vararg scenes: Scene) { + if (scenes.isEmpty()) return + logger.info("Reloading all CSS styles for ${scenes.size} scene(s)...") + + scenes.forEach { scene -> + scene.stylesheets.clear() + applyAllStyles(scene) + } + + logger.info("All styles reloaded successfully for ${scenes.size} scene(s).") + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ColorUtils.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ColorUtils.kt new file mode 100644 index 000000000..5cb27fc4b --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ColorUtils.kt @@ -0,0 +1,61 @@ +package io.github.frostzie.nodex.utils + +import javafx.scene.effect.ColorAdjust +import javafx.scene.paint.Color + +/** + * Utility class for color manipulation and conversion to ColorAdjust parameters + */ +object ColorUtils { + private val logger = LoggerProvider.getLogger("ColorUtils") + + /** + * Creates a ColorAdjust effect to transform a WHITE icon to a target color. + * This logic is based on the standard method for colorizing white sources, + * which requires inverting brightness and offsetting hue. + * + * @param hexColor Color in hex format (e.g., "#f0eded", "#FF0000") + * @return ColorAdjust effect that will transform white pixels to the target color + */ + fun createColorAdjustForWhiteIcon(hexColor: String): ColorAdjust { + val color = parseHexColor(hexColor) + + val hueAdjust = (((color.hue + 180) % 360) / 180.0) - 1.0 + val saturationAdjust = color.saturation + + val brightnessAdjust = color.brightness - 1.0 + + return ColorAdjust().apply { + this.hue = hueAdjust + this.saturation = saturationAdjust + this.brightness = brightnessAdjust + contrast = 0.0 + } + } + + /** + * Parses hex color string to JavaFX Color + */ + private fun parseHexColor(hexColor: String): Color { + return try { + val cleanHex = hexColor.removePrefix("#") + when (cleanHex.length) { + 3 -> { + val r = cleanHex[0].toString().repeat(2) + val g = cleanHex[1].toString().repeat(2) + val b = cleanHex[2].toString().repeat(2) + Color.web("#$r$g$b") + } + 6 -> Color.web("#$cleanHex") + 8 -> Color.web("#$cleanHex") + else -> { + logger.warn("Invalid hex color format: $hexColor, using default") + Color.WHITE + } + } + } catch (e: Exception) { + logger.error("Failed to parse hex color: $hexColor", e) + Color.WHITE + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ComponentResizer.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ComponentResizer.kt new file mode 100644 index 000000000..526aab5c3 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ComponentResizer.kt @@ -0,0 +1,80 @@ +package io.github.frostzie.nodex.utils + +import javafx.scene.Cursor +import javafx.scene.input.MouseEvent +import javafx.scene.layout.Region + +/** + * A utility to make a Region component horizontally resizable by dragging its right edge. + */ //TODO: Add other edges for resizing +object ComponentResizer { + + /** + * Installs resizing handlers on a given Region. + * + * @param region The Region to make resizable. + * @param handleWidth The width of the sensitive area on the right edge for resizing. + * @param minWidth The minimum width the region can be resized to. + * @param maxWidth The maximum width the region can be resized to. + */ + fun install( + region: Region, + handleWidth: Double = UIConstants.FILE_TREE_RESIZER_WIDTH, + minWidth: Double, + maxWidth: Double + ) { + var isResizing = false + var resizeStartX = 0.0 + var resizeStartWidth = 0.0 + + fun isOverResizeArea(x: Double): Boolean { + return x >= region.width - handleWidth + } + + region.addEventFilter(MouseEvent.MOUSE_MOVED) { event -> + if (!event.isPrimaryButtonDown && isResizing) { + isResizing = false + } + + if (!isResizing) { + region.cursor = if (isOverResizeArea(event.x)) Cursor.E_RESIZE else Cursor.DEFAULT + } + } + + region.addEventHandler(MouseEvent.MOUSE_PRESSED) { event -> + if (isOverResizeArea(event.x)) { + isResizing = true + resizeStartX = event.screenX + resizeStartWidth = if (region.prefWidth > 0) region.prefWidth else region.width + region.cursor = Cursor.E_RESIZE + event.consume() + } + } + + region.addEventHandler(MouseEvent.MOUSE_DRAGGED) { event -> + if (isResizing) { + val deltaX = event.screenX - resizeStartX + var newWidth = resizeStartWidth + deltaX + + newWidth = newWidth.coerceIn(minWidth, maxWidth) + + region.prefWidth = newWidth + event.consume() + } + } + + region.addEventHandler(MouseEvent.MOUSE_RELEASED) { event -> + if (isResizing) { + isResizing = false + region.cursor = if (isOverResizeArea(event.x)) Cursor.E_RESIZE else Cursor.DEFAULT + event.consume() + } + } + + region.addEventHandler(MouseEvent.MOUSE_EXITED) { + if (!isResizing) { + region.cursor = Cursor.DEFAULT + } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/IconUtils.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/IconUtils.kt new file mode 100644 index 000000000..bacbf1b97 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/IconUtils.kt @@ -0,0 +1,48 @@ +package io.github.frostzie.nodex.utils + +import io.github.frostzie.nodex.styling.common.IconSource +import io.github.frostzie.nodex.svg_icon.SVGImage +import javafx.scene.Node +import javafx.scene.image.ImageView +import javafx.scene.layout.Region +import org.kordamp.ikonli.javafx.FontIcon + +object IconUtils { + private val logger = LoggerProvider.getLogger("IconUtils") + + fun createIcon(source: IconSource): Node { + return when (source) { + is IconSource.IkonIcon -> { + val icon = FontIcon(source.ikon) + icon.iconSize = source.size + icon + } + + is IconSource.SvgIcon -> { + try { + val url = IconUtils::class.java.getResource(source.path) + if (url == null) { + logger.warn("SVG resource not found: ${source.path}") + return Region().apply { + prefWidth = source.size.toDouble() + prefHeight = source.size.toDouble() + } + } + val svgImage = SVGImage.of(url) + val image = svgImage.toImage(source.size, source.size) + ImageView(image).apply { + fitWidth = source.size.toDouble() + fitHeight = source.size.toDouble() + isPreserveRatio = true + } + } catch (e: Exception) { + logger.error("Failed to load SVG icon: ${source.path}", e) + Region().apply { + prefWidth = source.size.toDouble() + prefHeight = source.size.toDouble() + } + } + } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/JavaFXInitializer.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/JavaFXInitializer.kt new file mode 100644 index 000000000..c804dfd64 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/JavaFXInitializer.kt @@ -0,0 +1,95 @@ +package io.github.frostzie.nodex.utils + +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method + +object JavaFXInitializer { + private val logger = LoggerProvider.getLogger("JavaFXInitializer") + private var isJavaFXAvailable: Boolean? = null + private var platformClass: Class<*>? = null + private var startupMethod: Method? = null + private var runLaterMethod: Method? = null + private var setImplicitExitMethod: Method? = null + + fun isJavaFXAvailable(): Boolean { + if (isJavaFXAvailable != null) return isJavaFXAvailable!! + + try { + val classLoaders = listOf( + Thread.currentThread().contextClassLoader, + JavaFXInitializer::class.java.classLoader, + ClassLoader.getSystemClassLoader() + ) + + var lastException: Exception? = null + + for (classLoader in classLoaders) { + try { + platformClass = Class.forName("javafx.application.Platform", true, classLoader) + startupMethod = platformClass?.getMethod("startup", Runnable::class.java) + runLaterMethod = platformClass?.getMethod("runLater", Runnable::class.java) + setImplicitExitMethod = platformClass?.getMethod("setImplicitExit", Boolean::class.javaPrimitiveType) + + isJavaFXAvailable = true + logger.info("JavaFX is available and loaded successfully using classloader: ${classLoader.javaClass.simpleName}") + return true + } catch (e: ClassNotFoundException) { + lastException = e + logger.debug("JavaFX not found with classloader: ${classLoader.javaClass.simpleName}") + continue + } + } + + logger.error("JavaFX is not available on the classpath with any classloader", lastException) + isJavaFXAvailable = false + return false + } catch (e: Exception) { + logger.error("Error checking JavaFX availability", e) + isJavaFXAvailable = false + return false + } + } + + fun startup(runnable: Runnable) { + if (!isJavaFXAvailable()) { + logger.error("Cannot start JavaFX - not available") + return + } + + try { + startupMethod?.invoke(null, runnable) + } catch (e: Exception) { + val isAlreadyInitialized = (e is InvocationTargetException && e.cause is IllegalStateException) || (e is IllegalStateException) + + if (isAlreadyInitialized) { + logger.info("JavaFX Platform already initialized") + runLater(runnable) + } else { + logger.error("Error starting JavaFX platform", e) + } + } + } + + fun runLater(runnable: Runnable) { + if (!isJavaFXAvailable()) { + logger.error("Cannot run JavaFX task - not available") + return + } + + try { + runLaterMethod?.invoke(null, runnable) + } catch (e: Exception) { + logger.error("Error running JavaFX task", e) + } + } + + fun setImplicitExit(implicitExit: Boolean) { + if (!isJavaFXAvailable()) return + + try { + setImplicitExitMethod?.invoke(null, implicitExit) + } catch (e: Exception) { + logger.error("Error setting JavaFX implicit exit", e) + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/LoggerProvider.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/LoggerProvider.kt new file mode 100644 index 000000000..9bc8c46bd --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/LoggerProvider.kt @@ -0,0 +1,16 @@ +package io.github.frostzie.nodex.utils + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +object LoggerProvider { + private const val MAIN = "Nodex" + private val loggers = mutableMapOf() + + fun getLogger(name: String): Logger { + val fullName = "$MAIN:$name" + return loggers.getOrPut(fullName) { + LoggerWrapper(LoggerFactory.getLogger(fullName)) + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/LoggerWrapper.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/LoggerWrapper.kt new file mode 100644 index 000000000..d08b9110d --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/LoggerWrapper.kt @@ -0,0 +1,102 @@ +package io.github.frostzie.nodex.utils + +import io.github.frostzie.nodex.loader.minecraft.ChatMessageBuilder +import org.slf4j.Logger +import org.slf4j.helpers.MessageFormatter + +/** + * A wrapper around a standard SLF4J [Logger] that displays messages in the chat. + * + * This class uses the decorator pattern and Kotlin's delegation feature (`by delegate`) + * to automatically pass all standard logging calls to the underlying logger instance. It then + * specifically overrides the `warn` and `error` methods to add the functionality of sending + * a formatted message to the [ChatMessageBuilder]. + * + * @param delegate The underlying SLF4J [Logger] instance to which all logging calls are forwarded. + */ +class LoggerWrapper(private val delegate: Logger) : Logger by delegate { + + /** + * Constructs a warning message and sends it to the in-game chat. + * @param message The primary warning text. + * @param throwable An optional exception, whose stack trace will be included in the copyable text. + */ + private fun logWarningToChat(message: String?, throwable: Throwable? = null) { + val finalMessage = message ?: "Warning with exception" + val copyableText = if (throwable != null) { + "$finalMessage\n${throwable.stackTraceToString()}" + } else { + finalMessage + } + ChatMessageBuilder.warning(finalMessage, copyableText) + } + + /** + * Constructs an error message and sends it to the in-game chat. + * @param message The primary error text. + * @param throwable An optional exception, whose stack trace will be included in the copyable text. + */ + private fun logErrorToChat(message: String?, throwable: Throwable? = null) { + val finalMessage = message ?: "Error with exception" + val copyableText = if (throwable != null) { + "$finalMessage\n${throwable.stackTraceToString()}" + } else { + finalMessage + } + ChatMessageBuilder.error(finalMessage, copyableText) + } + + // --- WARN --- + + override fun warn(msg: String?) { + delegate.warn(msg) + msg?.let { logWarningToChat(it) } + } + + override fun warn(format: String?, arg: Any?) { + delegate.warn(format, arg) + MessageFormatter.format(format, arg).message?.let { logWarningToChat(it) } + } + + override fun warn(format: String?, arg1: Any?, arg2: Any?) { + delegate.warn(format, arg1, arg2) + MessageFormatter.format(format, arg1, arg2).message?.let { logWarningToChat(it) } + } + + override fun warn(format: String?, vararg arguments: Any?) { + delegate.warn(format, *arguments) + MessageFormatter.arrayFormat(format, arguments).message?.let { logWarningToChat(it) } + } + + override fun warn(msg: String?, t: Throwable?) { + delegate.warn(msg, t) + logWarningToChat(msg, t) + } + + // --- ERROR --- + + override fun error(msg: String?) { + delegate.error(msg) + msg?.let { logErrorToChat(it) } + } + + override fun error(format: String?, arg: Any?) { + delegate.error(format, arg) + MessageFormatter.format(format, arg).message?.let { logErrorToChat(it) } + } + + override fun error(format: String?, arg1: Any?, arg2: Any?) { + delegate.error(format, arg1, arg2) + MessageFormatter.format(format, arg1, arg2).message?.let { logErrorToChat(it) } + } + + override fun error(format: String?, vararg arguments: Any?) { + delegate.error(format, *arguments) + MessageFormatter.arrayFormat(format, arguments).message?.let { logErrorToChat(it) } + } + + override fun error(msg: String?, t: Throwable?) { + delegate.error(msg, t) + logErrorToChat(msg, t) + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/OpenLinks.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/OpenLinks.kt new file mode 100644 index 000000000..6b28f03a4 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/OpenLinks.kt @@ -0,0 +1,27 @@ +package io.github.frostzie.nodex.utils + +import net.minecraft.Util +import java.net.URI + +object OpenLinks { + + fun discordLink() { + Util.getPlatform().openUri(URI("https://discord.gg/qZ885qTvkx")) + } + + fun gitHubLink() { + Util.getPlatform().openUri(URI("https://github.com/Frostzie/Nodex")) + } + + fun reportBugLink() { + Util.getPlatform().openUri(URI("https://github.com/Frostzie/Nodex/issues")) + } + + fun buyMeACoffeeLink() { + Util.getPlatform().openUri(URI("https://www.buymeacoffee.com/frostzie")) + } + + fun modrinthLink() { + Util.getPlatform().openUri(URI("https://modrinth.com/project/XlilVGvF")) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ProjectIconUtils.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ProjectIconUtils.kt new file mode 100644 index 000000000..b98348721 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ProjectIconUtils.kt @@ -0,0 +1,55 @@ +package io.github.frostzie.nodex.utils + +import javafx.scene.Node +import javafx.scene.image.Image +import javafx.scene.image.ImageView +import org.kordamp.ikonli.javafx.FontIcon +import org.kordamp.ikonli.material2.Material2AL +import java.nio.file.Path +import java.util.zip.ZipFile +import kotlin.io.path.exists + +object ProjectIconUtils { + + fun getIcon(path: Path, size: Double): Node { + try { + val isZip = path.toString().endsWith(".zip") + val image = if (isZip) loadFromZip(path, size) else loadFromFolder(path, size) + + if (image != null && !image.isError) { + return ImageView(image).apply { + fitWidth = size + fitHeight = size + } + } + } catch (_: Exception) { + // Fallback + } + + val iconCode = if (path.toString().endsWith(".zip")) Material2AL.ARCHIVE else Material2AL.FOLDER + return FontIcon(iconCode).apply { iconSize = size.toInt() } + } + + private fun loadFromZip(path: Path, size: Double): Image? { + var entryName: String? = null + try { + ZipFile(path.toFile()).use { zip -> + if (zip.getEntry("pack.png") != null) entryName = "pack.png" + else if (zip.getEntry("icon.png") != null) entryName = "icon.png" + } + } catch (_: Exception) { return null } + + return if (entryName != null) { + Image("jar:${path.toUri()}!/$entryName", size, size, true, true) + } else null + } + + private fun loadFromFolder(path: Path, size: Double): Image? { + val iconFile = path.resolve("pack.png").takeIf { it.exists() } + ?: path.resolve("icon.png").takeIf { it.exists() } + + return if (iconFile != null) { + Image(iconFile.toUri().toString(), size, size, true, true) + } else null + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ThemeManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ThemeManager.kt new file mode 100644 index 000000000..ad66a925c --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ThemeManager.kt @@ -0,0 +1,128 @@ +package io.github.frostzie.nodex.utils + +import io.github.frostzie.nodex.config.ConfigManager +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.CloseSettingsEvent +import io.github.frostzie.nodex.events.OpenFile +import io.github.frostzie.nodex.events.OpenThemeEvent +import io.github.frostzie.nodex.events.ThemeChangeEvent +import io.github.frostzie.nodex.settings.categories.ThemeConfig.theme +import javafx.collections.FXCollections +import javafx.collections.ObservableList +import javafx.scene.control.Alert +import javafx.stage.FileChooser +import javafx.stage.Window +import java.io.File +import java.nio.file.Files + +/** + * Manages application themes, including built-in and custom themes. + * This object handles loading, importing, and applying themes. + */ +object ThemeManager { + private val logger = LoggerProvider.getLogger("ThemeManager") + val customThemesDir: File = ConfigManager.configDir.resolve("themes").toFile() + val availableThemes: ObservableList = FXCollections.observableArrayList() + + private val builtInThemes = listOf( + "Primer Light", + "Primer Dark", + "Nord Light", + "Nord Dark", + "Cupertino Light", + "Cupertino Dark", + "Dracula" + ) + + init { + loadThemes() + } + + /** + * Loads all available themes, both built-in and custom, into the [availableThemes] list. + */ + private fun loadThemes() { + availableThemes.clear() + availableThemes.addAll(builtInThemes) + + if (!customThemesDir.exists()) { + customThemesDir.mkdirs() + } + + val customThemes = customThemesDir.listFiles { _, name -> name.endsWith(".css", ignoreCase = true) } + ?.map { it.nameWithoutExtension } + ?: emptyList() + + availableThemes.addAll(customThemes) + } + + /** + * Opens a file chooser to allow the user to import a custom CSS theme file. + * The selected file is copied to the [customThemesDir] and the theme is applied. + * + * @param owner The parent window for the file chooser dialog. + */ + fun importTheme(owner: Window) { + val fileChooser = FileChooser() + fileChooser.title = "Select CSS Theme File" + fileChooser.extensionFilters.add(FileChooser.ExtensionFilter("CSS files (*.css)", "*.css")) + val selectedFile = fileChooser.showOpenDialog(owner) + + selectedFile?.let { file -> + try { + val destFile = customThemesDir.resolve(file.name) + file.copyTo(destFile, overwrite = true) + logger.debug("Copied theme file to ${destFile.absolutePath}") + + val newThemeName = destFile.nameWithoutExtension + if (!availableThemes.contains(newThemeName)) { + availableThemes.add(newThemeName) + } + + // Set the new theme as active in the config and trigger change event + theme.set(newThemeName) + EventBus.post(ThemeChangeEvent(newThemeName)) + + Alert(Alert.AlertType.INFORMATION).apply { + title = "Theme Imported" + headerText = "Successfully imported '${file.name}'." + contentText = "The new theme has been applied." + initOwner(owner) + showAndWait() + } + + } catch (e: Exception) { + logger.error("Failed to import theme '${file.name}'", e) + Alert(Alert.AlertType.ERROR).apply { + title = "Import Failed" + headerText = "Could not import '${file.name}'." + contentText = "An error occurred: ${e.message}" + initOwner(owner) + showAndWait() + } + // Revert to a default theme if something went wrong + EventBus.post(ThemeChangeEvent("Primer Dark")) + } + } + } + + /** + * Handles the [OpenThemeEvent] to open the CSS file of the currently selected custom theme in the editor. + * If the selected theme is a built-in theme, it shows a warning to the user. + * + * @param event The [OpenThemeEvent] that triggered this action. + */ + fun openTheme(event: OpenThemeEvent) { + val themeName = theme.get() + val customThemePath = customThemesDir.toPath().resolve("$themeName.css") + if (Files.exists(customThemePath)) { + EventBus.post(CloseSettingsEvent()) + EventBus.post(OpenFile(customThemePath)) + } else { + val alert = Alert(Alert.AlertType.WARNING) + alert.title = "Cannot Edit Theme" + alert.headerText = "The selected theme '$themeName' is a built-in theme and cannot be edited." + alert.showAndWait() + } + } +} diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ThemeUtils.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ThemeUtils.kt new file mode 100644 index 000000000..06d03fb4f --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ThemeUtils.kt @@ -0,0 +1,32 @@ +package io.github.frostzie.nodex.utils + +import atlantafx.base.theme.* +import io.github.frostzie.nodex.config.ConfigManager +import javafx.application.Application +import kotlin.io.path.exists + +object ThemeUtils { + + fun applyTheme(themeName: String) { + val themeStylesheetUrl = when (themeName) { + "Primer Light" -> PrimerLight().userAgentStylesheet + "Primer Dark" -> PrimerDark().userAgentStylesheet + "Nord Light" -> NordLight().userAgentStylesheet + "Nord Dark" -> NordDark().userAgentStylesheet + "Cupertino Light" -> CupertinoLight().userAgentStylesheet + "Cupertino Dark" -> CupertinoDark().userAgentStylesheet + "Dracula" -> Dracula().userAgentStylesheet + else -> { + val customThemeFile = ConfigManager.configDir.resolve("themes").resolve("$themeName.css") + if (customThemeFile.exists()) { + customThemeFile.toUri().toString() + } else { + // Default to Primer Dark if custom theme not found + //TODO: Show notif of error + PrimerDark().userAgentStylesheet + } + } + } + Application.setUserAgentStylesheet(themeStylesheetUrl) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/UIConstants.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/UIConstants.kt new file mode 100644 index 000000000..964976e4f --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/UIConstants.kt @@ -0,0 +1,53 @@ +package io.github.frostzie.nodex.utils + +/** + * A central place for all UI dimension constants. + * This helps in maintaining a consistent look and feel and makes it easy to adjust the layout. + * Possible that this will no longer be kept and instead, the user could modify the sizes of each element. + */ +object UIConstants { + + // Main Window Layout + const val TOP_BAR_HEIGHT = 40.0 + const val BOTTOM_BAR_HEIGHT = 24.0 + const val LEFT_BAR_WIDTH = 40.0 + const val MIN_CONTENT_WIDTH = 800.0 + const val MIN_CONTENT_HEIGHT = 600.0 + const val DEFAULT_WINDOW_WIDTH = 1200.0 + const val DEFAULT_WINDOW_HEIGHT = 800.0 + const val STAGE_BORDER_WIDTH = 4.0 + + // File Tree View + const val FILE_TREE_DEFAULT_WIDTH = 250.0 + const val FILE_TREE_MIN_WIDTH = 150.0 + const val FILE_TREE_MAX_WIDTH = 600.0 + const val FILE_TREE_RESIZER_WIDTH = 4.0 + const val FILE_TREE_NODE_HEIGHT = 22.0 + const val FILE_TREE_NODE_INDENT = 22.0 + const val FILE_TREE_SPACING = 1.0 + const val FILE_TREE_PADDING = 20.0 + const val FILE_TREE_CELL_SIZE = 25.0 + + // Undecorated Window Resizing + const val WINDOW_RESIZE_BORDER_DEPTH = 7.0 + const val WINDOW_SHADOW_INDENTATION = 0.0 + const val WINDOW_BORDER_WIDTH = 2.0 + + // Settings Window + const val SETTINGS_WIDTH = 900.0 + const val SETTINGS_HEIGHT = 700.0 + const val SETTINGS_MIN_WIDTH = 800.0 + const val SETTINGS_MIN_HEIGHT = 600.0 + const val SETTINGS_SIDE_PANEL_MIN_WIDTH = 180.0 + const val SETTINGS_SIDE_PANEL_MAX_WIDTH = 400.0 + + // New File Window + const val NEW_FILE_WINDOW_FIXED_CELL_SIZE = 28.0 + + // Move Confirmation Dialog + const val MOVE_CONFIRMATION_WIDTH = 700.0 + const val MOVE_CONFIRMATION_HEIGHT = 200.0 + + // Top Bar + const val TOP_BAR_BUTTON_SIZE = 30.0 +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/WindowDrag.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/WindowDrag.kt new file mode 100644 index 000000000..00e6ed23a --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/WindowDrag.kt @@ -0,0 +1,109 @@ +package io.github.frostzie.nodex.utils + +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.MainWindowMaximizedStateChanged +import io.github.frostzie.nodex.events.MainWindowRestore +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import javafx.animation.PauseTransition +import javafx.application.Platform +import javafx.event.EventHandler +import javafx.scene.input.MouseEvent +import javafx.scene.layout.Region +import javafx.stage.Stage +import javafx.util.Duration + +// Original code from: https://stackoverflow.com/questions/18792822/dragging-an-undecorated-stage-in-javafx +// Modified by Frostzie + +object WindowDrag { + private var isMaximized = false + private var isRegistered = false + + init { + EventBus.register(this) + isRegistered = true + } + + @Suppress("unused") + @SubscribeEvent + fun onMaximizedStateChanged(event: MainWindowMaximizedStateChanged) { + isMaximized = event.isMaximized + } + + fun makeDraggable(stage: Stage, byNode: Region, onDragFinished: () -> Unit, shouldDrag: (MouseEvent) -> Boolean = { true }): () -> Unit { + val dragDelta = Delta() + var isDragging = false + val dragDelay = PauseTransition(Duration.millis(150.0)) // 150ms delay so normal clicks won't count as dragging + var pressEvent: MouseEvent? = null + + dragDelay.onFinished = EventHandler { + pressEvent?.let { mouseEvent -> + if (!isMaximized) return@let + + val originalMouseX = mouseEvent.screenX + val originalStageWidth = stage.width + val mouseXRatio = originalMouseX / originalStageWidth + + EventBus.post(MainWindowRestore()) + + Platform.runLater { + val restoredWidth = stage.width + val newX = originalMouseX - (restoredWidth * mouseXRatio) + val newY = mouseEvent.screenY - mouseEvent.y + + stage.x = newX + stage.y = if (newY < 0) 0.0 else newY + + dragDelta.x = stage.x - mouseEvent.screenX + dragDelta.y = stage.y - mouseEvent.screenY + isDragging = true + } + } + } + + val pressHandler = EventHandler { mouseEvent -> + if (!shouldDrag(mouseEvent)) { + return@EventHandler + } + + if (isMaximized) { + pressEvent = mouseEvent + dragDelay.playFromStart() + } else { + dragDelta.x = stage.x - mouseEvent.screenX + dragDelta.y = stage.y - mouseEvent.screenY + isDragging = true + } + } + + val dragHandler = EventHandler { mouseEvent -> + if (isDragging && !isMaximized) { + stage.x = mouseEvent.screenX + dragDelta.x + stage.y = mouseEvent.screenY + dragDelta.y + } + } + + val releaseHandler = EventHandler { + dragDelay.stop() + if (isDragging) { + onDragFinished() + } + isDragging = false + } + + byNode.addEventHandler(MouseEvent.MOUSE_PRESSED, pressHandler) + byNode.addEventHandler(MouseEvent.MOUSE_DRAGGED, dragHandler) + byNode.addEventHandler(MouseEvent.MOUSE_RELEASED, releaseHandler) + + return { + byNode.removeEventHandler(MouseEvent.MOUSE_PRESSED, pressHandler) + byNode.removeEventHandler(MouseEvent.MOUSE_DRAGGED, dragHandler) + byNode.removeEventHandler(MouseEvent.MOUSE_RELEASED, releaseHandler) + } + } + + private class Delta { + var x: Double = 0.0 + var y: Double = 0.0 + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/WindowResizer.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/WindowResizer.kt new file mode 100644 index 000000000..7ed163b32 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/WindowResizer.kt @@ -0,0 +1,148 @@ +package io.github.frostzie.nodex.utils + +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.MainWindowMaximizedStateChanged +import io.github.frostzie.nodex.settings.annotations.SubscribeEvent +import javafx.event.EventHandler +import javafx.geometry.Pos +import javafx.scene.Cursor +import javafx.scene.layout.Pane +import javafx.scene.layout.Region +import javafx.scene.layout.StackPane +import javafx.stage.Stage + +object WindowResizer { + + private var resizeRegions: List = emptyList() + + fun install(stage: Stage, contentPane: Pane, onResizeFinished: () -> Unit): StackPane { + val wrapper = StackPane(contentPane) + val borderThickness = 4.0 + + val top = createResizeRegion(Cursor.N_RESIZE, borderThickness, -1.0) + val bottom = createResizeRegion(Cursor.S_RESIZE, borderThickness, -1.0) + val left = createResizeRegion(Cursor.W_RESIZE, -1.0, borderThickness) + val right = createResizeRegion(Cursor.E_RESIZE, -1.0, borderThickness) + val nw = createResizeRegion(Cursor.NW_RESIZE, borderThickness, borderThickness) + val ne = createResizeRegion(Cursor.NE_RESIZE, borderThickness, borderThickness) + val sw = createResizeRegion(Cursor.SW_RESIZE, borderThickness, borderThickness) + val se = createResizeRegion(Cursor.SE_RESIZE, borderThickness, borderThickness) + + resizeRegions = listOf(top, bottom, left, right, nw, ne, sw, se) + EventBus.register(this) + + val resizeData = ResizeData() + + setupResize(top, resizeData, stage, onResizeFinished, north = true) + setupResize(bottom, resizeData, stage, onResizeFinished, south = true) + setupResize(left, resizeData, stage, onResizeFinished, west = true) + setupResize(right, resizeData, stage, onResizeFinished, east = true) + setupResize(nw, resizeData, stage, onResizeFinished, north = true, west = true) + setupResize(ne, resizeData, stage, onResizeFinished, north = true, east = true) + setupResize(sw, resizeData, stage, onResizeFinished, south = true, west = true) + setupResize(se, resizeData, stage, onResizeFinished, south = true, east = true) + + wrapper.children.addAll(resizeRegions) + + StackPane.setAlignment(top, Pos.TOP_CENTER) + StackPane.setAlignment(bottom, Pos.BOTTOM_CENTER) + StackPane.setAlignment(left, Pos.CENTER_LEFT) + StackPane.setAlignment(right, Pos.CENTER_RIGHT) + StackPane.setAlignment(nw, Pos.TOP_LEFT) + StackPane.setAlignment(ne, Pos.TOP_RIGHT) + StackPane.setAlignment(sw, Pos.BOTTOM_LEFT) + StackPane.setAlignment(se, Pos.BOTTOM_RIGHT) + + return wrapper + } + + @Suppress("unused") + @SubscribeEvent + fun onMaximizedStateChanged(event: MainWindowMaximizedStateChanged) { + resizeRegions.forEach { it.isVisible = !event.isMaximized } + } + + private fun createResizeRegion(cursor: Cursor, height: Double, width: Double): Region { + return Region().apply { + this.cursor = cursor + if (height != -1.0) { + prefHeight = height + maxHeight = height + } + if (width != -1.0) { + prefWidth = width + maxWidth = width + } + styleClass.add("resize-handle-region") + } + } + + private fun setupResize( + region: Region, + data: ResizeData, + stage: Stage, + onResizeFinished: () -> Unit, + north: Boolean = false, + south: Boolean = false, + west: Boolean = false, + east: Boolean = false + ) { + var dragHappened = false + region.onMousePressed = EventHandler { e -> + data.x = e.screenX + data.y = e.screenY + data.width = stage.width + data.height = stage.height + data.stageX = stage.x + data.stageY = stage.y + dragHappened = false + e.consume() + } + + region.onMouseDragged = EventHandler { e -> + dragHappened = true + val deltaX = e.screenX - data.x + val deltaY = e.screenY - data.y + + if (east) { + val newWidth = data.width + deltaX + if (newWidth >= stage.minWidth) stage.width = newWidth + } + if (west) { + val newWidth = data.width - deltaX + if (newWidth >= stage.minWidth) { + stage.width = newWidth + stage.x = data.stageX + deltaX + } + } + if (south) { + val newHeight = data.height + deltaY + if (newHeight >= stage.minHeight) stage.height = newHeight + } + if (north) { + val newHeight = data.height - deltaY + if (newHeight >= stage.minHeight) { + stage.height = newHeight + stage.y = data.stageY + deltaY + } + } + e.consume() + } + + region.onMouseReleased = EventHandler { e -> + if (dragHappened) { + onResizeFinished() + } + e.consume() + } + } + + private class ResizeData { + var x: Double = 0.0 + var y: Double = 0.0 + var width: Double = 0.0 + var height: Double = 0.0 + var stageX: Double = 0.0 + var stageY: Double = 0.0 + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/dev/DebugManager.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/dev/DebugManager.kt new file mode 100644 index 000000000..b34feba8b --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/dev/DebugManager.kt @@ -0,0 +1,29 @@ +package io.github.frostzie.nodex.utils.dev + +import io.github.frostzie.nodex.settings.categories.AdvancedConfig +import javafx.beans.property.BooleanProperty +import javafx.scene.layout.Pane + +object DebugManager { + private var isInitialized = false + + fun initialize(root: Pane) { + if (isInitialized) return + isInitialized = true + applyStyleOnToggle(root, AdvancedConfig.debugLayoutBounds, "debug-layout") + applyStyleOnToggle(root, AdvancedConfig.debugResizeHandles, "debug-resize-handles") + } + + private fun applyStyleOnToggle(pane: Pane, property: BooleanProperty, styleClass: String) { + property.addListener { _, _, newValue -> + if (newValue) { + pane.styleClass.add(styleClass) + } else { + pane.styleClass.remove(styleClass) + } + } + if (property.get()) { + pane.styleClass.add(styleClass) + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/file/DirectoryChooseUtils.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/file/DirectoryChooseUtils.kt new file mode 100644 index 000000000..09dce55e6 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/file/DirectoryChooseUtils.kt @@ -0,0 +1,183 @@ +package io.github.frostzie.nodex.utils.file + +import io.github.frostzie.nodex.events.DirectorySelected +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.loader.minecraft.MCInterface +import io.github.frostzie.nodex.modules.universal.UniversalPackManager +import io.github.frostzie.nodex.settings.categories.MainConfig +import io.github.frostzie.nodex.utils.LoggerProvider +import javafx.stage.DirectoryChooser +import javafx.stage.Window +import java.nio.file.Path +import io.github.frostzie.nodex.project.validation.ProjectValidator +import io.github.frostzie.nodex.project.validation.ValidationResult +import io.github.frostzie.nodex.modules.project.preview.* +import io.github.frostzie.nodex.screen.elements.project.preview.ProjectPreviewView +import javafx.scene.control.Alert +import javafx.scene.control.ButtonBar +import javafx.scene.control.ButtonType +import javafx.scene.control.TextInputDialog +import javafx.stage.FileChooser +import javafx.concurrent.Task +import kotlin.io.path.name + +object DirectoryChooseUtils { + private val logger = LoggerProvider.getLogger("DirectoryChooseUtils") + + /** + * Shows the user to select a project (Folder or Zip), validates it, and fires DirectorySelected. + */ + fun promptOpenProject(ownerWindow: Window?) { + val directoryChooser = DirectoryChooser().apply { + title = "Open Project Folder" + try { + val datapackPath = getDatapackPath() + initialDirectory = if (isSingleplayer() && datapackPath != null) datapackPath.toFile() else getInstancePath()?.toFile() + } catch (e: Exception) { logger.warn("Could not set initial directory", e) } + } + + val selectedFile = directoryChooser.showDialog(ownerWindow) + if (selectedFile != null) { + handleSelection(selectedFile.toPath()) + } + } + + /** + * Shows the user to select a project Zip file, validates it, and fires DirectorySelected. + */ + fun promptOpenZip(ownerWindow: Window?) { + val fileChooser = FileChooser().apply { + title = "Open Project Zip" + extensionFilters.add(FileChooser.ExtensionFilter("Zip Files", "*.zip")) + try { + val datapackPath = getDatapackPath() + initialDirectory = if (isSingleplayer() && datapackPath != null) datapackPath.toFile() else getInstancePath()?.toFile() + } catch (e: Exception) { logger.warn("Could not set initial directory", e) } + } + + val selectedFile = fileChooser.showOpenDialog(ownerWindow) + if (selectedFile != null) { + handleSelection(selectedFile.toPath()) + } + } + + /** + * Call this when a path is selected (from chooser or drag-drop). + */ + fun handleSelection(path: Path) { + val result = ProjectValidator.validate(path) + + val viewModel = when (result) { + is ValidationResult.ValidSingle -> SingleProjectPreviewViewModel(result.path, result.metadata) + is ValidationResult.ValidZip -> ZipProjectPreviewViewModel(result.path, result.metadata) + is ValidationResult.ValidWorkspace -> WorkspaceProjectPreviewViewModel(result.root, result.projects) + is ValidationResult.Invalid -> InvalidProjectPreviewViewModel(result.path, result.reason) + } + + val dialog = ProjectPreviewView(viewModel) + + val button = dialog.showAndWait() + if (button.isPresent && button.get().buttonData == ButtonBar.ButtonData.OK_DONE) { + val task = object : Task() { + override fun call(): Path { + return viewModel.onConfirm() + } + } + + task.setOnSucceeded { + var finalPath = task.value + + // Universal Import Logic + if (MainConfig.universalDatapackToggle.get() && !UniversalPackManager.isUniversalProject(finalPath)) { + val confirm = Alert(Alert.AlertType.CONFIRMATION).apply { + title = "Universal Import" + headerText = "Import to Universal Folder?" + contentText = "Do you want to import '${finalPath.name}' to the Universal Datapack folder?" + buttonTypes.setAll(ButtonType.YES, ButtonType.NO) + }.showAndWait() + + if (confirm.isPresent && confirm.get() == ButtonType.YES) { + try { + finalPath = handleUniversalImport(finalPath) ?: finalPath + } catch (e: Exception) { + logger.error("Failed to import project", e) + Alert(Alert.AlertType.ERROR).apply { + title = "Import Failed" + headerText = "Failed to import project" + contentText = e.message + }.showAndWait() + } + } + } + + EventBus.post(DirectorySelected(finalPath)) + } + + task.setOnFailed { + logger.error("Failed to open/import project", task.exception) + } + + Thread(task).start() //TODO: Add loading indicator + } + } + + //TODO: Move UI out of here + private fun handleUniversalImport(source: Path): Path? { + val folderName = source.name + + if (UniversalPackManager.existsInUniversal(folderName)) { + val renameBtn = ButtonType("Rename") + val overwriteBtn = ButtonType("Overwrite") + val cancelBtn = ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE) + + val alert = Alert(Alert.AlertType.CONFIRMATION).apply { + title = "Conflict" + headerText = "Project already exists" + contentText = "A project named '$folderName' already exists in the Universal Folder." + buttonTypes.setAll(renameBtn, overwriteBtn, cancelBtn) + } + + val result = alert.showAndWait() + if (result.isEmpty || result.get().buttonData == ButtonBar.ButtonData.CANCEL_CLOSE) { + return null + } + + if (result.get() == renameBtn) { + val input = TextInputDialog("$folderName (1)").apply { + title = "Rename Project" + headerText = "Enter a new name for the project:" + graphic = null + } + val nameResult = input.showAndWait() + if (nameResult.isPresent) { + return UniversalPackManager.importProject(source, nameResult.get()) + } + return null + } + } + + return UniversalPackManager.importProject(source, folderName) + } + + /** + * Gets the instance folder path. + */ + fun getInstancePath(): Path? { + return MCInterface.getGamePath() + } + + /** + * Gets the datapack folder path for the current world. + * Returns null if not in singleplayer. + */ + fun getDatapackPath(): Path? { + return MCInterface.getWorldPath()?.resolve("datapacks") + } + + /** + * Checks if the player is in singleplayer (integrated server). + */ + fun isSingleplayer(): Boolean { + return MCInterface.isSingleplayer + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/file/FileSystemWatcher.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/file/FileSystemWatcher.kt new file mode 100644 index 000000000..3956fd214 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/file/FileSystemWatcher.kt @@ -0,0 +1,186 @@ +package io.github.frostzie.nodex.utils.file + +import io.github.frostzie.nodex.events.EventBus +import io.github.frostzie.nodex.events.FileDeleted +import io.github.frostzie.nodex.events.FileModified +import io.github.frostzie.nodex.events.FileMoved +import io.github.frostzie.nodex.utils.LoggerProvider +import io.methvin.watcher.DirectoryChangeEvent +import io.methvin.watcher.DirectoryWatcher +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +data class FileSystemUpdate(val path: Path) + +class FileSystemWatcher(private val watchPath: Path) { + private val logger = LoggerProvider.getLogger("FileSystemWatcher") + private var watcher: DirectoryWatcher? = null + private var watchThread: Thread? = null + private var debounceExecutor: ScheduledExecutorService? = null + private var scheduledUpdate: ScheduledFuture<*>? = null + private val debounceDelayMs = 500L + @Volatile + private var isWindowFocused = true + @Volatile + private var pendingUpdate = false + private val pendingEvents = ConcurrentLinkedQueue() + + private val ignoredPaths = ConcurrentHashMap() + private val ignoreDurationMs = 2000L + private val pendingDeletes = ConcurrentHashMap>() + private val renameDelayMs = 150L + + fun start() { + stop() + + try { + debounceExecutor = Executors.newSingleThreadScheduledExecutor() + + watcher = DirectoryWatcher.builder() + .path(watchPath) + .listener { event -> + if (shouldIgnore(event.path())) { + logger.debug("Ignoring internal change for: {}", event.path()) + return@listener + } + + when (event.eventType()) { + DirectoryChangeEvent.EventType.MODIFY -> { + postOrQueue(FileModified(event.path())) + } + DirectoryChangeEvent.EventType.DELETE -> { + val future = debounceExecutor?.schedule({ + pendingDeletes.remove(event.path()) + postOrQueue(FileDeleted(event.path())) + }, renameDelayMs, TimeUnit.MILLISECONDS) + if (future != null) { + pendingDeletes[event.path()] = future + } + } + DirectoryChangeEvent.EventType.CREATE -> { + // Try to match with a pending delete (rename/move) + // Prefer matching filename (move), otherwise take any (rename) + val oldPath = pendingDeletes.keys.find { it.fileName == event.path().fileName } + ?: pendingDeletes.keys.firstOrNull() + + if (oldPath != null) { + val future = pendingDeletes.remove(oldPath) + future?.cancel(false) + postOrQueue(FileMoved(oldPath, event.path())) + } + } + else -> {} + } + + logger.debug("File system event: {} - {}", event.eventType(), event.path()) + scheduleUpdate() + } + .build() + + watchThread = thread(name = "FileSystemWatcher") { + logger.debug("Started watching directory: {}", watchPath) + try { + watcher?.watch() + } catch (e: Exception) { + // DirectoryWatcher throws on close, so we check if we are stopping + if (watcher != null) { + logger.error("Watcher terminated unexpectedly", e) + } + } + } + } catch (e: Exception) { + logger.error("Failed to start file system watcher", e) + } + } + + fun stop() { + watchThread?.interrupt() + watchThread?.join(5000) // Wait up to 5 seconds for thread to terminate + watchThread = null + scheduledUpdate?.cancel(false) + debounceExecutor?.shutdown() + try { + if (debounceExecutor?.awaitTermination(5, TimeUnit.SECONDS) == false) { + debounceExecutor?.shutdownNow() + } + } catch (ie: InterruptedException) { + debounceExecutor?.shutdownNow() + Thread.currentThread().interrupt() + } + + try { + watcher?.close() + } catch (e: Exception) { + logger.warn("Failed to close watcher cleanly", e) + } + watcher = null + logger.info("Stopped file system watcher") + } + + fun setWindowFocused(focused: Boolean) { + isWindowFocused = focused + if (focused) { + while (!pendingEvents.isEmpty()) { + EventBus.post(pendingEvents.poll()) + } + + if (pendingUpdate) { + try { + triggerUpdate() + } finally { + pendingUpdate = false + } + } + } + } + + fun ignoreChanges(path: Path) { + ignoredPaths[path] = System.currentTimeMillis() + } + + private fun shouldIgnore(path: Path): Boolean { + val now = System.currentTimeMillis() + val iterator = ignoredPaths.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (now - entry.value > ignoreDurationMs) { + iterator.remove() + continue + } + if (path.startsWith(entry.key)) { + return true + } + } + return false + } + + private fun scheduleUpdate() { + scheduledUpdate?.cancel(false) + scheduledUpdate = debounceExecutor?.schedule({ + if (isWindowFocused) { + triggerUpdate() + } else { + pendingUpdate = true + } + }, debounceDelayMs, TimeUnit.MILLISECONDS) + } + + private fun postOrQueue(event: Any) { + if (isWindowFocused) { + EventBus.post(event) + } else { + pendingEvents.add(event) + } + } + + private fun triggerUpdate() { + logger.info("Triggering directory refresh due to file system changes") + EventBus.post(FileSystemUpdate(watchPath)) + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/SettingsControlBuilder.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/SettingsControlBuilder.kt new file mode 100644 index 000000000..49070c4e3 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/SettingsControlBuilder.kt @@ -0,0 +1,255 @@ +package io.github.frostzie.nodex.utils.ui + +import io.github.frostzie.nodex.settings.annotations.ConfigEditorSlider +import io.github.frostzie.nodex.settings.categories.ThemeConfig +import io.github.frostzie.nodex.settings.data.* +import io.github.frostzie.nodex.utils.LoggerProvider +import atlantafx.base.controls.ToggleSwitch +import io.github.frostzie.nodex.utils.ThemeManager +import io.github.frostzie.nodex.utils.ui.controls.KeybindInputButton +import javafx.geometry.Pos +import javafx.scene.control.* +import javafx.scene.layout.HBox +import javafx.scene.layout.Priority +import javafx.scene.layout.Region +import javafx.scene.paint.Color +import javafx.stage.DirectoryChooser +import java.io.File + +/** + * Builder for creating JavaFX controls for different setting types. + */ +object SettingsControlBuilder { + private val logger = LoggerProvider.getLogger("SettingsControlBuilder") + + fun createSliderValueLabel(field: SliderConfigField): Label { + val prop = field.property.get(field.objectInstance) + val sliderAnnotation = field.sliderAnnotation + return Label().apply { + styleClass.add("slider-value") + text = formatSliderLabel(prop.value.toDouble(), sliderAnnotation) + prop.addListener { _, _, newValue -> + text = formatSliderLabel(newValue.toDouble(), sliderAnnotation) + } + } + } + + private fun formatSliderLabel(value: Double, annotation: ConfigEditorSlider): String { + val hasDecimals = annotation.stepSize < 1.0 + return if (hasDecimals) { + "Value: %.2f".format(value) + } + else { + "Value: %d".format(value.toInt()) + } + } + + /** + * Creates a setting tile UI component based on the provided [ConfigField]. + * + * This function inspects the type of the [ConfigField] and constructs an appropriate + * JavaFX control (e.g., a toggle switch for a boolean, a slider for a number) + * wrapped in a standardized tile layout. + * + * @param field The configuration field for which to create a UI tile. + * @return An [HBox] containing the complete UI for the setting. + */ + fun createSettingTile(field: ConfigField): HBox { + return when (field) { + is BooleanConfigField -> { + val prop = field.property.get(field.objectInstance) + val toggleSwitch = ToggleSwitch().apply { + selectedProperty().bindBidirectional(prop) + } + Tiles.DefaultTile(field.name, field.description.takeIf { it.isNotEmpty() }, toggleSwitch) + } + + is KeybindConfigField -> { + val prop = field.property.get(field.objectInstance) + val keybindInputButton = KeybindInputButton().apply { + keybindProperty.bindBidirectional(prop) + } + Tiles.LargeTile(field.name, field.description.takeIf { it.isNotEmpty() }, keybindInputButton) + } + + is DropdownConfigField -> { + val prop = field.property.get(field.objectInstance) + val comboBox = ComboBox().apply { + //TODO: Remove this by make the Theme have their own nicer screen rather then just a dropdown. + if (field.objectInstance is ThemeConfig && field.property.name == "theme") { + items = ThemeManager.availableThemes + } else { + items.addAll(*field.dropdownAnnotation.values) + } + valueProperty().bindBidirectional(prop) + } + Tiles.LargeTile(field.name, field.description.takeIf { it.isNotEmpty() }, comboBox) + } + + is TextAreaConfigField -> { + val prop = field.property.get(field.objectInstance) + val textArea = TextArea().apply { + textFormatter = TextFormatter { change -> + if (change.controlNewText.length > 1000) { + null + } else { + change + } + } + textProperty().bindBidirectional(prop) + isWrapText = true + } + Tiles.LowTile(field.name, field.description.takeIf { it.isNotEmpty() }, textArea) + } + + is SliderConfigField -> { + val prop = field.property.get(field.objectInstance) + val sliderAnnotation = field.sliderAnnotation + val slider = Slider().apply { + min = sliderAnnotation.minValue + max = sliderAnnotation.maxValue + blockIncrement = sliderAnnotation.stepSize + valueProperty().bindBidirectional(prop) + prefWidth = 400.0 + } + val valueLabel = createSliderValueLabel(field) + + val sliderControl = HBox(slider, valueLabel).apply { + spacing = 5.0 + alignment = Pos.CENTER_LEFT + } + Tiles.LowTile(field.name, field.description.takeIf { it.isNotEmpty() }, sliderControl) + } + + is ButtonConfigField -> { + val action = field.property.get(field.objectInstance) + val button = Button(field.buttonAnnotation.text).apply { + styleClass.add("field-button") + minWidth = Region.USE_PREF_SIZE // Makes it so a text can't push buttons + setOnAction { + try { + action.invoke() + logger.debug("Action button '{}' executed.", field.name) + } catch (e: Exception) { + logger.error("Error executing action for button '{}'", field.name, e) + } + } + } + Tiles.LargeTile(field.name, field.description.takeIf { it.isNotEmpty() }, button) + } + + is InfoConfigField -> { + Tiles.InfoTile(field.name, field.description.takeIf { it.isNotEmpty() }) + } + + is SpinnerConfigField -> { + val prop = field.property.get(field.objectInstance) + val spinnerAnnotation = field.spinnerAnnotation + val spinner = Spinner(spinnerAnnotation.minValue, spinnerAnnotation.maxValue, prop.value).apply { + isEditable = true + prefWidth = 120.0 + } + + spinner.valueProperty().addListener { _, _, newValue -> + if (prop.value != newValue) { + prop.value = newValue + } + } + prop.addListener { _, _, newValue -> + if (spinner.value != newValue) { + spinner.valueFactory.value = newValue + } + } + Tiles.LargeTile(field.name, field.description.takeIf { it.isNotEmpty() }, spinner) + } + + is ColorPickerConfigField -> { + val prop = field.property.get(field.objectInstance) + val initialColor = try { + Color.valueOf(prop.value) + } catch (e: Exception) { + logger.warn("Invalid initial color string in settings: ${prop.value}", e) + Color.WHITE + } + val colorPicker = ColorPicker(initialColor) + + // Bind the ColorPicker to the StringProperty + colorPicker.valueProperty().addListener { _, _, newColor -> + val hex = "#%02X%02X%02X".format( + (newColor.red * 255).toInt(), + (newColor.green * 255).toInt(), + (newColor.blue * 255).toInt() + ) + if (prop.value != hex) { + prop.value = hex + } + } + + // Bind the StringProperty to the ColorPicker + prop.addListener { _, _, newHex -> + try { + val newColor = Color.valueOf(newHex) + if (colorPicker.value != newColor) { + colorPicker.value = newColor + } + } catch (e: Exception) { + logger.warn("Invalid color string in settings: $newHex", e) + } + } + Tiles.LargeTile(field.name, field.description.takeIf { it.isNotEmpty() }, colorPicker) + } + + is TextFieldConfigField -> { + val prop = field.property.get(field.objectInstance) + val textField = TextField().apply { + textFormatter = TextFormatter { change -> + if (change.controlNewText.length > 1000) { + null + } else { + change + } + } + textProperty().bindBidirectional(prop) + } + Tiles.LowTile(field.name, field.description.takeIf { it.isNotEmpty() }, textField) + } + + is FolderConfigField -> { + val prop = field.property.get(field.objectInstance) + val textField = TextField().apply { + textProperty().bindBidirectional(prop) + isDisable = true + HBox.setHgrow(this, Priority.ALWAYS) + } + + // Yeah yeah using an icon in code kill me now lol, but for now it's fine until I fix IconSource not* working on buttons + val button = Button("📁").apply { // Also yeah the reason way I changed it was since the UI otherwise gets way too big + styleClass.add("field-button") + setOnAction { + val dirChooser = DirectoryChooser() + dirChooser.title = "Select Folder" + + val currentPath = prop.value + if (!currentPath.isNullOrBlank()) { + val file = File(currentPath) + if (file.exists() && file.isDirectory) { + dirChooser.initialDirectory = file + } + } + + val selectedFile = dirChooser.showDialog(scene.window) + if (selectedFile != null) { + prop.value = selectedFile.absolutePath + } + } + } + + val container = HBox(textField, button).apply { + spacing = 5.0 + alignment = Pos.CENTER_LEFT + } + Tiles.LowTile(field.name, field.description.takeIf { it.isNotEmpty() }, container) + } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/Tiles.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/Tiles.kt new file mode 100644 index 000000000..af30e151e --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/Tiles.kt @@ -0,0 +1,193 @@ +package io.github.frostzie.nodex.utils.ui + +import atlantafx.base.controls.ToggleSwitch +import javafx.geometry.Pos +import javafx.scene.control.Label +import javafx.scene.input.MouseEvent +import javafx.scene.layout.HBox +import javafx.scene.layout.VBox +import javafx.scene.Node +import javafx.scene.control.ComboBox +import javafx.scene.control.TextArea +import javafx.scene.paint.Color +import javafx.scene.layout.Background.EMPTY +import javafx.scene.layout.BackgroundFill +import javafx.scene.layout.CornerRadii +import javafx.geometry.Insets +import javafx.scene.layout.Background +import javafx.scene.layout.Priority +import javafx.scene.layout.Region +//TODO: cleanup eventually and add new config toggles and tiles +class Tiles { + class DefaultTile(title: String, description: String?, toggleSwitch: ToggleSwitch) : HBox() { + + private val titleLabel = Label(title).apply { + isWrapText = true + styleClass.add("title") + } + private val descriptionLabel = Label(description).apply { + isWrapText = true + isVisible = !description.isNullOrBlank() + isManaged = !description.isNullOrBlank() + styleClass.add("description") + } + + private val textContainer = VBox(titleLabel, descriptionLabel).apply { + alignment = Pos.CENTER_LEFT + styleClass.add("text-container") + } + + private val hoveredBackground = Background(BackgroundFill(Color.gray(0.5, 0.1), CornerRadii(4.0), Insets.EMPTY)) + + init { + styleClass.add("tile") + alignment = Pos.CENTER_LEFT + spacing = 10.0 + padding = Insets(10.0) + + textContainer.prefWidth = 600.0 + + children.addAll(textContainer, toggleSwitch) + + addEventHandler(MouseEvent.MOUSE_ENTERED) { + background = hoveredBackground + } + addEventHandler(MouseEvent.MOUSE_EXITED) { + background = EMPTY + } + } + } + + class LargeTile(title: String, description: String?, controlNode: Node) : HBox() { + + private val titleLabel = Label(title).apply { + isWrapText = true + styleClass.add("title") + } + private val descriptionLabel = Label(description).apply { + isWrapText = true + isVisible = !description.isNullOrBlank() + isManaged = !description.isNullOrBlank() + styleClass.add("description") + } + + private val textContainer = VBox(titleLabel, descriptionLabel).apply { + alignment = Pos.CENTER_LEFT + styleClass.add("text-container") + } + + private val hoveredBackground = Background(BackgroundFill(Color.gray(0.5, 0.1), CornerRadii(4.0), Insets.EMPTY)) + + init { + styleClass.add("tile") + alignment = Pos.CENTER_LEFT + spacing = 10.0 + padding = Insets(10.0) + + // Made button alight to right. + val spacer = Region() + setHgrow(spacer, Priority.ALWAYS) + + children.addAll(textContainer, spacer, controlNode) + + if (controlNode is ComboBox<*>) { + controlNode.prefWidth = 150.0 + } + + addEventHandler(MouseEvent.MOUSE_ENTERED) { + background = hoveredBackground + } + addEventHandler(MouseEvent.MOUSE_EXITED) { + background = EMPTY + } + } + } + + class LowTile(title: String, description: String?, controlNode: Node) : HBox() { + + private val titleLabel = Label(title).apply { + isWrapText = true + styleClass.add("title") + } + private val descriptionLabel = Label(description).apply { + isWrapText = true + isVisible = !description.isNullOrBlank() + isManaged = !description.isNullOrBlank() + styleClass.add("description") + } + + private val textContainer = VBox(titleLabel, descriptionLabel).apply { + alignment = Pos.CENTER_LEFT + styleClass.add("text-container") + } + + private val hoveredBackground = Background(BackgroundFill(Color.gray(0.5, 0.1), CornerRadii(4.0), Insets.EMPTY)) + + init { + styleClass.add("tile") + alignment = Pos.TOP_LEFT + spacing = 10.0 + padding = Insets(10.0) + + val stackedContent = VBox(textContainer, controlNode).apply { + spacing = 5.0 + VBox.setVgrow(controlNode, Priority.ALWAYS) + } + + setHgrow(stackedContent, Priority.ALWAYS) + + if (controlNode is TextArea) { + controlNode.prefWidth = 400.0 + controlNode.prefHeight = 100.0 + } + + children.addAll(stackedContent) + + addEventHandler(MouseEvent.MOUSE_ENTERED) { + background = hoveredBackground + } + addEventHandler(MouseEvent.MOUSE_EXITED) { + background = EMPTY + } + } + } + + class InfoTile(title: String, description: String?) : HBox() { + + private val titleLabel = Label(title).apply { + isWrapText = true + styleClass.add("title") + } + private val descriptionLabel = Label(description).apply { + isWrapText = true + isVisible = !description.isNullOrBlank() + isManaged = !description.isNullOrBlank() + styleClass.add("description") + } + + private val textContainer = VBox(titleLabel, descriptionLabel).apply { + alignment = Pos.CENTER_LEFT + styleClass.add("text-container") + } + + private val hoveredBackground = Background(BackgroundFill(Color.gray(0.5, 0.1), CornerRadii(4.0), Insets.EMPTY)) + + init { + styleClass.add("tile") + alignment = Pos.CENTER_LEFT + spacing = 10.0 + padding = Insets(10.0) + + setHgrow(textContainer, Priority.ALWAYS) + + children.addAll(textContainer) + + addEventHandler(MouseEvent.MOUSE_ENTERED) { + background = hoveredBackground + } + addEventHandler(MouseEvent.MOUSE_EXITED) { + background = EMPTY + } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/controls/KeybindInputButton.kt b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/controls/KeybindInputButton.kt new file mode 100644 index 000000000..216c814e0 --- /dev/null +++ b/nodex/src/main/kotlin/io/github/frostzie/nodex/utils/ui/controls/KeybindInputButton.kt @@ -0,0 +1,114 @@ +package io.github.frostzie.nodex.utils.ui.controls + +import io.github.frostzie.nodex.settings.KeyCombination +import javafx.beans.property.SimpleObjectProperty +import javafx.scene.control.Button +import javafx.scene.input.KeyCode +import javafx.scene.text.Font +import javafx.scene.text.Text + +/** + * A custom button that listens for and captures a [KeyCombination]. + * The captured combination is stored in the [keybindProperty]. + */ +class KeybindInputButton : Button() { + + val keybindProperty = SimpleObjectProperty() + + private var isListening = false + + private val defaultFontSize = 15.0 + private val minFontSize = 8.0 + + init { + styleClass.add("keybind-input-button") + + keybindProperty.addListener { _, _, newKeybind -> + text = newKeybind?.toString() ?: "None" + adjustFontSize() + } + + text = keybindProperty.get()?.toString() ?: "None" + adjustFontSize() + + // Not sure if this is the best way of doing it but didn't find much else. + // Set a preferred width to prevent the button from resizing with the text. + // We'll compute it once based on a reasonably long string. + val tempText = Text("Ctrl + Alt + Shift + W") + tempText.font = Font.font(font.family, defaultFontSize) + prefWidth = tempText.layoutBounds.width + padding.left + padding.right + 10 // buffer + + widthProperty().addListener { _, _, _ -> adjustFontSize() } + + setOnAction { event -> + if (!isListening) { + startListening() + } + } + + setOnKeyPressed { event -> + if (isListening) { + event.consume() + + if (event.code == KeyCode.ESCAPE) { + stopListening(false) + return@setOnKeyPressed + } + + if (event.code.isModifierKey || event.code == KeyCode.UNDEFINED || event.code == KeyCode.CAPS) { + return@setOnKeyPressed + } + + val newKeybind = KeyCombination.fromEvent(event) + keybindProperty.set(newKeybind) + stopListening(true) + } + } + + focusedProperty().addListener { _, _, hasFocus -> + if (!hasFocus && isListening) { + stopListening(false) + } + } + } + + private fun startListening() { + isListening = true + text = "Press any key..." + adjustFontSize() + styleClass.add("listening") + } + + private fun stopListening(wasCompleted: Boolean) { + isListening = false + + if (!wasCompleted) { + text = keybindProperty.get()?.toString() ?: "None" + } + adjustFontSize() + styleClass.remove("listening") + } + + private fun adjustFontSize() { + val currentText = text + if (currentText.isNullOrEmpty()) { + font = Font.font(defaultFontSize) // Reset to default if no text + return + } + + var newFontSize = defaultFontSize + val tempTextNode = Text(currentText) + + // Iterate to find a fitting font size + while (newFontSize >= minFontSize) { + tempTextNode.font = Font.font(font.family, newFontSize) + // Subtract padding from width for accurate text fitting + if (tempTextNode.layoutBounds.width <= width - (padding.left + padding.right)) { + break + } + newFontSize -= 0.5 + } + + font = Font.font(font.family, newFontSize) + } +} \ No newline at end of file diff --git a/nodex/src/main/resources/assets/nodex/icon.png b/nodex/src/main/resources/assets/nodex/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fd6e14bb0e7fa976c26c5d7576c55b30b31a3c97 GIT binary patch literal 58338 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%0Dw^J(AW4hnXZ_D0SM_;}KKYtMf*J$`AS1FfXZIEg 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 N;OXk;vd$@?2>|>b^`-y- literal 0 HcmV?d00001 diff --git a/nodex/src/main/resources/assets/nodex/lang/en_us.json b/nodex/src/main/resources/assets/nodex/lang/en_us.json new file mode 100644 index 000000000..2b846ae04 --- /dev/null +++ b/nodex/src/main/resources/assets/nodex/lang/en_us.json @@ -0,0 +1,5 @@ +{ + "key.nodex.toggle_ide": "Opens Nodex window", + "category.nodex.general": "Nodex", + "key.category.nodex.general": "Nodex" +} \ No newline at end of file diff --git a/nodex/src/main/resources/assets/nodex/svg/icon.svg b/nodex/src/main/resources/assets/nodex/svg/icon.svg new file mode 100644 index 000000000..2721cf38e --- /dev/null +++ b/nodex/src/main/resources/assets/nodex/svg/icon.svg @@ -0,0 +1,508 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nodex/src/main/resources/assets/nodex/themes/styles/Debug.css b/nodex/src/main/resources/assets/nodex/themes/styles/Debug.css new file mode 100644 index 000000000..46481e073 --- /dev/null +++ b/nodex/src/main/resources/assets/nodex/themes/styles/Debug.css @@ -0,0 +1,80 @@ +/* TODO: Yeahhhh need to finish this but that's future me problem... */ + +.debug-layout .top-bar-view { + -fx-background-color: -color-bg-default; + -fx-effect: innershadow(one-pass-box, red, 4, 1, 0, 0); +} +.debug-layout .left-bar { + -fx-background-color: -color-bg-default; + -fx-effect: innershadow(one-pass-box, blue, 4, 1, 0, 0); +} +.debug-layout .file-tree-container { + -fx-background-color: -color-bg-default; + -fx-effect: innershadow(one-pass-box, green, 4, 1, 0, 0); +} +.debug-layout .text-editor-container { + -fx-background-color: -color-bg-default; + -fx-effect: innershadow(one-pass-box, yellow, 4, 1, 0, 0); +} +.debug-layout .status-bar { + -fx-background-color: -color-bg-default; + -fx-effect: innershadow(one-pass-box, orange, 4, 1, 0, 0); +} +.debug-layout .status-label { + -fx-background-color: -color-bg-default; + -fx-effect: innershadow(one-pass-box, orangered, 4, 1, 0, 0); +} + +.debug-resize-handles .resize-handle-region { + -fx-background-color: rgba(255, 0, 0, 0.5); +} + +/* --- Settings --- */ +/* */ +.debug-layout .category-scroll { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, blue, 4, 1, 0, 0); } /* Main Settings window */ +.debug-layout .fields-container { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, cyan, 4, 1, 0, 0); } /* Settings content TOTAL */ +.debug-layout .setting-reset-button { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, salmon, 4, 1, 0, 0); } /* Reset button */ +.debug-layout .toggle-switch { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, seagreen, 4, 1, 0, 0); } /* Boolean Toggle */ +.debug-layout .tile .text-container { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, lime, 4, 1, 0, 0); } /* Settings Text area INDIVIDUAL */ +.debug-layout .tile .title { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, yellow, 4, 1, 0, 0); } /* Settings Title INDIVIDUAL */ +.debug-layout .tile .description { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, green, 4, 1, 0, 0); } /* Settings Description INDIVIDUAL */ + + + + +/*.debug-layout .search-section { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, orange, 4, 1, 0, 0); } */ +/*.debug-layout .category-tree { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, yellow, 4, 1, 0, 0); } */ +/*.debug-layout .category-title { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, violet, 4, 1, 0, 0); } */ +/*.debug-layout .subcategory-section { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, purple, 4, 1, 0, 0); } */ +/*.debug-layout .subcategory-header { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, magenta, 4, 1, 0, 0); } */ +/*.debug-layout .subcategory-title { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, pink, 4, 1, 0, 0); } */ +/*.debug-layout .subcategory-description { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, gold, 4, 1, 0, 0); } */ +/*.debug-layout .tile { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, teal, 4, 1, 0, 0); } */ +/*.debug-layout .field-textfield { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, slateblue, 4, 1, 0, 0); } */ +/*.debug-layout .field-slider { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, tomato, 4, 1, 0, 0); } */ +/*.debug-layout .slider-value { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, turquoise, 4, 1, 0, 0); } */ +/*.debug-layout .field-button { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, wheat, 4, 1, 0, 0); } */ +/*.debug-layout .keybind-input-button { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, yellowgreen, 4, 1, 0, 0); } */ + +/* --- File Tree --- */ +/*.debug-layout .tree-cell { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, brown, 4, 1, 0, 0); } +.debug-layout .tree-cell .tree-disclosure-node { -fx-background-color: -color-bg-default; -fx-effect: innershadow(one-pass-box, purple, 4, 1, 0, 0); } */ + +/* --- Notifications --- *//* +.debug-layout .notification-overlay { + -fx-border-color: red; + -fx-border-width: 2; + -fx-border-style: dashed; +} +.debug-layout .notification-container { + -fx-background-color: rgba(255, 0, 255, 0.1); + -fx-effect: innershadow(one-pass-box, magenta, 4, 1, 0, 0); +} +*/ + +/* --- Project Manager --- */ +.debug-layout .start-screen { -fx-border-color: red; -fx-border-width: 1; } +.debug-layout .start-screen-header { -fx-border-color: blue; -fx-border-width: 1; } +.debug-layout .recent-projects-pane { -fx-border-color: green; -fx-border-width: 1; } +.debug-layout .actions-pane { -fx-border-color: yellow; -fx-border-width: 1; } +.debug-layout .actions-box { -fx-border-color: cyan; -fx-border-width: 1; -fx-border-style: dashed; } \ No newline at end of file diff --git a/nodex/src/main/resources/assets/nodex/themes/styles/FileTreeView.css b/nodex/src/main/resources/assets/nodex/themes/styles/FileTreeView.css new file mode 100644 index 000000000..b0a8a0cfd --- /dev/null +++ b/nodex/src/main/resources/assets/nodex/themes/styles/FileTreeView.css @@ -0,0 +1,21 @@ +/* Tree cell styling */ +.file-tree-container .tree-cell { + -fx-padding: 0 0 0 0; + -fx-cell-size: 25px; +} + +/* Default selection color when the TreeView item is not focused */ +.file-tree-container .tree-cell:selected { + -fx-background-color: -color-neutral-subtle; + -fx-text-fill: -color-fg-default; +} + +/* Highlight for valid drop target */ +.file-tree-container .tree-cell.drag-over { + -fx-background-color: -color-accent-subtle; +} + +/* Selection color when the TreeView item is focused */ +.file-tree-container .tree-view:focused .tree-cell:selected { + -fx-background-color: -color-accent-emphasis; +} \ No newline at end of file diff --git a/nodex/src/main/resources/assets/nodex/themes/styles/Override.css b/nodex/src/main/resources/assets/nodex/themes/styles/Override.css new file mode 100644 index 000000000..6cf36e509 --- /dev/null +++ b/nodex/src/main/resources/assets/nodex/themes/styles/Override.css @@ -0,0 +1,36 @@ +.ikonli-font-icon { + -fx-icon-size: 25px; +} + +/* Settings */ +.tile .title { + -fx-font-size: 1.1em; + -fx-font-weight: bold; +} + +.tile .description { + -fx-font-size: 1.0em; +} + +.category-content { + -fx-padding: 0 5px 0 15px; +} + +.category-title { + -fx-font-size: 1.4em; + -fx-font-weight: bold; +} + +.category-content .subcategory-title { + -fx-font-size: 1.3em; + -fx-font-weight: bold; +} + +/* MoveConfirmationView */ +.move-confirmation-dialog .content { + -fx-padding: 10px; +} + +.move-confirmation-dialog .label { + -fx-font-weight: bold; +} \ No newline at end of file diff --git a/nodex/src/main/resources/assets/nodex/themes/styles/TextEditorView.css b/nodex/src/main/resources/assets/nodex/themes/styles/TextEditorView.css new file mode 100644 index 000000000..055827f98 --- /dev/null +++ b/nodex/src/main/resources/assets/nodex/themes/styles/TextEditorView.css @@ -0,0 +1,24 @@ +.code-area { + -fx-background-color: -color-bg-default; +} + +.code-area .text { + -fx-fill: -color-fg-default; +} + +.code-area .lineno { + -fx-background-color: -color-bg-subtle; + -fx-text-fill: -color-fg-muted; +} + +.code-area .caret { + -fx-stroke: -color-fg-default; +} + +.code-area .paragraph-box:has-caret { + -fx-background-color: -color-bg-subtle; +} + +.code-area .selection { + -fx-fill: -color-accent-subtle; +} \ No newline at end of file diff --git a/nodex/src/main/resources/assets/nodex/themes/styles/Window.css b/nodex/src/main/resources/assets/nodex/themes/styles/Window.css new file mode 100644 index 000000000..7e2c8eb3b --- /dev/null +++ b/nodex/src/main/resources/assets/nodex/themes/styles/Window.css @@ -0,0 +1,4 @@ +/* Window Drop Shadow Effect */ +.window { + -fx-effect: dropshadow(three-pass-box, rgb(0,0,0,0.95), 2, 0.6, 0, 1); +} \ No newline at end of file diff --git a/nodex/src/main/resources/fabric.mod.json b/nodex/src/main/resources/fabric.mod.json new file mode 100644 index 000000000..bfb4c5bf6 --- /dev/null +++ b/nodex/src/main/resources/fabric.mod.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "id": "${id}", + "version": "${version}", + "name": "${name}", + "description": "Allows in game data pack editing with a built in text editor screen.", + "authors": [ + "Frostzie" + ], + "contributors": [ + "Arttale", + "Huckle" + ], + "contact": { + "homepage": "https://github.com/Frostzie/Nodex", + "sources": "https://github.com/Frostzie/Nodex", + "issues": "https://github.com/Frostzie/Nodex/issues" + }, + "license": "LGPL-2.1 or later", + "icon": "assets/nodex/icon.png", + "environment": "client", + "entrypoints": { + "main": [ + { + "value": "io.github.frostzie.nodex.Nodex", + "adapter": "kotlin" + } + ] + }, + "mixins": [ + "nodex.mixins.json" + ], + "depends": { + "fabric-language-kotlin": "*", + "fabric-api": "*" + }, + "custom": { + "modmenu": { + "links": { + "modmenu.discord": "https://discord.gg/qZ885qTvkx", + "modmenu.modrinth": "https://modrinth.com/project/XlilVGvF", + "modmenu.buymeacoffee": "https://buymeacoffee.com/frostzie" + } + } + } +} \ No newline at end of file diff --git a/nodex/src/main/resources/nodex.mixins.json b/nodex/src/main/resources/nodex.mixins.json new file mode 100644 index 000000000..e170efcce --- /dev/null +++ b/nodex/src/main/resources/nodex.mixins.json @@ -0,0 +1,18 @@ +{ + "required": true, + "package": "io.github.frostzie.nodex.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "CaseInsensitiveCommandMapMixin" + ], + "injectors": { + "defaultRequire": 1 + }, + "overwrites": { + "requireAnnotations": true + }, + "client": [ + "PauseScreenMixin", + "TitleScreenMixin" + ] +} \ No newline at end of file diff --git a/nodex/stonecutter.gradle.kts b/nodex/stonecutter.gradle.kts new file mode 100644 index 000000000..c41f675f1 --- /dev/null +++ b/nodex/stonecutter.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("dev.kikugie.stonecutter") + id("fabric-loom") version "1.13-SNAPSHOT" apply false + +} + +stonecutter active "1.21.10" + +stonecutter parameters { + swaps["mod_version"] = "\"" + property("mod.version") + "\";" + swaps["minecraft"] = "\"" + node.metadata.version + "\";" +} \ No newline at end of file diff --git a/nodex/versions/1.21.10/gradle.properties b/nodex/versions/1.21.10/gradle.properties new file mode 100644 index 000000000..536492f5f --- /dev/null +++ b/nodex/versions/1.21.10/gradle.properties @@ -0,0 +1,14 @@ +mcVersion=1.21.10 + +deps.fabric_api=0.138.3+1.21.10 + +# Minecraft dependency for fabric.mod.json +mod.mc_dep=>=1.21.9 <=1.21.10 + +# Release title for Modrinth and Curseforge +mod.mc_title=1.21.10 + +fmj.mcDep=~1.21.10 + +# Space-separated compatible versions for publishing +mod.mc_targets=1.21.9 1.21.10 \ No newline at end of file diff --git a/nodex/versions/1.21.8/gradle.properties b/nodex/versions/1.21.8/gradle.properties new file mode 100644 index 000000000..1f3009666 --- /dev/null +++ b/nodex/versions/1.21.8/gradle.properties @@ -0,0 +1,14 @@ +mcVersion=1.21.8 + +deps.fabric_api=0.136.1+1.21.8 + +# Minecraft dependency for fabric.mod.json +mod.mc_dep=>=1.20.5 <=1.21.8 + +# Release title for Modrinth and Curseforge +mod.mc_title=1.21.8 + +fmj.mcDep=~1.21.8 + +# Space-separated compatible versions for publishing +mod.mc_targets=1.20.5 1.21.8 \ No newline at end of file diff --git a/src/main/java/noppes/npcs/client/fx/JavaFxBridge.java b/src/main/java/noppes/npcs/client/fx/JavaFxBridge.java new file mode 100644 index 000000000..6796d974a --- /dev/null +++ b/src/main/java/noppes/npcs/client/fx/JavaFxBridge.java @@ -0,0 +1,106 @@ +package noppes.npcs.client.fx; + +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; +import noppes.npcs.LogWriter; + +import java.io.File; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Small bridge that starts JavaFX once and opens the Nodex-style IDE window. + */ +public final class JavaFxBridge { + + private static final AtomicBoolean started = new AtomicBoolean(false); + private static final AtomicBoolean failed = new AtomicBoolean(false); + + private JavaFxBridge() { + } + + public static boolean isJavaFxAvailable() { + try { + Class.forName("javafx.application.Platform"); + return true; + } catch (Throwable ignored) { + return false; + } + } + + public static boolean openScriptIde(File rootDir) { + if (rootDir == null) { + LogWriter.error("Cannot open JavaFX script IDE: script directory is null"); + return false; + } + + return openScriptIde(rootDir.toPath()); + } + + public static boolean openScriptIde(Path rootPath) { + return openScriptIde(rootPath, null); + } + + public static boolean openScriptIde(File rootDir, NodexScriptBinding binding) { + if (rootDir == null) { + LogWriter.error("Cannot open JavaFX script IDE: script directory is null"); + return false; + } + return openScriptIde(rootDir.toPath(), binding); + } + + public static boolean openScriptIde(Path rootPath, NodexScriptBinding binding) { + if (rootPath == null) { + LogWriter.error("Cannot open JavaFX script IDE: root path is null"); + return false; + } + + if (!isJavaFxAvailable()) { + LogWriter.error("JavaFX is not available in this Java runtime."); + return false; + } + + if (!ensureJavaFxStarted()) { + return false; + } + + Platform.runLater(() -> { + try { + NodexFxWindow.get().open(rootPath, binding); + } catch (Throwable t) { + LogWriter.error("Failed to open JavaFX Nodex window", asException(t)); + } + }); + + return true; + } + + private static boolean ensureJavaFxStarted() { + if (failed.get()) { + return false; + } + + if (started.compareAndSet(false, true)) { + try { + // JavaFX 8 bootstrap path. + new JFXPanel(); + Platform.setImplicitExit(false); + } catch (IllegalStateException alreadyStarted) { + // JavaFX already running, continue. + } catch (Throwable t) { + failed.set(true); + LogWriter.error("Failed to initialize JavaFX platform", asException(t)); + return false; + } + } + + return !failed.get(); + } + + private static Exception asException(Throwable t) { + if (t instanceof Exception) { + return (Exception) t; + } + return new Exception(t); + } +} diff --git a/src/main/java/noppes/npcs/client/fx/NodexFxWindow.java b/src/main/java/noppes/npcs/client/fx/NodexFxWindow.java new file mode 100644 index 000000000..1744ef027 --- /dev/null +++ b/src/main/java/noppes/npcs/client/fx/NodexFxWindow.java @@ -0,0 +1,2329 @@ +package noppes.npcs.client.fx; + +import javafx.animation.PauseTransition; +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.event.ActionEvent; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Label; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuItem; +import javafx.scene.control.Separator; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.SplitPane; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TextInputDialog; +import javafx.scene.control.ToolBar; +import javafx.scene.control.TreeCell; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.DataFormat; +import javafx.scene.input.DragEvent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseButton; +import javafx.scene.input.TransferMode; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.util.Duration; +import noppes.npcs.LogWriter; +import org.fxmisc.flowless.VirtualizedScrollPane; +import org.fxmisc.richtext.CodeArea; +import org.fxmisc.richtext.LineNumberFactory; +import org.fxmisc.richtext.model.StyleSpans; +import org.fxmisc.richtext.model.StyleSpansBuilder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.ClosedWatchServiceException; +import java.nio.file.DirectoryStream; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Nodex-style JavaFX IDE window for script file editing. + * This window intentionally runs side-by-side with the legacy in-game GUI. + */ +public final class NodexFxWindow { + + private static final NodexFxWindow INSTANCE = new NodexFxWindow(); + private static final DataFormat TREE_DRAG_FORMAT = new DataFormat("customnpcs.nodex.tree.path"); + private static final double DEFAULT_DIVIDER = 0.24D; + private static final String VIRTUAL_PREFIX = "__virtual__"; + + private boolean isVirtualPath(Path path) { + return path != null && path.toString().startsWith(VIRTUAL_PREFIX); + } + + private Stage stage; + private SplitPane contentSplit; + private TreeView fileTree; + private TabPane tabPane; + + private Label rootLabel; + private Label cursorLabel; + private Label encodingLabel; + private Label statusLabel; + + private Scene scene; + private Path rootPath; + private double lastDividerPosition = DEFAULT_DIVIDER; + private NodexTheme currentTheme = NodexTheme.DARK_BLUE; + + private final Map openEditors = new HashMap(); + private final Set watchedDirectories = new HashSet(); + private NodexScriptBinding activeBinding; + private Path cssTempFile; + private VBox scriptPanel; + private javafx.scene.control.ListView scriptListView; + private HBox scriptButtonBar; + private List currentTabs = new ArrayList(); + + private WatchService watchService; + private Thread watchThread; + private final AtomicBoolean watcherRunning = new AtomicBoolean(false); + private final AtomicBoolean pendingRefresh = new AtomicBoolean(false); + + private enum LanguageKind { + JAVA, + JS, + JSON, + PLAIN + } + + enum NodexTheme { + DARK_BLUE("Dark Blue"), + DARK_GRAY("Dark Gray"), + MONOKAI("Monokai"), + LIGHT("Light"); + + final String displayName; + NodexTheme(String displayName) { this.displayName = displayName; } + + static NodexTheme fromName(String name) { + for (NodexTheme t : values()) { + if (t.displayName.equals(name)) return t; + } + return DARK_BLUE; + } + } + + private static final class EditorTab { + Path path; + final Tab tab; + final CodeArea area; + final PauseTransition highlightDebounce; + final ChangeListener caretListener; + boolean dirty; + long lastModifiedMillis; + + // Virtual tab fields + String virtualId; + String displayName; + LanguageKind languageOverride; + int virtualTabIndex = -1; + + EditorTab(Path path, Tab tab, CodeArea area, PauseTransition debounce, ChangeListener caretListener) { + this.path = path; + this.tab = tab; + this.area = area; + this.highlightDebounce = debounce; + this.caretListener = caretListener; + this.dirty = false; + this.lastModifiedMillis = 0L; + } + + boolean isVirtual() { + return path == null && virtualId != null; + } + + String editorKey() { + if (virtualId != null) return virtualId; + return path != null ? path.toAbsolutePath().normalize().toString() : ""; + } + } + + private enum EditorAction { + UNDO, + REDO, + CUT, + COPY, + PASTE, + SELECT_ALL + } + + private static final String[] JAVA_KEYWORDS = new String[]{ + "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const", "continue", + "default", "do", "double", "else", "enum", "extends", "finally", "float", "for", "goto", "if", + "implements", "import", "instanceof", "int", "interface", "long", "new", "package", + "return", "short", "strictfp", "super", "switch", "this", + "throw", "throws", "try", "void", "while", "true", "false", "null" + }; + + private static final String[] JAVA_MODIFIERS = new String[]{ + "abstract", "final", "native", "private", "protected", "public", "static", + "synchronized", "transient", "volatile" + }; + + private static final String[] JS_KEYWORDS = new String[]{ + "await", "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", + "extends", "finally", "for", "function", "if", "import", "in", "instanceof", "let", "new", + "return", "super", "switch", "this", "throw", "try", "typeof", "var", "void", "while", "with", "yield", + "true", "false", "null", "undefined" + }; + + private static final String[] JS_MODIFIERS = new String[]{ + "export", "static", "async" + }; + + private static final String JAVA_KEYWORD_PATTERN = "\\b(" + String.join("|", JAVA_KEYWORDS) + ")\\b"; + private static final String JAVA_MODIFIER_PATTERN = "\\b(" + String.join("|", JAVA_MODIFIERS) + ")\\b"; + private static final String JS_KEYWORD_PATTERN = "\\b(" + String.join("|", JS_KEYWORDS) + ")\\b"; + private static final String JS_MODIFIER_PATTERN = "\\b(" + String.join("|", JS_MODIFIERS) + ")\\b"; + private static final String PAREN_PATTERN = "\\(|\\)"; + private static final String BRACE_PATTERN = "\\{|\\}"; + private static final String BRACKET_PATTERN = "\\[|\\]"; + private static final String SEMICOLON_PATTERN = "\\;"; + private static final String NUMBER_PATTERN = "\\b\\d+(?:\\.\\d+)?\\b"; + private static final String STRING_PATTERN = "\"([^\"\\\\]|\\\\.)*\"|'([^'\\\\]|\\\\.)*'"; + private static final String COMMENT_PATTERN = "//[^\\n]*|/\\*(.|\\R)*?\\*/"; + private static final String ANNOTATION_PATTERN = "@[A-Za-z_][a-zA-Z0-9_.]*"; + private static final String TYPE_REF_PATTERN = "\\b[A-Z][a-zA-Z0-9_]*\\b"; + private static final String METHOD_CALL_PATTERN = "[a-z_][a-zA-Z0-9_]*(?=\\s*\\()"; + + private static final Pattern JAVA_PATTERN = Pattern.compile( + "(?" + COMMENT_PATTERN + ")" + + "|(?" + STRING_PATTERN + ")" + + "|(?" + ANNOTATION_PATTERN + ")" + + "|(?" + JAVA_MODIFIER_PATTERN + ")" + + "|(?" + JAVA_KEYWORD_PATTERN + ")" + + "|(?" + TYPE_REF_PATTERN + ")" + + "|(?" + METHOD_CALL_PATTERN + ")" + + "|(?" + PAREN_PATTERN + ")" + + "|(?" + BRACE_PATTERN + ")" + + "|(?" + BRACKET_PATTERN + ")" + + "|(?" + SEMICOLON_PATTERN + ")" + + "|(?" + NUMBER_PATTERN + ")" + ); + + private static final Pattern JS_PATTERN = Pattern.compile( + "(?" + COMMENT_PATTERN + ")" + + "|(?" + STRING_PATTERN + ")" + + "|(?" + ANNOTATION_PATTERN + ")" + + "|(?" + JS_MODIFIER_PATTERN + ")" + + "|(?" + JS_KEYWORD_PATTERN + ")" + + "|(?" + TYPE_REF_PATTERN + ")" + + "|(?" + METHOD_CALL_PATTERN + ")" + + "|(?" + PAREN_PATTERN + ")" + + "|(?" + BRACE_PATTERN + ")" + + "|(?" + BRACKET_PATTERN + ")" + + "|(?" + SEMICOLON_PATTERN + ")" + + "|(?" + NUMBER_PATTERN + ")" + ); + + private static final Pattern JSON_PATTERN = Pattern.compile( + "(?\"([^\"\\\\]|\\\\.)*\"(?=\\s*:))" + + "|(?\"([^\"\\\\]|\\\\.)*\")" + + "|(?-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b)" + + "|(?\\b(true|false)\\b)" + + "|(?\\bnull\\b)" + + "|(?" + BRACE_PATTERN + ")" + + "|(?" + BRACKET_PATTERN + ")" + ); + + private NodexFxWindow() { + } + + public static NodexFxWindow get() { + return INSTANCE; + } + + public void open(Path root) { + open(root, null); + } + + public void open(Path root, NodexScriptBinding binding) { + if (root == null) { + return; + } + + Path normalizedRoot = root.toAbsolutePath().normalize(); + ensureRootExists(normalizedRoot); + + if (stage == null) { + buildStage(); + } + + boolean rootChanged = rootPath == null || !rootPath.equals(normalizedRoot); + rootPath = normalizedRoot; + rootLabel.setText("Root: " + rootPath.toString()); + + // Close old virtual tabs when a new binding replaces the old one + if (binding != null && activeBinding != null) { + closeVirtualTabs(); + } + activeBinding = binding; + + // Update title based on binding context + if (binding != null) { + stage.setTitle("Nodex IDE - " + binding.label); + } else { + stage.setTitle("Nodex IDE - " + getPathName(rootPath)); + } + + // Populate script panel from binding + currentTabs.clear(); + if (binding != null && binding.tabs != null) { + currentTabs.addAll(binding.tabs); + } + refreshScriptPanel(); + + if (rootChanged) { + reopenExistingTabsWithinRoot(); + refreshTree(null); + startWatcher(); + } else if (!watcherRunning.get()) { + startWatcher(); + } else { + refreshTree(null); + } + + // Auto-open virtual script tabs from the binding + if (binding != null && binding.tabs != null) { + for (NodexScriptBinding.ScriptTabData tabData : binding.tabs) { + openVirtualTab(tabData, binding.saveCallback); + } + // Select the first tab + if (!binding.tabs.isEmpty()) { + String firstVirtualId = "virtual#" + binding.contextId + "#" + binding.tabs.get(0).index; + EditorTab firstEditor = openEditors.get(firstVirtualId); + if (firstEditor != null) { + tabPane.getSelectionModel().select(firstEditor.tab); + firstEditor.area.requestFocus(); + } + } + } + + stage.show(); + stage.toFront(); + stage.requestFocus(); + + EditorTab active = getActiveEditor(); + if (active != null) { + active.area.requestFocus(); + } + } + + private void closeVirtualTabs() { + List toClose = new ArrayList(); + for (EditorTab editorTab : openEditors.values()) { + if (editorTab.isVirtual()) { + toClose.add(editorTab); + } + } + for (EditorTab editorTab : toClose) { + tabPane.getTabs().remove(editorTab.tab); + removeEditor(editorTab); + } + } + + private void openVirtualTab(NodexScriptBinding.ScriptTabData tabData, NodexScriptBinding.SaveCallback saveCallback) { + String virtualId = "virtual#" + (activeBinding != null ? activeBinding.contextId : "UNKNOWN") + "#" + tabData.index; + + EditorTab existing = openEditors.get(virtualId); + if (existing != null) { + tabPane.getSelectionModel().select(existing.tab); + existing.area.requestFocus(); + return; + } + + CodeArea area = new CodeArea(); + area.replaceText(tabData.content); + area.setParagraphGraphicFactory(LineNumberFactory.get(area)); + area.getStyleClass().add("code-area"); + + String tabDisplayName = tabData.name; + PauseTransition debounce = new PauseTransition(Duration.millis(120)); + Tab tab = new Tab(tabDisplayName); + tab.setGraphic(createIconLabel("S", "#55ff55")); + ChangeListener caretListener = (obs, oldVal, newVal) -> updateCursorStatus(); + EditorTab editorTab = new EditorTab(null, tab, area, debounce, caretListener); + editorTab.virtualId = virtualId; + editorTab.displayName = tabDisplayName; + editorTab.virtualTabIndex = tabData.index; + + // Determine language kind from the binding's language field + if ("Java".equalsIgnoreCase(tabData.language) || "Janino".equalsIgnoreCase(tabData.language)) { + editorTab.languageOverride = LanguageKind.JAVA; + } else { + editorTab.languageOverride = LanguageKind.JS; + } + + area.textProperty().addListener((obs, oldText, newText) -> { + if (!editorTab.dirty) { + editorTab.dirty = true; + refreshTabTitle(editorTab); + } + debounce.playFromStart(); + }); + area.caretPositionProperty().addListener(caretListener); + debounce.setOnFinished(evt -> applyHighlighting(editorTab)); + + tab.setOnCloseRequest(evt -> { + if (!confirmCloseEditor(editorTab)) { + evt.consume(); + } + }); + tab.setOnClosed(evt -> removeEditor(editorTab)); + tab.setContent(new VirtualizedScrollPane(area)); + + tabPane.getTabs().add(tab); + openEditors.put(virtualId, editorTab); + applyHighlighting(editorTab); + updateCursorStatus(); + updateBottomStatus(); + } + + private void buildStage() { + stage = new Stage(); + stage.initModality(Modality.NONE); + stage.setMinWidth(960); + stage.setMinHeight(620); + stage.setOnCloseRequest(evt -> { + evt.consume(); + if (confirmCloseAllDirtyEditors()) { + // Fire close callback to sync all tab contents back to the handler + fireCloseCallback(); + stage.hide(); + } + }); + stage.setOnHidden(evt -> stopWatcher()); + + BorderPane root = new BorderPane(); + root.getStyleClass().add("window"); + + MenuBar menuBar = buildMenuBar(); + ToolBar topBar = buildTopBar(); + VBox topContainer = new VBox(menuBar, topBar); + root.setTop(topContainer); + + scriptPanel = buildScriptPanel(); + fileTree = buildFileTree(); + + tabPane = new TabPane(); + tabPane.getStyleClass().add("text-editor-container"); + tabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.ALL_TABS); + tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> { + updateCursorStatus(); + updateBottomStatus(); + }); + + StackPane editorContainer = new StackPane(tabPane); + HBox.setHgrow(editorContainer, Priority.ALWAYS); + + contentSplit = new SplitPane(); + contentSplit.setOrientation(Orientation.HORIZONTAL); + // Default: show script panel on left, editor on right + contentSplit.getItems().addAll(scriptPanel, editorContainer); + contentSplit.setDividerPositions(DEFAULT_DIVIDER); + root.setCenter(contentSplit); + + ToolBar bottomBar = buildBottomBar(); + root.setBottom(bottomBar); + + currentTheme = loadThemePreference(); + scene = new Scene(root, 1240, 820); + scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handleShortcut); + String cssUri = buildThemeCssUri(currentTheme); + if (!cssUri.isEmpty()) { + scene.getStylesheets().add(cssUri); + } + stage.setScene(scene); + } + + private MenuBar buildMenuBar() { + MenuBar menuBar = new MenuBar(); + menuBar.getStyleClass().add("menu-bar"); + + Menu fileMenu = new Menu("File"); + fileMenu.getItems().addAll( + createMenuItem("New File", evt -> createFileInSelectedDirectory()), + createMenuItem("New Folder", evt -> createDirectoryInSelectedDirectory()), + new SeparatorMenuItem(), + createMenuItem("Save", evt -> saveActive()), + createMenuItem("Save All", evt -> saveAll()), + new SeparatorMenuItem(), + createMenuItem("Rename", evt -> renameSelected()), + createMenuItem("Delete", evt -> deleteSelected()), + new SeparatorMenuItem(), + createMenuItem("Refresh Tree", evt -> refreshTree(null)) + ); + + Menu editMenu = new Menu("Edit"); + editMenu.getItems().addAll( + createMenuItem("Undo", evt -> runEditorAction(EditorAction.UNDO)), + createMenuItem("Redo", evt -> runEditorAction(EditorAction.REDO)), + new SeparatorMenuItem(), + createMenuItem("Cut", evt -> runEditorAction(EditorAction.CUT)), + createMenuItem("Copy", evt -> runEditorAction(EditorAction.COPY)), + createMenuItem("Paste", evt -> runEditorAction(EditorAction.PASTE)), + createMenuItem("Select All", evt -> runEditorAction(EditorAction.SELECT_ALL)) + ); + + Menu viewMenu = new Menu("View"); + Menu themeMenu = new Menu("Theme"); + for (NodexTheme theme : NodexTheme.values()) { + MenuItem themeItem = new MenuItem(theme.displayName); + themeItem.setOnAction(evt -> applyTheme(theme)); + themeMenu.getItems().add(themeItem); + } + viewMenu.getItems().addAll( + createMenuItem("Toggle Script Panel", evt -> toggleScriptPanel()), + createMenuItem("Toggle File Tree", evt -> toggleFileTree()), + createMenuItem("Focus Editor", evt -> { + EditorTab active = getActiveEditor(); + if (active != null) { + active.area.requestFocus(); + } + }), + new SeparatorMenuItem(), + themeMenu + ); + + Menu helpMenu = new Menu("Help"); + helpMenu.getItems().add(createMenuItem("About Nodex Port", evt -> showInfo( + "Nodex Popup IDE", + "Ported Nodex-style popup editor for CustomNPC+.", + "JavaFX + RichTextFX bridge for script and source editing." + ))); + + menuBar.getMenus().addAll(fileMenu, editMenu, viewMenu, helpMenu); + return menuBar; + } + + private ToolBar buildTopBar() { + ToolBar bar = new ToolBar(); + bar.getStyleClass().add("top-bar-view"); + bar.setPadding(new Insets(6, 8, 6, 8)); + + Button saveButton = createFlatButton("Save", evt -> saveActive()); + Button saveAllButton = createFlatButton("Save All", evt -> saveAll()); + Button toggleScriptsButton = createFlatButton("Scripts", evt -> toggleScriptPanel()); + Button toggleFilesButton = createFlatButton("Files", evt -> toggleFileTree()); + + rootLabel = new Label("Root:"); + rootLabel.getStyleClass().add("meta-label"); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + bar.getItems().addAll( + saveButton, + saveAllButton, + new Separator(), + toggleScriptsButton, + toggleFilesButton, + new Separator(), + rootLabel, + spacer + ); + return bar; + } + + private VBox buildScriptPanel() { + VBox panel = new VBox(4); + panel.getStyleClass().add("script-panel"); + panel.setPadding(new Insets(8)); + panel.setMinWidth(160); + panel.setPrefWidth(220); + + Label header = new Label("Scripts"); + header.getStyleClass().add("script-panel-header"); + + scriptListView = new javafx.scene.control.ListView(); + scriptListView.getStyleClass().add("script-list"); + VBox.setVgrow(scriptListView, Priority.ALWAYS); + + // Single click opens the tab in the editor + scriptListView.setOnMouseClicked(evt -> { + int idx = scriptListView.getSelectionModel().getSelectedIndex(); + if (idx >= 0 && idx < currentTabs.size() && activeBinding != null) { + openVirtualTab(currentTabs.get(idx), activeBinding.saveCallback); + } + }); + + Button addButton = new Button("+"); + addButton.getStyleClass().add("script-btn"); + addButton.setPrefWidth(36); + addButton.setOnAction(evt -> addScriptTab()); + + Button deleteButton = new Button("-"); + deleteButton.getStyleClass().add("script-btn"); + deleteButton.setPrefWidth(36); + deleteButton.setOnAction(evt -> deleteScriptTab()); + + scriptButtonBar = new HBox(4, addButton, deleteButton); + scriptButtonBar.setAlignment(Pos.CENTER_LEFT); + scriptButtonBar.setPadding(new Insets(4, 0, 0, 0)); + + panel.getChildren().addAll(header, scriptListView, scriptButtonBar); + return panel; + } + + private void refreshScriptPanel() { + if (scriptListView == null) return; + + ObservableList items = FXCollections.observableArrayList(); + for (NodexScriptBinding.ScriptTabData tab : currentTabs) { + items.add(tab.name); + } + scriptListView.setItems(items); + + // Show/hide add/delete buttons + if (scriptButtonBar != null) { + scriptButtonBar.setVisible(activeBinding != null && activeBinding.canModifyTabs); + scriptButtonBar.setManaged(activeBinding != null && activeBinding.canModifyTabs); + } + + // Update the panel header + if (scriptPanel != null && !scriptPanel.getChildren().isEmpty()) { + javafx.scene.Node firstChild = scriptPanel.getChildren().get(0); + if (firstChild instanceof Label) { + String headerText = activeBinding != null ? activeBinding.label : "Scripts"; + ((Label) firstChild).setText(headerText); + } + } + } + + private ToolBar buildBottomBar() { + ToolBar bar = new ToolBar(); + bar.getStyleClass().add("status-bar"); + bar.setPadding(new Insets(4, 8, 4, 8)); + + cursorLabel = new Label("Ln 1, Col 1"); + cursorLabel.getStyleClass().add("status-label"); + + encodingLabel = new Label("UTF-8"); + encodingLabel.getStyleClass().add("status-label"); + + statusLabel = new Label("Ready"); + statusLabel.getStyleClass().add("status-label"); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + bar.getItems().addAll(cursorLabel, new Separator(), encodingLabel, new Separator(), spacer, statusLabel); + return bar; + } + + private TreeView buildFileTree() { + TreeView tree = new TreeView(); + tree.getStyleClass().add("file-tree-container"); + tree.setShowRoot(true); + + tree.setCellFactory(v -> new TreeCell() { + @Override + protected void updateItem(Path item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + setGraphic(null); + setContextMenu(null); + return; + } + + String display = getPathName(item); + if (display.length() == 0) { + display = item.toString(); + } + setText(display); + if (Files.isDirectory(item)) { + TreeItem treeItem = getTreeItem(); + boolean expanded = treeItem != null && treeItem.isExpanded(); + setGraphic(createIconLabel(expanded ? "V" : ">", "#E8A838")); + } else { + setGraphic(createFileIconLabel(display)); + } + setContextMenu(buildTreeContextMenu(item)); + } + }); + + tree.setOnMouseClicked(evt -> { + if (evt.getButton() != MouseButton.PRIMARY || evt.getClickCount() != 2) { + return; + } + TreeItem selected = tree.getSelectionModel().getSelectedItem(); + if (selected == null || selected.getValue() == null) { + return; + } + Path selectedPath = selected.getValue(); + + if (Files.isRegularFile(selectedPath)) { + openFile(selectedPath); + } + }); + + tree.setOnDragOver(this::handleTreeDragOver); + tree.setOnDragDropped(this::handleTreeDragDropped); + tree.setOnDragDetected(evt -> { + TreeItem selected = tree.getSelectionModel().getSelectedItem(); + if (selected == null || selected.getValue() == null || rootPath == null) { + return; + } + Path path = selected.getValue(); + if (path.equals(rootPath)) { + return; + } + ClipboardContent content = new ClipboardContent(); + content.put(TREE_DRAG_FORMAT, path.toString()); + tree.startDragAndDrop(TransferMode.MOVE).setContent(content); + evt.consume(); + }); + + return tree; + } + + private ContextMenu buildTreeContextMenu(Path item) { + ContextMenu menu = new ContextMenu(); + + MenuItem open = new MenuItem("Open"); + open.setDisable(Files.isDirectory(item)); + open.setOnAction(evt -> openFile(item)); + + MenuItem newFile = new MenuItem("New File"); + newFile.setOnAction(evt -> createFileInDirectory(Files.isDirectory(item) ? item : item.getParent())); + + MenuItem newFolder = new MenuItem("New Folder"); + newFolder.setOnAction(evt -> createDirectoryInDirectory(Files.isDirectory(item) ? item : item.getParent())); + + MenuItem rename = new MenuItem("Rename"); + rename.setDisable(rootPath != null && rootPath.equals(item)); + rename.setOnAction(evt -> renamePath(item)); + + MenuItem delete = new MenuItem("Delete"); + delete.setDisable(rootPath != null && rootPath.equals(item)); + delete.setOnAction(evt -> deletePath(item)); + + MenuItem refresh = new MenuItem("Refresh"); + refresh.setOnAction(evt -> refreshTree(item)); + + menu.getItems().addAll(open, new SeparatorMenuItem(), newFile, newFolder, new SeparatorMenuItem(), rename, delete, new SeparatorMenuItem(), refresh); + return menu; + } + + private static Label createIconLabel(String text, String color) { + Label label = new Label(text); + label.setStyle("-fx-text-fill: " + color + "; -fx-font-size: 10px; -fx-font-weight: bold; " + + "-fx-min-width: 20px; -fx-max-width: 20px; -fx-alignment: center;"); + return label; + } + + private static Label createFileIconLabel(String fileName) { + String lower = fileName.toLowerCase(Locale.ROOT); + if (lower.endsWith(".js")) return createIconLabel("JS", "#F0D73E"); + if (lower.endsWith(".java")) return createIconLabel("J", "#5382A1"); + if (lower.endsWith(".json") || lower.endsWith(".mcmeta")) return createIconLabel("{}", "#8BC34A"); + if (lower.endsWith(".groovy")) return createIconLabel("G", "#619CBB"); + if (lower.endsWith(".lua")) return createIconLabel("L", "#000080"); + if (lower.endsWith(".txt")) return createIconLabel("T", "#8ea0b5"); + if (lower.endsWith(".cfg") || lower.endsWith(".properties")) return createIconLabel("C", "#9E9E9E"); + return createIconLabel("f", "#8ea0b5"); + } + + private Button createFlatButton(String text, javafx.event.EventHandler action) { + Button button = new Button(text); + button.setOnAction(action); + return button; + } + + private MenuItem createMenuItem(String text, javafx.event.EventHandler action) { + MenuItem item = new MenuItem(text); + item.setOnAction(action); + return item; + } + + private void handleShortcut(KeyEvent event) { + if (!event.isControlDown()) { + return; + } + + KeyCode key = event.getCode(); + if (key == KeyCode.S) { + if (event.isShiftDown()) { + saveAll(); + } else { + saveActive(); + } + event.consume(); + return; + } + + if (key == KeyCode.R) { + refreshTree(null); + event.consume(); + return; + } + + if (key == KeyCode.N) { + createFileInSelectedDirectory(); + event.consume(); + return; + } + + if (key == KeyCode.W) { + closeActiveTabWithPrompt(); + event.consume(); + return; + } + + if (key == KeyCode.E) { + toggleFileTree(); + event.consume(); + } + } + + private void runEditorAction(EditorAction action) { + EditorTab active = getActiveEditor(); + if (active == null) { + return; + } + + if (action == EditorAction.UNDO) { + active.area.undo(); + } else if (action == EditorAction.REDO) { + active.area.redo(); + } else if (action == EditorAction.CUT) { + active.area.cut(); + } else if (action == EditorAction.COPY) { + active.area.copy(); + } else if (action == EditorAction.PASTE) { + active.area.paste(); + } else if (action == EditorAction.SELECT_ALL) { + active.area.selectAll(); + } + + active.area.requestFocus(); + } + + private void applyTheme(NodexTheme theme) { + currentTheme = theme; + if (scene != null) { + scene.getStylesheets().clear(); + String uri = buildThemeCssUri(theme); + if (!uri.isEmpty()) { + scene.getStylesheets().add(uri); + } + // Force re-highlight all open editors for new theme colors + for (EditorTab editorTab : openEditors.values()) { + applyHighlighting(editorTab); + } + } + saveThemePreference(theme); + setStatus("Theme: " + theme.displayName); + } + + private String buildThemeCssUri(NodexTheme theme) { + String css = buildThemeCss(theme); + try { + if (cssTempFile != null) { + Files.deleteIfExists(cssTempFile); + } + cssTempFile = Files.createTempFile("nodex-theme-", ".css"); + cssTempFile.toFile().deleteOnExit(); + Files.write(cssTempFile, css.getBytes(StandardCharsets.UTF_8)); + return cssTempFile.toUri().toString(); + } catch (Exception e) { + LogWriter.error("Failed to write CSS temp file for Nodex IDE", asException(e)); + return ""; + } + } + + private String buildThemeCss(NodexTheme theme) { + switch (theme) { + case DARK_GRAY: return buildDarkGrayCss(); + case MONOKAI: return buildMonokaiCss(); + case LIGHT: return buildLightCss(); + case DARK_BLUE: + default: return buildDarkBlueCss(); + } + } + + private static String buildChromeCss(String windowBg, String menuBg, String topBarBg, String leftBarBg, + String statusBg, String statusText, String treeBg, String treeText, + String treeSelectedBg, String treeSelectedText, + String tabBg, String tabHeaderBg, String tabActiveBg, String tabText, + String editorBg, String gutterBg, String gutterText, String caretColor, String defaultText) { + return ".window { -fx-background-color: " + windowBg + "; -fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.85), 14, 0.35, 0, 4); }" + + ".menu-bar { -fx-background-color: " + menuBg + "; }" + + ".top-bar-view { -fx-background-color: " + topBarBg + "; }" + + ".left-bar { -fx-background-color: " + leftBarBg + "; }" + + ".status-bar { -fx-background-color: " + statusBg + "; }" + + ".status-label { -fx-text-fill: " + statusText + "; }" + + ".meta-label { -fx-text-fill: " + statusText + "; -fx-padding: 0 0 0 8px; }" + + ".file-tree-container { -fx-background-color: " + treeBg + "; -fx-control-inner-background: " + treeBg + "; }" + + ".file-tree-container .tree-cell { -fx-cell-size: 24px; -fx-text-fill: " + treeText + "; }" + + ".file-tree-container .tree-cell:selected { -fx-background-color: " + treeSelectedBg + "; -fx-text-fill: " + treeSelectedText + "; }" + + ".tab-pane { -fx-background-color: " + tabBg + "; }" + + ".tab-pane .tab-header-background { -fx-background-color: " + tabHeaderBg + "; }" + + ".tab-pane .tab { -fx-background-color: " + tabBg + "; }" + + ".tab-pane .tab:selected { -fx-background-color: " + tabActiveBg + "; }" + + ".tab-pane .tab-label { -fx-text-fill: " + tabText + "; }" + + ".code-area { -fx-background-color: " + editorBg + "; -fx-font-family: 'Consolas'; -fx-font-size: 13px; -fx-text-fill: " + defaultText + "; }" + + ".code-area .lineno { -fx-background-color: " + gutterBg + "; -fx-text-fill: " + gutterText + "; }" + + ".code-area .caret { -fx-stroke: " + caretColor + "; }" + + ".script-panel { -fx-background-color: " + treeBg + "; }" + + ".script-panel-header { -fx-text-fill: " + treeText + "; -fx-font-weight: bold; -fx-font-size: 14px; -fx-padding: 0 0 4 0; }" + + ".script-list { -fx-background-color: " + treeBg + "; -fx-control-inner-background: " + treeBg + "; }" + + ".script-list .list-cell { -fx-text-fill: " + treeText + "; -fx-cell-size: 28px; -fx-background-color: transparent; }" + + ".script-list .list-cell:selected { -fx-background-color: " + treeSelectedBg + "; -fx-text-fill: " + treeSelectedText + "; }" + + ".script-btn { -fx-background-color: " + tabBg + "; -fx-text-fill: " + tabText + "; -fx-padding: 4 12; -fx-cursor: hand; }" + + ".script-btn:hover { -fx-background-color: " + treeSelectedBg + "; }"; + } + + private static String buildSyntaxCss(String keyword, String modifier, String annotation, String typeRef, + String methodCall, String paren, String brace, String bracket, + String semicolon, String string, String comment, String number, + String jsonProp, String jsonString, String jsonNumber, + String jsonBool, String jsonNull) { + return ".code-area .keyword { -fx-fill: " + keyword + "; }" + + ".code-area .modifier { -fx-fill: " + modifier + "; }" + + ".code-area .annotation { -fx-fill: " + annotation + "; }" + + ".code-area .type-ref { -fx-fill: " + typeRef + "; }" + + ".code-area .method-call { -fx-fill: " + methodCall + "; }" + + ".code-area .paren { -fx-fill: " + paren + "; }" + + ".code-area .brace { -fx-fill: " + brace + "; }" + + ".code-area .bracket { -fx-fill: " + bracket + "; }" + + ".code-area .semicolon { -fx-fill: " + semicolon + "; }" + + ".code-area .string { -fx-fill: " + string + "; }" + + ".code-area .comment { -fx-fill: " + comment + "; }" + + ".code-area .number { -fx-fill: " + number + "; }" + + ".code-area .json-property { -fx-fill: " + jsonProp + "; }" + + ".code-area .json-string { -fx-fill: " + jsonString + "; }" + + ".code-area .json-number { -fx-fill: " + jsonNumber + "; }" + + ".code-area .json-boolean { -fx-fill: " + jsonBool + "; }" + + ".code-area .json-null { -fx-fill: " + jsonNull + "; }"; + } + + private static String buildDarkBlueCss() { + return buildChromeCss( + "#121821", "#151d29", "#1a2433", "#101722", "#151d29", "#8ea0b5", + "#0f1621", "#a8b7c8", "#263348", "#f0f4fa", + "#182234", "#101a28", "#253349", "#d9e4f0", + "#0f1720", "#0b1218", "#5f7387", "#dbe5f0", "#dbe5f0" + ) + buildSyntaxCss( + "#70c4ff", "#ffaa00", "#cc9933", "#00aaaa", "#55ff55", + "#dbe5f0", "#8ed0ff", "#8ed0ff", "#dbe5f0", + "#8fd66a", "#6a7a88", "#f6b17a", + "#f0d58a", "#8fd66a", "#f6b17a", "#70c4ff", "#d88ec7" + ); + } + + private static String buildDarkGrayCss() { + return buildChromeCss( + "#1e1e1e", "#252526", "#333333", "#252526", "#007acc", "#cccccc", + "#252526", "#cccccc", "#094771", "#ffffff", + "#2d2d2d", "#252526", "#1e1e1e", "#cccccc", + "#1e1e1e", "#1e1e1e", "#858585", "#aeafad", "#d4d4d4" + ) + buildSyntaxCss( + "#569cd6", "#569cd6", "#dcdcaa", "#4ec9b0", "#dcdcaa", + "#d4d4d4", "#d4d4d4", "#d4d4d4", "#d4d4d4", + "#ce9178", "#6a9955", "#b5cea8", + "#9cdcfe", "#ce9178", "#b5cea8", "#569cd6", "#569cd6" + ); + } + + private static String buildMonokaiCss() { + return buildChromeCss( + "#272822", "#1e1f1c", "#3e3d32", "#1e1f1c", "#414339", "#75715e", + "#2f3129", "#f8f8f2", "#49483e", "#f8f8f0", + "#3e3d32", "#1e1f1c", "#272822", "#f8f8f2", + "#272822", "#2f3129", "#75715e", "#f8f8f0", "#f8f8f2" + ) + buildSyntaxCss( + "#f92672", "#f92672", "#e6db74", "#66d9ef", "#a6e22e", + "#f8f8f2", "#f8f8f2", "#f8f8f2", "#f8f8f2", + "#e6db74", "#75715e", "#ae81ff", + "#f92672", "#e6db74", "#ae81ff", "#66d9ef", "#ae81ff" + ); + } + + private static String buildLightCss() { + return buildChromeCss( + "#f5f5f5", "#e8e8e8", "#e0e0e0", "#e8e8e8", "#f5f5f5", "#616161", + "#f0f0f0", "#333333", "#c8ddf0", "#000000", + "#ececec", "#e0e0e0", "#ffffff", "#333333", + "#ffffff", "#f0f0f0", "#999999", "#000000", "#333333" + ) + buildSyntaxCss( + "#0000ff", "#7f0055", "#808000", "#267f99", "#795e26", + "#333333", "#333333", "#333333", "#333333", + "#008000", "#808080", "#098658", + "#a31515", "#008000", "#098658", "#0000ff", "#0000ff" + ); + } + + private NodexTheme loadThemePreference() { + try { + Path configDir = getConfigDir(); + if (configDir == null) return NodexTheme.DARK_BLUE; + Path configFile = configDir.resolve("config.properties"); + if (!Files.exists(configFile)) return NodexTheme.DARK_BLUE; + java.util.Properties props = new java.util.Properties(); + try (java.io.InputStream in = Files.newInputStream(configFile)) { + props.load(in); + } + String themeName = props.getProperty("theme", "Dark Blue"); + return NodexTheme.fromName(themeName); + } catch (Exception ignored) { + return NodexTheme.DARK_BLUE; + } + } + + private void saveThemePreference(NodexTheme theme) { + try { + Path configDir = getConfigDir(); + if (configDir == null) return; + if (!Files.exists(configDir)) Files.createDirectories(configDir); + Path configFile = configDir.resolve("config.properties"); + java.util.Properties props = new java.util.Properties(); + if (Files.exists(configFile)) { + try (java.io.InputStream in = Files.newInputStream(configFile)) { + props.load(in); + } + } + props.setProperty("theme", theme.displayName); + try (java.io.OutputStream out = Files.newOutputStream(configFile)) { + props.store(out, "Nodex IDE Configuration"); + } + } catch (Exception ignored) { + } + } + + private Path getConfigDir() { + if (rootPath == null) return null; + return rootPath.resolve(".nodex"); + } + + private void refreshTree(Path selectedPath) { + if (fileTree == null || rootPath == null) { + return; + } + + Set expanded = collectExpandedPaths(fileTree.getRoot(), new LinkedHashSet()); + + Path fallbackSelect = selectedPath; + if (fallbackSelect == null) { + TreeItem currentSelection = fileTree.getSelectionModel().getSelectedItem(); + fallbackSelect = currentSelection != null ? currentSelection.getValue() : rootPath; + } + if (fallbackSelect == null) { + fallbackSelect = rootPath; + } + + TreeItem rootItem = createNode(rootPath); + rootItem.setExpanded(true); + + fileTree.setRoot(rootItem); + restoreExpandedPaths(rootItem, expanded); + + if (fallbackSelect != null) { + revealPath(fallbackSelect); + } + } + + private TreeItem createNode(Path path) { + TreeItem node = new TreeItem(path); + + if (Files.isDirectory(path)) { + node.getChildren().add(new TreeItem()); + node.expandedProperty().addListener((obs, oldValue, expanded) -> { + if (!expanded) { + return; + } + if (node.getChildren().size() == 1 && node.getChildren().get(0).getValue() == null) { + node.getChildren().setAll(loadChildren(path)); + } + }); + } + return node; + } + + private ObservableList> loadChildren(Path directory) { + List children = new ArrayList(); + try (DirectoryStream stream = Files.newDirectoryStream(directory)) { + for (Path child : stream) { + children.add(child); + } + } catch (Exception e) { + LogWriter.error("Failed loading directory in Nodex IDE: " + directory, asException(e)); + } + + Collections.sort(children, PATH_COMPARATOR); + ObservableList> nodes = FXCollections.observableArrayList(); + for (Path child : children) { + nodes.add(createNode(child)); + } + return nodes; + } + + private void revealPath(Path target) { + if (target == null || fileTree.getRoot() == null) { + return; + } + TreeItem item = ensurePathVisible(fileTree.getRoot(), target.toAbsolutePath().normalize()); + if (item != null) { + fileTree.getSelectionModel().select(item); + fileTree.scrollTo(fileTree.getRow(item)); + } + } + + private TreeItem ensurePathVisible(TreeItem current, Path target) { + if (current == null || current.getValue() == null) { + return null; + } + + Path currentPath = current.getValue().toAbsolutePath().normalize(); + if (currentPath.equals(target)) { + return current; + } + + if (!target.startsWith(currentPath)) { + return null; + } + + if (Files.isDirectory(currentPath)) { + current.setExpanded(true); + if (current.getChildren().size() == 1 && current.getChildren().get(0).getValue() == null) { + current.getChildren().setAll(loadChildren(currentPath)); + } + } + + for (TreeItem child : current.getChildren()) { + TreeItem found = ensurePathVisible(child, target); + if (found != null) { + return found; + } + } + return currentPath.equals(target) ? current : null; + } + + private Set collectExpandedPaths(TreeItem node, Set collector) { + if (node == null || node.getValue() == null) { + return collector; + } + if (node.isExpanded()) { + collector.add(node.getValue().toAbsolutePath().normalize()); + } + for (TreeItem child : node.getChildren()) { + collectExpandedPaths(child, collector); + } + return collector; + } + + private void restoreExpandedPaths(TreeItem node, Set expanded) { + if (node == null || node.getValue() == null || expanded == null || expanded.isEmpty()) { + return; + } + + Path nodePath = node.getValue().toAbsolutePath().normalize(); + if (expanded.contains(nodePath) && Files.isDirectory(nodePath)) { + node.setExpanded(true); + if (node.getChildren().size() == 1 && node.getChildren().get(0).getValue() == null) { + node.getChildren().setAll(loadChildren(nodePath)); + } + } + for (TreeItem child : node.getChildren()) { + restoreExpandedPaths(child, expanded); + } + } + + private void openFile(Path filePath) { + if (filePath == null || Files.isDirectory(filePath)) { + return; + } + + Path normalized = filePath.toAbsolutePath().normalize(); + String key = normalized.toString(); + EditorTab existing = openEditors.get(key); + if (existing != null) { + tabPane.getSelectionModel().select(existing.tab); + existing.area.requestFocus(); + updateCursorStatus(); + updateBottomStatus(); + return; + } + + String text; + try { + text = readFileUtf8(normalized); + } catch (Exception e) { + LogWriter.error("Failed to open script file in Nodex IDE: " + normalized, asException(e)); + setStatus("Failed to open: " + getPathName(normalized)); + return; + } + + CodeArea area = new CodeArea(); + area.replaceText(text); + area.setParagraphGraphicFactory(LineNumberFactory.get(area)); + area.getStyleClass().add("code-area"); + + PauseTransition debounce = new PauseTransition(Duration.millis(120)); + Tab tab = new Tab(getPathName(normalized)); + ChangeListener caretListener = (obs, oldVal, newVal) -> updateCursorStatus(); + EditorTab editorTab = new EditorTab(normalized, tab, area, debounce, caretListener); + editorTab.lastModifiedMillis = getLastModifiedMillis(normalized); + + area.textProperty().addListener((obs, oldText, newText) -> { + if (!editorTab.dirty) { + editorTab.dirty = true; + refreshTabTitle(editorTab); + } + debounce.playFromStart(); + }); + area.caretPositionProperty().addListener(caretListener); + debounce.setOnFinished(evt -> applyHighlighting(editorTab)); + + tab.setOnCloseRequest(evt -> { + if (!confirmCloseEditor(editorTab)) { + evt.consume(); + } + }); + tab.setOnClosed(evt -> removeEditor(editorTab)); + tab.setContent(new VirtualizedScrollPane(area)); + + Tab selected = tabPane.getSelectionModel().getSelectedItem(); + int insertAt = selected != null ? tabPane.getTabs().indexOf(selected) + 1 : tabPane.getTabs().size(); + insertAt = Math.max(0, Math.min(insertAt, tabPane.getTabs().size())); + tabPane.getTabs().add(insertAt, tab); + + openEditors.put(key, editorTab); + applyHighlighting(editorTab); + tabPane.getSelectionModel().select(tab); + area.requestFocus(); + updateCursorStatus(); + updateBottomStatus(); + setStatus("Opened: " + normalized); + } + + private void saveActive() { + EditorTab active = getActiveEditor(); + if (active != null) { + saveEditor(active); + } + } + + private void saveAll() { + List editors = new ArrayList(openEditors.values()); + for (EditorTab editor : editors) { + saveEditor(editor); + } + updateBottomStatus(); + } + + private void saveEditor(EditorTab editor) { + if (editor == null) { + return; + } + + if (editor.isVirtual()) { + if (activeBinding != null && activeBinding.saveCallback != null) { + try { + activeBinding.saveCallback.onSave(editor.virtualTabIndex, editor.area.getText()); + editor.dirty = false; + refreshTabTitle(editor); + setStatus("Saved script: " + editor.displayName); + } catch (Exception e) { + LogWriter.error("Failed to save virtual script in Nodex IDE: " + editor.displayName, asException(e)); + setStatus("Failed saving: " + editor.displayName); + } + } + return; + } + + try { + Path parent = editor.path.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + Files.write(editor.path, editor.area.getText().getBytes(StandardCharsets.UTF_8)); + editor.lastModifiedMillis = getLastModifiedMillis(editor.path); + editor.dirty = false; + refreshTabTitle(editor); + setStatus("Saved: " + editor.path); + refreshTree(editor.path); + } catch (Exception e) { + LogWriter.error("Failed to save script file in Nodex IDE: " + editor.path, asException(e)); + setStatus("Failed saving: " + getPathName(editor.path)); + } + } + + private void closeActiveTabWithPrompt() { + EditorTab active = getActiveEditor(); + if (active == null) { + return; + } + if (confirmCloseEditor(active)) { + tabPane.getTabs().remove(active.tab); + removeEditor(active); + } + } + + private boolean confirmCloseAllDirtyEditors() { + boolean hasDirty = false; + for (EditorTab editorTab : openEditors.values()) { + if (editorTab.dirty) { + hasDirty = true; + break; + } + } + if (!hasDirty) { + return true; + } + + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.initOwner(stage); + alert.setTitle("Unsaved Files"); + alert.setHeaderText("There are unsaved files."); + alert.setContentText("Save all changes before closing?"); + + ButtonType saveAll = new ButtonType("Save All"); + ButtonType discard = new ButtonType("Discard"); + alert.getButtonTypes().setAll(saveAll, discard, ButtonType.CANCEL); + + Optional result = alert.showAndWait(); + if (!result.isPresent() || result.get() == ButtonType.CANCEL) { + return false; + } + + if (result.get() == saveAll) { + saveAll(); + for (EditorTab editorTab : openEditors.values()) { + if (editorTab.dirty) { + return false; + } + } + } + return true; + } + + private boolean confirmCloseEditor(EditorTab editorTab) { + if (editorTab == null || !editorTab.dirty) { + return true; + } + + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.initOwner(stage); + alert.setTitle("Unsaved Changes"); + alert.setHeaderText("Close unsaved file?"); + String displayName = editorTab.displayName != null ? editorTab.displayName : getPathName(editorTab.path); + alert.setContentText(displayName + " has unsaved changes."); + + ButtonType save = new ButtonType("Save"); + ButtonType discard = new ButtonType("Discard"); + alert.getButtonTypes().setAll(save, discard, ButtonType.CANCEL); + + Optional result = alert.showAndWait(); + if (!result.isPresent() || result.get() == ButtonType.CANCEL) { + return false; + } + + if (result.get() == save) { + saveEditor(editorTab); + return !editorTab.dirty; + } + + return true; + } + + private void removeEditor(EditorTab editorTab) { + if (editorTab == null) { + return; + } + editorTab.area.caretPositionProperty().removeListener(editorTab.caretListener); + openEditors.remove(editorTab.editorKey()); + updateCursorStatus(); + updateBottomStatus(); + } + + private void refreshTabTitle(EditorTab editorTab) { + if (editorTab == null) { + return; + } + String base = editorTab.displayName != null ? editorTab.displayName : getPathName(editorTab.path); + editorTab.tab.setText(editorTab.dirty ? base + " *" : base); + } + + private EditorTab getActiveEditor() { + if (tabPane == null) { + return null; + } + Tab selected = tabPane.getSelectionModel().getSelectedItem(); + if (selected == null) { + return null; + } + for (EditorTab editorTab : openEditors.values()) { + if (editorTab.tab == selected) { + return editorTab; + } + } + return null; + } + + private void updateCursorStatus() { + if (cursorLabel == null) { + return; + } + EditorTab active = getActiveEditor(); + if (active == null) { + cursorLabel.setText("Ln 1, Col 1"); + return; + } + int line = active.area.getCurrentParagraph() + 1; + int col = active.area.getCaretColumn() + 1; + cursorLabel.setText("Ln " + line + ", Col " + col); + } + + private void updateBottomStatus() { + EditorTab active = getActiveEditor(); + if (active == null) { + setStatus("Ready"); + return; + } + LanguageKind language = active.languageOverride != null ? active.languageOverride : inferLanguage(active.path); + String name = active.isVirtual() ? active.displayName : String.valueOf(active.path); + setStatus(name + " | " + language.name() + (active.dirty ? " | Unsaved" : " | Saved")); + } + + private void setStatus(String status) { + if (statusLabel != null) { + statusLabel.setText(status); + } + } + + private void applyHighlighting(EditorTab editorTab) { + if (editorTab == null) { + return; + } + + LanguageKind language = editorTab.languageOverride != null ? editorTab.languageOverride : inferLanguage(editorTab.path); + Pattern pattern = getPattern(language); + if (pattern == null) { + clearHighlighting(editorTab.area); + return; + } + + try { + StyleSpans> spans = computeHighlighting(editorTab.area.getText(), pattern, language); + editorTab.area.setStyleSpans(0, spans); + } catch (Exception ignored) { + } + } + + private void clearHighlighting(CodeArea area) { + String text = area.getText(); + StyleSpansBuilder> builder = new StyleSpansBuilder>(); + builder.add(Collections.emptyList(), text.length()); + area.setStyleSpans(0, builder.create()); + } + + private Pattern getPattern(LanguageKind language) { + if (language == LanguageKind.JAVA) { + return JAVA_PATTERN; + } + if (language == LanguageKind.JS) { + return JS_PATTERN; + } + if (language == LanguageKind.JSON) { + return JSON_PATTERN; + } + return null; + } + + private LanguageKind inferLanguage(Path path) { + if (path == null || path.getFileName() == null) { + return LanguageKind.PLAIN; + } + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + if (name.endsWith(".java")) { + return LanguageKind.JAVA; + } + if (name.endsWith(".js")) { + return LanguageKind.JS; + } + if (name.endsWith(".json") || name.endsWith(".mcmeta")) { + return LanguageKind.JSON; + } + return LanguageKind.PLAIN; + } + + private StyleSpans> computeHighlighting(String text, Pattern pattern, LanguageKind language) { + Matcher matcher = pattern.matcher(text); + int last = 0; + StyleSpansBuilder> builder = new StyleSpansBuilder>(); + + while (matcher.find()) { + String styleClass = pickStyleClass(matcher, language); + if (styleClass == null) { + continue; + } + + builder.add(Collections.emptyList(), matcher.start() - last); + builder.add(Collections.singleton(styleClass), matcher.end() - matcher.start()); + last = matcher.end(); + } + + builder.add(Collections.emptyList(), text.length() - last); + return builder.create(); + } + + private String pickStyleClass(Matcher matcher, LanguageKind language) { + if (language == LanguageKind.JSON) { + if (matcher.group("PROPERTY") != null) { + return "json-property"; + } + if (matcher.group("STRING") != null) { + return "json-string"; + } + if (matcher.group("NUMBER") != null) { + return "json-number"; + } + if (matcher.group("BOOLEAN") != null) { + return "json-boolean"; + } + if (matcher.group("NULL") != null) { + return "json-null"; + } + if (matcher.group("BRACE") != null) { + return "brace"; + } + if (matcher.group("BRACKET") != null) { + return "bracket"; + } + return null; + } + + if (matcher.group("COMMENT") != null) { + return "comment"; + } + if (matcher.group("STRING") != null) { + return "string"; + } + if (matcher.group("ANNOTATION") != null) { + return "annotation"; + } + if (matcher.group("MODIFIER") != null) { + return "modifier"; + } + if (matcher.group("KEYWORD") != null) { + return "keyword"; + } + if (matcher.group("TYPEREF") != null) { + return "type-ref"; + } + if (matcher.group("METHODCALL") != null) { + return "method-call"; + } + if (matcher.group("PAREN") != null) { + return "paren"; + } + if (matcher.group("BRACE") != null) { + return "brace"; + } + if (matcher.group("BRACKET") != null) { + return "bracket"; + } + if (matcher.group("SEMICOLON") != null) { + return "semicolon"; + } + if (matcher.group("NUMBER") != null) { + return "number"; + } + + return null; + } + + private void toggleScriptPanel() { + if (contentSplit == null || scriptPanel == null) return; + + if (contentSplit.getItems().contains(scriptPanel)) { + if (!contentSplit.getDividers().isEmpty()) { + lastDividerPosition = contentSplit.getDividers().get(0).getPosition(); + } + contentSplit.getItems().remove(scriptPanel); + setStatus("Script panel hidden"); + } else { + // Remove file tree if showing, then add script panel + contentSplit.getItems().remove(fileTree); + contentSplit.getItems().add(0, scriptPanel); + contentSplit.setDividerPositions(lastDividerPosition > 0.02D ? lastDividerPosition : DEFAULT_DIVIDER); + setStatus("Script panel shown"); + } + } + + private void toggleFileTree() { + if (contentSplit == null || fileTree == null) { + return; + } + + if (contentSplit.getItems().contains(fileTree)) { + if (!contentSplit.getDividers().isEmpty()) { + lastDividerPosition = contentSplit.getDividers().get(0).getPosition(); + } + contentSplit.getItems().remove(fileTree); + // Restore script panel if it was hidden + if (!contentSplit.getItems().contains(scriptPanel)) { + contentSplit.getItems().add(0, scriptPanel); + contentSplit.setDividerPositions(lastDividerPosition > 0.02D ? lastDividerPosition : DEFAULT_DIVIDER); + } + setStatus("File tree hidden"); + } else { + // Remove script panel and show file tree + contentSplit.getItems().remove(scriptPanel); + contentSplit.getItems().add(0, fileTree); + contentSplit.setDividerPositions(lastDividerPosition > 0.02D ? lastDividerPosition : DEFAULT_DIVIDER); + refreshTree(null); + setStatus("File tree shown"); + } + } + + private void addScriptTab() { + if (activeBinding == null || !activeBinding.canModifyTabs) return; + + // Determine next tab index and name + int nextIndex = 0; + for (NodexScriptBinding.ScriptTabData tab : currentTabs) { + if (tab.index >= nextIndex) nextIndex = tab.index + 1; + } + String tabName = "Tab " + (currentTabs.size() + 1); + String language = currentTabs.isEmpty() ? "ECMAScript" : currentTabs.get(0).language; + + NodexScriptBinding.ScriptTabData newTab = new NodexScriptBinding.ScriptTabData(nextIndex, tabName, "", language); + currentTabs.add(newTab); + refreshScriptPanel(); + + // Open the new tab in the editor + openVirtualTab(newTab, activeBinding.saveCallback); + setStatus("Added: " + tabName); + } + + private void deleteScriptTab() { + if (activeBinding == null || !activeBinding.canModifyTabs) return; + if (currentTabs.isEmpty()) return; + + int selectedIdx = scriptListView != null ? scriptListView.getSelectionModel().getSelectedIndex() : -1; + if (selectedIdx < 0 || selectedIdx >= currentTabs.size()) return; + + NodexScriptBinding.ScriptTabData tabToDelete = currentTabs.get(selectedIdx); + + // Confirm deletion + Alert confirm = new Alert(Alert.AlertType.CONFIRMATION); + confirm.initOwner(stage); + confirm.setTitle("Delete Tab"); + confirm.setHeaderText("Delete \"" + tabToDelete.name + "\"?"); + confirm.setContentText("This will remove the script tab."); + Optional result = confirm.showAndWait(); + if (!result.isPresent() || result.get() != ButtonType.OK) return; + + // Close the corresponding editor tab if open + String virtualId = "virtual#" + activeBinding.contextId + "#" + tabToDelete.index; + EditorTab editorTab = openEditors.get(virtualId); + if (editorTab != null) { + tabPane.getTabs().remove(editorTab.tab); + removeEditor(editorTab); + } + + currentTabs.remove(selectedIdx); + refreshScriptPanel(); + setStatus("Deleted: " + tabToDelete.name); + } + + private void fireCloseCallback() { + if (activeBinding == null || activeBinding.closeCallback == null) return; + + // Collect current state of all tabs + List snapshot = new ArrayList(); + for (NodexScriptBinding.ScriptTabData tab : currentTabs) { + String virtualId = "virtual#" + activeBinding.contextId + "#" + tab.index; + EditorTab editor = openEditors.get(virtualId); + String content = editor != null ? editor.area.getText() : tab.content; + snapshot.add(new NodexScriptBinding.ScriptTabData(tab.index, tab.name, content, tab.language)); + } + + try { + activeBinding.closeCallback.onClose(snapshot); + } catch (Exception e) { + LogWriter.error("Failed to fire Nodex close callback", asException(e)); + } + } + + private void createFileInSelectedDirectory() { + Path dir = resolveSelectedDirectory(); + createFileInDirectory(dir); + } + + private void createDirectoryInSelectedDirectory() { + Path dir = resolveSelectedDirectory(); + createDirectoryInDirectory(dir); + } + + private Path resolveSelectedDirectory() { + TreeItem selected = fileTree != null ? fileTree.getSelectionModel().getSelectedItem() : null; + if (selected == null || selected.getValue() == null) { + return rootPath; + } + Path selectedPath = selected.getValue(); + if (isVirtualPath(selectedPath)) { + return rootPath; + } + if (Files.isDirectory(selectedPath)) { + return selectedPath; + } + return selectedPath.getParent() != null ? selectedPath.getParent() : rootPath; + } + + private void createFileInDirectory(Path directory) { + if (directory == null) { + return; + } + Optional name = promptText("New File", "Create file in " + directory, "script.js"); + if (!name.isPresent()) { + return; + } + + String raw = sanitizeName(name.get()); + if (raw.length() == 0) { + return; + } + + Path filePath = directory.resolve(raw).toAbsolutePath().normalize(); + try { + if (Files.exists(filePath)) { + showError("File already exists", filePath.toString()); + return; + } + Path parent = filePath.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + Files.createFile(filePath); + refreshTree(filePath); + openFile(filePath); + setStatus("Created file: " + filePath); + } catch (Exception e) { + LogWriter.error("Failed creating file in Nodex IDE: " + filePath, asException(e)); + showError("Create File Failed", e.getMessage()); + } + } + + private void createDirectoryInDirectory(Path directory) { + if (directory == null) { + return; + } + Optional name = promptText("New Folder", "Create folder in " + directory, "new_folder"); + if (!name.isPresent()) { + return; + } + + String raw = sanitizeName(name.get()); + if (raw.length() == 0) { + return; + } + + Path folder = directory.resolve(raw).toAbsolutePath().normalize(); + try { + if (Files.exists(folder)) { + showError("Folder already exists", folder.toString()); + return; + } + Files.createDirectories(folder); + refreshTree(folder); + revealPath(folder); + setStatus("Created folder: " + folder); + } catch (Exception e) { + LogWriter.error("Failed creating folder in Nodex IDE: " + folder, asException(e)); + showError("Create Folder Failed", e.getMessage()); + } + } + + private void renameSelected() { + TreeItem selected = fileTree != null ? fileTree.getSelectionModel().getSelectedItem() : null; + if (selected == null || selected.getValue() == null) { + return; + } + renamePath(selected.getValue()); + } + + private void renamePath(Path originalPath) { + if (originalPath == null || (rootPath != null && rootPath.equals(originalPath))) { + return; + } + String currentName = getPathName(originalPath); + Optional newName = promptText("Rename", "Rename " + currentName, currentName); + if (!newName.isPresent()) { + return; + } + String sanitized = sanitizeName(newName.get()); + if (sanitized.length() == 0 || sanitized.equals(currentName)) { + return; + } + + Path newPath = originalPath.resolveSibling(sanitized).toAbsolutePath().normalize(); + try { + if (Files.exists(newPath)) { + showError("Target already exists", newPath.toString()); + return; + } + Files.move(originalPath, newPath); + handlePathMove(originalPath.toAbsolutePath().normalize(), newPath); + refreshTree(newPath); + setStatus("Renamed to: " + newPath); + } catch (Exception e) { + LogWriter.error("Failed renaming in Nodex IDE: " + originalPath + " -> " + newPath, asException(e)); + showError("Rename Failed", e.getMessage()); + } + } + + private void deleteSelected() { + TreeItem selected = fileTree != null ? fileTree.getSelectionModel().getSelectedItem() : null; + if (selected == null || selected.getValue() == null) { + return; + } + deletePath(selected.getValue()); + } + + private void deletePath(Path path) { + if (path == null || (rootPath != null && rootPath.equals(path))) { + return; + } + + Alert confirmation = new Alert(Alert.AlertType.CONFIRMATION); + confirmation.initOwner(stage); + confirmation.setTitle("Delete"); + confirmation.setHeaderText("Delete " + getPathName(path) + "?"); + confirmation.setContentText(Files.isDirectory(path) ? "This will delete folder contents recursively." : "This cannot be undone."); + Optional result = confirmation.showAndWait(); + if (!result.isPresent() || result.get() != ButtonType.OK) { + return; + } + + try { + closeEditorsInside(path); + deleteRecursively(path); + refreshTree(path.getParent()); + setStatus("Deleted: " + path); + } catch (Exception e) { + LogWriter.error("Failed deleting in Nodex IDE: " + path, asException(e)); + showError("Delete Failed", e.getMessage()); + } + } + + private void closeEditorsInside(Path basePath) { + Path normalizedBase = basePath.toAbsolutePath().normalize(); + List toClose = new ArrayList(); + for (EditorTab editorTab : openEditors.values()) { + if (editorTab.isVirtual() || editorTab.path == null) continue; + if (editorTab.path.startsWith(normalizedBase)) { + toClose.add(editorTab); + } + } + for (EditorTab editorTab : toClose) { + tabPane.getTabs().remove(editorTab.tab); + removeEditor(editorTab); + } + } + + private void handlePathMove(Path oldPath, Path newPath) { + Map updated = new HashMap(); + for (EditorTab editorTab : openEditors.values()) { + if (editorTab.isVirtual()) { + updated.put(editorTab.editorKey(), editorTab); + continue; + } + Path editorPath = editorTab.path.toAbsolutePath().normalize(); + if (editorPath.equals(oldPath) || editorPath.startsWith(oldPath)) { + Path relative = oldPath.relativize(editorPath); + editorTab.path = newPath.resolve(relative).toAbsolutePath().normalize(); + refreshTabTitle(editorTab); + } + updated.put(editorTab.editorKey(), editorTab); + } + + openEditors.clear(); + openEditors.putAll(updated); + } + + private void handleTreeDragOver(DragEvent event) { + if (!event.getDragboard().hasContent(TREE_DRAG_FORMAT)) { + return; + } + + TreeItem targetItem = fileTree.getSelectionModel().getSelectedItem(); + if (targetItem == null || targetItem.getValue() == null) { + return; + } + + Path target = targetItem.getValue(); + if (!Files.isDirectory(target)) { + target = target.getParent(); + } + if (target == null) { + return; + } + + Object sourceObj = event.getDragboard().getContent(TREE_DRAG_FORMAT); + if (!(sourceObj instanceof String)) { + return; + } + + Path source = Paths.get((String) sourceObj).toAbsolutePath().normalize(); + if (target.equals(source) || target.startsWith(source)) { + return; + } + + event.acceptTransferModes(TransferMode.MOVE); + event.consume(); + } + + private void handleTreeDragDropped(DragEvent event) { + boolean success = false; + try { + if (!event.getDragboard().hasContent(TREE_DRAG_FORMAT)) { + return; + } + + TreeItem targetItem = fileTree.getSelectionModel().getSelectedItem(); + if (targetItem == null || targetItem.getValue() == null) { + return; + } + + Path targetDir = targetItem.getValue(); + if (!Files.isDirectory(targetDir)) { + targetDir = targetDir.getParent(); + } + if (targetDir == null) { + return; + } + + Object sourceObj = event.getDragboard().getContent(TREE_DRAG_FORMAT); + if (!(sourceObj instanceof String)) { + return; + } + Path source = Paths.get((String) sourceObj).toAbsolutePath().normalize(); + Path destination = targetDir.resolve(source.getFileName()).toAbsolutePath().normalize(); + + if (destination.equals(source) || destination.startsWith(source)) { + return; + } + if (Files.exists(destination)) { + showError("Move failed", "Target already exists: " + destination); + return; + } + + Files.move(source, destination); + handlePathMove(source, destination); + refreshTree(destination); + setStatus("Moved: " + source + " -> " + destination); + success = true; + } catch (Exception e) { + LogWriter.error("Failed moving path in Nodex IDE", asException(e)); + showError("Move Failed", e.getMessage()); + } finally { + event.setDropCompleted(success); + event.consume(); + } + } + + private void reopenExistingTabsWithinRoot() { + List reopenPaths = new ArrayList(); + for (EditorTab editorTab : openEditors.values()) { + if (editorTab.isVirtual()) continue; + if (rootPath != null && editorTab.path != null && editorTab.path.startsWith(rootPath)) { + reopenPaths.add(editorTab.path); + } + } + + openEditors.clear(); + if (tabPane != null) { + tabPane.getTabs().clear(); + } + + for (Path path : reopenPaths) { + if (Files.exists(path) && Files.isRegularFile(path)) { + openFile(path); + } + } + } + + private void startWatcher() { + stopWatcher(); + if (rootPath == null || !Files.exists(rootPath)) { + return; + } + + try { + watchService = FileSystems.getDefault().newWatchService(); + watchedDirectories.clear(); + registerDirectoryTree(rootPath); + watcherRunning.set(true); + + watchThread = new Thread(this::watchLoop, "CNPC-Nodex-Watcher"); + watchThread.setDaemon(true); + watchThread.start(); + } catch (Exception e) { + LogWriter.error("Failed to start filesystem watcher for Nodex IDE", asException(e)); + stopWatcher(); + } + } + + private void stopWatcher() { + watcherRunning.set(false); + if (watchThread != null) { + watchThread.interrupt(); + watchThread = null; + } + if (watchService != null) { + try { + watchService.close(); + } catch (IOException ignored) { + } + watchService = null; + } + watchedDirectories.clear(); + } + + private void watchLoop() { + while (watcherRunning.get()) { + WatchKey key; + try { + key = watchService.take(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return; + } catch (ClosedWatchServiceException ignored) { + return; + } + + boolean changed = false; + Path watchRoot = (Path) key.watchable(); + for (WatchEvent event : key.pollEvents()) { + WatchEvent.Kind kind = event.kind(); + if (kind == StandardWatchEventKinds.OVERFLOW) { + continue; + } + changed = true; + + Object context = event.context(); + if (context instanceof Path) { + Path child = watchRoot.resolve((Path) context).toAbsolutePath().normalize(); + if (kind == StandardWatchEventKinds.ENTRY_CREATE && Files.isDirectory(child)) { + registerDirectoryTree(child); + } + } + } + + key.reset(); + if (changed) { + scheduleExternalRefresh(); + } + } + } + + private void scheduleExternalRefresh() { + if (!pendingRefresh.compareAndSet(false, true)) { + return; + } + Platform.runLater(() -> { + pendingRefresh.set(false); + refreshTree(null); + syncOpenEditorsFromDisk(); + }); + } + + private void syncOpenEditorsFromDisk() { + List editors = new ArrayList(openEditors.values()); + for (EditorTab editorTab : editors) { + if (editorTab.isVirtual()) continue; + try { + if (!Files.exists(editorTab.path)) { + if (!editorTab.dirty) { + tabPane.getTabs().remove(editorTab.tab); + removeEditor(editorTab); + } + continue; + } + + long diskTimestamp = getLastModifiedMillis(editorTab.path); + if (diskTimestamp <= 0L || diskTimestamp == editorTab.lastModifiedMillis) { + continue; + } + + if (!editorTab.dirty) { + String fresh = readFileUtf8(editorTab.path); + if (!fresh.equals(editorTab.area.getText())) { + editorTab.area.replaceText(fresh); + editorTab.lastModifiedMillis = diskTimestamp; + editorTab.dirty = false; + refreshTabTitle(editorTab); + applyHighlighting(editorTab); + } + } + } catch (Exception e) { + LogWriter.error("Failed syncing file changes in Nodex IDE: " + editorTab.path, asException(e)); + } + } + updateBottomStatus(); + } + + private void registerDirectoryTree(Path rootDirectory) { + if (rootDirectory == null || !Files.isDirectory(rootDirectory) || watchService == null) { + return; + } + + try { + Files.walkFileTree(rootDirectory, new FileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + registerWatchDirectory(dir); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) { + return FileVisitResult.CONTINUE; + } + }); + } catch (Exception e) { + LogWriter.error("Failed registering directory tree in Nodex IDE: " + rootDirectory, asException(e)); + } + } + + private void registerWatchDirectory(Path dir) { + Path normalized = dir.toAbsolutePath().normalize(); + if (watchedDirectories.contains(normalized)) { + return; + } + try { + normalized.register(watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY); + watchedDirectories.add(normalized); + } catch (Exception ignored) { + } + } + + private Optional promptText(String title, String header, String defaultValue) { + TextInputDialog dialog = new TextInputDialog(defaultValue); + dialog.initOwner(stage); + dialog.setTitle(title); + dialog.setHeaderText(header); + dialog.setContentText("Name:"); + return dialog.showAndWait(); + } + + private void showError(String header, String content) { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.initOwner(stage); + alert.setTitle("Nodex IDE"); + alert.setHeaderText(header); + alert.setContentText(content != null ? content : ""); + alert.showAndWait(); + } + + private void showInfo(String title, String header, String content) { + Alert alert = new Alert(Alert.AlertType.INFORMATION); + alert.initOwner(stage); + alert.setTitle(title); + alert.setHeaderText(header); + alert.setContentText(content); + alert.showAndWait(); + } + + private void deleteRecursively(Path rootToDelete) throws IOException { + if (!Files.exists(rootToDelete)) { + return; + } + Files.walkFileTree(rootToDelete, new FileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + }); + } + + private void ensureRootExists(Path normalizedRoot) { + if (Files.exists(normalizedRoot)) { + return; + } + try { + Files.createDirectories(normalizedRoot); + } catch (Exception e) { + LogWriter.error("Failed to create script root for Nodex IDE: " + normalizedRoot, asException(e)); + } + } + + private long getLastModifiedMillis(Path path) { + try { + return Files.getLastModifiedTime(path).toMillis(); + } catch (Exception ignored) { + return 0L; + } + } + + private String readFileUtf8(Path path) throws IOException { + byte[] bytes = Files.readAllBytes(path); + return new String(bytes, StandardCharsets.UTF_8); + } + + private String sanitizeName(String raw) { + if (raw == null) { + return ""; + } + String trimmed = raw.trim(); + if (trimmed.length() == 0) { + return ""; + } + return trimmed.replace('\\', '_').replace('/', '_'); + } + + private String getPathName(Path path) { + if (path == null) { + return ""; + } + Path fileName = path.getFileName(); + return fileName != null ? fileName.toString() : path.toString(); + } + + private Exception asException(Throwable t) { + if (t instanceof Exception) { + return (Exception) t; + } + return new Exception(t); + } + + private static final Comparator PATH_COMPARATOR = new Comparator() { + @Override + public int compare(Path left, Path right) { + boolean leftDir = Files.isDirectory(left); + boolean rightDir = Files.isDirectory(right); + if (leftDir != rightDir) { + return leftDir ? -1 : 1; + } + return naturalCompare(getNameForCompare(left), getNameForCompare(right)); + } + }; + + private static String getNameForCompare(Path path) { + Path fileName = path.getFileName(); + return fileName != null ? fileName.toString() : path.toString(); + } + + private static int naturalCompare(String a, String b) { + int i = 0; + int j = 0; + + while (i < a.length() && j < b.length()) { + char ca = a.charAt(i); + char cb = b.charAt(j); + + if (Character.isDigit(ca) && Character.isDigit(cb)) { + int startI = i; + int startJ = j; + + while (i < a.length() && Character.isDigit(a.charAt(i))) { + i++; + } + while (j < b.length() && Character.isDigit(b.charAt(j))) { + j++; + } + + String numA = a.substring(startI, i); + String numB = b.substring(startJ, j); + + if (numA.length() != numB.length()) { + return numA.length() - numB.length(); + } + + int cmp = numA.compareTo(numB); + if (cmp != 0) { + return cmp; + } + } else { + int cmp = Character.toLowerCase(ca) - Character.toLowerCase(cb); + if (cmp != 0) { + return cmp; + } + i++; + j++; + } + } + return a.length() - b.length(); + } +} diff --git a/src/main/java/noppes/npcs/client/fx/NodexScriptBinding.java b/src/main/java/noppes/npcs/client/fx/NodexScriptBinding.java new file mode 100644 index 000000000..280e3454e --- /dev/null +++ b/src/main/java/noppes/npcs/client/fx/NodexScriptBinding.java @@ -0,0 +1,69 @@ +package noppes.npcs.client.fx; + +import java.util.List; + +/** + * Bridge object that carries script tab data from the Minecraft thread to the JavaFX Nodex IDE. + * All string data is copied at creation time to avoid cross-thread issues. + */ +public final class NodexScriptBinding { + + /** Display label for this binding group (e.g., "NPC Scripts", "Player Scripts") */ + public final String label; + + /** Script context identifier (e.g., "NPC", "PLAYER", "FORGE") */ + public final String contextId; + + /** Individual script tab data */ + public final List tabs; + + /** Callback to save a specific tab back to the handler */ + public final SaveCallback saveCallback; + + /** Callback invoked when Nodex window closes - syncs all tab contents back to handler */ + public final CloseCallback closeCallback; + + /** Whether the user can add/delete tabs (true for Player/Forge, false for NPC) */ + public final boolean canModifyTabs; + + public NodexScriptBinding(String label, String contextId, List tabs, + SaveCallback saveCallback, CloseCallback closeCallback, boolean canModifyTabs) { + this.label = label; + this.contextId = contextId; + this.tabs = tabs; + this.saveCallback = saveCallback; + this.closeCallback = closeCallback; + this.canModifyTabs = canModifyTabs; + } + + public static final class ScriptTabData { + public final int index; + public final String name; + public final String content; + public final String language; + + public ScriptTabData(int index, String name, String content, String language) { + this.index = index; + this.name = name; + this.content = content != null ? content : ""; + this.language = language != null ? language : "ECMAScript"; + } + } + + /** + * Callback interface for saving a virtual tab back to its handler. + * Implementations must dispatch to the Minecraft main thread. + */ + public interface SaveCallback { + void onSave(int tabIndex, String newContent); + } + + /** + * Callback invoked when the Nodex window is closing. + * Receives the complete list of tab snapshots (index + current content). + * The handler should reconcile its script list with this data and refresh the in-game GUI. + */ + public interface CloseCallback { + void onClose(List allTabs); + } +} diff --git a/src/main/java/noppes/npcs/client/gui/GuiScript.java b/src/main/java/noppes/npcs/client/gui/GuiScript.java index 796064b60..2b56e590b 100644 --- a/src/main/java/noppes/npcs/client/gui/GuiScript.java +++ b/src/main/java/noppes/npcs/client/gui/GuiScript.java @@ -10,6 +10,8 @@ import net.minecraft.util.StatCollector; import noppes.npcs.NoppesStringUtils; import noppes.npcs.client.NoppesUtil; import noppes.npcs.client.gui.script.GuiScriptInterface; +import noppes.npcs.client.fx.JavaFxBridge; +import noppes.npcs.client.fx.NodexScriptBinding; import noppes.npcs.client.gui.util.GuiCustomScroll; import noppes.npcs.client.gui.util.GuiMenuTopButton; import noppes.npcs.client.gui.util.GuiNpcButton; @@ -236,6 +238,7 @@ public class GuiScript extends GuiScriptInterface { if (MinecraftServer.getServer() != null) addButton(new GuiNpcButton(106, guiLeft + 232, guiTop + 71, 150, 20, "script.openfolder")); + addButton(new GuiNpcButton(114, guiLeft + 232, guiTop + 93, 150, 20, "script.nodex")); } // Apply editor globals for the active NPC hook. @@ -333,6 +336,13 @@ public class GuiScript extends GuiScriptInterface { if (guibutton.id == 106) { NoppesUtil.openFolder(ScriptController.Instance.dir); } + if (guibutton.id == 114) { + NodexScriptBinding binding = createNodexBinding(); + JavaFxBridge.openScriptIde( + ScriptController.Instance != null ? ScriptController.Instance.dir : null, + binding + ); + } if (guibutton.id == 107) { ScriptContainer container = getCurrentContainer(); if (container == null) @@ -341,6 +351,68 @@ public class GuiScript extends GuiScriptInterface { } } + @Override + protected NodexScriptBinding createNodexBinding() { + if (script == null) return null; + + java.util.List tabs = new java.util.ArrayList(); + for (int i = 0; i < hookList.size(); i++) { + ScriptContainer container = script.getNPCScript(i); + String content = container != null ? container.script : ""; + tabs.add(new NodexScriptBinding.ScriptTabData(i, hookList.get(i), content, script.scriptLanguage)); + } + + final DataScript scriptRef = script; + NodexScriptBinding.SaveCallback saveCallback = new NodexScriptBinding.SaveCallback() { + @Override + public void onSave(int tabIndex, String newContent) { + net.minecraft.client.Minecraft.getMinecraft().func_152344_a(new Runnable() { + @Override + public void run() { + ScriptContainer container = scriptRef.getNPCScript(tabIndex); + if (container == null) { + container = new ScriptContainer(scriptRef); + scriptRef.setNPCScript(tabIndex, container); + } + container.script = newContent; + NPCScriptPacket.Save(scriptRef.writeToNBT(new NBTTagCompound())); + } + }); + } + }; + + NodexScriptBinding.CloseCallback closeCallback = new NodexScriptBinding.CloseCallback() { + @Override + public void onClose(java.util.List allTabs) { + net.minecraft.client.Minecraft.getMinecraft().func_152344_a(new Runnable() { + @Override + public void run() { + for (NodexScriptBinding.ScriptTabData tab : allTabs) { + ScriptContainer container = scriptRef.getNPCScript(tab.index); + if (container == null) { + container = new ScriptContainer(scriptRef); + scriptRef.setNPCScript(tab.index, container); + } + container.script = tab.content; + } + NPCScriptPacket.Save(scriptRef.writeToNBT(new NBTTagCompound())); + + // Refresh in-game GUI if still showing + net.minecraft.client.gui.GuiScreen current = net.minecraft.client.Minecraft.getMinecraft().currentScreen; + if (current instanceof GuiScript) { + GuiScript gui = (GuiScript) current; + gui.textAreas.clear(); + gui.initGui(); + } + } + }); + } + }; + + // NPC scripts have fixed hook-based tabs - no add/delete + return new NodexScriptBinding("NPC Scripts", "NPC", tabs, saveCallback, closeCallback, false); + } + @Override protected void setScript() { if (showScript) { diff --git a/src/main/java/noppes/npcs/client/gui/script/GuiScriptInterface.java b/src/main/java/noppes/npcs/client/gui/script/GuiScriptInterface.java index 688d9f65d..5243a9ba6 100644 --- a/src/main/java/noppes/npcs/client/gui/script/GuiScriptInterface.java +++ b/src/main/java/noppes/npcs/client/gui/script/GuiScriptInterface.java @@ -20,6 +20,8 @@ import noppes.npcs.client.gui.util.GuiNpcTextArea; import noppes.npcs.client.gui.util.GuiNpcTextField; import noppes.npcs.client.gui.util.GuiScriptTextArea; import noppes.npcs.client.gui.util.GuiUtil; +import noppes.npcs.client.fx.JavaFxBridge; +import noppes.npcs.client.fx.NodexScriptBinding; import noppes.npcs.client.gui.util.ICustomScrollListener; import noppes.npcs.client.gui.util.IGuiData; import noppes.npcs.client.gui.util.IJTextAreaListener; @@ -364,12 +366,13 @@ public class GuiScriptInterface extends GuiNPCInterface implements GuiYesNoCallb if (this.player.worldObj.isRemote) { this.addButton(new GuiNpcButton(106, var9, this.guiTop + 55, 150, 20, "script.openfolder")); + this.addButton(new GuiNpcButton(114, var9, this.guiTop + 78, 150, 20, "script.nodex")); } - this.addButton(new GuiNpcButton(109, var9, this.guiTop + 78, 80, 20, "gui.website")); - this.addButton(new GuiNpcButton(112, var9 + 81, this.guiTop + 78, 80, 20, "gui.forum")); - this.addButton(new GuiNpcButton(110, var9, this.guiTop + 99, 80, 20, "script.apidoc")); - this.addButton(new GuiNpcButton(111, var9 + 81, this.guiTop + 99, 80, 20, "script.apisrc")); + this.addButton(new GuiNpcButton(109, var9, this.guiTop + 101, 80, 20, "gui.website")); + this.addButton(new GuiNpcButton(112, var9 + 81, this.guiTop + 101, 80, 20, "gui.forum")); + this.addButton(new GuiNpcButton(110, var9, this.guiTop + 122, 80, 20, "script.apidoc")); + this.addButton(new GuiNpcButton(111, var9 + 81, this.guiTop + 122, 80, 20, "script.apisrc")); } public GuiScriptInterface setDimensions(int x, int y) { @@ -387,7 +390,89 @@ public class GuiScriptInterface extends GuiNPCInterface implements GuiYesNoCallb protected ScriptContext getScriptContext() { return handler.getContext(); } - + + protected NodexScriptBinding createNodexBinding() { + if (handler == null) return null; + + java.util.List scripts = handler.getScripts(); + if (scripts == null || scripts.isEmpty()) return null; + + // For multi-tab handlers, use "Tab 1", "Tab 2", etc. + java.util.List tabs = new java.util.ArrayList(); + for (int i = 0; i < scripts.size(); i++) { + IScriptUnit unit = scripts.get(i); + String name = "Tab " + (i + 1); + String lang = unit.getLanguage() != null ? unit.getLanguage() : handler.getLanguage(); + tabs.add(new NodexScriptBinding.ScriptTabData(i, name, unit.getScript(), lang)); + } + + String label = handler.getContext() != null ? handler.getContext().id + " Scripts" : "Scripts"; + String contextId = handler.getContext() != null ? handler.getContext().id : "GLOBAL"; + final IScriptHandler handlerRef = handler; + + NodexScriptBinding.SaveCallback saveCallback = new NodexScriptBinding.SaveCallback() { + @Override + public void onSave(int tabIndex, String newContent) { + net.minecraft.client.Minecraft.getMinecraft().func_152344_a(new Runnable() { + @Override + public void run() { + java.util.List currentScripts = handlerRef.getScripts(); + if (tabIndex >= 0 && tabIndex < currentScripts.size()) { + currentScripts.get(tabIndex).setScript(newContent); + } + if (handlerRef instanceof IScriptHandlerPacket) { + ((IScriptHandlerPacket) handlerRef).sync(); + } + } + }); + } + }; + + NodexScriptBinding.CloseCallback closeCallback = new NodexScriptBinding.CloseCallback() { + @Override + public void onClose(java.util.List allTabs) { + net.minecraft.client.Minecraft.getMinecraft().func_152344_a(new Runnable() { + @Override + public void run() { + java.util.List currentScripts = handlerRef.getScripts(); + + // Update existing tabs + for (NodexScriptBinding.ScriptTabData tab : allTabs) { + if (tab.index >= 0 && tab.index < currentScripts.size()) { + currentScripts.get(tab.index).setScript(tab.content); + } else { + // New tab added in Nodex + ScriptContainer newContainer = new ScriptContainer(handlerRef); + newContainer.script = tab.content; + handlerRef.addScriptUnit(newContainer); + } + } + + // Remove extra tabs if user deleted some (from the end) + while (handlerRef.getScripts().size() > allTabs.size()) { + handlerRef.removeScriptUnit(handlerRef.getScripts().size() - 1); + } + + if (handlerRef instanceof IScriptHandlerPacket) { + ((IScriptHandlerPacket) handlerRef).sync(); + } + + // Refresh in-game GUI if still showing + net.minecraft.client.gui.GuiScreen current = net.minecraft.client.Minecraft.getMinecraft().currentScreen; + if (current instanceof GuiScriptInterface) { + ((GuiScriptInterface) current).textAreas.clear(); + ((GuiScriptInterface) current).initGui(); + } + } + }); + } + }; + + // Multi-tab handlers (Player, Forge, etc.) allow add/delete + boolean canModify = !handler.isSingleContainer(); + return new NodexScriptBinding(label, contextId, tabs, saveCallback, closeCallback, canModify); + } + // ==================== MOUSE HANDLING ==================== @Override @@ -663,6 +748,14 @@ public class GuiScriptInterface extends GuiNPCInterface implements GuiYesNoCallb NoppesUtil.openFolder(ScriptController.Instance.dir); } + if (guibutton.id == 114) { + NodexScriptBinding binding = createNodexBinding(); + JavaFxBridge.openScriptIde( + ScriptController.Instance != null ? ScriptController.Instance.dir : null, + binding + ); + } + if (guibutton.id == 107) { IScriptUnit container = getCurrentContainer(); if (container == null) { diff --git a/src/main/resources/assets/customnpcs/lang/en_US.lang b/src/main/resources/assets/customnpcs/lang/en_US.lang index e7683fc77..3a0570683 100644 --- a/src/main/resources/assets/customnpcs/lang/en_US.lang +++ b/src/main/resources/assets/customnpcs/lang/en_US.lang @@ -64,6 +64,7 @@ gui.add=Add gui.view=View gui.remove=Remove gui.save=Save +gui.saveall=Save All gui.name=Name gui.displayName=Display Name gui.color=Color @@ -854,8 +855,11 @@ script.apisrc=API Source script.scripts=Scripts script.hooks=Hooks +script.units=Script Units +script.add=Add Script script.loadscript=Load Scripts script.openfolder=Open scripts folder +script.nodex=Open Nodex IDE script.init=Init script.interact=Interact script.update=Update